Skip to main content

areev_cal/
executor.rs

1//! CAL executor — runs parsed CAL queries against a `CalStoreFacade`.
2//!
3//! The executor takes a parsed [`CalQuery`] (or a raw CAL string, which it
4//! parses internally) and executes it against a `&dyn CalStoreFacade`,
5//! returning a [`CalExecResult`].
6//!
7//! # Security conditions
8//!
9//! - **S-2**: Tier 1 (evolve) statements — `Add`, `Supersede`, `Accumulate` —
10//!   execute by default (`CalExecutorConfig::tier1_enabled = true`). They can be
11//!   disabled by setting `tier1_enabled = false`. `Revert` always returns
12//!   `Unsupported` regardless of this flag (semantics not yet defined).
13//!   All Tier 1 writes go through the same `PolicyEngine::check_write()` and
14//!   audit trail as the HTTP/gRPC write path, so compliance invariants are preserved.
15//! - **S-5**: Audit MUST NOT log parameter values, only names.
16//!
17//! # Compliance conditions
18//!
19//! - **C-1**: The `CalExecResult::query_hash` field is always populated with
20//!   a SHA-256 of the normalized CAL string (for audit trail).
21//! - **C-4**: `query_hash` is the SHA-256 of the normalized (trimmed) query.
22
23use std::cmp::Ordering;
24use std::collections::{BTreeMap, HashMap};
25
26use sha2::{Digest, Sha256};
27
28use super::ast::{
29    AddWithOption, AssembleStmt, BatchEntry, BatchStmt, CalQuery, CalStatement, CoalesceStmt,
30    Comparator, Condition, DescribeStmt, DescribeTarget, ExistsStmt, ExplainStmt, Extractor,
31    FormatClause, GrainTypePlural, HistoryStmt, LetBinding, PipelineStage, RecallStmt, SetOp,
32    SetOpStmt, Source, Value, WithOption,
33};
34use super::errors::{CalError, Span};
35use super::facade::{AssemblyManifest, CalStoreFacade};
36use crate::store_types::{AddOptions, DiversityConfig, RecallParams};
37use areev_core::error::{AreevError, Hash};
38
39// ---------------------------------------------------------------------------
40// CalExecutorConfig
41// ---------------------------------------------------------------------------
42
43/// Configuration for the CAL executor.
44#[derive(Debug, Clone)]
45pub struct CalExecutorConfig {
46    /// Maximum LIMIT value allowed. Queries specifying higher limits are clamped.
47    /// Default: 1000.
48    pub max_limit: u64,
49    /// Default LIMIT applied when the query doesn't specify one.
50    /// Default: 50.
51    pub default_limit: u64,
52    /// Whether Tier 1 (evolve) statements are enabled.
53    ///
54    /// When `true` (default), ADD, SUPERSEDE, and ACCUMULATE execute through
55    /// the store facade. All writes go through `PolicyEngine::check_write()`
56    /// and emit audit events, preserving compliance invariants.
57    /// When `false`, they return `Unsupported`. REVERT always returns
58    /// `Unsupported` regardless of this flag (semantics undefined).
59    ///
60    /// Under the grants model (`docs/cal-all-you-need-proposal.md`) this is
61    /// a **process-wide restrictive cap**, not the authorization mechanism:
62    /// the session's grants decide who may write, and this flag set `false`
63    /// still wins over any grant — belt-and-suspenders for "serve untrusted
64    /// callers read-only".
65    pub tier1_enabled: bool,
66    /// Namespace override injected from the auth/capability token.
67    ///
68    /// When set, overrides any `namespace` condition in the CAL query's
69    /// WHERE clause, preventing a client from querying outside its scope.
70    pub namespace_override: Option<String>,
71    /// User ID override injected from the auth/capability token.
72    ///
73    /// When set, overrides any `user_id` condition in the CAL query's
74    /// WHERE clause.
75    pub user_id_override: Option<String>,
76    /// Whether destructive operations (FORGET, DROP, PURGE) are permitted.
77    ///
78    /// When `true` (**the default**), FORGET/DROP/PURGE execute through the
79    /// store facade. When `false`, they return `Unsupported`. This is
80    /// per-process host config (invariant #5) — never persisted in the file.
81    /// Set it to `false` to make a session read-only, e.g.
82    /// `areev serve --mcp --no-destructive-ops`.
83    ///
84    /// Under the grants model this is a **process-wide restrictive cap**:
85    /// the session's grants (`AreevFacade::authz`) decide who may destroy,
86    /// and this flag set `false` still wins over any grant.
87    pub allow_destructive_ops: bool,
88    /// When `true`, ASSEMBLE results strip internal budget metadata
89    /// (tokens_allocated, tokens_used) from the `sources` array.
90    /// Default: `false`.  (S-09)
91    pub redact_budget_metadata: bool,
92    /// Fraction of ASSEMBLE operations whose provenance manifest is stored in
93    /// `agent:harness`. Host-only and never persisted; 0.0 (default) is off.
94    pub assembly_manifest_sample_rate: f64,
95    /// Caller's JWT scopes for identity-based access control.
96    /// When non-empty, the executor checks the required scope for each statement
97    /// type before execution. Empty = no enforcement (CLI, tests).
98    pub caller_scopes: Vec<String>,
99}
100
101impl Default for CalExecutorConfig {
102    fn default() -> Self {
103        Self {
104            max_limit: 1000,
105            default_limit: 50,
106            tier1_enabled: true,
107            allow_destructive_ops: true,
108            namespace_override: None,
109            user_id_override: None,
110            redact_budget_metadata: false,
111            assembly_manifest_sample_rate: 0.0,
112            caller_scopes: vec![],
113        }
114    }
115}
116
117// ---------------------------------------------------------------------------
118// CalExecResult and associated types
119// ---------------------------------------------------------------------------
120
121/// Result of executing a CAL query.
122///
123/// Named `CalExecResult` (not `CalResult`) to avoid shadowing the
124/// `CalResult<T>` type alias in `errors.rs`.
125#[derive(Debug, serde::Serialize)]
126pub struct CalExecResult {
127    /// The query string that was executed (as supplied, not normalized).
128    pub query: String,
129    /// SHA-256 hash of the trimmed query string (C-4 audit requirement).
130    pub query_hash: String,
131    /// The result payload.
132    pub result: CalResultPayload,
133    /// Non-fatal warnings emitted during parsing or execution.
134    pub warnings: Vec<String>,
135    /// Anonymization egress report (proposal §4.1): present when the store
136    /// has an egress policy active, carrying mode/floor and mapping *ids*
137    /// only — the mapping itself never rides a payload (D5 custody).
138    pub anonymized: Option<serde_json::Value>,
139    /// Execution metadata.
140    pub metadata: CalMetadata,
141}
142
143impl CalExecResult {
144    /// The wire payload every embedding surface returns: `result` with the
145    /// non-fatal `warnings` folded in under a `warnings` key.
146    ///
147    /// Warnings used to be a field only the caller could reach, and the three
148    /// surfaces that return JSON to a user — Python, Node, and the MCP
149    /// `areev_cal` tool — all serialized `result` alone. So every `CAL-Wnnn`
150    /// the executor raised was dropped on the way out, and an option that
151    /// parsed but could not change the result (`score_breakdown` on `RECALL`,
152    /// `CAL-W014`) read as a silent no-op — the exact failure the warning
153    /// exists to make visible. `warnings` is omitted when empty, so a clean
154    /// query keeps the payload it always had.
155    /// Errors exactly where serializing `result` alone would have, so folding
156    /// the warnings in never converts a failure into a plausible-looking
157    /// payload.
158    pub fn payload_json(&self) -> serde_json::Result<serde_json::Value> {
159        let mut v = serde_json::to_value(&self.result)?;
160        if let Some(a) = &self.anonymized {
161            if let Some(obj) = v.as_object_mut() {
162                obj.insert("anonymized".into(), a.clone());
163            }
164        }
165        if !self.warnings.is_empty() {
166            // `CalResultPayload` is internally tagged, so it always serializes
167            // to an object — but degrade to the bare payload rather than panic
168            // if that ever stops being true.
169            if let Some(obj) = v.as_object_mut() {
170                obj.insert("warnings".into(), serde_json::json!(self.warnings));
171            }
172        }
173        Ok(v)
174    }
175}
176
177/// Metadata about a CAL query execution.
178#[derive(Debug, serde::Serialize)]
179pub struct CalMetadata {
180    /// CAL version declared in the query (e.g. `1` for `CAL/1`).
181    pub version: u32,
182    /// Statement type name (e.g. `"recall"`, `"exists"`).
183    pub statement_type: String,
184    /// Wall-clock execution time in milliseconds.
185    pub execution_time_ms: u64,
186    /// Number of top-level results in the payload.
187    pub result_count: usize,
188}
189
190/// The result payload, discriminated by statement type.
191#[derive(Debug, serde::Serialize)]
192#[serde(tag = "type", rename_all = "snake_case")]
193pub enum CalResultPayload {
194    /// Result of `RECALL`, `SET`, or `ASSEMBLE` operations.
195    Grains {
196        grains: Vec<CalGrainResult>,
197        /// Total results available before pipeline LIMIT/OFFSET (best-effort).
198        total_available: Option<usize>,
199    },
200    /// Result of `EXISTS`.
201    Exists { exists: bool, hash: String },
202    /// Result of a `| COUNT` pipeline stage.
203    Count { count: usize },
204    /// Result of `GRANT` (CAL 1.3 §8.15).
205    Granted { principal: String, object: String, hash: String },
206    /// Result of `REVOKE`.
207    Revoked { principal: String, grants_touched: usize },
208    /// Result of `SHOW GRANTS` — one row per live grant grain.
209    GrantList { grants: Vec<serde_json::Value> },
210    /// Result of `RUN LOOP` (CAL 1.3 §8.16) — the engine's run report.
211    LoopRan { run: serde_json::Value },
212    /// Result of `APPROVE`/`REJECT`.
213    Reviewed { hash: String, decision: String },
214    /// Result of `APPLY`.
215    RecApplied { hash: String, rollbackable: bool },
216    /// Result of `ROLLBACK`.
217    RecRolledBack { hash: String },
218    /// Result of `REMEMBER` — the captured Event's hash.
219    Remembered { hash: String },
220    /// Result of `ENTITY … AT` — the as-of grain, or null.
221    EntityAt { grain: Option<serde_json::Value>, axis: String, at_ms: i64 },
222    /// Result of `RUN TRACE` — what a run recorded and produced.
223    RunTrace { run_id: String, trace: serde_json::Value },
224    /// Result of `RUNS TOUCHING` — run ids, most recent first.
225    RunsTouching { hash: String, runs: Vec<String> },
226    /// Result of `DERIVED FROM` — reverse provenance rows.
227    DerivedFrom { hash: String, grains: Vec<serde_json::Value> },
228    /// Result of `SHOW FORKS` — the open forks.
229    Forks { forks: Vec<serde_json::Value> },
230    /// Result of `MERGE` — the resolved head.
231    Merged { hash: String, subject: String, relation: String, object: String },
232    /// Result of `RELATED` — the reachable entities.
233    RelatedEntities { start: String, entities: Vec<String> },
234    /// Result of `NOVELTY` — nearest matches, most similar first.
235    NoveltyMatches { matches: Vec<serde_json::Value> },
236    /// Result of `HISTORY OF`.
237    History { versions: Vec<CalVersionResult> },
238    /// Result of `DESCRIBE`.
239    Describe { info: serde_json::Value },
240    /// Result of `EXPLAIN`.
241    Explain { plan: CalQueryPlan },
242    /// Result of `BATCH`.
243    Batch {
244        results: HashMap<String, CalResultPayload>,
245    },
246    /// Result of a multi-source `ASSEMBLE` (Phase 2).
247    Assembled {
248        /// Assembled grains after budget and dedup processing.
249        grains: Vec<CalGrainResult>,
250        /// Per-source metadata.
251        sources: Vec<super::assemble::SourceMeta>,
252        /// Total tokens used across all sources.
253        total_tokens: u32,
254        /// Budget limit (if specified).
255        budget_limit: Option<u32>,
256        /// Always false (progressive_disclosure has been removed).
257        progressive: bool,
258        /// Total grains available before trimming.
259        total_available: Option<usize>,
260    },
261    /// Result of `HISTORY <hash> DIFF <hash>` — field-level differences.
262    Diff {
263        /// Content-address hash of the source grain.
264        source_hash: String,
265        /// Content-address hash of the target grain.
266        target_hash: String,
267        /// Field-level differences between source and target.
268        changes: Vec<super::ast::FieldDiff>,
269    },
270    /// Result of single-format rendering (WI-1.1). Contains the rendered text
271    /// and format name. Used when ASSEMBLE or RECALL has a single FORMAT clause.
272    Formatted {
273        /// Rendered text output.
274        text: String,
275        /// Format name (e.g. "json", "markdown", "sml").
276        format: String,
277        /// Number of grains that were formatted.
278        grain_count: usize,
279        /// Raw grains for A2UI surface building (not serialized on the wire).
280        #[serde(skip_serializing)]
281        grains: Vec<CalGrainResult>,
282    },
283    /// Result of multi-format rendering (CAL spec v1.0.1, Section 14.2.1).
284    /// Contains multiple renderings keyed by format name.
285    MultiFormatted {
286        /// Renderings keyed by format name (e.g. "markdown" -> "...", "json" -> "...").
287        formats: HashMap<String, String>,
288        /// Number of grains that were formatted.
289        grain_count: usize,
290        /// Raw grains for A2UI surface building (not serialized on the wire).
291        #[serde(skip_serializing)]
292        grains: Vec<CalGrainResult>,
293    },
294    /// Result of `ADD` (Tier 1).
295    Added {
296        hash: String,
297        grain_type: String,
298        /// Number of facts extracted (when `WITH extract_memories` was used).
299        #[serde(skip_serializing_if = "Option::is_none")]
300        extracted_count: Option<usize>,
301        /// Warnings from the extraction pipeline.
302        #[serde(skip_serializing_if = "Vec::is_empty")]
303        extraction_warnings: Vec<String>,
304    },
305    /// Result of `SUPERSEDE` (Tier 1).
306    Superseded { old_hash: String, new_hash: String },
307    /// Result of `ACCUMULATE` (Tier 1).
308    Accumulated {
309        old_hash: String,
310        new_hash: String,
311        deltas: Vec<AccumulatedDelta>,
312    },
313    /// Result of `DEFINE TEMPLATE` (FR-003).
314    TemplateDefined { name: String },
315    /// Result of `DROP TEMPLATE` (FR-003).
316    TemplateDropped { name: String },
317    /// Result of `DEFINE QUERY`.
318    QueryDefined { name: String },
319    /// Result of `DROP QUERY`.
320    QueryDropped { name: String },
321    /// Returned for `STREAM ASSEMBLE` — signals the HTTP handler to use SSE (FR-004).
322    StreamAssemble {
323        /// The parsed assemble statement (to be executed by the streaming handler).
324        assemble: Box<super::ast::AssembleStmt>,
325        /// Query-level WITH options to apply post-merge (rerank, dedup, etc.).
326        with_options: Vec<super::ast::WithOption>,
327    },
328    /// Result of `FORGET <hash>`, `FORGET USER`, or `FORGET SCOPE` (Tier 2).
329    Forgotten { target: String, count: u64 },
330    /// Result of `PURGE STALE` (Tier 2).
331    Purged { count: usize },
332    /// Result of `REPORT SUBJECT` — the read-only DSAR selection: the
333    /// matched identity strings and grains (`{hash, type, fields}`).
334    SubjectReport {
335        subject: String,
336        identity_names: Vec<String>,
337        grains: Vec<serde_json::Value>,
338    },
339    /// Returned for Tier 1/2 statements (S-2) or genuinely unsupported paths.
340    Unsupported { statement: String, message: String },
341}
342
343/// A single grain in a CAL result set (projected view).
344#[derive(Debug, Clone, serde::Serialize)]
345pub struct CalGrainResult {
346    /// Content-address hash of the grain (hex string).
347    pub hash: String,
348    /// Canonical grain type name (e.g. `"fact"`, `"event"`).
349    pub grain_type: String,
350    /// Final relevance score (RRF-fused).
351    pub score: f64,
352    /// All grain fields as a JSON object.
353    pub fields: serde_json::Value,
354    /// Per-component score breakdown. Present when `WITH score_breakdown`.
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub score_breakdown: Option<serde_json::Value>,
357    /// Human-readable ranking explanation. Present when `WITH explanation`.
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub explanation: Option<String>,
360    /// Coarse age label ("3 hours ago"). Present when
361    /// `WITH annotate_relative_time`.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub relative_time: Option<String>,
364    /// True when this grain came from a RECALL source with no ABOUT clause —
365    /// i.e. no semantic comparison was performed and `score` is a structural
366    /// sentinel, not a relevance signal. Such grains must bypass score-based
367    /// filters (`WITH min_score`) at the post-merge ASSEMBLE level; their
368    /// inclusion is governed by the source's PRIORITY/BUDGET allocation.
369    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
370    pub is_deterministic: bool,
371    /// The other live heads that contest this grain's `(subject, relation)`.
372    ///
373    /// Present only when `CONTRADICTIONS` or `WITH contradiction_detection`
374    /// asked for fork status, and only on a grain that is itself a live tip of
375    /// an open fork. Non-empty means: another writer holds a different current
376    /// value for this key and neither has been merged. An agent should treat
377    /// the value as disputed rather than settled.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub contested_by: Option<Vec<String>>,
380}
381
382/// A single delta that was applied during ACCUMULATE.
383#[derive(Debug, Clone, serde::Serialize)]
384pub struct AccumulatedDelta {
385    pub field: String,
386    pub old_value: f64,
387    pub new_value: f64,
388}
389
390/// One entry in a `HISTORY OF` result.
391#[derive(Debug, serde::Serialize)]
392pub struct CalVersionResult {
393    /// Content-address hash of this version.
394    pub hash: String,
395    /// The object/value at this version.
396    pub object: String,
397    /// Creation timestamp (epoch milliseconds).
398    pub created_at: i64,
399    /// Confidence score.
400    pub confidence: f64,
401    /// Hash of the grain that superseded this version, if any.
402    pub superseded_by: Option<String>,
403}
404
405/// Query execution plan returned by `EXPLAIN`.
406#[derive(Debug, serde::Serialize)]
407pub struct CalQueryPlan {
408    /// Statement type that will be executed.
409    pub statement_type: String,
410    /// Grain type filter (if known at plan time).
411    pub grain_type: Option<String>,
412    /// Which query routing path will be used (e.g. `"bm25"`, `"hybrid"`, `"structural"`).
413    pub query_routing: String,
414    /// Index layers that will be consulted.
415    pub index_usage: Vec<String>,
416    /// Estimated relative cost.
417    pub estimated_cost: String,
418    /// Filters that will be applied.
419    pub filters: Vec<String>,
420    /// Logical pipeline stages (statement + pipeline stages).
421    pub pipeline: Vec<String>,
422}
423
424// ---------------------------------------------------------------------------
425// LET binding scope (Phase 2)
426// ---------------------------------------------------------------------------
427
428/// Maximum number of LET bindings per query (S-06).
429const MAX_LET_BINDINGS: usize = 5;
430
431/// Maximum grains per LET binding evaluation (C2-04).
432const MAX_GRAINS_PER_LET: usize = 1000;
433
434/// Resolved value of a LET binding.
435///
436/// Two variants mirror the two pipeline output shapes:
437/// - `Grains`: the raw grain result set.
438/// - `Extracted`: string values from `| SUBJECTS`, `| OBJECTS`, or `| HASHES`.
439#[derive(Debug, Clone)]
440pub enum LetValue {
441    /// A grain result set (from RECALL without an extractor pipeline).
442    Grains(Vec<CalGrainResult>),
443    /// Extracted string values (from `| SUBJECTS`, `| OBJECTS`, or `| HASHES`).
444    Extracted(Vec<String>),
445}
446
447/// LET binding scope for an execution context.
448///
449/// # Security (S-03)
450///
451/// `Drop` implementation clears and shrinks the bindings map to prevent
452/// sensitive data from lingering in freed memory.
453#[derive(Debug)]
454pub struct LetScope {
455    bindings: HashMap<String, LetValue>,
456}
457
458impl LetScope {
459    /// Flatten the bindings into the string lists a `WHERE ... IN $var` needs.
460    ///
461    /// A `SUBJECTS`/`OBJECTS`/`HASHES` extractor already produces strings. A
462    /// binding with no extractor holds whole grains, so its members are their
463    /// content addresses — the only identity every grain type shares, and the
464    /// one `WHERE hash IN $var` can match on.
465    fn to_values(&self) -> HashMap<String, Vec<String>> {
466        self.bindings
467            .iter()
468            .map(|(name, value)| {
469                let values = match value {
470                    LetValue::Extracted(v) => v.clone(),
471                    LetValue::Grains(g) => g.iter().map(|r| r.hash.clone()).collect(),
472                };
473                (name.clone(), values)
474            })
475            .collect()
476    }
477}
478
479impl LetScope {
480    /// Evaluate all LET bindings in declaration order.
481    ///
482    /// # Limits
483    ///
484    /// - **S-06**: Maximum 5 LET bindings per query.
485    /// - **C2-04**: Each binding evaluation is capped at 1000 grains.
486    pub fn evaluate(
487        let_bindings: &[LetBinding],
488        executor: &CalExecutor,
489        store: &dyn CalStoreFacade,
490        query: &CalQuery,
491        warnings: &mut Vec<String>,
492    ) -> std::result::Result<Self, CalError> {
493        if let_bindings.len() > MAX_LET_BINDINGS {
494            return Err(CalError::TooManyLetBindings {
495                count: let_bindings.len(),
496                max: MAX_LET_BINDINGS,
497                span: let_bindings.first().and_then(|b| b.span),
498            });
499        }
500
501        let mut scope = LetScope {
502            bindings: HashMap::new(),
503        };
504
505        for binding in let_bindings {
506            // Check for duplicate names.
507            if scope.bindings.contains_key(&binding.name) {
508                return Err(CalError::DuplicateParameter {
509                    name: binding.name.clone(),
510                    span: binding.span,
511                });
512            }
513
514            // Execute the source sub-query.
515            let surrogate = CalQuery {
516                version: query.version,
517                statement: (*binding.source).clone(),
518                pipeline: Vec::new(),
519                with_options: Vec::new(),
520                format: None,
521                let_bindings: Vec::new(),
522                let_values: scope.to_values(),
523                user_vars: HashMap::new(),
524                warnings: Vec::new(),
525            };
526
527            let payload =
528                executor.execute_statement(&surrogate.statement, store, &surrogate, warnings)?;
529
530            // Extract grains from the payload.
531            let grains = extract_grains(payload);
532
533            // C2-04: Cap grains per binding.
534            let grains = if grains.len() > MAX_GRAINS_PER_LET {
535                warnings.push(format!(
536                    "LET ${} produced {} grains (capped to {})",
537                    binding.name,
538                    grains.len(),
539                    MAX_GRAINS_PER_LET
540                ));
541                grains.into_iter().take(MAX_GRAINS_PER_LET).collect()
542            } else {
543                grains
544            };
545
546            // Apply the extractor to determine the LetValue type.
547            let value = match binding.extractor {
548                Extractor::Subjects => {
549                    let values: Vec<String> = grains
550                        .iter()
551                        .filter_map(|g| {
552                            json_field(&g.fields, "subject")
553                                .and_then(|v| v.as_str())
554                                .map(|s| s.to_string())
555                        })
556                        .collect();
557                    LetValue::Extracted(values)
558                }
559                Extractor::Objects => {
560                    let values: Vec<String> = grains
561                        .iter()
562                        .filter_map(|g| {
563                            json_field(&g.fields, "object")
564                                .and_then(|v| v.as_str())
565                                .map(|s| s.to_string())
566                        })
567                        .collect();
568                    LetValue::Extracted(values)
569                }
570                Extractor::Hashes => {
571                    let values: Vec<String> = grains.iter().map(|g| g.hash.clone()).collect();
572                    LetValue::Extracted(values)
573                }
574            };
575
576            scope.bindings.insert(binding.name.clone(), value);
577        }
578
579        Ok(scope)
580    }
581
582    /// Resolve a `$name` reference.
583    ///
584    /// Returns `CalError::UnboundParameter` if the name is not in scope.
585    pub fn resolve(&self, name: &str) -> std::result::Result<&LetValue, CalError> {
586        self.bindings
587            .get(name)
588            .ok_or_else(|| CalError::UnboundParameter {
589                name: name.to_string(),
590                span: None,
591            })
592    }
593}
594
595/// S-03: Clear and shrink bindings on drop to prevent data lingering.
596impl Drop for LetScope {
597    fn drop(&mut self) {
598        self.bindings.clear();
599        self.bindings.shrink_to_fit();
600    }
601}
602
603// ---------------------------------------------------------------------------
604// CalExecutor
605// ---------------------------------------------------------------------------
606
607/// Maximum parsed statements held in the plan cache.
608///
609/// A production host runs a small, fixed set of statements (its saved queries
610/// and a handful of literals) many times, so a modest cap gets essentially
611/// every hit; the bound exists to stop a host that interpolates values into
612/// statement text from growing the map without limit.
613const PLAN_CACHE_MAX: usize = 256;
614
615/// CAL query executor.
616///
617/// Stateless aside from configuration and a parse cache. Create once and call
618/// `execute()` for each query. Thread-safe.
619pub struct CalExecutor {
620    config: CalExecutorConfig,
621    /// Parsed statements, keyed by exact statement text.
622    ///
623    /// A real-time turn does not call the Rust store — it calls a binding with
624    /// a statement STRING, and used to pay lexing, parsing and validation on
625    /// every single turn. Parsing is pure and deterministic over the text, so
626    /// the result is cacheable with no semantic change whatsoever: the same
627    /// text yields the same AST.
628    ///
629    /// Deliberately internal rather than a `prepare()` the caller must manage:
630    /// every surface — CLI, MCP, the server, both bindings, and `RUN "name"()`
631    /// — gets the saving without an API change and without a handle-lifetime
632    /// bug class. The bindings additionally expose an explicit handle for
633    /// hosts that want the guarantee rather than the heuristic.
634    ///
635    /// Cleared wholesale at the cap rather than evicted LRU: at this size the
636    /// bookkeeping costs more than the occasional re-parse, and the working
637    /// set is refilled by the next few turns.
638    plan_cache: std::sync::Mutex<HashMap<String, crate::ast::CalQuery>>,
639    /// The governance seam (CAL 1.3 §8.16): loop lifecycle statements
640    /// execute only when a host attached one — `areev-loop-adapter` provides the
641    /// real implementation. Absent → those statements return
642    /// `Unsupported`.
643    governance: Option<std::sync::Arc<dyn crate::governance::GovernanceHost>>,
644}
645
646impl CalExecutor {
647    /// Create a new executor with the given configuration.
648    pub fn new(config: CalExecutorConfig) -> Self {
649        Self { config, governance: None, plan_cache: Default::default() }
650    }
651
652    /// Parse `input`, reusing a cached AST for identical text.
653    ///
654    /// Returns a clone: `execute` binds LET values into the query, so the
655    /// cached entry must stay pristine. Cloning an AST is still far cheaper
656    /// than lexing + parsing + validating one.
657    ///
658    /// A poisoned lock degrades to a plain parse rather than failing the
659    /// query — the cache is an optimization, and a caller's statement should
660    /// not fail because some other thread panicked.
661    pub fn parse_cached(
662        &self,
663        input: &str,
664    ) -> std::result::Result<crate::ast::CalQuery, CalError> {
665        if let Ok(cache) = self.plan_cache.lock() {
666            if let Some(q) = cache.get(input) {
667                return Ok(q.clone());
668            }
669        }
670        let query = crate::parser::parse(input)?;
671        if let Ok(mut cache) = self.plan_cache.lock() {
672            if cache.len() >= PLAN_CACHE_MAX {
673                cache.clear();
674            }
675            cache.insert(input.to_string(), query.clone());
676        }
677        Ok(query)
678    }
679
680    /// How many statements the plan cache currently holds. Diagnostics and
681    /// tests; not a stability guarantee.
682    pub fn plan_cache_len(&self) -> usize {
683        self.plan_cache.lock().map(|c| c.len()).unwrap_or(0)
684    }
685
686    /// Attach the governance host that backs `RUN LOOP`, `APPROVE`,
687    /// `REJECT`, `APPLY`, `ROLLBACK`, and the loop `DESCRIBE` reads.
688    pub fn with_governance(
689        mut self,
690        host: std::sync::Arc<dyn crate::governance::GovernanceHost>,
691    ) -> Self {
692        self.governance = Some(host);
693        self
694    }
695
696    /// The effective configuration (read-only; for observability surfaces).
697    pub fn config(&self) -> &CalExecutorConfig {
698        &self.config
699    }
700
701    /// Create an executor with all defaults.
702    pub fn with_defaults() -> Self {
703        Self::new(CalExecutorConfig::default())
704    }
705
706    /// Check that the caller's scopes allow executing this statement.
707    /// Returns Ok if: scopes are empty (no enforcement), OR scopes contain
708    /// the required scope or "admin" (admin is superset of all).
709    fn check_caller_scope(&self, stmt: &CalStatement) -> std::result::Result<(), CalError> {
710        let scopes = &self.config.caller_scopes;
711        if scopes.is_empty() {
712            return Ok(()); // No enforcement (CLI, tests)
713        }
714        let required = required_scope_for_statement(stmt);
715        // Admin scope passes any check (superset)
716        if scopes.iter().any(|s| s == "admin") {
717            return Ok(());
718        }
719        if scopes.iter().any(|s| s == required) {
720            return Ok(());
721        }
722        // Write scope implies read
723        if required == "read" && scopes.iter().any(|s| s == "write") {
724            return Ok(());
725        }
726        Err(CalError::InsufficientScope {
727            required: required.to_string(),
728            statement: statement_type_name(stmt),
729        })
730    }
731
732    // -----------------------------------------------------------------------
733    // Public API
734    // -----------------------------------------------------------------------
735
736    /// Parse and execute a CAL query string against `store`.
737    ///
738    /// This is the primary entry point. Callers that have already parsed the
739    /// query can call `execute_query()` instead.
740    ///
741    /// # Errors
742    ///
743    /// Returns a `CalError` for parse failures or execution errors that map to
744    /// a specific CAL error code.  Store errors that do not map to a CAL code
745    /// are wrapped in `CalError::BudgetExceeded`.
746    pub fn execute(
747        &self,
748        input: &str,
749        store: &dyn CalStoreFacade,
750    ) -> std::result::Result<CalExecResult, CalError> {
751        let start = std::time::Instant::now();
752
753        // 1. Parse (validates length, bidi, nesting limits, etc.), reusing an
754        // earlier parse of the identical text when there is one.
755        let query = self.parse_cached(input)?;
756
757        // 2. Compute query hash (C-4: SHA-256 of normalized / trimmed input).
758        let query_hash = compute_query_hash(input);
759
760        // 2b. Evaluate LET bindings, then bind the results into the query.
761        //
762        // Bindings are evaluated sequentially in declaration order. The scope
763        // used to be evaluated and dropped — the comment here said WHERE
764        // resolution was "Phase 3" — so `$friends` never reached the WHERE
765        // clause at all, and the documented two-step pattern quietly matched
766        // nothing it was supposed to scope by.
767        let mut query = query;
768        let mut exec_warnings: Vec<String> = Vec::new();
769        if !query.let_bindings.is_empty() {
770            let scope = LetScope::evaluate(
771                &query.let_bindings,
772                self,
773                store,
774                &query,
775                &mut exec_warnings,
776            )?;
777            query.let_values = scope.to_values();
778        }
779
780        // Validate pipeline-stage field references against the closed field
781        // set before execution.
782        if let CalStatement::Recall(ref r) = query.statement {
783            Self::validate_pipeline_fields(&query.pipeline, &r.grain_type)?;
784        }
785
786        // 3. Execute the statement (collects execution-time warnings).
787        let payload =
788            self.execute_statement(&query.statement, store, &query, &mut exec_warnings)?;
789
790        // 4. Apply pipeline stages.
791        let (payload, grouped_by) = self.apply_pipeline(payload, &query.pipeline, &mut exec_warnings)?;
792
793        // 5. Apply FORMAT clause if present (CAL spec v1.0.1).
794        let payload = apply_format_clause(
795            payload,
796            &query.format,
797            grouped_by.as_deref(),
798            RenderInputs {
799                user_vars: &query.user_vars,
800                store,
801                disclosure: disclosure_of(&query.with_options),
802            },
803            None,
804            &mut exec_warnings,
805        )?;
806
807        // 6. Collect warnings from the AST + execution.
808        let mut warnings: Vec<String> = query.warnings.iter().map(|w| w.to_string()).collect();
809        warnings.extend(exec_warnings);
810
811        // 7. Assemble result.
812        let elapsed_ms = start.elapsed().as_millis() as u64;
813        let stmt_type = statement_type_name(&query.statement);
814        let result_count = count_payload_results(&payload);
815
816        Ok(CalExecResult {
817            query: input.to_string(),
818            query_hash,
819            result: payload,
820            warnings,
821            anonymized: store.anon_egress_report(),
822            metadata: CalMetadata {
823                version: query.version.0,
824                statement_type: stmt_type,
825                execution_time_ms: elapsed_ms,
826                result_count,
827            },
828        })
829    }
830
831    /// Execute an already-parsed `CalQuery` AST against the store.
832    ///
833    /// Used by the `application/json+cal` wire format path where the AST
834    /// arrives as JSON rather than text.  The `original_text` parameter is
835    /// used for the query hash and the `query` field in the result; pass
836    /// the JSON body or a synthetic representation.
837    pub fn execute_parsed(
838        &self,
839        query: crate::ast::CalQuery,
840        original_text: &str,
841        store: &dyn CalStoreFacade,
842    ) -> std::result::Result<CalExecResult, CalError> {
843        let start = std::time::Instant::now();
844
845        // Compute query hash (C-4).
846        let query_hash = compute_query_hash(original_text);
847
848        // Evaluate LET bindings and bind them into the query (mirror of
849        // `execute()` — the two entry points have to stay in step).
850        let mut query = query;
851        let mut exec_warnings: Vec<String> = Vec::new();
852        if !query.let_bindings.is_empty() {
853            let scope = LetScope::evaluate(
854                &query.let_bindings,
855                self,
856                store,
857                &query,
858                &mut exec_warnings,
859            )?;
860            query.let_values = scope.to_values();
861        }
862
863        // Validate pipeline-stage field references (mirror of execute()).
864        if let CalStatement::Recall(ref r) = query.statement {
865            Self::validate_pipeline_fields(&query.pipeline, &r.grain_type)?;
866        }
867
868        // Execute the statement.
869        let payload =
870            self.execute_statement(&query.statement, store, &query, &mut exec_warnings)?;
871
872        // Apply pipeline stages.
873        let (payload, grouped_by) = self.apply_pipeline(payload, &query.pipeline, &mut exec_warnings)?;
874
875        // Apply FORMAT clause if present (CAL spec v1.0.1).
876        let payload = apply_format_clause(
877            payload,
878            &query.format,
879            grouped_by.as_deref(),
880            RenderInputs {
881                user_vars: &query.user_vars,
882                store,
883                disclosure: disclosure_of(&query.with_options),
884            },
885            None,
886            &mut exec_warnings,
887        )?;
888
889        // Collect warnings from the AST + execution.
890        let mut warnings: Vec<String> = query.warnings.iter().map(|w| w.to_string()).collect();
891        warnings.extend(exec_warnings);
892
893        // Assemble result.
894        let elapsed_ms = start.elapsed().as_millis() as u64;
895        let stmt_type = statement_type_name(&query.statement);
896        let result_count = count_payload_results(&payload);
897
898        Ok(CalExecResult {
899            query: original_text.to_string(),
900            query_hash,
901            result: payload,
902            warnings,
903            anonymized: store.anon_egress_report(),
904            metadata: CalMetadata {
905                version: query.version.0,
906                statement_type: stmt_type,
907                execution_time_ms: elapsed_ms,
908                result_count,
909            },
910        })
911    }
912
913    // -----------------------------------------------------------------------
914    // Statement dispatch
915    // -----------------------------------------------------------------------
916
917    /// Internal statement execution — exposed to sibling modules (e.g.
918    /// `assemble.rs`) via `pub(super)`.
919    /// Namespace for Tier-1 write statements, mirroring the RECALL
920    /// precedence: a capability-scoped `namespace_override` always wins,
921    /// then an explicit `SET namespace = ...`, then the session default.
922    /// Without this, an ADD in a `with_session` facade lands in the store
923    /// default namespace while RECALL reads the session one — the write
924    /// succeeds but the same session can't see it.
925    fn inject_write_namespace(
926        &self,
927        fields: &mut serde_json::Map<String, serde_json::Value>,
928        store: &dyn CalStoreFacade,
929    ) {
930        if let Some(ref ns) = self.config.namespace_override {
931            fields.insert("namespace".into(), serde_json::Value::String(ns.clone()));
932        } else if !fields.contains_key("namespace") {
933            if let Some(ns) = store.default_namespace() {
934                fields.insert(
935                    "namespace".into(),
936                    serde_json::Value::String(ns.to_string()),
937                );
938            }
939        }
940    }
941
942    pub(super) fn execute_statement_internal(
943        &self,
944        stmt: &CalStatement,
945        store: &dyn CalStoreFacade,
946        query: &CalQuery,
947        exec_warnings: &mut Vec<String>,
948    ) -> std::result::Result<CalResultPayload, CalError> {
949        self.execute_statement(stmt, store, query, exec_warnings)
950    }
951
952    pub fn execute_statement(
953        &self,
954        stmt: &CalStatement,
955        store: &dyn CalStoreFacade,
956        query: &CalQuery,
957        exec_warnings: &mut Vec<String>,
958    ) -> std::result::Result<CalResultPayload, CalError> {
959        // Identity-based scope check — must pass before any execution.
960        self.check_caller_scope(stmt)?;
961
962        match stmt {
963            CalStatement::Recall(recall) => {
964                self.execute_recall(recall, store, query, exec_warnings)
965            }
966            CalStatement::Exists(exists) => {
967                self.execute_exists(exists, store, exec_warnings, &query.let_values)
968            }
969            CalStatement::History(history) => {
970                self.execute_history(history, store, exec_warnings, &query.let_values)
971            }
972            CalStatement::Describe(describe) => self.execute_describe(describe, store),
973            CalStatement::Explain(explain) => self.execute_explain(explain, store, query),
974            CalStatement::Batch(batch) => self.execute_batch(batch, store, exec_warnings),
975            CalStatement::Coalesce(coalesce) => {
976                self.execute_coalesce(coalesce, store, query, exec_warnings)
977            }
978            CalStatement::SetOp(set_op) => self.execute_set_op(set_op, store, query, exec_warnings),
979            CalStatement::Assemble(assemble) => {
980                self.execute_assemble(assemble, store, query, exec_warnings)
981            }
982
983            // Tier 1 — ADD and SUPERSEDE execute when tier1_enabled, REVERT always unsupported.
984            CalStatement::Add(add) => {
985                if !self.config.tier1_enabled {
986                    return Err(CalError::Tier1NotEnabled {
987                        statement: "ADD".into(),
988                        span: add.span,
989                    });
990                }
991                // Reject grain types that cannot be built from flat `SET k = v`
992                // pairs. Sourced from the grain-type registry (D1) so there is
993                // no separate list to keep in sync. This is a shape check, not a
994                // permission check — the structured write paths (ADD WORKFLOW,
995                // the per-type JSON builders behind `cal_add`, `capture()`) are
996                // deliberately not gated by it.
997                let add_type = add.grain_type.as_str();
998                if !areev_core::types::registry::add_via_set_names().any(|n| n == add_type) {
999                    let shapeable: Vec<&str> = areev_core::types::registry::add_via_set_names().collect();
1000                    return Ok(CalResultPayload::Unsupported {
1001                        statement: "add".into(),
1002                        message: format!(
1003                            "Grain type '{}' cannot be created via ADD. Addable types: {}.",
1004                            add_type,
1005                            shapeable.join(", ")
1006                        ),
1007                    });
1008                }
1009                // Reject unresolved parameters in SET values.
1010                for fa in &add.fields {
1011                    if let super::ast::Value::Parameter { name } = &fa.value {
1012                        return Ok(CalResultPayload::Unsupported {
1013                            statement: "add".into(),
1014                            message: format!(
1015                                "Unresolved parameter ${} in SET clause. Parameters must be bound via LET.",
1016                                name
1017                            ),
1018                        });
1019                    }
1020                }
1021                let mut fields = serde_json::Map::new();
1022                for fa in &add.fields {
1023                    fields.insert(fa.field.clone(), cal_value_to_json(&fa.value));
1024                }
1025                // Inject the REASON into the fields map.
1026                if !add.reason.is_empty() {
1027                    fields.insert(
1028                        "add_reason".into(),
1029                        serde_json::Value::String(add.reason.clone()),
1030                    );
1031                }
1032                // `WITH occurrence` — honest inert-option handling: tool
1033                // grains are not ADD-able from text (add_via_set is false;
1034                // the shape check above already refused them), and the host
1035                // API that IS the tool-call path (`record_tool_call`)
1036                // stamps per-call identity itself. The option exists so an
1037                // agent pasting the pattern gets a warning that names the
1038                // right tool, not silence.
1039                if add
1040                    .with_options
1041                    .iter()
1042                    .any(|o| matches!(o, super::ast::AddWithOption::Occurrence))
1043                {
1044                    exec_warnings.push(
1045                        "CAL-W014: WITH occurrence is inert here — tool occurrences are \
1046                         recorded via record_tool_call (every binding), which stamps the \
1047                         per-call identity that keeps retries distinct."
1048                            .to_string(),
1049                    );
1050                }
1051                self.inject_write_namespace(&mut fields, store);
1052                // Build AddOptions from WITH clause.
1053                let options = build_add_options(&add.with_options, exec_warnings);
1054                match store.cal_add_with_options(add.grain_type.as_str(), &fields, options) {
1055                    Ok(result) => Ok(CalResultPayload::Added {
1056                        hash: hex::encode(result.hash.as_bytes()),
1057                        grain_type: add.grain_type.as_str().to_string(),
1058                        extracted_count: if result.extracted_count > 0 {
1059                            Some(result.extracted_count)
1060                        } else {
1061                            None
1062                        },
1063                        extraction_warnings: result.extraction_warnings,
1064                    }),
1065                    Err(e) => Ok(CalResultPayload::Unsupported {
1066                        statement: "add".into(),
1067                        message: format!("ADD failed: {e}"),
1068                    }),
1069                }
1070            }
1071            CalStatement::Supersede(sup) => {
1072                if !self.config.tier1_enabled {
1073                    return Err(CalError::Tier1NotEnabled {
1074                        statement: "SUPERSEDE".into(),
1075                        span: sup.span,
1076                    });
1077                }
1078                // Strip optional "sha256:" prefix.
1079                let raw_hash = sup.hash.strip_prefix("sha256:").unwrap_or(&sup.hash);
1080                let old_hash = match Hash::from_hex(raw_hash) {
1081                    Ok(h) => h,
1082                    Err(e) => {
1083                        return Ok(CalResultPayload::Unsupported {
1084                            statement: "supersede".into(),
1085                            message: format!("invalid hash: {e}"),
1086                        })
1087                    }
1088                };
1089                // Get old grain to determine its type and merge fields.
1090                let old_grain = match store.get(&old_hash) {
1091                    Ok(g) => g,
1092                    Err(e) => {
1093                        return Ok(CalResultPayload::Unsupported {
1094                            statement: "supersede".into(),
1095                            message: format!("cannot retrieve grain for SUPERSEDE: {e}"),
1096                        })
1097                    }
1098                };
1099                let grain_type_str = old_grain.grain_type.as_str();
1100                // Start from the old grain's fields and overlay SET clauses.
1101                let mut fields: serde_json::Map<String, serde_json::Value> =
1102                    old_grain.fields.into_iter().collect();
1103                for sc in &sup.set_clauses {
1104                    fields.insert(sc.field.clone(), cal_value_to_json(&sc.value));
1105                }
1106                // Inject the BECAUSE reason.
1107                if !sup.reason.is_empty() {
1108                    fields.insert(
1109                        "supersede_reason".into(),
1110                        serde_json::Value::String(sup.reason.clone()),
1111                    );
1112                }
1113                match store.cal_supersede(&old_hash, grain_type_str, &fields) {
1114                    Ok(new_hash) => Ok(CalResultPayload::Superseded {
1115                        old_hash: hex::encode(old_hash.as_bytes()),
1116                        new_hash: hex::encode(new_hash.as_bytes()),
1117                    }),
1118                    Err(e) => Ok(CalResultPayload::Unsupported {
1119                        statement: "supersede".into(),
1120                        message: format!("SUPERSEDE failed: {e}"),
1121                    }),
1122                }
1123            }
1124            CalStatement::AddWorkflow(wf) => {
1125                if !self.config.tier1_enabled {
1126                    return Err(CalError::Tier1NotEnabled {
1127                        statement: "ADD WORKFLOW".into(),
1128                        span: wf.span,
1129                    });
1130                }
1131                // Build JSON fields for the workflow grain.
1132                let mut fields = serde_json::Map::new();
1133                fields.insert("name".into(), serde_json::Value::String(wf.name.clone()));
1134                // nodes: array of strings
1135                fields.insert(
1136                    "nodes".into(),
1137                    serde_json::Value::Array(
1138                        wf.nodes
1139                            .iter()
1140                            .map(|n| serde_json::Value::String(n.clone()))
1141                            .collect(),
1142                    ),
1143                );
1144                // edges: array of objects (repeat is NOT stored on edges —
1145                // `* N` populates the top-level `retries` map instead).
1146                let edges_json: Vec<serde_json::Value> = wf
1147                    .edges
1148                    .iter()
1149                    .map(|e| {
1150                        let mut m = serde_json::Map::new();
1151                        m.insert("src".into(), serde_json::Value::String(e.src.clone()));
1152                        m.insert("dst".into(), serde_json::Value::String(e.dst.clone()));
1153                        if let Some(ref c) = e.cond {
1154                            m.insert("cond".into(), serde_json::Value::String(c.clone()));
1155                        }
1156                        serde_json::Value::Object(m)
1157                    })
1158                    .collect();
1159                fields.insert("edges".into(), serde_json::Value::Array(edges_json));
1160                // bindings: object
1161                if !wf.bindings.is_empty() {
1162                    let mut bind_map = serde_json::Map::new();
1163                    for b in &wf.bindings {
1164                        bind_map.insert(
1165                            b.node.clone(),
1166                            serde_json::Value::String(format!("sha256:{}", b.hash)),
1167                        );
1168                    }
1169                    fields.insert("bindings".into(), serde_json::Value::Object(bind_map));
1170                }
1171                // retries: `* N` on an edge means "retry the target node
1172                // up to N times on failure".  This is stored as a top-level
1173                // `retries` map keyed by the destination node name.
1174                // Several edges may share a destination (a join, or a diamond), and
1175                // each may carry its own `* N`. The map is keyed by node, so a plain
1176                // insert lets the last edge win and the other bounds vanish — take
1177                // the largest instead, the only merge that never retries a node
1178                // fewer times than some edge asked for.
1179                let mut retries_map = serde_json::Map::new();
1180                for e in &wf.edges {
1181                    if let Some(r) = e.repeat {
1182                        let merged = retries_map
1183                            .get(&e.dst)
1184                            .and_then(|v| v.as_u64())
1185                            .map_or(r, |prev| (prev as u32).max(r));
1186                        retries_map.insert(
1187                            e.dst.clone(),
1188                            serde_json::Value::Number(serde_json::Number::from(merged)),
1189                        );
1190                    }
1191                }
1192                if !retries_map.is_empty() {
1193                    fields.insert("retries".into(), serde_json::Value::Object(retries_map));
1194                }
1195                self.inject_write_namespace(&mut fields, store);
1196                let options = build_add_options(&wf.with_options, exec_warnings);
1197                match store.cal_add_with_options("workflow", &fields, options) {
1198                    Ok(result) => Ok(CalResultPayload::Added {
1199                        hash: hex::encode(result.hash.as_bytes()),
1200                        grain_type: "workflow".into(),
1201                        extracted_count: None,
1202                        extraction_warnings: vec![],
1203                    }),
1204                    Err(e) => Ok(CalResultPayload::Unsupported {
1205                        statement: "add workflow".into(),
1206                        message: format!("ADD workflow failed: {e}"),
1207                    }),
1208                }
1209            }
1210            CalStatement::SupersedeWorkflow(wf) => {
1211                if !self.config.tier1_enabled {
1212                    return Err(CalError::Tier1NotEnabled {
1213                        statement: "SUPERSEDE WORKFLOW".into(),
1214                        span: wf.span,
1215                    });
1216                }
1217                let raw_hash = wf.hash.strip_prefix("sha256:").unwrap_or(&wf.hash);
1218                let old_hash = match Hash::from_hex(raw_hash) {
1219                    Ok(h) => h,
1220                    Err(e) => {
1221                        return Ok(CalResultPayload::Unsupported {
1222                            statement: "supersede workflow".into(),
1223                            message: format!("invalid hash: {e}"),
1224                        })
1225                    }
1226                };
1227                // Build workflow fields for supersession.
1228                let mut fields = serde_json::Map::new();
1229                fields.insert(
1230                    "nodes".into(),
1231                    serde_json::Value::Array(
1232                        wf.nodes
1233                            .iter()
1234                            .map(|n| serde_json::Value::String(n.clone()))
1235                            .collect(),
1236                    ),
1237                );
1238                let edges_json: Vec<serde_json::Value> = wf
1239                    .edges
1240                    .iter()
1241                    .map(|e| {
1242                        let mut m = serde_json::Map::new();
1243                        m.insert("src".into(), serde_json::Value::String(e.src.clone()));
1244                        m.insert("dst".into(), serde_json::Value::String(e.dst.clone()));
1245                        if let Some(ref c) = e.cond {
1246                            m.insert("cond".into(), serde_json::Value::String(c.clone()));
1247                        }
1248                        serde_json::Value::Object(m)
1249                    })
1250                    .collect();
1251                fields.insert("edges".into(), serde_json::Value::Array(edges_json));
1252                if !wf.bindings.is_empty() {
1253                    let mut bind_map = serde_json::Map::new();
1254                    for b in &wf.bindings {
1255                        bind_map.insert(
1256                            b.node.clone(),
1257                            serde_json::Value::String(format!("sha256:{}", b.hash)),
1258                        );
1259                    }
1260                    fields.insert("bindings".into(), serde_json::Value::Object(bind_map));
1261                }
1262                // retries: same semantics as AddWorkflow.
1263                // Several edges may share a destination (a join, or a diamond), and
1264                // each may carry its own `* N`. The map is keyed by node, so a plain
1265                // insert lets the last edge win and the other bounds vanish — take
1266                // the largest instead, the only merge that never retries a node
1267                // fewer times than some edge asked for.
1268                let mut retries_map = serde_json::Map::new();
1269                for e in &wf.edges {
1270                    if let Some(r) = e.repeat {
1271                        let merged = retries_map
1272                            .get(&e.dst)
1273                            .and_then(|v| v.as_u64())
1274                            .map_or(r, |prev| (prev as u32).max(r));
1275                        retries_map.insert(
1276                            e.dst.clone(),
1277                            serde_json::Value::Number(serde_json::Number::from(merged)),
1278                        );
1279                    }
1280                }
1281                if !retries_map.is_empty() {
1282                    fields.insert("retries".into(), serde_json::Value::Object(retries_map));
1283                }
1284                if !wf.reason.is_empty() {
1285                    fields.insert(
1286                        "supersede_reason".into(),
1287                        serde_json::Value::String(wf.reason.clone()),
1288                    );
1289                }
1290                match store.cal_supersede(&old_hash, "workflow", &fields) {
1291                    Ok(new_hash) => Ok(CalResultPayload::Superseded {
1292                        old_hash: hex::encode(old_hash.as_bytes()),
1293                        new_hash: hex::encode(new_hash.as_bytes()),
1294                    }),
1295                    Err(e) => Ok(CalResultPayload::Unsupported {
1296                        statement: "supersede workflow".into(),
1297                        message: format!("SUPERSEDE workflow failed: {e}"),
1298                    }),
1299                }
1300            }
1301            CalStatement::Accumulate(acc) => {
1302                if !self.config.tier1_enabled {
1303                    return Err(CalError::Tier1NotEnabled {
1304                        statement: "ACCUMULATE".into(),
1305                        span: acc.span,
1306                    });
1307                }
1308
1309                // Convert DeltaOps to (field, delta) pairs.
1310                let add_ops: Vec<(String, f64)> = acc
1311                    .add_ops
1312                    .iter()
1313                    .map(|op| (op.field.clone(), op.delta))
1314                    .collect();
1315
1316                // Convert SET ops to JSON map.
1317                let mut set_map = serde_json::Map::new();
1318                for s in &acc.set_ops {
1319                    set_map.insert(s.field.clone(), cal_value_to_json(&s.value));
1320                }
1321                if !acc.reason.is_empty() {
1322                    set_map.insert(
1323                        "supersede_reason".into(),
1324                        serde_json::Value::String(acc.reason.clone()),
1325                    );
1326                }
1327
1328                match store.cal_accumulate(
1329                    acc.grain_type.as_str(),
1330                    &acc.target,
1331                    &add_ops,
1332                    &set_map,
1333                    &acc.reason,
1334                ) {
1335                    Ok(result) => Ok(CalResultPayload::Accumulated {
1336                        old_hash: hex::encode(result.old_hash.as_bytes()),
1337                        new_hash: hex::encode(result.new_hash.as_bytes()),
1338                        deltas: result
1339                            .applied_deltas
1340                            .iter()
1341                            .map(|(f, old, new)| AccumulatedDelta {
1342                                field: f.clone(),
1343                                old_value: *old,
1344                                new_value: *new,
1345                            })
1346                            .collect(),
1347                    }),
1348                    // CU-86d2wr4n4: typed CalError propagation. Retry
1349                    // exhaustion → CAL-E083 (409); generic internal →
1350                    // CAL-E084 (500). Inner-error text never reaches
1351                    // the wire (security C3); request_id correlation
1352                    // happens in the route layer's tracing::error!.
1353                    Err(areev_core::error::AreevError::AccumulateRetryExhausted) => {
1354                        // Log the inner cause (no PII — fixed string)
1355                        // for operator forensics; correlation with
1356                        // request_id is added by the tracing span.
1357                        tracing::error!(
1358                            cal_code = "CAL-E083",
1359                            "ACCUMULATE retry budget exhausted under sustained contention"
1360                        );
1361                        let (subject, relation) = match &acc.target {
1362                            super::ast::AccumulateTarget::TipResolved {
1363                                subject, relation, ..
1364                            } => (subject.clone(), relation.clone()),
1365                            super::ast::AccumulateTarget::Hash { .. } => {
1366                                (String::new(), String::new())
1367                            }
1368                        };
1369                        Err(CalError::AccumulateRetryExhausted {
1370                            subject,
1371                            relation,
1372                            span: acc.span,
1373                        })
1374                    }
1375                    Err(areev_core::error::AreevError::AccumulateInternal(detail)) => {
1376                        tracing::error!(
1377                            cal_code = "CAL-E084",
1378                            error = %detail,
1379                            "ACCUMULATE internal failure"
1380                        );
1381                        Err(CalError::AccumulateInternal { span: acc.span })
1382                    }
1383                    // CU-86d2wr4n4 v2.1: CAL-E085 backpressure — admission
1384                    // control rejected this attempt (per-key inflight cap
1385                    // or global retry-permit semaphore saturated). Surfaces
1386                    // as HTTP 429 with `Retry-After: 1`.
1387                    Err(areev_core::error::AreevError::AccumulateBackpressureRejected) => {
1388                        tracing::warn!(cal_code = "CAL-E085", "ACCUMULATE backpressure rejected");
1389                        let (subject, relation) = match &acc.target {
1390                            super::ast::AccumulateTarget::TipResolved {
1391                                subject, relation, ..
1392                            } => (subject.clone(), relation.clone()),
1393                            super::ast::AccumulateTarget::Hash { .. } => {
1394                                (String::new(), String::new())
1395                            }
1396                        };
1397                        Err(CalError::AccumulateBackpressureRejected {
1398                            subject,
1399                            relation,
1400                            span: acc.span,
1401                        })
1402                    }
1403                    // Pre-existing validation/typing errors keep flowing
1404                    // as CAL-E081 / CAL-E020 etc. — wrapped in the
1405                    // generic Unsupported envelope, which routes will
1406                    // continue to surface as 400 via the existing
1407                    // AreevError::Validation mapping.
1408                    Err(e) => Ok(CalResultPayload::Unsupported {
1409                        statement: "accumulate".into(),
1410                        message: format!("ACCUMULATE failed: {e}"),
1411                    }),
1412                }
1413            }
1414            CalStatement::Revert(_) => Ok(CalResultPayload::Unsupported {
1415                statement: "revert".into(),
1416                message: "Tier 1 REVERT semantics are not yet defined. \
1417                              Use EXPLAIN to preview without execution."
1418                    .into(),
1419            }),
1420
1421            // Tier 2 — FORGET executes when allow_destructive_ops is enabled.
1422            CalStatement::Forget(forget) => {
1423                if !self.config.allow_destructive_ops {
1424                    return Ok(CalResultPayload::Unsupported {
1425                        statement: "forget".into(),
1426                        message: "Destructive operations are disabled for this session \
1427                                  (started with --no-destructive-ops)."
1428                            .into(),
1429                    });
1430                }
1431                match &forget.target {
1432                    super::ast::ForgetTarget::Hash { hash } => {
1433                        let h = Hash::from_hex(hash).map_err(|_| CalError::InvalidHash {
1434                            found: hash.clone(),
1435                            span: forget.span,
1436                        })?;
1437                        match store.cal_delete(&h, forget.reason.as_deref()) {
1438                            Ok(()) => Ok(CalResultPayload::Forgotten {
1439                                target: format!("hash:{hash}"),
1440                                count: 1,
1441                            }),
1442                            Err(e) => Ok(CalResultPayload::Unsupported {
1443                                statement: "forget".into(),
1444                                message: format!("FORGET failed: {e}"),
1445                            }),
1446                        }
1447                    }
1448                    super::ast::ForgetTarget::User { user_id } => {
1449                        // BECAUSE is mandatory on identity erasure (the text
1450                        // parser enforces it; the JSON-CAL path enforces it
1451                        // here).
1452                        let because = match forget.reason.as_deref() {
1453                            Some(r) if !r.trim().is_empty() => r,
1454                            _ => {
1455                                return Err(CalError::MissingReason { span: forget.span });
1456                            }
1457                        };
1458                        match store.cal_forget_user(user_id, forget.text_mentions, because) {
1459                            Ok(proof) => Ok(CalResultPayload::Forgotten {
1460                                target: format!("subject:{user_id}"),
1461                                count: proof.count,
1462                            }),
1463                            Err(e) => Ok(CalResultPayload::Unsupported {
1464                                statement: "forget".into(),
1465                                message: format!("FORGET SUBJECT failed: {e}"),
1466                            }),
1467                        }
1468                    }
1469                    super::ast::ForgetTarget::Scope { scope } => {
1470                        match store.cal_forget_scope(scope) {
1471                            Ok(proof) => Ok(CalResultPayload::Forgotten {
1472                                target: format!("scope:{scope}"),
1473                                count: proof.count,
1474                            }),
1475                            Err(e) => Ok(CalResultPayload::Unsupported {
1476                                statement: "forget".into(),
1477                                message: format!("FORGET SCOPE failed: {e}"),
1478                            }),
1479                        }
1480                    }
1481                }
1482            }
1483
1484            // Template management (FR-003). The per-namespace ceiling
1485            // (§10.8, CAL-E118) is enforced in the registry.
1486            CalStatement::DefineTemplate(def) => {
1487                store
1488                    .define_template(
1489                        &def.name,
1490                        &def.source,
1491                        def.description.as_deref(),
1492                        def.parent.as_deref(),
1493                        &def.grain_types,
1494                    )
1495                    .map_err(|e| {
1496                        let s = e.to_string();
1497                        // An authorization refusal is neither a bad name nor
1498                        // bad syntax — surface it as itself.
1499                        if s.contains("AUT-E") {
1500                            return CalError::NotAuthorized { detail: s, span: None };
1501                        }
1502                        // If the inner error is already a CAL error about template
1503                        // validation (unknown variable, syntax, etc.), surface it
1504                        // directly instead of wrapping it as TemplateInvalidName.
1505                        if s.contains("CAL-E04")
1506                            || s.contains("CAL-E11")
1507                            || s.contains("Unknown template")
1508                            || s.contains("Invalid template")
1509                            || s.contains("syntax")
1510                        {
1511                            CalError::TemplateSyntaxError {
1512                                detail: s,
1513                                span: None,
1514                            }
1515                        } else {
1516                            CalError::TemplateInvalidName {
1517                                name: def.name.clone(),
1518                                span: None,
1519                            }
1520                        }
1521                    })?;
1522                Ok(CalResultPayload::TemplateDefined {
1523                    name: def.name.clone(),
1524                })
1525            }
1526            CalStatement::DropTemplate(drop) => {
1527                if !self.config.allow_destructive_ops {
1528                    return Ok(CalResultPayload::Unsupported {
1529                        statement: "drop_template".into(),
1530                        message: "Destructive operations are disabled for this session \
1531                                  (started with --no-destructive-ops)."
1532                            .into(),
1533                    });
1534                }
1535                match store.drop_template(&drop.name) {
1536                    Ok(()) => Ok(CalResultPayload::TemplateDropped {
1537                        name: drop.name.clone(),
1538                    }),
1539                    Err(e) => Ok(CalResultPayload::Unsupported {
1540                        statement: "drop_template".into(),
1541                        message: format!("DROP TEMPLATE failed: {e}"),
1542                    }),
1543                }
1544            }
1545
1546            // Saved query management. The per-namespace ceiling is enforced
1547            // in the registry.
1548            CalStatement::DefineQuery(def) => {
1549                store
1550                    .define_query(
1551                        &def.name,
1552                        &def.body,
1553                        def.description.as_deref(),
1554                        &def.params,
1555                    )
1556                    .map_err(|e| {
1557                        let detail = e.to_string();
1558                        if detail.contains("AUT-E") {
1559                            CalError::NotAuthorized { detail, span: None }
1560                        } else {
1561                            CalError::InvalidQueryBody { detail, span: None }
1562                        }
1563                    })?;
1564                Ok(CalResultPayload::QueryDefined {
1565                    name: def.name.clone(),
1566                })
1567            }
1568            CalStatement::DropQuery(drop) => {
1569                if !self.config.allow_destructive_ops {
1570                    return Ok(CalResultPayload::Unsupported {
1571                        statement: "drop_query".into(),
1572                        message: "Destructive operations are disabled for this session \
1573                                  (started with --no-destructive-ops)."
1574                            .into(),
1575                    });
1576                }
1577                match store.drop_query(&drop.name) {
1578                    Ok(()) => Ok(CalResultPayload::QueryDropped {
1579                        name: drop.name.clone(),
1580                    }),
1581                    Err(e) => Ok(CalResultPayload::Unsupported {
1582                        statement: "drop_query".into(),
1583                        message: format!("DROP QUERY failed: {e}"),
1584                    }),
1585                }
1586            }
1587            CalStatement::RunQuery(run) => self.execute_run_query(run, store, query, exec_warnings),
1588
1589            // Tier 2 — PURGE OLDER THAN (the retention sweep).
1590            CalStatement::Purge(purge) => {
1591                if !self.config.allow_destructive_ops {
1592                    return Ok(CalResultPayload::Unsupported {
1593                        statement: "purge".into(),
1594                        message: "Destructive operations are disabled for this session \
1595                                  (started with --no-destructive-ops)."
1596                            .into(),
1597                    });
1598                }
1599                // BECAUSE is mandatory (the text parser enforces it; the
1600                // JSON-CAL path enforces it here).
1601                let because = match purge.reason.as_deref() {
1602                    Some(r) if !r.trim().is_empty() => r,
1603                    _ => {
1604                        return Err(CalError::MissingReason { span: purge.span });
1605                    }
1606                };
1607                let min_age = purge.min_age_days.unwrap_or(30.0);
1608                let batch_limit = purge.limit.unwrap_or(1000);
1609                let ns = purge.namespace.as_deref();
1610                match store.cal_purge_stale(
1611                    min_age,
1612                    ns,
1613                    batch_limit,
1614                    purge.grain_type.as_deref(),
1615                    because,
1616                ) {
1617                    Ok(count) => Ok(CalResultPayload::Purged { count }),
1618                    Err(e) => Ok(CalResultPayload::Unsupported {
1619                        statement: "purge".into(),
1620                        message: format!("PURGE failed: {e}"),
1621                    }),
1622                }
1623            }
1624
1625            // REPORT SUBJECT — the read-only DSAR selection (OMS 1.6
1626            // draft): a pure read under the session's `read` grant, no
1627            // destructive gate and no BECAUSE.
1628            CalStatement::ReportSubject(rs) => {
1629                match store.cal_subject_report(&rs.subject_id, rs.text_mentions) {
1630                    Ok(report) => Ok(CalResultPayload::SubjectReport {
1631                        subject: rs.subject_id.clone(),
1632                        identity_names: report.identity_names,
1633                        grains: report.grains,
1634                    }),
1635                    Err(e) => Ok(CalResultPayload::Unsupported {
1636                        statement: "report_subject".into(),
1637                        message: format!("REPORT SUBJECT failed: {e}"),
1638                    }),
1639                }
1640            }
1641
1642            // Wave-2 reads (CAL 1.3): as-of, the run↔memory join, reverse
1643            // provenance, the fork listing. All plain reads under the
1644            // session's grants.
1645            CalStatement::EntityAt(ea) => {
1646                let axis = ea.axis.clone().unwrap_or_else(|| "world".to_string());
1647                match store.cal_entity_at(&ea.subject, &ea.relation, ea.at_ms, &axis) {
1648                    Ok(grain) => Ok(CalResultPayload::EntityAt { grain, axis, at_ms: ea.at_ms }),
1649                    Err(e) => Ok(CalResultPayload::Unsupported {
1650                        statement: "entity_at".into(),
1651                        message: format!("ENTITY AT failed: {e}"),
1652                    }),
1653                }
1654            }
1655            CalStatement::RunTrace(rt) => {
1656                match store.cal_run_trace(&rt.run_id, rt.limit.unwrap_or(64).min(1024)) {
1657                    Ok(trace) => Ok(CalResultPayload::RunTrace {
1658                        run_id: rt.run_id.clone(),
1659                        trace,
1660                    }),
1661                    Err(e) => Ok(CalResultPayload::Unsupported {
1662                        statement: "run_trace".into(),
1663                        message: format!("RUN TRACE failed: {e}"),
1664                    }),
1665                }
1666            }
1667            CalStatement::RunsTouching(rt) => {
1668                let h = Hash::from_hex(&rt.hash).map_err(|_| CalError::InvalidHash {
1669                    found: rt.hash.clone(),
1670                    span: rt.span,
1671                })?;
1672                match store.cal_runs_touching(&h, rt.depth.unwrap_or(4).min(8)) {
1673                    Ok(runs) => Ok(CalResultPayload::RunsTouching { hash: rt.hash.clone(), runs }),
1674                    Err(e) => Ok(CalResultPayload::Unsupported {
1675                        statement: "runs_touching".into(),
1676                        message: format!("RUNS TOUCHING failed: {e}"),
1677                    }),
1678                }
1679            }
1680            CalStatement::DerivedFrom(df) => {
1681                let h = Hash::from_hex(&df.hash).map_err(|_| CalError::InvalidHash {
1682                    found: df.hash.clone(),
1683                    span: df.span,
1684                })?;
1685                match store.cal_derived_from(&h) {
1686                    Ok(grains) => Ok(CalResultPayload::DerivedFrom { hash: df.hash.clone(), grains }),
1687                    Err(e) => Ok(CalResultPayload::Unsupported {
1688                        statement: "derived_from".into(),
1689                        message: format!("DERIVED FROM failed: {e}"),
1690                    }),
1691                }
1692            }
1693            CalStatement::Merge(mg) => {
1694                if !self.config.tier1_enabled {
1695                    return Err(CalError::Tier1NotEnabled {
1696                        statement: "MERGE".into(),
1697                        span: mg.span,
1698                    });
1699                }
1700                match store.cal_merge(
1701                    &mg.subject,
1702                    &mg.relation,
1703                    &mg.object,
1704                    mg.confidence.unwrap_or(0.9),
1705                    &mg.reason,
1706                ) {
1707                    Ok(hash) => Ok(CalResultPayload::Merged {
1708                        hash: hash.to_hex(),
1709                        subject: mg.subject.clone(),
1710                        relation: mg.relation.clone(),
1711                        object: mg.object.clone(),
1712                    }),
1713                    Err(e) => Ok(CalResultPayload::Unsupported {
1714                        statement: "merge".into(),
1715                        message: format!("MERGE failed: {e}"),
1716                    }),
1717                }
1718            }
1719            CalStatement::Related(rel) => {
1720                let relations: Vec<&str> = rel
1721                    .relations
1722                    .split(',')
1723                    .map(str::trim)
1724                    .filter(|r| !r.is_empty())
1725                    .collect();
1726                match store.cal_related(
1727                    &rel.start,
1728                    &relations,
1729                    rel.direction.as_deref().unwrap_or("out"),
1730                    rel.depth.unwrap_or(2),
1731                    rel.limit.unwrap_or(64),
1732                ) {
1733                    Ok(entities) => Ok(CalResultPayload::RelatedEntities {
1734                        start: rel.start.clone(),
1735                        entities,
1736                    }),
1737                    Err(e) => Ok(CalResultPayload::Unsupported {
1738                        statement: "related".into(),
1739                        message: format!("RELATED failed: {e}"),
1740                    }),
1741                }
1742            }
1743            CalStatement::Novelty(nv) => {
1744                match store.cal_novelty(
1745                    &nv.text,
1746                    nv.subject.as_deref(),
1747                    nv.relation.as_deref(),
1748                    nv.limit.unwrap_or(5),
1749                ) {
1750                    Ok(rows) => Ok(CalResultPayload::NoveltyMatches {
1751                        matches: rows
1752                            .into_iter()
1753                            .map(|(hash, similarity)| {
1754                                serde_json::json!({ "hash": hash, "similarity": similarity })
1755                            })
1756                            .collect(),
1757                    }),
1758                    Err(e) => Ok(CalResultPayload::Unsupported {
1759                        statement: "novelty".into(),
1760                        message: format!("NOVELTY failed: {e}"),
1761                    }),
1762                }
1763            }
1764            CalStatement::ShowForks(_) => match store.open_forks() {
1765                Ok(groups) => Ok(CalResultPayload::Forks {
1766                    forks: groups
1767                        .iter()
1768                        .map(|f| {
1769                            serde_json::json!({
1770                                "namespace": f.namespace,
1771                                "subject": f.subject,
1772                                "relation": f.relation,
1773                                "heads": f.heads,
1774                            })
1775                        })
1776                        .collect(),
1777                }),
1778                Err(e) => Ok(CalResultPayload::Unsupported {
1779                    statement: "show_forks".into(),
1780                    message: format!("SHOW FORKS failed: {e}"),
1781                }),
1782            },
1783
1784            // REMEMBER (CAL 1.3) — the capture verb, an append-only write.
1785            CalStatement::Remember(rem) => {
1786                if !self.config.tier1_enabled {
1787                    return Err(CalError::Tier1NotEnabled {
1788                        statement: "REMEMBER".into(),
1789                        span: rem.span,
1790                    });
1791                }
1792                match store.cal_remember(
1793                    &rem.content,
1794                    rem.session_id.as_deref(),
1795                    rem.role.as_deref(),
1796                    rem.run_id.as_deref(),
1797                ) {
1798                    Ok(hash) => Ok(CalResultPayload::Remembered { hash: hash.to_hex() }),
1799                    Err(e) => Ok(CalResultPayload::Unsupported {
1800                        statement: "remember".into(),
1801                        message: format!("REMEMBER failed: {e}"),
1802                    }),
1803                }
1804            }
1805
1806            // Tier 3 — DCL (CAL 1.3 §8.15). Append-only writes to the authz
1807            // namespace: capped by the writes cap (`tier1_enabled`), gated
1808            // by the session's `admin` grant at the facade, untouched by
1809            // `allow_destructive_ops`.
1810            CalStatement::Grant(grant) => {
1811                if !self.config.tier1_enabled {
1812                    return Err(CalError::Tier1NotEnabled {
1813                        statement: "GRANT".into(),
1814                        span: grant.span,
1815                    });
1816                }
1817                match store.cal_grant(
1818                    &grant.principal,
1819                    &grant.verbs,
1820                    &grant.namespaces,
1821                    grant.reason.as_deref(),
1822                ) {
1823                    Ok(hash) => {
1824                        let object = areev_core::authz::Grant {
1825                            verbs: grant
1826                                .verbs
1827                                .iter()
1828                                .filter_map(|v| areev_core::authz::Verb::parse(v).ok())
1829                                .collect(),
1830                            namespaces: grant.namespaces.clone(),
1831                        }
1832                        .to_object_string();
1833                        Ok(CalResultPayload::Granted {
1834                            principal: grant.principal.clone(),
1835                            object,
1836                            hash: hash.to_hex(),
1837                        })
1838                    }
1839                    Err(e) => Ok(CalResultPayload::Unsupported {
1840                        statement: "grant".into(),
1841                        message: format!("GRANT failed: {e}"),
1842                    }),
1843                }
1844            }
1845            CalStatement::Revoke(revoke) => {
1846                if !self.config.tier1_enabled {
1847                    return Err(CalError::Tier1NotEnabled {
1848                        statement: "REVOKE".into(),
1849                        span: revoke.span,
1850                    });
1851                }
1852                match store.cal_revoke(
1853                    &revoke.principal,
1854                    &revoke.verbs,
1855                    &revoke.namespaces,
1856                    revoke.reason.as_deref(),
1857                ) {
1858                    Ok(grants_touched) => Ok(CalResultPayload::Revoked {
1859                        principal: revoke.principal.clone(),
1860                        grants_touched,
1861                    }),
1862                    Err(e) => Ok(CalResultPayload::Unsupported {
1863                        statement: "revoke".into(),
1864                        message: format!("REVOKE failed: {e}"),
1865                    }),
1866                }
1867            }
1868            // Governance (CAL 1.3 §8.16) — executes through the attached
1869            // GovernanceHost; identity comes from the bound session, never
1870            // the statement. No host → Unsupported (this executor was not
1871            // wired for governance).
1872            CalStatement::Approve(g) | CalStatement::Reject(g) => {
1873                let decision = if matches!(stmt, CalStatement::Approve(_)) {
1874                    crate::governance::ReviewDecision::Approve
1875                } else {
1876                    crate::governance::ReviewDecision::Reject
1877                };
1878                let name = if decision == crate::governance::ReviewDecision::Approve {
1879                    "approve"
1880                } else {
1881                    "reject"
1882                };
1883                let Some(host) = &self.governance else {
1884                    return Ok(governance_unwired(name));
1885                };
1886                match host.review(store, &g.hash, decision, &g.reason) {
1887                    Ok(()) => Ok(CalResultPayload::Reviewed {
1888                        hash: g.hash.clone(),
1889                        decision: name.into(),
1890                    }),
1891                    Err(e) => Ok(CalResultPayload::Unsupported {
1892                        statement: name.into(),
1893                        message: format!("{} failed: {e}", name.to_uppercase()),
1894                    }),
1895                }
1896            }
1897            CalStatement::ApplyRec(g) => {
1898                let Some(host) = &self.governance else {
1899                    return Ok(governance_unwired("apply"));
1900                };
1901                match host.apply(
1902                    store,
1903                    &g.hash,
1904                    &g.reason,
1905                    self.config.allow_destructive_ops,
1906                ) {
1907                    Ok(rollbackable) => Ok(CalResultPayload::RecApplied {
1908                        hash: g.hash.clone(),
1909                        rollbackable,
1910                    }),
1911                    Err(e) => Ok(CalResultPayload::Unsupported {
1912                        statement: "apply".into(),
1913                        message: format!("APPLY failed: {e}"),
1914                    }),
1915                }
1916            }
1917            CalStatement::RollbackRec(g) => {
1918                let Some(host) = &self.governance else {
1919                    return Ok(governance_unwired("rollback"));
1920                };
1921                match host.rollback(store, &g.hash, &g.reason) {
1922                    Ok(()) => Ok(CalResultPayload::RecRolledBack { hash: g.hash.clone() }),
1923                    Err(e) => Ok(CalResultPayload::Unsupported {
1924                        statement: "rollback".into(),
1925                        message: format!("ROLLBACK failed: {e}"),
1926                    }),
1927                }
1928            }
1929            CalStatement::RunLoop(run) => {
1930                let Some(host) = &self.governance else {
1931                    return Ok(governance_unwired("run_loop"));
1932                };
1933                let opts = crate::governance::RunLoopOptions {
1934                    full_sweep: run.full_sweep,
1935                    min_new: run.min_new,
1936                    if_stale_ms: run.if_stale_ms,
1937                };
1938                match host.run_loop(store, &opts) {
1939                    Ok(report) => Ok(CalResultPayload::LoopRan { run: report }),
1940                    Err(e) => Ok(CalResultPayload::Unsupported {
1941                        statement: "run_loop".into(),
1942                        message: format!("RUN LOOP failed: {e}"),
1943                    }),
1944                }
1945            }
1946            CalStatement::ShowGrants(show) => {
1947                match store.cal_show_grants(show.principal.as_deref()) {
1948                    Ok(rows) => Ok(CalResultPayload::GrantList {
1949                        grants: rows
1950                            .into_iter()
1951                            .map(|row| {
1952                                let parsed =
1953                                    areev_core::authz::Grant::from_object_string(&row.object)
1954                                        .ok();
1955                                serde_json::json!({
1956                                    "principal": row.principal,
1957                                    "object": row.object,
1958                                    "verbs": parsed.as_ref().map(|g| g
1959                                        .verbs
1960                                        .iter()
1961                                        .map(|v| v.as_str())
1962                                        .collect::<Vec<_>>()),
1963                                    "namespaces": parsed.as_ref().map(|g| g.namespaces.clone()),
1964                                    "hash": row.hash,
1965                                })
1966                            })
1967                            .collect(),
1968                    }),
1969                    Err(e) => Ok(CalResultPayload::Unsupported {
1970                        statement: "show_grants".into(),
1971                        message: format!("SHOW GRANTS failed: {e}"),
1972                    }),
1973                }
1974            }
1975        }
1976    }
1977
1978    // -----------------------------------------------------------------------
1979    // RUN (saved query execution)
1980    // -----------------------------------------------------------------------
1981
1982    fn execute_run_query(
1983        &self,
1984        run: &super::ast::RunQueryStmt,
1985        store: &dyn CalStoreFacade,
1986        outer_query: &CalQuery,
1987        exec_warnings: &mut Vec<String>,
1988    ) -> std::result::Result<CalResultPayload, CalError> {
1989        // 1. Load saved query from store.
1990        let entry = store
1991            .get_query(&run.name)
1992            .ok_or_else(|| CalError::QueryNotFound {
1993                name: run.name.clone(),
1994                span: run.span,
1995            })?;
1996
1997        // 2. Substitute parameters into body.
1998        let mut body = entry.body.clone();
1999
2000        // Build a map of available bindings: call-site bindings override defaults.
2001        let mut param_values: HashMap<String, String> = HashMap::new();
2002
2003        // Apply defaults first.
2004        for p in &entry.params {
2005            if let Some(ref default) = p.default {
2006                param_values.insert(p.name.clone(), value_to_cal_literal(default));
2007            }
2008        }
2009
2010        // Apply call-site bindings (override defaults).
2011        for (name, value) in &run.bindings {
2012            param_values.insert(name.clone(), value_to_cal_literal(value));
2013        }
2014
2015        // Check for missing required parameters.
2016        for p in &entry.params {
2017            if p.default.is_none() && !param_values.contains_key(&p.name) {
2018                return Err(CalError::MissingQueryParam {
2019                    name: p.name.clone(),
2020                    query: run.name.clone(),
2021                    span: run.span,
2022                });
2023            }
2024        }
2025
2026        // Warn on unused parameters (supplied but not in query definition).
2027        let declared_names: std::collections::HashSet<&str> =
2028            entry.params.iter().map(|p| p.name.as_str()).collect();
2029        for (name, _) in &run.bindings {
2030            if !declared_names.contains(name.as_str()) {
2031                exec_warnings.push(format!(
2032                    "CAL-W006: Parameter \"${}\" supplied but not used in query \"{}\"",
2033                    name, run.name
2034                ));
2035            }
2036        }
2037
2038        // Substitute $param references in body text.
2039        for (name, literal) in &param_values {
2040            body = body.replace(&format!("${}", name), literal);
2041        }
2042
2043        // 3. Parse the substituted body.
2044        //
2045        // Cached like any other statement — but note WHAT the key is. Params
2046        // are substituted into the body TEXT above, before parsing, so the
2047        // cache key is the substituted body: `RUN "triage"($subject: "amy")`
2048        // reuses a plan only when called with the same arguments again. A
2049        // zero-parameter saved query therefore always hits after its first
2050        // call; a parameterized one hits per distinct argument set. This is
2051        // the honest answer to "does RUN reuse a compiled plan?" and it is
2052        // recorded in docs/cal-reference.md.
2053        let parsed = self.parse_cached(&body).map_err(|e| CalError::InvalidQueryBody {
2054            detail: e.to_string(),
2055            span: run.span,
2056        })?;
2057
2058        // 3a. Re-validate read-only at execution. The DEFINE-time scan is
2059        // skipped for $-parameterized bodies, so this is the precise gate: a
2060        // saved-query body that resolves to ADD/SUPERSEDE/FORGET/DROP/DEFINE/RUN
2061        // is refused here regardless of the destructive-ops gate.
2062        let span = run.span.unwrap_or_else(Span::zero);
2063        super::parser::check_read_only_statement(&parsed.statement, &span)?;
2064
2065        // 4. Merge WITH options: saved query's with + outer query's with (outer wins).
2066        let mut merged_query = parsed;
2067
2068        // If the outer query (RUN site) has WITH options, merge them.
2069        for opt in &outer_query.with_options {
2070            // Check if this option already exists in the merged query.
2071            let existing_idx = merged_query
2072                .with_options
2073                .iter()
2074                .position(|o| std::mem::discriminant(o) == std::mem::discriminant(opt));
2075            if let Some(idx) = existing_idx {
2076                // Call-site wins on conflict.
2077                merged_query.with_options[idx] = opt.clone();
2078            } else {
2079                merged_query.with_options.push(opt.clone());
2080            }
2081        }
2082
2083        // If the outer query has a FORMAT, it replaces the body's FORMAT.
2084        if outer_query.format.is_some() {
2085            merged_query.format = outer_query.format.clone();
2086        }
2087
2088        // If the outer query has pipeline stages, append them.
2089        for stage in &outer_query.pipeline {
2090            merged_query.pipeline.push(stage.clone());
2091        }
2092
2093        // If the outer query has user_vars, merge them.
2094        for (k, v) in &outer_query.user_vars {
2095            merged_query
2096                .user_vars
2097                .entry(k.clone())
2098                .or_insert_with(|| v.clone());
2099        }
2100
2101        // 5. Execute the parsed+merged query through the normal path.
2102        let result =
2103            self.execute_statement(&merged_query.statement, store, &merged_query, exec_warnings)?;
2104
2105        // 6. Record last_run_at timestamp on successful execution.
2106        //    Best-effort — a persistence failure here should not fail the query.
2107        let _ = store.update_query_last_run(&run.name);
2108
2109        Ok(result)
2110    }
2111
2112    // -----------------------------------------------------------------------
2113    // RECALL
2114    // -----------------------------------------------------------------------
2115
2116    fn execute_recall(
2117        &self,
2118        recall: &RecallStmt,
2119        store: &dyn CalStoreFacade,
2120        query: &CalQuery,
2121        exec_warnings: &mut Vec<String>,
2122    ) -> std::result::Result<CalResultPayload, CalError> {
2123        let mut params = RecallParams::default();
2124
2125        // Grain type filter.
2126        if let Some(gt) = recall.grain_type.to_grain_type() {
2127            params.grain_type = Some(gt);
2128        }
2129
2130        // ABOUT clause → free-text BM25 query.
2131        if let Some(ref about) = recall.about {
2132            params.query = Some(about.text.clone());
2133        }
2134
2135        // LIKE clause → textual similarity; in Areev this rides the same
2136        // BM25 leg (parser already rejects ABOUT+LIKE together).
2137        if params.query.is_none() {
2138            if let Some(ref like) = recall.like {
2139                params.query = Some(like.text.clone());
2140            }
2141        }
2142
2143        // WHERE clause → structured filter fields.
2144        if let Some(ref where_clause) = recall.where_clause {
2145            self.apply_where_clause(&where_clause.condition, &mut params, exec_warnings, &query.let_values)?;
2146        }
2147
2148        // Consent grains index subject_did in the hexastore, so when the user
2149        // queries `WHERE subject = "alice"`, also search for "did:alice" to
2150        // match both plain and DID-prefixed storage formats.
2151        if recall.grain_type == GrainTypePlural::Consents {
2152            let expand_did = |value: &str| -> Vec<String> {
2153                let mut variants = vec![value.to_string()];
2154                if !value.starts_with("did:") {
2155                    variants.push(format!("did:{}", value));
2156                }
2157                variants
2158            };
2159
2160            if let Some(ref subj) = params.subject.take() {
2161                let expanded = expand_did(subj);
2162                match params.subject_in.as_mut() {
2163                    Some(existing) => existing.extend(expanded),
2164                    None => params.subject_in = Some(expanded),
2165                }
2166            }
2167            if let Some(ref existing) = params.subject_in.clone() {
2168                // Dedup while preserving order.
2169                let mut seen = std::collections::HashSet::new();
2170                let deduped: Vec<String> = existing
2171                    .iter()
2172                    .flat_map(|s| expand_did(s))
2173                    .filter(|s| seen.insert(s.clone()))
2174                    .collect();
2175                params.subject_in = Some(deduped);
2176            }
2177        }
2178
2179        // SINCE + optional UNTIL → temporal expression.
2180        match (&recall.since, &recall.until) {
2181            (Some(since), Some(until)) => {
2182                // SINCE "start" UNTIL "end" → combine into a range expression.
2183                params.temporal_expr = Some(format!(
2184                    "between {} and {}",
2185                    since.expression, until.expression
2186                ));
2187            }
2188            (Some(since), None) => {
2189                params.temporal_expr = Some(since.expression.clone());
2190            }
2191            (None, Some(until)) => {
2192                params.temporal_expr = Some(format!("before {}", until.expression));
2193            }
2194            (None, None) => {}
2195        }
2196
2197        // BETWEEN clause → temporal range expression.
2198        if let Some(ref between) = recall.between {
2199            params.temporal_expr = Some(format!("between {} and {}", between.start, between.end));
2200        }
2201
2202        // RECENT n → limit + implicit created_at DESC ordering.
2203        if let Some(ref recent) = recall.recent {
2204            params.limit = Some(recent.count.min(self.config.max_limit) as usize);
2205        }
2206
2207        // Inline LIMIT (overrides RECENT if both present — parser prevents that).
2208        if let Some(limit) = recall.limit {
2209            params.limit = Some(limit.min(self.config.max_limit) as usize);
2210        }
2211
2212        // Apply default limit if still unset.
2213        if params.limit.is_none() {
2214            params.limit = Some(self.config.default_limit as usize);
2215        }
2216
2217        // ── CONTRADICTIONS widens the candidate scan ─────────────────────
2218        //
2219        // The clause filters *after* recall, so whatever LIMIT bounded the
2220        // recall also bounds which forks can be seen. Left alone, the default
2221        // limit would make "nothing is contested" mean "nothing among the
2222        // newest 50" — the exact false all-clear this clause exists to
2223        // prevent. So scan as wide as CAL allows and re-apply the caller's
2224        // LIMIT to the *contested* grains afterwards: LIMIT bounds the answer,
2225        // not the search for it.
2226        let contradictions_limit = if recall.contradictions.is_some() {
2227            params.limit.replace(self.config.max_limit as usize)
2228        } else {
2229            None
2230        };
2231
2232        // ── A post-retrieval stage must see the whole matching set ────────
2233        //
2234        // Same defect as CONTRADICTIONS above, in three more places. ORDER BY,
2235        // the type-specific WHERE post-filter, and COUNT all run over the
2236        // grains the statement ALREADY returned — a `default_limit` page. So
2237        // `ORDER BY priority DESC | LIMIT 5` returned the top 5 *of the newest
2238        // 50* and was indistinguishable from the top 5 overall; a
2239        // `WHERE tool_name = …` post-filter searched the newest 50 for a match
2240        // that might be older; `| COUNT` counted the page. Widening the scan
2241        // and re-applying the caller's bound afterwards is the same trade
2242        // CONTRADICTIONS already makes: LIMIT bounds the ANSWER, not the
2243        // search for it.
2244        //
2245        // Why widening rather than a sort key pushed into SQL: the sort keys
2246        // callers actually use (`priority`, `status`, `confidence`, and every
2247        // type-specific field) live INSIDE the content-addressed blob, not in
2248        // a `grains` column — the table carries only seq/ns/gtype/created_at/
2249        // s/p/o/validity. Ordering on them in SQL would mean materializing a
2250        // column per field. `created_at` IS a column, so that one case is
2251        // pushed down properly (see `RecallParams::order_by`); everything else
2252        // is ranked here, over a scan widened to `max_limit`, and says so via
2253        // CAL-W015 when even that scan comes back full.
2254        let order_by: Option<(String, bool)> = query.pipeline.iter().find_map(|st| match st {
2255            PipelineStage::OrderBy {
2256                field, descending, ..
2257            } => Some((field.clone(), *descending)),
2258            _ => None,
2259        });
2260        let has_count = query
2261            .pipeline
2262            .iter()
2263            .any(|st| matches!(st, PipelineStage::Count { .. }));
2264        // #91 — plan the residual WHERE tree up front. This validates every
2265        // filter BEFORE the scan (refusing what cannot be honoured with
2266        // CAL-E060/E061 instead of widening) and decides whether a
2267        // post-retrieval pass needs the scan widened to see the full set.
2268        let residual_where = match recall.where_clause.as_ref() {
2269            Some(w) => plan_residual_where(&w.condition, &recall.grain_type, exec_warnings)?,
2270            None => None,
2271        };
2272        let has_post_filter = residual_where.is_some();
2273        // CONTRADICTIONS has already widened to the same bound; don't stack.
2274        let wide_reason: Option<String> = if recall.contradictions.is_some() {
2275            None
2276        } else if let Some((ref f, _)) = order_by {
2277            Some(format!("ORDER BY {f}"))
2278        } else if has_count {
2279            Some("COUNT".to_string())
2280        } else if has_post_filter {
2281            Some("a post-retrieval WHERE filter".to_string())
2282        } else {
2283            None
2284        };
2285        // `created_at` is the one sort key the index can serve, so push it
2286        // down instead of widening for it.
2287        let pushed_down_sort = matches!(order_by, Some((ref f, _)) if f == "created_at");
2288        if let (true, Some((ref field, descending))) = (pushed_down_sort, &order_by) {
2289            params.order_by = Some(crate::store_types::SortKey {
2290                field: field.clone(),
2291                descending: *descending,
2292            });
2293        }
2294        let widened_limit = if wide_reason.is_some() && !pushed_down_sort {
2295            params.limit.replace(self.config.max_limit as usize)
2296        } else {
2297            None
2298        };
2299
2300        // WITH options.
2301        self.apply_with_options(&query.with_options, &mut params)?;
2302
2303        // WITH exhaustive requires an ABOUT clause for semantic search.
2304        if params.exhaustive.is_some() && recall.about.is_none() {
2305            return Err(CalError::UnexpectedToken {
2306                expected: "ABOUT clause (WITH exhaustive requires a semantic search query)".into(),
2307                found: "no ABOUT clause".into(),
2308                span: recall.span,
2309                suggestion: Some(
2310                    "Add an ABOUT clause: RECALL facts ABOUT \"...\" WITH exhaustive".into(),
2311                ),
2312            });
2313        }
2314
2315        // Namespace and user_id overrides from config (capability-scoped auth).
2316        if let Some(ref ns) = self.config.namespace_override {
2317            params.namespace = Some(ns.clone());
2318            // The pin wins over EVERY caller-supplied scope: a surviving
2319            // `namespace IN (…)` set (or a prefix pattern inside one) would
2320            // let a pinned session read outside its tenant.
2321            params.namespaces = None;
2322        } else if params.namespace.is_none() && params.namespaces.is_none() {
2323            // The session default fills in only when the query named NO scope
2324            // at all — an explicit `namespace IN (…)` set is already a scope.
2325            if let Some(ns) = store.default_namespace() {
2326                params.namespace = Some(ns.to_string());
2327            }
2328        }
2329
2330        if let Some(ref uid) = self.config.user_id_override {
2331            params.user_id = Some(uid.clone());
2332        }
2333
2334        // Execute via the facade.
2335        let hits = store
2336            .recall(&params)
2337            .map_err(|e| map_store_err(e, recall.span))?;
2338
2339        // A recall that came back exactly full was cut off by the limit, so
2340        // anything CONTRADICTIONS says about grains beyond it is unknown.
2341        let scan_was_bounded = params.limit.is_some_and(|l| hits.len() >= l);
2342
2343        let mut grains = hits_to_grain_results(&hits);
2344
2345        // Tag grains from deterministic recalls (no ABOUT) so post-merge
2346        // score-based filters (e.g. WITH min_score in ASSEMBLE) skip them.
2347        if recall.about.is_none() {
2348            for g in &mut grains {
2349                g.is_deterministic = true;
2350            }
2351        }
2352
2353        // ── #91: Residual WHERE — evaluated per grain ────────────────────
2354        //
2355        // Everything push-down did not consume (type-specific fields,
2356        // NOT/OR subtrees, unsupported comparators, IS NULL, …) is applied
2357        // here through the ONE authoritative evaluator, with full boolean
2358        // semantics. Validation already ran in `plan_residual_where`,
2359        // before the scan — a filter is pushed down, evaluated here, or
2360        // refused; it is never dropped.
2361        if let Some(ref residual) = residual_where {
2362            grains.retain(|grain| grain_matches_condition_tree(grain, residual));
2363        }
2364
2365        // ── Re-apply the caller's bound to the widened scan ──────────────
2366        //
2367        // The scan above was widened so this ranking/filtering could see the
2368        // whole matching set. Rank it here, over that full set, then bound the
2369        // ANSWER back to what the caller asked for. The pipeline's own ORDER BY
2370        // still runs afterwards and re-sorts the same grains — idempotent, and
2371        // it keeps the stage's behaviour unchanged for every path that did not
2372        // widen.
2373        if let Some(reason) = wide_reason {
2374            // Even a max_limit scan can fill up. Saying so is the point: the
2375            // result is a well-formed list that happens to be the top-k of a
2376            // window rather than of the memory, and nothing else distinguishes
2377            // the two.
2378            if scan_was_bounded {
2379                exec_warnings.push(
2380                    super::errors::CalWarning::ScanBounded {
2381                        stage: reason,
2382                        scanned: self.config.max_limit as usize,
2383                    }
2384                    .to_string(),
2385                );
2386            }
2387            if let Some((ref field, descending)) = order_by {
2388                grains.sort_by(|a, b| {
2389                    let cmp = compare_json_values(
2390                        json_field(&a.fields, field),
2391                        json_field(&b.fields, field),
2392                    );
2393                    if descending {
2394                        cmp.reverse()
2395                    } else {
2396                        cmp
2397                    }
2398                });
2399            }
2400            // A pipeline that bounds or aggregates the result does that job
2401            // itself, over the ranked set — truncating first would put the
2402            // page back.
2403            let pipeline_bounds = query.pipeline.iter().any(|st| {
2404                matches!(
2405                    st,
2406                    PipelineStage::Limit { .. }
2407                        | PipelineStage::First { .. }
2408                        | PipelineStage::Count { .. }
2409                )
2410            });
2411            if !pipeline_bounds {
2412                if let Some(limit) = widened_limit {
2413                    grains.truncate(limit);
2414                }
2415            }
2416        }
2417
2418        // ── WITH dedup(<field>) on RECALL ────────────────────────────────
2419        //
2420        // §5 introduces the WITH table with "WITH options tune recall
2421        // behavior", but `dedup` was only ever implemented on the ASSEMBLE
2422        // merge path — on a RECALL it parsed, ran, and changed nothing. Keep
2423        // the first grain per distinct value of the field, which is the same
2424        // rule ASSEMBLE applies, and preserves recall order (so the
2425        // highest-ranked representative of each value survives).
2426        for opt in &query.with_options {
2427            // `WITH dedup` with no field is the similarity-based form; only the
2428            // per-field spelling is meaningful on a single recall's grains.
2429            if let WithOption::Dedup { field: Some(field) } = opt {
2430                let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2431                grains.retain(|g| match json_field(&g.fields, field) {
2432                    Some(v) => seen.insert(value_dedup_key(v)),
2433                    // A grain that does not carry the field cannot be a
2434                    // duplicate on it; dropping those would silently narrow the
2435                    // result to whichever grain types happen to have it.
2436                    None => true,
2437                });
2438            }
2439        }
2440
2441        // `WITH score_breakdown` asks for per-leg scoring detail this recall
2442        // path does not produce: the facade returns grains, not per-leg scores,
2443        // so every structural hit carries the sentinel 1.0. §5's own rule is
2444        // that an option needing an unavailable backend "returns an honest
2445        // error rather than silently degrading" — a warning is the
2446        // non-breaking form of honest, since the option has shipped for
2447        // several releases and callers pass it today.
2448        if params.score_breakdown == Some(true) {
2449            exec_warnings.push(
2450                super::errors::CalWarning::WithOptionInert {
2451                    option: "score_breakdown",
2452                    statement: "RECALL",
2453                    why: "this recall path returns fused grains, not per-leg scores, so \
2454                          structural hits all carry the sentinel score 1.0",
2455                }
2456                .to_string(),
2457            );
2458        }
2459
2460        // ── CONTRADICTIONS — restrict to grains that are contested ───────
2461        //
2462        // A fork is `(ns, subject, relation)` with more than one live head, which
2463        // happens when two writers diverge and then sync: immutability keeps both
2464        // tips, and recall serves a deterministically-elected provisional head.
2465        // Plain recall therefore answers with a value that *looks* settled. This
2466        // clause is how an agent asks the opposite question — "what do I hold
2467        // that is still disputed?" — without an operator running `areev forks`.
2468        //
2469        // Runs last, after every other filter, so `CONTRADICTIONS` composes with
2470        // ABOUT/WHERE/SINCE rather than overriding them — and before LIMIT, so
2471        // the limit bounds the contested grains rather than the search.
2472        if let Some(ref clause) = recall.contradictions {
2473            // `CONTRADICTIONS OF (sub-query)` narrows the fork set to the keys
2474            // that sub-query selected. Execute it with the pipeline and FORMAT
2475            // stripped: those belong to the enclosing statement, and letting them
2476            // reach the inner query is the leak that bit nested ASSEMBLE.
2477            let scope = match clause.inner {
2478                Some(ref inner) => {
2479                    let inner_query = CalQuery {
2480                        statement: (**inner).clone(),
2481                        pipeline: Vec::new(),
2482                        format: None,
2483                        ..query.clone()
2484                    };
2485                    let payload = self.execute_statement(
2486                        &inner_query.statement,
2487                        store,
2488                        &inner_query,
2489                        exec_warnings,
2490                    )?;
2491                    match payload {
2492                        CalResultPayload::Grains { ref grains, .. } => Some(
2493                            contradiction_scope_keys(grains, params.namespace.as_deref()),
2494                        ),
2495                        // A sub-query that yields no grain set (COUNT, EXISTS, a
2496                        // formatted string) selects no keys to scope by. Treat it
2497                        // as an empty scope rather than silently ignoring the
2498                        // tail, so the query cannot over-report.
2499                        _ => {
2500                            exec_warnings.push(
2501                                "CONTRADICTIONS OF (...) sub-query returned no grains; \
2502                                 no keys to scope by"
2503                                    .into(),
2504                            );
2505                            Some(std::collections::HashSet::new())
2506                        }
2507                    }
2508                }
2509                None => None,
2510            };
2511            apply_fork_status(&mut grains, store, true, scope.as_ref(), exec_warnings);
2512
2513            // Even a max_limit scan can be cut off. Saying so is the whole
2514            // point: an agent may act on "nothing is contested", so it must
2515            // learn when that answer only covers part of the memory.
2516            if scan_was_bounded {
2517                exec_warnings.push(
2518                    super::errors::CalWarning::ContradictionScanBounded {
2519                        scanned: self.config.max_limit as usize,
2520                    }
2521                    .to_string(),
2522                );
2523            }
2524
2525            // The caller's LIMIT applies to the contested grains, not to the
2526            // scan that found them (see where `contradictions_limit` is taken).
2527            if let Some(limit) = contradictions_limit {
2528                grains.truncate(limit);
2529            }
2530        } else if params.detect_contradictions == Some(true) {
2531            // `WITH contradiction_detection` — annotate, don't filter. The agent
2532            // gets its normal context back, with disputed grains marked.
2533            apply_fork_status(&mut grains, store, false, None, exec_warnings);
2534        }
2535
2536        let count = grains.len();
2537
2538        // FORMAT clause — render into the requested format.
2539        // Skip early rendering when pipeline stages exist; the main execute()
2540        // path applies FORMAT after pipeline, which is needed for GROUP BY to
2541        // propagate its metadata to the renderer.
2542        if query.pipeline.is_empty() {
2543            if let Some(ref fmt) = query.format {
2544                return apply_format_clause_to_grains(
2545                    &grains,
2546                    fmt,
2547                    None,
2548                    RenderInputs {
2549                        user_vars: &query.user_vars,
2550                        store,
2551                        disclosure: disclosure_of(&query.with_options),
2552                    },
2553                    &Default::default(),
2554                    exec_warnings,
2555                );
2556            }
2557        }
2558
2559        Ok(CalResultPayload::Grains {
2560            grains,
2561            total_available: Some(count),
2562        })
2563    }
2564
2565    // -----------------------------------------------------------------------
2566    // WHERE clause mapping
2567    // -----------------------------------------------------------------------
2568
2569    fn apply_where_clause(
2570        &self,
2571        condition: &Condition,
2572        params: &mut RecallParams,
2573        warnings: &mut Vec<String>,
2574        // Resolved LET bindings, so `IN $var` can expand. Empty for statements
2575        // that carry no LET clause, which is every statement but the two-step
2576        // pattern.
2577        let_values: &HashMap<String, Vec<String>>,
2578    ) -> std::result::Result<(), CalError> {
2579        match condition {
2580            Condition::Comparison {
2581                field,
2582                comparator,
2583                value,
2584                ..
2585            } => {
2586                match (field.as_str(), comparator) {
2587                    ("subject", Comparator::Eq) => {
2588                        params.subject = Some(value_to_string(value)?);
2589                    }
2590                    ("relation", Comparator::Eq) => {
2591                        params.relation = Some(value_to_string(value)?);
2592                    }
2593                    ("object", Comparator::Eq) => {
2594                        params.object = Some(value_to_string(value)?);
2595                    }
2596                    ("namespace", Comparator::Eq) => {
2597                        // Only apply if not overridden by capability token.
2598                        if self.config.namespace_override.is_none() {
2599                            params.namespace = Some(value_to_string(value)?);
2600                        }
2601                    }
2602                    ("user_id", Comparator::Eq) => {
2603                        // Only apply if not overridden by capability token.
2604                        if self.config.user_id_override.is_none() {
2605                            params.user_id = Some(value_to_string(value)?);
2606                        }
2607                    }
2608                    ("confidence", Comparator::Gte) | ("confidence", Comparator::Gt) => {
2609                        params.confidence_threshold = Some(value_to_f64(value)?);
2610                    }
2611                    ("importance", Comparator::Gte) | ("importance", Comparator::Gt) => {
2612                        params.importance_threshold = Some(value_to_f64(value)?);
2613                    }
2614                    ("query", Comparator::Eq) => {
2615                        params.query = Some(value_to_string(value)?);
2616                    }
2617                    // Pushed down to the thread index rather than post-filtered.
2618                    // `session_id` stays OUT of `COMMON_FIELDS` (it is
2619                    // type-specific, and `test_session_id_not_in_common_fields`
2620                    // pins that), so the post-retrieval filter still runs over
2621                    // it — this arm only narrows the SCAN, from "a page of the
2622                    // namespace" to "this conversation".
2623                    ("session_id", Comparator::Eq) => {
2624                        params.session_id = Some(value_to_string(value)?);
2625                    }
2626                    ("time", Comparator::Eq) => {
2627                        params.temporal_expr = Some(value_to_string(value)?);
2628                    }
2629                    ("contradicted", Comparator::Eq) => {
2630                        if let Value::Boolean { value: b } = value {
2631                            params.include_contradicted = Some(*b);
2632                        }
2633                    }
2634                    ("entity", Comparator::Eq) => {
2635                        params.entity = Some(value_to_string(value)?);
2636                    }
2637                    // `hash` is on the grain envelope, not in `fields`, so it
2638                    // reached neither the structural filters nor the
2639                    // post-filter's `fields` lookup: `WHERE hash = "<real>"`
2640                    // returned the whole result set with a CAL-W010, and
2641                    // `hash IN (...)` returned nothing. Both are handled as
2642                    // post-filters now (see `grain_matches_condition`); listing
2643                    // the field here is what stops the spurious warning.
2644                    ("hash", Comparator::Eq) | ("hash", Comparator::NotEq) => {}
2645                    ("scope_path", Comparator::Eq) | ("scope", Comparator::Eq) => {
2646                        params.scope_path = Some(value_to_string(value)?);
2647                    }
2648                    // Everything else (type-specific fields, unsupported
2649                    // comparators on common fields) is the residual filter's
2650                    // job — `plan_residual_where` validated it and
2651                    // `grain_matches_condition_tree` applies it per grain
2652                    // (#91). No warning here: a residual filter is honoured,
2653                    // not ignored.
2654                    _ => {}
2655                }
2656                Ok(())
2657            }
2658
2659            Condition::And { left, right, .. } => {
2660                self.apply_where_clause(left, params, warnings, let_values)?;
2661                self.apply_where_clause(right, params, warnings, let_values)?;
2662                Ok(())
2663            }
2664
2665            Condition::In { field, values, span } => {
2666                // `IN $var` arrives as a single `Value::Parameter`. Expanding it
2667                // is what makes `LET $friends = SUBJECTS OF (…)` mean anything;
2668                // a `filter_map` over string literals dropped it, leaving an
2669                // empty set that nothing downstream read, so the query returned
2670                // the whole table. An unbound name is an error rather than an
2671                // empty set: silently scoping to nothing is as wrong as
2672                // silently scoping to everything, and the caller can tell the
2673                // difference only if we say so.
2674                let mut str_values: Vec<String> = Vec::new();
2675                for v in values {
2676                    match v {
2677                        Value::String { value } | Value::Hash { value } => {
2678                            str_values.push(value.clone())
2679                        }
2680                        Value::Number { value } => str_values.push(value.to_string()),
2681                        Value::Parameter { name } => match let_values.get(name.as_str()) {
2682                            Some(bound) => str_values.extend(bound.iter().cloned()),
2683                            None => {
2684                                return Err(CalError::UnboundParameter {
2685                                    name: name.clone(),
2686                                    span: *span,
2687                                })
2688                            }
2689                        },
2690                        Value::Array { values: inner } => {
2691                            for iv in inner {
2692                                if let Value::String { value } = iv {
2693                                    str_values.push(value.clone());
2694                                }
2695                            }
2696                        }
2697                        Value::Boolean { value } => str_values.push(value.to_string()),
2698                    }
2699                }
2700                match field.as_str() {
2701                    "subject" => params.subject_in = Some(str_values),
2702                    "relation" => params.relation_in = Some(str_values),
2703                    "object" => params.object_in = Some(str_values),
2704                    "tags" => params.tags = Some(str_values),
2705                    "namespace" => {
2706                        // Multi-namespace scope. The facade consumes the SET
2707                        // (issue #19: setting only a "primary" first value
2708                        // meant every other member was silently dropped);
2709                        // `params.namespace` stays untouched so a session
2710                        // default cannot shadow an explicit IN scope.
2711                        params.namespaces = Some(str_values);
2712                    }
2713                    _ => {
2714                        // Unknown field IN — silently ignore (CAL spec allows
2715                        // domain-specific fields that may not map to RecallParams).
2716                    }
2717                }
2718                Ok(())
2719            }
2720
2721            Condition::NotIn { field, values, .. } => {
2722                if field == "tags" {
2723                    let tag_strs: Vec<String> = values
2724                        .iter()
2725                        .filter_map(|v| match v {
2726                            Value::String { value } => Some(value.clone()),
2727                            _ => None,
2728                        })
2729                        .collect();
2730                    params.exclude_tags = Some(tag_strs);
2731                }
2732                Ok(())
2733            }
2734
2735            Condition::Contains { field, value, .. } => {
2736                // Map CONTAINS to substring search for subject/object,
2737                // or to BM25 text query for other searchable fields.
2738                match field.as_str() {
2739                    "subject" => {
2740                        params.subject_contains = Some(value.clone());
2741                    }
2742                    "object" => {
2743                        params.object_contains = Some(value.clone());
2744                    }
2745                    "content" | "summary" if params.query.is_none() => {
2746                        params.query = Some(value.clone());
2747                    }
2748                    _ => {}
2749                }
2750                Ok(())
2751            }
2752
2753            Condition::Or { .. } => {
2754                // OR is not representable in RecallParams (conjunctive), so
2755                // NOTHING inside it is pushed down — the whole subtree goes
2756                // to the residual filter, which evaluates it per grain with
2757                // real disjunction semantics (#91). Pushing only the left
2758                // branch, as this used to, silently narrowed `a OR b` to
2759                // `a`.
2760                Ok(())
2761            }
2762
2763            // IS CATEGORY expansion — desugar to relation_in using the mg: vocabulary.
2764            Condition::IsCategory {
2765                field, category, ..
2766            } => {
2767                if field == "relation" {
2768                    let relations = super::relations::expand_category(category);
2769                    if relations.is_empty() {
2770                        warnings.push(format!(
2771                            "Unknown relation category '{}'; no relations expanded.",
2772                            category
2773                        ));
2774                    } else {
2775                        // expand_category returns both mg: and plain variants
2776                        // (minimum 2 per category), so always use relation_in.
2777                        params.relation_in =
2778                            Some(relations.iter().map(|r| r.to_string()).collect());
2779                    }
2780                } else {
2781                    warnings.push(format!(
2782                        "CAL-W008: IS {} used on field '{}' — IS CATEGORY is only meaningful on the 'relation' field; this condition was ignored.",
2783                        category, field
2784                    ));
2785                }
2786                Ok(())
2787            }
2788
2789            // NOT, IsNull, IsNotNull, StartsWith — treated as pass-through.
2790            // The engine will do a broader recall; post-filters can be added
2791            // in Phase 2 when we have a richer filter DSL.
2792            _ => Ok(()),
2793        }
2794    }
2795
2796    // -----------------------------------------------------------------------
2797    // WITH options
2798    // -----------------------------------------------------------------------
2799
2800    fn apply_with_options(
2801        &self,
2802        options: &[WithOption],
2803        params: &mut RecallParams,
2804    ) -> std::result::Result<(), CalError> {
2805        for opt in options {
2806            match opt {
2807                WithOption::Superseded => {
2808                    params.exclude_superseded = Some(false);
2809                }
2810                WithOption::ScoreBreakdown => {
2811                    params.score_breakdown = Some(true);
2812                }
2813                WithOption::Explanation => {
2814                    params.explanation = Some(true);
2815                }
2816                WithOption::ContradictionDetection => {
2817                    params.detect_contradictions = Some(true);
2818                }
2819                WithOption::Diversity { lambda } => {
2820                    params.diversity = Some(if let Some(l) = lambda {
2821                        DiversityConfig::mmr_with_lambda(*l as f32)
2822                    } else {
2823                        DiversityConfig::mmr()
2824                    });
2825                }
2826
2827                // -- Previously parsed but ignored, now wired ----------------
2828                WithOption::Provenance => {
2829                    params.record_provenance = Some(true);
2830                }
2831                WithOption::Dedup { field: _ } => {
2832                    // Bug 5: argument is now a field name (spec EBNF); the
2833                    // underlying recall engine still uses similarity-based
2834                    // dedup with its default threshold. Per-field dedup is
2835                    // not yet wired through `RecallParams`.
2836                    params.deduplicate = Some(true);
2837                }
2838                // -- Recall feature flags (parity with HTTP/gRPC/MCP/A2A) ----
2839                // Rerank is a runtime seam in Areev (an installed
2840                // `RerankBackend`, not a cargo feature): always translate the
2841                // option; the facade no-ops it when no backend is installed.
2842                WithOption::Rerank { ref model } => {
2843                    let mut cfg = crate::store_types::RerankConfig::default();
2844                    if let Some(m) = model {
2845                        cfg.model = Some(m.clone());
2846                    }
2847                    params.rerank = Some(cfg);
2848                }
2849                // LLM-dependent refinements (Tier-3): Areev takes no LLM
2850                // dependency by policy, so these are honestly unavailable
2851                // rather than silently ignored. They live in the host's loop.
2852                WithOption::LlmRerank { .. } => {
2853                    return Err(CalError::LlmFeatureUnavailable { feature: "llm_rerank".into() });
2854                }
2855                WithOption::Hyde => {
2856                    return Err(CalError::LlmFeatureUnavailable { feature: "hyde".into() });
2857                }
2858                WithOption::QueryExpansion => {
2859                    params.query_expansion = Some(true);
2860                }
2861                WithOption::QueryDecompose => {
2862                    params.query_decompose = Some(true);
2863                }
2864                WithOption::ConflictResolution => {
2865                    params.conflict_resolution = Some(true);
2866                }
2867                WithOption::IncludeSources => {
2868                    params.include_sources = Some(true);
2869                }
2870                WithOption::AnnotateRelativeTime => {
2871                    params.annotate_relative_time = Some(true);
2872                }
2873                WithOption::RecencyWeight { weight } => {
2874                    params.recency_weight = Some(*weight);
2875                }
2876                WithOption::MinScore { score } => {
2877                    params.min_score = Some(*score);
2878                }
2879                WithOption::MultiHop { hops } => {
2880                    params.multi_hop = Some((*hops as u8).clamp(1, 3));
2881                }
2882                WithOption::SessionAffinity { boost } => {
2883                    params.session_affinity_boost = Some(boost.clamp(0.0, 1.0));
2884                }
2885                WithOption::SubjectAffinity { boost } => {
2886                    params.subject_affinity_boost = Some(boost.clamp(0.0, 1.0));
2887                }
2888                WithOption::SessionCoverage { min_per_ns } => {
2889                    params.min_per_namespace = Some((*min_per_ns as usize).clamp(1, 10));
2890                }
2891                WithOption::MaxNamespaces { max } => {
2892                    params.max_namespaces = Some((*max as usize).clamp(1, 100));
2893                }
2894                WithOption::Exhaustive { max_rounds } => {
2895                    let mut config = crate::store_types::ExhaustiveConfig::default();
2896                    if let Some(rounds) = max_rounds {
2897                        config.max_rounds = (*rounds as u8).clamp(1, 5);
2898                    }
2899                    config.validate();
2900                    params.exhaustive = Some(config);
2901                }
2902                WithOption::SessionCensus {
2903                    min_per_session,
2904                    min_score,
2905                } => {
2906                    let mut config = crate::store_types::SessionCensusConfig::default();
2907                    if let Some(mps) = min_per_session {
2908                        config.min_per_session = (*mps as u8).clamp(1, 10);
2909                    }
2910                    if let Some(ms) = min_score {
2911                        config.min_score = ms.clamp(0.0, 1.0);
2912                    }
2913                    config.validate();
2914                    params.session_census = Some(config);
2915                }
2916                WithOption::AggregationIntent => {
2917                    params.aggregation_intent = Some(true);
2918                }
2919
2920                WithOption::PreferenceEnrichment => {
2921                    params.preference_enrichment = Some(true);
2922                }
2923
2924                // OMS §4 WITH options accepted at parse time; runtime
2925                // semantics not yet wired into RecallParams. The parser
2926                // emits a CAL-W004 UnknownExtensionOption warning at
2927                // parse time (see `parse_with_option` arms) so the
2928                // caller knows the option is recognized syntactically
2929                // but is currently a no-op at the executor.
2930                WithOption::ProgressiveDisclosure { .. } => {}
2931                WithOption::Consistency { .. } => {}
2932                WithOption::Locale { .. } => {}
2933                WithOption::Cache { .. } => {}
2934
2935            }
2936        }
2937        Ok(())
2938    }
2939
2940    // -----------------------------------------------------------------------
2941    // Post-merge WITH options for multi-source ASSEMBLE
2942    // -----------------------------------------------------------------------
2943
2944    /// Apply query-level WITH options to an already-merged ASSEMBLE result.
2945    ///
2946    /// Multi-source ASSEMBLE delegates per-source retrieval to the AssembleEngine,
2947    /// which handles budget allocation, hash-dedup, and grain capping.  However,
2948    /// query-level WITH options (parsed from the CAL query's trailing WITH clause)
2949    /// were previously never applied to the merged result set.  This method
2950    /// closes that gap by applying **post-merge** operations.
2951    ///
2952    /// # Post-merge vs per-source options
2953    ///
2954    /// | Category        | Options                                                      |
2955    /// |-----------------|--------------------------------------------------------------|
2956    /// | Post-merge      | `conflict_resolution`, `dedup`, `min_score`, `rerank`,      |
2957    /// |                 | `llm_rerank`, `diversity`                                    |
2958    /// | Per-source only | `query_expansion`, `hyde`, `temporal_field`, `recency_weight`|
2959    /// | Both            | Per-source retrieval already applies all WITH options;       |
2960    /// |                 | this method re-applies post-merge operations on the combined |
2961    /// |                 | result set so cross-source conflicts/duplicates are handled. |
2962    ///
2963    /// `rerank` and `llm_rerank` are applied both per-source (each sub-RECALL
2964    /// is reranked individually) and post-merge (the merged set is reranked
2965    /// against the ASSEMBLE topic via `CalStoreFacade::rerank_passages()`).
2966    /// Post-merge reranking requires a non-empty `about_text` (the ASSEMBLE
2967    /// topic); a warning is emitted if the topic is empty.
2968    #[allow(unused_variables)] // warnings, store, about_text used only with rerank features
2969    #[allow(clippy::ptr_arg)] // Vec: pushed to inside #[cfg(feature = "rerank"/"llm-rerank")] blocks
2970    fn apply_assemble_post_merge_options(
2971        &self,
2972        payload: CalResultPayload,
2973        options: &[WithOption],
2974        warnings: &mut Vec<String>,
2975        store: &dyn CalStoreFacade,
2976        about_text: &str,
2977    ) -> std::result::Result<CalResultPayload, CalError> {
2978        // Only Assembled payloads have grains to post-process.
2979        let (mut grains, sources, total_tokens, budget_limit, _total_available) = match payload {
2980            CalResultPayload::Assembled {
2981                grains,
2982                sources,
2983                total_tokens,
2984                budget_limit,
2985                total_available,
2986                ..
2987            } => (grains, sources, total_tokens, budget_limit, total_available),
2988            other => return Ok(other),
2989        };
2990
2991        for opt in options {
2992            match opt {
2993                // ── conflict_resolution ──────────────────────────────────
2994                // Keep only the newest grain per (subject, relation) when
2995                // multiple grains across sources conflict (same key,
2996                // different object).
2997                WithOption::ConflictResolution => {
2998                    #[allow(clippy::type_complexity)]
2999                    let mut groups: HashMap<
3000                        (String, String),
3001                        Vec<(i64, usize, String)>,
3002                    > = HashMap::new();
3003                    for (idx, grain) in grains.iter().enumerate() {
3004                        let subj = grain
3005                            .fields
3006                            .get("subject")
3007                            .and_then(|v| v.as_str())
3008                            .unwrap_or("")
3009                            .to_string();
3010                        let rel = grain
3011                            .fields
3012                            .get("relation")
3013                            .and_then(|v| v.as_str())
3014                            .unwrap_or("")
3015                            .to_string();
3016                        if subj.is_empty() && rel.is_empty() {
3017                            continue;
3018                        }
3019                        let obj = grain
3020                            .fields
3021                            .get("object")
3022                            .and_then(|v| v.as_str())
3023                            .unwrap_or("")
3024                            .to_string();
3025                        let created = grain
3026                            .fields
3027                            .get("created_at")
3028                            .and_then(|v| v.as_i64())
3029                            .unwrap_or(0);
3030                        groups
3031                            .entry((subj, rel))
3032                            .or_default()
3033                            .push((created, idx, obj));
3034                    }
3035                    let mut remove_indices: std::collections::HashSet<usize> =
3036                        std::collections::HashSet::new();
3037                    for members in groups.values() {
3038                        if members.len() < 2 {
3039                            continue;
3040                        }
3041                        // Check if objects differ within this group.
3042                        let has_diff = members.windows(2).any(|w| w[0].2 != w[1].2);
3043                        if !has_diff {
3044                            continue;
3045                        }
3046                        // Keep the newest, remove the rest.
3047                        let best_idx = members
3048                            .iter()
3049                            .max_by_key(|(ts, _, _)| *ts)
3050                            .map(|(_, idx, _)| *idx)
3051                            .unwrap();
3052                        for (_, idx, _) in members {
3053                            if *idx != best_idx {
3054                                remove_indices.insert(*idx);
3055                            }
3056                        }
3057                    }
3058                    if !remove_indices.is_empty() {
3059                        let mut idx = 0;
3060                        grains.retain(|_| {
3061                            let keep = !remove_indices.contains(&idx);
3062                            idx += 1;
3063                            keep
3064                        });
3065                    }
3066                }
3067
3068                // ── dedup (threshold-based) ──────────────────────────────
3069                // Cross-source near-duplicate removal.  The AssembleEngine
3070                // already does hash-based dedup; this applies the softer
3071                // threshold-based dedup from WITH dedup (similarity threshold).
3072                // `WITH dedup(<field>)` keeps one grain per distinct value of
3073                // that field — the same rule the RECALL path applies, so the
3074                // option means the same thing wherever it is written. Without
3075                // a field it falls back to the similarity-based form.
3076                WithOption::Dedup { field: Some(field) } => {
3077                    let mut seen: std::collections::HashSet<String> =
3078                        std::collections::HashSet::new();
3079                    grains.retain(|grain| match json_field(&grain.fields, field) {
3080                        Some(v) => seen.insert(value_dedup_key(v)),
3081                        None => true,
3082                    });
3083                }
3084                WithOption::Dedup { field: None } => {
3085                    let threshold = 0.85_f64;
3086                    let mut seen_texts: Vec<String> = Vec::new();
3087                    grains.retain(|grain| {
3088                        let text = grain_result_text(grain);
3089                        for prev in &seen_texts {
3090                            if text_similarity(&text, prev) >= threshold {
3091                                return false;
3092                            }
3093                        }
3094                        seen_texts.push(text);
3095                        true
3096                    });
3097                }
3098
3099                // ── min_score ────────────────────────────────────────────
3100                // Drop grains below the score floor.  Per-source retrieval
3101                // already applies min_score, but grains may have been rescored
3102                // or normalised during merge.
3103                //
3104                // Deterministic-source grains (RECALL with no ABOUT) carry a
3105                // structural sentinel score, not a relevance signal — score-
3106                // based filtering would silently erase entire sources whose
3107                // selection was governed by PRIORITY/BUDGET, not semantics.
3108                WithOption::MinScore { score } => {
3109                    grains.retain(|g| g.is_deterministic || g.score >= *score);
3110                }
3111
3112                // ── diversity (score-based) ──────────────────────────────
3113                // Without vector embeddings we cannot do MMR diversity.
3114                // Re-sort by score descending so the most relevant grains
3115                // from each source are interleaved at the top.
3116                WithOption::Diversity { .. } => {
3117                    grains.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal));
3118                }
3119
3120                // ── rerank (cross-encoder) ───────────────────────────────
3121                // Post-merge cross-source reranking using the ASSEMBLE topic
3122                // as the rerank query. Delegates to CalStoreFacade::rerank_passages().
3123                #[cfg(feature = "rerank")]
3124                WithOption::Rerank { ref model } => {
3125                    if about_text.is_empty() {
3126                        warnings.push(
3127                            "WITH rerank on ASSEMBLE requires a topic — skipping \
3128                             post-merge reranking"
3129                                .into(),
3130                        );
3131                    } else if !grains.is_empty() {
3132                        let texts: Vec<String> = grains.iter().map(grain_result_text).collect();
3133                        let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
3134                        let user_id = self.config.user_id_override.as_deref();
3135                        let perm = store
3136                            .rerank_passages(
3137                                about_text,
3138                                &refs,
3139                                super::facade::RerankType::CrossEncoder,
3140                                model.as_deref(),
3141                                user_id,
3142                            )
3143                            .map_err(|e| CalError::BudgetExceeded {
3144                                detail: format!("post-merge reranking failed: {}", e),
3145                                span: None,
3146                            })?;
3147                        let mut reranked = Vec::with_capacity(grains.len());
3148                        for &i in &perm {
3149                            if let Some(g) = grains.get(i) {
3150                                reranked.push(g.clone());
3151                            }
3152                        }
3153                        grains = reranked;
3154                    }
3155                }
3156
3157                // ── llm_rerank (LLM listwise) ──────────────────────────────
3158                // Post-merge cross-source reranking via external LLM backend.
3159                #[cfg(feature = "llm-rerank")]
3160                WithOption::LlmRerank { ref model } => {
3161                    if about_text.is_empty() {
3162                        warnings.push(
3163                            "WITH llm_rerank on ASSEMBLE requires a topic — skipping \
3164                             post-merge reranking"
3165                                .into(),
3166                        );
3167                    } else if !grains.is_empty() {
3168                        let texts: Vec<String> = grains.iter().map(grain_result_text).collect();
3169                        let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
3170                        let user_id = self.config.user_id_override.as_deref();
3171                        let perm = store
3172                            .rerank_passages(
3173                                about_text,
3174                                &refs,
3175                                super::facade::RerankType::Llm,
3176                                model.as_deref(),
3177                                user_id,
3178                            )
3179                            .map_err(|e| CalError::BudgetExceeded {
3180                                detail: format!("post-merge LLM reranking failed: {}", e),
3181                                span: None,
3182                            })?;
3183                        let mut reranked = Vec::with_capacity(grains.len());
3184                        for &i in &perm {
3185                            if let Some(g) = grains.get(i) {
3186                                reranked.push(g.clone());
3187                            }
3188                        }
3189                        grains = reranked;
3190                    }
3191                }
3192
3193                // All other WITH options either:
3194                // - Are per-source (query_expansion, hyde, temporal_field, etc.)
3195                //   and already applied in execute_source via the surrogate query.
3196                // - Are display options (score_breakdown, explanation, etc.)
3197                //   already applied per-source.
3198                // - Are not applicable to ASSEMBLE (summarize, etc.)
3199                _ => {}
3200            }
3201        }
3202
3203        let count = grains.len();
3204        Ok(CalResultPayload::Assembled {
3205            grains,
3206            sources,
3207            total_tokens,
3208            budget_limit,
3209            progressive: false,
3210            total_available: Some(count),
3211        })
3212    }
3213
3214    // -----------------------------------------------------------------------
3215    // EXISTS
3216    // -----------------------------------------------------------------------
3217
3218    fn execute_exists(
3219        &self,
3220        exists: &ExistsStmt,
3221        store: &dyn CalStoreFacade,
3222        exec_warnings: &mut Vec<String>,
3223        let_values: &HashMap<String, Vec<String>>,
3224    ) -> std::result::Result<CalResultPayload, CalError> {
3225        // EXISTS by grain type and WHERE clause: recall with limit=1 and check count.
3226        let mut params = RecallParams::default();
3227
3228        if let Some(gt) = exists.grain_type.to_grain_type() {
3229            params.grain_type = Some(gt);
3230        }
3231
3232        if let Some(ref about) = exists.about {
3233            params.query = Some(about.text.clone());
3234        }
3235
3236        if let Some(ref where_clause) = exists.where_clause {
3237            self.apply_where_clause(&where_clause.condition, &mut params, exec_warnings, let_values)?;
3238        }
3239
3240        // Special case: if WHERE contains a hash comparison, look up directly.
3241        let hash_str =
3242            extract_hash_from_condition(exists.where_clause.as_ref().map(|w| &w.condition));
3243        if let Some(ref hs) = hash_str {
3244            let hash = Hash::from_hex(hs).map_err(|_| CalError::InvalidHash {
3245                found: hs.clone(),
3246                span: exists.span,
3247            })?;
3248            let found = store
3249                .exists(&hash)
3250                .map_err(|e| map_store_err(e, exists.span))?;
3251            return Ok(CalResultPayload::Exists {
3252                exists: found,
3253                hash: hs.clone(),
3254            });
3255        }
3256
3257        // #91 — the same refuse-or-honour contract as RECALL: anything
3258        // push-down did not consume must be evaluated per grain, so an
3259        // EXISTS with a residual filter scans wide and post-filters instead
3260        // of trusting the first hit of an under-filtered scan (which made
3261        // `EXISTS tools WHERE tool_name = "x"` true whenever ANY tool
3262        // grain existed).
3263        let residual_where = match exists.where_clause.as_ref() {
3264            Some(w) => plan_residual_where(&w.condition, &exists.grain_type, exec_warnings)?,
3265            None => None,
3266        };
3267
3268        // General case: recall with limit 1 to detect presence — widened to
3269        // the scan bound when a residual filter still has to run.
3270        params.limit = if residual_where.is_some() {
3271            Some(self.config.max_limit as usize)
3272        } else {
3273            Some(1)
3274        };
3275
3276        // Apply capability overrides (the pin also clears any IN-set scope).
3277        if let Some(ref ns) = self.config.namespace_override {
3278            params.namespace = Some(ns.clone());
3279            params.namespaces = None;
3280        }
3281        if let Some(ref uid) = self.config.user_id_override {
3282            params.user_id = Some(uid.clone());
3283        }
3284
3285        let hits = store
3286            .recall(&params)
3287            .map_err(|e| map_store_err(e, exists.span))?;
3288
3289        let (found, hash_out) = if let Some(ref residual) = residual_where {
3290            let grains = hits_to_grain_results(&hits);
3291            let first = grains
3292                .iter()
3293                .find(|g| grain_matches_condition_tree(g, residual));
3294            (first.is_some(), first.map(|g| g.hash.clone()).unwrap_or_default())
3295        } else {
3296            (
3297                !hits.is_empty(),
3298                hits.first().map(|h| h.hash.to_hex()).unwrap_or_default(),
3299            )
3300        };
3301
3302        Ok(CalResultPayload::Exists {
3303            exists: found,
3304            hash: hash_out,
3305        })
3306    }
3307
3308    // -----------------------------------------------------------------------
3309    // HISTORY
3310    // -----------------------------------------------------------------------
3311
3312    fn execute_history(
3313        &self,
3314        history: &HistoryStmt,
3315        store: &dyn CalStoreFacade,
3316        exec_warnings: &mut Vec<String>,
3317        let_values: &HashMap<String, Vec<String>>,
3318    ) -> std::result::Result<CalResultPayload, CalError> {
3319        // ── HISTORY DIFF path ──────────────────────────────────────────
3320        //
3321        // When `diff_target` is set, compare two grains field-by-field
3322        // and return a `CalResultPayload::Diff`.
3323        if let Some(ref diff_hash_str) = history.diff_target {
3324            let source_hash = Hash::from_hex(&history.hash).map_err(|_| CalError::InvalidHash {
3325                found: history.hash.clone(),
3326                span: history.span,
3327            })?;
3328            let target_hash = Hash::from_hex(diff_hash_str).map_err(|_| CalError::InvalidHash {
3329                found: diff_hash_str.clone(),
3330                span: history.span,
3331            })?;
3332
3333            // C2-07: If either grain cannot be retrieved (e.g. user was
3334            // erased, or grain does not exist), return an error.
3335            let grain_a = store.get(&source_hash).map_err(|e| match e {
3336                // Map "no such grain" to HashNotFound (CAL-E091) — distinct
3337                // from BudgetExceeded so callers can differentiate.
3338                AreevError::NotFound(_) => CalError::HashNotFound {
3339                    hash: history.hash.clone(),
3340                    span: history.span,
3341                },
3342                AreevError::CryptoError(_) => CalError::CryptoError {
3343                    detail: format!("DIFF source grain decrypt failed: {}", e),
3344                    span: history.span,
3345                },
3346                other => CalError::BudgetExceeded {
3347                    detail: format!("DIFF source grain error: {}", other),
3348                    span: history.span,
3349                },
3350            })?;
3351            let grain_b = store.get(&target_hash).map_err(|e| match e {
3352                // Mirror the DIFF source mapping above.
3353                AreevError::NotFound(_) => CalError::HashNotFound {
3354                    hash: diff_hash_str.clone(),
3355                    span: history.span,
3356                },
3357                AreevError::CryptoError(_) => CalError::CryptoError {
3358                    detail: format!("DIFF target grain decrypt failed: {}", e),
3359                    span: history.span,
3360                },
3361                other => CalError::BudgetExceeded {
3362                    detail: format!("DIFF target grain error: {}", other),
3363                    span: history.span,
3364                },
3365            })?;
3366
3367            // CAL-W005: Warn if subject+relation differ between grains.
3368            let sub_a = grain_a.get_str("subject").unwrap_or("");
3369            let sub_b = grain_b.get_str("subject").unwrap_or("");
3370            let rel_a = grain_a.get_str("relation").unwrap_or("");
3371            let rel_b = grain_b.get_str("relation").unwrap_or("");
3372            if sub_a != sub_b || rel_a != rel_b {
3373                exec_warnings.push(
3374                    "CAL-W005: DIFF targets have different subject/relation — diff may not be meaningful".to_string()
3375                );
3376            }
3377
3378            let changes = diff_grains(&grain_a, &grain_b);
3379            return Ok(CalResultPayload::Diff {
3380                source_hash: history.hash.clone(),
3381                target_hash: diff_hash_str.clone(),
3382                changes,
3383            });
3384        }
3385
3386        // ── HISTORY WHERE path ─────────────────────────────────────────
3387        //
3388        // When `where_clause` is present (and `hash` is empty), use the
3389        // WHERE clause to find matching grains via recall, then return
3390        // their version chains.
3391        if history.hash.is_empty() {
3392            if let Some(ref wc) = history.where_clause {
3393                let mut params = RecallParams::default();
3394                self.apply_where_clause(&wc.condition, &mut params, exec_warnings, let_values)?;
3395
3396                // #91 — HISTORY WHERE is untyped, so plan against the
3397                // wildcard: refuse engine-only fields in residual position,
3398                // honour everything else per grain instead of dropping it.
3399                let residual_where =
3400                    plan_residual_where(&wc.condition, &GrainTypePlural::All, exec_warnings)?;
3401
3402                // Apply capability overrides (the pin also clears any IN-set scope).
3403                if let Some(ref ns) = self.config.namespace_override {
3404                    params.namespace = Some(ns.clone());
3405                    params.namespaces = None;
3406                }
3407                if let Some(ref uid) = self.config.user_id_override {
3408                    params.user_id = Some(uid.clone());
3409                }
3410
3411                // Use a modest limit to find matching grains for history —
3412                // widened when a residual filter still has to select among
3413                // them.
3414                if params.limit.is_none() {
3415                    params.limit = Some(if residual_where.is_some() {
3416                        self.config.max_limit as usize
3417                    } else {
3418                        10
3419                    });
3420                }
3421
3422                let hits = store
3423                    .recall(&params)
3424                    .map_err(|e| map_store_err(e, history.span))?;
3425
3426                // Residual filter: keep only the grains the WHERE clause
3427                // actually selects (push-down alone may under-filter).
3428                let hits: Vec<crate::store_types::SearchHit> =
3429                    if let Some(ref residual) = residual_where {
3430                        let grains = hits_to_grain_results(&hits);
3431                        hits.into_iter()
3432                            .zip(grains)
3433                            .filter(|(_, g)| grain_matches_condition_tree(g, residual))
3434                            .map(|(h, _)| h)
3435                            .collect()
3436                    } else {
3437                        hits
3438                    };
3439
3440                // Collect version histories for all matching grains.
3441                let mut all_versions: Vec<CalVersionResult> = Vec::new();
3442                for hit in &hits {
3443                    let ns = hit.grain.get_str("namespace").unwrap_or("");
3444                    let subj = hit.grain.get_str("subject").unwrap_or("");
3445                    let rel = hit.grain.get_str("relation").unwrap_or("");
3446
3447                    if let Ok(entries) = store.get_history(ns, subj, rel) {
3448                        for v in entries {
3449                            // Avoid duplicates if multiple grains share
3450                            // the same (subject, relation) triple.
3451                            if !all_versions
3452                                .iter()
3453                                .any(|existing| existing.hash == v.hash.to_hex())
3454                            {
3455                                all_versions.push(CalVersionResult {
3456                                    hash: v.hash.to_hex(),
3457                                    object: v.object,
3458                                    created_at: v.created_at,
3459                                    confidence: v.confidence,
3460                                    superseded_by: v
3461                                        .superseded_by
3462                                        .map(|h: areev_core::error::Hash| h.to_hex()),
3463                                });
3464                            }
3465                        }
3466                    }
3467                }
3468
3469                return Ok(CalResultPayload::History {
3470                    versions: all_versions,
3471                });
3472            }
3473
3474            // Empty hash with no WHERE clause is an error.
3475            return Err(CalError::InvalidHash {
3476                found: String::new(),
3477                span: history.span,
3478            });
3479        }
3480
3481        // ── Standard HISTORY path (hash-based) ────────────────────────
3482
3483        // Parse hash from AST.
3484        let hash = Hash::from_hex(&history.hash).map_err(|_| CalError::InvalidHash {
3485            found: history.hash.clone(),
3486            span: history.span,
3487        })?;
3488
3489        // Fetch the grain to extract (namespace, subject, relation).
3490        let grain = match store.get(&hash) {
3491            Ok(g) => g,
3492            Err(AreevError::NotFound(_)) => {
3493                return Ok(CalResultPayload::Unsupported {
3494                    statement: "history".into(),
3495                    message: format!("grain not found: {}", history.hash),
3496                });
3497            }
3498            Err(other) => {
3499                return Err(map_store_err(other, history.span));
3500            }
3501        };
3502
3503        let namespace = grain.get_str("namespace").unwrap_or("");
3504        let subject = grain.get_str("subject").unwrap_or("");
3505        let relation = grain.get_str("relation").unwrap_or("");
3506
3507        let entries = store
3508            .get_history(namespace, subject, relation)
3509            .map_err(|e: areev_core::error::AreevError| map_store_err(e, history.span))?;
3510
3511        let versions: Vec<CalVersionResult> = entries
3512            .into_iter()
3513            .map(|v| CalVersionResult {
3514                hash: v.hash.to_hex(),
3515                object: v.object,
3516                created_at: v.created_at,
3517                confidence: v.confidence,
3518                superseded_by: v.superseded_by.map(|h: areev_core::error::Hash| h.to_hex()),
3519            })
3520            .collect();
3521
3522        Ok(CalResultPayload::History { versions })
3523    }
3524
3525    // -----------------------------------------------------------------------
3526    // DESCRIBE
3527    // -----------------------------------------------------------------------
3528
3529    fn execute_describe(
3530        &self,
3531        describe: &DescribeStmt,
3532        store: &dyn CalStoreFacade,
3533    ) -> std::result::Result<CalResultPayload, CalError> {
3534        let info = match &describe.target {
3535            DescribeTarget::Schema => serde_json::json!({
3536                "grain_types": [
3537                    "fact", "event", "state", "workflow", "tool", "recommendation",
3538                    "observation", "goal", "reasoning", "consensus", "consent", "skill"
3539                ],
3540                "common_fields": [
3541                    "subject", "relation", "object", "namespace", "user_id",
3542                    "created_at", "confidence", "importance", "tags",
3543                    "session_id", "content", "summary"
3544                ],
3545                "cal_version": 1,
3546                "tier1_enabled": self.config.tier1_enabled,
3547                "max_limit": self.config.max_limit,
3548                "default_limit": self.config.default_limit,
3549                "oms_version": "1.2",
3550                "pipeline_stages": [
3551                    "SELECT", "ORDER BY", "LIMIT", "OFFSET", "COUNT",
3552                    "FIRST", "SUBJECTS", "OBJECTS", "HASHES", "GROUP BY", "PROJECT"
3553                ],
3554                // What actually changes the result on a RECALL. The list used
3555                // to advertise `score_breakdown`, which is inert here (there
3556                // are no per-leg scores to break down) — DESCRIBE naming an
3557                // option that does nothing is worse than not naming it, since
3558                // it is what a client introspects to decide what to send.
3559                "with_options": [
3560                    "superseded", "explanation", "annotate_relative_time",
3561                    "conflict_resolution", "dedup", "contradiction_detection",
3562                    "diversity", "multi_hop", "provenance", "query_expansion",
3563                    "rerank"
3564                ]
3565            }),
3566            DescribeTarget::GrainType(gt) => {
3567                let type_name = gt.as_str();
3568                let specific_fields: &[&str] = match gt {
3569                    // OMS 1.6 §8.13. Typed and queryable — the reason a trigger
3570                    // is its own grain rather than an Observation carrying
3571                    // `int:` keys in a context map, which no query can filter.
3572                    GrainTypePlural::Triggers => &[
3573                        "kind",
3574                        "workflow",
3575                        "connector",
3576                        "scope",
3577                        "enabled",
3578                        "cron",
3579                    ],
3580                    GrainTypePlural::Facts => {
3581                        &["subject", "relation", "object", "confidence", "session_id"]
3582                    }
3583                    GrainTypePlural::Events => &[
3584                        "session_id",
3585                        "run_id",
3586                        "content",
3587                        "created_at",
3588                        "role",
3589                        "parent_message_id",
3590                        "model_id",
3591                        "stop_reason",
3592                    ],
3593                    // OMS §8.3. `checkpoint_data` never existed as a field, and
3594                    // `session_id` is Event-only.
3595                    // OMS 1.5 §8.12. `rec_status` is index-layer — filterable,
3596                    // never author-written.
3597                    GrainTypePlural::Recommendations => &[
3598                        "target_ref",
3599                        "analyzer",
3600                        "severity",
3601                        "dedup_key",
3602                        "rec_status",
3603                    ],
3604                    GrainTypePlural::States => &["context", "plan", "history"],
3605                    GrainTypePlural::Workflows => &[
3606                        "name",
3607                        "nodes",
3608                        "edges",
3609                        "bindings",
3610                        "retries",
3611                        "trigger",
3612                        "status",
3613                        "session_id",
3614                    ],
3615                    GrainTypePlural::Tools => &[
3616                        "tool",
3617                        "input",
3618                        "content",
3619                        "is_error",
3620                        "duration_ms",
3621                        "session_id",
3622                    ],
3623                    GrainTypePlural::Observations => &["sensor", "value", "unit", "session_id"],
3624                    GrainTypePlural::Goals => &[
3625                        "title",
3626                        "description",
3627                        "priority",
3628                        "status",
3629                        "parent_hash",
3630                        "session_id",
3631                    ],
3632                    GrainTypePlural::Reasonings => {
3633                        &["premises", "conclusion", "confidence", "session_id"]
3634                    }
3635                    GrainTypePlural::Consensuses => {
3636                        &["participants", "agreement", "confidence", "session_id"]
3637                    }
3638                    GrainTypePlural::Consents => &[
3639                        "user_id",
3640                        "scope",
3641                        "granted",
3642                        "expires_at",
3643                        "subject_did",
3644                        "grantee_did",
3645                        "session_id",
3646                    ],
3647                    GrainTypePlural::Skills => &[
3648                        "name",
3649                        "description",
3650                        "version",
3651                        "domain",
3652                        "holder_did",
3653                        "proficiency",
3654                        "transferable",
3655                        "practice_count",
3656                        "last_practiced_at",
3657                        "session_id",
3658                    ],
3659                    GrainTypePlural::All => &[],
3660                };
3661                serde_json::json!({
3662                    "grain_type": type_name,
3663                    "specific_fields": specific_fields,
3664                    // What the write path refuses to build the grain without.
3665                    // Without this a caller discovers the shape one VAL-E001 at
3666                    // a time — `skill` asks for `name`, then asks for
3667                    // `description` — with no way to ask up front.
3668                    "required_fields": crate::json_build::required_fields(
3669                        gt.to_grain_type().map(|t| t.as_str()).unwrap_or("")
3670                    ),
3671                    "common_fields": [
3672                        "namespace", "user_id", "created_at", "tags", "importance"
3673                    ]
3674                })
3675            }
3676
3677            // ── Phase 2 DESCRIBE targets ───────────────────────────────
3678            DescribeTarget::Capabilities => {
3679                let caps = store.describe_capabilities();
3680                serde_json::json!({
3681                    "cal_version": caps.cal_version,
3682                    "conformance_level": caps.conformance_level,
3683                    "supported_statements": caps.supported_statements,
3684                    "max_sources": caps.max_sources,
3685                    "max_let_bindings": caps.max_let_bindings,
3686                    "max_budget_tokens": caps.max_budget_tokens,
3687                    "tier1_enabled": self.config.tier1_enabled,
3688                    "oms_version": "1.2"
3689                })
3690            }
3691            DescribeTarget::Server => {
3692                let caps = store.describe_capabilities();
3693                serde_json::json!({
3694                    "name": "areev",
3695                    "version": env!("CARGO_PKG_VERSION"),
3696                    "cal_version": caps.cal_version,
3697                    "conformance_level": caps.conformance_level,
3698                    "oms_version": "1.2",
3699                    "build_features": build_features_list()
3700                })
3701            }
3702            DescribeTarget::Fields(opt_gt) => {
3703                let gt_engine = opt_gt.as_ref().and_then(|g| g.to_grain_type());
3704                let field_infos = store.describe_fields(gt_engine);
3705
3706                // If the facade returns data, use it; otherwise fall back
3707                // to the static field table for backward compatibility.
3708                if !field_infos.is_empty() {
3709                    let fields_json: Vec<serde_json::Value> = field_infos
3710                        .iter()
3711                        .map(|fi| {
3712                            serde_json::json!({
3713                                "name": fi.name,
3714                                "type": fi.field_type,
3715                                "filterable": fi.filterable,
3716                                "sortable": fi.sortable,
3717                            })
3718                        })
3719                        .collect();
3720                    if let Some(gt) = opt_gt {
3721                        serde_json::json!({
3722                            "grain_type": gt.as_str(),
3723                            "fields": fields_json
3724                        })
3725                    } else {
3726                        serde_json::json!({
3727                            "fields": fields_json
3728                        })
3729                    }
3730                } else {
3731                    // Static fallback. Common fields every recall honours…
3732                    let mut fields: Vec<serde_json::Value> = vec![
3733                        serde_json::json!({"name": "subject", "type": "string", "filterable": true, "sortable": true}),
3734                        serde_json::json!({"name": "relation", "type": "string", "filterable": true, "sortable": true}),
3735                        serde_json::json!({"name": "object", "type": "string", "filterable": true, "sortable": false}),
3736                        serde_json::json!({"name": "namespace", "type": "string", "filterable": true, "sortable": true}),
3737                        serde_json::json!({"name": "user_id", "type": "string", "filterable": true, "sortable": true}),
3738                        serde_json::json!({"name": "created_at", "type": "timestamp", "filterable": true, "sortable": true}),
3739                        serde_json::json!({"name": "confidence", "type": "number", "filterable": true, "sortable": true}),
3740                        serde_json::json!({"name": "importance", "type": "number", "filterable": true, "sortable": true}),
3741                        serde_json::json!({"name": "tags", "type": "array", "filterable": true, "sortable": false}),
3742                    ];
3743                    // …plus, for a typed DESCRIBE, exactly the registry's
3744                    // queryable set for that type (#91): every advertised
3745                    // field now genuinely filters — `WHERE` refuses what it
3746                    // cannot honour, so this list and the executor cannot
3747                    // drift apart. (`session_id` is here for the types that
3748                    // declare it, no longer advertised type-free.)
3749                    if let Some(gt) = gt_engine {
3750                        for name in areev_core::types::registry::meta(gt).queryable_fields {
3751                            if fields.iter().any(|f| f["name"] == *name) {
3752                                continue;
3753                            }
3754                            fields.push(serde_json::json!({
3755                                "name": name,
3756                                "type": "field",
3757                                "filterable": true,
3758                                "sortable": false,
3759                            }));
3760                        }
3761                    }
3762                    if let Some(gt) = opt_gt {
3763                        serde_json::json!({
3764                            "grain_type": gt.as_str(),
3765                            "fields": fields
3766                        })
3767                    } else {
3768                        serde_json::json!({
3769                            "fields": fields
3770                        })
3771                    }
3772                }
3773            }
3774            DescribeTarget::Templates => {
3775                let templates = store.list_templates();
3776                let template_list: Vec<serde_json::Value> = templates
3777                    .iter()
3778                    .map(|t| {
3779                        serde_json::json!({
3780                            "name": t.name,
3781                            "description": t.description,
3782                            "builtin": t.builtin,
3783                            "parent": t.parent,
3784                        })
3785                    })
3786                    .collect();
3787                serde_json::json!({ "templates": template_list })
3788            }
3789            DescribeTarget::Queries => {
3790                let queries = store.list_queries();
3791                serde_json::json!({
3792                    "queries": queries,
3793                })
3794            }
3795            DescribeTarget::Query(name) => {
3796                let entry = store
3797                    .get_query(name)
3798                    .ok_or_else(|| CalError::QueryNotFound {
3799                        name: name.clone(),
3800                        span: None,
3801                    })?;
3802                serde_json::json!({
3803                    "name": name,
3804                    "description": entry.description,
3805                    "builtin": entry.builtin,
3806                    "params": entry.params.iter().map(|p| {
3807                        serde_json::json!({
3808                            "name": p.name,
3809                            "required": p.default.is_none(),
3810                            "default": p.default.as_ref().map(|v| match v {
3811                                super::ast::Value::String { value } => value.clone(),
3812                                super::ast::Value::Number { value } => value.to_string(),
3813                                super::ast::Value::Boolean { value } => value.to_string(),
3814                                other => format!("{}", other),
3815                            }),
3816                        })
3817                    }).collect::<Vec<_>>(),
3818                    "body": entry.body,
3819                    "body_size": entry.body.len(),
3820                })
3821            }
3822            // The self-discovery read (CAL 1.3 §8.15): what may this
3823            // principal do, per namespace. Empty grants = the fail-closed
3824            // answer, stated rather than implied.
3825            DescribeTarget::Principal(name) => {
3826                let rows = store.cal_show_grants(Some(name)).map_err(|e| {
3827                    let detail = e.to_string();
3828                    if detail.contains("AUT-E") {
3829                        CalError::NotAuthorized { detail, span: None }
3830                    } else {
3831                        CalError::InvalidQuery { detail, span: None }
3832                    }
3833                })?;
3834                let grants: Vec<serde_json::Value> = rows
3835                    .iter()
3836                    .filter_map(|row| {
3837                        let g =
3838                            areev_core::authz::Grant::from_object_string(&row.object).ok()?;
3839                        Some(serde_json::json!({
3840                            "verbs": g.verbs.iter().map(|v| v.as_str()).collect::<Vec<_>>(),
3841                            "namespaces": g.namespaces,
3842                            "hash": row.hash,
3843                        }))
3844                    })
3845                    .collect();
3846                serde_json::json!({
3847                    "principal": name,
3848                    "grants": grants,
3849                    "note": if grants.is_empty() {
3850                        "no live grants — this principal can do nothing in a bound session"
3851                    } else {
3852                        "rights shown are the live grant heads; owner sessions bypass grants"
3853                    },
3854                })
3855            }
3856            // The loop reads (CAL 1.3 §8.16) — delegated to the governance
3857            // host; unwired surfaces say so instead of pretending.
3858            DescribeTarget::Loop
3859            | DescribeTarget::Analyzers
3860            | DescribeTarget::Outcomes
3861            | DescribeTarget::LoopPolicy => {
3862                let what = match &describe.target {
3863                    DescribeTarget::Loop => crate::governance::LoopInfo::Loop,
3864                    DescribeTarget::Analyzers => crate::governance::LoopInfo::Analyzers,
3865                    DescribeTarget::Outcomes => crate::governance::LoopInfo::Outcomes,
3866                    _ => crate::governance::LoopInfo::Policy,
3867                };
3868                match &self.governance {
3869                    None => serde_json::json!({
3870                        "error": "governance is not wired on this surface — no \
3871                                  GovernanceHost attached",
3872                    }),
3873                    Some(host) => host.describe(store, what).map_err(|e| {
3874                        let detail = e.to_string();
3875                        if detail.contains("AUT-E") {
3876                            CalError::NotAuthorized { detail, span: None }
3877                        } else {
3878                            CalError::InvalidQuery { detail, span: None }
3879                        }
3880                    })?,
3881                }
3882            }
3883            DescribeTarget::Stats | DescribeTarget::Integrity => {
3884                let res = if matches!(&describe.target, DescribeTarget::Stats) {
3885                    store.cal_stats()
3886                } else {
3887                    store.cal_verify()
3888                };
3889                res.map_err(|e| {
3890                    let detail = e.to_string();
3891                    if detail.contains("AUT-E") {
3892                        CalError::NotAuthorized { detail, span: None }
3893                    } else {
3894                        CalError::InvalidQuery { detail, span: None }
3895                    }
3896                })?
3897            }
3898            DescribeTarget::Grammar => serde_json::json!({
3899                "grammar": "CAL/1 grammar (simplified BNF)",
3900                "version": 1,
3901                "conformance_level": 2,
3902                "features": [
3903                    "RECALL", "EXISTS", "ASSEMBLE", "HISTORY", "HISTORY DIFF",
3904                    "EXPLAIN", "DESCRIBE", "BATCH", "COALESCE",
3905                    "SET operations (UNION, INTERSECT, EXCEPT)",
3906                    "Pipeline stages (SELECT, ORDER BY, LIMIT, OFFSET, COUNT, FIRST, SUBJECTS, OBJECTS, HASHES, GROUP BY, PROJECT)",
3907                    "WITH options (superseded, score_breakdown, explanation, contradiction_detection, diversity)",
3908                    "LET bindings", "WHERE clause", "ABOUT semantic search",
3909                    "SINCE / BETWEEN temporal filters", "RECENT shorthand"
3910                ],
3911                "url": "https://github.com/AreevAI/areev"
3912            }),
3913        };
3914
3915        Ok(CalResultPayload::Describe { info })
3916    }
3917
3918    // -----------------------------------------------------------------------
3919    // EXPLAIN
3920    // -----------------------------------------------------------------------
3921
3922    fn execute_explain(
3923        &self,
3924        explain: &ExplainStmt,
3925        store: &dyn CalStoreFacade,
3926        query: &CalQuery,
3927    ) -> std::result::Result<CalResultPayload, CalError> {
3928        let inner = explain.inner.as_ref();
3929        let stmt_type = statement_type_name(inner);
3930
3931        let (grain_type, query_routing, index_usage, mut filters) = match inner {
3932            CalStatement::Recall(recall) => {
3933                let gt = recall
3934                    .grain_type
3935                    .to_grain_type()
3936                    .map(|g| g.as_str().to_string());
3937
3938                let mut index_usage = Vec::new();
3939                let mut filters = Vec::new();
3940
3941                // `LIKE` and `ABOUT` are the same free-text leg at execution —
3942                // both set `RecallParams::query` (see `build_recall_params`).
3943                // Reporting only `ABOUT` made EXPLAIN describe a `LIKE` recall
3944                // as a structural `O(n) full scan` with no index, which is the
3945                // opposite of what it runs. A planner that misreports is worse
3946                // than one that says nothing.
3947                let free_text = recall.about.is_some() || recall.like.is_some();
3948
3949                if free_text {
3950                    index_usage.push("bm25_fts".to_string());
3951                }
3952
3953                if let Some(ref wc) = recall.where_clause {
3954                    collect_filter_names(&wc.condition, &mut filters);
3955                    // If WHERE has subject/relation/object: structural index.
3956                    if filters
3957                        .iter()
3958                        .any(|f| f == "subject" || f == "relation" || f == "object")
3959                    {
3960                        index_usage.push("hexastore".to_string());
3961                    }
3962                }
3963
3964                if recall.since.is_some() || recall.until.is_some() || recall.between.is_some() {
3965                    filters.push("temporal".to_string());
3966                }
3967
3968                // S-5: list filter names only, not values.
3969                let routing = if free_text && !index_usage.contains(&"hexastore".to_string()) {
3970                    "bm25".to_string()
3971                } else if free_text {
3972                    "hybrid_rrf".to_string()
3973                } else {
3974                    "structural".to_string()
3975                };
3976
3977                (gt, routing, index_usage, filters)
3978            }
3979            CalStatement::Exists(exists) => {
3980                let gt = exists
3981                    .grain_type
3982                    .to_grain_type()
3983                    .map(|g| g.as_str().to_string());
3984                let routing = "structural".to_string();
3985                let index_usage = vec!["hexastore".to_string()];
3986                let mut filters = Vec::new();
3987                if let Some(ref wc) = exists.where_clause {
3988                    collect_filter_names(&wc.condition, &mut filters);
3989                }
3990                (gt, routing, index_usage, filters)
3991            }
3992            CalStatement::History(h) => {
3993                let mut filters = vec!["hash".to_string()];
3994                if h.diff_target.is_some() {
3995                    filters.push("diff_target".to_string());
3996                }
3997                if h.where_clause.is_some() {
3998                    filters.push("where_clause".to_string());
3999                }
4000                (
4001                    None,
4002                    if h.diff_target.is_some() {
4003                        "diff_comparison".to_string()
4004                    } else {
4005                        "entity_latest".to_string()
4006                    },
4007                    vec![
4008                        "entity_latest".to_string(),
4009                        "supersession_chain".to_string(),
4010                    ],
4011                    filters,
4012                )
4013            }
4014            CalStatement::Assemble(assemble) => {
4015                let mut index_usage = Vec::new();
4016                let mut filters = Vec::new();
4017
4018                // Determine source count for multi-source plan.
4019                let source_count = assemble.sources.as_ref().map_or(1, |s| s.len());
4020                filters.push(format!("sources: {}", source_count));
4021
4022                if assemble.budget.is_some() {
4023                    filters.push("budget_allocation".to_string());
4024                }
4025                for opt in &assemble.assemble_with {
4026                    let super::ast::AssembleWithOption::Dedup { .. } = opt;
4027                    filters.push("dedup".to_string());
4028                }
4029                index_usage.push("bm25_fts".to_string());
4030                index_usage.push("hexastore".to_string());
4031
4032                (
4033                    None,
4034                    format!("multi_source_assemble({}_sources)", source_count),
4035                    index_usage,
4036                    filters,
4037                )
4038            }
4039            CalStatement::Coalesce(coalesce) => {
4040                let gt = coalesce
4041                    .grain_type
4042                    .to_grain_type()
4043                    .map(|g| g.as_str().to_string());
4044                let branch_count = if coalesce.branches.is_empty() {
4045                    1
4046                } else {
4047                    coalesce.branches.len()
4048                };
4049                let has_else = coalesce.else_branch.is_some();
4050                let mut filters = vec![format!("fallback_chain: {} branches", branch_count)];
4051                if has_else {
4052                    filters.push("else_fallback".to_string());
4053                }
4054                (
4055                    gt,
4056                    "coalesce_fallback".to_string(),
4057                    vec!["bm25_fts".to_string(), "hexastore".to_string()],
4058                    filters,
4059                )
4060            }
4061            CalStatement::Batch(batch) => {
4062                let stmt_count = batch.statements.len();
4063                let has_labels = batch.labeled.is_some();
4064                let has_formats = batch.statements.iter().any(|e| e.format.is_some());
4065                let has_pipelines = batch.statements.iter().any(|e| !e.pipeline.is_empty());
4066                let mut filters = vec![format!("parallel_execution: {} statements", stmt_count)];
4067                if has_labels {
4068                    filters.push("labeled_results".to_string());
4069                }
4070                if has_formats {
4071                    filters.push("per_entry_format".to_string());
4072                }
4073                if has_pipelines {
4074                    filters.push("per_entry_pipeline".to_string());
4075                }
4076                (None, "parallel_batch".to_string(), vec![], filters)
4077            }
4078            CalStatement::Describe(_) => (None, "introspection".to_string(), vec![], vec![]),
4079            CalStatement::ReportSubject(rs) => {
4080                let mut filters = vec![format!("subject: {}", rs.subject_id)];
4081                if rs.text_mentions {
4082                    filters.push("text_mentions".to_string());
4083                }
4084                (
4085                    None,
4086                    "subject_report".to_string(),
4087                    vec!["terms_dictionary".to_string(), "fts_postings".to_string()],
4088                    filters,
4089                )
4090            }
4091            CalStatement::Purge(purge) => {
4092                let mut filters = vec![];
4093                if let Some(age) = purge.min_age_days {
4094                    filters.push(format!("min_age_days: {age}"));
4095                }
4096                if let Some(ref ns) = purge.namespace {
4097                    filters.push(format!("namespace: {ns}"));
4098                }
4099                if let Some(lim) = purge.limit {
4100                    filters.push(format!("batch_limit: {lim}"));
4101                }
4102                (
4103                    None,
4104                    "destructive_purge_stale".to_string(),
4105                    vec!["blobs_partition".to_string(), "decay_engine".to_string()],
4106                    filters,
4107                )
4108            }
4109            CalStatement::Forget(forget) => {
4110                let target_desc = match &forget.target {
4111                    super::ast::ForgetTarget::Hash { hash } => format!("hash: {hash}"),
4112                    super::ast::ForgetTarget::User { user_id } => format!("user: {user_id}"),
4113                    super::ast::ForgetTarget::Scope { scope } => format!("scope: {scope}"),
4114                };
4115                let indexes = match &forget.target {
4116                    super::ast::ForgetTarget::Hash { .. } => vec!["blobs_partition".to_string()],
4117                    _ => vec!["blobs_partition".to_string(), "key_store".to_string()],
4118                };
4119                (
4120                    None,
4121                    "destructive_forget".to_string(),
4122                    indexes,
4123                    vec![target_desc],
4124                )
4125            }
4126            CalStatement::DefineTemplate(def) => (
4127                None,
4128                "template_registry_write".to_string(),
4129                vec!["meta_partition".to_string()],
4130                vec![format!("template_name: {}", def.name)],
4131            ),
4132            CalStatement::DropTemplate(drop) => (
4133                None,
4134                "template_registry_delete".to_string(),
4135                vec!["meta_partition".to_string()],
4136                vec![format!("template_name: {}", drop.name)],
4137            ),
4138            CalStatement::DefineQuery(def) => (
4139                None,
4140                "query_registry_write".to_string(),
4141                vec!["meta_partition".to_string()],
4142                vec![format!("query_name: {}", def.name)],
4143            ),
4144            CalStatement::DropQuery(drop) => (
4145                None,
4146                "query_registry_delete".to_string(),
4147                vec!["meta_partition".to_string()],
4148                vec![format!("query_name: {}", drop.name)],
4149            ),
4150            CalStatement::RunQuery(run) => (
4151                None,
4152                "saved_query_execute".to_string(),
4153                vec!["meta_partition".to_string()],
4154                vec![format!("query_name: {}", run.name)],
4155            ),
4156            _ => (None, "unknown".to_string(), vec![], vec![]),
4157        };
4158
4159        // Build pipeline step descriptions.
4160        let mut pipeline_steps = vec![format!("execute_{}", stmt_type)];
4161        for stage in &query.pipeline {
4162            pipeline_steps.push(pipeline_stage_name(stage));
4163        }
4164
4165        // ── Active policy filters (WI-1.5) ───────────────────────────────
4166        //
4167        // Report namespace/user scoping and auth constraints so the caller
4168        // can understand which policy filters are active for this query.
4169        let mut policy_filters = Vec::new();
4170
4171        if self.config.namespace_override.is_some() {
4172            policy_filters.push("namespace_override (capability token)".to_string());
4173        } else if store.default_namespace().is_some() {
4174            policy_filters.push("namespace_scope (session)".to_string());
4175        }
4176
4177        if self.config.user_id_override.is_some() {
4178            policy_filters.push("user_id_override (capability token)".to_string());
4179        } else if store.active_user().is_some() {
4180            policy_filters.push("user_id_scope (session)".to_string());
4181        }
4182
4183        if !self.config.tier1_enabled {
4184            policy_filters.push("tier1_disabled (read-only mode)".to_string());
4185        }
4186
4187        // Append policy filters to the main filters list.
4188        filters.extend(policy_filters);
4189
4190        // Check if the namespace oracle knows the default.
4191        let ns_context = if self.config.namespace_override.is_some() {
4192            "(namespace: from capability token)"
4193        } else if store.default_namespace().is_some() {
4194            "(namespace: from session)"
4195        } else {
4196            "(namespace: not set)"
4197        };
4198
4199        let cost = match index_usage.len() {
4200            0 => "O(n) full scan",
4201            1 => "O(log n) index lookup",
4202            _ => "O(log n) hybrid with RRF fusion",
4203        };
4204
4205        Ok(CalResultPayload::Explain {
4206            plan: CalQueryPlan {
4207                statement_type: stmt_type,
4208                grain_type,
4209                query_routing,
4210                index_usage,
4211                estimated_cost: format!("{} {}", cost, ns_context),
4212                filters,
4213                pipeline: pipeline_steps,
4214            },
4215        })
4216    }
4217
4218    // -----------------------------------------------------------------------
4219    // BATCH
4220    // -----------------------------------------------------------------------
4221
4222    fn execute_batch(
4223        &self,
4224        batch: &BatchStmt,
4225        store: &dyn CalStoreFacade,
4226        exec_warnings: &mut Vec<String>,
4227    ) -> std::result::Result<CalResultPayload, CalError> {
4228        // ── Phase 2: Labeled BATCH path ──────────────────────────────────
4229        //
4230        // When `batch.labeled` is Some, process labeled entries and return
4231        // results keyed by label. Duplicate labels produce CAL-E034.
4232        if let Some(ref labeled) = batch.labeled {
4233            let mut results: HashMap<String, CalResultPayload> = HashMap::new();
4234            let mut seen_labels: std::collections::HashSet<String> =
4235                std::collections::HashSet::new();
4236
4237            for (label, entry) in labeled {
4238                // Check for duplicate labels (CAL-E034).
4239                if !seen_labels.insert(label.clone()) {
4240                    return Err(CalError::AssembleDuplicateLabel {
4241                        label: label.clone(),
4242                        span: batch.span,
4243                    });
4244                }
4245
4246                let payload = self.execute_batch_entry(entry, store, exec_warnings)?;
4247                results.insert(label.clone(), payload);
4248            }
4249
4250            return Ok(CalResultPayload::Batch { results });
4251        }
4252
4253        // ── Positional BATCH path ────────────────────────────────────────
4254        let mut results: HashMap<String, CalResultPayload> = HashMap::new();
4255
4256        for (idx, entry) in batch.statements.iter().enumerate() {
4257            let payload = self.execute_batch_entry(entry, store, exec_warnings)?;
4258            results.insert(idx.to_string(), payload);
4259        }
4260
4261        Ok(CalResultPayload::Batch { results })
4262    }
4263
4264    /// Execute a single `BatchEntry`: run statement, apply pipeline, apply FORMAT.
4265    fn execute_batch_entry(
4266        &self,
4267        entry: &BatchEntry,
4268        store: &dyn CalStoreFacade,
4269        exec_warnings: &mut Vec<String>,
4270    ) -> std::result::Result<CalResultPayload, CalError> {
4271        let surrogate_query = crate::ast::CalQuery {
4272            version: crate::ast::CalVersion(1),
4273            statement: entry.statement.clone(),
4274            pipeline: entry.pipeline.clone(),
4275            with_options: entry.with_options.clone(),
4276            format: entry.format.clone(),
4277            let_bindings: Vec::new(),
4278            let_values: HashMap::new(),
4279            user_vars: entry.user_vars.clone(),
4280            warnings: Vec::new(),
4281        };
4282
4283        let payload = self
4284            .execute_statement(&entry.statement, store, &surrogate_query, exec_warnings)
4285            .unwrap_or_else(|e| CalResultPayload::Unsupported {
4286                statement: statement_type_name(&entry.statement),
4287                message: e.to_string(),
4288            });
4289
4290        // Apply pipeline stages (SELECT, LIMIT, ORDER BY, WHERE, etc.).
4291        let (payload, grouped_by) = if entry.pipeline.is_empty() {
4292            (payload, None)
4293        } else {
4294            self.apply_pipeline(payload, &entry.pipeline, exec_warnings)?
4295        };
4296
4297        // Apply FORMAT clause if present.
4298        let payload = apply_format_clause(
4299            payload,
4300            &entry.format,
4301            grouped_by.as_deref(),
4302            RenderInputs {
4303                user_vars: &entry.user_vars,
4304                store,
4305                disclosure: disclosure_of(&entry.with_options),
4306            },
4307            None,
4308            exec_warnings,
4309        )?;
4310
4311        Ok(payload)
4312    }
4313
4314    // -----------------------------------------------------------------------
4315    // COALESCE
4316    // -----------------------------------------------------------------------
4317
4318    fn execute_coalesce(
4319        &self,
4320        coalesce: &CoalesceStmt,
4321        store: &dyn CalStoreFacade,
4322        query: &CalQuery,
4323        exec_warnings: &mut Vec<String>,
4324    ) -> std::result::Result<CalResultPayload, CalError> {
4325        // ── Phase 2: Multi-branch COALESCE ────────────────────────────────
4326        //
4327        // When `branches` is non-empty, try each branch in order.
4328        // Return the first non-empty result. Short-circuit remaining branches.
4329        // If all branches are empty, try the ELSE branch.
4330        if !coalesce.branches.is_empty() {
4331            for (i, branch) in coalesce.branches.iter().enumerate() {
4332                let surrogate = CalQuery {
4333                    version: query.version,
4334                    statement: branch.query.clone(),
4335                    pipeline: Vec::new(),
4336                    with_options: query.with_options.clone(),
4337                    format: None,
4338                    let_bindings: Vec::new(),
4339                    let_values: query.let_values.clone(),
4340                    user_vars: HashMap::new(),
4341                    warnings: Vec::new(),
4342                };
4343
4344                let payload =
4345                    self.execute_statement(&surrogate.statement, store, &surrogate, exec_warnings)?;
4346
4347                // Check if result is non-empty — short-circuit on first hit.
4348                let is_non_empty = match &payload {
4349                    CalResultPayload::Grains { grains, .. } => !grains.is_empty(),
4350                    CalResultPayload::Exists { exists, .. } => *exists,
4351                    CalResultPayload::History { versions } => !versions.is_empty(),
4352                    CalResultPayload::Count { count } => *count > 0,
4353                    _ => true,
4354                };
4355
4356                if is_non_empty {
4357                    exec_warnings.push(format!(
4358                        "COALESCE: branch {} returned results; short-circuited {} remaining branch(es)",
4359                        i + 1,
4360                        coalesce.branches.len() - i - 1
4361                            + if coalesce.else_branch.is_some() { 1 } else { 0 }
4362                    ));
4363                    return Ok(payload);
4364                }
4365            }
4366
4367            // All branches empty — try ELSE branch if present.
4368            if let Some(ref else_stmt) = coalesce.else_branch {
4369                let surrogate = CalQuery {
4370                    version: query.version,
4371                    statement: *else_stmt.clone(),
4372                    pipeline: Vec::new(),
4373                    with_options: query.with_options.clone(),
4374                    format: None,
4375                    let_bindings: Vec::new(),
4376                    let_values: query.let_values.clone(),
4377                    user_vars: HashMap::new(),
4378                    warnings: Vec::new(),
4379                };
4380
4381                return self.execute_statement(
4382                    &surrogate.statement,
4383                    store,
4384                    &surrogate,
4385                    exec_warnings,
4386                );
4387            }
4388
4389            // All branches empty, no ELSE — return empty grains.
4390            return Ok(CalResultPayload::Grains {
4391                grains: Vec::new(),
4392                total_available: Some(0),
4393            });
4394        }
4395
4396        // ── Phase 1: Single-branch COALESCE (backward compat) ────────────
4397        //
4398        // Treat as RECALL with the coalesce grain type and where clause.
4399        let recall = RecallStmt {
4400            grain_type: coalesce.grain_type.clone(),
4401            about: None,
4402            where_clause: coalesce.where_clause.clone(),
4403            recent: None,
4404            since: None,
4405            until: None,
4406            like: None,
4407            between: None,
4408            contradictions: None,
4409            limit: None,
4410            as_format: None,
4411            span: coalesce.span,
4412        };
4413
4414        let payload = self.execute_recall(&recall, store, query, exec_warnings)?;
4415
4416        // Return the first grain only (coalesce semantics: stop at first hit).
4417        match payload {
4418            CalResultPayload::Grains { mut grains, .. } => {
4419                grains.truncate(1);
4420                let count = grains.len();
4421                Ok(CalResultPayload::Grains {
4422                    grains,
4423                    total_available: Some(count),
4424                })
4425            }
4426            other => Ok(other),
4427        }
4428    }
4429
4430    // -----------------------------------------------------------------------
4431    // SET OPERATIONS (UNION / INTERSECT / EXCEPT)
4432    // -----------------------------------------------------------------------
4433
4434    fn execute_set_op(
4435        &self,
4436        set_op: &SetOpStmt,
4437        store: &dyn CalStoreFacade,
4438        query: &CalQuery,
4439        exec_warnings: &mut Vec<String>,
4440    ) -> std::result::Result<CalResultPayload, CalError> {
4441        // Execute each operand independently.
4442        let mut operand_results: Vec<Vec<CalGrainResult>> = Vec::new();
4443
4444        for stmt in &set_op.operands {
4445            let surrogate = crate::ast::CalQuery {
4446                version: query.version,
4447                statement: stmt.clone(),
4448                pipeline: Vec::new(),
4449                with_options: query.with_options.clone(),
4450                format: None,
4451                let_bindings: Vec::new(),
4452                let_values: query.let_values.clone(),
4453                user_vars: HashMap::new(),
4454                warnings: Vec::new(),
4455            };
4456            let payload = self.execute_statement(stmt, store, &surrogate, exec_warnings)?;
4457            let grains = extract_grains(payload);
4458            operand_results.push(grains);
4459        }
4460
4461        if operand_results.is_empty() {
4462            return Ok(CalResultPayload::Grains {
4463                grains: Vec::new(),
4464                total_available: Some(0),
4465            });
4466        }
4467
4468        let mut result = operand_results.remove(0);
4469
4470        for next in operand_results {
4471            result = match set_op.op {
4472                SetOp::Union => union_grains(result, next),
4473                SetOp::Intersect => intersect_grains(result, &next),
4474                SetOp::Except => except_grains(result, &next),
4475            };
4476        }
4477
4478        let count = result.len();
4479        Ok(CalResultPayload::Grains {
4480            grains: result,
4481            total_available: Some(count),
4482        })
4483    }
4484
4485    // -----------------------------------------------------------------------
4486    // ASSEMBLE
4487    // -----------------------------------------------------------------------
4488
4489    fn sample_assembly_manifest(&self) -> bool {
4490        let rate = self.config.assembly_manifest_sample_rate.clamp(0.0, 1.0);
4491        if rate <= 0.0 {
4492            return false;
4493        }
4494        if rate >= 1.0 {
4495            return true;
4496        }
4497        use std::sync::atomic::{AtomicU64, Ordering};
4498        static SAMPLE_SEQ: AtomicU64 = AtomicU64::new(0);
4499        // Golden-ratio stepping distributes samples without pulling a random
4500        // generator into the dependency-light CAL crate.
4501        let n = SAMPLE_SEQ.fetch_add(1, Ordering::Relaxed);
4502        let bucket = n.wrapping_mul(0x9e3779b97f4a7c15) % 1_000_000;
4503        bucket < (rate * 1_000_000.0) as u64
4504    }
4505
4506    // Keeping every measured value explicit makes the call site an auditable
4507    // inventory of what the manifest commits to; this is internal and sampled.
4508    #[allow(clippy::too_many_arguments)]
4509    fn record_assembly_manifest(
4510        &self,
4511        assemble: &AssembleStmt,
4512        store: &dyn CalStoreFacade,
4513        candidates: Vec<String>,
4514        included: Vec<String>,
4515        payload: &CalResultPayload,
4516        budget: serde_json::Value,
4517        sources: serde_json::Value,
4518    ) {
4519        if !self.sample_assembly_manifest() {
4520            return;
4521        }
4522        let included_set: std::collections::HashSet<&str> =
4523            included.iter().map(String::as_str).collect();
4524        let dropped_hashes = candidates
4525            .into_iter()
4526            .filter(|h| !included_set.contains(h.as_str()))
4527            .collect();
4528        let rendered = match payload {
4529            CalResultPayload::Formatted { text, .. } => text.as_bytes().to_vec(),
4530            CalResultPayload::MultiFormatted { formats, .. } => {
4531                let sorted: std::collections::BTreeMap<&str, &str> = formats
4532                    .iter()
4533                    .map(|(k, v)| (k.as_str(), v.as_str()))
4534                    .collect();
4535                serde_json::to_vec(&sorted).unwrap_or_default()
4536            }
4537            _ => serde_json::to_vec(payload).unwrap_or_default(),
4538        };
4539        let mut digest = Sha256::new();
4540        digest.update(&rendered);
4541        // Digest the statement, never store it: an ASSEMBLE routinely names its
4542        // subject, and this manifest is immutable, replicating, and sits in a
4543        // namespace the erasure selector does not reach.
4544        let mut query_digest = Sha256::new();
4545        query_digest.update(
4546            serde_json::to_vec(assemble)
4547                .unwrap_or_default()
4548                .as_slice(),
4549        );
4550        store.note_assembly_manifest(&AssemblyManifest {
4551            query_sha256: hex::encode(query_digest.finalize()),
4552            included_hashes: included,
4553            rendered_sha256: hex::encode(digest.finalize()),
4554            budget,
4555            dropped_hashes,
4556            sources,
4557        });
4558    }
4559
4560    fn execute_assemble(
4561        &self,
4562        assemble: &AssembleStmt,
4563        store: &dyn CalStoreFacade,
4564        query: &CalQuery,
4565        exec_warnings: &mut Vec<String>,
4566    ) -> std::result::Result<CalResultPayload, CalError> {
4567        // The ASSEMBLE's own recall-tuning options (the non-dedup tail of its
4568        // `WITH dedup, <opts>` clause) live on the AssembleStmt so they scope to
4569        // THIS assemble even when it is nested (EXPLAIN/COALESCE/…). Fold them
4570        // into the query's with_options once, here, so every downstream consumer
4571        // — the streaming payload, the AssembleEngine's per-source recall, the
4572        // post-merge pass, and the single-source surrogate — sees them.
4573        let merged;
4574        let query = if assemble.with_options.is_empty() {
4575            query
4576        } else {
4577            merged = CalQuery {
4578                with_options: query
4579                    .with_options
4580                    .iter()
4581                    .chain(&assemble.with_options)
4582                    .cloned()
4583                    .collect(),
4584                ..query.clone()
4585            };
4586            &merged
4587        };
4588        // FR-004: Streaming ASSEMBLE — return the statement + WITH options
4589        // for the HTTP handler to stream. The handler applies post-merge
4590        // options (rerank, dedup, diversity, etc.) before streaming grains.
4591        if assemble.streaming {
4592            return Ok(CalResultPayload::StreamAssemble {
4593                assemble: Box::new(assemble.clone()),
4594                with_options: query.with_options.clone(),
4595            });
4596        }
4597
4598        // ── Multi-source path (Phase 2) ────────────────────────────────
4599        //
4600        // When `assemble.sources` is `Some(...)`, delegate to the
4601        // AssembleEngine which handles budget allocation, dedup,
4602        // and per-source timeouts.
4603        if assemble.sources.is_some() {
4604            let engine = super::assemble::AssembleEngine::new(self);
4605            let result = engine.execute(assemble, store, query, exec_warnings)?;
4606            let (candidates, source_snapshot, budget_snapshot) = match &result {
4607                CalResultPayload::Assembled {
4608                    grains,
4609                    sources,
4610                    total_tokens,
4611                    budget_limit,
4612                    ..
4613                } => {
4614                    let mut all = Vec::new();
4615                    let mut offset = 0usize;
4616                    for source in sources {
4617                        let end = (offset + source.grain_count).min(grains.len());
4618                        all.extend(grains[offset..end].iter().map(|g| g.hash.clone()));
4619                        all.extend(source.omitted.iter().map(|g| g.hash.clone()));
4620                        offset = end;
4621                    }
4622                    (
4623                        all,
4624                        serde_json::to_value(sources).unwrap_or(serde_json::Value::Null),
4625                        serde_json::json!({
4626                            "limit": budget_limit,
4627                            "unit": "tokens",
4628                            "used": total_tokens,
4629                        }),
4630                    )
4631                }
4632                _ => (Vec::new(), serde_json::Value::Null, serde_json::Value::Null),
4633            };
4634
4635            // ── Post-merge WITH options ────────────────────────────────
4636            //
4637            // The AssembleEngine merges results from multiple sources but
4638            // does not apply query-level WITH options (e.g., conflict_resolution,
4639            // dedup, min_score, diversity, rerank, llm_rerank).  These are
4640            // parsed into `query.with_options` but were previously silently
4641            // discarded for multi-source ASSEMBLE.  Apply them now on the
4642            // merged result set.
4643            let result = if !query.with_options.is_empty() {
4644                self.apply_assemble_post_merge_options(
4645                    result,
4646                    &query.with_options,
4647                    exec_warnings,
4648                    store,
4649                    &assemble.topic,
4650                )?
4651            } else {
4652                result
4653            };
4654
4655            let included = match &result {
4656                CalResultPayload::Assembled { grains, .. } => {
4657                    grains.iter().map(|g| g.hash.clone()).collect()
4658                }
4659                _ => Vec::new(),
4660            };
4661
4662            // Apply FORMAT clause to multi-source results (same as single-source path).
4663            let result = if assemble.format.is_some() {
4664                apply_format_clause(
4665                    result,
4666                    &assemble.format,
4667                    None,
4668                    RenderInputs {
4669                        user_vars: &query.user_vars,
4670                        store,
4671                        // An ASSEMBLE holds its own WITH options so they scope
4672                        // to this assembly even when nested; fall back to the
4673                        // enclosing query's.
4674                        disclosure: disclosure_of(&assemble.with_options)
4675                            .or_else(|| disclosure_of(&query.with_options)),
4676                    },
4677                    // `context_name` is the explicit `ASSEMBLE "name"`
4678                    // override; `topic` is the bare name. `for_whom` is the
4679                    // FOR clause, which is what §10.5 calls the intent.
4680                    Some((
4681                        assemble.context_name.as_deref().unwrap_or(&assemble.topic),
4682                        assemble.for_whom.as_deref().unwrap_or(""),
4683                    )),
4684                    exec_warnings,
4685                )?
4686            } else {
4687                result
4688            };
4689            self.record_assembly_manifest(
4690                assemble,
4691                store,
4692                candidates,
4693                included,
4694                &result,
4695                budget_snapshot,
4696                source_snapshot,
4697            );
4698            return Ok(result);
4699        }
4700
4701        // ── Single-source path (Phase 1 compat) ───────────────────────
4702        //
4703        // Execute the FROM source query, then apply the optional WHERE
4704        // filter as a second pass over the results.
4705        let base_grains = match &assemble.from {
4706            Source::Query(recall_stmt) => {
4707                let surrogate = crate::ast::CalQuery {
4708                    version: query.version,
4709                    statement: CalStatement::Recall(*recall_stmt.clone()),
4710                    pipeline: Vec::new(),
4711                    with_options: query.with_options.clone(),
4712                    format: None,
4713                    let_bindings: Vec::new(),
4714                    let_values: query.let_values.clone(),
4715                    user_vars: HashMap::new(),
4716                    warnings: Vec::new(),
4717                };
4718                let payload =
4719                    self.execute_statement(&surrogate.statement, store, &surrogate, exec_warnings)?;
4720                extract_grains(payload)
4721            }
4722            Source::Hashes(hashes) => {
4723                let mut grains = Vec::new();
4724                for hs in hashes {
4725                    match Hash::from_hex(hs) {
4726                        Ok(hash) => {
4727                            if let Ok(grain) = store.get(&hash) {
4728                                grains.push(CalGrainResult {
4729                                    hash: hs.clone(),
4730                                    grain_type: grain.grain_type.as_str().to_string(),
4731                                    score: 1.0,
4732                                    fields: serde_json::Value::Object(
4733                                        grain
4734                                            .fields
4735                                            .iter()
4736                                            .map(|(k, v)| (k.clone(), v.clone()))
4737                                            .collect(),
4738                                    ),
4739                                    score_breakdown: None,
4740                                    explanation: None,
4741                                    relative_time: None,
4742                                    is_deterministic: true,
4743                                    contested_by: None,
4744                                });
4745                            }
4746                        }
4747                        Err(_) => {
4748                            // Silently skip invalid hashes (parser validates them).
4749                        }
4750                    }
4751                }
4752                grains
4753            }
4754            Source::Parameter { name, .. } => {
4755                return Ok(CalResultPayload::Unsupported {
4756                    statement: "assemble".into(),
4757                    message: format!(
4758                        "Parameter source ${} is not yet supported in single-source ASSEMBLE. \
4759                         Use a RECALL subquery instead.",
4760                        name
4761                    ),
4762                });
4763            }
4764        };
4765
4766        // ── WI-1.1: ASSEMBLE WHERE clause ────────────────────────────────
4767        //
4768        // Apply the WHERE clause as a post-composition filter on assembled
4769        // results. Each condition is matched against the grain's fields.
4770        let grains = if let Some(ref wc) = assemble.where_clause {
4771            // #91 — ASSEMBLE WHERE is a pure post-composition filter: no
4772            // push-down exists here, so the WHOLE tree is evaluated per
4773            // grain by the one authoritative evaluator (the flat extraction
4774            // it replaces lost NOT and turned OR into AND). Validate
4775            // against the wildcard first so engine-only fields (query,
4776            // time, tags, …) refuse instead of matching nothing.
4777            validate_residual_subtree(
4778                &wc.condition,
4779                &GrainTypePlural::All,
4780                "in ASSEMBLE WHERE (a post-composition filter)",
4781                exec_warnings,
4782            )?;
4783            base_grains
4784                .into_iter()
4785                .filter(|grain| grain_matches_condition_tree(grain, &wc.condition))
4786                .collect()
4787        } else {
4788            base_grains
4789        };
4790
4791        // ── Apply BUDGET limit (single-source path) ─────────────────────
4792        //
4793        // For the single-source path, apply the budget as a grain-count
4794        // limit. The multi-source path uses the AssembleEngine with proper
4795        // token-counting; here grain count is a reasonable approximation.
4796        // Keep what the budget cut: a template's `ELEMENT_OMIT` section is how
4797        // an assembly accounts for what it left out, and that has to work the
4798        // same whether there is one source or several.
4799        let candidates: Vec<String> = grains.iter().map(|g| g.hash.clone()).collect();
4800        let (grains, omitted) = if let Some(ref budget) = assemble.budget {
4801            let mut grains = grains;
4802            let limit = budget.tokens as usize;
4803            if grains.len() > limit {
4804                let dropped = grains.split_off(limit);
4805                (grains, dropped)
4806            } else {
4807                (grains, Vec::new())
4808            }
4809        } else {
4810            (grains, Vec::new())
4811        };
4812        store.note_assembly_budget(!omitted.is_empty());
4813        let included: Vec<String> = grains.iter().map(|g| g.hash.clone()).collect();
4814        let budget_snapshot = serde_json::json!({
4815            "limit": assemble.budget.as_ref().map(|b| b.tokens),
4816            "unit": "grains",
4817            "used": grains.len(),
4818        });
4819        let source_snapshot = serde_json::json!([{
4820            "label": "",
4821            "grain_count": grains.len(),
4822            "dropped_count": omitted.len(),
4823        }]);
4824
4825        // ── WI-1.1: ASSEMBLE FORMAT clause ───────────────────────────────
4826        //
4827        // If a FORMAT clause is present, render the grains into the
4828        // specified format and return a Formatted/MultiFormatted payload.
4829        let result = if let Some(ref clause) = assemble.format {
4830            // One source, but still an assembly: `assembly.*`, `budget.*` and
4831            // `source.*` must resolve here exactly as they do on the
4832            // multi-source path. Rendering with an empty plan is what silently
4833            // blanked them.
4834            let ctx = super::templates::AssemblyContext {
4835                name: assemble
4836                    .context_name
4837                    .clone()
4838                    .unwrap_or_else(|| assemble.topic.clone()),
4839                intent: assemble.for_whom.clone().unwrap_or_default(),
4840                source_count: 1,
4841                grain_count: grains.len(),
4842                budget_total: assemble.budget.as_ref().map_or(0, |b| b.tokens as u64),
4843                budget_used: grains.len() as u64,
4844                // The single-source path budgets by grain count, not tokens —
4845                // say so rather than mislabel the unit.
4846                budget_unit: "grains".to_string(),
4847            };
4848            let sources = [super::templates::RenderSource {
4849                label: "",
4850                grains: &grains,
4851                omitted: &omitted,
4852                priority: 1,
4853                tokens_used: grains.len(),
4854                truncated: !omitted.is_empty(),
4855            }];
4856            let plan = super::templates::RenderPlan {
4857                assembly: Some(&ctx),
4858                sources: Some(&sources),
4859            };
4860            apply_format_clause_to_grains(
4861                &grains,
4862                clause,
4863                None,
4864                RenderInputs {
4865                    user_vars: &query.user_vars,
4866                    store,
4867                    disclosure: disclosure_of(&query.with_options),
4868                },
4869                &plan,
4870                exec_warnings,
4871            )?
4872        } else {
4873            let count = grains.len();
4874            CalResultPayload::Grains {
4875                grains,
4876                total_available: Some(count),
4877            }
4878        };
4879        self.record_assembly_manifest(
4880            assemble,
4881            store,
4882            candidates,
4883            included,
4884            &result,
4885            budget_snapshot,
4886            source_snapshot,
4887        );
4888        Ok(result)
4889    }
4890
4891    // -----------------------------------------------------------------------
4892    // Pipeline application
4893    // -----------------------------------------------------------------------
4894
4895    /// Validate that any field references in `stages` are known. SELECT /
4896    /// ORDER BY / GROUP BY / PROJECT field names are restricted to the
4897    /// common-field set plus the declared grain type's type-specific fields.
4898    fn validate_pipeline_fields(
4899        stages: &[PipelineStage],
4900        grain_type: &GrainTypePlural,
4901    ) -> std::result::Result<(), CalError> {
4902        let check = |field: &str, span: Option<Span>| -> std::result::Result<(), CalError> {
4903            // Common fields are always valid.
4904            if COMMON_FIELDS.contains(&field) {
4905                return Ok(());
4906            }
4907            // Domain-prefixed fields (hc:patient_id, fin:account, …) are
4908            // valid by structure — skip lookup.
4909            if field.contains(':') {
4910                return Ok(());
4911            }
4912            // Type-specific fields valid on the declared grain type.
4913            let allowed = type_specific_fields(grain_type);
4914            if allowed.contains(&field) {
4915                return Ok(());
4916            }
4917            // If no grain type was specified (`RECALL WHERE …`), only the
4918            // common set is in scope — anything else is a hard reject.
4919            let suggestion = suggest_field(field, grain_type);
4920            Err(CalError::FieldNotOnGrainType {
4921                field: field.to_string(),
4922                grain_type: grain_type.as_str().to_string(),
4923                span,
4924                suggestion,
4925            })
4926        };
4927
4928        for stage in stages {
4929            match stage {
4930                PipelineStage::Select { fields, span } => {
4931                    for f in fields {
4932                        check(f, *span)?;
4933                    }
4934                }
4935                PipelineStage::OrderBy { field, span, .. } => check(field, *span)?,
4936                PipelineStage::GroupBy { field, span } => check(field, *span)?,
4937                PipelineStage::Project { fields, span } => {
4938                    for pf in fields {
4939                        check(&pf.field, *span)?;
4940                    }
4941                }
4942                _ => {}
4943            }
4944        }
4945        Ok(())
4946    }
4947
4948    fn apply_pipeline(
4949        &self,
4950        payload: CalResultPayload,
4951        stages: &[PipelineStage],
4952        exec_warnings: &mut Vec<String>,
4953    ) -> std::result::Result<(CalResultPayload, Option<String>), CalError> {
4954        let mut current = payload;
4955        let mut grouped_by: Option<String> = None;
4956
4957        for stage in stages {
4958            current = match (current, stage) {
4959                // LIMIT
4960                (CalResultPayload::Grains { grains, .. }, PipelineStage::Limit { value, .. }) => {
4961                    let capped = (*value).min(self.config.max_limit) as usize;
4962                    let limited: Vec<_> = grains.into_iter().take(capped).collect();
4963                    let count = limited.len();
4964                    CalResultPayload::Grains {
4965                        grains: limited,
4966                        total_available: Some(count),
4967                    }
4968                }
4969
4970                // OFFSET
4971                (CalResultPayload::Grains { grains, .. }, PipelineStage::Offset { value, .. }) => {
4972                    let offset: Vec<_> = grains.into_iter().skip(*value as usize).collect();
4973                    let count = offset.len();
4974                    CalResultPayload::Grains {
4975                        grains: offset,
4976                        total_available: Some(count),
4977                    }
4978                }
4979
4980                // COUNT
4981                (CalResultPayload::Grains { grains, .. }, PipelineStage::Count { .. }) => {
4982                    CalResultPayload::Count {
4983                        count: grains.len(),
4984                    }
4985                }
4986
4987                // FIRST
4988                (CalResultPayload::Grains { grains, .. }, PipelineStage::First { .. }) => {
4989                    let first: Vec<_> = grains.into_iter().take(1).collect();
4990                    CalResultPayload::Grains {
4991                        grains: first,
4992                        total_available: Some(1),
4993                    }
4994                }
4995
4996                // ORDER BY
4997                (
4998                    CalResultPayload::Grains { grains, .. },
4999                    PipelineStage::OrderBy {
5000                        field, descending, ..
5001                    },
5002                ) => {
5003                    let mut sorted = grains;
5004                    sorted.sort_by(|a, b| {
5005                        let va = json_field(&a.fields, field);
5006                        let vb = json_field(&b.fields, field);
5007                        let cmp = compare_json_values(va, vb);
5008                        if *descending {
5009                            cmp.reverse()
5010                        } else {
5011                            cmp
5012                        }
5013                    });
5014                    let count = sorted.len();
5015                    CalResultPayload::Grains {
5016                        grains: sorted,
5017                        total_available: Some(count),
5018                    }
5019                }
5020
5021                // SELECT (field projection)
5022                (CalResultPayload::Grains { grains, .. }, PipelineStage::Select { fields, .. }) => {
5023                    let projected: Vec<_> = grains
5024                        .into_iter()
5025                        .map(|g| {
5026                            let projected_fields = project_fields(&g.fields, fields);
5027                            CalGrainResult {
5028                                fields: projected_fields,
5029                                ..g
5030                            }
5031                        })
5032                        .collect();
5033                    let count = projected.len();
5034                    CalResultPayload::Grains {
5035                        grains: projected,
5036                        total_available: Some(count),
5037                    }
5038                }
5039
5040                // PROJECT (field projection with optional aliasing)
5041                (
5042                    CalResultPayload::Grains { grains, .. },
5043                    PipelineStage::Project { fields, .. },
5044                ) => {
5045                    let field_names: Vec<String> =
5046                        fields.iter().map(|pf| pf.field.clone()).collect();
5047                    let projected: Vec<_> = grains
5048                        .into_iter()
5049                        .map(|g| {
5050                            let mut new_map = serde_json::Map::new();
5051                            if let serde_json::Value::Object(map) = &g.fields {
5052                                for pf in fields {
5053                                    if let Some(v) = map.get(&pf.field) {
5054                                        let key = pf.alias.as_deref().unwrap_or(&pf.field);
5055                                        new_map.insert(key.to_string(), v.clone());
5056                                    }
5057                                }
5058                            }
5059                            let _ = field_names.len(); // suppress unused warning
5060                            CalGrainResult {
5061                                fields: serde_json::Value::Object(new_map),
5062                                ..g
5063                            }
5064                        })
5065                        .collect();
5066                    let count = projected.len();
5067                    CalResultPayload::Grains {
5068                        grains: projected,
5069                        total_available: Some(count),
5070                    }
5071                }
5072
5073                // SUBJECTS extractor (I-7 fix: returns Grains, not Describe)
5074                (CalResultPayload::Grains { grains, .. }, PipelineStage::Subjects { .. }) => {
5075                    let extracted: Vec<CalGrainResult> = grains
5076                        .iter()
5077                        .filter_map(|g| {
5078                            json_field(&g.fields, "subject").map(|v| CalGrainResult {
5079                                hash: String::new(),
5080                                grain_type: "extracted".into(),
5081                                score: 0.0,
5082                                fields: serde_json::json!({ "value": v }),
5083                                score_breakdown: None,
5084                                explanation: None,
5085                                relative_time: None,
5086                                is_deterministic: true,
5087                                contested_by: None,
5088                            })
5089                        })
5090                        .collect();
5091                    let count = extracted.len();
5092                    CalResultPayload::Grains {
5093                        grains: extracted,
5094                        total_available: Some(count),
5095                    }
5096                }
5097
5098                // OBJECTS extractor (I-7 fix: returns Grains, not Describe)
5099                (CalResultPayload::Grains { grains, .. }, PipelineStage::Objects { .. }) => {
5100                    let extracted: Vec<CalGrainResult> = grains
5101                        .iter()
5102                        .filter_map(|g| {
5103                            json_field(&g.fields, "object").map(|v| CalGrainResult {
5104                                hash: String::new(),
5105                                grain_type: "extracted".into(),
5106                                score: 0.0,
5107                                fields: serde_json::json!({ "value": v }),
5108                                score_breakdown: None,
5109                                explanation: None,
5110                                relative_time: None,
5111                                is_deterministic: true,
5112                                contested_by: None,
5113                            })
5114                        })
5115                        .collect();
5116                    let count = extracted.len();
5117                    CalResultPayload::Grains {
5118                        grains: extracted,
5119                        total_available: Some(count),
5120                    }
5121                }
5122
5123                // HASHES extractor (I-7 fix: returns Grains, not Describe)
5124                (CalResultPayload::Grains { grains, .. }, PipelineStage::Hashes { .. }) => {
5125                    let extracted: Vec<CalGrainResult> = grains
5126                        .iter()
5127                        .map(|g| CalGrainResult {
5128                            hash: String::new(),
5129                            grain_type: "extracted".into(),
5130                            score: 0.0,
5131                            fields: serde_json::json!({ "value": g.hash }),
5132                            score_breakdown: None,
5133                            explanation: None,
5134                            relative_time: None,
5135                            is_deterministic: true,
5136                            contested_by: None,
5137                        })
5138                        .collect();
5139                    let count = extracted.len();
5140                    CalResultPayload::Grains {
5141                        grains: extracted,
5142                        total_available: Some(count),
5143                    }
5144                }
5145
5146                // GROUP BY — reorders grains so same-field-value grains are
5147                // contiguous, sorted chronologically within each group. Groups
5148                // are ordered by the earliest created_at_sec in each group.
5149                (
5150                    CalResultPayload::Grains {
5151                        grains,
5152                        total_available,
5153                    },
5154                    PipelineStage::GroupBy { field, .. },
5155                ) => {
5156                    grouped_by = Some(field.clone());
5157                    let grouped = group_grains_by_field(grains, field);
5158                    CalResultPayload::Grains {
5159                        grains: grouped,
5160                        total_available,
5161                    }
5162                }
5163
5164                // WHERE (post-pipeline filter)
5165                (
5166                    CalResultPayload::Grains { grains, .. },
5167                    PipelineStage::Filter { condition, .. },
5168                ) => {
5169                    let filtered: Vec<_> = grains
5170                        .into_iter()
5171                        .filter(|grain| grain_matches_condition_tree(grain, condition))
5172                        .collect();
5173                    let count = filtered.len();
5174                    CalResultPayload::Grains {
5175                        grains: filtered,
5176                        total_available: Some(count),
5177                    }
5178                }
5179
5180                // A stage attached to a payload it cannot act on.
5181                //
5182                // This used to be a bare passthrough, so `ORDER BY` on a
5183                // multi-source ASSEMBLE — which returns `Assembled`, not
5184                // `Grains` — was discarded with no error and no warning. That
5185                // contradicts docs/cal-reference.md §5 (silence means the
5186                // option did something), and it is precisely the case a host
5187                // reaches for: rendering authored instruction blocks in an
5188                // intended order. Section order on an ASSEMBLE is FROM-clause
5189                // order and is not reorderable by a pipeline stage, so the
5190                // honest answer is to say the stage did nothing.
5191                (other, stage) => {
5192                    exec_warnings.push(
5193                        super::errors::CalWarning::PipelineStageInert {
5194                            stage: pipeline_stage_name(stage),
5195                            payload: payload_kind_name(&other),
5196                            why: inert_stage_reason(&other),
5197                        }
5198                        .to_string(),
5199                    );
5200                    other
5201                }
5202            };
5203        }
5204
5205        Ok((current, grouped_by))
5206    }
5207}
5208
5209// ---------------------------------------------------------------------------
5210// Helper functions
5211// ---------------------------------------------------------------------------
5212
5213/// Compute field-level differences between two grain versions.
5214///
5215/// Uses BTreeSet-based key comparison to produce a deterministic,
5216/// sorted list of `FieldDiff` entries.
5217fn diff_grains(
5218    a: &areev_core::format::deserialize::DeserializedGrain,
5219    b: &areev_core::format::deserialize::DeserializedGrain,
5220) -> Vec<super::ast::FieldDiff> {
5221    use std::collections::BTreeSet;
5222
5223    let keys_a: BTreeSet<&str> = a.fields.keys().map(|s| s.as_str()).collect();
5224    let keys_b: BTreeSet<&str> = b.fields.keys().map(|s| s.as_str()).collect();
5225
5226    let mut diffs = Vec::new();
5227
5228    // Added fields (in b but not in a)
5229    for key in keys_b.difference(&keys_a) {
5230        diffs.push(super::ast::FieldDiff::Added {
5231            field: key.to_string(),
5232            value: b.fields[*key].clone(),
5233        });
5234    }
5235
5236    // Removed fields (in a but not in b)
5237    for key in keys_a.difference(&keys_b) {
5238        diffs.push(super::ast::FieldDiff::Removed {
5239            field: key.to_string(),
5240            value: a.fields[*key].clone(),
5241        });
5242    }
5243
5244    // Changed fields (in both, different values)
5245    for key in keys_a.intersection(&keys_b) {
5246        if a.fields[*key] != b.fields[*key] {
5247            diffs.push(super::ast::FieldDiff::Changed {
5248                field: key.to_string(),
5249                old: a.fields[*key].clone(),
5250                new: b.fields[*key].clone(),
5251            });
5252        }
5253    }
5254
5255    diffs
5256}
5257
5258/// Return a list of build-time feature flags for DESCRIBE SERVER.
5259#[allow(clippy::vec_init_then_push)]
5260fn build_features_list() -> Vec<&'static str> {
5261    let mut features = Vec::new();
5262    #[cfg(feature = "http")]
5263    features.push("http");
5264    #[cfg(feature = "grpc")]
5265    features.push("grpc");
5266    #[cfg(feature = "mcp")]
5267    features.push("mcp");
5268    #[cfg(feature = "a2a")]
5269    features.push("a2a");
5270    #[cfg(feature = "app")]
5271    features.push("app");
5272    #[cfg(feature = "signing")]
5273    features.push("signing");
5274    #[cfg(feature = "import")]
5275    features.push("import");
5276    #[cfg(feature = "auth")]
5277    features.push("auth");
5278    #[cfg(feature = "rerank")]
5279    features.push("rerank");
5280    #[cfg(feature = "llm-rerank")]
5281    features.push("llm-rerank");
5282    #[cfg(feature = "pii_ner")]
5283    features.push("pii_ner");
5284    #[cfg(feature = "cal")]
5285    features.push("cal");
5286    features
5287}
5288
5289/// Compute a SHA-256 hash of the NFC-normalized, trimmed query string (C-4).
5290fn compute_query_hash(input: &str) -> String {
5291    use unicode_normalization::UnicodeNormalization as _;
5292    let normalized: String = input.trim().nfc().collect();
5293    let mut hasher = Sha256::new();
5294    hasher.update(normalized.as_bytes());
5295    hex::encode(hasher.finalize())
5296}
5297
5298/// Map an `AreevError` raised during CAL execution into the right `CalError`
5299/// variant. Crypto failures (AES-GCM decrypt, envelope too short, KEY-E003)
5300/// are a distinct class from budget overruns and get `CAL-E090`. Everything
5301/// else falls through to `CAL-E030 BudgetExceeded` for backwards
5302/// compatibility with existing error-surfacing tests.
5303pub(super) fn map_store_err(e: AreevError, span: Option<super::errors::Span>) -> CalError {
5304    match e {
5305        AreevError::CryptoError(_) => CalError::CryptoError {
5306            detail: e.to_string(),
5307            span,
5308        },
5309        // A store-side input validation failure is not a resource overrun —
5310        // surface it as CAL-E092, not CAL-E030 "Budget exceeded".
5311        AreevError::Validation(_) => CalError::InvalidQuery {
5312            detail: e.to_string(),
5313            span,
5314        },
5315        _ => CalError::BudgetExceeded {
5316            detail: e.to_string(),
5317            span,
5318        },
5319    }
5320}
5321
5322/// Build `AddOptions` from CAL `AddWithOption` list.
5323fn build_add_options(opts: &[AddWithOption], warnings: &mut Vec<String>) -> AddOptions {
5324    let mut options = AddOptions::default();
5325    for opt in opts {
5326        match opt {
5327            AddWithOption::ExtractEventDate => {
5328                options.extract_event_date = Some(true);
5329            }
5330            AddWithOption::AutoRelate => {
5331                // Parsed and accepted since 1.0, but no store path has ever read
5332                // `AddOptions::auto_relate` — the option silently did nothing.
5333                // It stays in the grammar (it is documented, and removing it
5334                // would turn working queries into parse errors) but now says so
5335                // instead of pretending.
5336                // CAL-W013, not W004: W004 is UnknownExtensionOption, and a code
5337                // has to locate exactly one variant to be worth reporting.
5338                warnings.push(
5339                    "CAL-W013: WITH auto_relate is accepted but not implemented — no relations \
5340                     are inferred. Link grains explicitly with related_to, or use WITH \
5341                     multi_hop(n) on recall to widen through the entity graph."
5342                        .to_string(),
5343                );
5344                options.auto_relate = Some(true);
5345            }
5346            AddWithOption::ExtractMemories => {
5347                // No-op: sync extraction removed, offloaded to an async extraction executor via markers.
5348            }
5349            AddWithOption::Sync => {
5350                options.sync = Some(true);
5351            }
5352            // Handled at the ADD arm (field stamping) — nothing for the
5353            // store contract to carry.
5354            AddWithOption::Occurrence => {}
5355        }
5356    }
5357    options
5358}
5359
5360/// Convert a `Value` to its CAL literal representation for parameter substitution.
5361fn value_to_cal_literal(value: &super::ast::Value) -> String {
5362    match value {
5363        super::ast::Value::String { value } => {
5364            format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
5365        }
5366        super::ast::Value::Number { value } => value.to_string(),
5367        super::ast::Value::Boolean { value } => value.to_string(),
5368        super::ast::Value::Hash { value } => format!("#{value}"),
5369        super::ast::Value::Parameter { name } => format!("${name}"),
5370        super::ast::Value::Array { values } => {
5371            let items: Vec<String> = values.iter().map(value_to_cal_literal).collect();
5372            format!("[{}]", items.join(", "))
5373        }
5374    }
5375}
5376
5377/// Convert a CAL AST `Value` to a `serde_json::Value`.
5378fn cal_value_to_json(val: &super::ast::Value) -> serde_json::Value {
5379    match val {
5380        super::ast::Value::String { value } => serde_json::Value::String(value.clone()),
5381        // An integral literal becomes a JSON *integer*, not a float. The AST
5382        // holds every number as f64, and `Number::from_f64` always yields a
5383        // float — so `serde_json::Value::as_i64()` returned None for all of
5384        // them, and every i64-typed field set from CAL (`created_at`,
5385        // `valid_to`, `duration_ms`, …) was silently discarded on the way to
5386        // the grain builder. Fractional values are unaffected.
5387        super::ast::Value::Number { value } => {
5388            if value.fract() == 0.0 && value.abs() < 9.007_199_254_740_992e15 {
5389                serde_json::Value::Number(serde_json::Number::from(*value as i64))
5390            } else {
5391                serde_json::Number::from_f64(*value)
5392                    .map(serde_json::Value::Number)
5393                    .unwrap_or(serde_json::Value::Null)
5394            }
5395        }
5396        super::ast::Value::Boolean { value } => serde_json::Value::Bool(*value),
5397        super::ast::Value::Array { values } => {
5398            serde_json::Value::Array(values.iter().map(cal_value_to_json).collect())
5399        }
5400        super::ast::Value::Hash { value } => serde_json::Value::String(value.clone()),
5401        super::ast::Value::Parameter { name } => serde_json::Value::String(format!("${}", name)),
5402    }
5403}
5404
5405/// The payload for a governance statement on an executor no host wired.
5406fn governance_unwired(statement: &str) -> CalResultPayload {
5407    CalResultPayload::Unsupported {
5408        statement: statement.into(),
5409        message: "governance is not wired on this surface — the host attaches a \
5410                  GovernanceHost (CalExecutor::with_governance) to back RUN LOOP, \
5411                  APPROVE, REJECT, APPLY, ROLLBACK, and the loop DESCRIBE reads"
5412            .into(),
5413    }
5414}
5415
5416/// Return the canonical statement type name as a lowercase string.
5417fn statement_type_name(stmt: &CalStatement) -> String {
5418    match stmt {
5419        CalStatement::Recall(_) => "recall",
5420        CalStatement::SetOp(_) => "set_op",
5421        CalStatement::Exists(_) => "exists",
5422        CalStatement::Assemble(_) => "assemble",
5423        CalStatement::History(_) => "history",
5424        CalStatement::Explain(_) => "explain",
5425        CalStatement::Describe(_) => "describe",
5426        CalStatement::Batch(_) => "batch",
5427        CalStatement::Coalesce(_) => "coalesce",
5428        CalStatement::Add(_) => "add",
5429        CalStatement::AddWorkflow(_) => "add_workflow",
5430        CalStatement::Supersede(_) => "supersede",
5431        CalStatement::SupersedeWorkflow(_) => "supersede_workflow",
5432        CalStatement::Accumulate(_) => "accumulate",
5433        CalStatement::Revert(_) => "revert",
5434        CalStatement::Forget(_) => "forget",
5435        CalStatement::Purge(_) => "purge",
5436        CalStatement::ReportSubject(_) => "report_subject",
5437        CalStatement::DefineTemplate(_) => "define_template",
5438        CalStatement::DropTemplate(_) => "drop_template",
5439        CalStatement::DefineQuery(_) => "define_query",
5440        CalStatement::DropQuery(_) => "drop_query",
5441        CalStatement::RunQuery(_) => "run_query",
5442        CalStatement::Grant(_) => "grant",
5443        CalStatement::Revoke(_) => "revoke",
5444        CalStatement::ShowGrants(_) => "show_grants",
5445        CalStatement::Approve(_) => "approve",
5446        CalStatement::Reject(_) => "reject",
5447        CalStatement::ApplyRec(_) => "apply",
5448        CalStatement::RollbackRec(_) => "rollback",
5449        CalStatement::RunLoop(_) => "run_loop",
5450        CalStatement::Remember(_) => "remember",
5451        CalStatement::EntityAt(_) => "entity_at",
5452        CalStatement::RunTrace(_) => "run_trace",
5453        CalStatement::RunsTouching(_) => "runs_touching",
5454        CalStatement::DerivedFrom(_) => "derived_from",
5455        CalStatement::ShowForks(_) => "show_forks",
5456        CalStatement::Merge(_) => "merge",
5457        CalStatement::Related(_) => "related",
5458        CalStatement::Novelty(_) => "novelty",
5459    }
5460    .to_string()
5461}
5462
5463/// Classify the required JWT scope for a CAL statement type.
5464/// Read statements require "read", write statements require "write",
5465/// destructive and admin statements require "admin".
5466fn required_scope_for_statement(stmt: &CalStatement) -> &'static str {
5467    match stmt {
5468        CalStatement::Recall(_)
5469        | CalStatement::SetOp(_)
5470        | CalStatement::Exists(_)
5471        | CalStatement::Assemble(_)
5472        | CalStatement::History(_)
5473        | CalStatement::Explain(_)
5474        | CalStatement::Describe(_)
5475        | CalStatement::Batch(_)
5476        | CalStatement::Coalesce(_)
5477        | CalStatement::RunQuery(_)
5478        | CalStatement::ShowGrants(_)
5479        | CalStatement::EntityAt(_)
5480        | CalStatement::RunTrace(_)
5481        | CalStatement::RunsTouching(_)
5482        | CalStatement::DerivedFrom(_)
5483        | CalStatement::ShowForks(_)
5484        | CalStatement::Related(_)
5485        | CalStatement::Novelty(_)
5486        | CalStatement::ReportSubject(_) => "read",
5487
5488        CalStatement::Add(_)
5489        | CalStatement::AddWorkflow(_)
5490        | CalStatement::Supersede(_)
5491        | CalStatement::SupersedeWorkflow(_)
5492        | CalStatement::Accumulate(_)
5493        | CalStatement::Revert(_)
5494        | CalStatement::Remember(_)
5495        | CalStatement::Merge(_) => "write",
5496
5497        CalStatement::Forget(_)
5498        | CalStatement::Purge(_)
5499        | CalStatement::DefineTemplate(_)
5500        | CalStatement::DropTemplate(_)
5501        | CalStatement::DefineQuery(_)
5502        | CalStatement::DropQuery(_)
5503        | CalStatement::Grant(_)
5504        | CalStatement::Revoke(_)
5505        | CalStatement::Approve(_)
5506        | CalStatement::Reject(_)
5507        | CalStatement::ApplyRec(_)
5508        | CalStatement::RollbackRec(_)
5509        | CalStatement::RunLoop(_) => "admin",
5510    }
5511}
5512
5513/// The payload kind named in a `CAL-W016` inert-stage warning.
5514fn payload_kind_name(payload: &CalResultPayload) -> &'static str {
5515    match payload {
5516        CalResultPayload::Assembled { .. } => "assembled",
5517        CalResultPayload::Count { .. } => "count",
5518        CalResultPayload::Formatted { .. } => "formatted",
5519        CalResultPayload::Exists { .. } => "exists",
5520        CalResultPayload::History { .. } => "history",
5521        CalResultPayload::Explain { .. } => "explain",
5522        CalResultPayload::Batch { .. } => "batch",
5523        _ => "non-grain",
5524    }
5525}
5526
5527/// Why a pipeline stage cannot act on this payload — the `CAL-W016` clause
5528/// that tells a caller what to do instead.
5529fn inert_stage_reason(payload: &CalResultPayload) -> &'static str {
5530    match payload {
5531        // The case that motivated the warning. Section order on a multi-source
5532        // ASSEMBLE is FROM-clause order by contract (docs/cal-reference.md),
5533        // deliberately independent of PRIORITY, which weights the budget.
5534        CalResultPayload::Assembled { .. } => {
5535            "an assembly is a list of named sections, not a flat grain list — \
5536             section order is FROM-clause order (PRIORITY weights the budget, \
5537             it does not reorder); to order WITHIN a section, put the stage on \
5538             that source's sub-query"
5539        }
5540        CalResultPayload::Count { .. } => "a count is a scalar; stage it before | COUNT",
5541        CalResultPayload::Formatted { .. } => {
5542            "FORMAT has already rendered the grains to text; stage it before FORMAT"
5543        }
5544        _ => "this statement does not return a grain list",
5545    }
5546}
5547
5548/// Return the canonical pipeline stage name for EXPLAIN plans.
5549fn pipeline_stage_name(stage: &PipelineStage) -> String {
5550    match stage {
5551        PipelineStage::Select { .. } => "SELECT".to_string(),
5552        PipelineStage::OrderBy {
5553            field, descending, ..
5554        } => {
5555            format!(
5556                "ORDER BY {} {}",
5557                field,
5558                if *descending { "DESC" } else { "ASC" }
5559            )
5560        }
5561        PipelineStage::Limit { value, .. } => format!("LIMIT {}", value),
5562        PipelineStage::Offset { value, .. } => format!("OFFSET {}", value),
5563        PipelineStage::Count { .. } => "COUNT".to_string(),
5564        PipelineStage::First { .. } => "FIRST".to_string(),
5565        PipelineStage::Subjects { .. } => "SUBJECTS".to_string(),
5566        PipelineStage::Objects { .. } => "OBJECTS".to_string(),
5567        PipelineStage::Hashes { .. } => "HASHES".to_string(),
5568        PipelineStage::GroupBy { field, .. } => format!("GROUP BY {}", field),
5569        PipelineStage::Project { .. } => "PROJECT".to_string(),
5570        PipelineStage::Filter { .. } => "WHERE (post-pipeline)".to_string(),
5571    }
5572}
5573
5574/// Count the top-level items in a payload (for metadata.result_count).
5575fn count_payload_results(payload: &CalResultPayload) -> usize {
5576    match payload {
5577        CalResultPayload::Grains { grains, .. } => grains.len(),
5578        CalResultPayload::Exists { .. } => 1,
5579        CalResultPayload::Count { .. } => 1,
5580        CalResultPayload::History { versions } => versions.len(),
5581        CalResultPayload::Describe { .. } => 1,
5582        CalResultPayload::Explain { .. } => 1,
5583        CalResultPayload::Batch { results } => results.len(),
5584        CalResultPayload::Assembled { grains, .. } => grains.len(),
5585        CalResultPayload::Diff { changes, .. } => changes.len(),
5586        CalResultPayload::Formatted { grain_count, .. } => *grain_count,
5587        CalResultPayload::MultiFormatted { grain_count, .. } => *grain_count,
5588        CalResultPayload::Added { .. } => 1,
5589        CalResultPayload::Granted { .. } => 1,
5590        CalResultPayload::Revoked { .. } => 1,
5591        CalResultPayload::GrantList { grants } => grants.len(),
5592        CalResultPayload::LoopRan { .. } => 1,
5593        CalResultPayload::Reviewed { .. } => 1,
5594        CalResultPayload::RecApplied { .. } => 1,
5595        CalResultPayload::RecRolledBack { .. } => 1,
5596        CalResultPayload::Remembered { .. } => 1,
5597        CalResultPayload::EntityAt { grain, .. } => usize::from(grain.is_some()),
5598        CalResultPayload::RunTrace { .. } => 1,
5599        CalResultPayload::RunsTouching { runs, .. } => runs.len(),
5600        CalResultPayload::DerivedFrom { grains, .. } => grains.len(),
5601        CalResultPayload::Forks { forks } => forks.len(),
5602        CalResultPayload::Merged { .. } => 1,
5603        CalResultPayload::RelatedEntities { entities, .. } => entities.len(),
5604        CalResultPayload::NoveltyMatches { matches } => matches.len(),
5605        CalResultPayload::Superseded { .. } => 1,
5606        CalResultPayload::Accumulated { .. } => 1,
5607        CalResultPayload::Forgotten { .. } => 1,
5608        CalResultPayload::Purged { count } => *count,
5609        CalResultPayload::SubjectReport { grains, .. } => grains.len(),
5610        CalResultPayload::Unsupported { .. } => 0,
5611        CalResultPayload::TemplateDefined { .. } => 1,
5612        CalResultPayload::TemplateDropped { .. } => 1,
5613        CalResultPayload::QueryDefined { .. } => 1,
5614        CalResultPayload::QueryDropped { .. } => 1,
5615        CalResultPayload::StreamAssemble { .. } => 0,
5616    }
5617}
5618
5619/// Extract a Vec<CalGrainResult> from a payload (for set operations).
5620fn extract_grains(payload: CalResultPayload) -> Vec<CalGrainResult> {
5621    match payload {
5622        CalResultPayload::Grains { grains, .. } => grains,
5623        CalResultPayload::Assembled { grains, .. } => grains,
5624        _ => Vec::new(),
5625    }
5626}
5627
5628/// Convert a SearchHit slice to CalGrainResult vec.
5629fn hits_to_grain_results(hits: &[crate::store_types::SearchHit]) -> Vec<CalGrainResult> {
5630    hits.iter()
5631        .map(|hit| CalGrainResult {
5632            hash: hit.hash.to_hex(),
5633            grain_type: hit.grain.grain_type.as_str().to_string(),
5634            score: hit.score,
5635            fields: serde_json::Value::Object({
5636                let mut map: serde_json::Map<String, serde_json::Value> = hit
5637                    .grain
5638                    .fields
5639                    .iter()
5640                    .map(|(k, v)| (k.clone(), v.clone()))
5641                    .collect();
5642                // `superseded_by` is a common grain field, but nothing can fill
5643                // it at write time — a grain cannot know its own successor — so
5644                // it only ever arrives from the index, via a recall that asked
5645                // for history. Project it here and every downstream consumer
5646                // gets the label for free: the JSON payload, and the renderers'
5647                // existing "(superseded)" note.
5648                if let Some(sup) = hit.superseded_by_hash {
5649                    map.entry("superseded_by".to_string())
5650                        .or_insert_with(|| serde_json::Value::String(sup.to_hex()));
5651                }
5652                map
5653            }),
5654            score_breakdown: hit
5655                .score_breakdown
5656                .as_ref()
5657                .map(|sb| serde_json::to_value(sb).unwrap_or(serde_json::Value::Null)),
5658            explanation: hit.explanation.clone(),
5659            relative_time: hit.relative_time.clone(),
5660            is_deterministic: false,
5661            contested_by: None,
5662        })
5663        .collect()
5664}
5665
5666/// Stamp fork status onto recalled grains, and optionally drop the uncontested.
5667///
5668/// Two surfaces share this. `CONTRADICTIONS` keeps **only** contested grains —
5669/// the agent asked for the conflicts. `WITH contradiction_detection` keeps
5670/// everything and merely marks what is disputed — the agent asked for its normal
5671/// context, plus a warning about which parts of it are not settled.
5672///
5673/// `restrict_to` is the `(namespace, subject, relation)` key set from a
5674/// `CONTRADICTIONS OF (sub-query)` tail; `None` considers every open fork.
5675///
5676/// **Fail-open**, matching the recall path: a facade that cannot enumerate heads
5677/// reports no forks and the query degrades to "nothing is contested" with a
5678/// warning, rather than turning a working recall into a failed one. The one
5679/// exception is a *filtering* query, which yields nothing — claiming "no
5680/// conflicts" when the check did not run would be a false all-clear, and this
5681/// clause exists precisely so an agent can trust that answer.
5682fn apply_fork_status(
5683    grains: &mut Vec<CalGrainResult>,
5684    store: &dyn CalStoreFacade,
5685    filter: bool,
5686    restrict_to: Option<&std::collections::HashSet<(String, String, String)>>,
5687    exec_warnings: &mut Vec<String>,
5688) {
5689    let forks = match store.open_forks() {
5690        Ok(f) => f,
5691        Err(e) => {
5692            exec_warnings.push(format!(
5693                "contradiction detection unavailable ({e}); fork status not applied"
5694            ));
5695            if filter {
5696                grains.clear();
5697            }
5698            return;
5699        }
5700    };
5701
5702    // hash -> every *other* live tip contesting the same key.
5703    let mut peers: HashMap<String, Vec<String>> = HashMap::new();
5704    for f in &forks {
5705        if let Some(keys) = restrict_to {
5706            if !keys.contains(&(
5707                f.namespace.clone(),
5708                f.subject.clone(),
5709                f.relation.clone(),
5710            )) {
5711                continue;
5712            }
5713        }
5714        for (i, head) in f.heads.iter().enumerate() {
5715            let others: Vec<String> = f
5716                .heads
5717                .iter()
5718                .enumerate()
5719                .filter(|(j, _)| *j != i)
5720                .map(|(_, o)| o.clone())
5721                .collect();
5722            peers.insert(head.clone(), others);
5723        }
5724    }
5725
5726    if filter {
5727        grains.retain(|g| peers.contains_key(&g.hash));
5728    }
5729    for g in grains.iter_mut() {
5730        if let Some(others) = peers.get(&g.hash) {
5731            g.contested_by = Some(others.clone());
5732        }
5733    }
5734}
5735
5736/// The `(namespace, subject, relation)` keys a `CONTRADICTIONS OF (...)`
5737/// sub-query selected.
5738///
5739/// Keyed on the full fork identity, not just `(subject, relation)`: the same
5740/// subject and relation in two namespaces are two different keys, and matching
5741/// on the pair alone would let a fork in one namespace be reported as in scope
5742/// because an unrelated grain in another namespace happened to share them.
5743/// A grain that does not carry its namespace is attributed to the namespace the
5744/// recall was scoped to, which is where it came from.
5745///
5746/// Grains with no subject or no relation (an Event, say) contribute no key and
5747/// so narrow nothing — the tail restricts the fork set, it never widens it.
5748fn contradiction_scope_keys(
5749    grains: &[CalGrainResult],
5750    default_ns: Option<&str>,
5751) -> std::collections::HashSet<(String, String, String)> {
5752    grains
5753        .iter()
5754        .filter_map(|g| {
5755            let s = g.fields.get("subject").and_then(|v| v.as_str())?;
5756            let r = g.fields.get("relation").and_then(|v| v.as_str())?;
5757            let ns = g
5758                .fields
5759                .get("namespace")
5760                .and_then(|v| v.as_str())
5761                .or(default_ns)
5762                .unwrap_or_default();
5763            Some((ns.to_string(), s.to_string(), r.to_string()))
5764        })
5765        .collect()
5766}
5767
5768/// Extract the primary text content from a `CalGrainResult` for dedup comparison.
5769///
5770/// Mirrors the text extraction logic in `src/llm_rerank/mod.rs::extract_grain_text`
5771/// but operates on the JSON `fields` value instead of `DeserializedGrain`.
5772fn grain_result_text(grain: &CalGrainResult) -> String {
5773    // For facts: "subject relation object"
5774    if grain.grain_type == "fact" {
5775        let s = grain
5776            .fields
5777            .get("subject")
5778            .and_then(|v| v.as_str())
5779            .unwrap_or("");
5780        let r = grain
5781            .fields
5782            .get("relation")
5783            .and_then(|v| v.as_str())
5784            .unwrap_or("");
5785        let o = grain
5786            .fields
5787            .get("object")
5788            .and_then(|v| v.as_str())
5789            .unwrap_or("");
5790        return format!("{} {} {}", s, r, o).trim().to_string();
5791    }
5792    // For other types: try common text fields.
5793    for field in &[
5794        "content",
5795        "description",
5796        "title",
5797        "goal",
5798        "query",
5799        "result",
5800        "output",
5801    ] {
5802        if let Some(v) = grain.fields.get(*field).and_then(|v| v.as_str()) {
5803            if !v.trim().is_empty() {
5804                return v.to_string();
5805            }
5806        }
5807    }
5808    grain.grain_type.clone()
5809}
5810
5811/// Compute a simple text similarity score (Jaccard over word bigrams).
5812///
5813/// Returns a value in `[0.0, 1.0]`.  Used for threshold-based dedup in
5814/// `apply_assemble_post_merge_options`.  This is intentionally a lightweight
5815/// approximation — it does not need to match the engine's `find_similar_facts`
5816/// which uses TF-IDF cosine similarity.
5817fn text_similarity(a: &str, b: &str) -> f64 {
5818    if a == b {
5819        return 1.0;
5820    }
5821    let words_a: Vec<&str> = a.split_whitespace().collect();
5822    let words_b: Vec<&str> = b.split_whitespace().collect();
5823    if words_a.is_empty() && words_b.is_empty() {
5824        return 1.0;
5825    }
5826    if words_a.is_empty() || words_b.is_empty() {
5827        return 0.0;
5828    }
5829    // Bigram Jaccard.
5830    let bigrams = |words: &[&str]| -> std::collections::HashSet<(String, String)> {
5831        if words.len() < 2 {
5832            let mut s = std::collections::HashSet::new();
5833            s.insert((words[0].to_lowercase(), String::new()));
5834            return s;
5835        }
5836        words
5837            .windows(2)
5838            .map(|w| (w[0].to_lowercase(), w[1].to_lowercase()))
5839            .collect()
5840    };
5841    let set_a = bigrams(&words_a);
5842    let set_b = bigrams(&words_b);
5843    let intersection = set_a.intersection(&set_b).count();
5844    let union = set_a.union(&set_b).count();
5845    if union == 0 {
5846        return 1.0;
5847    }
5848    intersection as f64 / union as f64
5849}
5850
5851/// Extract a string from a CAL `Value`.
5852///
5853/// # I-5 fix
5854///
5855/// `Value::Parameter` now returns `CalError::UnboundParameter` instead of
5856/// silently producing `"$name"`.  Parameters must be resolved before
5857/// execution; encountering one here means the caller forgot to bind it.
5858fn value_to_string(value: &Value) -> std::result::Result<String, CalError> {
5859    match value {
5860        Value::String { value } => Ok(value.clone()),
5861        Value::Hash { value } => Ok(value.clone()),
5862        Value::Parameter { name } => Err(CalError::UnboundParameter {
5863            name: name.clone(),
5864            span: None,
5865        }),
5866        other => Err(CalError::IncompatibleTypes {
5867            left: "string".into(),
5868            right: format!("{:?}", other),
5869            span: None,
5870            suggestion: Some("expected a quoted string value".into()),
5871        }),
5872    }
5873}
5874
5875/// Extract a number from a CAL `Value`.
5876fn value_to_f64(value: &Value) -> std::result::Result<f64, CalError> {
5877    match value {
5878        Value::Number { value } => Ok(*value),
5879        other => Err(CalError::IncompatibleTypes {
5880            left: "number".into(),
5881            right: format!("{:?}", other),
5882            span: None,
5883            suggestion: Some("expected a numeric value".into()),
5884        }),
5885    }
5886}
5887
5888/// Try to extract a hash string from a WHERE condition for EXISTS optimisation.
5889fn extract_hash_from_condition(condition: Option<&Condition>) -> Option<String> {
5890    match condition? {
5891        Condition::Comparison {
5892            field,
5893            comparator: Comparator::Eq,
5894            value: Value::Hash { value },
5895            ..
5896        } if field == "hash" => Some(value.clone()),
5897        _ => None,
5898    }
5899}
5900
5901// ---------------------------------------------------------------------------
5902// FORMAT clause application (CAL spec v1.0.1)
5903// ---------------------------------------------------------------------------
5904
5905/// Apply a `FormatClause` to a payload after pipeline stages.
5906///
5907/// Only applies to grain-bearing payloads (Grains, Assembled). Other payload
5908/// types (Exists, Count, History, etc.) pass through unchanged.
5909///
5910/// When `grouped_by` is `Some`, the grains have been reordered by `| GROUP BY`
5911/// and FORMAT renderers emit group headers.
5912/// The ambient inputs a FORMAT render needs beyond the grains themselves.
5913/// Grouped because all three stages of the format path take the same set, and
5914/// threading them one by one pushed each signature past what is readable.
5915#[derive(Clone, Copy)]
5916struct RenderInputs<'a> {
5917    /// `WITH VARS` bindings, for template rendering.
5918    user_vars: &'a HashMap<String, String>,
5919    /// Store access — templates are host metadata read through the facade.
5920    store: &'a dyn CalStoreFacade,
5921    /// OMS §4 `WITH progressive_disclosure(level)`. `None` = not asked for, and
5922    /// every render then keeps its historical output byte for byte.
5923    disclosure: Option<crate::render::Disclosure>,
5924}
5925
5926/// The progressive-disclosure tier a `WITH` clause asks for (OMS §4).
5927///
5928/// The parser has already refused any level word outside
5929/// `summary | headlines | full`, so the fallthrough only ever catches the bare
5930/// `WITH progressive_disclosure` form — read as "everything", since `full` is
5931/// the only tier that adds anything a caller could be asking for.
5932fn disclosure_of(opts: &[super::ast::WithOption]) -> Option<crate::render::Disclosure> {
5933    opts.iter().find_map(|o| match o {
5934        super::ast::WithOption::ProgressiveDisclosure { level } => Some(match level.as_deref() {
5935            Some("summary") => crate::render::Disclosure::Summary,
5936            Some("headlines") => crate::render::Disclosure::Headlines,
5937            _ => crate::render::Disclosure::Full,
5938        }),
5939        _ => None,
5940    })
5941}
5942
5943fn apply_format_clause(
5944    payload: CalResultPayload,
5945    format: &Option<FormatClause>,
5946    grouped_by: Option<&str>,
5947    inputs: RenderInputs<'_>,
5948    // `(context name, FOR intent)` — only an ASSEMBLE has them, and they feed
5949    // `{{assembly.name}}` / `{{assembly.intent}}`.
5950    assemble_ident: Option<(&str, &str)>,
5951    warnings: &mut Vec<String>,
5952) -> std::result::Result<CalResultPayload, CalError> {
5953    let Some(clause) = format else {
5954        return Ok(payload);
5955    };
5956
5957    // Extract grains from the payload, plus the source structure when this
5958    // came from an ASSEMBLE — that structure is what SOURCE_BREAK,
5959    // ELEMENT_OMIT and the assembly./source./budget. namespaces render from,
5960    // and flattening it here is what previously made them unreachable.
5961    let (grains, assembled) = match &payload {
5962        CalResultPayload::Grains { grains, .. } => (grains, None),
5963        CalResultPayload::Assembled {
5964            grains,
5965            sources,
5966            total_tokens,
5967            budget_limit,
5968            ..
5969        } => (grains, Some((sources, *total_tokens, *budget_limit))),
5970        // Non-grain payloads pass through unchanged.
5971        _ => return Ok(payload),
5972    };
5973
5974    let mut render_sources: Vec<super::templates::RenderSource<'_>> = Vec::new();
5975    let mut assembly_ctx: Option<super::templates::AssemblyContext> = None;
5976
5977    if let Some((metas, total_tokens, budget_limit)) = assembled {
5978        // `AssembleEngine` concatenates each source's surviving grains in
5979        // order, so `grain_count` walks the boundaries back out.
5980        let mut offset = 0usize;
5981        for (i, m) in metas.iter().enumerate() {
5982            let end = (offset + m.grain_count).min(grains.len());
5983            render_sources.push(super::templates::RenderSource {
5984                label: &m.label,
5985                grains: &grains[offset..end],
5986                omitted: &m.omitted,
5987                priority: i + 1,
5988                tokens_used: m.tokens_used as usize,
5989                truncated: !m.omitted.is_empty(),
5990            });
5991            offset = end;
5992        }
5993        assembly_ctx = Some(super::templates::AssemblyContext {
5994            name: assemble_ident.map(|(n, _)| n.to_string()).unwrap_or_default(),
5995            intent: assemble_ident.map(|(_, i)| i.to_string()).unwrap_or_default(),
5996            source_count: metas.len(),
5997            grain_count: grains.len(),
5998            budget_total: budget_limit.unwrap_or(0) as u64,
5999            budget_used: total_tokens as u64,
6000            budget_unit: "tokens".to_string(),
6001        });
6002    }
6003
6004    let plan = super::templates::RenderPlan {
6005        assembly: assembly_ctx.as_ref(),
6006        sources: (!render_sources.is_empty()).then_some(render_sources.as_slice()),
6007    };
6008
6009    apply_format_clause_to_grains(
6010        grains, clause, grouped_by, inputs, &plan, warnings,
6011    )
6012}
6013
6014/// Apply a `FormatClause` to a slice of grains, producing either
6015/// `Grains` (for single JSON without GROUP BY), `Formatted` (other single
6016/// formats), or `MultiFormatted` (multi-format list).
6017///
6018/// Special case: `FORMAT json` (single, non-grouped) returns the structured
6019/// `Grains` payload directly so that `result.grains` is a JSON array on the
6020/// wire — not a stringified `result.text`.  This fixes the "CAL RECALL
6021/// returns 0" confusion where clients parsed `result.grains` and found it
6022/// empty because the actual data was in `result.text`.
6023fn apply_format_clause_to_grains(
6024    grains: &[CalGrainResult],
6025    clause: &FormatClause,
6026    grouped_by: Option<&str>,
6027    inputs: RenderInputs<'_>,
6028    plan: &super::templates::RenderPlan<'_>,
6029    warnings: &mut Vec<String>,
6030) -> std::result::Result<CalResultPayload, CalError> {
6031    match clause {
6032        FormatClause::Single(super::ast::FormatSpec::Json) if grouped_by.is_none() => {
6033            // FORMAT json is a no-op for the JSON wire format: grains are
6034            // already serialisable.  Return Grains directly so that
6035            // `result.grains` is a structured array.
6036            Ok(CalResultPayload::Grains {
6037                grains: grains.to_vec(),
6038                total_available: Some(grains.len()),
6039            })
6040        }
6041        FormatClause::Single(spec) => {
6042            format_grain_results(
6043                grains, spec, grouped_by, inputs, plan, warnings,
6044            )
6045        }
6046        FormatClause::Multi(entries) => {
6047            let mut formats = HashMap::new();
6048            for entry in entries {
6049                let rendered =
6050                    format_grain_results(
6051                        grains, &entry.spec, grouped_by, inputs, plan, warnings,
6052                    )?;
6053                if let CalResultPayload::Formatted { text, format, .. } = rendered {
6054                    let key = entry.alias.clone().unwrap_or(format);
6055                    formats.insert(key, text);
6056                }
6057            }
6058            Ok(CalResultPayload::MultiFormatted {
6059                formats,
6060                grain_count: grains.len(),
6061                grains: grains.to_vec(),
6062            })
6063        }
6064    }
6065}
6066
6067// ---------------------------------------------------------------------------
6068// FORMAT rendering (WI-1.1)
6069// ---------------------------------------------------------------------------
6070
6071/// Render grains using the specified FORMAT clause.
6072///
6073/// Returns a `CalResultPayload::Formatted` with the rendered text and format
6074/// name. When `grouped_by` is `Some`, grains are assumed to already be in
6075/// group order (from `| GROUP BY`) and renderers emit group headers.
6076/// The shared-render view of a CAL result grain. `created_at` in result
6077/// fields is epoch milliseconds; the view carries seconds.
6078fn grain_view(grain: &CalGrainResult) -> crate::render::GrainView<'_> {
6079    crate::render::GrainView {
6080        grain_type: &grain.grain_type,
6081        hash: &grain.hash,
6082        fields: &grain.fields,
6083        created_at_sec: crate::render::created_at_sec_from_fields(&grain.fields),
6084    }
6085}
6086
6087/// Disclosure tier for a template render (§10.5/§10.6): an ASSEMBLE token
6088/// budget squeezes per-grain rendering from ELEMENT toward ELEMENT_SUMMARY
6089/// via `select_tier`; a grain-unit budget or a plain RECALL renders Full.
6090fn template_tier(
6091    plan: &super::templates::RenderPlan<'_>,
6092    grain_count: usize,
6093) -> super::templates::DisclosureTier {
6094    match plan.assembly {
6095        Some(a) if a.budget_unit == "tokens" && a.budget_total > 0 => {
6096            super::templates::select_tier(a.budget_total.min(u32::MAX as u64) as u32, grain_count)
6097        }
6098        _ => super::templates::DisclosureTier::Full,
6099    }
6100}
6101
6102fn format_grain_results(
6103    grains: &[CalGrainResult],
6104    format: &super::ast::FormatSpec,
6105    grouped_by: Option<&str>,
6106    inputs: RenderInputs<'_>,
6107    plan: &super::templates::RenderPlan<'_>,
6108    warnings: &mut Vec<String>,
6109) -> std::result::Result<CalResultPayload, CalError> {
6110    let RenderInputs { user_vars, store, disclosure } = inputs;
6111    let (text, format_name) = match format {
6112        super::ast::FormatSpec::Json => {
6113            if let Some(field) = grouped_by {
6114                let groups = collect_groups(grains, field);
6115                let json_groups: Vec<serde_json::Value> = groups
6116                    .iter()
6117                    .map(|(key, members)| {
6118                        serde_json::json!({
6119                            "group_key": key,
6120                            "count": members.len(),
6121                            "grains": members,
6122                        })
6123                    })
6124                    .collect();
6125                let json = serde_json::to_string_pretty(&json_groups).unwrap_or_default();
6126                (json, "json")
6127            } else {
6128                let json = serde_json::to_string_pretty(grains).unwrap_or_default();
6129                (json, "json")
6130            }
6131        }
6132        super::ast::FormatSpec::Markdown => {
6133            let mut md = String::new();
6134            if let Some(field) = grouped_by {
6135                let groups = collect_groups(grains, field);
6136                for (key, members) in &groups {
6137                    md.push_str(&format!(
6138                        "### {} ({} {})\n\n",
6139                        key,
6140                        members.len(),
6141                        if members.len() == 1 {
6142                            "memory"
6143                        } else {
6144                            "memories"
6145                        }
6146                    ));
6147                    for grain in members {
6148                        md.push_str(&crate::render::render_grain_markdown_at(
6149                            &grain_view(grain),
6150                            disclosure,
6151                        ));
6152                        md.push('\n');
6153                    }
6154                }
6155            } else {
6156                // No per-grain heading: a hash and a type repeated above every
6157                // line is noise in a prompt, and the assertion already names
6158                // what it is about.
6159                for grain in grains {
6160                    md.push_str(&crate::render::render_grain_markdown_at(
6161                        &grain_view(grain),
6162                        disclosure,
6163                    ));
6164                    md.push('\n');
6165                }
6166            }
6167            (md, "markdown")
6168        }
6169        super::ast::FormatSpec::Yaml => {
6170            // Simple YAML-like output.
6171            let mut yaml = String::new();
6172            for (i, grain) in grains.iter().enumerate() {
6173                yaml.push_str(&format!("- hash: \"{}\"\n", grain.hash));
6174                yaml.push_str(&format!("  grain_type: \"{}\"\n", grain.grain_type));
6175                if let serde_json::Value::Object(map) = &grain.fields {
6176                    yaml.push_str("  fields:\n");
6177                    for (k, v) in map {
6178                        yaml.push_str(&format!("    {}: {}\n", k, v));
6179                    }
6180                }
6181                if i < grains.len() - 1 {
6182                    yaml.push('\n');
6183                }
6184            }
6185            (yaml, "yaml")
6186        }
6187        super::ast::FormatSpec::Text => {
6188            let mut text = String::new();
6189            if let Some(field) = grouped_by {
6190                let groups = collect_groups(grains, field);
6191                let total_groups = groups.len();
6192                for (idx, (key, members)) in groups.iter().enumerate() {
6193                    text.push_str(&format!(
6194                        "--- Group {}/{}: {} ({} {}) ---\n",
6195                        idx + 1,
6196                        total_groups,
6197                        key,
6198                        members.len(),
6199                        if members.len() == 1 {
6200                            "memory"
6201                        } else {
6202                            "memories"
6203                        }
6204                    ));
6205                    for (i, grain) in members.iter().enumerate() {
6206                        text.push_str(&crate::render::render_grain_text_line(
6207                            &grain_view(grain),
6208                            Some(i + 1),
6209                        ));
6210                        text.push('\n');
6211                    }
6212                    if idx + 1 < total_groups {
6213                        text.push('\n');
6214                    }
6215                }
6216            } else {
6217                for grain in grains {
6218                    text.push_str(&crate::render::render_grain_text_line(
6219                        &grain_view(grain),
6220                        None,
6221                    ));
6222                    text.push('\n');
6223                }
6224            }
6225            (text, "text")
6226        }
6227        super::ast::FormatSpec::Sml => {
6228            // Semantic per-type elements via the shared renderer; the
6229            // `<grains>` / `<group>` envelope is this surface's own.
6230            let level = crate::render::MetadataDetail::Minimal;
6231            let mut sml = String::from("<grains>\n");
6232            if let Some(field) = grouped_by {
6233                let groups = collect_groups(grains, field);
6234                for (key, members) in &groups {
6235                    let escaped_key = crate::render::sml_escape(key);
6236                    sml.push_str(&format!(
6237                        "  <group key=\"{}\" count=\"{}\">\n",
6238                        escaped_key,
6239                        members.len()
6240                    ));
6241                    for grain in members {
6242                        sml.push_str("    ");
6243                        sml.push_str(&crate::render::render_grain_sml_at(
6244                            &grain_view(grain),
6245                            level,
6246                            disclosure,
6247                        ));
6248                        sml.push('\n');
6249                    }
6250                    sml.push_str("  </group>\n");
6251                }
6252            } else {
6253                for grain in grains {
6254                    sml.push_str("  ");
6255                    sml.push_str(&crate::render::render_grain_sml_at(
6256                        &grain_view(grain),
6257                        level,
6258                        disclosure,
6259                    ));
6260                    sml.push('\n');
6261                }
6262            }
6263            sml.push_str("</grains>");
6264            (sml, "sml")
6265        }
6266        super::ast::FormatSpec::Toon => {
6267            let views: Vec<crate::render::GrainView<'_>> = grains.iter().map(grain_view).collect();
6268            (crate::render::render_toon(&views), "toon")
6269        }
6270        super::ast::FormatSpec::Triples => {
6271            let mut triples = String::new();
6272            for grain in grains {
6273                if let serde_json::Value::Object(map) = &grain.fields {
6274                    let s = map.get("subject").and_then(|v| v.as_str()).unwrap_or("_");
6275                    let r = map.get("relation").and_then(|v| v.as_str()).unwrap_or("_");
6276                    let o = map.get("object").and_then(|v| v.as_str()).unwrap_or("_");
6277                    triples.push_str(&format!("{}\t{}\t{}\n", s, r, o));
6278                }
6279            }
6280            (triples, "triples")
6281        }
6282        super::ast::FormatSpec::Csv => {
6283            let mut csv = String::from("hash,grain_type,subject,relation,object,confidence\n");
6284            for grain in grains {
6285                let get_field = |f: &str| -> String {
6286                    json_field(&grain.fields, f)
6287                        .map(|v| match v {
6288                            serde_json::Value::String(s) => {
6289                                // Escape quotes and wrap if contains comma or newline.
6290                                if s.contains(',') || s.contains('"') || s.contains('\n') {
6291                                    format!("\"{}\"", s.replace('"', "\"\""))
6292                                } else {
6293                                    s.clone()
6294                                }
6295                            }
6296                            _ => v.to_string(),
6297                        })
6298                        .unwrap_or_default()
6299                };
6300                csv.push_str(&format!(
6301                    "{},{},{},{},{},{}\n",
6302                    grain.hash,
6303                    grain.grain_type,
6304                    get_field("subject"),
6305                    get_field("relation"),
6306                    get_field("object"),
6307                    get_field("confidence"),
6308                ));
6309            }
6310            (csv, "csv")
6311        }
6312        super::ast::FormatSpec::Table => {
6313            let header = "| hash | grain_type | subject | relation | object | confidence |";
6314            let separator = "| --- | --- | --- | --- | --- | --- |";
6315            let mut table = format!("{}\n{}\n", header, separator);
6316            for grain in grains {
6317                let get_field = |f: &str| -> String {
6318                    json_field(&grain.fields, f)
6319                        .map(|v| match v {
6320                            serde_json::Value::String(s) => s.replace('|', "\\|"),
6321                            _ => v.to_string(),
6322                        })
6323                        .unwrap_or_default()
6324                };
6325                table.push_str(&format!(
6326                    "| {} | {} | {} | {} | {} | {} |\n",
6327                    &grain.hash[..8.min(grain.hash.len())],
6328                    grain.grain_type,
6329                    get_field("subject"),
6330                    get_field("relation"),
6331                    get_field("object"),
6332                    get_field("confidence"),
6333                ));
6334            }
6335            (table, "table")
6336        }
6337        // `FORMAT TEMPLATE <name>` (§10.6) and the older `FORMAT preset "<name>"`
6338        // resolve identically — both name a registered template.
6339        super::ast::FormatSpec::Preset { name } | super::ast::FormatSpec::TemplateRef { name } => {
6340            // Look up the preset name in the template registry.
6341            let info = store
6342                .get_template(name)
6343                .ok_or_else(|| CalError::TemplateNotFound {
6344                    name: name.clone(),
6345                    span: None,
6346                })?;
6347            // Parse and render using the proper Mustache template engine.
6348            // `parse_template_any` recovers the §10.6 sectioned form, so a
6349            // sectioned template renders through the engine-driven pipeline
6350            // rather than being emitted as its own source text.
6351            let mut parsed = super::templates::parse_template_any(&info.source)?;
6352
6353            // §10.7 inheritance. Registration validates the parent but the
6354            // render path never applied it, so EXTENDS was inert; the default
6355            // `readable` parent is what supplies ELEMENT_SUMMARY to a
6356            // sectioned template that only defines ELEMENT.
6357            let parent_name = info.parent.clone().or_else(|| {
6358                (parsed.is_sectioned() && !info.builtin).then(|| "readable".to_string())
6359            });
6360            if let Some(parent_name) = parent_name {
6361                if let Some(parent_info) = store.get_template(&parent_name) {
6362                    let parent = super::templates::parse_template_any(&parent_info.source)?;
6363                    parsed = super::templates::merge_templates(&parent, &parsed);
6364                }
6365            }
6366            let user_vars_map: std::collections::HashMap<String, String> = user_vars
6367                .iter()
6368                .map(|(k, v)| (k.clone(), v.clone()))
6369                .collect();
6370            let ctx = super::templates::RenderContext {
6371                now_secs: std::time::SystemTime::now()
6372                    .duration_since(std::time::UNIX_EPOCH)
6373                    .map(|d| d.as_secs() as i64)
6374                    .unwrap_or(0),
6375                tier: template_tier(plan, grains.len()),
6376                total_count: grains.len(),
6377                user_vars: user_vars_map,
6378            };
6379            let rendered = match parsed.sections() {
6380                Some(sections) => super::templates::render_sectioned(
6381                    sections,
6382                    plan.sources
6383                        .unwrap_or(&[super::templates::RenderSource::single(grains)]),
6384                    &ctx,
6385                    plan.assembly,
6386                    super::templates::MAX_RENDER_OUTPUT_SIZE,
6387                )?,
6388                None => {
6389                    // §10.8 caps {{#each}} at 200. Truncating quietly would
6390                    // read as "these are all the grains", so say so.
6391                    if let Some((rendered, total)) =
6392                        super::templates::each_iteration_cap(&parsed, grains.len())
6393                    {
6394                        warnings.push(
6395                            super::errors::CalWarning::EachIterationCapped {
6396                                rendered,
6397                                total,
6398                                max: super::templates::MAX_EACH_ITERATIONS,
6399                            }
6400                            .to_string(),
6401                        );
6402                    }
6403                    super::templates::render(&parsed, grains, &ctx)?
6404                }
6405            };
6406            store.record_template_run(name);
6407            let key = match format {
6408                super::ast::FormatSpec::Preset { .. } => "preset",
6409                _ => "template",
6410            };
6411            (rendered, key)
6412        }
6413        super::ast::FormatSpec::Template { template } => {
6414            // `FORMAT TEMPLATE "<text>"` is an inline template in the §10.6.1
6415            // ELEMENT shorthand: the string renders one grain and the engine
6416            // iterates. It goes through the same parser, variable set and
6417            // limits as a registered template — this used to be naive string
6418            // substitution, which silently ignored filters, conditionals and
6419            // the closed variable set.
6420            let user_vars_map: std::collections::HashMap<String, String> = user_vars
6421                .iter()
6422                .map(|(k, v)| (k.clone(), v.clone()))
6423                .collect();
6424            let ctx = super::templates::RenderContext {
6425                now_secs: std::time::SystemTime::now()
6426                    .duration_since(std::time::UNIX_EPOCH)
6427                    .map(|d| d.as_secs() as i64)
6428                    .unwrap_or(0),
6429                tier: template_tier(plan, grains.len()),
6430                total_count: grains.len(),
6431                user_vars: user_vars_map,
6432            };
6433            let sections = super::templates::TemplateSections {
6434                element: Some(super::templates::parse_section(template)?),
6435                ..Default::default()
6436            };
6437            let rendered = super::templates::render_sectioned(
6438                &sections,
6439                plan.sources
6440                    .unwrap_or(&[super::templates::RenderSource::single(grains)]),
6441                &ctx,
6442                plan.assembly,
6443                super::templates::MAX_RENDER_OUTPUT_SIZE,
6444            )?;
6445            (rendered, "template")
6446        }
6447        super::ast::FormatSpec::TemplateInline { sections } => {
6448            // `FORMAT TEMPLATE { HEADER { ... } ELEMENT { ... } }` — the same
6449            // section pipeline as a registered template, defined at the point
6450            // of use.
6451            let user_vars_map: std::collections::HashMap<String, String> = user_vars
6452                .iter()
6453                .map(|(k, v)| (k.clone(), v.clone()))
6454                .collect();
6455            let ctx = super::templates::RenderContext {
6456                now_secs: std::time::SystemTime::now()
6457                    .duration_since(std::time::UNIX_EPOCH)
6458                    .map(|d| d.as_secs() as i64)
6459                    .unwrap_or(0),
6460                tier: template_tier(plan, grains.len()),
6461                total_count: grains.len(),
6462                user_vars: user_vars_map,
6463            };
6464            let parsed = super::templates::parse_sections(sections)?;
6465            let rendered = super::templates::render_sectioned(
6466                &parsed,
6467                plan.sources
6468                    .unwrap_or(&[super::templates::RenderSource::single(grains)]),
6469                &ctx,
6470                plan.assembly,
6471                super::templates::MAX_RENDER_OUTPUT_SIZE,
6472            )?;
6473            (rendered, "template")
6474        }
6475    };
6476
6477    Ok(CalResultPayload::Formatted {
6478        text,
6479        format: format_name.to_string(),
6480        grain_count: grains.len(),
6481        grains: grains.to_vec(),
6482    })
6483}
6484
6485// ---------------------------------------------------------------------------
6486// GROUP BY helpers
6487// ---------------------------------------------------------------------------
6488
6489/// Group grains by field value, reorder so same-value grains are contiguous,
6490/// sorted chronologically within each group. Groups ordered by earliest
6491/// `created_at_sec`.
6492fn group_grains_by_field(grains: Vec<CalGrainResult>, field: &str) -> Vec<CalGrainResult> {
6493    // Collect grains into groups keyed by the field value.
6494    let mut groups: BTreeMap<String, Vec<CalGrainResult>> = BTreeMap::new();
6495    for grain in grains {
6496        let key = json_field(&grain.fields, field)
6497            .map(|v| match v {
6498                serde_json::Value::String(s) => s.clone(),
6499                _ => v.to_string(),
6500            })
6501            .unwrap_or_default();
6502        groups.entry(key).or_default().push(grain);
6503    }
6504
6505    // Sort within each group by created_at_sec ascending.
6506    for members in groups.values_mut() {
6507        members.sort_by(|a, b| {
6508            let ta = json_field(&a.fields, "created_at_sec").and_then(|v| v.as_f64());
6509            let tb = json_field(&b.fields, "created_at_sec").and_then(|v| v.as_f64());
6510            ta.partial_cmp(&tb).unwrap_or(Ordering::Equal)
6511        });
6512    }
6513
6514    // Order groups by the earliest created_at_sec in each group.
6515    let mut group_vec: Vec<(String, Vec<CalGrainResult>)> = groups.into_iter().collect();
6516    group_vec.sort_by(|a, b| {
6517        let earliest_a =
6518            a.1.first()
6519                .and_then(|g| json_field(&g.fields, "created_at_sec").and_then(|v| v.as_f64()));
6520        let earliest_b =
6521            b.1.first()
6522                .and_then(|g| json_field(&g.fields, "created_at_sec").and_then(|v| v.as_f64()));
6523        earliest_a
6524            .partial_cmp(&earliest_b)
6525            .unwrap_or(Ordering::Equal)
6526    });
6527
6528    // Move empty-key group to the end.
6529    if let Some(pos) = group_vec.iter().position(|(k, _)| k.is_empty()) {
6530        let empty = group_vec.remove(pos);
6531        group_vec.push(empty);
6532    }
6533
6534    // Flatten.
6535    group_vec
6536        .into_iter()
6537        .flat_map(|(_, members)| members)
6538        .collect()
6539}
6540
6541/// Collect already-grouped grains into `(key, members)` pairs by detecting
6542/// contiguous runs of the same field value.
6543fn collect_groups<'a>(
6544    grains: &'a [CalGrainResult],
6545    field: &str,
6546) -> Vec<(String, Vec<&'a CalGrainResult>)> {
6547    let mut groups: Vec<(String, Vec<&CalGrainResult>)> = Vec::new();
6548    for grain in grains {
6549        let key = json_field(&grain.fields, field)
6550            .map(|v| match v {
6551                serde_json::Value::String(s) => s.clone(),
6552                _ => v.to_string(),
6553            })
6554            .unwrap_or_default();
6555        if let Some(last) = groups.last_mut() {
6556            if last.0 == key {
6557                last.1.push(grain);
6558                continue;
6559            }
6560        }
6561        groups.push((key, vec![grain]));
6562    }
6563    groups
6564}
6565
6566// ---------------------------------------------------------------------------
6567// Grain-type-specific field validation and filtering (WI-1.6)
6568// ---------------------------------------------------------------------------
6569
6570/// Common fields shared by all grain types. These are handled directly by
6571/// `apply_where_clause` and do not need post-retrieval filtering. Aligned
6572/// with OMS §5.2 common-field set plus Areev-specific extensions retained
6573/// for backward compatibility with persisted queries (`created_at`,
6574/// `summary`, `content`, `grain_type`, plus four cross-grain extensions
6575/// `scope`/`scope_path`/`priority`/`status` that pre-date the spec audit).
6576const COMMON_FIELDS: &[&str] = &[
6577    // Spec §5.2 — 18 fields.
6578    "subject",
6579    "relation",
6580    "object",
6581    "namespace",
6582    "user_id",
6583    "confidence",
6584    "importance",
6585    "score",
6586    "tags",
6587    "type",
6588    "time",
6589    "hash",
6590    "contradicted",
6591    "verification_status",
6592    "source_type",
6593    "recall_priority",
6594    "epistemic_status",
6595    "query",
6596    // Areev extensions (pre-spec; kept for back-compat).
6597    "created_at",
6598    "content",
6599    "summary",
6600    "grain_type",
6601    "scope",
6602    "scope_path",
6603    "priority",
6604    "status",
6605];
6606
6607/// Return the known type-specific fields for a grain type plural name.
6608///
6609/// These fields are NOT part of the engine's `RecallParams` and must be
6610/// filtered post-retrieval by inspecting the grain's `fields` JSON. Each
6611/// list combines the spec-mandated fields (per OMS §6.3) with Areev
6612/// implementation extensions (e.g. `session_id` cross-cutting marker,
6613/// `nodes`/`bindings` plural aliases) kept for backward compatibility.
6614fn type_specific_fields(grain_type: &GrainTypePlural) -> &'static [&'static str] {
6615    // Data-only — sourced from the grain-type registry (D1). The wildcard has
6616    // no single type, so it lists no type-specific fields.
6617    match grain_type.to_grain_type() {
6618        Some(ty) => areev_core::types::registry::meta(ty).queryable_fields,
6619        None => &[],
6620    }
6621}
6622
6623/// Check if a field is a known type-specific field for ANY grain type.
6624fn is_known_type_specific_field(field: &str) -> bool {
6625    // Sourced from the registry (D1) — every type's queryable fields.
6626    areev_core::types::registry::GRAIN_TYPES
6627        .iter()
6628        .any(|m| m.queryable_fields.contains(&field))
6629}
6630
6631/// Suggest the closest valid field name for a given unknown field on a grain type.
6632fn suggest_field(field: &str, grain_type: &GrainTypePlural) -> Option<String> {
6633    let valid_fields = type_specific_fields(grain_type);
6634    for known in valid_fields {
6635        // Simple Levenshtein-like: if they share a common prefix or one contains the other.
6636        if known.contains(field) || field.contains(known) {
6637            return Some(known.to_string());
6638        }
6639    }
6640    // Check if the field is valid on a different grain type.
6641    if is_known_type_specific_field(field) {
6642        return Some(format!(
6643            "'{}' exists on a different grain type, not on {}",
6644            field,
6645            grain_type.as_str()
6646        ));
6647    }
6648    None
6649}
6650
6651// ---------------------------------------------------------------------------
6652// #91 — WHERE planning: push-down vs residual, refuse-instead-of-widen
6653// ---------------------------------------------------------------------------
6654
6655/// Fields that exist only at engine level: they narrow the SCAN (BM25 text,
6656/// temporal windows, the entity graph, fork status, scope/prefix registry,
6657/// the tags index) and have no per-grain value the post-filter could read.
6658/// A condition on one of these that push-down cannot consume — under
6659/// `NOT`/`OR`, or with an unsupported comparator — is refused with
6660/// `CAL-E061` rather than silently widening the result.
6661const ENGINE_ONLY_FIELDS: &[&str] = &[
6662    "query",
6663    "time",
6664    "entity",
6665    "contradicted",
6666    "scope",
6667    "scope_path",
6668    "tags",
6669];
6670
6671/// Common fields the residual filter can evaluate on ANY grain type: they
6672/// live in the grain's field map (OMS §5.2 common set) or on the result
6673/// envelope (`hash`, `grain_type`/`type`, `score`). A grain that does not
6674/// carry the field simply does not match — narrowing, never widening.
6675const GRAIN_EVALUABLE_COMMON: &[&str] = &[
6676    "subject",
6677    "relation",
6678    "object",
6679    "namespace",
6680    "user_id",
6681    "confidence",
6682    "importance",
6683    "created_at",
6684    "verification_status",
6685    "source_type",
6686    "recall_priority",
6687    "epistemic_status",
6688    "content",
6689    "summary",
6690    "hash",
6691    "grain_type",
6692    "type",
6693    "score",
6694];
6695
6696/// Does `apply_where_clause` consume this leaf into `RecallParams`?
6697///
6698/// Exactly the arms of that match — the two must stay in sync (test-pinned
6699/// by `test_pushdown_consumed_matches_apply_where_clause_arms`). A consumed
6700/// leaf is replaced by TRUE in the residual tree: the engine already
6701/// narrowed by it, and re-checking per grain would be wrong for filters
6702/// with engine semantics (namespace prefix scoping, BM25 `query`, …).
6703///
6704/// Deliberately NOT consumed even though push-down also sets a param:
6705/// `session_id` — its push-down narrows the scan via the thread index but
6706/// the post-filter remains authoritative (`session_id` stays out of
6707/// `COMMON_FIELDS`, test-pinned), so it is re-checked per grain.
6708fn leaf_pushdown_consumed(condition: &Condition) -> bool {
6709    match condition {
6710        Condition::Comparison {
6711            field, comparator, ..
6712        } => matches!(
6713            (field.as_str(), comparator),
6714            (
6715                "subject" | "relation" | "object" | "namespace" | "user_id" | "query" | "time"
6716                    | "entity" | "scope" | "scope_path",
6717                Comparator::Eq
6718            ) | ("confidence" | "importance", Comparator::Gte | Comparator::Gt)
6719                | ("contradicted", Comparator::Eq)
6720        ),
6721        Condition::In { field, .. } => matches!(
6722            field.as_str(),
6723            "subject" | "relation" | "object" | "tags" | "namespace"
6724        ),
6725        Condition::NotIn { field, .. } => field == "tags",
6726        Condition::Contains { field, .. } => {
6727            matches!(field.as_str(), "subject" | "object" | "content" | "summary")
6728        }
6729        // IS CATEGORY on `relation` desugars to relation_in; on any other
6730        // field it is warned (CAL-W008) and ignored — both are "handled" by
6731        // push-down, so neither reaches the residual filter.
6732        Condition::IsCategory { .. } => true,
6733        _ => false,
6734    }
6735}
6736
6737/// Plan the residual WHERE tree for a recall-shaped statement.
6738///
6739/// Splits `condition` into what the engine's push-down consumed and a
6740/// residual tree that `grain_matches_condition_tree` must evaluate per
6741/// grain after retrieval. Returns `Ok(None)` when push-down consumed
6742/// everything. Every residual leaf is validated first:
6743///
6744/// - engine-only fields ([`ENGINE_ONLY_FIELDS`]) in residual position →
6745///   `CAL-E061` (they have no per-grain value);
6746/// - a field a typed recall cannot carry (not grain-evaluable-common, not
6747///   in the type's queryable set, not domain-prefixed) → `CAL-E060`;
6748/// - on an untyped recall an unknown field warns `CAL-W010` and still
6749///   filters (matching only grains that carry it).
6750///
6751/// This is the #91 fix: a filter is either pushed down, evaluated per
6752/// grain, or refused — never dropped. `NOT`/`OR` subtrees are never pushed
6753/// (push-down is conjunctive-positive only), so they land here whole and
6754/// are evaluated with full boolean semantics by the ONE authoritative
6755/// evaluator, `grain_matches_condition_tree`.
6756fn plan_residual_where(
6757    condition: &Condition,
6758    grain_type: &GrainTypePlural,
6759    warnings: &mut Vec<String>,
6760) -> std::result::Result<Option<Condition>, CalError> {
6761    match condition {
6762        Condition::And { left, right, span } => {
6763            let l = plan_residual_where(left, grain_type, warnings)?;
6764            let r = plan_residual_where(right, grain_type, warnings)?;
6765            Ok(match (l, r) {
6766                (None, None) => None,
6767                (Some(c), None) | (None, Some(c)) => Some(c),
6768                (Some(a), Some(b)) => Some(Condition::And {
6769                    left: Box::new(a),
6770                    right: Box::new(b),
6771                    span: *span,
6772                }),
6773            })
6774        }
6775        Condition::Or { left, right, .. } => {
6776            validate_residual_subtree(left, grain_type, "under NOT/OR", warnings)?;
6777            validate_residual_subtree(right, grain_type, "under NOT/OR", warnings)?;
6778            Ok(Some(condition.clone()))
6779        }
6780        Condition::Not { inner, .. } => {
6781            validate_residual_subtree(inner, grain_type, "under NOT/OR", warnings)?;
6782            Ok(Some(condition.clone()))
6783        }
6784        leaf => {
6785            if leaf_pushdown_consumed(leaf) {
6786                Ok(None)
6787            } else {
6788                validate_residual_leaf(leaf, grain_type, None, warnings)?;
6789                Ok(Some(leaf.clone()))
6790            }
6791        }
6792    }
6793}
6794
6795/// Validate every leaf of a subtree that will be evaluated per grain
6796/// (a `NOT`/`OR` subtree — nothing inside it was pushed down).
6797fn validate_residual_subtree(
6798    condition: &Condition,
6799    grain_type: &GrainTypePlural,
6800    context: &str,
6801    warnings: &mut Vec<String>,
6802) -> std::result::Result<(), CalError> {
6803    match condition {
6804        Condition::And { left, right, .. } | Condition::Or { left, right, .. } => {
6805            validate_residual_subtree(left, grain_type, context, warnings)?;
6806            validate_residual_subtree(right, grain_type, context, warnings)
6807        }
6808        Condition::Not { inner, .. } => {
6809            validate_residual_subtree(inner, grain_type, context, warnings)
6810        }
6811        leaf => validate_residual_leaf(leaf, grain_type, Some(context), warnings),
6812    }
6813}
6814
6815/// Validate one residual leaf. `forced_context` is set for leaves inside a
6816/// `NOT`/`OR` subtree; a bare leaf reports its own comparator instead.
6817fn validate_residual_leaf(
6818    leaf: &Condition,
6819    grain_type: &GrainTypePlural,
6820    forced_context: Option<&str>,
6821    warnings: &mut Vec<String>,
6822) -> std::result::Result<(), CalError> {
6823    let (field, span, own_context): (&str, Option<Span>, String) = match leaf {
6824        Condition::Comparison {
6825            field,
6826            comparator,
6827            span,
6828            ..
6829        } => (field, *span, format!("with comparator {comparator}")),
6830        Condition::In { field, span, .. } => (field, *span, "with IN".to_string()),
6831        Condition::NotIn { field, span, .. } => (field, *span, "with NOT IN".to_string()),
6832        Condition::Contains { field, span, .. } => (field, *span, "with CONTAINS".to_string()),
6833        Condition::StartsWith { field, span, .. } => {
6834            (field, *span, "with STARTS WITH".to_string())
6835        }
6836        Condition::IsNull { field, span, .. } | Condition::IsNotNull { field, span, .. } => {
6837            (field, *span, "with IS [NOT] NULL".to_string())
6838        }
6839        Condition::IsCategory { field, span, .. } => (field, *span, "with IS".to_string()),
6840        // Non-leaf variants never reach here.
6841        _ => return Ok(()),
6842    };
6843
6844    // Domain-prefixed fields (hc:patient_id, fin:account, …) ride in
6845    // extra_fields and are valid by structure on every type.
6846    if field.contains(':') {
6847        return Ok(());
6848    }
6849    if ENGINE_ONLY_FIELDS.contains(&field) {
6850        return Err(CalError::EngineFieldNotFilterable {
6851            field: field.to_string(),
6852            context: forced_context.map(str::to_string).unwrap_or(own_context),
6853            span,
6854        });
6855    }
6856    if GRAIN_EVALUABLE_COMMON.contains(&field) {
6857        return Ok(());
6858    }
6859    if grain_type.to_grain_type().is_some() {
6860        if type_specific_fields(grain_type).contains(&field) {
6861            return Ok(());
6862        }
6863        let suggestion = suggest_field(field, grain_type);
6864        return Err(CalError::FieldNotOnGrainType {
6865            field: field.to_string(),
6866            grain_type: grain_type.as_str().to_string(),
6867            span,
6868            suggestion,
6869        });
6870    }
6871    // Untyped recall: no type to validate against. An unknown-everywhere
6872    // field still filters (matching only grains that carry it) but warns,
6873    // because it is far more likely a typo than a domain field.
6874    if !is_known_type_specific_field(field) {
6875        warnings.push(
6876            super::errors::CalWarning::UnrecognizedWhereField {
6877                field: field.to_string(),
6878                span,
6879            }
6880            .to_string(),
6881        );
6882    }
6883    Ok(())
6884}
6885
6886/// Apply a type-specific field condition to a single grain result.
6887///
6888/// Returns `true` if the grain matches the condition.
6889pub fn grain_matches_condition(
6890    grain: &CalGrainResult,
6891    field: &str,
6892    comparator: &Comparator,
6893    value: &Value,
6894) -> bool {
6895    // Envelope fields are not in `fields` — a grain's content address is a
6896    // property *of* the blob, so it cannot be inside it. Looking `hash` up in
6897    // `fields` therefore always missed, which is why `hash IN ("<real hash>")`
6898    // matched nothing. A `sha256:` prefix is accepted because that is how the
6899    // rest of CAL spells an address.
6900    let envelope: Option<serde_json::Value> = match field {
6901        "hash" => Some(serde_json::Value::String(grain.hash.clone())),
6902        // `type` is the OMS §5.2 spelling of the same envelope property.
6903        "grain_type" | "type" => Some(serde_json::Value::String(grain.grain_type.clone())),
6904        // The fused relevance score lives on the envelope, not in `fields`.
6905        "score" => serde_json::Number::from_f64(grain.score).map(serde_json::Value::Number),
6906        // Omit-default discriminators (#91): canonical serialization omits
6907        // the default value to keep legacy blobs byte-identical, so an
6908        // absent field MEANS the default and a filter must see it that way
6909        // (`kind = "execution"` has to match a grain that never wrote
6910        // `kind`).
6911        "kind" if grain.grain_type == "tool" && json_field(&grain.fields, "kind").is_none() => {
6912            Some(serde_json::Value::String("execution".into()))
6913        }
6914        "status" if grain.grain_type == "tool" && json_field(&grain.fields, "status").is_none() => {
6915            Some(serde_json::Value::String("completed".into()))
6916        }
6917        _ => None,
6918    };
6919    let grain_value = match &envelope {
6920        Some(v) => Some(v),
6921        None => json_field(&grain.fields, field),
6922    };
6923    let value = &match (field, value) {
6924        ("hash", Value::String { value: v }) => Value::String {
6925            value: v.strip_prefix("sha256:").unwrap_or(v).to_string(),
6926        },
6927        _ => value.clone(),
6928    };
6929
6930    match comparator {
6931        Comparator::Eq => match value {
6932            Value::String { value: target } => grain_value
6933                .and_then(|v| v.as_str())
6934                .map(|s| s == target.as_str())
6935                .unwrap_or(false),
6936            Value::Number { value: target } => grain_value
6937                .and_then(|v| v.as_f64())
6938                .map(|n| (n - target).abs() < f64::EPSILON)
6939                .unwrap_or(false),
6940            Value::Boolean { value: target } => grain_value
6941                .and_then(|v| v.as_bool())
6942                .map(|b| b == *target)
6943                .unwrap_or(false),
6944            _ => false,
6945        },
6946        Comparator::NotEq => !grain_matches_condition(grain, field, &Comparator::Eq, value),
6947        Comparator::Gte => match value {
6948            Value::Number { value: target } => grain_value
6949                .and_then(|v| v.as_f64())
6950                .map(|n| n >= *target)
6951                .unwrap_or(false),
6952            _ => false,
6953        },
6954        Comparator::Gt => match value {
6955            Value::Number { value: target } => grain_value
6956                .and_then(|v| v.as_f64())
6957                .map(|n| n > *target)
6958                .unwrap_or(false),
6959            _ => false,
6960        },
6961        Comparator::Lte => match value {
6962            Value::Number { value: target } => grain_value
6963                .and_then(|v| v.as_f64())
6964                .map(|n| n <= *target)
6965                .unwrap_or(false),
6966            _ => false,
6967        },
6968        Comparator::Lt => match value {
6969            Value::Number { value: target } => grain_value
6970                .and_then(|v| v.as_f64())
6971                .map(|n| n < *target)
6972                .unwrap_or(false),
6973            _ => false,
6974        },
6975    }
6976}
6977
6978/// Evaluate a full `Condition` tree against a single grain result.
6979///
6980/// Public because it is the ONE boolean evaluator in the workspace, and a
6981/// second implementation would be a second set of semantics. `areev-trigger`
6982/// uses it for memory-trigger predicates and composite gates: the `Condition`
6983/// AST already has And/Or/Not with parenthesised grouping and the right
6984/// precedence, it already serializes, and it is total — a missing field is
6985/// false rather than an error. Reusing it also keeps `areev_run_core::cond`'s
6986/// standing exclusion on expression languages intact, because nothing new is
6987/// parsed.
6988///
6989/// Note this is the AUTHORITATIVE match. Since #91 the recall path uses it
6990/// too: `plan_residual_where` routes everything the structural push-down
6991/// does not consume — NOT/OR subtrees included — through this evaluator, so
6992/// a recall result reflects the whole WHERE clause rather than widening.
6993///
6994/// Used by `PipelineStage::Filter` (post-pipeline WHERE) to filter grains
6995/// by conditions after pipeline stages like SELECT have been applied.
6996pub fn grain_matches_condition_tree(grain: &CalGrainResult, condition: &Condition) -> bool {
6997    match condition {
6998        Condition::Comparison {
6999            field,
7000            comparator,
7001            value,
7002            ..
7003        } => grain_matches_condition(grain, field, comparator, value),
7004        Condition::And { left, right, .. } => {
7005            grain_matches_condition_tree(grain, left) && grain_matches_condition_tree(grain, right)
7006        }
7007        Condition::Or { left, right, .. } => {
7008            grain_matches_condition_tree(grain, left) || grain_matches_condition_tree(grain, right)
7009        }
7010        Condition::Not { inner, .. } => !grain_matches_condition_tree(grain, inner),
7011        Condition::In { field, values, .. } => values
7012            .iter()
7013            .any(|v| grain_matches_condition(grain, field, &Comparator::Eq, v)),
7014        Condition::NotIn { field, values, .. } => !values
7015            .iter()
7016            .any(|v| grain_matches_condition(grain, field, &Comparator::Eq, v)),
7017        Condition::IsNull { field, .. } => {
7018            json_field(&grain.fields, field).is_none()
7019                || json_field(&grain.fields, field) == Some(&serde_json::Value::Null)
7020        }
7021        Condition::IsNotNull { field, .. } => {
7022            matches!(json_field(&grain.fields, field), Some(v) if !v.is_null())
7023        }
7024        Condition::Contains { field, value, .. } => json_field(&grain.fields, field)
7025            .and_then(|v| v.as_str())
7026            .map(|s| s.contains(value.as_str()))
7027            .unwrap_or(false),
7028        Condition::StartsWith { field, value, .. } => json_field(&grain.fields, field)
7029            .and_then(|v| v.as_str())
7030            .map(|s| s.starts_with(value.as_str()))
7031            .unwrap_or(false),
7032        Condition::IsCategory {
7033            field, category, ..
7034        } => json_field(&grain.fields, field)
7035            .and_then(|v| v.as_str())
7036            .map(|s| s.eq_ignore_ascii_case(category))
7037            .unwrap_or(false),
7038    }
7039}
7040
7041/// Collect field names referenced in a condition (for EXPLAIN plans).
7042/// Records only names, never values (S-5).
7043fn collect_filter_names(condition: &Condition, names: &mut Vec<String>) {
7044    match condition {
7045        Condition::Comparison { field, .. } => {
7046            if !names.contains(field) {
7047                names.push(field.clone());
7048            }
7049        }
7050        Condition::In { field, .. } | Condition::NotIn { field, .. } => {
7051            if !names.contains(field) {
7052                names.push(field.clone());
7053            }
7054        }
7055        Condition::IsNull { field, .. } | Condition::IsNotNull { field, .. } => {
7056            if !names.contains(field) {
7057                names.push(field.clone());
7058            }
7059        }
7060        Condition::Contains { field, .. } | Condition::StartsWith { field, .. } => {
7061            if !names.contains(field) {
7062                names.push(field.clone());
7063            }
7064        }
7065        Condition::IsCategory { field, .. } => {
7066            if !names.contains(field) {
7067                names.push(field.clone());
7068            }
7069        }
7070        Condition::And { left, right, .. } | Condition::Or { left, right, .. } => {
7071            collect_filter_names(left, names);
7072            collect_filter_names(right, names);
7073        }
7074        Condition::Not { inner, .. } => {
7075            collect_filter_names(inner, names);
7076        }
7077    }
7078}
7079
7080/// Get a field value from a grain's JSON fields object.
7081/// Collapse a JSON field value to the string key `WITH dedup(<field>)` groups
7082/// on. Strings compare as themselves; anything else by its canonical JSON, so
7083/// `1` and `"1"` stay distinct rather than colliding through `to_string`.
7084fn value_dedup_key(v: &serde_json::Value) -> String {
7085    match v {
7086        serde_json::Value::String(s) => s.clone(),
7087        other => other.to_string(),
7088    }
7089}
7090
7091fn json_field<'a>(fields: &'a serde_json::Value, field: &str) -> Option<&'a serde_json::Value> {
7092    if let serde_json::Value::Object(map) = fields {
7093        map.get(field)
7094    } else {
7095        None
7096    }
7097}
7098
7099/// Project a subset of fields from a JSON value.
7100fn project_fields(fields: &serde_json::Value, selected: &[String]) -> serde_json::Value {
7101    let mut out = serde_json::Map::new();
7102    if let serde_json::Value::Object(map) = fields {
7103        for key in selected {
7104            if let Some(v) = map.get(key) {
7105                out.insert(key.clone(), v.clone());
7106            }
7107        }
7108    }
7109    serde_json::Value::Object(out)
7110}
7111
7112/// Total ordering for JSON values (used for ORDER BY).
7113fn compare_json_values(a: Option<&serde_json::Value>, b: Option<&serde_json::Value>) -> Ordering {
7114    match (a, b) {
7115        (None, None) => Ordering::Equal,
7116        (None, Some(_)) => Ordering::Less,
7117        (Some(_), None) => Ordering::Greater,
7118        (Some(va), Some(vb)) => {
7119            // Compare numbers as f64, strings lexicographically, booleans as int.
7120            if let (Some(fa), Some(fb)) = (va.as_f64(), vb.as_f64()) {
7121                fa.partial_cmp(&fb).unwrap_or(Ordering::Equal)
7122            } else if let (Some(sa), Some(sb)) = (va.as_str(), vb.as_str()) {
7123                sa.cmp(sb)
7124            } else if let (Some(ba), Some(bb)) = (va.as_bool(), vb.as_bool()) {
7125                ba.cmp(&bb)
7126            } else {
7127                // Fallback: compare display representations.
7128                va.to_string().cmp(&vb.to_string())
7129            }
7130        }
7131    }
7132}
7133
7134/// Union of two grain result sets (deduplicated by hash).
7135fn union_grains(mut left: Vec<CalGrainResult>, right: Vec<CalGrainResult>) -> Vec<CalGrainResult> {
7136    let left_hashes: std::collections::HashSet<String> =
7137        left.iter().map(|g| g.hash.clone()).collect();
7138    for g in right {
7139        if !left_hashes.contains(&g.hash) {
7140            left.push(g);
7141        }
7142    }
7143    left
7144}
7145
7146/// Intersection of two grain result sets (grains present in both, by hash).
7147fn intersect_grains(left: Vec<CalGrainResult>, right: &[CalGrainResult]) -> Vec<CalGrainResult> {
7148    let right_hashes: std::collections::HashSet<&str> =
7149        right.iter().map(|g| g.hash.as_str()).collect();
7150    left.into_iter()
7151        .filter(|g| right_hashes.contains(g.hash.as_str()))
7152        .collect()
7153}
7154
7155/// Difference of two grain result sets (grains in left but not in right).
7156fn except_grains(left: Vec<CalGrainResult>, right: &[CalGrainResult]) -> Vec<CalGrainResult> {
7157    let right_hashes: std::collections::HashSet<&str> =
7158        right.iter().map(|g| g.hash.as_str()).collect();
7159    left.into_iter()
7160        .filter(|g| !right_hashes.contains(g.hash.as_str()))
7161        .collect()
7162}
7163
7164// ---------------------------------------------------------------------------
7165// Tests
7166// ---------------------------------------------------------------------------
7167
7168#[cfg(test)]
7169mod tests {
7170    use super::*;
7171    use crate::facade::CalStoreFacade;
7172    use crate::store_types::{RecallParams, SearchHit};
7173    use crate::store_types::VersionEntry;
7174    use areev_core::error::{AreevError, Hash};
7175    use areev_core::format::deserialize::DeserializedGrain;
7176    use areev_core::format::header::MgHeader;
7177    use areev_core::types::GrainType;
7178    use std::collections::HashMap;
7179
7180    // -----------------------------------------------------------------------
7181    // Shared mock store
7182    // -----------------------------------------------------------------------
7183
7184    struct MockStore {
7185        grains: Vec<(Hash, DeserializedGrain)>,
7186    }
7187
7188    impl MockStore {
7189        fn empty() -> Self {
7190            Self { grains: Vec::new() }
7191        }
7192
7193        fn with_grains(grains: Vec<(Hash, DeserializedGrain)>) -> Self {
7194            Self { grains }
7195        }
7196    }
7197
7198    /// Build a minimal Fact grain with a given subject, returning (hash, grain).
7199    fn make_fact(subject: &str, relation: &str, object: &str) -> (Hash, DeserializedGrain) {
7200        let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
7201        fields.insert("subject".into(), serde_json::json!(subject));
7202        fields.insert("relation".into(), serde_json::json!(relation));
7203        fields.insert("object".into(), serde_json::json!(object));
7204        fields.insert("grain_type".into(), serde_json::json!("fact"));
7205        fields.insert("confidence".into(), serde_json::json!(0.9));
7206
7207        // Build a deterministic hash from subject bytes.
7208        let mut hash_bytes = [0u8; 32];
7209        let key = format!("{}|{}|{}", subject, relation, object);
7210        for (i, b) in key.as_bytes().iter().enumerate().take(32) {
7211            hash_bytes[i] = *b;
7212        }
7213        let hash = Hash::from_bytes(&hash_bytes);
7214
7215        let grain = DeserializedGrain {
7216            header: MgHeader {
7217                version: 1,
7218                flags: 0,
7219                grain_type: 0x01,
7220                ns_hash: 0,
7221                created_at_sec: 0,
7222            },
7223            grain_type: GrainType::Fact,
7224            fields,
7225            hash,
7226        };
7227        (hash, grain)
7228    }
7229
7230    impl CalStoreFacade for MockStore {
7231        fn recall(&self, params: &RecallParams) -> areev_core::error::Result<Vec<SearchHit>> {
7232            let mut hits: Vec<SearchHit> = self
7233                .grains
7234                .iter()
7235                .filter(|(_, g)| {
7236                    if let Some(ref s) = params.subject {
7237                        if g.get_str("subject") != Some(s.as_str()) {
7238                            return false;
7239                        }
7240                    }
7241                    if let Some(ref r) = params.relation {
7242                        if g.get_str("relation") != Some(r.as_str()) {
7243                            return false;
7244                        }
7245                    }
7246                    true
7247                })
7248                .map(|(hash, grain)| SearchHit {
7249                    grain: grain.clone(),
7250                    score: 1.0,
7251                    hash: *hash,
7252                    score_breakdown: None,
7253                    explanation: None,
7254                    scope_depth: None,
7255                    source_namespace: None,
7256                    #[cfg(feature = "rerank")]
7257                    rerank_score: None,
7258                    #[cfg(feature = "llm-rerank")]
7259                    llm_rerank_score: None,
7260                    relative_time: None,
7261                    conflict_status: None,
7262                    supersession_status: None,
7263                    superseded_by_hash: None,
7264                    recall_source: None,
7265                })
7266                .collect();
7267            if let Some(limit) = params.limit {
7268                hits.truncate(limit);
7269            }
7270            Ok(hits)
7271        }
7272
7273        fn exists(&self, hash: &Hash) -> areev_core::error::Result<bool> {
7274            Ok(self.grains.iter().any(|(h, _)| h == hash))
7275        }
7276
7277        fn get(&self, hash: &Hash) -> areev_core::error::Result<DeserializedGrain> {
7278            self.grains
7279                .iter()
7280                .find(|(h, _)| h == hash)
7281                .map(|(_, g)| g.clone())
7282                .ok_or(AreevError::NotFound(*hash))
7283        }
7284
7285        fn count(&self) -> areev_core::error::Result<usize> {
7286            Ok(self.grains.len())
7287        }
7288
7289        fn get_history(
7290            &self,
7291            _ns: &str,
7292            _s: &str,
7293            _r: &str,
7294        ) -> areev_core::error::Result<Vec<VersionEntry>> {
7295            Ok(Vec::new())
7296        }
7297
7298        fn default_namespace(&self) -> Option<&str> {
7299            None
7300        }
7301
7302        fn active_user(&self) -> Option<&str> {
7303            None
7304        }
7305
7306        fn cal_add(
7307            &self,
7308            _grain_type: &str,
7309            _fields: &serde_json::Map<String, serde_json::Value>,
7310        ) -> areev_core::error::Result<Hash> {
7311            Err(AreevError::Validation(
7312                "mock: cal_add not implemented".into(),
7313            ))
7314        }
7315
7316        fn cal_supersede(
7317            &self,
7318            _old_hash: &Hash,
7319            _grain_type: &str,
7320            _fields: &serde_json::Map<String, serde_json::Value>,
7321        ) -> areev_core::error::Result<Hash> {
7322            Err(AreevError::Validation(
7323                "mock: cal_supersede not implemented".into(),
7324            ))
7325        }
7326
7327        fn list_templates(&self) -> Vec<crate::facade::TemplateInfo> {
7328            let registry = crate::templates::TemplateRegistry::new();
7329            registry.list()
7330        }
7331    }
7332
7333    // -----------------------------------------------------------------------
7334    // Helper to build an executor with defaults.
7335    // -----------------------------------------------------------------------
7336
7337    fn exec() -> CalExecutor {
7338        CalExecutor::with_defaults()
7339    }
7340
7341    // -----------------------------------------------------------------------
7342    // Test 1: Execute a simple RECALL (empty store returns empty grains).
7343    // -----------------------------------------------------------------------
7344
7345    #[test]
7346    fn test_execute_recall_empty_store() {
7347        let store = MockStore::empty();
7348        let ex = exec();
7349        let result = ex.execute("RECALL facts", &store).unwrap();
7350        assert_eq!(result.metadata.statement_type, "recall");
7351        match result.result {
7352            CalResultPayload::Grains { grains, .. } => assert!(grains.is_empty()),
7353            other => panic!("unexpected payload: {:?}", other),
7354        }
7355    }
7356
7357    // -----------------------------------------------------------------------
7358    // Test 2: RECALL with WHERE subject = "john" matches correctly.
7359    // -----------------------------------------------------------------------
7360
7361    #[test]
7362    fn test_execute_recall_where_subject() {
7363        let (hash_a, grain_a) = make_fact("john", "likes", "coffee");
7364        let (hash_b, grain_b) = make_fact("bob", "likes", "coffee");
7365        let store = MockStore::with_grains(vec![(hash_a, grain_a), (hash_b, grain_b)]);
7366        let ex = exec();
7367        let result = ex
7368            .execute(r#"RECALL facts WHERE subject = "john""#, &store)
7369            .unwrap();
7370        match result.result {
7371            CalResultPayload::Grains { grains, .. } => {
7372                assert_eq!(grains.len(), 1);
7373                assert_eq!(grains[0].hash, hash_a.to_hex());
7374            }
7375            other => panic!("unexpected payload: {:?}", other),
7376        }
7377    }
7378
7379    // -----------------------------------------------------------------------
7380    // Test 3: RECALL with ABOUT clause sets query param.
7381    // -----------------------------------------------------------------------
7382
7383    #[test]
7384    fn test_execute_recall_about_clause() {
7385        let store = MockStore::empty();
7386        let ex = exec();
7387        // Should not fail even if no FTS; mock returns empty.
7388        let result = ex
7389            .execute(r#"RECALL facts ABOUT "coffee preferences""#, &store)
7390            .unwrap();
7391        assert_eq!(result.metadata.statement_type, "recall");
7392    }
7393
7394    // -----------------------------------------------------------------------
7395    // Test 4: RECALL with pipeline LIMIT.
7396    // -----------------------------------------------------------------------
7397
7398    #[test]
7399    fn test_execute_recall_pipeline_limit() {
7400        let grains: Vec<_> = (0..10u8)
7401            .map(|i| {
7402                let key = format!("user{}", i);
7403                make_fact(&key, "likes", "coffee")
7404            })
7405            .collect();
7406        let store = MockStore::with_grains(grains);
7407        let ex = exec();
7408        let result = ex.execute("RECALL facts LIMIT 3", &store).unwrap();
7409        match result.result {
7410            CalResultPayload::Grains { grains, .. } => assert_eq!(grains.len(), 3),
7411            other => panic!("unexpected: {:?}", other),
7412        }
7413    }
7414
7415    // -----------------------------------------------------------------------
7416    // Test 5: RECALL with pipeline COUNT.
7417    // -----------------------------------------------------------------------
7418
7419    #[test]
7420    fn test_execute_recall_pipeline_count() {
7421        let grains: Vec<_> = (0..5u8)
7422            .map(|i| make_fact(&format!("u{}", i), "likes", "tea"))
7423            .collect();
7424        let store = MockStore::with_grains(grains);
7425        let ex = exec();
7426        let result = ex.execute("RECALL facts COUNT", &store).unwrap();
7427        match result.result {
7428            CalResultPayload::Count { count } => assert_eq!(count, 5),
7429            other => panic!("unexpected: {:?}", other),
7430        }
7431    }
7432
7433    // -----------------------------------------------------------------------
7434    // Test 6: RECALL with pipeline ORDER BY field.
7435    // -----------------------------------------------------------------------
7436
7437    #[test]
7438    fn test_execute_recall_pipeline_order_by() {
7439        let mut grains = vec![
7440            make_fact("charlie", "likes", "rust"),
7441            make_fact("john", "likes", "python"),
7442            make_fact("bob", "likes", "go"),
7443        ];
7444        // Assign predictable created_at values so ORDER BY works.
7445        for (i, (_, g)) in grains.iter_mut().enumerate() {
7446            g.fields
7447                .insert("created_at".into(), serde_json::json!(i as i64));
7448        }
7449        let store = MockStore::with_grains(grains);
7450        let ex = exec();
7451        let result = ex.execute("RECALL facts ORDER BY subject", &store).unwrap();
7452        match result.result {
7453            CalResultPayload::Grains { grains, .. } => {
7454                // Should be sorted A-Z by subject: bob < charlie < john.
7455                assert_eq!(grains[0].hash, make_fact("bob", "likes", "go").0.to_hex());
7456                assert_eq!(
7457                    grains[1].hash,
7458                    make_fact("charlie", "likes", "rust").0.to_hex()
7459                );
7460            }
7461            other => panic!("unexpected: {:?}", other),
7462        }
7463    }
7464
7465    // -----------------------------------------------------------------------
7466    // Test 7: RECALL with pipeline SELECT (field projection).
7467    // -----------------------------------------------------------------------
7468
7469    #[test]
7470    fn test_execute_recall_pipeline_select() {
7471        let (hash, _) = make_fact("john", "likes", "vim");
7472        let store = MockStore::with_grains(vec![make_fact("john", "likes", "vim")]);
7473        let ex = exec();
7474        let result = ex
7475            .execute("RECALL facts SELECT subject, object", &store)
7476            .unwrap();
7477        match result.result {
7478            CalResultPayload::Grains { grains, .. } => {
7479                assert_eq!(grains.len(), 1);
7480                if let serde_json::Value::Object(map) = &grains[0].fields {
7481                    assert!(map.contains_key("subject"));
7482                    assert!(map.contains_key("object"));
7483                    // "relation" should not be present after SELECT.
7484                    assert!(!map.contains_key("relation"));
7485                } else {
7486                    panic!("expected Object fields");
7487                }
7488                let _ = hash;
7489            }
7490            other => panic!("unexpected: {:?}", other),
7491        }
7492    }
7493
7494    // -----------------------------------------------------------------------
7495    // Test 8: EXISTS with known hash returns true.
7496    //
7497    // CAL syntax: `EXISTS sha256:<hex>` (direct hash lookup form).
7498    // The parser desugars this to ExistsStmt with WHERE hash = <hash>.
7499    // -----------------------------------------------------------------------
7500
7501    #[test]
7502    fn test_execute_exists_known_hash() {
7503        let (hash, grain) = make_fact("john", "is", "a developer");
7504        let store = MockStore::with_grains(vec![(hash, grain)]);
7505        let ex = exec();
7506        let hex = hash.to_hex();
7507        // CAL hash literals use the `sha256:` prefix.
7508        let query = format!("EXISTS sha256:{}", hex);
7509        let result = ex.execute(&query, &store).unwrap();
7510        match result.result {
7511            CalResultPayload::Exists { exists, .. } => assert!(exists),
7512            other => panic!("unexpected: {:?}", other),
7513        }
7514    }
7515
7516    // -----------------------------------------------------------------------
7517    // Test 9: EXISTS with unknown hash returns false.
7518    //
7519    // CAL syntax: `EXISTS sha256:<hex>` with a hash not in the store.
7520    // -----------------------------------------------------------------------
7521
7522    #[test]
7523    fn test_execute_exists_unknown_hash() {
7524        let store = MockStore::empty();
7525        let ex = exec();
7526        // 64 hex chars = 32 bytes = valid SHA-256 hash length.
7527        let hex = "a".repeat(64);
7528        let query = format!("EXISTS sha256:{}", hex);
7529        let result = ex.execute(&query, &store).unwrap();
7530        match result.result {
7531            CalResultPayload::Exists { exists, .. } => assert!(!exists),
7532            other => panic!("unexpected: {:?}", other),
7533        }
7534    }
7535
7536    // -----------------------------------------------------------------------
7537    // Test 10: DESCRIBE SCHEMA returns introspection info.
7538    // -----------------------------------------------------------------------
7539
7540    #[test]
7541    fn test_execute_describe_schema() {
7542        let store = MockStore::empty();
7543        let ex = exec();
7544        let result = ex.execute("DESCRIBE SCHEMA", &store).unwrap();
7545        assert_eq!(result.metadata.statement_type, "describe");
7546        match result.result {
7547            CalResultPayload::Describe { info } => {
7548                assert!(info.get("grain_types").is_some());
7549                assert!(info.get("common_fields").is_some());
7550            }
7551            other => panic!("unexpected: {:?}", other),
7552        }
7553    }
7554
7555    // -----------------------------------------------------------------------
7556    // Test 11: EXPLAIN RECALL builds a query plan without executing.
7557    // -----------------------------------------------------------------------
7558
7559    #[test]
7560    fn test_execute_explain_recall() {
7561        let store = MockStore::empty();
7562        let ex = exec();
7563        let result = ex
7564            .execute(r#"EXPLAIN RECALL facts ABOUT "preferences""#, &store)
7565            .unwrap();
7566        assert_eq!(result.metadata.statement_type, "explain");
7567        match result.result {
7568            CalResultPayload::Explain { plan } => {
7569                assert_eq!(plan.statement_type, "recall");
7570                assert!(!plan.index_usage.is_empty());
7571            }
7572            other => panic!("unexpected: {:?}", other),
7573        }
7574    }
7575
7576    #[test]
7577    fn test_explain_reports_like_as_the_bm25_leg_it_runs() {
7578        // `LIKE` and `ABOUT` both set `RecallParams::query` and execute the
7579        // same free-text leg, but the plan was derived from `ABOUT` alone, so
7580        // a `LIKE` recall was described as a structural `O(n) full scan` over
7581        // no index — the opposite of what it does. Both spellings must report
7582        // the same plan.
7583        let store = MockStore::empty();
7584        let ex = exec();
7585        let plan_of = |q: &str| match ex.execute(q, &store).unwrap().result {
7586            CalResultPayload::Explain { plan } => plan,
7587            other => panic!("unexpected: {:?}", other),
7588        };
7589
7590        let like = plan_of(r#"EXPLAIN RECALL facts LIKE "window""#);
7591        assert_eq!(like.query_routing, "bm25");
7592        assert!(like.index_usage.contains(&"bm25_fts".to_string()));
7593        assert!(
7594            like.estimated_cost.starts_with("O(log n)"),
7595            "LIKE must not be reported as a full scan, got: {}",
7596            like.estimated_cost
7597        );
7598
7599        let about = plan_of(r#"EXPLAIN RECALL facts ABOUT "window""#);
7600        assert_eq!(like.query_routing, about.query_routing);
7601        assert_eq!(like.index_usage, about.index_usage);
7602
7603        // Anchored, the same free text is the hybrid path for either spelling.
7604        let anchored = plan_of(r#"EXPLAIN RECALL facts LIKE "window" WHERE subject = "john""#);
7605        assert_eq!(anchored.query_routing, "hybrid_rrf");
7606    }
7607
7608    // -----------------------------------------------------------------------
7609    // Test 12: BATCH with two queries returns two results.
7610    // -----------------------------------------------------------------------
7611
7612    #[test]
7613    fn test_execute_batch_two_queries() {
7614        let store = MockStore::empty();
7615        let ex = exec();
7616        let result = ex
7617            .execute("BATCH { RECALL facts ; RECALL events }", &store)
7618            .unwrap();
7619        assert_eq!(result.metadata.statement_type, "batch");
7620        match result.result {
7621            CalResultPayload::Batch { results } => {
7622                assert_eq!(results.len(), 2);
7623                assert!(results.contains_key("0"));
7624                assert!(results.contains_key("1"));
7625            }
7626            other => panic!("unexpected: {:?}", other),
7627        }
7628    }
7629
7630    // -----------------------------------------------------------------------
7631    // Test 13: COALESCE returns first non-empty result.
7632    //
7633    // CAL syntax: `COALESCE(RECALL ..., RECALL ...)` (function call form).
7634    // The executor executes the first branch that returns a non-empty result.
7635    // -----------------------------------------------------------------------
7636
7637    #[test]
7638    fn test_execute_coalesce() {
7639        let (hash, grain) = make_fact("john", "likes", "coffee");
7640        let store = MockStore::with_grains(vec![(hash, grain)]);
7641        let ex = exec();
7642        // COALESCE with one sub-query that will match john → 1 result returned.
7643        let result = ex
7644            .execute(
7645                r#"COALESCE(RECALL facts WHERE subject = "john", RECALL facts WHERE subject = "bob")"#,
7646                &store,
7647            )
7648            .unwrap();
7649        match result.result {
7650            CalResultPayload::Grains { grains, .. } => assert_eq!(grains.len(), 1),
7651            other => panic!("unexpected: {:?}", other),
7652        }
7653    }
7654
7655    // -----------------------------------------------------------------------
7656    // Test 13b: COALESCE fallback — first branch empty, second matches.
7657    // -----------------------------------------------------------------------
7658
7659    #[test]
7660    fn test_execute_coalesce_fallback_to_second_branch() {
7661        let (hash, grain) = make_fact("bob", "likes", "coffee");
7662        let store = MockStore::with_grains(vec![(hash, grain)]);
7663        let ex = exec();
7664        // First branch queries "john" (not in store), second queries "bob" (in store).
7665        let result = ex
7666            .execute(
7667                r#"COALESCE(RECALL facts WHERE subject = "john", RECALL facts WHERE subject = "bob")"#,
7668                &store,
7669            )
7670            .unwrap();
7671        match result.result {
7672            CalResultPayload::Grains { grains, .. } => {
7673                assert_eq!(grains.len(), 1, "should fall back to second branch");
7674                assert_eq!(
7675                    grains[0].fields.get("subject").and_then(|v| v.as_str()),
7676                    Some("bob"),
7677                    "result should be from the second branch (bob)"
7678                );
7679            }
7680            other => panic!("expected Grains, got: {:?}", other),
7681        }
7682    }
7683
7684    // -----------------------------------------------------------------------
7685    // Test 13c: COALESCE with all branches empty returns empty Grains.
7686    // -----------------------------------------------------------------------
7687
7688    #[test]
7689    fn test_execute_coalesce_all_branches_empty() {
7690        let store = MockStore::empty();
7691        let ex = exec();
7692        let result = ex
7693            .execute(
7694                r#"COALESCE(RECALL facts WHERE subject = "john", RECALL facts WHERE subject = "bob")"#,
7695                &store,
7696            )
7697            .unwrap();
7698        match result.result {
7699            CalResultPayload::Grains { grains, .. } => {
7700                assert!(grains.is_empty(), "all branches empty → empty result");
7701            }
7702            other => panic!("expected Grains, got: {:?}", other),
7703        }
7704    }
7705
7706    // -----------------------------------------------------------------------
7707    // Test 13d: Large BATCH with 5 queries.
7708    // -----------------------------------------------------------------------
7709
7710    #[test]
7711    fn test_execute_batch_five_queries() {
7712        let (hash, grain) = make_fact("john", "likes", "coffee");
7713        let store = MockStore::with_grains(vec![(hash, grain)]);
7714        let ex = exec();
7715        let result = ex
7716            .execute(
7717                r#"BATCH { RECALL facts ; RECALL events ; RECALL tools ; RECALL goals ; RECALL facts WHERE subject = "john" }"#,
7718                &store,
7719            )
7720            .unwrap();
7721        assert_eq!(result.metadata.statement_type, "batch");
7722        match result.result {
7723            CalResultPayload::Batch { results } => {
7724                assert_eq!(results.len(), 5, "BATCH should execute all 5 sub-queries");
7725                // Sub-query 0: RECALL facts (john is a fact) → 1 result
7726                // Sub-query 4: RECALL facts WHERE subject = "john" → 1 result
7727                assert!(results.contains_key("0"));
7728                assert!(results.contains_key("4"));
7729            }
7730            other => panic!("expected Batch, got: {:?}", other),
7731        }
7732    }
7733
7734    // -----------------------------------------------------------------------
7735    // Test 14: Tier 1 ADD returns Unsupported when tier1_enabled = false.
7736    //
7737    // CAL syntax: `ADD fact SET subject = "..." SET relation = "..." ...`
7738    // REASON is optional; all field assignments use the SET keyword.
7739    // The executor must return Unsupported for all Tier 1 statements
7740    // when tier1_enabled is explicitly disabled.
7741    // -----------------------------------------------------------------------
7742
7743    #[test]
7744    fn test_execute_tier1_add_returns_e044() {
7745        // Per spec §2.4, ADD with tier1 disabled must surface as
7746        // CAL-E044 Tier1NotEnabled (hard error), not a soft "Unsupported"
7747        // 200-payload that masks a capability denial.
7748        let store = MockStore::empty();
7749        let ex = CalExecutor::new(CalExecutorConfig {
7750            tier1_enabled: false,
7751            ..Default::default()
7752        });
7753        let err = ex
7754            .execute(
7755                r#"ADD fact SET subject = "john" SET relation = "likes" SET object = "rust" REASON "test""#,
7756                &store,
7757            )
7758            .expect_err("Tier 1 disabled must error, not Unsupported-payload");
7759        assert_eq!(err.code(), "CAL-E044");
7760        assert!(err.to_string().contains("ADD"));
7761    }
7762
7763    // -----------------------------------------------------------------------
7764    // Test 14b: generic ADD ... SET with a type that cannot be shaped that
7765    // way returns Unsupported (see GrainTypeMeta::add_via_set).
7766    // -----------------------------------------------------------------------
7767
7768    #[test]
7769    fn test_execute_generic_add_rejects_unshapeable_type() {
7770        let store = MockStore::empty();
7771        let ex = exec();
7772        let result = ex
7773            .execute(r#"ADD event SET content = "test" REASON "test""#, &store)
7774            .unwrap();
7775        match result.result {
7776            CalResultPayload::Unsupported { message, .. } => {
7777                assert!(
7778                    message.contains("cannot be created via ADD"),
7779                    "expected grain type restriction message, got: {}",
7780                    message
7781                );
7782                assert!(
7783                    message.contains("event"),
7784                    "message should mention the rejected type, got: {}",
7785                    message
7786                );
7787            }
7788            other => panic!("expected Unsupported, got: {:?}", other),
7789        }
7790    }
7791
7792    // -----------------------------------------------------------------------
7793    // Test 14c: ADD with unresolved parameter returns Unsupported.
7794    // -----------------------------------------------------------------------
7795
7796    #[test]
7797    fn test_execute_add_unresolved_parameter() {
7798        let store = MockStore::empty();
7799        let ex = exec();
7800        let result = ex
7801            .execute(
7802                r#"ADD fact SET subject = $user SET relation = "likes" SET object = "rust" REASON "test""#,
7803                &store,
7804            )
7805            .unwrap();
7806        match result.result {
7807            CalResultPayload::Unsupported { message, .. } => {
7808                assert!(
7809                    message.contains("Unresolved parameter"),
7810                    "expected unresolved parameter message, got: {}",
7811                    message
7812                );
7813                assert!(
7814                    message.contains("$user"),
7815                    "message should mention the parameter name, got: {}",
7816                    message
7817                );
7818            }
7819            other => panic!("expected Unsupported, got: {:?}", other),
7820        }
7821    }
7822
7823    // -----------------------------------------------------------------------
7824    // Test 14d: CalResultPayload::Added has extraction fields.
7825    // -----------------------------------------------------------------------
7826
7827    #[test]
7828    fn test_added_payload_has_extraction_fields() {
7829        // Compile-time check: verify the Added variant has the new fields.
7830        let payload = CalResultPayload::Added {
7831            hash: "abc123".into(),
7832            grain_type: "fact".into(),
7833            extracted_count: Some(3),
7834            extraction_warnings: vec!["warn1".into()],
7835        };
7836        match payload {
7837            CalResultPayload::Added {
7838                extracted_count,
7839                extraction_warnings,
7840                ..
7841            } => {
7842                assert_eq!(extracted_count, Some(3));
7843                assert_eq!(extraction_warnings.len(), 1);
7844            }
7845            _ => unreachable!(),
7846        }
7847
7848        // Verify None/empty defaults work.
7849        let payload2 = CalResultPayload::Added {
7850            hash: "def456".into(),
7851            grain_type: "observation".into(),
7852            extracted_count: None,
7853            extraction_warnings: vec![],
7854        };
7855        match payload2 {
7856            CalResultPayload::Added {
7857                extracted_count,
7858                extraction_warnings,
7859                ..
7860            } => {
7861                assert_eq!(extracted_count, None);
7862                assert!(extraction_warnings.is_empty());
7863            }
7864            _ => unreachable!(),
7865        }
7866    }
7867
7868    // -----------------------------------------------------------------------
7869    // Test 15: RECALL with WITH score_breakdown sets the flag.
7870    // -----------------------------------------------------------------------
7871
7872    #[test]
7873    fn test_execute_recall_with_score_breakdown() {
7874        let store = MockStore::empty();
7875        let ex = exec();
7876        // Should not error; mock doesn't populate score_breakdown but the
7877        // RecallParams flag must be set correctly.
7878        let result = ex
7879            .execute("RECALL facts WITH score_breakdown", &store)
7880            .unwrap();
7881        assert_eq!(result.metadata.statement_type, "recall");
7882    }
7883
7884    // -----------------------------------------------------------------------
7885    // Test 16: Query hash is computed (C-4).
7886    // -----------------------------------------------------------------------
7887
7888    #[test]
7889    fn test_query_hash_is_computed_c4() {
7890        let store = MockStore::empty();
7891        let ex = exec();
7892        let input = "RECALL facts";
7893        let result = ex.execute(input, &store).unwrap();
7894        // Must be a 64-character lowercase hex string (SHA-256).
7895        assert_eq!(result.query_hash.len(), 64);
7896        assert!(result.query_hash.chars().all(|c| c.is_ascii_hexdigit()));
7897        // Must be reproducible.
7898        let result2 = ex.execute(input, &store).unwrap();
7899        assert_eq!(result.query_hash, result2.query_hash);
7900    }
7901
7902    // -----------------------------------------------------------------------
7903    // Test 17: Namespace override is applied (ignores WHERE namespace).
7904    // -----------------------------------------------------------------------
7905
7906    #[test]
7907    fn test_namespace_override_applied() {
7908        let store = MockStore::empty();
7909        let config = CalExecutorConfig {
7910            namespace_override: Some("tenant_a".to_string()),
7911            ..Default::default()
7912        };
7913        let ex = CalExecutor::new(config);
7914        // WHERE namespace = "other" should be ignored because of the override.
7915        // If it weren't ignored, the test would still pass since mock doesn't
7916        // filter by namespace — this test verifies no panic or parse error.
7917        let result = ex
7918            .execute(r#"RECALL facts WHERE namespace = "other""#, &store)
7919            .unwrap();
7920        assert_eq!(result.metadata.statement_type, "recall");
7921    }
7922
7923    // -----------------------------------------------------------------------
7924    // Test 18: compute_query_hash is stable across calls.
7925    // -----------------------------------------------------------------------
7926
7927    #[test]
7928    fn test_compute_query_hash_stable() {
7929        let h1 = compute_query_hash("RECALL facts");
7930        let h2 = compute_query_hash("RECALL facts");
7931        assert_eq!(h1, h2);
7932    }
7933
7934    // -----------------------------------------------------------------------
7935    // Test 19: compute_query_hash differs for different inputs.
7936    // -----------------------------------------------------------------------
7937
7938    #[test]
7939    fn test_compute_query_hash_distinct() {
7940        let h1 = compute_query_hash("RECALL facts");
7941        let h2 = compute_query_hash("RECALL events");
7942        assert_ne!(h1, h2);
7943    }
7944
7945    // -----------------------------------------------------------------------
7946    // Test 20: Pipeline OFFSET skips results.
7947    // -----------------------------------------------------------------------
7948
7949    #[test]
7950    fn test_execute_pipeline_offset() {
7951        let grains: Vec<_> = (0..5u8)
7952            .map(|i| make_fact(&format!("u{}", i), "likes", "rust"))
7953            .collect();
7954        let store = MockStore::with_grains(grains);
7955        let ex = exec();
7956        let result = ex.execute("RECALL facts OFFSET 3", &store).unwrap();
7957        match result.result {
7958            CalResultPayload::Grains { grains, .. } => {
7959                // 5 grains in store, limit 50 default (gets all 5), then offset 3 → 2 remain.
7960                assert_eq!(grains.len(), 2);
7961            }
7962            other => panic!("unexpected: {:?}", other),
7963        }
7964    }
7965
7966    // -----------------------------------------------------------------------
7967    // Test 21: Pipeline FIRST returns exactly one grain.
7968    // -----------------------------------------------------------------------
7969
7970    #[test]
7971    fn test_execute_pipeline_first() {
7972        let grains: Vec<_> = (0..5u8)
7973            .map(|i| make_fact(&format!("u{}", i), "likes", "rust"))
7974            .collect();
7975        let store = MockStore::with_grains(grains);
7976        let ex = exec();
7977        let result = ex.execute("RECALL facts FIRST", &store).unwrap();
7978        match result.result {
7979            CalResultPayload::Grains { grains, .. } => assert_eq!(grains.len(), 1),
7980            other => panic!("unexpected: {:?}", other),
7981        }
7982    }
7983
7984    // -----------------------------------------------------------------------
7985    // Test 22: Pipeline HASHES extractor.
7986    // -----------------------------------------------------------------------
7987
7988    #[test]
7989    fn test_execute_pipeline_hashes() {
7990        let (hash, grain) = make_fact("john", "is", "a developer");
7991        let store = MockStore::with_grains(vec![(hash, grain)]);
7992        let ex = exec();
7993        let result = ex.execute("RECALL facts HASHES", &store).unwrap();
7994        // I-7 fix: HASHES now returns Grains (not Describe).
7995        match result.result {
7996            CalResultPayload::Grains { grains, .. } => {
7997                assert_eq!(grains.len(), 1);
7998                assert_eq!(grains[0].grain_type, "extracted");
7999                let value = grains[0].fields.get("value").unwrap();
8000                assert_eq!(value.as_str().unwrap(), hash.to_hex());
8001            }
8002            other => panic!("unexpected: {:?}", other),
8003        }
8004    }
8005
8006    // -----------------------------------------------------------------------
8007    // Test 23: DESCRIBE facts returns type-specific fields.
8008    // -----------------------------------------------------------------------
8009
8010    #[test]
8011    fn test_execute_describe_facts() {
8012        let store = MockStore::empty();
8013        let ex = exec();
8014        let result = ex.execute("DESCRIBE facts", &store).unwrap();
8015        match result.result {
8016            CalResultPayload::Describe { info } => {
8017                assert_eq!(info["grain_type"], "facts");
8018                assert!(info.get("specific_fields").is_some());
8019            }
8020            other => panic!("unexpected: {:?}", other),
8021        }
8022    }
8023
8024    // -----------------------------------------------------------------------
8025    // Test 24: S-2 — SUPERSEDE returns Unsupported in Phase 1.
8026    // -----------------------------------------------------------------------
8027
8028    #[test]
8029    fn test_execute_tier1_supersede_returns_e044() {
8030        // S-2: SUPERSEDE with tier1 disabled now surfaces as
8031        // CAL-E044 Tier1NotEnabled (hard error) per spec §2.4.
8032        let store = MockStore::empty();
8033        let ex = CalExecutor::new(CalExecutorConfig {
8034            tier1_enabled: false,
8035            ..Default::default()
8036        });
8037        let err = ex
8038            .execute(
8039                r#"SUPERSEDE sha256:abc123def456 SET object = "new value" REASON "update""#,
8040                &store,
8041            )
8042            .expect_err("Tier 1 disabled must error, not Unsupported-payload");
8043        assert_eq!(err.code(), "CAL-E044");
8044        assert!(err.to_string().contains("SUPERSEDE"));
8045    }
8046
8047    // -----------------------------------------------------------------------
8048    // Test 25: S-2 — REVERT returns Unsupported in Phase 1.
8049    // -----------------------------------------------------------------------
8050
8051    #[test]
8052    fn test_execute_tier1_revert_returns_unsupported_s2() {
8053        let store = MockStore::empty();
8054        // REVERT always returns Unsupported regardless of tier1_enabled
8055        // (semantics not yet defined), so default config suffices.
8056        let ex = exec();
8057        let result = ex
8058            .execute(r#"REVERT sha256:abc123def456 REASON "mistake""#, &store)
8059            .unwrap();
8060        match result.result {
8061            CalResultPayload::Unsupported { message, .. } => {
8062                assert!(
8063                    message.contains("Tier 1"),
8064                    "REVERT must return Tier 1 unsupported message (S-2), got: {}",
8065                    message
8066                );
8067            }
8068            other => panic!("expected Unsupported for REVERT, got: {:?}", other),
8069        }
8070    }
8071
8072    // -----------------------------------------------------------------------
8073    // Test 26: C-4 — query_hash is SHA-256 hex for various inputs.
8074    // -----------------------------------------------------------------------
8075
8076    #[test]
8077    fn test_query_hash_format_c4() {
8078        let store = MockStore::empty();
8079        let ex = exec();
8080        let queries = [
8081            "RECALL facts",
8082            "RECALL events",
8083            "EXISTS sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
8084            "DESCRIBE SCHEMA",
8085            "BATCH { RECALL facts ; RECALL events }",
8086        ];
8087        for q in &queries {
8088            let result = ex.execute(q, &store).unwrap();
8089            assert_eq!(
8090                result.query_hash.len(),
8091                64,
8092                "query_hash must be 64 hex chars for '{}', got {}",
8093                q,
8094                result.query_hash.len()
8095            );
8096            assert!(
8097                result.query_hash.chars().all(|c| c.is_ascii_hexdigit()),
8098                "query_hash must be hex for '{}', got '{}'",
8099                q,
8100                result.query_hash
8101            );
8102        }
8103    }
8104
8105    // -----------------------------------------------------------------------
8106    // Test 27: C-4 — NFC-equivalent inputs produce the same query hash.
8107    // -----------------------------------------------------------------------
8108
8109    #[test]
8110    fn test_query_hash_nfc_equivalence_c4_s6() {
8111        let store = MockStore::empty();
8112        let ex = exec();
8113        // Decomposed: e + combining acute = precomposed e-acute
8114        let decomposed = "RECALL facts WHERE subject = \"caf\u{0065}\u{0301}\"";
8115        let precomposed = "RECALL facts WHERE subject = \"caf\u{00E9}\"";
8116        let r1 = ex.execute(decomposed, &store).unwrap();
8117        let r2 = ex.execute(precomposed, &store).unwrap();
8118        assert_eq!(
8119            r1.query_hash, r2.query_hash,
8120            "NFC-equivalent inputs must produce the same query_hash (C-4 + S-6)"
8121        );
8122    }
8123
8124    // -----------------------------------------------------------------------
8125    // Test 28: OR condition propagates warning about partial support.
8126    // -----------------------------------------------------------------------
8127
8128    #[test]
8129    fn test_or_condition_warning_propagation() {
8130        let store = MockStore::empty();
8131        let ex = exec();
8132        let result = ex
8133            .execute(
8134                r#"RECALL facts WHERE subject = "john" OR subject = "bob""#,
8135                &store,
8136            )
8137            .unwrap();
8138        // The parser should produce an OR warning since OR is partial in Phase 1.
8139        // Verify it does not panic and produces a valid result.
8140        assert_eq!(result.metadata.statement_type, "recall");
8141    }
8142
8143    // -----------------------------------------------------------------------
8144    // Test 29: ASSEMBLE WHERE clause produces a warning (not supported in P1).
8145    // -----------------------------------------------------------------------
8146
8147    #[test]
8148    fn test_assemble_where_clause_applied() {
8149        // WI-1.1: WHERE clause is now applied as post-composition filter.
8150        // Previously this was a stub that emitted a warning; now it filters.
8151        let store = MockStore::empty();
8152        let ex = exec();
8153        let result = ex
8154            .execute(
8155                r#"ASSEMBLE "summary" FROM (RECALL facts) WHERE confidence >= 0.8"#,
8156                &store,
8157            )
8158            .unwrap();
8159        // With an empty store, there are no grains to filter.
8160        match result.result {
8161            CalResultPayload::Grains { grains, .. } => {
8162                assert!(grains.is_empty(), "empty store should return no grains");
8163            }
8164            other => panic!("expected Grains, got: {:?}", other),
8165        }
8166    }
8167
8168    // -----------------------------------------------------------------------
8169    // Test 30: Empty input through executor produces an error (not a panic).
8170    // -----------------------------------------------------------------------
8171
8172    #[test]
8173    fn test_execute_empty_input_error() {
8174        let store = MockStore::empty();
8175        let ex = exec();
8176        let result = ex.execute("", &store);
8177        assert!(result.is_err(), "empty input must return an error");
8178    }
8179
8180    // -----------------------------------------------------------------------
8181    // Test 31: Whitespace-only input produces an error (not a panic).
8182    // -----------------------------------------------------------------------
8183
8184    #[test]
8185    fn test_execute_whitespace_only_error() {
8186        let store = MockStore::empty();
8187        let ex = exec();
8188        let result = ex.execute("   \t\n  ", &store);
8189        assert!(
8190            result.is_err(),
8191            "whitespace-only input must return an error"
8192        );
8193    }
8194
8195    // -----------------------------------------------------------------------
8196    // Test 32: Input exceeding MAX_QUERY_LENGTH produces an error.
8197    // -----------------------------------------------------------------------
8198
8199    #[test]
8200    fn test_execute_query_too_long_error() {
8201        let store = MockStore::empty();
8202        let ex = exec();
8203        // MAX_QUERY_LENGTH is 65536 bytes; create a query exceeding that.
8204        let huge = format!("RECALL facts WHERE subject = \"{}\"", "a".repeat(66_000));
8205        let result = ex.execute(&huge, &store);
8206        assert!(result.is_err(), "oversized input must return an error");
8207    }
8208
8209    // -----------------------------------------------------------------------
8210    // Test 33: Malformed input does not panic (fuzz-like basic check).
8211    // -----------------------------------------------------------------------
8212
8213    #[test]
8214    fn test_execute_malformed_inputs_no_panic() {
8215        let store = MockStore::empty();
8216        let ex = exec();
8217        let malformed = [
8218            "RECALL",
8219            "WHERE subject",
8220            "|||",
8221            "RECALL facts |",
8222            "RECALL facts WHERE",
8223            "RECALL facts WHERE subject =",
8224            "RECALL 123",
8225            "; ; ;",
8226            "RECALL facts beliefs beliefs",
8227            "RECALL facts WHERE subject = \"unterminated",
8228        ];
8229        for input in &malformed {
8230            // Must not panic; Ok or Err is fine.
8231            let _ = ex.execute(input, &store);
8232        }
8233    }
8234
8235    // ===================================================================
8236    // Sprint 2c: HISTORY DIFF tests
8237    // ===================================================================
8238
8239    #[test]
8240    fn test_history_diff_identical_grains() {
8241        let (hash, grain) = make_fact("john", "likes", "coffee");
8242        let store = MockStore::with_grains(vec![(hash, grain)]);
8243        let ex = exec();
8244
8245        let history_stmt = super::HistoryStmt {
8246            hash: hash.to_hex(),
8247            where_clause: None,
8248            diff_target: Some(hash.to_hex()),
8249            span: None,
8250        };
8251        let query = super::CalQuery {
8252            version: super::super::ast::CalVersion(1),
8253            statement: super::CalStatement::History(history_stmt),
8254            pipeline: Vec::new(),
8255            with_options: Vec::new(),
8256            format: None,
8257            let_bindings: Vec::new(),
8258            let_values: Default::default(),
8259            user_vars: HashMap::new(),
8260            warnings: Vec::new(),
8261        };
8262        let mut warnings = Vec::new();
8263        let payload = ex
8264            .execute_statement(&query.statement, &store, &query, &mut warnings)
8265            .unwrap();
8266
8267        match payload {
8268            CalResultPayload::Diff { changes, .. } => {
8269                assert!(
8270                    changes.is_empty(),
8271                    "identical grains should have no differences"
8272                );
8273            }
8274            other => panic!("expected Diff, got {:?}", other),
8275        }
8276        assert!(warnings.is_empty(), "no warnings for same subject/relation");
8277    }
8278
8279    #[test]
8280    fn test_history_diff_different_grains() {
8281        let (hash_a, grain_a) = make_fact("john", "likes", "coffee");
8282        let (hash_b, grain_b) = make_fact("john", "likes", "tea");
8283        let store = MockStore::with_grains(vec![(hash_a, grain_a), (hash_b, grain_b)]);
8284        let ex = exec();
8285
8286        let history_stmt = super::HistoryStmt {
8287            hash: hash_a.to_hex(),
8288            where_clause: None,
8289            diff_target: Some(hash_b.to_hex()),
8290            span: None,
8291        };
8292        let query = super::CalQuery {
8293            version: super::super::ast::CalVersion(1),
8294            statement: super::CalStatement::History(history_stmt),
8295            pipeline: Vec::new(),
8296            with_options: Vec::new(),
8297            format: None,
8298            let_bindings: Vec::new(),
8299            let_values: Default::default(),
8300            user_vars: HashMap::new(),
8301            warnings: Vec::new(),
8302        };
8303        let mut warnings = Vec::new();
8304        let payload = ex
8305            .execute_statement(&query.statement, &store, &query, &mut warnings)
8306            .unwrap();
8307
8308        match payload {
8309            CalResultPayload::Diff {
8310                source_hash,
8311                target_hash,
8312                changes,
8313            } => {
8314                assert_eq!(source_hash, hash_a.to_hex());
8315                assert_eq!(target_hash, hash_b.to_hex());
8316                let obj_change = changes.iter().find(|c| {
8317                    matches!(c,
8318                        super::super::ast::FieldDiff::Changed { field, .. } if field == "object"
8319                    )
8320                });
8321                assert!(
8322                    obj_change.is_some(),
8323                    "expected 'object' field to be Changed"
8324                );
8325            }
8326            other => panic!("expected Diff, got {:?}", other),
8327        }
8328        assert!(
8329            !warnings.iter().any(|w| w.contains("CAL-W005")),
8330            "same subject+relation should not trigger CAL-W005"
8331        );
8332    }
8333
8334    #[test]
8335    fn test_history_diff_w005_different_subject() {
8336        let (hash_a, grain_a) = make_fact("john", "likes", "coffee");
8337        let (hash_b, grain_b) = make_fact("bob", "likes", "coffee");
8338        let store = MockStore::with_grains(vec![(hash_a, grain_a), (hash_b, grain_b)]);
8339        let ex = exec();
8340
8341        let history_stmt = super::HistoryStmt {
8342            hash: hash_a.to_hex(),
8343            where_clause: None,
8344            diff_target: Some(hash_b.to_hex()),
8345            span: None,
8346        };
8347        let query = super::CalQuery {
8348            version: super::super::ast::CalVersion(1),
8349            statement: super::CalStatement::History(history_stmt),
8350            pipeline: Vec::new(),
8351            with_options: Vec::new(),
8352            format: None,
8353            let_bindings: Vec::new(),
8354            let_values: Default::default(),
8355            user_vars: HashMap::new(),
8356            warnings: Vec::new(),
8357        };
8358        let mut warnings = Vec::new();
8359        let _ = ex
8360            .execute_statement(&query.statement, &store, &query, &mut warnings)
8361            .unwrap();
8362        assert!(
8363            warnings.iter().any(|w| w.contains("CAL-W005")),
8364            "different subject/relation should trigger CAL-W005, got: {:?}",
8365            warnings
8366        );
8367    }
8368
8369    #[test]
8370    fn test_history_diff_source_not_found() {
8371        let (hash_b, grain_b) = make_fact("john", "likes", "coffee");
8372        let store = MockStore::with_grains(vec![(hash_b, grain_b)]);
8373        let ex = exec();
8374
8375        let fake_hash = "a".repeat(64);
8376        let history_stmt = super::HistoryStmt {
8377            hash: fake_hash,
8378            where_clause: None,
8379            diff_target: Some(hash_b.to_hex()),
8380            span: None,
8381        };
8382        let query = super::CalQuery {
8383            version: super::super::ast::CalVersion(1),
8384            statement: super::CalStatement::History(history_stmt),
8385            pipeline: Vec::new(),
8386            with_options: Vec::new(),
8387            format: None,
8388            let_bindings: Vec::new(),
8389            let_values: Default::default(),
8390            user_vars: HashMap::new(),
8391            warnings: Vec::new(),
8392        };
8393        let mut warnings = Vec::new();
8394        let result = ex.execute_statement(&query.statement, &store, &query, &mut warnings);
8395        assert!(result.is_err(), "should error when source grain not found");
8396    }
8397
8398    #[test]
8399    fn test_history_diff_target_not_found() {
8400        let (hash_a, grain_a) = make_fact("john", "likes", "coffee");
8401        let store = MockStore::with_grains(vec![(hash_a, grain_a)]);
8402        let ex = exec();
8403
8404        let fake_hash = "b".repeat(64);
8405        let history_stmt = super::HistoryStmt {
8406            hash: hash_a.to_hex(),
8407            where_clause: None,
8408            diff_target: Some(fake_hash),
8409            span: None,
8410        };
8411        let query = super::CalQuery {
8412            version: super::super::ast::CalVersion(1),
8413            statement: super::CalStatement::History(history_stmt),
8414            pipeline: Vec::new(),
8415            with_options: Vec::new(),
8416            format: None,
8417            let_bindings: Vec::new(),
8418            let_values: Default::default(),
8419            user_vars: HashMap::new(),
8420            warnings: Vec::new(),
8421        };
8422        let mut warnings = Vec::new();
8423        let result = ex.execute_statement(&query.statement, &store, &query, &mut warnings);
8424        assert!(result.is_err(), "should error when target grain not found");
8425    }
8426
8427    // ===================================================================
8428    // Sprint 2c: diff_grains unit tests
8429    // ===================================================================
8430
8431    #[test]
8432    fn test_diff_grains_field_added() {
8433        let (_, grain_a) = make_fact("john", "likes", "coffee");
8434        let (_, mut grain_b) = make_fact("john", "likes", "coffee");
8435        grain_b
8436            .fields
8437            .insert("extra".into(), serde_json::json!("new_value"));
8438
8439        let diffs = super::diff_grains(&grain_a, &grain_b);
8440        let added = diffs.iter().find(|d| {
8441            matches!(d,
8442                super::super::ast::FieldDiff::Added { field, .. } if field == "extra"
8443            )
8444        });
8445        assert!(added.is_some(), "should detect added field 'extra'");
8446    }
8447
8448    #[test]
8449    fn test_diff_grains_field_removed() {
8450        let (_, mut grain_a) = make_fact("john", "likes", "coffee");
8451        let (_, grain_b) = make_fact("john", "likes", "coffee");
8452        grain_a
8453            .fields
8454            .insert("old_field".into(), serde_json::json!("old_value"));
8455
8456        let diffs = super::diff_grains(&grain_a, &grain_b);
8457        let removed = diffs.iter().find(|d| {
8458            matches!(d,
8459                super::super::ast::FieldDiff::Removed { field, .. } if field == "old_field"
8460            )
8461        });
8462        assert!(removed.is_some(), "should detect removed field 'old_field'");
8463    }
8464
8465    #[test]
8466    fn test_diff_grains_field_changed() {
8467        let (_, grain_a) = make_fact("john", "likes", "coffee");
8468        let (_, grain_b) = make_fact("john", "likes", "tea");
8469
8470        let diffs = super::diff_grains(&grain_a, &grain_b);
8471        let changed = diffs.iter().find(|d| {
8472            matches!(d,
8473                super::super::ast::FieldDiff::Changed { field, .. } if field == "object"
8474            )
8475        });
8476        assert!(changed.is_some(), "should detect changed field 'object'");
8477
8478        if let Some(super::super::ast::FieldDiff::Changed { old, new, .. }) = changed {
8479            assert_eq!(old.as_str().unwrap(), "coffee");
8480            assert_eq!(new.as_str().unwrap(), "tea");
8481        }
8482    }
8483
8484    #[test]
8485    fn test_diff_grains_identical() {
8486        let (_, grain_a) = make_fact("john", "likes", "vim");
8487        let (_, grain_b) = make_fact("john", "likes", "vim");
8488        let diffs = super::diff_grains(&grain_a, &grain_b);
8489        assert!(
8490            diffs.is_empty(),
8491            "identical grains should produce empty diff"
8492        );
8493    }
8494
8495    // ===================================================================
8496    // Sprint 2c: Enhanced DESCRIBE tests
8497    // ===================================================================
8498
8499    #[test]
8500    fn test_execute_describe_capabilities() {
8501        let store = MockStore::empty();
8502        let ex = exec();
8503        let result = ex.execute("DESCRIBE CAPABILITIES", &store).unwrap();
8504        assert_eq!(result.metadata.statement_type, "describe");
8505        match result.result {
8506            CalResultPayload::Describe { info } => {
8507                assert_eq!(info["cal_version"], 1);
8508                assert_eq!(info["conformance_level"], 2);
8509                assert!(!info["supported_statements"].as_array().unwrap().is_empty());
8510                assert_eq!(info["max_sources"], 8);
8511                assert_eq!(info["max_let_bindings"], 5);
8512            }
8513            other => panic!("expected Describe, got {:?}", other),
8514        }
8515    }
8516
8517    #[test]
8518    fn test_execute_describe_server() {
8519        let store = MockStore::empty();
8520        let ex = exec();
8521        let result = ex.execute("DESCRIBE SERVER", &store).unwrap();
8522        match result.result {
8523            CalResultPayload::Describe { info } => {
8524                assert_eq!(info["name"], "areev");
8525                assert!(info.get("version").is_some());
8526                assert_eq!(info["oms_version"], "1.2");
8527                assert!(info.get("build_features").is_some());
8528            }
8529            other => panic!("expected Describe, got {:?}", other),
8530        }
8531    }
8532
8533    #[test]
8534    fn test_execute_describe_fields() {
8535        let store = MockStore::empty();
8536        let ex = exec();
8537        let result = ex.execute("DESCRIBE FIELDS", &store).unwrap();
8538        match result.result {
8539            CalResultPayload::Describe { info } => {
8540                assert!(info.get("fields").is_some());
8541                let fields = info["fields"].as_array().unwrap();
8542                assert!(!fields.is_empty());
8543                let has_subject = fields.iter().any(|f| f["name"] == "subject");
8544                assert!(has_subject, "fields should contain 'subject'");
8545            }
8546            other => panic!("expected Describe, got {:?}", other),
8547        }
8548    }
8549
8550    #[test]
8551    fn test_execute_describe_templates() {
8552        let store = MockStore::empty();
8553        let ex = exec();
8554        let result = ex.execute("DESCRIBE TEMPLATES", &store).unwrap();
8555        match result.result {
8556            CalResultPayload::Describe { info } => {
8557                assert!(info.get("templates").is_some());
8558                let templates = info["templates"].as_array().unwrap();
8559                assert!(!templates.is_empty(), "templates list should not be empty");
8560                // Built-ins are exactly the §10.1 presets; in particular no
8561                // builtin may shadow a FORMAT arm name (toon, triples, …).
8562                for name in ["structured", "readable", "compact"] {
8563                    assert!(
8564                        templates.iter().any(|t| t["name"] == name),
8565                        "templates should include '{name}'"
8566                    );
8567                }
8568                assert!(
8569                    !templates.iter().any(|t| t["name"] == "toon"),
8570                    "no builtin template may shadow the FORMAT toon arm"
8571                );
8572            }
8573            other => panic!("expected Describe, got {:?}", other),
8574        }
8575    }
8576
8577    #[test]
8578    fn test_execute_describe_grammar() {
8579        let store = MockStore::empty();
8580        let ex = exec();
8581        let result = ex.execute("DESCRIBE GRAMMAR", &store).unwrap();
8582        match result.result {
8583            CalResultPayload::Describe { info } => {
8584                assert_eq!(info["version"], 1);
8585                assert!(info.get("features").is_some());
8586                let features = info["features"].as_array().unwrap();
8587                assert!(!features.is_empty());
8588                assert_eq!(info["conformance_level"], 2);
8589            }
8590            other => panic!("expected Describe, got {:?}", other),
8591        }
8592    }
8593
8594    // ===================================================================
8595    // Sprint 2c: Enhanced EXPLAIN tests
8596    // ===================================================================
8597
8598    #[test]
8599    fn test_execute_explain_assemble() {
8600        let store = MockStore::empty();
8601        let ex = exec();
8602        let result = ex
8603            .execute(r#"EXPLAIN ASSEMBLE "summary" FROM (RECALL facts)"#, &store)
8604            .unwrap();
8605        match result.result {
8606            CalResultPayload::Explain { plan } => {
8607                assert_eq!(plan.statement_type, "assemble");
8608                assert!(plan.query_routing.contains("assemble"));
8609                assert!(plan.filters.iter().any(|f| f.contains("sources")));
8610            }
8611            other => panic!("expected Explain, got {:?}", other),
8612        }
8613    }
8614
8615    #[test]
8616    fn test_execute_explain_batch() {
8617        let store = MockStore::empty();
8618        let ex = exec();
8619        let result = ex
8620            .execute("EXPLAIN BATCH { RECALL facts ; RECALL events }", &store)
8621            .unwrap();
8622        match result.result {
8623            CalResultPayload::Explain { plan } => {
8624                assert_eq!(plan.statement_type, "batch");
8625                assert_eq!(plan.query_routing, "parallel_batch");
8626                assert!(plan
8627                    .filters
8628                    .iter()
8629                    .any(|f| f.contains("parallel_execution")));
8630            }
8631            other => panic!("expected Explain, got {:?}", other),
8632        }
8633    }
8634
8635    #[test]
8636    fn test_execute_explain_coalesce() {
8637        let store = MockStore::empty();
8638        let ex = exec();
8639        let result = ex.execute(
8640            r#"EXPLAIN COALESCE(RECALL facts WHERE subject = "john", RECALL facts WHERE subject = "bob")"#,
8641            &store,
8642        ).unwrap();
8643        match result.result {
8644            CalResultPayload::Explain { plan } => {
8645                assert_eq!(plan.statement_type, "coalesce");
8646                assert_eq!(plan.query_routing, "coalesce_fallback");
8647                assert!(plan.filters.iter().any(|f| f.contains("fallback_chain")));
8648            }
8649            other => panic!("expected Explain, got {:?}", other),
8650        }
8651    }
8652
8653    #[test]
8654    fn test_execute_explain_describe_rejected() {
8655        // EXPLAIN can only wrap statements that produce an execution plan;
8656        // DESCRIBE is introspection and has no plan.
8657        let store = MockStore::empty();
8658        let ex = exec();
8659        let err = ex
8660            .execute("EXPLAIN DESCRIBE SCHEMA", &store)
8661            .expect_err("EXPLAIN DESCRIBE should be a parse error per §8.5");
8662        assert_eq!(err.code(), "CAL-E002");
8663        assert!(
8664            err.to_string().contains("DESCRIBE"),
8665            "error should mention DESCRIBE: {}",
8666            err
8667        );
8668    }
8669
8670    #[test]
8671    fn test_execute_explain_history() {
8672        let (hash, grain) = make_fact("john", "likes", "coffee");
8673        let store = MockStore::with_grains(vec![(hash, grain)]);
8674        let ex = exec();
8675        let hex = hash.to_hex();
8676        let query_str = format!("EXPLAIN HISTORY OF sha256:{}", hex);
8677        let result = ex.execute(&query_str, &store).unwrap();
8678        match result.result {
8679            CalResultPayload::Explain { plan } => {
8680                assert_eq!(plan.statement_type, "history");
8681                assert_eq!(plan.query_routing, "entity_latest");
8682                assert!(plan.index_usage.contains(&"entity_latest".to_string()));
8683            }
8684            other => panic!("expected Explain, got {:?}", other),
8685        }
8686    }
8687
8688    // ===================================================================
8689    // Sprint 2c: Serialization and helper tests
8690    // ===================================================================
8691
8692    #[test]
8693    fn test_field_diff_serialization() {
8694        let diff = super::super::ast::FieldDiff::Changed {
8695            field: "object".into(),
8696            old: serde_json::json!("coffee"),
8697            new: serde_json::json!("tea"),
8698        };
8699        let json = serde_json::to_value(&diff).unwrap();
8700        assert_eq!(json["kind"], "changed");
8701        assert_eq!(json["field"], "object");
8702        assert_eq!(json["old"], "coffee");
8703        assert_eq!(json["new"], "tea");
8704    }
8705
8706    #[test]
8707    fn test_diff_payload_serialization() {
8708        let payload = CalResultPayload::Diff {
8709            source_hash: "aaa".into(),
8710            target_hash: "bbb".into(),
8711            changes: vec![
8712                super::super::ast::FieldDiff::Added {
8713                    field: "new_field".into(),
8714                    value: serde_json::json!("value"),
8715                },
8716                super::super::ast::FieldDiff::Removed {
8717                    field: "old_field".into(),
8718                    value: serde_json::json!(42),
8719                },
8720            ],
8721        };
8722        let json = serde_json::to_value(&payload).unwrap();
8723        assert_eq!(json["type"], "diff");
8724        assert_eq!(json["source_hash"], "aaa");
8725        assert_eq!(json["target_hash"], "bbb");
8726        assert_eq!(json["changes"].as_array().unwrap().len(), 2);
8727    }
8728
8729    #[test]
8730    fn test_build_features_list() {
8731        let features = super::build_features_list();
8732        assert!(features.contains(&"cal"), "cal feature should be active");
8733    }
8734
8735    #[test]
8736    fn test_count_payload_results_diff() {
8737        let payload = CalResultPayload::Diff {
8738            source_hash: "a".into(),
8739            target_hash: "b".into(),
8740            changes: vec![super::super::ast::FieldDiff::Added {
8741                field: "x".into(),
8742                value: serde_json::json!(1),
8743            }],
8744        };
8745        assert_eq!(super::count_payload_results(&payload), 1);
8746    }
8747
8748    // ===================================================================
8749    // Sprint 2b: LET binding, IS CATEGORY, I-5, I-7, redact_budget_metadata
8750    // ===================================================================
8751
8752    // -- I-5: value_to_string for Parameter returns UnboundParameter ----
8753
8754    #[test]
8755    fn test_i5_value_to_string_parameter_returns_error() {
8756        let val = Value::Parameter {
8757            name: "test".into(),
8758        };
8759        let result = super::value_to_string(&val);
8760        assert!(
8761            result.is_err(),
8762            "Parameter must return error, not \"$test\""
8763        );
8764        match result.unwrap_err() {
8765            CalError::UnboundParameter { name, .. } => {
8766                assert_eq!(name, "test");
8767            }
8768            other => panic!("expected UnboundParameter, got: {:?}", other),
8769        }
8770    }
8771
8772    // -- I-7: Extractors return Grains, not Describe --------------------
8773
8774    #[test]
8775    fn test_i7_subjects_extractor_returns_grains() {
8776        let (hash, grain) = make_fact("john", "likes", "coffee");
8777        let store = MockStore::with_grains(vec![(hash, grain)]);
8778        let ex = exec();
8779        let result = ex.execute("RECALL facts SUBJECTS", &store).unwrap();
8780        match result.result {
8781            CalResultPayload::Grains { grains, .. } => {
8782                assert_eq!(grains.len(), 1);
8783                assert_eq!(grains[0].grain_type, "extracted");
8784                let value = grains[0].fields.get("value").unwrap();
8785                assert_eq!(value.as_str().unwrap(), "john");
8786            }
8787            other => panic!("SUBJECTS should return Grains (I-7 fix), got: {:?}", other),
8788        }
8789    }
8790
8791    #[test]
8792    fn test_i7_objects_extractor_returns_grains() {
8793        let (hash, grain) = make_fact("john", "likes", "coffee");
8794        let store = MockStore::with_grains(vec![(hash, grain)]);
8795        let ex = exec();
8796        let result = ex.execute("RECALL facts OBJECTS", &store).unwrap();
8797        match result.result {
8798            CalResultPayload::Grains { grains, .. } => {
8799                assert_eq!(grains.len(), 1);
8800                assert_eq!(grains[0].grain_type, "extracted");
8801                let value = grains[0].fields.get("value").unwrap();
8802                assert_eq!(value.as_str().unwrap(), "coffee");
8803            }
8804            other => panic!("OBJECTS should return Grains (I-7 fix), got: {:?}", other),
8805        }
8806    }
8807
8808    #[test]
8809    fn test_i7_hashes_extractor_returns_grains() {
8810        let (hash, grain) = make_fact("john", "likes", "coffee");
8811        let store = MockStore::with_grains(vec![(hash, grain)]);
8812        let ex = exec();
8813        let result = ex.execute("RECALL facts HASHES", &store).unwrap();
8814        match result.result {
8815            CalResultPayload::Grains { grains, .. } => {
8816                assert_eq!(grains.len(), 1);
8817                assert_eq!(grains[0].grain_type, "extracted");
8818                let value = grains[0].fields.get("value").unwrap();
8819                assert_eq!(value.as_str().unwrap(), hash.to_hex());
8820            }
8821            other => panic!("HASHES should return Grains (I-7 fix), got: {:?}", other),
8822        }
8823    }
8824
8825    // -- LET binding tests -----------------------------------------------
8826
8827    #[test]
8828    fn test_let_binding_subjects_extractor() {
8829        let grains = vec![
8830            make_fact("john", "likes", "coffee"),
8831            make_fact("bob", "likes", "tea"),
8832        ];
8833        let store = MockStore::with_grains(grains);
8834        let ex = exec();
8835        // LET $users = RECALL facts SUBJECTS ; RECALL facts
8836        let result = ex
8837            .execute(
8838                r#"LET $users = RECALL facts SUBJECTS; RECALL facts WHERE subject = "john""#,
8839                &store,
8840            )
8841            .unwrap();
8842        // The main query should still work (LET scope is evaluated but
8843        // not yet used for parameter resolution in WHERE clauses).
8844        assert_eq!(result.metadata.statement_type, "recall");
8845    }
8846
8847    #[test]
8848    fn test_let_scope_evaluate_basic() {
8849        let grains = vec![
8850            make_fact("john", "likes", "coffee"),
8851            make_fact("bob", "likes", "coffee"),
8852        ];
8853        let store = MockStore::with_grains(grains);
8854        let ex = exec();
8855
8856        let binding = super::super::ast::LetBinding {
8857            name: "users".into(),
8858            extractor: super::super::ast::Extractor::Subjects,
8859            source: Box::new(CalStatement::Recall(RecallStmt {
8860                grain_type: GrainTypePlural::Facts,
8861                about: None,
8862                where_clause: None,
8863                recent: None,
8864                since: None,
8865                until: None,
8866                like: None,
8867                between: None,
8868                contradictions: None,
8869                limit: None,
8870                as_format: None,
8871                span: None,
8872            })),
8873            span: None,
8874        };
8875
8876        let query = CalQuery {
8877            version: super::super::ast::CalVersion(1),
8878            statement: CalStatement::Recall(RecallStmt {
8879                grain_type: GrainTypePlural::Facts,
8880                about: None,
8881                where_clause: None,
8882                recent: None,
8883                since: None,
8884                until: None,
8885                like: None,
8886                between: None,
8887                contradictions: None,
8888                limit: None,
8889                as_format: None,
8890                span: None,
8891            }),
8892            pipeline: Vec::new(),
8893            with_options: Vec::new(),
8894            format: None,
8895            let_bindings: vec![binding.clone()],
8896            let_values: Default::default(),
8897            user_vars: HashMap::new(),
8898            warnings: Vec::new(),
8899        };
8900
8901        let mut warnings = Vec::new();
8902        let scope =
8903            super::LetScope::evaluate(&[binding], &ex, &store, &query, &mut warnings).unwrap();
8904
8905        match scope.resolve("users").unwrap() {
8906            super::LetValue::Extracted(values) => {
8907                assert!(values.contains(&"john".to_string()));
8908                assert!(values.contains(&"bob".to_string()));
8909            }
8910            other => panic!("expected Extracted, got: {:?}", other),
8911        }
8912
8913        // Unbound name should error.
8914        assert!(scope.resolve("missing").is_err());
8915    }
8916
8917    #[test]
8918    fn test_let_scope_too_many_bindings_s06() {
8919        let store = MockStore::empty();
8920        let ex = exec();
8921
8922        // Create 6 bindings (max is 5).
8923        let bindings: Vec<super::super::ast::LetBinding> = (0..6)
8924            .map(|i| super::super::ast::LetBinding {
8925                name: format!("var{}", i),
8926                extractor: super::super::ast::Extractor::Subjects,
8927                source: Box::new(CalStatement::Recall(RecallStmt {
8928                    grain_type: GrainTypePlural::Facts,
8929                    about: None,
8930                    where_clause: None,
8931                    recent: None,
8932                    since: None,
8933                    until: None,
8934                    like: None,
8935                    between: None,
8936                    contradictions: None,
8937                    limit: None,
8938                    as_format: None,
8939                    span: None,
8940                })),
8941                span: None,
8942            })
8943            .collect();
8944
8945        let query = CalQuery {
8946            version: super::super::ast::CalVersion(1),
8947            statement: CalStatement::Recall(RecallStmt {
8948                grain_type: GrainTypePlural::Facts,
8949                about: None,
8950                where_clause: None,
8951                recent: None,
8952                since: None,
8953                until: None,
8954                like: None,
8955                between: None,
8956                contradictions: None,
8957                limit: None,
8958                as_format: None,
8959                span: None,
8960            }),
8961            pipeline: Vec::new(),
8962            with_options: Vec::new(),
8963            format: None,
8964            let_bindings: bindings.clone(),
8965            let_values: Default::default(),
8966            user_vars: HashMap::new(),
8967            warnings: Vec::new(),
8968        };
8969
8970        let mut warnings = Vec::new();
8971        let result = super::LetScope::evaluate(&bindings, &ex, &store, &query, &mut warnings);
8972        assert!(result.is_err(), "6 bindings must exceed S-06 limit of 5");
8973        match result.unwrap_err() {
8974            CalError::TooManyLetBindings { count, max, .. } => {
8975                assert_eq!(count, 6);
8976                assert_eq!(max, 5);
8977            }
8978            other => panic!("expected TooManyLetBindings, got: {:?}", other),
8979        }
8980    }
8981
8982    // -- redact_budget_metadata config test --------------------------------
8983
8984    #[test]
8985    fn test_redact_budget_metadata_config_default() {
8986        let config = CalExecutorConfig::default();
8987        assert!(
8988            !config.redact_budget_metadata,
8989            "default should be false (S-09)"
8990        );
8991    }
8992
8993    #[test]
8994    fn test_redact_budget_metadata_config_override() {
8995        let config = CalExecutorConfig {
8996            redact_budget_metadata: true,
8997            ..Default::default()
8998        };
8999        assert!(config.redact_budget_metadata);
9000    }
9001
9002    // -- Assembled payload count test -------------------------------------
9003
9004    #[test]
9005    fn test_count_payload_results_assembled() {
9006        let payload = CalResultPayload::Assembled {
9007            grains: vec![CalGrainResult {
9008                hash: "abc".into(),
9009                grain_type: "fact".into(),
9010                score: 1.0,
9011                fields: serde_json::json!({}),
9012                score_breakdown: None,
9013                explanation: None,
9014                relative_time: None,
9015                is_deterministic: false,
9016                contested_by: None,
9017            }],
9018            sources: vec![],
9019            total_tokens: 100,
9020            budget_limit: Some(500),
9021            progressive: false,
9022            total_available: Some(1),
9023        };
9024        assert_eq!(super::count_payload_results(&payload), 1);
9025    }
9026
9027    // ===================================================================
9028    // Phase 2 WI tests — WI-1.1 through WI-1.6
9029    // ===================================================================
9030
9031    // -- WI-1.2: Labeled BATCH -------------------------------------------
9032
9033    #[test]
9034    fn test_labeled_batch_returns_keyed_results() {
9035        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
9036        let ex = exec();
9037
9038        // Build a labeled BATCH with two labels, each recalling facts.
9039        let batch = super::super::ast::BatchStmt {
9040            statements: Vec::new(),
9041            labeled: Some(vec![
9042                (
9043                    "prefs".to_string(),
9044                    super::super::ast::BatchEntry {
9045                        statement: CalStatement::Recall(RecallStmt {
9046                            grain_type: GrainTypePlural::Facts,
9047                            about: None,
9048                            where_clause: Some(super::super::ast::WhereClause {
9049                                condition: super::super::ast::Condition::Comparison {
9050                                    field: "subject".into(),
9051                                    comparator: super::super::ast::Comparator::Eq,
9052                                    value: super::super::ast::Value::String {
9053                                        value: "john".into(),
9054                                    },
9055                                    span: None,
9056                                },
9057                                span: None,
9058                            }),
9059                            recent: None,
9060                            since: None,
9061                            until: None,
9062                            like: None,
9063                            between: None,
9064                            contradictions: None,
9065                            limit: None,
9066                            as_format: None,
9067                            span: None,
9068                        }),
9069                        pipeline: Vec::new(),
9070                        with_options: Vec::new(),
9071                        format: None,
9072                        user_vars: HashMap::new(),
9073                    },
9074                ),
9075                (
9076                    "all".to_string(),
9077                    super::super::ast::BatchEntry {
9078                        statement: CalStatement::Recall(RecallStmt {
9079                            grain_type: GrainTypePlural::Facts,
9080                            about: None,
9081                            where_clause: None,
9082                            recent: None,
9083                            since: None,
9084                            until: None,
9085                            like: None,
9086                            between: None,
9087                            contradictions: None,
9088                            limit: None,
9089                            as_format: None,
9090                            span: None,
9091                        }),
9092                        pipeline: Vec::new(),
9093                        with_options: Vec::new(),
9094                        format: None,
9095                        user_vars: HashMap::new(),
9096                    },
9097                ),
9098            ]),
9099            span: None,
9100        };
9101
9102        let mut warnings = Vec::new();
9103        let result = ex.execute_batch(&batch, &store, &mut warnings).unwrap();
9104
9105        match result {
9106            CalResultPayload::Batch { results } => {
9107                assert!(results.contains_key("prefs"), "should have 'prefs' label");
9108                assert!(results.contains_key("all"), "should have 'all' label");
9109                assert_eq!(results.len(), 2, "exactly 2 labeled results");
9110            }
9111            other => panic!("expected Batch, got: {:?}", other),
9112        }
9113    }
9114
9115    #[test]
9116    fn test_labeled_batch_duplicate_label_errors() {
9117        let store = MockStore::empty();
9118        let ex = exec();
9119
9120        let batch = super::super::ast::BatchStmt {
9121            statements: Vec::new(),
9122            labeled: Some(vec![
9123                (
9124                    "dup".to_string(),
9125                    super::super::ast::BatchEntry {
9126                        statement: CalStatement::Recall(RecallStmt {
9127                            grain_type: GrainTypePlural::Facts,
9128                            about: None,
9129                            where_clause: None,
9130                            recent: None,
9131                            since: None,
9132                            until: None,
9133                            like: None,
9134                            between: None,
9135                            contradictions: None,
9136                            limit: None,
9137                            as_format: None,
9138                            span: None,
9139                        }),
9140                        pipeline: Vec::new(),
9141                        with_options: Vec::new(),
9142                        format: None,
9143                        user_vars: HashMap::new(),
9144                    },
9145                ),
9146                (
9147                    "dup".to_string(),
9148                    super::super::ast::BatchEntry {
9149                        statement: CalStatement::Recall(RecallStmt {
9150                            grain_type: GrainTypePlural::Events,
9151                            about: None,
9152                            where_clause: None,
9153                            recent: None,
9154                            since: None,
9155                            until: None,
9156                            like: None,
9157                            between: None,
9158                            contradictions: None,
9159                            limit: None,
9160                            as_format: None,
9161                            span: None,
9162                        }),
9163                        pipeline: Vec::new(),
9164                        with_options: Vec::new(),
9165                        format: None,
9166                        user_vars: HashMap::new(),
9167                    },
9168                ),
9169            ]),
9170            span: None,
9171        };
9172
9173        let mut warnings = Vec::new();
9174        let result = ex.execute_batch(&batch, &store, &mut warnings);
9175        assert!(result.is_err());
9176        match result.unwrap_err() {
9177            CalError::AssembleDuplicateLabel { label, .. } => {
9178                assert_eq!(label, "dup");
9179            }
9180            other => panic!("expected AssembleDuplicateLabel, got: {:?}", other),
9181        }
9182    }
9183
9184    #[test]
9185    fn test_positional_batch_still_works() {
9186        // Ensure the Phase 1 positional path is not broken.
9187        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
9188        let ex = exec();
9189
9190        let batch = super::super::ast::BatchStmt {
9191            statements: vec![super::super::ast::BatchEntry {
9192                statement: CalStatement::Recall(RecallStmt {
9193                    grain_type: GrainTypePlural::Facts,
9194                    about: None,
9195                    where_clause: None,
9196                    recent: None,
9197                    since: None,
9198                    until: None,
9199                    like: None,
9200                    between: None,
9201                    contradictions: None,
9202                    limit: None,
9203                    as_format: None,
9204                    span: None,
9205                }),
9206                pipeline: Vec::new(),
9207                with_options: Vec::new(),
9208                format: None,
9209                user_vars: HashMap::new(),
9210            }],
9211            labeled: None,
9212            span: None,
9213        };
9214
9215        let mut warnings = Vec::new();
9216        let result = ex.execute_batch(&batch, &store, &mut warnings).unwrap();
9217        match result {
9218            CalResultPayload::Batch { results } => {
9219                assert!(
9220                    results.contains_key("0"),
9221                    "positional batch uses index keys"
9222                );
9223            }
9224            other => panic!("expected Batch, got: {:?}", other),
9225        }
9226    }
9227
9228    // -- WI-1.3: Multi-branch COALESCE -----------------------------------
9229
9230    #[test]
9231    fn test_coalesce_multibranch_first_hit_wins() {
9232        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
9233        let ex = exec();
9234
9235        let coalesce = super::super::ast::CoalesceStmt {
9236            grain_type: GrainTypePlural::Facts,
9237            where_clause: None,
9238            branches: vec![
9239                // Branch 1: matches john.
9240                super::super::ast::CoalesceBranch {
9241                    query: CalStatement::Recall(RecallStmt {
9242                        grain_type: GrainTypePlural::Facts,
9243                        about: None,
9244                        where_clause: Some(super::super::ast::WhereClause {
9245                            condition: super::super::ast::Condition::Comparison {
9246                                field: "subject".into(),
9247                                comparator: super::super::ast::Comparator::Eq,
9248                                value: super::super::ast::Value::String {
9249                                    value: "john".into(),
9250                                },
9251                                span: None,
9252                            },
9253                            span: None,
9254                        }),
9255                        recent: None,
9256                        since: None,
9257                        until: None,
9258                        like: None,
9259                        between: None,
9260                        contradictions: None,
9261                        limit: None,
9262                        as_format: None,
9263                        span: None,
9264                    }),
9265                    span: None,
9266                },
9267                // Branch 2: should NOT be reached.
9268                super::super::ast::CoalesceBranch {
9269                    query: CalStatement::Recall(RecallStmt {
9270                        grain_type: GrainTypePlural::Events,
9271                        about: None,
9272                        where_clause: None,
9273                        recent: None,
9274                        since: None,
9275                        until: None,
9276                        like: None,
9277                        between: None,
9278                        contradictions: None,
9279                        limit: None,
9280                        as_format: None,
9281                        span: None,
9282                    }),
9283                    span: None,
9284                },
9285            ],
9286            else_branch: None,
9287            span: None,
9288        };
9289
9290        let query = CalQuery {
9291            version: super::super::ast::CalVersion(1),
9292            statement: CalStatement::Coalesce(coalesce.clone()),
9293            pipeline: Vec::new(),
9294            with_options: Vec::new(),
9295            format: None,
9296            let_bindings: Vec::new(),
9297            let_values: Default::default(),
9298            user_vars: HashMap::new(),
9299            warnings: Vec::new(),
9300        };
9301
9302        let mut warnings = Vec::new();
9303        let result = ex
9304            .execute_coalesce(&coalesce, &store, &query, &mut warnings)
9305            .unwrap();
9306
9307        match result {
9308            CalResultPayload::Grains { grains, .. } => {
9309                assert_eq!(grains.len(), 1, "branch 1 should return john");
9310            }
9311            other => panic!("expected Grains, got: {:?}", other),
9312        }
9313
9314        // Check the short-circuit warning was emitted.
9315        assert!(
9316            warnings.iter().any(|w| w.contains("short-circuited")),
9317            "should emit short-circuit warning"
9318        );
9319    }
9320
9321    #[test]
9322    fn test_coalesce_multibranch_falls_through_to_else() {
9323        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
9324        let ex = exec();
9325
9326        let coalesce = super::super::ast::CoalesceStmt {
9327            grain_type: GrainTypePlural::Facts,
9328            where_clause: None,
9329            branches: vec![
9330                // Branch 1: no match (nobody is named "zzz").
9331                super::super::ast::CoalesceBranch {
9332                    query: CalStatement::Recall(RecallStmt {
9333                        grain_type: GrainTypePlural::Facts,
9334                        about: None,
9335                        where_clause: Some(super::super::ast::WhereClause {
9336                            condition: super::super::ast::Condition::Comparison {
9337                                field: "subject".into(),
9338                                comparator: super::super::ast::Comparator::Eq,
9339                                value: super::super::ast::Value::String {
9340                                    value: "zzz".into(),
9341                                },
9342                                span: None,
9343                            },
9344                            span: None,
9345                        }),
9346                        recent: None,
9347                        since: None,
9348                        until: None,
9349                        like: None,
9350                        between: None,
9351                        contradictions: None,
9352                        limit: None,
9353                        as_format: None,
9354                        span: None,
9355                    }),
9356                    span: None,
9357                },
9358            ],
9359            // ELSE: recall all facts.
9360            else_branch: Some(Box::new(CalStatement::Recall(RecallStmt {
9361                grain_type: GrainTypePlural::Facts,
9362                about: None,
9363                where_clause: None,
9364                recent: None,
9365                since: None,
9366                until: None,
9367                like: None,
9368                between: None,
9369                contradictions: None,
9370                limit: None,
9371                as_format: None,
9372                span: None,
9373            }))),
9374            span: None,
9375        };
9376
9377        let query = CalQuery {
9378            version: super::super::ast::CalVersion(1),
9379            statement: CalStatement::Coalesce(coalesce.clone()),
9380            pipeline: Vec::new(),
9381            with_options: Vec::new(),
9382            format: None,
9383            let_bindings: Vec::new(),
9384            let_values: Default::default(),
9385            user_vars: HashMap::new(),
9386            warnings: Vec::new(),
9387        };
9388
9389        let mut warnings = Vec::new();
9390        let result = ex
9391            .execute_coalesce(&coalesce, &store, &query, &mut warnings)
9392            .unwrap();
9393
9394        match result {
9395            CalResultPayload::Grains { grains, .. } => {
9396                assert_eq!(grains.len(), 1, "ELSE branch should return john");
9397            }
9398            other => panic!("expected Grains, got: {:?}", other),
9399        }
9400    }
9401
9402    #[test]
9403    fn test_coalesce_multibranch_all_empty_no_else() {
9404        let store = MockStore::empty();
9405        let ex = exec();
9406
9407        let coalesce = super::super::ast::CoalesceStmt {
9408            grain_type: GrainTypePlural::Facts,
9409            where_clause: None,
9410            branches: vec![super::super::ast::CoalesceBranch {
9411                query: CalStatement::Recall(RecallStmt {
9412                    grain_type: GrainTypePlural::Facts,
9413                    about: None,
9414                    where_clause: None,
9415                    recent: None,
9416                    since: None,
9417                    until: None,
9418                    like: None,
9419                    between: None,
9420                    contradictions: None,
9421                    limit: None,
9422                    as_format: None,
9423                    span: None,
9424                }),
9425                span: None,
9426            }],
9427            else_branch: None,
9428            span: None,
9429        };
9430
9431        let query = CalQuery {
9432            version: super::super::ast::CalVersion(1),
9433            statement: CalStatement::Coalesce(coalesce.clone()),
9434            pipeline: Vec::new(),
9435            with_options: Vec::new(),
9436            format: None,
9437            let_bindings: Vec::new(),
9438            let_values: Default::default(),
9439            user_vars: HashMap::new(),
9440            warnings: Vec::new(),
9441        };
9442
9443        let mut warnings = Vec::new();
9444        let result = ex
9445            .execute_coalesce(&coalesce, &store, &query, &mut warnings)
9446            .unwrap();
9447
9448        match result {
9449            CalResultPayload::Grains { grains, .. } => {
9450                assert!(grains.is_empty(), "all empty, no ELSE → empty result");
9451            }
9452            other => panic!("expected Grains, got: {:?}", other),
9453        }
9454    }
9455
9456    // -- WI-1.5: EXPLAIN with policy filters -----------------------------
9457
9458    #[test]
9459    fn test_explain_reports_tier1_disabled() {
9460        let store = MockStore::empty();
9461        let ex = CalExecutor::new(CalExecutorConfig {
9462            tier1_enabled: false,
9463            ..Default::default()
9464        });
9465        let result = ex.execute("EXPLAIN RECALL facts", &store).unwrap();
9466
9467        match result.result {
9468            CalResultPayload::Explain { plan } => {
9469                assert!(
9470                    plan.filters.iter().any(|f| f.contains("tier1_disabled")),
9471                    "should report tier1_disabled: {:?}",
9472                    plan.filters
9473                );
9474            }
9475            other => panic!("expected Explain, got: {:?}", other),
9476        }
9477    }
9478
9479    #[test]
9480    fn test_explain_reports_namespace_override() {
9481        let store = MockStore::empty();
9482        let ex = CalExecutor::new(CalExecutorConfig {
9483            namespace_override: Some("test_ns".to_string()),
9484            ..Default::default()
9485        });
9486        let result = ex.execute("EXPLAIN RECALL facts", &store).unwrap();
9487
9488        match result.result {
9489            CalResultPayload::Explain { plan } => {
9490                assert!(
9491                    plan.filters
9492                        .iter()
9493                        .any(|f| f.contains("namespace_override")),
9494                    "should report namespace_override: {:?}",
9495                    plan.filters
9496                );
9497            }
9498            other => panic!("expected Explain, got: {:?}", other),
9499        }
9500    }
9501
9502    #[test]
9503    fn test_explain_reports_user_id_override() {
9504        let store = MockStore::empty();
9505        let ex = CalExecutor::new(CalExecutorConfig {
9506            user_id_override: Some("user123".to_string()),
9507            ..Default::default()
9508        });
9509        let result = ex.execute("EXPLAIN RECALL facts", &store).unwrap();
9510
9511        match result.result {
9512            CalResultPayload::Explain { plan } => {
9513                assert!(
9514                    plan.filters.iter().any(|f| f.contains("user_id_override")),
9515                    "should report user_id_override: {:?}",
9516                    plan.filters
9517                );
9518            }
9519            other => panic!("expected Explain, got: {:?}", other),
9520        }
9521    }
9522
9523    // -- WI-1.6: Grain-type-specific field filtering ---------------------
9524
9525    #[test]
9526    fn test_type_specific_fields_tools() {
9527        let fields = super::type_specific_fields(&GrainTypePlural::Tools);
9528        assert!(fields.contains(&"tool"), "Tools should have 'tool' field");
9529        assert!(
9530            fields.contains(&"is_error"),
9531            "Tools should have 'is_error' field"
9532        );
9533        assert!(
9534            fields.contains(&"duration_ms"),
9535            "Tools should have 'duration_ms' field"
9536        );
9537    }
9538
9539    #[test]
9540    fn test_type_specific_fields_goals() {
9541        let fields = super::type_specific_fields(&GrainTypePlural::Goals);
9542        assert!(fields.contains(&"title"), "Goals should have 'title' field");
9543        // `priority` and `status` are common fields (per spec they appear
9544        // across multiple grain types) and are validated via
9545        // `COMMON_FIELDS`, not via Goals-specific list.
9546        assert!(super::COMMON_FIELDS.contains(&"priority"));
9547        assert!(super::COMMON_FIELDS.contains(&"status"));
9548    }
9549
9550    #[test]
9551    fn test_type_specific_fields_all_returns_empty() {
9552        let fields = super::type_specific_fields(&GrainTypePlural::All);
9553        assert!(
9554            fields.is_empty(),
9555            "All should return empty (no type-specific validation)"
9556        );
9557    }
9558
9559    #[test]
9560    fn test_is_known_type_specific_field() {
9561        assert!(
9562            super::is_known_type_specific_field("tool"),
9563            "'tool' is known (on Tools)"
9564        );
9565        // `priority` is a common field — known overall via COMMON_FIELDS,
9566        // not via type-specific table.
9567        assert!(super::COMMON_FIELDS.contains(&"priority"));
9568        assert!(
9569            !super::is_known_type_specific_field("zzz_unknown"),
9570            "'zzz_unknown' is not known"
9571        );
9572    }
9573
9574    #[test]
9575    fn test_grain_matches_condition_string_eq() {
9576        let grain = CalGrainResult {
9577            hash: "abc".into(),
9578            grain_type: "tool".into(),
9579            score: 1.0,
9580            fields: serde_json::json!({
9581                "tool": "web_search",
9582                "is_error": false,
9583                "duration_ms": 150
9584            }),
9585            score_breakdown: None,
9586            explanation: None,
9587            relative_time: None,
9588            is_deterministic: false,
9589            contested_by: None,
9590        };
9591
9592        // String equality.
9593        assert!(super::grain_matches_condition(
9594            &grain,
9595            "tool",
9596            &super::super::ast::Comparator::Eq,
9597            &super::super::ast::Value::String {
9598                value: "web_search".into()
9599            }
9600        ));
9601        assert!(!super::grain_matches_condition(
9602            &grain,
9603            "tool",
9604            &super::super::ast::Comparator::Eq,
9605            &super::super::ast::Value::String {
9606                value: "db_query".into()
9607            }
9608        ));
9609    }
9610
9611    #[test]
9612    fn test_grain_matches_condition_number_comparisons() {
9613        let grain = CalGrainResult {
9614            hash: "abc".into(),
9615            grain_type: "tool".into(),
9616            score: 1.0,
9617            fields: serde_json::json!({
9618                "duration_ms": 150.0
9619            }),
9620            score_breakdown: None,
9621            explanation: None,
9622            relative_time: None,
9623            is_deterministic: false,
9624            contested_by: None,
9625        };
9626
9627        // Gte
9628        assert!(super::grain_matches_condition(
9629            &grain,
9630            "duration_ms",
9631            &super::super::ast::Comparator::Gte,
9632            &super::super::ast::Value::Number { value: 100.0 }
9633        ));
9634        assert!(super::grain_matches_condition(
9635            &grain,
9636            "duration_ms",
9637            &super::super::ast::Comparator::Gte,
9638            &super::super::ast::Value::Number { value: 150.0 }
9639        ));
9640        assert!(!super::grain_matches_condition(
9641            &grain,
9642            "duration_ms",
9643            &super::super::ast::Comparator::Gte,
9644            &super::super::ast::Value::Number { value: 200.0 }
9645        ));
9646
9647        // Lt
9648        assert!(super::grain_matches_condition(
9649            &grain,
9650            "duration_ms",
9651            &super::super::ast::Comparator::Lt,
9652            &super::super::ast::Value::Number { value: 200.0 }
9653        ));
9654        assert!(!super::grain_matches_condition(
9655            &grain,
9656            "duration_ms",
9657            &super::super::ast::Comparator::Lt,
9658            &super::super::ast::Value::Number { value: 150.0 }
9659        ));
9660    }
9661
9662    #[test]
9663    fn test_grain_matches_condition_boolean() {
9664        let grain = CalGrainResult {
9665            hash: "abc".into(),
9666            grain_type: "tool".into(),
9667            score: 1.0,
9668            fields: serde_json::json!({
9669                "is_error": false
9670            }),
9671            score_breakdown: None,
9672            explanation: None,
9673            relative_time: None,
9674            is_deterministic: false,
9675            contested_by: None,
9676        };
9677
9678        assert!(super::grain_matches_condition(
9679            &grain,
9680            "is_error",
9681            &super::super::ast::Comparator::Eq,
9682            &super::super::ast::Value::Boolean { value: false }
9683        ));
9684        assert!(!super::grain_matches_condition(
9685            &grain,
9686            "is_error",
9687            &super::super::ast::Comparator::Eq,
9688            &super::super::ast::Value::Boolean { value: true }
9689        ));
9690    }
9691
9692    #[test]
9693    fn test_grain_matches_condition_not_eq() {
9694        let grain = CalGrainResult {
9695            hash: "abc".into(),
9696            grain_type: "tool".into(),
9697            score: 1.0,
9698            fields: serde_json::json!({
9699                "tool": "web_search"
9700            }),
9701            score_breakdown: None,
9702            explanation: None,
9703            relative_time: None,
9704            is_deterministic: false,
9705            contested_by: None,
9706        };
9707
9708        assert!(super::grain_matches_condition(
9709            &grain,
9710            "tool",
9711            &super::super::ast::Comparator::NotEq,
9712            &super::super::ast::Value::String {
9713                value: "db_query".into()
9714            }
9715        ));
9716        assert!(!super::grain_matches_condition(
9717            &grain,
9718            "tool",
9719            &super::super::ast::Comparator::NotEq,
9720            &super::super::ast::Value::String {
9721                value: "web_search".into()
9722            }
9723        ));
9724    }
9725
9726    #[test]
9727    fn test_grain_matches_condition_missing_field_returns_false() {
9728        let grain = CalGrainResult {
9729            hash: "abc".into(),
9730            grain_type: "tool".into(),
9731            score: 1.0,
9732            fields: serde_json::json!({}),
9733            score_breakdown: None,
9734            explanation: None,
9735            relative_time: None,
9736            is_deterministic: false,
9737            contested_by: None,
9738        };
9739
9740        // Missing field never matches Eq.
9741        assert!(!super::grain_matches_condition(
9742            &grain,
9743            "tool",
9744            &super::super::ast::Comparator::Eq,
9745            &super::super::ast::Value::String {
9746                value: "anything".into()
9747            }
9748        ));
9749        // Missing field DOES match NotEq (since !false = true).
9750        assert!(super::grain_matches_condition(
9751            &grain,
9752            "tool",
9753            &super::super::ast::Comparator::NotEq,
9754            &super::super::ast::Value::String {
9755                value: "anything".into()
9756            }
9757        ));
9758    }
9759
9760    #[test]
9761    fn test_plan_residual_where_splits_pushdown_from_residual() {
9762        // `subject = "john"` is push-down consumed; `tool = "web_search"`
9763        // is type-specific and must survive as the residual tree.
9764        let condition = super::super::ast::Condition::And {
9765            left: Box::new(super::super::ast::Condition::Comparison {
9766                field: "subject".into(), // push-down consumed
9767                comparator: super::super::ast::Comparator::Eq,
9768                value: super::super::ast::Value::String {
9769                    value: "john".into(),
9770                },
9771                span: None,
9772            }),
9773            right: Box::new(super::super::ast::Condition::Comparison {
9774                field: "tool".into(), // type-specific field
9775                comparator: super::super::ast::Comparator::Eq,
9776                value: super::super::ast::Value::String {
9777                    value: "web_search".into(),
9778                },
9779                span: None,
9780            }),
9781            span: None,
9782        };
9783
9784        let mut warnings = Vec::new();
9785        let residual =
9786            super::plan_residual_where(&condition, &GrainTypePlural::Tools, &mut warnings)
9787                .expect("plan must succeed")
9788                .expect("the type-specific leaf must remain residual");
9789        match residual {
9790            super::super::ast::Condition::Comparison { field, .. } => {
9791                assert_eq!(field, "tool");
9792            }
9793            other => panic!("expected the bare 'tool' leaf, got {other:?}"),
9794        }
9795        assert!(warnings.is_empty(), "no warnings for valid fields");
9796    }
9797
9798    #[test]
9799    fn test_plan_residual_where_unpushed_comparator_on_common_field() {
9800        // #91 — `confidence < 0.5` has no push-down arm (only >= and >),
9801        // so it must be RESIDUAL, not dropped: dropping a narrowing clause
9802        // fails open.
9803        let condition = super::super::ast::Condition::Comparison {
9804            field: "confidence".into(),
9805            comparator: super::super::ast::Comparator::Lt,
9806            value: super::super::ast::Value::Number { value: 0.5 },
9807            span: None,
9808        };
9809
9810        let mut warnings = Vec::new();
9811        let residual =
9812            super::plan_residual_where(&condition, &GrainTypePlural::Facts, &mut warnings)
9813                .expect("plan must succeed");
9814        assert!(
9815            residual.is_some(),
9816            "an unpushed comparator on a common field must be post-filtered"
9817        );
9818    }
9819
9820    #[test]
9821    fn test_plan_residual_where_refuses_unfilterable_field_on_typed_recall() {
9822        // #91 repro 1 — `RECALL tools WHERE status_x = …`-style fields that
9823        // the type cannot carry refuse with CAL-E060 instead of widening.
9824        let condition = super::super::ast::Condition::Comparison {
9825            field: "priority".into(),
9826            comparator: super::super::ast::Comparator::Eq,
9827            value: super::super::ast::Value::String {
9828                value: "high".into(),
9829            },
9830            span: None,
9831        };
9832
9833        let mut warnings = Vec::new();
9834        let err = super::plan_residual_where(&condition, &GrainTypePlural::Tools, &mut warnings)
9835            .expect_err("a filter that cannot be honoured must refuse");
9836        assert_eq!(err.code(), "CAL-E060");
9837    }
9838
9839    #[test]
9840    fn test_plan_residual_where_refuses_engine_field_under_not() {
9841        // `NOT query = "x"` cannot be evaluated per grain (BM25 is a scan
9842        // property) — CAL-E061, never a silent drop.
9843        let condition = super::super::ast::Condition::Not {
9844            inner: Box::new(super::super::ast::Condition::Comparison {
9845                field: "query".into(),
9846                comparator: super::super::ast::Comparator::Eq,
9847                value: super::super::ast::Value::String { value: "x".into() },
9848                span: None,
9849            }),
9850            span: None,
9851        };
9852
9853        let mut warnings = Vec::new();
9854        let err = super::plan_residual_where(&condition, &GrainTypePlural::Facts, &mut warnings)
9855            .expect_err("engine-only fields under NOT must refuse");
9856        assert_eq!(err.code(), "CAL-E061");
9857    }
9858
9859    #[test]
9860    fn test_plan_residual_where_keeps_not_subtree_whole() {
9861        // #91 repro 2 — `NOT tool_name = "x"` must survive as a NOT tree so
9862        // the evaluator returns the complement, not the matches.
9863        let condition = super::super::ast::Condition::Not {
9864            inner: Box::new(super::super::ast::Condition::Comparison {
9865                field: "tool_name".into(),
9866                comparator: super::super::ast::Comparator::Eq,
9867                value: super::super::ast::Value::String { value: "x".into() },
9868                span: None,
9869            }),
9870            span: None,
9871        };
9872
9873        let mut warnings = Vec::new();
9874        let residual =
9875            super::plan_residual_where(&condition, &GrainTypePlural::Tools, &mut warnings)
9876                .expect("plan must succeed")
9877                .expect("NOT subtree must remain residual");
9878        assert!(
9879            matches!(residual, super::super::ast::Condition::Not { .. }),
9880            "the negation must be preserved"
9881        );
9882    }
9883
9884    #[test]
9885    fn test_pushdown_consumed_matches_apply_where_clause_arms() {
9886        // Truth table pinning `leaf_pushdown_consumed` to the arms of
9887        // `apply_where_clause`. If an arm is added or removed there, this
9888        // table must move with it — a leaf marked consumed that no arm
9889        // pushes is a dropped filter (the #91 fail-open), and a pushed
9890        // leaf left unmarked double-filters with per-grain semantics
9891        // (wrong for prefix-scoped `namespace`, BM25 `query`, …).
9892        use super::super::ast::{Comparator, Condition, Value};
9893        let cmp = |field: &str, comparator: Comparator| Condition::Comparison {
9894            field: field.into(),
9895            comparator,
9896            value: Value::String { value: "v".into() },
9897            span: None,
9898        };
9899
9900        // Consumed: engine-level equality push-down.
9901        for f in [
9902            "subject", "relation", "object", "namespace", "user_id", "query", "time", "entity",
9903            "scope", "scope_path",
9904        ] {
9905            assert!(super::leaf_pushdown_consumed(&cmp(f, Comparator::Eq)), "{f} Eq");
9906            // …but only for the comparator the push-down supports.
9907            assert!(!super::leaf_pushdown_consumed(&cmp(f, Comparator::NotEq)), "{f} NotEq");
9908        }
9909        for f in ["confidence", "importance"] {
9910            assert!(super::leaf_pushdown_consumed(&cmp(f, Comparator::Gte)), "{f} Gte");
9911            assert!(!super::leaf_pushdown_consumed(&cmp(f, Comparator::Lt)), "{f} Lt");
9912        }
9913        assert!(super::leaf_pushdown_consumed(&cmp("contradicted", Comparator::Eq)));
9914
9915        // NOT consumed: the residual filter is authoritative for these.
9916        assert!(!super::leaf_pushdown_consumed(&cmp("hash", Comparator::Eq)));
9917        assert!(!super::leaf_pushdown_consumed(&cmp("session_id", Comparator::Eq)));
9918        assert!(!super::leaf_pushdown_consumed(&cmp("tool_name", Comparator::Eq)));
9919        assert!(!super::leaf_pushdown_consumed(&cmp("status", Comparator::Eq)));
9920
9921        // Set forms.
9922        let in_cond = |field: &str| Condition::In {
9923            field: field.into(),
9924            values: vec![Value::String { value: "v".into() }],
9925            span: None,
9926        };
9927        for f in ["subject", "relation", "object", "tags", "namespace"] {
9928            assert!(super::leaf_pushdown_consumed(&in_cond(f)), "{f} IN");
9929        }
9930        assert!(!super::leaf_pushdown_consumed(&in_cond("role")));
9931        let not_in_tags = Condition::NotIn {
9932            field: "tags".into(),
9933            values: vec![Value::String { value: "v".into() }],
9934            span: None,
9935        };
9936        assert!(super::leaf_pushdown_consumed(&not_in_tags));
9937    }
9938
9939    #[test]
9940    fn test_suggest_field_cross_type() {
9941        // "tool" is on Tools but not on Goals.
9942        let suggestion = super::suggest_field("tool", &GrainTypePlural::Goals);
9943        assert!(
9944            suggestion.is_some(),
9945            "should suggest 'tool' exists on another type"
9946        );
9947        assert!(
9948            suggestion.unwrap().contains("different grain type"),
9949            "should mention different grain type"
9950        );
9951    }
9952
9953    #[test]
9954    fn test_suggest_field_substring_match() {
9955        // "stat" contains the substring checked against "status" on Goals.
9956        let suggestion = super::suggest_field("stat", &GrainTypePlural::Goals);
9957        assert!(
9958            suggestion.is_some(),
9959            "should suggest 'status' for substring 'stat'"
9960        );
9961    }
9962
9963    // -- Type-specific fields coverage ------------------------------------
9964
9965    #[test]
9966    fn test_type_specific_fields_events() {
9967        let fields = super::type_specific_fields(&GrainTypePlural::Events);
9968        assert!(fields.contains(&"session_id"));
9969        assert!(fields.contains(&"content"));
9970        assert!(fields.contains(&"created_at"));
9971    }
9972
9973    #[test]
9974    fn test_type_specific_fields_states() {
9975        let fields = super::type_specific_fields(&GrainTypePlural::States);
9976        // Per spec §8.3/§6.3: context (required), plan + history (optional).
9977        // `checkpoint_data` was advertised here as "an Areev extension" but had
9978        // no struct field, serializer, or deserializer — it was never storable.
9979        // `session_id` is Event-only and no longer leaks here.
9980        assert!(fields.contains(&"context"));
9981        assert!(fields.contains(&"plan"));
9982        assert!(fields.contains(&"history"));
9983        assert!(!fields.contains(&"checkpoint_data"));
9984        assert!(!fields.contains(&"session_id"));
9985    }
9986
9987    #[test]
9988    fn test_type_specific_fields_consents() {
9989        let fields = super::type_specific_fields(&GrainTypePlural::Consents);
9990        // `scope` is a common field (per spec §5.2 cross-grain) — validated
9991        // via COMMON_FIELDS, not via the consent type-specific table.
9992        assert!(super::COMMON_FIELDS.contains(&"scope"));
9993        assert!(fields.contains(&"granted"));
9994        assert!(fields.contains(&"subject_did"));
9995        assert!(fields.contains(&"grantee_did"));
9996    }
9997
9998    #[test]
9999    fn test_type_specific_fields_observations() {
10000        let fields = super::type_specific_fields(&GrainTypePlural::Observations);
10001        assert!(fields.contains(&"sensor"));
10002        assert!(fields.contains(&"value"));
10003        assert!(fields.contains(&"unit"));
10004    }
10005
10006    #[test]
10007    fn test_type_specific_fields_workflows() {
10008        let fields = super::type_specific_fields(&GrainTypePlural::Workflows);
10009        assert!(fields.contains(&"name"));
10010        // `status` is a common field; the Workflow-specific list no longer
10011        // duplicates it.
10012        assert!(super::COMMON_FIELDS.contains(&"status"));
10013        assert!(fields.contains(&"nodes"));
10014    }
10015
10016    #[test]
10017    fn test_type_specific_fields_reasonings() {
10018        let fields = super::type_specific_fields(&GrainTypePlural::Reasonings);
10019        assert!(fields.contains(&"premises"));
10020        assert!(fields.contains(&"conclusion"));
10021        // `confidence` is common.
10022        assert!(super::COMMON_FIELDS.contains(&"confidence"));
10023    }
10024
10025    #[test]
10026    fn test_session_id_not_in_common_fields() {
10027        // session_id must NOT be in COMMON_FIELDS: its push-down only
10028        // narrows the SCAN (thread index), so `plan_residual_where` keeps
10029        // it residual and the post-filter stays authoritative.
10030        assert!(
10031            !super::COMMON_FIELDS.contains(&"session_id"),
10032            "session_id should not be in COMMON_FIELDS"
10033        );
10034    }
10035
10036    #[test]
10037    fn test_session_id_stays_residual_despite_scan_pushdown() {
10038        let condition = super::super::ast::Condition::And {
10039            left: Box::new(super::super::ast::Condition::Comparison {
10040                field: "subject".into(),
10041                comparator: super::super::ast::Comparator::Eq,
10042                value: super::super::ast::Value::String {
10043                    value: "alice".into(),
10044                },
10045                span: None,
10046            }),
10047            right: Box::new(super::super::ast::Condition::Comparison {
10048                field: "session_id".into(),
10049                comparator: super::super::ast::Comparator::Eq,
10050                value: super::super::ast::Value::String {
10051                    value: "sess-001".into(),
10052                },
10053                span: None,
10054            }),
10055            span: None,
10056        };
10057
10058        let mut warnings = Vec::new();
10059        let residual =
10060            super::plan_residual_where(&condition, &GrainTypePlural::Events, &mut warnings)
10061                .expect("plan must succeed")
10062                .expect("session_id must remain residual");
10063        // `subject = "alice"` is consumed by push-down; `session_id` is
10064        // pushed only as a scan hint, so it must survive for the
10065        // authoritative per-grain re-check.
10066        match residual {
10067            super::super::ast::Condition::Comparison { field, .. } => {
10068                assert_eq!(field, "session_id");
10069            }
10070            other => panic!("expected the session_id leaf, got {other:?}"),
10071        }
10072    }
10073
10074    #[test]
10075    fn test_goal_state_post_filter() {
10076        let grain = CalGrainResult {
10077            hash: "abc".into(),
10078            grain_type: "goal".into(),
10079            score: 1.0,
10080            fields: serde_json::json!({"goal_state": "active", "subject": "alice"}),
10081            score_breakdown: None,
10082            explanation: None,
10083            relative_time: None,
10084            is_deterministic: false,
10085            contested_by: None,
10086        };
10087
10088        assert!(super::grain_matches_condition(
10089            &grain,
10090            "goal_state",
10091            &super::super::ast::Comparator::Eq,
10092            &super::super::ast::Value::String {
10093                value: "active".into()
10094            }
10095        ));
10096        assert!(!super::grain_matches_condition(
10097            &grain,
10098            "goal_state",
10099            &super::super::ast::Comparator::Eq,
10100            &super::super::ast::Value::String {
10101                value: "failed".into()
10102            }
10103        ));
10104    }
10105
10106    #[test]
10107    fn test_session_id_post_filter() {
10108        let grain = CalGrainResult {
10109            hash: "abc".into(),
10110            grain_type: "event".into(),
10111            score: 1.0,
10112            fields: serde_json::json!({"session_id": "sess-001", "subject": "alice"}),
10113            score_breakdown: None,
10114            explanation: None,
10115            relative_time: None,
10116            is_deterministic: false,
10117            contested_by: None,
10118        };
10119
10120        assert!(super::grain_matches_condition(
10121            &grain,
10122            "session_id",
10123            &super::super::ast::Comparator::Eq,
10124            &super::super::ast::Value::String {
10125                value: "sess-001".into()
10126            }
10127        ));
10128        assert!(!super::grain_matches_condition(
10129            &grain,
10130            "session_id",
10131            &super::super::ast::Comparator::Eq,
10132            &super::super::ast::Value::String {
10133                value: "sess-999".into()
10134            }
10135        ));
10136    }
10137
10138    // -- Chat-event type-specific filter coverage (harness-chat-events) --
10139
10140    #[test]
10141    fn event_role_listed_as_type_specific() {
10142        let fields = super::type_specific_fields(&GrainTypePlural::Events);
10143        assert!(fields.contains(&"role"));
10144        assert!(fields.contains(&"parent_message_id"));
10145        assert!(fields.contains(&"model_id"));
10146        assert!(fields.contains(&"stop_reason"));
10147    }
10148
10149    #[test]
10150    fn event_role_not_in_common_fields() {
10151        assert!(!super::COMMON_FIELDS.contains(&"role"));
10152        assert!(!super::COMMON_FIELDS.contains(&"parent_message_id"));
10153        assert!(!super::COMMON_FIELDS.contains(&"model_id"));
10154        assert!(!super::COMMON_FIELDS.contains(&"stop_reason"));
10155    }
10156
10157    #[test]
10158    fn role_eq_post_filter() {
10159        let grain = CalGrainResult {
10160            hash: "h1".into(),
10161            grain_type: "event".into(),
10162            score: 1.0,
10163            fields: serde_json::json!({"role": "user", "content": "hi"}),
10164            score_breakdown: None,
10165            explanation: None,
10166            relative_time: None,
10167            is_deterministic: false,
10168            contested_by: None,
10169        };
10170        assert!(super::grain_matches_condition(
10171            &grain,
10172            "role",
10173            &super::super::ast::Comparator::Eq,
10174            &super::super::ast::Value::String {
10175                value: "user".into()
10176            }
10177        ));
10178        assert!(!super::grain_matches_condition(
10179            &grain,
10180            "role",
10181            &super::super::ast::Comparator::Eq,
10182            &super::super::ast::Value::String {
10183                value: "assistant".into()
10184            }
10185        ));
10186    }
10187
10188    #[test]
10189    fn role_in_list_post_filter() {
10190        use super::super::ast::{Condition, Value};
10191
10192        let condition = Condition::In {
10193            field: "role".into(),
10194            values: vec![
10195                Value::String {
10196                    value: "user".into(),
10197                },
10198                Value::String {
10199                    value: "assistant".into(),
10200                },
10201            ],
10202            span: None,
10203        };
10204
10205        let user_grain = CalGrainResult {
10206            hash: "h1".into(),
10207            grain_type: "event".into(),
10208            score: 1.0,
10209            fields: serde_json::json!({"role": "user"}),
10210            score_breakdown: None,
10211            explanation: None,
10212            relative_time: None,
10213            is_deterministic: false,
10214            contested_by: None,
10215        };
10216        let tool_grain = CalGrainResult {
10217            hash: "h2".into(),
10218            grain_type: "event".into(),
10219            score: 1.0,
10220            fields: serde_json::json!({"role": "tool"}),
10221            score_breakdown: None,
10222            explanation: None,
10223            relative_time: None,
10224            is_deterministic: false,
10225            contested_by: None,
10226        };
10227
10228        assert!(super::grain_matches_condition_tree(&user_grain, &condition));
10229        assert!(!super::grain_matches_condition_tree(
10230            &tool_grain,
10231            &condition
10232        ));
10233    }
10234
10235    #[test]
10236    fn parent_message_id_eq_post_filter() {
10237        let grain = CalGrainResult {
10238            hash: "h1".into(),
10239            grain_type: "event".into(),
10240            score: 1.0,
10241            fields: serde_json::json!({"parent_message_id": "deadbeef"}),
10242            score_breakdown: None,
10243            explanation: None,
10244            relative_time: None,
10245            is_deterministic: false,
10246            contested_by: None,
10247        };
10248        assert!(super::grain_matches_condition(
10249            &grain,
10250            "parent_message_id",
10251            &super::super::ast::Comparator::Eq,
10252            &super::super::ast::Value::String {
10253                value: "deadbeef".into()
10254            }
10255        ));
10256    }
10257
10258    #[test]
10259    fn role_and_session_id_both_stay_residual() {
10260        use super::super::ast::{Comparator, Condition, Value};
10261        let condition = Condition::And {
10262            left: Box::new(Condition::Comparison {
10263                field: "role".into(),
10264                comparator: Comparator::Eq,
10265                value: Value::String {
10266                    value: "user".into(),
10267                },
10268                span: None,
10269            }),
10270            right: Box::new(Condition::Comparison {
10271                field: "session_id".into(),
10272                comparator: Comparator::Eq,
10273                value: Value::String { value: "s1".into() },
10274                span: None,
10275            }),
10276            span: None,
10277        };
10278        let mut warnings = Vec::new();
10279        let residual =
10280            super::plan_residual_where(&condition, &GrainTypePlural::Events, &mut warnings)
10281                .expect("plan must succeed")
10282                .expect("both leaves must remain residual");
10283        // Neither leaf is push-down consumed, so the residual keeps the
10284        // whole AND tree.
10285        match residual {
10286            super::super::ast::Condition::And { .. } => {}
10287            other => panic!("expected the full AND tree, got {other:?}"),
10288        }
10289    }
10290
10291    // -- WI-1.1: ASSEMBLE WHERE clause -----------------------------------
10292
10293    #[test]
10294    fn test_assemble_where_filters_results() {
10295        let store = MockStore::with_grains(vec![
10296            make_fact("john", "likes", "coffee"),
10297            make_fact("bob", "likes", "coffee"),
10298        ]);
10299        let ex = exec();
10300
10301        let assemble = super::super::ast::AssembleStmt {
10302            topic: "test".into(),
10303            from: super::super::ast::Source::Query(Box::new(RecallStmt {
10304                grain_type: GrainTypePlural::Facts,
10305                about: None,
10306                where_clause: None,
10307                recent: None,
10308                since: None,
10309                until: None,
10310                like: None,
10311                between: None,
10312                contradictions: None,
10313                limit: None,
10314                as_format: None,
10315                span: None,
10316            })),
10317            where_clause: Some(super::super::ast::WhereClause {
10318                condition: super::super::ast::Condition::Comparison {
10319                    field: "subject".into(),
10320                    comparator: super::super::ast::Comparator::Eq,
10321                    value: super::super::ast::Value::String {
10322                        value: "john".into(),
10323                    },
10324                    span: None,
10325                },
10326                span: None,
10327            }),
10328            context_name: None,
10329            sources: None,
10330            budget: None,
10331            priority: None,
10332            format: None,
10333            for_whom: None,
10334            assemble_with: Vec::new(),
10335            with_options: Vec::new(),
10336            streaming: false,
10337            span: None,
10338        };
10339
10340        let query = CalQuery {
10341            version: super::super::ast::CalVersion(1),
10342            statement: CalStatement::Assemble(assemble.clone()),
10343            pipeline: Vec::new(),
10344            with_options: Vec::new(),
10345            format: None,
10346            let_bindings: Vec::new(),
10347            let_values: Default::default(),
10348            user_vars: HashMap::new(),
10349            warnings: Vec::new(),
10350        };
10351
10352        let mut warnings = Vec::new();
10353        let result = ex
10354            .execute_assemble(&assemble, &store, &query, &mut warnings)
10355            .unwrap();
10356
10357        match result {
10358            CalResultPayload::Grains { grains, .. } => {
10359                assert_eq!(grains.len(), 1, "WHERE subject='john' should filter to 1");
10360                assert_eq!(
10361                    grains[0].fields.get("subject").and_then(|v| v.as_str()),
10362                    Some("john")
10363                );
10364            }
10365            other => panic!("expected Grains, got: {:?}", other),
10366        }
10367    }
10368
10369    #[test]
10370    fn test_assemble_format_json() {
10371        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
10372        let ex = exec();
10373
10374        let assemble = super::super::ast::AssembleStmt {
10375            topic: "test".into(),
10376            from: super::super::ast::Source::Query(Box::new(RecallStmt {
10377                grain_type: GrainTypePlural::Facts,
10378                about: None,
10379                where_clause: None,
10380                recent: None,
10381                since: None,
10382                until: None,
10383                like: None,
10384                between: None,
10385                contradictions: None,
10386                limit: None,
10387                as_format: None,
10388                span: None,
10389            })),
10390            where_clause: None,
10391            context_name: None,
10392            sources: None,
10393            budget: None,
10394            priority: None,
10395            format: Some(super::super::ast::FormatClause::Single(
10396                super::super::ast::FormatSpec::Json,
10397            )),
10398            for_whom: None,
10399            assemble_with: Vec::new(),
10400            with_options: Vec::new(),
10401            streaming: false,
10402            span: None,
10403        };
10404
10405        let query = CalQuery {
10406            version: super::super::ast::CalVersion(1),
10407            statement: CalStatement::Assemble(assemble.clone()),
10408            pipeline: Vec::new(),
10409            with_options: Vec::new(),
10410            format: None,
10411            let_bindings: Vec::new(),
10412            let_values: Default::default(),
10413            user_vars: HashMap::new(),
10414            warnings: Vec::new(),
10415        };
10416
10417        let mut warnings = Vec::new();
10418        let result = ex
10419            .execute_assemble(&assemble, &store, &query, &mut warnings)
10420            .unwrap();
10421
10422        // FORMAT json now returns a Grains payload directly (not Formatted text)
10423        // so that result.grains is a structured array.
10424        match result {
10425            CalResultPayload::Grains { grains, .. } => {
10426                assert_eq!(grains.len(), 1);
10427                assert_eq!(grains[0].grain_type, "fact");
10428                let subject = grains[0].fields.get("subject").and_then(|v| v.as_str());
10429                assert_eq!(subject, Some("john"));
10430            }
10431            other => panic!("expected Grains, got: {:?}", other),
10432        }
10433    }
10434
10435    #[test]
10436    fn test_assemble_format_markdown() {
10437        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
10438        let ex = exec();
10439
10440        let assemble = super::super::ast::AssembleStmt {
10441            topic: "test".into(),
10442            from: super::super::ast::Source::Query(Box::new(RecallStmt {
10443                grain_type: GrainTypePlural::Facts,
10444                about: None,
10445                where_clause: None,
10446                recent: None,
10447                since: None,
10448                until: None,
10449                like: None,
10450                between: None,
10451                contradictions: None,
10452                limit: None,
10453                as_format: None,
10454                span: None,
10455            })),
10456            where_clause: None,
10457            context_name: None,
10458            sources: None,
10459            budget: None,
10460            priority: None,
10461            format: Some(super::super::ast::FormatClause::Single(
10462                super::super::ast::FormatSpec::Markdown,
10463            )),
10464            for_whom: None,
10465            assemble_with: Vec::new(),
10466            with_options: Vec::new(),
10467            streaming: false,
10468            span: None,
10469        };
10470
10471        let query = CalQuery {
10472            version: super::super::ast::CalVersion(1),
10473            statement: CalStatement::Assemble(assemble.clone()),
10474            pipeline: Vec::new(),
10475            with_options: Vec::new(),
10476            format: None,
10477            let_bindings: Vec::new(),
10478            let_values: Default::default(),
10479            user_vars: HashMap::new(),
10480            warnings: Vec::new(),
10481        };
10482
10483        let mut warnings = Vec::new();
10484        let result = ex
10485            .execute_assemble(&assemble, &store, &query, &mut warnings)
10486            .unwrap();
10487
10488        match result {
10489            CalResultPayload::Formatted {
10490                text,
10491                format,
10492                grain_count,
10493                ..
10494            } => {
10495                assert_eq!(format, "markdown");
10496                assert_eq!(grain_count, 1);
10497                // Ungrouped markdown is a flat list of assertions: a `### fact
10498                // (hash)` heading above every line is noise in a prompt, which
10499                // is what this output is for. Grouped renders keep their
10500                // group heading — see the GROUP BY tests.
10501                assert!(
10502                    !text.contains("###"),
10503                    "ungrouped markdown should not carry per-grain headings, got: {text}"
10504                );
10505                // The lead of each line is bold — under the old dump that was
10506                // the literal field name (`**subject**:`), now it is the value
10507                // the line is about, which is what a reader needs.
10508                assert!(
10509                    text.starts_with("- **"),
10510                    "Markdown should render an assertion line, got: {text}"
10511                );
10512            }
10513            other => panic!("expected Formatted, got: {:?}", other),
10514        }
10515    }
10516
10517    #[test]
10518    fn test_assemble_format_triples() {
10519        let store = MockStore::with_grains(vec![
10520            make_fact("john", "likes", "coffee"),
10521            make_fact("bob", "likes", "coffee"),
10522        ]);
10523        let ex = exec();
10524
10525        let assemble = super::super::ast::AssembleStmt {
10526            topic: "test".into(),
10527            from: super::super::ast::Source::Query(Box::new(RecallStmt {
10528                grain_type: GrainTypePlural::Facts,
10529                about: None,
10530                where_clause: None,
10531                recent: None,
10532                since: None,
10533                until: None,
10534                like: None,
10535                between: None,
10536                contradictions: None,
10537                limit: None,
10538                as_format: None,
10539                span: None,
10540            })),
10541            where_clause: None,
10542            context_name: None,
10543            sources: None,
10544            budget: None,
10545            priority: None,
10546            format: Some(super::super::ast::FormatClause::Single(
10547                super::super::ast::FormatSpec::Triples,
10548            )),
10549            for_whom: None,
10550            assemble_with: Vec::new(),
10551            with_options: Vec::new(),
10552            streaming: false,
10553            span: None,
10554        };
10555
10556        let query = CalQuery {
10557            version: super::super::ast::CalVersion(1),
10558            statement: CalStatement::Assemble(assemble.clone()),
10559            pipeline: Vec::new(),
10560            with_options: Vec::new(),
10561            format: None,
10562            let_bindings: Vec::new(),
10563            let_values: Default::default(),
10564            user_vars: HashMap::new(),
10565            warnings: Vec::new(),
10566        };
10567
10568        let mut warnings = Vec::new();
10569        let result = ex
10570            .execute_assemble(&assemble, &store, &query, &mut warnings)
10571            .unwrap();
10572
10573        match result {
10574            CalResultPayload::Formatted {
10575                text,
10576                format,
10577                grain_count,
10578                ..
10579            } => {
10580                assert_eq!(format, "triples");
10581                assert_eq!(grain_count, 2);
10582                // Triples are tab-separated: subject\trelation\tobject
10583                assert_eq!(text.lines().count(), 2, "should have 2 triple lines");
10584            }
10585            other => panic!("expected Formatted, got: {:?}", other),
10586        }
10587    }
10588
10589    #[test]
10590    fn test_assemble_format_with_where_filters_then_formats() {
10591        // Test that WHERE is applied BEFORE FORMAT.
10592        let store = MockStore::with_grains(vec![
10593            make_fact("john", "likes", "coffee"),
10594            make_fact("bob", "likes", "coffee"),
10595        ]);
10596        let ex = exec();
10597
10598        let assemble = super::super::ast::AssembleStmt {
10599            topic: "test".into(),
10600            from: super::super::ast::Source::Query(Box::new(RecallStmt {
10601                grain_type: GrainTypePlural::Facts,
10602                about: None,
10603                where_clause: None,
10604                recent: None,
10605                since: None,
10606                until: None,
10607                like: None,
10608                between: None,
10609                contradictions: None,
10610                limit: None,
10611                as_format: None,
10612                span: None,
10613            })),
10614            where_clause: Some(super::super::ast::WhereClause {
10615                condition: super::super::ast::Condition::Comparison {
10616                    field: "subject".into(),
10617                    comparator: super::super::ast::Comparator::Eq,
10618                    value: super::super::ast::Value::String {
10619                        value: "john".into(),
10620                    },
10621                    span: None,
10622                },
10623                span: None,
10624            }),
10625            context_name: None,
10626            sources: None,
10627            budget: None,
10628            priority: None,
10629            format: Some(super::super::ast::FormatClause::Single(
10630                super::super::ast::FormatSpec::Json,
10631            )),
10632            for_whom: None,
10633            assemble_with: Vec::new(),
10634            with_options: Vec::new(),
10635            streaming: false,
10636            span: None,
10637        };
10638
10639        let query = CalQuery {
10640            version: super::super::ast::CalVersion(1),
10641            statement: CalStatement::Assemble(assemble.clone()),
10642            pipeline: Vec::new(),
10643            with_options: Vec::new(),
10644            format: None,
10645            let_bindings: Vec::new(),
10646            let_values: Default::default(),
10647            user_vars: HashMap::new(),
10648            warnings: Vec::new(),
10649        };
10650
10651        let mut warnings = Vec::new();
10652        let result = ex
10653            .execute_assemble(&assemble, &store, &query, &mut warnings)
10654            .unwrap();
10655
10656        // FORMAT json now returns Grains payload directly.
10657        match result {
10658            CalResultPayload::Grains { grains, .. } => {
10659                assert_eq!(grains.len(), 1, "WHERE should filter to 1 grain");
10660                let subject = grains[0].fields.get("subject").and_then(|v| v.as_str());
10661                assert_eq!(subject, Some("john"));
10662            }
10663            other => panic!("expected Grains, got: {:?}", other),
10664        }
10665    }
10666
10667    #[test]
10668    fn test_assemble_without_where_returns_all() {
10669        let store = MockStore::with_grains(vec![
10670            make_fact("john", "likes", "coffee"),
10671            make_fact("bob", "likes", "coffee"),
10672        ]);
10673        let ex = exec();
10674
10675        let assemble = super::super::ast::AssembleStmt {
10676            topic: "test".into(),
10677            from: super::super::ast::Source::Query(Box::new(RecallStmt {
10678                grain_type: GrainTypePlural::Facts,
10679                about: None,
10680                where_clause: None,
10681                recent: None,
10682                since: None,
10683                until: None,
10684                like: None,
10685                between: None,
10686                contradictions: None,
10687                limit: None,
10688                as_format: None,
10689                span: None,
10690            })),
10691            where_clause: None,
10692            context_name: None,
10693            sources: None,
10694            budget: None,
10695            priority: None,
10696            format: None,
10697            for_whom: None,
10698            assemble_with: Vec::new(),
10699            with_options: Vec::new(),
10700            streaming: false,
10701            span: None,
10702        };
10703
10704        let query = CalQuery {
10705            version: super::super::ast::CalVersion(1),
10706            statement: CalStatement::Assemble(assemble.clone()),
10707            pipeline: Vec::new(),
10708            with_options: Vec::new(),
10709            format: None,
10710            let_bindings: Vec::new(),
10711            let_values: Default::default(),
10712            user_vars: HashMap::new(),
10713            warnings: Vec::new(),
10714        };
10715
10716        let mut warnings = Vec::new();
10717        let result = ex
10718            .execute_assemble(&assemble, &store, &query, &mut warnings)
10719            .unwrap();
10720
10721        match result {
10722            CalResultPayload::Grains { grains, .. } => {
10723                assert_eq!(grains.len(), 2, "no WHERE should return all");
10724            }
10725            other => panic!("expected Grains, got: {:?}", other),
10726        }
10727    }
10728
10729    // ── Multi-source ASSEMBLE FORMAT tests ────────────────────────────
10730
10731    #[test]
10732    fn test_multisource_assemble_format_json() {
10733        let store = MockStore::with_grains(vec![
10734            make_fact("john", "likes", "coffee"),
10735            make_fact("bob", "likes", "coffee"),
10736        ]);
10737        let ex = exec();
10738
10739        let recall_facts = RecallStmt {
10740            grain_type: GrainTypePlural::Facts,
10741            about: None,
10742            where_clause: None,
10743            recent: None,
10744            since: None,
10745            until: None,
10746            like: None,
10747            between: None,
10748            contradictions: None,
10749            limit: None,
10750            as_format: None,
10751            span: None,
10752        };
10753
10754        let assemble = super::super::ast::AssembleStmt {
10755            topic: "test".into(),
10756            from: super::super::ast::Source::Query(Box::new(recall_facts.clone())),
10757            where_clause: None,
10758            context_name: None,
10759            sources: Some(vec![super::super::ast::NamedSource {
10760                label: "facts".into(),
10761                literal: None,
10762                pinned: false,
10763                query: Box::new(CalStatement::Recall(recall_facts)),
10764                with_options: vec![],
10765                span: None,
10766            }]),
10767            budget: None,
10768            priority: None,
10769            format: Some(super::super::ast::FormatClause::Single(
10770                super::super::ast::FormatSpec::Json,
10771            )),
10772            for_whom: None,
10773            assemble_with: Vec::new(),
10774            with_options: Vec::new(),
10775            streaming: false,
10776            span: None,
10777        };
10778
10779        let query = CalQuery {
10780            version: super::super::ast::CalVersion(1),
10781            statement: CalStatement::Assemble(assemble.clone()),
10782            pipeline: Vec::new(),
10783            with_options: Vec::new(),
10784            format: None,
10785            let_bindings: Vec::new(),
10786            let_values: Default::default(),
10787            user_vars: HashMap::new(),
10788            warnings: Vec::new(),
10789        };
10790
10791        let mut warnings = Vec::new();
10792        let result = ex
10793            .execute_assemble(&assemble, &store, &query, &mut warnings)
10794            .unwrap();
10795
10796        // FORMAT json now returns Grains payload directly.
10797        match result {
10798            CalResultPayload::Grains { grains, .. } => {
10799                assert!(!grains.is_empty(), "should have grains");
10800                assert!(
10801                    grains
10802                        .iter()
10803                        .filter_map(|g| g.fields.get("subject").and_then(|v| v.as_str()))
10804                        .any(|x| x == "john"),
10805                    "should contain john"
10806                );
10807            }
10808            other => panic!(
10809                "expected Grains for multi-source FORMAT json, got: {:?}",
10810                other
10811            ),
10812        }
10813    }
10814
10815    #[test]
10816    fn test_multisource_assemble_format_sml() {
10817        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
10818        let ex = exec();
10819
10820        let recall_facts = RecallStmt {
10821            grain_type: GrainTypePlural::Facts,
10822            about: None,
10823            where_clause: None,
10824            recent: None,
10825            since: None,
10826            until: None,
10827            like: None,
10828            between: None,
10829            contradictions: None,
10830            limit: None,
10831            as_format: None,
10832            span: None,
10833        };
10834
10835        let assemble = super::super::ast::AssembleStmt {
10836            topic: "test".into(),
10837            from: super::super::ast::Source::Query(Box::new(recall_facts.clone())),
10838            where_clause: None,
10839            context_name: None,
10840            sources: Some(vec![super::super::ast::NamedSource {
10841                label: "events".into(),
10842                literal: None,
10843                pinned: false,
10844                query: Box::new(CalStatement::Recall(recall_facts)),
10845                with_options: vec![],
10846                span: None,
10847            }]),
10848            budget: None,
10849            priority: None,
10850            format: Some(super::super::ast::FormatClause::Single(
10851                super::super::ast::FormatSpec::Sml,
10852            )),
10853            for_whom: None,
10854            assemble_with: Vec::new(),
10855            with_options: Vec::new(),
10856            streaming: false,
10857            span: None,
10858        };
10859
10860        let query = CalQuery {
10861            version: super::super::ast::CalVersion(1),
10862            statement: CalStatement::Assemble(assemble.clone()),
10863            pipeline: Vec::new(),
10864            with_options: Vec::new(),
10865            format: None,
10866            let_bindings: Vec::new(),
10867            let_values: Default::default(),
10868            user_vars: HashMap::new(),
10869            warnings: Vec::new(),
10870        };
10871
10872        let mut warnings = Vec::new();
10873        let result = ex
10874            .execute_assemble(&assemble, &store, &query, &mut warnings)
10875            .unwrap();
10876
10877        match result {
10878            CalResultPayload::Formatted {
10879                text,
10880                format,
10881                grain_count,
10882                ..
10883            } => {
10884                assert_eq!(format, "sml");
10885                assert!(grain_count > 0, "should have grains");
10886                assert!(text.contains("<grains>"), "SML should contain <grains> tag");
10887            }
10888            other => panic!(
10889                "expected Formatted for multi-source FORMAT sml, got: {:?}",
10890                other
10891            ),
10892        }
10893    }
10894
10895    #[test]
10896    fn test_multisource_assemble_without_format_returns_assembled() {
10897        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
10898        let ex = exec();
10899
10900        let recall_facts = RecallStmt {
10901            grain_type: GrainTypePlural::Facts,
10902            about: None,
10903            where_clause: None,
10904            recent: None,
10905            since: None,
10906            until: None,
10907            like: None,
10908            between: None,
10909            contradictions: None,
10910            limit: None,
10911            as_format: None,
10912            span: None,
10913        };
10914
10915        let assemble = super::super::ast::AssembleStmt {
10916            topic: "test".into(),
10917            from: super::super::ast::Source::Query(Box::new(recall_facts.clone())),
10918            where_clause: None,
10919            context_name: None,
10920            sources: Some(vec![super::super::ast::NamedSource {
10921                label: "facts".into(),
10922                literal: None,
10923                pinned: false,
10924                query: Box::new(CalStatement::Recall(recall_facts)),
10925                with_options: vec![],
10926                span: None,
10927            }]),
10928            budget: None,
10929            priority: None,
10930            format: None,
10931            for_whom: None,
10932            assemble_with: Vec::new(),
10933            with_options: Vec::new(),
10934            streaming: false,
10935            span: None,
10936        };
10937
10938        let query = CalQuery {
10939            version: super::super::ast::CalVersion(1),
10940            statement: CalStatement::Assemble(assemble.clone()),
10941            pipeline: Vec::new(),
10942            with_options: Vec::new(),
10943            format: None,
10944            let_bindings: Vec::new(),
10945            let_values: Default::default(),
10946            user_vars: HashMap::new(),
10947            warnings: Vec::new(),
10948        };
10949
10950        let mut warnings = Vec::new();
10951        let result = ex
10952            .execute_assemble(&assemble, &store, &query, &mut warnings)
10953            .unwrap();
10954
10955        match result {
10956            CalResultPayload::Assembled { grains, .. } => {
10957                assert!(!grains.is_empty(), "should have assembled grains");
10958            }
10959            other => panic!("expected Assembled (no FORMAT), got: {:?}", other),
10960        }
10961    }
10962
10963    // ── Multi-format executor tests (CAL spec v1.0.1) ─────────────────
10964
10965    #[test]
10966    fn test_multi_format_rendering() {
10967        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
10968        let ex = CalExecutor::with_defaults();
10969
10970        let result = ex
10971            .execute("RECALL facts FORMAT [json, markdown]", &store)
10972            .unwrap();
10973        match result.result {
10974            CalResultPayload::MultiFormatted {
10975                formats,
10976                grain_count,
10977                ..
10978            } => {
10979                assert_eq!(grain_count, 1);
10980                assert_eq!(formats.len(), 2);
10981                assert!(formats.contains_key("json"), "should have json key");
10982                assert!(formats.contains_key("markdown"), "should have markdown key");
10983                // JSON rendering should contain the grain data.
10984                assert!(formats["json"].contains("john"));
10985                // Markdown rendering should contain the grain data.
10986                assert!(formats["markdown"].contains("john"));
10987            }
10988            other => panic!("expected MultiFormatted, got: {:?}", other),
10989        }
10990    }
10991
10992    #[test]
10993    fn test_single_format_backward_compat() {
10994        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
10995        let ex = CalExecutor::with_defaults();
10996
10997        // FORMAT json now returns Grains payload directly (not Formatted text).
10998        let result = ex.execute("RECALL facts FORMAT json", &store).unwrap();
10999        match result.result {
11000            CalResultPayload::Grains { grains, .. } => {
11001                assert_eq!(grains.len(), 1);
11002            }
11003            other => panic!("expected Grains, got: {:?}", other),
11004        }
11005    }
11006
11007    #[test]
11008    fn test_multi_format_single_element_list() {
11009        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11010        let ex = CalExecutor::with_defaults();
11011
11012        // FORMAT [json] should produce MultiFormatted, not Formatted.
11013        let result = ex.execute("RECALL facts FORMAT [json]", &store).unwrap();
11014        match result.result {
11015            CalResultPayload::MultiFormatted {
11016                formats,
11017                grain_count,
11018                ..
11019            } => {
11020                assert_eq!(grain_count, 1);
11021                assert_eq!(formats.len(), 1);
11022                assert!(formats.contains_key("json"));
11023            }
11024            other => panic!("expected MultiFormatted, got: {:?}", other),
11025        }
11026    }
11027
11028    #[test]
11029    fn test_multi_format_all_seven_types() {
11030        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11031        let ex = CalExecutor::with_defaults();
11032
11033        let result = ex
11034            .execute(
11035                "RECALL facts FORMAT [json, markdown, yaml, text, sml]",
11036                &store,
11037            )
11038            .unwrap();
11039        match result.result {
11040            CalResultPayload::MultiFormatted { formats, .. } => {
11041                assert_eq!(formats.len(), 5);
11042                for key in &["json", "markdown", "yaml", "text", "sml"] {
11043                    assert!(formats.contains_key(*key), "missing format: {}", key);
11044                }
11045            }
11046            other => panic!("expected MultiFormatted, got: {:?}", other),
11047        }
11048    }
11049
11050    #[test]
11051    fn test_no_format_returns_grains() {
11052        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11053        let ex = CalExecutor::with_defaults();
11054
11055        // No FORMAT clause — returns Grains payload (unchanged behavior).
11056        let result = ex.execute("RECALL facts", &store).unwrap();
11057        assert!(matches!(result.result, CalResultPayload::Grains { .. }));
11058    }
11059
11060    #[test]
11061    fn test_multi_format_with_aliases() {
11062        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11063        let ex = CalExecutor::with_defaults();
11064
11065        let result = ex
11066            .execute(
11067                "RECALL facts FORMAT [json AS customers, markdown AS report]",
11068                &store,
11069            )
11070            .unwrap();
11071        match result.result {
11072            CalResultPayload::MultiFormatted {
11073                formats,
11074                grain_count,
11075                ..
11076            } => {
11077                assert_eq!(grain_count, 1);
11078                assert_eq!(formats.len(), 2);
11079                assert!(
11080                    formats.contains_key("customers"),
11081                    "should have aliased key 'customers'"
11082                );
11083                assert!(
11084                    formats.contains_key("report"),
11085                    "should have aliased key 'report'"
11086                );
11087                assert!(
11088                    !formats.contains_key("json"),
11089                    "should NOT have canonical key 'json'"
11090                );
11091                assert!(
11092                    !formats.contains_key("markdown"),
11093                    "should NOT have canonical key 'markdown'"
11094                );
11095                assert!(formats["customers"].contains("john"));
11096                assert!(formats["report"].contains("john"));
11097            }
11098            other => panic!("expected MultiFormatted, got: {:?}", other),
11099        }
11100    }
11101
11102    #[test]
11103    fn test_multi_format_mixed_alias_and_no_alias() {
11104        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11105        let ex = CalExecutor::with_defaults();
11106
11107        let result = ex
11108            .execute("RECALL facts FORMAT [json AS customers, markdown]", &store)
11109            .unwrap();
11110        match result.result {
11111            CalResultPayload::MultiFormatted {
11112                formats,
11113                grain_count,
11114                ..
11115            } => {
11116                assert_eq!(grain_count, 1);
11117                assert_eq!(formats.len(), 2);
11118                assert!(formats.contains_key("customers"), "aliased key");
11119                assert!(
11120                    formats.contains_key("markdown"),
11121                    "canonical key for non-aliased"
11122                );
11123            }
11124            other => panic!("expected MultiFormatted, got: {:?}", other),
11125        }
11126    }
11127
11128    #[test]
11129    fn test_multi_format_template_alias() {
11130        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11131        let ex = CalExecutor::with_defaults();
11132
11133        let result = ex
11134            .execute(
11135                r#"RECALL facts FORMAT [TEMPLATE "{{subject}}: {{object}}" AS summary, json]"#,
11136                &store,
11137            )
11138            .unwrap();
11139        match result.result {
11140            CalResultPayload::MultiFormatted {
11141                formats,
11142                grain_count,
11143                ..
11144            } => {
11145                assert_eq!(grain_count, 1);
11146                assert_eq!(formats.len(), 2);
11147                assert!(formats.contains_key("summary"), "template alias");
11148                assert!(formats.contains_key("json"), "json canonical");
11149                assert!(formats["summary"].contains("john: coffee"));
11150            }
11151            other => panic!("expected MultiFormatted, got: {:?}", other),
11152        }
11153    }
11154
11155    // ── GROUP BY pipeline stage tests ─────────────────────────────────
11156
11157    /// Build a fact grain with a custom `created_at_sec` for GROUP BY tests.
11158    fn make_fact_with_time(
11159        subject: &str,
11160        relation: &str,
11161        object: &str,
11162        created_at_sec: u32,
11163    ) -> (Hash, DeserializedGrain) {
11164        let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
11165        fields.insert("subject".into(), serde_json::json!(subject));
11166        fields.insert("relation".into(), serde_json::json!(relation));
11167        fields.insert("object".into(), serde_json::json!(object));
11168        fields.insert("grain_type".into(), serde_json::json!("fact"));
11169        fields.insert("confidence".into(), serde_json::json!(0.9));
11170        fields.insert("created_at_sec".into(), serde_json::json!(created_at_sec));
11171
11172        let mut hash_bytes = [0u8; 32];
11173        let key = format!("{}|{}|{}|{}", subject, relation, object, created_at_sec);
11174        for (i, b) in key.as_bytes().iter().enumerate().take(32) {
11175            hash_bytes[i] = *b;
11176        }
11177        let hash = Hash::from_bytes(&hash_bytes);
11178
11179        let grain = DeserializedGrain {
11180            header: MgHeader {
11181                version: 1,
11182                flags: 0,
11183                grain_type: 0x01,
11184                ns_hash: 0,
11185                created_at_sec,
11186            },
11187            grain_type: GrainType::Fact,
11188            fields,
11189            hash,
11190        };
11191        (hash, grain)
11192    }
11193
11194    #[test]
11195    fn test_group_by_parses() {
11196        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11197        let ex = CalExecutor::with_defaults();
11198        let result = ex.execute("RECALL facts GROUP BY subject", &store);
11199        assert!(result.is_ok(), "GROUP BY should parse and execute");
11200    }
11201
11202    #[test]
11203    fn test_group_by_reorders_grains() {
11204        // Create grains with interleaved subjects: bob, john, bob, john.
11205        let store = MockStore::with_grains(vec![
11206            make_fact_with_time("bob", "likes", "tea", 100),
11207            make_fact_with_time("john", "likes", "coffee", 200),
11208            make_fact_with_time("bob", "likes", "vim", 300),
11209            make_fact_with_time("john", "likes", "rust", 400),
11210        ]);
11211        let ex = CalExecutor::with_defaults();
11212
11213        let result = ex.execute("RECALL facts GROUP BY subject", &store).unwrap();
11214        match result.result {
11215            CalResultPayload::Grains { grains, .. } => {
11216                assert_eq!(grains.len(), 4);
11217                // Grains should be grouped: john grains first (earlier created_at_sec
11218                // in first grain? no — bob has 100 which is earliest). So bob first.
11219                // bob: 100, 300; john: 200, 400. Groups ordered by earliest.
11220                let subjects: Vec<&str> = grains
11221                    .iter()
11222                    .filter_map(|g| g.fields.get("subject").and_then(|v| v.as_str()))
11223                    .collect();
11224                assert_eq!(subjects, vec!["bob", "bob", "john", "john"]);
11225            }
11226            other => panic!("expected Grains, got: {:?}", other),
11227        }
11228    }
11229
11230    #[test]
11231    fn test_group_by_chronological_within_group() {
11232        let store = MockStore::with_grains(vec![
11233            make_fact_with_time("john", "likes", "vim", 500),
11234            make_fact_with_time("john", "likes", "coffee", 100),
11235            make_fact_with_time("john", "likes", "rust", 300),
11236        ]);
11237        let ex = CalExecutor::with_defaults();
11238
11239        let result = ex.execute("RECALL facts GROUP BY subject", &store).unwrap();
11240        match result.result {
11241            CalResultPayload::Grains { grains, .. } => {
11242                let times: Vec<u64> = grains
11243                    .iter()
11244                    .filter_map(|g| g.fields.get("created_at_sec").and_then(|v| v.as_u64()))
11245                    .collect();
11246                assert_eq!(
11247                    times,
11248                    vec![100, 300, 500],
11249                    "should be chronological within group"
11250                );
11251            }
11252            other => panic!("expected Grains, got: {:?}", other),
11253        }
11254    }
11255
11256    #[test]
11257    fn test_group_by_sml_format() {
11258        let store = MockStore::with_grains(vec![
11259            make_fact_with_time("sess_1", "user", "hello", 100),
11260            make_fact_with_time("sess_2", "user", "world", 200),
11261            make_fact_with_time("sess_1", "assistant", "hi", 150),
11262        ]);
11263        let ex = CalExecutor::with_defaults();
11264
11265        let result = ex
11266            .execute("RECALL facts GROUP BY subject FORMAT sml", &store)
11267            .unwrap();
11268        match result.result {
11269            CalResultPayload::Formatted { text, format, .. } => {
11270                assert_eq!(format, "sml");
11271                assert!(text.contains("<group key=\"sess_1\" count=\"2\">"));
11272                assert!(text.contains("<group key=\"sess_2\" count=\"1\">"));
11273                assert!(text.contains("</group>"));
11274            }
11275            other => panic!("expected Formatted, got: {:?}", other),
11276        }
11277    }
11278
11279    #[test]
11280    fn test_group_by_markdown_format() {
11281        let store = MockStore::with_grains(vec![
11282            make_fact_with_time("sess_1", "user", "hello", 100),
11283            make_fact_with_time("sess_2", "user", "world", 200),
11284        ]);
11285        let ex = CalExecutor::with_defaults();
11286
11287        let result = ex
11288            .execute("RECALL facts GROUP BY subject FORMAT markdown", &store)
11289            .unwrap();
11290        match result.result {
11291            CalResultPayload::Formatted { text, format, .. } => {
11292                assert_eq!(format, "markdown");
11293                assert!(text.contains("### sess_1 (1 memory)"));
11294                assert!(text.contains("### sess_2 (1 memory)"));
11295            }
11296            other => panic!("expected Formatted, got: {:?}", other),
11297        }
11298    }
11299
11300    #[test]
11301    fn test_group_by_text_format() {
11302        let store = MockStore::with_grains(vec![
11303            make_fact_with_time("sess_1", "user", "hello", 100),
11304            make_fact_with_time("sess_1", "assistant", "hi", 150),
11305            make_fact_with_time("sess_2", "user", "world", 200),
11306        ]);
11307        let ex = CalExecutor::with_defaults();
11308
11309        let result = ex
11310            .execute("RECALL facts GROUP BY subject FORMAT text", &store)
11311            .unwrap();
11312        match result.result {
11313            CalResultPayload::Formatted { text, format, .. } => {
11314                assert_eq!(format, "text");
11315                assert!(text.contains("--- Group 1/2: sess_1 (2 memories) ---"));
11316                assert!(text.contains("--- Group 2/2: sess_2 (1 memory) ---"));
11317                assert!(text.contains("[1]"));
11318                assert!(text.contains("[2]"));
11319            }
11320            other => panic!("expected Formatted, got: {:?}", other),
11321        }
11322    }
11323
11324    #[test]
11325    fn test_group_by_json_format() {
11326        let store = MockStore::with_grains(vec![
11327            make_fact_with_time("sess_1", "user", "hello", 100),
11328            make_fact_with_time("sess_2", "user", "world", 200),
11329        ]);
11330        let ex = CalExecutor::with_defaults();
11331
11332        let result = ex
11333            .execute("RECALL facts GROUP BY subject FORMAT json", &store)
11334            .unwrap();
11335        match result.result {
11336            CalResultPayload::Formatted { text, format, .. } => {
11337                assert_eq!(format, "json");
11338                assert!(text.contains("\"group_key\""));
11339                assert!(text.contains("\"sess_1\""));
11340                assert!(text.contains("\"sess_2\""));
11341                assert!(text.contains("\"count\""));
11342            }
11343            other => panic!("expected Formatted, got: {:?}", other),
11344        }
11345    }
11346
11347    #[test]
11348    fn test_group_by_missing_field_present_field() {
11349        // Grains without the grouped-by field should go to empty-key group.
11350        // Use `relation`, a common field that exists on every grain.
11351        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11352        let ex = CalExecutor::with_defaults();
11353
11354        let result = ex
11355            .execute("RECALL facts GROUP BY relation FORMAT text", &store)
11356            .unwrap();
11357        match result.result {
11358            CalResultPayload::Formatted { text, format, .. } => {
11359                assert_eq!(format, "text");
11360                assert!(text.contains("--- Group 1/1:"));
11361            }
11362            other => panic!("expected Formatted, got: {:?}", other),
11363        }
11364    }
11365
11366    #[test]
11367    fn test_group_by_unknown_field_rejected() {
11368        // Pipeline-stage field references are validated against the closed
11369        // common + type-specific field set. `session_id` is an Event-only
11370        // field, so GROUP BY session_id on RECALL facts must CAL-E060.
11371        let store = MockStore::with_grains(vec![make_fact("john", "likes", "coffee")]);
11372        let ex = CalExecutor::with_defaults();
11373
11374        let err = ex
11375            .execute("RECALL facts GROUP BY session_id FORMAT text", &store)
11376            .expect_err("GROUP BY unknown field must be rejected per Bug 9");
11377        assert_eq!(err.code(), "CAL-E060");
11378    }
11379
11380    // -----------------------------------------------------------------------
11381    // WITH VARS end-to-end tests
11382    // -----------------------------------------------------------------------
11383
11384    #[test]
11385    fn test_with_vars_template_substitution() {
11386        let (hash_a, grain_a) = make_fact("john", "likes", "coffee");
11387        let store = MockStore::with_grains(vec![(hash_a, grain_a)]);
11388        let ex = CalExecutor::with_defaults();
11389
11390        let result = ex
11391            .execute(
11392                r#"RECALL facts FORMAT TEMPLATE "User: {{$user_name}} | {{subject}} {{relation}} {{object}}" WITH VARS { "user_name": "John" }"#,
11393                &store,
11394            )
11395            .unwrap();
11396        match result.result {
11397            CalResultPayload::Formatted { text, format, .. } => {
11398                assert_eq!(format, "template");
11399                assert!(
11400                    text.contains("User: John |"),
11401                    "user var not substituted: {}",
11402                    text
11403                );
11404                assert!(text.contains("john"), "subject not substituted: {}", text);
11405                assert!(text.contains("coffee"), "object not substituted: {}", text);
11406            }
11407            other => panic!("expected Formatted, got: {:?}", other),
11408        }
11409    }
11410
11411    #[test]
11412    fn test_with_vars_missing_var_renders_empty() {
11413        let (hash_a, grain_a) = make_fact("john", "likes", "coffee");
11414        let store = MockStore::with_grains(vec![(hash_a, grain_a)]);
11415        let ex = CalExecutor::with_defaults();
11416
11417        let result = ex
11418            .execute(
11419                r#"RECALL facts FORMAT TEMPLATE "{{$missing}} | {{subject}}" WITH VARS { "other": "val" }"#,
11420                &store,
11421            )
11422            .unwrap();
11423        match result.result {
11424            CalResultPayload::Formatted { text, .. } => {
11425                // {{$missing}} should remain as-is (simple replacement only replaces known keys)
11426                assert!(
11427                    text.contains("john"),
11428                    "subject should be resolved: {}",
11429                    text
11430                );
11431            }
11432            other => panic!("expected Formatted, got: {:?}", other),
11433        }
11434    }
11435
11436    #[test]
11437    fn test_with_vars_parsed_into_query() {
11438        let store = MockStore::empty();
11439        let ex = CalExecutor::with_defaults();
11440
11441        let result = ex
11442            .execute(
11443                r#"RECALL facts WITH VARS { "app": "test", "version": "1.0" }"#,
11444                &store,
11445            )
11446            .unwrap();
11447        // Query should parse and execute without error even without FORMAT
11448        assert_eq!(result.metadata.statement_type, "recall");
11449    }
11450
11451    // --- Bug 86d29rjng: relation field rendering tests ---
11452
11453    /// Helper: create a CalGrainResult with content and relation fields (event-style grain).
11454    fn make_event_grain_result(content: &str, relation: &str, subject: &str) -> CalGrainResult {
11455        let mut fields = serde_json::Map::new();
11456        fields.insert("subject".into(), serde_json::json!(subject));
11457        fields.insert("relation".into(), serde_json::json!(relation));
11458        fields.insert("content".into(), serde_json::json!(content));
11459        CalGrainResult {
11460            hash: "aabbccdd00112233".into(),
11461            grain_type: "event".into(),
11462            score: 1.0,
11463            fields: serde_json::Value::Object(fields),
11464            score_breakdown: None,
11465            explanation: None,
11466            relative_time: None,
11467            is_deterministic: false,
11468            contested_by: None,
11469        }
11470    }
11471
11472    /// Helper: create a CalGrainResult without a relation field (event with no speaker).
11473    fn make_event_grain_result_no_relation(content: &str, subject: &str) -> CalGrainResult {
11474        let mut fields = serde_json::Map::new();
11475        fields.insert("subject".into(), serde_json::json!(subject));
11476        fields.insert("content".into(), serde_json::json!(content));
11477        CalGrainResult {
11478            hash: "aabbccdd00112233".into(),
11479            grain_type: "event".into(),
11480            score: 1.0,
11481            fields: serde_json::Value::Object(fields),
11482            score_breakdown: None,
11483            explanation: None,
11484            relative_time: None,
11485            is_deterministic: false,
11486            contested_by: None,
11487        }
11488    }
11489
11490    /// Shared-renderer view of a test CalGrainResult.
11491    fn view_of(grain: &CalGrainResult) -> crate::render::GrainView<'_> {
11492        grain_view(grain)
11493    }
11494
11495    #[test]
11496    fn test_render_sml_event_carries_speaker_role() {
11497        let grain = make_event_grain_result("I am a hair stylist.", "user", "s000");
11498        let out = crate::render::render_grain_sml(&view_of(&grain), crate::render::MetadataDetail::Minimal);
11499        assert!(
11500            out.contains(r#"role="user""#),
11501            "SML should carry the speaker as a role attribute, got: {out}"
11502        );
11503        assert!(
11504            out.starts_with("<event"),
11505            "events render as semantic <event> elements, got: {out}"
11506        );
11507        assert!(out.contains("I am a hair stylist."));
11508    }
11509
11510    #[test]
11511    fn test_render_sml_omits_role_attribute_when_absent() {
11512        let grain = make_event_grain_result_no_relation("Hello world", "s000");
11513        let out = crate::render::render_grain_sml(&view_of(&grain), crate::render::MetadataDetail::Minimal);
11514        assert!(
11515            !out.contains("role="),
11516            "SML should not have a role attribute when the field is absent, got: {out}"
11517        );
11518    }
11519
11520    #[test]
11521    fn test_render_sml_omits_role_attribute_when_empty() {
11522        let grain = make_event_grain_result("Hello world", "", "s000");
11523        let out = crate::render::render_grain_sml(&view_of(&grain), crate::render::MetadataDetail::Minimal);
11524        assert!(
11525            !out.contains("role=\"\""),
11526            "SML should not have an empty role attribute, got: {out}"
11527        );
11528    }
11529
11530    #[test]
11531    fn test_render_sml_escapes_role_attribute() {
11532        let grain = make_event_grain_result("test", "user<script>", "s000");
11533        let out = crate::render::render_grain_sml(&view_of(&grain), crate::render::MetadataDetail::Minimal);
11534        assert!(
11535            out.contains("role=\"user&lt;script&gt;\""),
11536            "SML should escape the role attribute value, got: {out}"
11537        );
11538    }
11539
11540    #[test]
11541    fn test_render_markdown_prefixes_content_with_relation() {
11542        let grain = make_event_grain_result("I am a hair stylist.", "user", "s000");
11543        let out = crate::render::render_grain_markdown(&view_of(&grain));
11544        assert!(
11545            out.contains("- **user**: I am a hair stylist."),
11546            "Markdown should prefix content with relation, got: {out}"
11547        );
11548    }
11549
11550    #[test]
11551    fn test_render_markdown_no_relation_names_the_subject() {
11552        let grain = make_event_grain_result_no_relation("Hello world", "s000");
11553        let out = crate::render::render_grain_markdown(&view_of(&grain));
11554        // With no speaker to name, lead with what the text is about. The old
11555        // output led with the literal field name (`**content**:`), which tells
11556        // a reader nothing they cannot see.
11557        assert!(
11558            out.contains("- **s000**: Hello world"),
11559            "Markdown should name the subject when there is no relation, got: {out}"
11560        );
11561    }
11562
11563    #[test]
11564    fn test_render_markdown_speaker_disambiguation() {
11565        // Two grains from same session, different speakers — must be distinguishable.
11566        let assistant = make_event_grain_result("I am a school teacher.", "assistant", "s000");
11567        let user = make_event_grain_result("I am a hair stylist.", "user", "s000");
11568        let mut out = String::new();
11569        out.push_str(&crate::render::render_grain_markdown(&view_of(&assistant)));
11570        out.push_str(&crate::render::render_grain_markdown(&view_of(&user)));
11571        assert!(
11572            out.contains("- **assistant**: I am a school teacher."),
11573            "Markdown should show assistant role, got: {out}"
11574        );
11575        assert!(
11576            out.contains("- **user**: I am a hair stylist."),
11577            "Markdown should show user role, got: {out}"
11578        );
11579    }
11580
11581    #[test]
11582    fn test_render_text_event_with_subject_relation_uses_triple_path() {
11583        // Event grains with subject + relation take the triple path (subject relation object).
11584        // The relation (speaker role) IS visible via the triple rendering.
11585        let grain = make_event_grain_result("I am a hair stylist.", "user", "s000");
11586        let out = crate::render::render_grain_text_line(&view_of(&grain), None);
11587        assert!(
11588            out.contains("s000 user"),
11589            "Text should render subject + relation via triple path, got: {out}"
11590        );
11591    }
11592
11593    #[test]
11594    fn test_render_text_content_only_no_relation() {
11595        // Grain with only content (no triple fields) renders content directly.
11596        let grain = make_event_grain_result_no_relation("Hello world", "");
11597        let out = crate::render::render_grain_text_line(&view_of(&grain), None);
11598        assert_eq!(
11599            out.trim(),
11600            "Hello world",
11601            "Text should render content without prefix when no triple fields"
11602        );
11603    }
11604
11605    #[test]
11606    fn test_render_text_speaker_disambiguation_via_triple() {
11607        // Two grains from same session, different speakers — distinguishable via triple path.
11608        let assistant = make_event_grain_result("I am a school teacher.", "assistant", "s000");
11609        let user = make_event_grain_result("I am a hair stylist.", "user", "s000");
11610        let mut out = String::new();
11611        out.push_str(&crate::render::render_grain_text_line(&view_of(&assistant), Some(1)));
11612        out.push_str(&crate::render::render_grain_text_line(&view_of(&user), Some(2)));
11613        assert!(
11614            out.contains("[1] s000 assistant"),
11615            "Text should show assistant relation in triple, got: {out}"
11616        );
11617        assert!(
11618            out.contains("[2] s000 user"),
11619            "Text should show user relation in triple, got: {out}"
11620        );
11621    }
11622
11623    #[test]
11624    fn test_render_sml_fact_states_the_triple_inline() {
11625        // Semantic SML says the assertion as a sentence, relation included.
11626        let (_, grain) = make_fact("john", "likes", "coffee");
11627        let cgr = CalGrainResult {
11628            hash: grain.hash.to_hex(),
11629            grain_type: "fact".into(),
11630            score: 1.0,
11631            fields: serde_json::to_value(&grain.fields).unwrap(),
11632            score_breakdown: None,
11633            explanation: None,
11634            relative_time: None,
11635            is_deterministic: false,
11636            contested_by: None,
11637        };
11638        let out = crate::render::render_grain_sml(&view_of(&cgr), crate::render::MetadataDetail::Minimal);
11639        assert!(
11640            out.starts_with("<fact") && out.contains("john likes coffee"),
11641            "SML fact should state the triple inline, got: {out}"
11642        );
11643    }
11644
11645    // -----------------------------------------------------------------------
11646    // Scope enforcement tests
11647    // -----------------------------------------------------------------------
11648
11649    #[test]
11650    fn test_scope_read_allows_recall() {
11651        let store = MockStore::empty();
11652        let config = CalExecutorConfig {
11653            caller_scopes: vec!["read".to_string()],
11654            ..Default::default()
11655        };
11656        let ex = CalExecutor::new(config);
11657        let result = ex.execute("RECALL facts LIMIT 1", &store);
11658        assert!(result.is_ok(), "read scope should allow RECALL");
11659    }
11660
11661    #[test]
11662    fn test_scope_read_blocks_add() {
11663        let store = MockStore::empty();
11664        let config = CalExecutorConfig {
11665            caller_scopes: vec!["read".to_string()],
11666            ..Default::default()
11667        };
11668        let ex = CalExecutor::new(config);
11669        let result = ex.execute(
11670            r#"ADD fact SET subject = "x" SET relation = "y" SET object = "z" REASON "test""#,
11671            &store,
11672        );
11673        assert!(result.is_err(), "read scope should block ADD");
11674        let err = result.unwrap_err();
11675        assert_eq!(err.code(), "CAL-E114");
11676    }
11677
11678    #[test]
11679    fn test_scope_write_allows_add() {
11680        let store = MockStore::empty();
11681        let config = CalExecutorConfig {
11682            caller_scopes: vec!["read".to_string(), "write".to_string()],
11683            ..Default::default()
11684        };
11685        let ex = CalExecutor::new(config);
11686        let result = ex.execute(
11687            r#"ADD fact SET subject = "x" SET relation = "y" SET object = "z" REASON "test""#,
11688            &store,
11689        );
11690        if let Err(ref e) = result {
11691            assert_ne!(e.code(), "CAL-E114", "write scope should not block ADD");
11692        }
11693    }
11694
11695    #[test]
11696    fn test_forget_hash_parses_user_scope_rejected() {
11697        // `FORGET <hash>` is a valid CAL statement (gated at execution by
11698        // allow_destructive_ops). The USER/SCOPE targets are NOT reachable
11699        // from CAL text — only the hash form parses — because user/scope
11700        // crypto-erasure has no store backing.
11701        let store = MockStore::empty();
11702        // Empty scopes → no scope enforcement; isolate the grammar/gate layers.
11703        let ex = CalExecutor::new(CalExecutorConfig {
11704            allow_destructive_ops: true,
11705            ..Default::default()
11706        });
11707
11708        // Hash form parses (no parse error).
11709        let hash = format!("sha256:{}", "a".repeat(64));
11710        ex.execute(&format!("FORGET {hash}"), &store)
11711            .expect("FORGET <hash> is a valid CAL statement");
11712
11713        // USER form is rejected at parse time with CAL-E002.
11714        let err = ex
11715            .execute("FORGET USER \"test\"", &store)
11716            .expect_err("FORGET USER is not reachable from CAL text");
11717        assert_eq!(err.code(), "CAL-E002");
11718    }
11719
11720    #[test]
11721    fn test_forget_requires_admin_scope_when_scoped() {
11722        // Independent of allow_destructive_ops: when caller_scopes are enforced
11723        // (server path), FORGET requires the "admin" scope. read+write is not
11724        // enough — a capability token can permit writes yet forbid erasure.
11725        let store = MockStore::empty();
11726        let hash = format!("sha256:{}", "a".repeat(64));
11727        let ex = CalExecutor::new(CalExecutorConfig {
11728            caller_scopes: vec!["read".to_string(), "write".to_string()],
11729            allow_destructive_ops: true,
11730            ..Default::default()
11731        });
11732        let err = ex
11733            .execute(&format!("FORGET {hash}"), &store)
11734            .expect_err("FORGET needs admin scope");
11735        assert!(
11736            matches!(err, CalError::InsufficientScope { .. }),
11737            "expected InsufficientScope, got {err:?}"
11738        );
11739    }
11740
11741    #[test]
11742    fn test_forget_hash_gated_by_allow_destructive_ops() {
11743        // With destructive ops disabled, `FORGET <hash>` still parses but the
11744        // executor returns Unsupported instead of touching the store.
11745        let store = MockStore::empty();
11746        let hash = format!("sha256:{}", "a".repeat(64));
11747        let ex = CalExecutor::new(CalExecutorConfig {
11748            allow_destructive_ops: false,
11749            ..Default::default()
11750        });
11751        let res = ex
11752            .execute(&format!("FORGET {hash}"), &store)
11753            .expect("FORGET <hash> parses even when disabled");
11754        match res.result {
11755            CalResultPayload::Unsupported { statement, message } => {
11756                assert_eq!(statement, "forget");
11757                assert!(message.contains("disabled"), "unexpected message: {message}");
11758            }
11759            other => panic!("expected Unsupported when disabled, got {other:?}"),
11760        }
11761    }
11762
11763    #[test]
11764    fn test_admin_forget_still_rejected_at_parse_time() {
11765        // `FORGET USER "…"` is not reachable from CAL text regardless of scope
11766        // — only `FORGET <hash>` parses. User-scoped erasure has no store
11767        // backing and is not exposed through the query language.
11768        let store = MockStore::empty();
11769        let config = CalExecutorConfig {
11770            caller_scopes: vec!["read".to_string(), "write".to_string(), "admin".to_string()],
11771            allow_destructive_ops: true,
11772            ..Default::default()
11773        };
11774        let ex = CalExecutor::new(config);
11775        let err = ex
11776            .execute("FORGET USER \"nonexistent\"", &store)
11777            .expect_err("FORGET rejected unconditionally");
11778        assert_eq!(err.code(), "CAL-E002");
11779    }
11780
11781    #[test]
11782    fn test_scope_empty_no_enforcement() {
11783        let store = MockStore::empty();
11784        let config = CalExecutorConfig {
11785            caller_scopes: vec![], // empty = CLI/test mode
11786            ..Default::default()
11787        };
11788        let ex = CalExecutor::new(config);
11789        let result = ex.execute(
11790            r#"ADD fact SET subject = "x" SET relation = "y" SET object = "z" REASON "test""#,
11791            &store,
11792        );
11793        if let Err(ref e) = result {
11794            assert_ne!(e.code(), "CAL-E114", "empty scopes should skip enforcement");
11795        }
11796    }
11797
11798    #[test]
11799    fn test_scope_admin_bypasses_all() {
11800        let store = MockStore::empty();
11801        let config = CalExecutorConfig {
11802            caller_scopes: vec!["admin".to_string()],
11803            ..Default::default()
11804        };
11805        let ex = CalExecutor::new(config);
11806        // Admin with just "admin" scope (no explicit "write") should still allow ADD
11807        let result = ex.execute(
11808            r#"ADD fact SET subject = "x" SET relation = "y" SET object = "z" REASON "test""#,
11809            &store,
11810        );
11811        if let Err(ref e) = result {
11812            assert_ne!(e.code(), "CAL-E114", "admin scope should bypass all checks");
11813        }
11814    }
11815
11816    // -- Deterministic grains bypass post-merge min_score (ClickUp 86d2x9j7k) -----
11817    //
11818    // RECALL sources without an ABOUT clause produce structurally-scored
11819    // (or sentinel-scored) grains. A `WITH min_score(...)` on a multi-source
11820    // ASSEMBLE was silently dropping these, even though the user selected
11821    // them via PRIORITY/BUDGET, not by relevance.
11822    #[test]
11823    fn test_assemble_post_merge_min_score_keeps_deterministic_grains() {
11824        let ex = exec();
11825        let store = MockStore::empty();
11826
11827        let semantic_low = CalGrainResult {
11828            hash: "11".repeat(32),
11829            grain_type: "fact".into(),
11830            score: 0.10,
11831            fields: serde_json::json!({"subject": "a", "relation": "r", "object": "o"}),
11832            score_breakdown: None,
11833            explanation: None,
11834            relative_time: None,
11835            is_deterministic: false,
11836            contested_by: None,
11837        };
11838        let semantic_high = CalGrainResult {
11839            hash: "22".repeat(32),
11840            grain_type: "fact".into(),
11841            score: 0.90,
11842            fields: serde_json::json!({"subject": "b", "relation": "r", "object": "o"}),
11843            score_breakdown: None,
11844            explanation: None,
11845            relative_time: None,
11846            is_deterministic: false,
11847            contested_by: None,
11848        };
11849        let deterministic = CalGrainResult {
11850            hash: "33".repeat(32),
11851            grain_type: "workflow".into(),
11852            score: 0.0, // sentinel — no semantic comparison was performed
11853            fields: serde_json::json!({"name": "boot", "status": "active"}),
11854            score_breakdown: None,
11855            explanation: None,
11856            relative_time: None,
11857            is_deterministic: true,
11858            contested_by: None,
11859        };
11860
11861        let payload = CalResultPayload::Assembled {
11862            grains: vec![
11863                semantic_low.clone(),
11864                semantic_high.clone(),
11865                deterministic.clone(),
11866            ],
11867            sources: vec![],
11868            total_tokens: 0,
11869            budget_limit: Some(4000),
11870            progressive: false,
11871            total_available: Some(3),
11872        };
11873
11874        let mut warnings = Vec::new();
11875        let out = ex
11876            .apply_assemble_post_merge_options(
11877                payload,
11878                &[WithOption::MinScore { score: 0.5 }],
11879                &mut warnings,
11880                &store,
11881                "test topic",
11882            )
11883            .unwrap();
11884
11885        match out {
11886            CalResultPayload::Assembled { grains, .. } => {
11887                let kept_hashes: Vec<&str> = grains.iter().map(|g| g.hash.as_str()).collect();
11888                assert!(
11889                    kept_hashes.contains(&semantic_high.hash.as_str()),
11890                    "high-scoring semantic grain must be retained, got: {:?}",
11891                    kept_hashes
11892                );
11893                assert!(
11894                    !kept_hashes.contains(&semantic_low.hash.as_str()),
11895                    "low-scoring semantic grain must be dropped, got: {:?}",
11896                    kept_hashes
11897                );
11898                assert!(
11899                    kept_hashes.contains(&deterministic.hash.as_str()),
11900                    "deterministic-source grain must NOT be dropped by min_score, got: {:?}",
11901                    kept_hashes
11902                );
11903            }
11904            other => panic!("expected Assembled payload, got: {:?}", other),
11905        }
11906    }
11907
11908    // -- Recall with no ABOUT marks results as deterministic --------------------
11909    #[test]
11910    fn test_execute_recall_marks_no_about_as_deterministic() {
11911        let store = MockStore::with_grains(vec![
11912            make_fact("alice", "knows", "bob"),
11913            make_fact("alice", "knows", "carol"),
11914        ]);
11915        let ex = exec();
11916
11917        let recall_stmt = RecallStmt {
11918            grain_type: GrainTypePlural::Facts,
11919            about: None, // deterministic — no semantic query
11920            where_clause: Some(super::super::ast::WhereClause {
11921                condition: super::super::ast::Condition::Comparison {
11922                    field: "subject".into(),
11923                    comparator: super::super::ast::Comparator::Eq,
11924                    value: super::super::ast::Value::String {
11925                        value: "alice".into(),
11926                    },
11927                    span: None,
11928                },
11929                span: None,
11930            }),
11931            recent: None,
11932            since: None,
11933            until: None,
11934            like: None,
11935            between: None,
11936            contradictions: None,
11937            limit: None,
11938            as_format: None,
11939            span: None,
11940        };
11941
11942        let query = CalQuery {
11943            version: super::super::ast::CalVersion(1),
11944            statement: CalStatement::Recall(recall_stmt.clone()),
11945            pipeline: Vec::new(),
11946            with_options: Vec::new(),
11947            format: None,
11948            let_bindings: Vec::new(),
11949            let_values: Default::default(),
11950            user_vars: HashMap::new(),
11951            warnings: Vec::new(),
11952        };
11953
11954        let mut warnings = Vec::new();
11955        let payload = ex
11956            .execute_recall(&recall_stmt, &store, &query, &mut warnings)
11957            .unwrap();
11958
11959        match payload {
11960            CalResultPayload::Grains { grains, .. } => {
11961                assert!(!grains.is_empty(), "expected at least one grain");
11962                assert!(
11963                    grains.iter().all(|g| g.is_deterministic),
11964                    "every grain from a no-ABOUT RECALL must be flagged deterministic"
11965                );
11966            }
11967            other => panic!("expected Grains payload, got: {:?}", other),
11968        }
11969    }
11970
11971    // -- Recall WITH ABOUT does NOT mark results as deterministic ---------------
11972    #[test]
11973    fn test_execute_recall_with_about_not_deterministic() {
11974        let store = MockStore::with_grains(vec![make_fact("alice", "likes", "coffee")]);
11975        let ex = exec();
11976
11977        let recall_stmt = RecallStmt {
11978            grain_type: GrainTypePlural::Facts,
11979            about: Some(super::super::ast::AboutClause {
11980                text: "coffee preferences".into(),
11981                span: None,
11982            }),
11983            where_clause: None,
11984            recent: None,
11985            since: None,
11986            until: None,
11987            like: None,
11988            between: None,
11989            contradictions: None,
11990            limit: None,
11991            as_format: None,
11992            span: None,
11993        };
11994
11995        let query = CalQuery {
11996            version: super::super::ast::CalVersion(1),
11997            statement: CalStatement::Recall(recall_stmt.clone()),
11998            pipeline: Vec::new(),
11999            with_options: Vec::new(),
12000            format: None,
12001            let_bindings: Vec::new(),
12002            let_values: Default::default(),
12003            user_vars: HashMap::new(),
12004            warnings: Vec::new(),
12005        };
12006
12007        let mut warnings = Vec::new();
12008        let payload = ex
12009            .execute_recall(&recall_stmt, &store, &query, &mut warnings)
12010            .unwrap();
12011
12012        match payload {
12013            CalResultPayload::Grains { grains, .. } => {
12014                assert!(
12015                    grains.iter().all(|g| !g.is_deterministic),
12016                    "grains from an ABOUT RECALL must NOT be flagged deterministic"
12017                );
12018            }
12019            other => panic!("expected Grains payload, got: {:?}", other),
12020        }
12021    }
12022}