Skip to main content

areev_cal/
errors.rs

1//! CAL error model — ~30 error codes for Phase 1 (Core conformance).
2//!
3//! Error codes follow the CAL specification section 22:
4//! - CAL-E001..CAL-E019: Parse errors
5//! - CAL-E020..CAL-E022: Type errors
6//! - CAL-E030..CAL-E031: Execution errors
7//! - CAL-E060: Shortcut / field resolution errors
8//! - CAL-E100: Version errors
9//! - CAL-W001..CAL-W004: Warnings
10
11use thiserror::Error;
12
13// ---------------------------------------------------------------------------
14// Span — source location for diagnostics
15// ---------------------------------------------------------------------------
16
17/// A byte-offset span within a CAL query string.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct Span {
20    /// Byte offset of the first character (inclusive).
21    pub start: usize,
22    /// Byte offset past the last character (exclusive).
23    pub end: usize,
24    /// 1-based line number.
25    pub line: usize,
26    /// 1-based column number (byte offset from line start).
27    pub col: usize,
28}
29
30impl Span {
31    /// Create a new span.
32    pub fn new(start: usize, end: usize, line: usize, col: usize) -> Self {
33        Self {
34            start,
35            end,
36            line,
37            col,
38        }
39    }
40
41    /// A zero-width span at the start of input (used when no better location
42    /// is available).
43    pub fn zero() -> Self {
44        Self {
45            start: 0,
46            end: 0,
47            line: 1,
48            col: 1,
49        }
50    }
51}
52
53impl std::fmt::Display for Span {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(f, "{}:{}", self.line, self.col)
56    }
57}
58
59// ---------------------------------------------------------------------------
60// CalError — the 27 Phase-1 error codes
61// ---------------------------------------------------------------------------
62
63/// All CAL query errors.
64///
65/// Each variant carries its CAL spec error code in the `#[error]` message so
66/// that `Display` output always starts with `CAL-Exxx:`.
67#[derive(Debug, Error)]
68pub enum CalError {
69    // ── Parse errors (CAL-E001 – CAL-E019) ──────────────────────────────
70    /// CAL-E001 — Query exceeds the maximum allowed byte length.
71    #[error("CAL-E001: Query exceeds maximum length ({length} bytes, max {max})")]
72    QueryTooLong {
73        length: usize,
74        max: usize,
75        span: Option<Span>,
76    },
77
78    /// CAL-E002 — The parser encountered a token it did not expect.
79    #[error("CAL-E002: Unexpected token: expected {expected}, found {found}")]
80    UnexpectedToken {
81        expected: String,
82        found: String,
83        span: Option<Span>,
84        suggestion: Option<String>,
85    },
86
87    /// CAL-E003 — A grain type name was used that does not match any of
88    /// the 11 OMS types (singular or plural form).
89    #[error("CAL-E003: Unknown grain type \"{found}\"")]
90    UnknownGrainType {
91        found: String,
92        span: Option<Span>,
93        suggestion: Option<String>,
94    },
95
96    /// CAL-E004 — A field name was used that is not a recognised common or
97    /// type-specific field.
98    #[error("CAL-E004: Unknown field \"{found}\"")]
99    UnknownField {
100        found: String,
101        span: Option<Span>,
102        suggestion: Option<String>,
103    },
104
105    /// CAL-E005 — A string literal was opened but never closed.
106    #[error("CAL-E005: Unterminated string literal")]
107    UnterminatedString { span: Option<Span> },
108
109    /// CAL-E006 — A numeric literal could not be parsed.
110    #[error("CAL-E006: Invalid number \"{found}\"")]
111    InvalidNumber { found: String, span: Option<Span> },
112
113    /// CAL-E007 — Parenthesised or sub-query nesting exceeds the allowed
114    /// depth.
115    #[error("CAL-E007: Nesting too deep ({depth} levels, max {max})")]
116    NestingTooDeep {
117        depth: usize,
118        max: usize,
119        span: Option<Span>,
120    },
121
122    /// CAL-E008 — A `$parameter` was referenced but never bound.
123    #[error("CAL-E008: Unbound parameter \"${name}\"")]
124    UnboundParameter { name: String, span: Option<Span> },
125
126    /// CAL-E009 — The same parameter name was bound more than once.
127    #[error("CAL-E009: Duplicate parameter \"${name}\"")]
128    DuplicateParameter { name: String, span: Option<Span> },
129
130    /// CAL-E010 — A `LIMIT` value exceeds the server-configured maximum.
131    #[error("CAL-E010: Limit {value} exceeds maximum allowed ({max})")]
132    LimitExceeded {
133        value: u64,
134        max: u64,
135        span: Option<Span>,
136    },
137
138    /// CAL-E011 — An `IN (...)` set contains more elements than permitted.
139    #[error("CAL-E011: IN set too large ({count} elements, max {max})")]
140    InSetTooLarge {
141        count: usize,
142        max: usize,
143        span: Option<Span>,
144    },
145
146    /// CAL-E012 — Too many pipeline stages (`|`) in a single query.
147    #[error("CAL-E012: Too many pipeline stages ({count}, max {max})")]
148    TooManyPipelineStages {
149        count: usize,
150        max: usize,
151        span: Option<Span>,
152    },
153
154    /// CAL-E013 — A set operation (UNION / INTERSECT / EXCEPT) has more
155    /// operands than allowed.
156    #[error("CAL-E013: Too many set operands ({count}, max {max})")]
157    TooManySetOperands {
158        count: usize,
159        max: usize,
160        span: Option<Span>,
161    },
162
163    /// CAL-E014 — The query string is empty or contains only whitespace.
164    #[error("CAL-E014: Empty query")]
165    EmptyQuery { span: Option<Span> },
166
167    /// CAL-E015 — A hash literal is not valid hex or has the wrong length.
168    #[error("CAL-E015: Invalid hash \"{found}\"")]
169    InvalidHash { found: String, span: Option<Span> },
170
171    /// CAL-E016 — A reason string (e.g. `BECAUSE "..."`) exceeds the
172    /// maximum length.  Tier 1 statement, but the parser validates it.
173    #[error("CAL-E016: Reason too long ({length} chars, max {max})")]
174    ReasonTooLong {
175        length: usize,
176        max: usize,
177        span: Option<Span>,
178    },
179
180    /// CAL-E017 — An `EVOLVE ... SET` clause references a field that does
181    /// not exist on the target grain type.  Tier 1 statement.
182    #[error("CAL-E017: Unknown EVOLVE field \"{found}\"")]
183    UnknownEvolveField {
184        found: String,
185        span: Option<Span>,
186        suggestion: Option<String>,
187    },
188
189    /// CAL-E018 — A write statement that requires `BECAUSE` was issued
190    /// without one.  Tier 1 statement.
191    #[error("CAL-E018: Missing BECAUSE reason clause")]
192    MissingReason { span: Option<Span> },
193
194    /// CAL-E019 — A `SUPERSEDE` or `EVOLVE` is missing its `SET` clause.
195    /// Tier 1 statement.
196    #[error("CAL-E019: Missing SET clause")]
197    MissingSetClause { span: Option<Span> },
198
199    // ── Type errors (CAL-E020 – CAL-E022) ───────────────────────────────
200    /// CAL-E020 — A comparison or operation was attempted between
201    /// incompatible types (e.g. string vs number).
202    #[error("CAL-E020: Incompatible types: {left} vs {right}")]
203    IncompatibleTypes {
204        left: String,
205        right: String,
206        span: Option<Span>,
207        suggestion: Option<String>,
208    },
209
210    /// CAL-E021 — A pipeline stage received input of a type it cannot
211    /// process.
212    #[error(
213        "CAL-E021: Pipeline type mismatch: stage \"{stage}\" expected {expected}, got {found}"
214    )]
215    PipelineTypeMismatch {
216        stage: String,
217        expected: String,
218        found: String,
219        span: Option<Span>,
220    },
221
222    /// CAL-E022 — An extractor (SUBJECTS / OBJECTS / HASHES) was used on
223    /// a non-Fact grain type.
224    #[error("CAL-E022: Extractor \"{extractor}\" requires facts, got {found}")]
225    ExtractorRequiresFacts {
226        extractor: String,
227        found: String,
228        span: Option<Span>,
229    },
230
231    // ── Execution errors (CAL-E030 – CAL-E031) ─────────────────────────
232    /// CAL-E030 — The query exceeded its resource budget (e.g. result-set
233    /// size or intermediate working-set cap).
234    #[error("CAL-E030: Budget exceeded: {detail}")]
235    BudgetExceeded { detail: String, span: Option<Span> },
236
237    /// CAL-E031 — The query exceeded the per-query timeout.
238    #[error("CAL-E031: Query timeout after {elapsed_ms}ms (limit {limit_ms}ms)")]
239    QueryTimeout {
240        elapsed_ms: u64,
241        limit_ms: u64,
242        span: Option<Span>,
243    },
244
245    /// CAL-E092 — The store rejected the query as invalid input during
246    /// execution (a validation failure, e.g. a malformed or under-specified
247    /// filter). Distinct from `BudgetExceeded` (CAL-E030, a resource overrun):
248    /// nothing was over budget, the request itself was not valid. Carries the
249    /// store's `VAL-Ennn` detail so the underlying reason stays visible.
250    #[error("CAL-E092: Invalid query: {detail}")]
251    InvalidQuery { detail: String, span: Option<Span> },
252
253    /// CAL-E090 — A cryptographic operation failed while executing a CAL
254    /// statement (typically AES-GCM decrypt of an encrypted grain blob).
255    /// This is **not** a budget overrun — it indicates a key-material
256    /// mismatch, envelope corruption, or missing key manager. Common
257    /// operator causes: master key changed between write and read
258    /// (Vault key rotation, different unseal), missing `blob_owner`
259    /// mapping, per-user DEK destroyed via crypto-erasure.
260    #[error("CAL-E090: Crypto error during query execution: {detail}")]
261    CryptoError { detail: String, span: Option<Span> },
262
263    /// CAL-E091 — A grain referenced by content address (sha256 hash) was
264    /// not found in the store. Distinct from `InvalidHash` (CAL-E015,
265    /// malformed literal) and `BudgetExceeded` (CAL-E030, resource overrun).
266    #[error("CAL-E091: Grain not found for hash \"{hash}\"")]
267    HashNotFound { hash: String, span: Option<Span> },
268
269    // ── Shortcut / field resolution errors (CAL-E060) ──────────────────
270    /// CAL-E060 — A shorthand field name (e.g. `subject`) is ambiguous
271    /// because the query targets a grain type that does not have that
272    /// field, or the field only exists on a different type.
273    #[error("CAL-E060: Field \"{field}\" is not available on grain type \"{grain_type}\"")]
274    FieldNotOnGrainType {
275        field: String,
276        grain_type: String,
277        span: Option<Span>,
278        suggestion: Option<String>,
279    },
280
281    /// CAL-E061 — An engine-level filter field (`query`, `time`, `entity`,
282    /// `contradicted`, `scope`, `tags`, …) was used where it cannot be
283    /// honoured: under `NOT`/`OR`, or with a comparator its push-down does
284    /// not support. These fields narrow the scan and have no per-grain
285    /// value, so the executor refuses rather than silently widening (#91).
286    #[error("CAL-E061: Engine-level field \"{field}\" cannot be used {context}; it narrows the scan and has no per-grain value to filter on")]
287    EngineFieldNotFilterable {
288        field: String,
289        /// Where it appeared, e.g. `"under NOT/OR"` or `"with comparator !="`.
290        context: String,
291        span: Option<Span>,
292    },
293
294    // ── Phase 2: ASSEMBLE errors (CAL-E032 – CAL-E035) ─────────────────
295    /// CAL-E032 — ASSEMBLE FROM has more than 8 named sources.
296    #[error("CAL-E032: Too many ASSEMBLE sources ({count}, max {max})")]
297    AssembleTooManySources {
298        count: usize,
299        max: usize,
300        span: Option<Span>,
301    },
302
303    /// CAL-E033 — ASSEMBLE BUDGET exceeds the maximum allowed value.
304    #[error("CAL-E033: ASSEMBLE budget exceeded ({value} {unit}, max {max})")]
305    AssembleBudgetExceeded {
306        value: u64,
307        max: u64,
308        unit: String,
309        span: Option<Span>,
310    },
311
312    /// CAL-E034 — Two ASSEMBLE sources share the same label.
313    #[error("CAL-E034: Duplicate ASSEMBLE source label \"{label}\"")]
314    AssembleDuplicateLabel { label: String, span: Option<Span> },
315
316    /// CAL-E122 — The `PIN`ned sources alone do not fit the `BUDGET`.
317    ///
318    /// A pin is a promise of full, verbatim disclosure, so there is no
319    /// degraded answer to fall back to: summarising the section would break
320    /// the guarantee the pin exists to make, and dropping it silently is
321    /// worse. Failing loudly is the only honest outcome — the budget or the
322    /// pinned text has to change.
323    #[error(
324        "CAL-E122: pinned ASSEMBLE source(s) [{}] need {required} tokens but BUDGET is {budget} — a PIN is never summarised or dropped, so raise the budget or shorten the pinned text",
325        labels.join(", ")
326    )]
327    AssemblePinnedBudgetExceeded {
328        labels: Vec<String>,
329        required: u32,
330        budget: u32,
331        span: Option<Span>,
332    },
333
334    /// CAL-E035 — PRIORITY references a label not in the FROM clause.
335    #[error("CAL-E035: PRIORITY references unknown source label \"{label}\"")]
336    AssemblePriorityMismatch { label: String, span: Option<Span> },
337
338    // ── Phase 2: LET binding errors (CAL-E036 – CAL-E038) ──────────────
339    /// CAL-E036 — More than 5 LET bindings in a single query.
340    #[error("CAL-E036: Too many LET bindings ({count}, max {max})")]
341    TooManyLetBindings {
342        count: usize,
343        max: usize,
344        span: Option<Span>,
345    },
346
347    /// CAL-E037 — A LET binding references itself or creates a cycle.
348    #[error("CAL-E037: Circular reference in LET binding \"${name}\"")]
349    LetCircularReference { name: String, span: Option<Span> },
350
351    /// CAL-E038 — LET chain depth exceeds the maximum (3).
352    #[error("CAL-E038: LET chain depth exceeded ({depth}, max {max})")]
353    LetDepthExceeded {
354        depth: usize,
355        max: usize,
356        span: Option<Span>,
357    },
358
359    // ── Phase 2: COALESCE errors (CAL-E039) ─────────────────────────────
360    /// CAL-E039 — COALESCE has more than 5 branches.
361    #[error("CAL-E039: Too many COALESCE branches ({count}, max {max})")]
362    CoalesceTooManyBranches {
363        count: usize,
364        max: usize,
365        span: Option<Span>,
366    },
367
368    // ── Phase 2: Timeout error (CAL-E071) ───────────────────────────────
369    /// CAL-E071 — ASSEMBLE execution exceeded the timeout.
370    #[error("CAL-E071: ASSEMBLE timeout after {elapsed_ms}ms (limit {limit_ms}ms)")]
371    AssembleTimeout {
372        elapsed_ms: u64,
373        limit_ms: u64,
374        span: Option<Span>,
375    },
376
377    // ── Template limits and inheritance, OMS CAL §10.7–§10.8
378    //    (CAL-E117 – CAL-E119) ─────────────────────────────────────────
379    /// CAL-E117 — Template conditional nesting exceeds the §10.8 limit.
380    #[error("CAL-E117: Template nesting too deep (max {max} levels)")]
381    TemplateNestingTooDeep { max: usize, span: Option<Span> },
382
383    /// CAL-E118 — Namespace is at the §10.8 template limit.
384    #[error("CAL-E118: Too many templates ({count}, max {max})")]
385    TooManyTemplates {
386        count: usize,
387        max: usize,
388        span: Option<Span>,
389    },
390
391    /// CAL-E119 — The `data` preset outputs structural JSON, not
392    /// template-driven text, so §10.7 forbids extending it.
393    #[error("CAL-E119: Template \"{name}\" cannot extend the 'data' preset")]
394    CannotExtendData { name: String, span: Option<Span> },
395
396    // ── JSON wire format error (CAL-E120) ─────────────────────────────
397    /// CAL-E120 — JSON wire format (`application/json+cal`) parse failure.
398    #[error("CAL-E120: Invalid JSON+CAL: {detail}")]
399    InvalidJsonCal { detail: String, span: Option<Span> },
400
401    // ── Authorization (CAL-E121) ───────────────────────────────────────
402    /// CAL-E121 — The session's grants don't cover this statement. Carries
403    /// the store's `AUT-Ennn` detail verbatim: the refused verb, namespace,
404    /// and principal are the caller's own session facts and exactly what a
405    /// granting admin needs to fix it.
406    #[error("CAL-E121: Not authorized: {detail}")]
407    NotAuthorized { detail: String, span: Option<Span> },
408
409    /// CAL-E070 — Query input contains invalid UTF-8 byte sequences or
410    /// bidi-override characters. HTTP body extractors typically reject
411    /// non-UTF-8 upstream; this variant covers in-band rejection
412    /// (bidi runs, mixed-script confusables) surfaced by the lexer.
413    #[error("CAL-E070: Invalid UTF-8 or unsafe character in query: {detail}")]
414    InvalidUtf8 { detail: String, span: Option<Span> },
415
416    // ── ACCUMULATE errors (CAL-E080 – CAL-E082) ────────────────────────
417    /// CAL-E080 — ACCUMULATE requires at least one ADD operation.
418    #[error("CAL-E080: ACCUMULATE requires at least one ADD operation")]
419    MissingAccumulateOps { span: Option<Span> },
420
421    /// CAL-E081 — ADD targets a non-numeric field (detected at execution time).
422    #[error(
423        "CAL-E081: ADD delta applied to non-numeric field \"{field}\" (current value: {current})"
424    )]
425    AccumulateNonNumericField {
426        field: String,
427        current: String,
428        span: Option<Span>,
429    },
430
431    /// CAL-E082 — ACCUMULATE WHERE matched no grain (tip not found).
432    #[error("CAL-E082: No grain found for ACCUMULATE target (subject=\"{subject}\", relation=\"{relation}\")")]
433    AccumulateTipNotFound {
434        subject: String,
435        relation: String,
436        span: Option<Span>,
437    },
438
439    /// CAL-E083 — ACCUMULATE retry budget exhausted under sustained
440    /// contention (CU-86d2wr4n4). With per-key serialization in place
441    /// this should never fire under normal contention; defensive belt
442    /// against unforeseen retry pathologies. HTTP status: 409 Conflict.
443    /// Body echoes only `subject` / `relation` (security C4) — inner
444    /// cause is logged separately with `request_id`.
445    #[error(
446        "CAL-E083: ACCUMULATE retry budget exhausted (subject=\"{subject}\", relation=\"{relation}\")"
447    )]
448    AccumulateRetryExhausted {
449        subject: String,
450        relation: String,
451        span: Option<Span>,
452    },
453
454    /// CAL-E084 — ACCUMULATE failed for an internal reason that is
455    /// neither user validation nor contention. HTTP status: 500.
456    /// Inner-error text MUST NOT be in the wire body (security C3) —
457    /// surfaced only through `tracing::error!` with request_id.
458    #[error("CAL-E084: ACCUMULATE internal failure")]
459    AccumulateInternal { span: Option<Span> },
460
461    /// CAL-E085 — ACCUMULATE rejected at admission control (CU-86d2wr4n4
462    /// v2.1). Either the per-key inflight cap or the global retry-permit
463    /// semaphore was saturated. HTTP status: 429 Too Many Requests with
464    /// a fixed `Retry-After: 1` header (no queue-depth signaling —
465    /// security review condition). Body echoes only `subject` /
466    /// `relation` (security C4) — same sanitization as CAL-E083.
467    #[error(
468        "CAL-E085: ACCUMULATE backpressure: per-key inflight cap exceeded (subject=\"{subject}\", relation=\"{relation}\")"
469    )]
470    AccumulateBackpressureRejected {
471        subject: String,
472        relation: String,
473        span: Option<Span>,
474    },
475
476    // ── Phase 4: Template errors (CAL-E040 – CAL-E050) ────────────────
477    /// CAL-E040 — Template source exceeds maximum allowed size.
478    #[error("CAL-E040: Template too large ({size} bytes, max {max})")]
479    TemplateTooLarge {
480        size: usize,
481        max: usize,
482        span: Option<Span>,
483    },
484
485    /// CAL-E041 — Template contains nested {{#each}} blocks.
486    #[error("CAL-E041: Nested {{{{#each}}}} blocks are not allowed")]
487    TemplateNestedEach { span: Option<Span> },
488
489    /// CAL-E042 — Template references an unknown variable.
490    #[error("CAL-E042: Unknown template variable \"{name}\"")]
491    TemplateUnknownVariable {
492        name: String,
493        span: Option<Span>,
494        suggestion: Option<String>,
495    },
496
497    /// CAL-E043 — Template uses an unknown filter.
498    #[error("CAL-E043: Unknown template filter \"{name}\"")]
499    TemplateUnknownFilter { name: String, span: Option<Span> },
500
501    /// CAL-E115 — Template name is invalid (must start with a letter,
502    /// max 64 chars, only letters/digits/spaces/hyphens/underscores).
503    #[error("CAL-E115: Invalid template name \"{name}\"")]
504    TemplateInvalidName { name: String, span: Option<Span> },
505
506    /// CAL-E044 — Tier 1 (Evolve) statement was issued while Tier 1 is
507    /// disabled on the server. The parser accepts the statement but the
508    /// executor refuses to run it because the capability is gated off.
509    #[error("CAL-E044: Tier 1 (Evolve) is not enabled: {statement}")]
510    Tier1NotEnabled {
511        statement: String,
512        span: Option<Span>,
513    },
514
515    /// CAL-E045 — Referenced template does not exist in the registry.
516    #[error("CAL-E045: Template \"{name}\" not found")]
517    TemplateNotFound { name: String, span: Option<Span> },
518
519    /// CAL-E046 — Attempted to delete or overwrite a built-in template.
520    #[error("CAL-E046: Built-in template \"{name}\" cannot be modified")]
521    TemplateBuiltinImmutable { name: String, span: Option<Span> },
522
523    /// CAL-E047 — Template inheritance parent not found.
524    #[error("CAL-E047: Template \"{name}\" extends unknown parent \"{parent}\"")]
525    TemplateParentNotFound {
526        name: String,
527        parent: String,
528        span: Option<Span>,
529    },
530
531    /// CAL-E048 — Template inheritance depth exceeds 1 level.
532    #[error("CAL-E048: Template \"{name}\" exceeds maximum inheritance depth (1 level)")]
533    TemplateInheritanceDepth { name: String, span: Option<Span> },
534
535    /// CAL-E049 — Template syntax error (unclosed tag, malformed filter, etc.).
536    #[error("CAL-E049: Template syntax error: {detail}")]
537    TemplateSyntaxError { detail: String, span: Option<Span> },
538
539    /// CAL-E050 — Rendered output exceeds maximum allowed size (F1 safety).
540    #[error("CAL-E050: Rendered output too large ({size} bytes, max {max})")]
541    RenderOutputTooLarge {
542        size: usize,
543        max: usize,
544        span: Option<Span>,
545    },
546
547    // ── Phase 5: Saved query errors (CAL-E051 – CAL-E059) ──────────────
548    /// CAL-E051 — Referenced saved query does not exist.
549    #[error("CAL-E051: Saved query \"{name}\" not found")]
550    QueryNotFound { name: String, span: Option<Span> },
551
552    /// CAL-E052 — A saved query with this name already exists.
553    #[error("CAL-E052: Saved query \"{name}\" already exists")]
554    DuplicateQueryName { name: String, span: Option<Span> },
555
556    /// CAL-E053 — Too many saved queries in this namespace.
557    #[error("CAL-E053: Too many saved queries ({count}, max {max})")]
558    TooManyQueries {
559        count: usize,
560        max: usize,
561        span: Option<Span>,
562    },
563
564    /// CAL-E054 — Query body exceeds maximum allowed size.
565    #[error("CAL-E054: Query body too large ({size} bytes, max {max})")]
566    QueryBodyTooLarge {
567        size: usize,
568        max: usize,
569        span: Option<Span>,
570    },
571
572    /// CAL-E055 — Too many parameters declared on a saved query.
573    #[error("CAL-E055: Too many query parameters ({count}, max {max})")]
574    TooManyQueryParams {
575        count: usize,
576        max: usize,
577        span: Option<Span>,
578    },
579
580    /// CAL-E056 — A required parameter was not supplied at the RUN call site.
581    #[error("CAL-E056: Missing required parameter \"${name}\" for query \"{query}\"")]
582    MissingQueryParam {
583        name: String,
584        query: String,
585        span: Option<Span>,
586    },
587
588    /// CAL-E057 — RUN found inside DEFINE QUERY body (recursion not allowed).
589    #[error("CAL-E057: RUN is not allowed inside DEFINE QUERY body")]
590    RecursiveQuery { span: Option<Span> },
591
592    /// CAL-E058 — Write statement found in DEFINE QUERY body (read-tier only).
593    #[error("CAL-E058: Write statement \"{stmt}\" not allowed in DEFINE QUERY body")]
594    WriteInQueryBody { stmt: String, span: Option<Span> },
595
596    /// CAL-E059 — General query body parse error.
597    #[error("CAL-E059: Invalid query body: {detail}")]
598    InvalidQueryBody { detail: String, span: Option<Span> },
599
600    // ── Version errors (CAL-E100) ──────────────────────────────────────
601    /// CAL-E100 — The `CAL/<version>` prefix specifies a version the
602    /// server does not support.
603    #[error("CAL-E100: Unsupported CAL version {version}")]
604    UnsupportedVersion { version: u32, span: Option<Span> },
605
606    // ── Multi-format errors (CAL-E110) ──────────────────────────────
607    /// CAL-E110 — A multi-format list contains more formats than allowed.
608    #[error("CAL-E110: Too many formats in multi-format list ({count}, max {max})")]
609    TooManyFormats {
610        count: usize,
611        max: usize,
612        span: Option<Span>,
613    },
614
615    // ── User vars errors (CAL-E111, CAL-E112) ────────────────────────
616    /// CAL-E111 — Too many user variables in WITH VARS clause.
617    #[error("CAL-E111: Too many user variables ({count}, max {max})")]
618    TooManyUserVars {
619        count: usize,
620        max: usize,
621        span: Option<Span>,
622    },
623
624    /// CAL-E112 — A user variable value exceeds the maximum allowed size.
625    #[error("CAL-E112: User variable \"{key}\" too large ({size} bytes, max {max})")]
626    UserVarTooLarge {
627        key: String,
628        size: usize,
629        max: usize,
630        span: Option<Span>,
631    },
632
633    // ── Format alias errors (CAL-E113) ──────────────────────────────
634    /// CAL-E113 — Duplicate key in multi-format list (alias or canonical name collision).
635    #[error("CAL-E113: Duplicate format key \"{key}\" in multi-format list")]
636    DuplicateFormatKey { key: String, span: Option<Span> },
637
638    // ── Scope enforcement (CAL-E114) ─────────────────────────────────
639    /// CAL-E114 — Caller lacks the required scope for this statement type.
640    #[error("CAL-E114: insufficient scope: '{statement}' requires '{required}' scope")]
641    InsufficientScope { required: String, statement: String },
642
643    // ── LLM-dependent feature (CAL-E116) ─────────────────────────────
644    /// CAL-E116 — A `WITH` option that intrinsically needs an external LLM
645    /// (e.g. `hyde`, `llm_rerank`). Areev is a passive, dependency-light
646    /// engine and takes no LLM dependency by policy — these live in the host's
647    /// agent loop. Surfaced as a clear error instead of a silent no-op.
648    #[error(
649        "CAL-E116: WITH {feature} needs an external LLM and is not implemented in Areev — \
650         the engine takes no LLM dependency by design (these belong in your agent loop). \
651         Want it built in? Open a feature request at \
652         https://github.com/AreevAI/areev/issues — we'll build it if there's demand."
653    )]
654    LlmFeatureUnavailable { feature: String },
655}
656
657impl CalError {
658    /// Return the CAL spec error code (e.g. `"CAL-E001"`).
659    pub fn code(&self) -> &'static str {
660        match self {
661            Self::QueryTooLong { .. } => "CAL-E001",
662            Self::UnexpectedToken { .. } => "CAL-E002",
663            Self::UnknownGrainType { .. } => "CAL-E003",
664            Self::UnknownField { .. } => "CAL-E004",
665            Self::UnterminatedString { .. } => "CAL-E005",
666            Self::InvalidNumber { .. } => "CAL-E006",
667            Self::NestingTooDeep { .. } => "CAL-E007",
668            Self::UnboundParameter { .. } => "CAL-E008",
669            Self::DuplicateParameter { .. } => "CAL-E009",
670            Self::LimitExceeded { .. } => "CAL-E010",
671            Self::InSetTooLarge { .. } => "CAL-E011",
672            Self::TooManyPipelineStages { .. } => "CAL-E012",
673            Self::TooManySetOperands { .. } => "CAL-E013",
674            Self::EmptyQuery { .. } => "CAL-E014",
675            Self::InvalidHash { .. } => "CAL-E015",
676            Self::ReasonTooLong { .. } => "CAL-E016",
677            Self::UnknownEvolveField { .. } => "CAL-E017",
678            Self::MissingReason { .. } => "CAL-E018",
679            Self::MissingSetClause { .. } => "CAL-E019",
680            Self::IncompatibleTypes { .. } => "CAL-E020",
681            Self::PipelineTypeMismatch { .. } => "CAL-E021",
682            Self::ExtractorRequiresFacts { .. } => "CAL-E022",
683            Self::BudgetExceeded { .. } => "CAL-E030",
684            Self::QueryTimeout { .. } => "CAL-E031",
685            Self::CryptoError { .. } => "CAL-E090",
686            Self::HashNotFound { .. } => "CAL-E091",
687            Self::InvalidQuery { .. } => "CAL-E092",
688            Self::FieldNotOnGrainType { .. } => "CAL-E060",
689            Self::EngineFieldNotFilterable { .. } => "CAL-E061",
690            Self::AssembleTooManySources { .. } => "CAL-E032",
691            Self::AssembleBudgetExceeded { .. } => "CAL-E033",
692            Self::AssembleDuplicateLabel { .. } => "CAL-E034",
693            Self::AssemblePinnedBudgetExceeded { .. } => "CAL-E122",
694            Self::AssemblePriorityMismatch { .. } => "CAL-E035",
695            Self::TooManyLetBindings { .. } => "CAL-E036",
696            Self::LetCircularReference { .. } => "CAL-E037",
697            Self::LetDepthExceeded { .. } => "CAL-E038",
698            Self::CoalesceTooManyBranches { .. } => "CAL-E039",
699            Self::AssembleTimeout { .. } => "CAL-E071",
700            Self::TemplateNestingTooDeep { .. } => "CAL-E117",
701            Self::TooManyTemplates { .. } => "CAL-E118",
702            Self::CannotExtendData { .. } => "CAL-E119",
703            Self::InvalidJsonCal { .. } => "CAL-E120",
704            Self::NotAuthorized { .. } => "CAL-E121",
705            Self::InvalidUtf8 { .. } => "CAL-E070",
706            Self::TemplateTooLarge { .. } => "CAL-E040",
707            Self::TemplateNestedEach { .. } => "CAL-E041",
708            Self::TemplateUnknownVariable { .. } => "CAL-E042",
709            Self::TemplateUnknownFilter { .. } => "CAL-E043",
710            Self::TemplateInvalidName { .. } => "CAL-E115",
711            Self::Tier1NotEnabled { .. } => "CAL-E044",
712            Self::TemplateNotFound { .. } => "CAL-E045",
713            Self::TemplateBuiltinImmutable { .. } => "CAL-E046",
714            Self::TemplateParentNotFound { .. } => "CAL-E047",
715            Self::TemplateInheritanceDepth { .. } => "CAL-E048",
716            Self::TemplateSyntaxError { .. } => "CAL-E049",
717            Self::RenderOutputTooLarge { .. } => "CAL-E050",
718            Self::UnsupportedVersion { .. } => "CAL-E100",
719            Self::TooManyFormats { .. } => "CAL-E110",
720            Self::TooManyUserVars { .. } => "CAL-E111",
721            Self::UserVarTooLarge { .. } => "CAL-E112",
722            Self::DuplicateFormatKey { .. } => "CAL-E113",
723            Self::InsufficientScope { .. } => "CAL-E114",
724            Self::LlmFeatureUnavailable { .. } => "CAL-E116",
725            Self::MissingAccumulateOps { .. } => "CAL-E080",
726            Self::AccumulateNonNumericField { .. } => "CAL-E081",
727            Self::AccumulateTipNotFound { .. } => "CAL-E082",
728            Self::AccumulateRetryExhausted { .. } => "CAL-E083",
729            Self::AccumulateInternal { .. } => "CAL-E084",
730            Self::AccumulateBackpressureRejected { .. } => "CAL-E085",
731            Self::QueryNotFound { .. } => "CAL-E051",
732            Self::DuplicateQueryName { .. } => "CAL-E052",
733            Self::TooManyQueries { .. } => "CAL-E053",
734            Self::QueryBodyTooLarge { .. } => "CAL-E054",
735            Self::TooManyQueryParams { .. } => "CAL-E055",
736            Self::MissingQueryParam { .. } => "CAL-E056",
737            Self::RecursiveQuery { .. } => "CAL-E057",
738            Self::WriteInQueryBody { .. } => "CAL-E058",
739            Self::InvalidQueryBody { .. } => "CAL-E059",
740        }
741    }
742
743    /// Return the source span, if one was recorded.
744    pub fn span(&self) -> Option<Span> {
745        match self {
746            Self::QueryTooLong { span, .. }
747            | Self::UnexpectedToken { span, .. }
748            | Self::UnknownGrainType { span, .. }
749            | Self::UnknownField { span, .. }
750            | Self::UnterminatedString { span, .. }
751            | Self::InvalidNumber { span, .. }
752            | Self::NestingTooDeep { span, .. }
753            | Self::UnboundParameter { span, .. }
754            | Self::DuplicateParameter { span, .. }
755            | Self::LimitExceeded { span, .. }
756            | Self::InSetTooLarge { span, .. }
757            | Self::TooManyPipelineStages { span, .. }
758            | Self::TooManySetOperands { span, .. }
759            | Self::EmptyQuery { span, .. }
760            | Self::InvalidHash { span, .. }
761            | Self::ReasonTooLong { span, .. }
762            | Self::UnknownEvolveField { span, .. }
763            | Self::MissingReason { span, .. }
764            | Self::MissingSetClause { span, .. }
765            | Self::IncompatibleTypes { span, .. }
766            | Self::PipelineTypeMismatch { span, .. }
767            | Self::ExtractorRequiresFacts { span, .. }
768            | Self::BudgetExceeded { span, .. }
769            | Self::QueryTimeout { span, .. }
770            | Self::InvalidQuery { span, .. }
771            | Self::CryptoError { span, .. }
772            | Self::FieldNotOnGrainType { span, .. }
773            | Self::EngineFieldNotFilterable { span, .. }
774            | Self::AssemblePinnedBudgetExceeded { span, .. }
775            | Self::AssembleTooManySources { span, .. }
776            | Self::AssembleBudgetExceeded { span, .. }
777            | Self::AssembleDuplicateLabel { span, .. }
778            | Self::AssemblePriorityMismatch { span, .. }
779            | Self::TooManyLetBindings { span, .. }
780            | Self::LetCircularReference { span, .. }
781            | Self::LetDepthExceeded { span, .. }
782            | Self::CoalesceTooManyBranches { span, .. }
783            | Self::AssembleTimeout { span, .. }
784            | Self::InvalidJsonCal { span, .. }
785            | Self::NotAuthorized { span, .. }
786            | Self::TemplateTooLarge { span, .. }
787            | Self::TemplateNestedEach { span, .. }
788            | Self::TemplateUnknownVariable { span, .. }
789            | Self::TemplateUnknownFilter { span, .. }
790            | Self::TemplateInvalidName { span, .. }
791            | Self::TemplateNotFound { span, .. }
792            | Self::TemplateBuiltinImmutable { span, .. }
793            | Self::TemplateParentNotFound { span, .. }
794            | Self::TemplateInheritanceDepth { span, .. }
795            | Self::TemplateSyntaxError { span, .. }
796            | Self::RenderOutputTooLarge { span, .. }
797            | Self::UnsupportedVersion { span, .. }
798            | Self::TooManyFormats { span, .. }
799            | Self::TooManyUserVars { span, .. }
800            | Self::UserVarTooLarge { span, .. }
801            | Self::DuplicateFormatKey { span, .. }
802            | Self::MissingAccumulateOps { span, .. }
803            | Self::AccumulateNonNumericField { span, .. }
804            | Self::AccumulateTipNotFound { span, .. }
805            | Self::AccumulateRetryExhausted { span, .. }
806            | Self::AccumulateInternal { span, .. }
807            | Self::AccumulateBackpressureRejected { span, .. }
808            | Self::QueryNotFound { span, .. }
809            | Self::DuplicateQueryName { span, .. }
810            | Self::TooManyQueries { span, .. }
811            | Self::TemplateNestingTooDeep { span, .. }
812            | Self::TooManyTemplates { span, .. }
813            | Self::CannotExtendData { span, .. }
814            | Self::QueryBodyTooLarge { span, .. }
815            | Self::TooManyQueryParams { span, .. }
816            | Self::MissingQueryParam { span, .. }
817            | Self::RecursiveQuery { span, .. }
818            | Self::WriteInQueryBody { span, .. }
819            | Self::InvalidQueryBody { span, .. }
820            | Self::HashNotFound { span, .. }
821            | Self::Tier1NotEnabled { span, .. }
822            | Self::InvalidUtf8 { span, .. } => *span,
823            Self::InsufficientScope { .. } | Self::LlmFeatureUnavailable { .. } => None,
824        }
825    }
826
827    /// Return the suggestion, if one was attached.
828    pub fn suggestion(&self) -> Option<&str> {
829        match self {
830            Self::UnexpectedToken { suggestion, .. }
831            | Self::UnknownGrainType { suggestion, .. }
832            | Self::UnknownField { suggestion, .. }
833            | Self::UnknownEvolveField { suggestion, .. }
834            | Self::IncompatibleTypes { suggestion, .. }
835            | Self::FieldNotOnGrainType { suggestion, .. }
836            | Self::TemplateUnknownVariable { suggestion, .. } => suggestion.as_deref(),
837            _ => None,
838        }
839    }
840
841    /// Attach a human-readable suggestion to this error.
842    ///
843    /// Only affects variants that carry a `suggestion` field; for others
844    /// the error is returned unchanged.
845    pub fn with_suggestion(self, hint: &str) -> Self {
846        let hint = Some(hint.to_string());
847        match self {
848            Self::UnexpectedToken {
849                expected,
850                found,
851                span,
852                ..
853            } => Self::UnexpectedToken {
854                expected,
855                found,
856                span,
857                suggestion: hint,
858            },
859            Self::UnknownGrainType { found, span, .. } => Self::UnknownGrainType {
860                found,
861                span,
862                suggestion: hint,
863            },
864            Self::UnknownField { found, span, .. } => Self::UnknownField {
865                found,
866                span,
867                suggestion: hint,
868            },
869            Self::UnknownEvolveField { found, span, .. } => Self::UnknownEvolveField {
870                found,
871                span,
872                suggestion: hint,
873            },
874            Self::IncompatibleTypes {
875                left, right, span, ..
876            } => Self::IncompatibleTypes {
877                left,
878                right,
879                span,
880                suggestion: hint,
881            },
882            Self::FieldNotOnGrainType {
883                field,
884                grain_type,
885                span,
886                ..
887            } => Self::FieldNotOnGrainType {
888                field,
889                grain_type,
890                span,
891                suggestion: hint,
892            },
893            Self::TemplateUnknownVariable { name, span, .. } => Self::TemplateUnknownVariable {
894                name,
895                span,
896                suggestion: hint,
897            },
898            other => other,
899        }
900    }
901
902    /// Attach a span to this error, replacing any existing span.
903    pub fn with_span(self, new_span: Span) -> Self {
904        let s = Some(new_span);
905        match self {
906            Self::QueryTooLong { length, max, .. } => Self::QueryTooLong {
907                length,
908                max,
909                span: s,
910            },
911            Self::UnexpectedToken {
912                expected,
913                found,
914                suggestion,
915                ..
916            } => Self::UnexpectedToken {
917                expected,
918                found,
919                span: s,
920                suggestion,
921            },
922            Self::UnknownGrainType {
923                found, suggestion, ..
924            } => Self::UnknownGrainType {
925                found,
926                span: s,
927                suggestion,
928            },
929            Self::UnknownField {
930                found, suggestion, ..
931            } => Self::UnknownField {
932                found,
933                span: s,
934                suggestion,
935            },
936            Self::UnterminatedString { .. } => Self::UnterminatedString { span: s },
937            Self::InvalidNumber { found, .. } => Self::InvalidNumber { found, span: s },
938            Self::NestingTooDeep { depth, max, .. } => Self::NestingTooDeep {
939                depth,
940                max,
941                span: s,
942            },
943            Self::UnboundParameter { name, .. } => Self::UnboundParameter { name, span: s },
944            Self::DuplicateParameter { name, .. } => Self::DuplicateParameter { name, span: s },
945            Self::LimitExceeded { value, max, .. } => Self::LimitExceeded {
946                value,
947                max,
948                span: s,
949            },
950            Self::InSetTooLarge { count, max, .. } => Self::InSetTooLarge {
951                count,
952                max,
953                span: s,
954            },
955            Self::TooManyPipelineStages { count, max, .. } => Self::TooManyPipelineStages {
956                count,
957                max,
958                span: s,
959            },
960            Self::TooManySetOperands { count, max, .. } => Self::TooManySetOperands {
961                count,
962                max,
963                span: s,
964            },
965            Self::EmptyQuery { .. } => Self::EmptyQuery { span: s },
966            Self::InvalidHash { found, .. } => Self::InvalidHash { found, span: s },
967            Self::ReasonTooLong { length, max, .. } => Self::ReasonTooLong {
968                length,
969                max,
970                span: s,
971            },
972            Self::UnknownEvolveField {
973                found, suggestion, ..
974            } => Self::UnknownEvolveField {
975                found,
976                span: s,
977                suggestion,
978            },
979            Self::MissingReason { .. } => Self::MissingReason { span: s },
980            Self::MissingSetClause { .. } => Self::MissingSetClause { span: s },
981            Self::IncompatibleTypes {
982                left,
983                right,
984                suggestion,
985                ..
986            } => Self::IncompatibleTypes {
987                left,
988                right,
989                span: s,
990                suggestion,
991            },
992            Self::PipelineTypeMismatch {
993                stage,
994                expected,
995                found,
996                ..
997            } => Self::PipelineTypeMismatch {
998                stage,
999                expected,
1000                found,
1001                span: s,
1002            },
1003            Self::ExtractorRequiresFacts {
1004                extractor, found, ..
1005            } => Self::ExtractorRequiresFacts {
1006                extractor,
1007                found,
1008                span: s,
1009            },
1010            Self::BudgetExceeded { detail, .. } => Self::BudgetExceeded { detail, span: s },
1011            Self::InvalidQuery { detail, .. } => Self::InvalidQuery { detail, span: s },
1012            Self::CryptoError { detail, .. } => Self::CryptoError { detail, span: s },
1013            Self::HashNotFound { hash, .. } => Self::HashNotFound { hash, span: s },
1014            Self::Tier1NotEnabled { statement, .. } => Self::Tier1NotEnabled { statement, span: s },
1015            Self::InvalidUtf8 { detail, .. } => Self::InvalidUtf8 { detail, span: s },
1016            Self::QueryTimeout {
1017                elapsed_ms,
1018                limit_ms,
1019                ..
1020            } => Self::QueryTimeout {
1021                elapsed_ms,
1022                limit_ms,
1023                span: s,
1024            },
1025            Self::FieldNotOnGrainType {
1026                field,
1027                grain_type,
1028                suggestion,
1029                ..
1030            } => Self::FieldNotOnGrainType {
1031                field,
1032                grain_type,
1033                span: s,
1034                suggestion,
1035            },
1036            Self::EngineFieldNotFilterable { field, context, .. } => {
1037                Self::EngineFieldNotFilterable {
1038                    field,
1039                    context,
1040                    span: s,
1041                }
1042            }
1043            Self::AssembleTooManySources { count, max, .. } => Self::AssembleTooManySources {
1044                count,
1045                max,
1046                span: s,
1047            },
1048            Self::AssembleBudgetExceeded {
1049                value, max, unit, ..
1050            } => Self::AssembleBudgetExceeded {
1051                value,
1052                max,
1053                unit,
1054                span: s,
1055            },
1056            Self::AssembleDuplicateLabel { label, .. } => {
1057                Self::AssembleDuplicateLabel { label, span: s }
1058            }
1059            Self::AssemblePriorityMismatch { label, .. } => {
1060                Self::AssemblePriorityMismatch { label, span: s }
1061            }
1062            Self::TooManyLetBindings { count, max, .. } => Self::TooManyLetBindings {
1063                count,
1064                max,
1065                span: s,
1066            },
1067            Self::LetCircularReference { name, .. } => Self::LetCircularReference { name, span: s },
1068            Self::LetDepthExceeded { depth, max, .. } => Self::LetDepthExceeded {
1069                depth,
1070                max,
1071                span: s,
1072            },
1073            Self::CoalesceTooManyBranches { count, max, .. } => Self::CoalesceTooManyBranches {
1074                count,
1075                max,
1076                span: s,
1077            },
1078            Self::AssembleTimeout {
1079                elapsed_ms,
1080                limit_ms,
1081                ..
1082            } => Self::AssembleTimeout {
1083                elapsed_ms,
1084                limit_ms,
1085                span: s,
1086            },
1087            Self::InvalidJsonCal { detail, .. } => Self::InvalidJsonCal { detail, span: s },
1088            Self::NotAuthorized { detail, .. } => Self::NotAuthorized { detail, span: s },
1089            Self::TemplateTooLarge { size, max, .. } => {
1090                Self::TemplateTooLarge { size, max, span: s }
1091            }
1092            Self::TemplateNestedEach { .. } => Self::TemplateNestedEach { span: s },
1093            Self::TemplateUnknownVariable {
1094                name, suggestion, ..
1095            } => Self::TemplateUnknownVariable {
1096                name,
1097                span: s,
1098                suggestion,
1099            },
1100            Self::TemplateUnknownFilter { name, .. } => {
1101                Self::TemplateUnknownFilter { name, span: s }
1102            }
1103            Self::TemplateInvalidName { name, .. } => Self::TemplateInvalidName { name, span: s },
1104            Self::TemplateNotFound { name, .. } => Self::TemplateNotFound { name, span: s },
1105            Self::TemplateBuiltinImmutable { name, .. } => {
1106                Self::TemplateBuiltinImmutable { name, span: s }
1107            }
1108            Self::TemplateParentNotFound { name, parent, .. } => Self::TemplateParentNotFound {
1109                name,
1110                parent,
1111                span: s,
1112            },
1113            Self::TemplateInheritanceDepth { name, .. } => {
1114                Self::TemplateInheritanceDepth { name, span: s }
1115            }
1116            Self::TemplateSyntaxError { detail, .. } => {
1117                Self::TemplateSyntaxError { detail, span: s }
1118            }
1119            Self::RenderOutputTooLarge { size, max, .. } => {
1120                Self::RenderOutputTooLarge { size, max, span: s }
1121            }
1122            Self::UnsupportedVersion { version, .. } => {
1123                Self::UnsupportedVersion { version, span: s }
1124            }
1125            Self::TooManyFormats { count, max, .. } => Self::TooManyFormats {
1126                count,
1127                max,
1128                span: s,
1129            },
1130            Self::TooManyUserVars { count, max, .. } => Self::TooManyUserVars {
1131                count,
1132                max,
1133                span: s,
1134            },
1135            Self::UserVarTooLarge { key, size, max, .. } => Self::UserVarTooLarge {
1136                key,
1137                size,
1138                max,
1139                span: s,
1140            },
1141            Self::DuplicateFormatKey { key, .. } => Self::DuplicateFormatKey { key, span: s },
1142            Self::AssemblePinnedBudgetExceeded {
1143                labels,
1144                required,
1145                budget,
1146                ..
1147            } => Self::AssemblePinnedBudgetExceeded {
1148                labels,
1149                required,
1150                budget,
1151                span: s,
1152            },
1153            Self::MissingAccumulateOps { .. } => Self::MissingAccumulateOps { span: s },
1154            Self::AccumulateNonNumericField { field, current, .. } => {
1155                Self::AccumulateNonNumericField {
1156                    field,
1157                    current,
1158                    span: s,
1159                }
1160            }
1161            Self::AccumulateTipNotFound {
1162                subject, relation, ..
1163            } => Self::AccumulateTipNotFound {
1164                subject,
1165                relation,
1166                span: s,
1167            },
1168            Self::AccumulateRetryExhausted {
1169                subject, relation, ..
1170            } => Self::AccumulateRetryExhausted {
1171                subject,
1172                relation,
1173                span: s,
1174            },
1175            Self::AccumulateInternal { .. } => Self::AccumulateInternal { span: s },
1176            Self::AccumulateBackpressureRejected {
1177                subject, relation, ..
1178            } => Self::AccumulateBackpressureRejected {
1179                subject,
1180                relation,
1181                span: s,
1182            },
1183            Self::QueryNotFound { name, .. } => Self::QueryNotFound { name, span: s },
1184            Self::DuplicateQueryName { name, .. } => Self::DuplicateQueryName { name, span: s },
1185            Self::CannotExtendData { name, .. } => Self::CannotExtendData { name, span: s },
1186            Self::TemplateNestingTooDeep { max, .. } => {
1187                Self::TemplateNestingTooDeep { max, span: s }
1188            }
1189            Self::TooManyTemplates { count, max, .. } => Self::TooManyTemplates {
1190                count,
1191                max,
1192                span: s,
1193            },
1194            Self::TooManyQueries { count, max, .. } => Self::TooManyQueries {
1195                count,
1196                max,
1197                span: s,
1198            },
1199            Self::QueryBodyTooLarge { size, max, .. } => {
1200                Self::QueryBodyTooLarge { size, max, span: s }
1201            }
1202            Self::TooManyQueryParams { count, max, .. } => Self::TooManyQueryParams {
1203                count,
1204                max,
1205                span: s,
1206            },
1207            Self::MissingQueryParam { name, query, .. } => Self::MissingQueryParam {
1208                name,
1209                query,
1210                span: s,
1211            },
1212            Self::RecursiveQuery { .. } => Self::RecursiveQuery { span: s },
1213            Self::WriteInQueryBody { stmt, .. } => Self::WriteInQueryBody { stmt, span: s },
1214            Self::InvalidQueryBody { detail, .. } => Self::InvalidQueryBody { detail, span: s },
1215            // InsufficientScope has no source span — return unchanged.
1216            Self::InsufficientScope {
1217                required,
1218                statement,
1219            } => Self::InsufficientScope {
1220                required,
1221                statement,
1222            },
1223            // LlmFeatureUnavailable has no source span — return unchanged.
1224            Self::LlmFeatureUnavailable { feature } => Self::LlmFeatureUnavailable { feature },
1225        }
1226    }
1227
1228    /// Format a diagnostic message suitable for terminal or JSON error
1229    /// responses.  Includes the error code, message, location (if known),
1230    /// and suggestion (if any).
1231    pub fn diagnostic(&self) -> String {
1232        let mut msg = self.to_string();
1233        if let Some(span) = self.span() {
1234            msg.push_str(&format!(" at {}", span));
1235        }
1236        if let Some(hint) = self.suggestion() {
1237            msg.push_str(&format!(" (hint: {})", hint));
1238        }
1239        msg
1240    }
1241
1242    /// Return a sanitized error message safe for client-facing responses.
1243    ///
1244    /// Strips the free-form `detail` field from variants that carry inner
1245    /// error strings (typically `AreevError::to_string()` passed through
1246    /// from the executor / assembler / crypto path). These strings can leak
1247    /// internal paths, identifiers, backend errors, and key names (CWE-209).
1248    ///
1249    /// The full diagnostic is still available via `Display` /
1250    /// `diagnostic()` for server-side logging — only the client-facing
1251    /// surface is stripped.
1252    ///
1253    /// Variants WITHOUT a `detail` field are returned via `diagnostic()`
1254    /// unchanged: their messages are bounded constants or caller-supplied
1255    /// values that the parser already validated (identifier names, limits,
1256    /// counts, etc.) and do not expose internal implementation details.
1257    pub fn sanitize_for_client(&self) -> String {
1258        let code = self.code();
1259        let span_suffix = self
1260            .span()
1261            .map(|s| format!(" at {}", s))
1262            .unwrap_or_default();
1263        match self {
1264            // Variants whose `#[error]` message ends in `: {detail}` —
1265            // the detail is constructed from inner errors (e.g. crypto
1266            // failures, executor errors, backend message from `AreevError`).
1267            // Replace with the code + a generic description, never the detail.
1268            Self::BudgetExceeded { .. } => {
1269                format!("{}: budget exceeded{}", code, span_suffix)
1270            }
1271            Self::InvalidQuery { .. } => {
1272                format!("{}: invalid query{}", code, span_suffix)
1273            }
1274            Self::CryptoError { .. } => {
1275                format!(
1276                    "{}: crypto error during query execution{}",
1277                    code, span_suffix
1278                )
1279            }
1280            Self::InvalidJsonCal { .. } => {
1281                format!("{}: invalid JSON+CAL input{}", code, span_suffix)
1282            }
1283            Self::NotAuthorized { detail, .. } => {
1284                // Deliberately unredacted: the detail names the caller's own
1285                // principal, the refused verb, and the namespace — no
1286                // internals, and it is the on-ramp to the GRANT that fixes
1287                // it.
1288                format!("{}: not authorized: {}{}", code, detail, span_suffix)
1289            }
1290            Self::TemplateSyntaxError { .. } => {
1291                format!("{}: template syntax error{}", code, span_suffix)
1292            }
1293            Self::InvalidQueryBody { .. } => {
1294                format!("{}: invalid query body{}", code, span_suffix)
1295            }
1296            // CAL-E083 — strip control chars from caller-supplied
1297            // subject/relation before echoing (security C4 — log-injection
1298            // and odd-byte safety). Body remains: code + stable message
1299            // + sanitized subject/relation. Inner cause never reaches
1300            // the wire (security C3).
1301            Self::AccumulateRetryExhausted {
1302                subject, relation, ..
1303            } => {
1304                format!(
1305                    "{}: ACCUMULATE retry budget exhausted (subject=\"{}\", relation=\"{}\"){}",
1306                    code,
1307                    sanitize_echo(subject),
1308                    sanitize_echo(relation),
1309                    span_suffix
1310                )
1311            }
1312            // CAL-E084 — never echo inner-error text (security C3).
1313            Self::AccumulateInternal { .. } => {
1314                format!("{}: ACCUMULATE internal failure{}", code, span_suffix)
1315            }
1316            // CAL-E085 — same echo handling as CAL-E083 (sanitize
1317            // caller-supplied subject/relation; no queue-depth signal in
1318            // the body — security review condition).
1319            Self::AccumulateBackpressureRejected {
1320                subject, relation, ..
1321            } => {
1322                format!(
1323                    "{}: ACCUMULATE backpressure: per-key inflight cap exceeded (subject=\"{}\", relation=\"{}\"){}",
1324                    code,
1325                    sanitize_echo(subject),
1326                    sanitize_echo(relation),
1327                    span_suffix
1328                )
1329            }
1330            // All other variants: their messages are bounded strings
1331            // (codes, counts, limits, parser-validated identifiers) —
1332            // safe to pass through with span + suggestion.
1333            _ => self.diagnostic(),
1334        }
1335    }
1336}
1337
1338/// Strip control characters and trim caller-supplied identifiers before
1339/// echoing them in error messages (CU-86d2wr4n4 security C4).
1340///
1341/// Replaces ASCII control bytes (incl. CR/LF and the bidi-override range
1342/// already rejected by the lexer for query bodies, but reapplied here in
1343/// case error construction sites bypass the lexer) with `?`. Caps the
1344/// echoed length at 128 chars so untrusted callers cannot bloat error
1345/// bodies.
1346fn sanitize_echo(s: &str) -> String {
1347    const MAX_ECHO_LEN: usize = 128;
1348    let mut out = String::with_capacity(s.len().min(MAX_ECHO_LEN));
1349    for ch in s.chars().take(MAX_ECHO_LEN) {
1350        if ch.is_control() || ('\u{202A}'..='\u{202E}').contains(&ch) {
1351            out.push('?');
1352        } else {
1353            out.push(ch);
1354        }
1355    }
1356    out
1357}
1358
1359// ---------------------------------------------------------------------------
1360// CalWarning — non-fatal diagnostics
1361// ---------------------------------------------------------------------------
1362
1363/// Non-fatal CAL warnings emitted during parsing or execution.
1364#[derive(Debug, Clone, PartialEq)]
1365pub enum CalWarning {
1366    /// CAL-W001 — The relation name in a Fact grain is not one of the
1367    /// well-known OMS relations.
1368    UnknownRelation {
1369        relation: String,
1370        span: Option<Span>,
1371    },
1372
1373    /// CAL-W002 — A domain-prefixed field was used without a
1374    /// corresponding `@tag` on the query.
1375    DomainFieldWithoutTag { field: String, span: Option<Span> },
1376
1377    /// CAL-W003 — A domain prefix was not recognised.
1378    UnknownDomainPrefix { prefix: String, span: Option<Span> },
1379
1380    /// CAL-W004 — An extension option in a `WITH` clause was not
1381    /// recognised and will be ignored.
1382    UnknownExtensionOption { option: String, span: Option<Span> },
1383
1384    /// CAL-W005 — A SET field name was specified more than once in the
1385    /// same statement; only the last value is used.
1386    DuplicateSetField { field: String, span: Option<Span> },
1387
1388    /// CAL-W006 — A parameter was supplied at the RUN call site but is not
1389    /// referenced in the saved query body.
1390    UnusedQueryParam {
1391        name: String,
1392        query: String,
1393        span: Option<Span>,
1394    },
1395
1396    /// CAL-W007 — The bare pipe operator `|` before pipeline stages is
1397    /// deprecated (removed in CAL 1.1). Use direct clause syntax instead
1398    /// (e.g. `RECALL facts ORDER BY confidence DESC LIMIT 10`).
1399    DeprecatedPipeOperator { span: Option<Span> },
1400
1401    /// CAL-W008 — IS CATEGORY used on a non-relation field. The IS CATEGORY
1402    /// check is only meaningful on the `relation` field; using it on other
1403    /// fields silently produces no matches.
1404    IsCategoryOnNonRelation {
1405        field: String,
1406        category: String,
1407        span: Option<Span>,
1408    },
1409
1410    /// CAL-W009 — ASSEMBLE sources have inconsistent subject scoping.
1411    /// Some sources filter by subject while others don't, which may return
1412    /// data from unrelated subjects.
1413    AssembleUnscopedSource {
1414        labels: Vec<String>,
1415        span: Option<Span>,
1416    },
1417
1418    /// CAL-W010 — A WHERE field on an untyped (`RECALL all`) query is not a
1419    /// recognized field on any grain type. The filter is still applied per
1420    /// grain (matching only grains that carry the field — likely none), so
1421    /// this usually signals a misspelled field name. On a *typed* recall the
1422    /// same situation is a hard `CAL-E060` instead (#91: a filter that
1423    /// cannot be honoured refuses rather than widening).
1424    UnrecognizedWhereField { field: String, span: Option<Span> },
1425
1426    /// CAL-W011 — A `{{#each}}` block hit the OMS CAL §10.8 iteration cap,
1427    /// so the rendered output covers only the first `max` grains. The result
1428    /// set itself is complete; only this rendering is short.
1429    EachIterationCapped {
1430        rendered: usize,
1431        total: usize,
1432        max: usize,
1433    },
1434
1435    /// CAL-W012 — A `CONTRADICTIONS` query's candidate scan hit the executor's
1436    /// `max_limit`, so grains past it were never examined for fork status.
1437    ///
1438    /// This exists because the useful answer to `CONTRADICTIONS` is often the
1439    /// *empty* one, and an agent may act on it. "Nothing is contested" and
1440    /// "nothing among the first N is contested" are different claims; without
1441    /// this warning the second would be indistinguishable from the first.
1442    ContradictionScanBounded { scanned: usize },
1443
1444    /// CAL-W014 — A `WITH` option parsed and ran, but does nothing on the
1445    /// statement it was attached to.
1446    ///
1447    /// §5 promises that an option needing an unavailable backend "returns an
1448    /// honest error rather than silently degrading". Several did neither: they
1449    /// parsed, ran, and returned output byte-identical to the same query
1450    /// without them. A hard error would break callers who have been passing
1451    /// these since 1.0, so the honest form is a warning that names the option
1452    /// and the surface — silence was the actual defect.
1453    WithOptionInert {
1454        option: &'static str,
1455        statement: &'static str,
1456        why: &'static str,
1457    },
1458
1459    /// CAL-W015 — A post-retrieval stage (ORDER BY, a type-specific WHERE
1460    /// filter, COUNT) widened its scan to the executor's `max_limit` and
1461    /// still filled it, so it ranked/filtered/counted a bounded window rather
1462    /// than the whole matching set.
1463    ///
1464    /// The sibling of `ContradictionScanBounded`, generalized. `ORDER BY`
1465    /// sorts the grains a statement already returned; without widening, that
1466    /// is a page of `default_limit` rows, so `ORDER BY priority DESC LIMIT 5`
1467    /// returned the top 5 *of the newest 50* and looked exactly like the top
1468    /// 5 overall. Widening fixes every corpus up to `max_limit`; past that the
1469    /// only honest thing left is to say so, because the answer is still a
1470    /// well-formed list that happens to be wrong.
1471    ScanBounded {
1472        /// What forced the wide scan — "ORDER BY priority", "WHERE tool_name", "COUNT".
1473        stage: String,
1474        scanned: usize,
1475    },
1476
1477    /// CAL-W016 — A pipeline stage was attached to a payload it cannot act on
1478    /// (e.g. `ORDER BY` on a multi-source `ASSEMBLE`, which returns an
1479    /// assembled section list rather than a flat grain list).
1480    ///
1481    /// These used to hit a catch-all passthrough arm and vanish with no error
1482    /// and no warning, which contradicts `docs/cal-reference.md` §5: silence
1483    /// means the option did something. Ordering an assembly is exactly the
1484    /// case a host reaches for when rendering authored instruction blocks in
1485    /// an intended order — and it was the one case that silently did nothing.
1486    PipelineStageInert {
1487        stage: String,
1488        payload: &'static str,
1489        why: &'static str,
1490    },
1491}
1492
1493impl CalWarning {
1494    /// Return the CAL spec warning code.
1495    pub fn code(&self) -> &'static str {
1496        match self {
1497            Self::UnknownRelation { .. } => "CAL-W001",
1498            Self::DomainFieldWithoutTag { .. } => "CAL-W002",
1499            Self::UnknownDomainPrefix { .. } => "CAL-W003",
1500            Self::UnknownExtensionOption { .. } => "CAL-W004",
1501            Self::DuplicateSetField { .. } => "CAL-W005",
1502            Self::UnusedQueryParam { .. } => "CAL-W006",
1503            Self::DeprecatedPipeOperator { .. } => "CAL-W007",
1504            Self::IsCategoryOnNonRelation { .. } => "CAL-W008",
1505            Self::AssembleUnscopedSource { .. } => "CAL-W009",
1506            Self::UnrecognizedWhereField { .. } => "CAL-W010",
1507            Self::EachIterationCapped { .. } => "CAL-W011",
1508            Self::ContradictionScanBounded { .. } => "CAL-W012",
1509            Self::WithOptionInert { .. } => "CAL-W014",
1510            Self::ScanBounded { .. } => "CAL-W015",
1511            Self::PipelineStageInert { .. } => "CAL-W016",
1512        }
1513    }
1514
1515    /// Return the source span, if one was recorded.
1516    pub fn span(&self) -> Option<Span> {
1517        match self {
1518            Self::UnknownRelation { span, .. }
1519            | Self::DomainFieldWithoutTag { span, .. }
1520            | Self::UnknownDomainPrefix { span, .. }
1521            | Self::UnknownExtensionOption { span, .. }
1522            | Self::DuplicateSetField { span, .. }
1523            | Self::UnusedQueryParam { span, .. }
1524            | Self::DeprecatedPipeOperator { span }
1525            | Self::IsCategoryOnNonRelation { span, .. }
1526            | Self::AssembleUnscopedSource { span, .. }
1527            | Self::UnrecognizedWhereField { span, .. } => *span,
1528            Self::EachIterationCapped { .. }
1529            | Self::ContradictionScanBounded { .. }
1530            | Self::WithOptionInert { .. }
1531            | Self::ScanBounded { .. }
1532            | Self::PipelineStageInert { .. } => None,
1533        }
1534    }
1535}
1536
1537impl std::fmt::Display for CalWarning {
1538    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1539        match self {
1540            Self::UnknownRelation { relation, .. } => {
1541                write!(f, "CAL-W001: Unknown relation \"{}\"", relation)
1542            }
1543            Self::DomainFieldWithoutTag { field, .. } => {
1544                write!(f, "CAL-W002: Domain field \"{}\" used without @tag", field)
1545            }
1546            Self::UnknownDomainPrefix { prefix, .. } => {
1547                write!(f, "CAL-W003: Unknown domain prefix \"{}\"", prefix)
1548            }
1549            Self::UnknownExtensionOption { option, .. } => {
1550                write!(
1551                    f,
1552                    "CAL-W004: Unknown extension option \"{}\" (ignored)",
1553                    option
1554                )
1555            }
1556            Self::DuplicateSetField { field, .. } => {
1557                write!(
1558                    f,
1559                    "CAL-W005: Duplicate SET field \"{}\" — only the last value is used",
1560                    field
1561                )
1562            }
1563            Self::UnusedQueryParam { name, query, .. } => {
1564                write!(
1565                    f,
1566                    "CAL-W006: Parameter \"${}\" supplied but not referenced in query \"{}\"",
1567                    name, query
1568                )
1569            }
1570            Self::DeprecatedPipeOperator { .. } => {
1571                write!(
1572                    f,
1573                    "CAL-W007: Bare pipe operator `|` is deprecated (CAL 1.1). Use direct clause syntax instead, e.g. `RECALL facts ORDER BY confidence DESC LIMIT 10`"
1574                )
1575            }
1576            Self::IsCategoryOnNonRelation {
1577                field, category, ..
1578            } => {
1579                write!(
1580                    f,
1581                    "CAL-W008: IS {} used on field '{}' — IS CATEGORY is only meaningful on the 'relation' field; this condition was ignored",
1582                    category, field
1583                )
1584            }
1585            Self::AssembleUnscopedSource { labels, .. } => {
1586                write!(
1587                    f,
1588                    "CAL-W009: ASSEMBLE source(s) [{}] have no subject filter while other sources do — results may include data from unrelated subjects",
1589                    labels.join(", ")
1590                )
1591            }
1592            Self::UnrecognizedWhereField { field, .. } => {
1593                write!(
1594                    f,
1595                    "CAL-W010: WHERE field '{}' is not a recognized field on any grain type; it will match only grains that carry it. Check the field name.",
1596                    field
1597                )
1598            }
1599            Self::EachIterationCapped {
1600                rendered,
1601                total,
1602                max,
1603            } => {
1604                write!(
1605                    f,
1606                    "CAL-W011: {{{{#each}}}} rendered {rendered} of {total} grains (§10.8 caps iteration at {max}) — the result set is complete, this rendering is not"
1607                )
1608            }
1609            Self::ContradictionScanBounded { scanned } => {
1610                write!(
1611                    f,
1612                    "CAL-W012: CONTRADICTIONS examined the first {scanned} matching grains (the executor's max_limit) — grains past that were not checked, so this is not a complete all-clear. Narrow the query with WHERE/ABOUT/SINCE to be sure."
1613                )
1614            }
1615            Self::WithOptionInert {
1616                option,
1617                statement,
1618                why,
1619            } => {
1620                write!(
1621                    f,
1622                    "CAL-W014: WITH {option} has no effect on {statement} — {why}. The result is the same as without it."
1623                )
1624            }
1625            Self::ScanBounded { stage, scanned } => {
1626                write!(
1627                    f,
1628                    "CAL-W015: {stage} ran over the first {scanned} matching grains (the executor's max_limit) and that scan came back full — grains past it were never considered, so this is a bounded answer, not the true one. Narrow the query with WHERE/ABOUT/SINCE, or raise max_limit."
1629                )
1630            }
1631            Self::PipelineStageInert {
1632                stage,
1633                payload,
1634                why,
1635            } => {
1636                write!(
1637                    f,
1638                    "CAL-W016: {stage} has no effect on a {payload} result — {why}. The stage was skipped; the result is the same as without it."
1639                )
1640            }
1641        }
1642    }
1643}
1644
1645// ---------------------------------------------------------------------------
1646// Result alias
1647// ---------------------------------------------------------------------------
1648
1649/// Convenience alias used throughout the CAL module.
1650pub type CalResult<T> = std::result::Result<T, CalError>;
1651
1652// ---------------------------------------------------------------------------
1653// Conversion: CalError → AreevError
1654// ---------------------------------------------------------------------------
1655
1656impl From<CalError> for areev_core::error::AreevError {
1657    fn from(e: CalError) -> Self {
1658        areev_core::error::AreevError::Validation(e.diagnostic())
1659    }
1660}
1661
1662// ---------------------------------------------------------------------------
1663// Tests
1664// ---------------------------------------------------------------------------
1665
1666#[cfg(test)]
1667mod tests {
1668    use super::*;
1669
1670    #[test]
1671    fn test_error_codes_match_display() {
1672        let err = CalError::QueryTooLong {
1673            length: 5000,
1674            max: 4096,
1675            span: None,
1676        };
1677        assert!(err.to_string().starts_with("CAL-E001"));
1678        assert_eq!(err.code(), "CAL-E001");
1679    }
1680
1681    #[test]
1682    fn test_invalid_query_is_e092_not_budget() {
1683        // A store validation failure must not masquerade as CAL-E030
1684        // "Budget exceeded" (the mislabel the persona review flagged).
1685        let err = CalError::InvalidQuery {
1686            detail: "VAL-E001: validation error: bad filter".into(),
1687            span: None,
1688        };
1689        assert_eq!(err.code(), "CAL-E092");
1690        assert!(err.to_string().starts_with("CAL-E092"));
1691        // Inner store detail is stripped on the client-facing path (CWE-209).
1692        let sanitized = err.sanitize_for_client();
1693        assert!(sanitized.starts_with("CAL-E092"));
1694        assert!(!sanitized.contains("bad filter"));
1695    }
1696
1697    #[test]
1698    fn test_with_suggestion() {
1699        let err = CalError::UnknownGrainType {
1700            found: "facts".into(),
1701            span: None,
1702            suggestion: None,
1703        };
1704        let err = err.with_suggestion("did you mean \"facts\"?");
1705        assert_eq!(err.suggestion(), Some("did you mean \"facts\"?"));
1706    }
1707
1708    #[test]
1709    fn test_with_span() {
1710        let err = CalError::EmptyQuery { span: None };
1711        assert!(err.span().is_none());
1712        let err = err.with_span(Span::new(0, 5, 1, 1));
1713        assert_eq!(err.span(), Some(Span::new(0, 5, 1, 1)));
1714    }
1715
1716    #[test]
1717    fn test_diagnostic_with_span_and_suggestion() {
1718        let err = CalError::UnknownField {
1719            found: "titel".into(),
1720            span: Some(Span::new(10, 15, 1, 11)),
1721            suggestion: Some("did you mean \"title\"?".into()),
1722        };
1723        let diag = err.diagnostic();
1724        assert!(diag.contains("CAL-E004"));
1725        assert!(diag.contains("at 1:11"));
1726        assert!(diag.contains("hint: did you mean \"title\"?"));
1727    }
1728
1729    /// Follow-up #3: `CalError::sanitize_for_client()` must strip the inner
1730    /// `detail` field for variants that carry inner error strings.
1731    /// These details often come from `AreevError::to_string()` passed through
1732    /// from the executor/assemble/crypto paths — they can leak internal
1733    /// paths, identifiers, and backend error shapes to the client (CWE-209).
1734    #[test]
1735    fn test_sanitize_strips_detail_for_leaky_variants() {
1736        // BudgetExceeded carries inner AreevError text in `detail` on the
1737        // executor error-mapping paths (see src/cal/executor.rs). The
1738        // sanitised form must NOT include that detail.
1739        let leaky_detail = "user_id=alice@example.com /var/lib/areev/db blob 0xABCDEF missing dek";
1740        let err = CalError::BudgetExceeded {
1741            detail: leaky_detail.into(),
1742            span: Some(Span::new(10, 15, 2, 5)),
1743        };
1744        let sanitized = err.sanitize_for_client();
1745        assert!(
1746            sanitized.starts_with("CAL-E030"),
1747            "sanitised error must carry the CAL code, got: {sanitized}"
1748        );
1749        assert!(
1750            !sanitized.contains(leaky_detail),
1751            "sanitised error must NOT contain the inner detail: {sanitized}"
1752        );
1753        assert!(
1754            !sanitized.contains("alice@example.com"),
1755            "sanitised error must NOT contain user identifiers: {sanitized}"
1756        );
1757        assert!(
1758            !sanitized.contains("/var/lib/areev/db"),
1759            "sanitised error must NOT contain internal paths: {sanitized}"
1760        );
1761        // Span may still appear — it is a public input position, not an
1762        // internal identifier.
1763        assert!(
1764            sanitized.contains("2:5"),
1765            "sanitised error should keep the public span: {sanitized}"
1766        );
1767
1768        // The full diagnostic should STILL contain the detail for
1769        // server-side logging — only the client-facing sanitisation strips it.
1770        let diag = err.diagnostic();
1771        assert!(
1772            diag.contains(leaky_detail),
1773            "diagnostic() must preserve the full detail for server logs"
1774        );
1775    }
1776
1777    #[test]
1778    fn test_sanitize_strips_detail_for_all_leaky_variants() {
1779        // All five CalError variants that carry a free-form `detail`.
1780        let variants = [
1781            CalError::BudgetExceeded {
1782                detail: "internal backend=Fjall key=aabbcc".into(),
1783                span: None,
1784            },
1785            CalError::CryptoError {
1786                detail: "DEK 0xDEADBEEF destroyed for user alice".into(),
1787                span: None,
1788            },
1789            CalError::InvalidJsonCal {
1790                detail: "expected field `tok_xyz` at pointer /auth/token".into(),
1791                span: None,
1792            },
1793            CalError::TemplateSyntaxError {
1794                detail: "unclosed {{alice.secret}} at /tmpl/1".into(),
1795                span: None,
1796            },
1797            CalError::InvalidQueryBody {
1798                detail: "grain 0xA1B2 under namespace ns_internal".into(),
1799                span: None,
1800            },
1801        ];
1802        for err in variants {
1803            let sanitized = err.sanitize_for_client();
1804            let code = err.code();
1805            assert!(
1806                sanitized.starts_with(code),
1807                "{code}: sanitised output must start with the code, got: {sanitized}"
1808            );
1809            // Inner detail strings contain tokens like "0x", "alice",
1810            // "DEK", "namespace" — none should leak.
1811            for leaky in ["0xDEADBEEF", "alice", "0xA1B2", "aabbcc", "tok_xyz"] {
1812                assert!(
1813                    !sanitized.contains(leaky),
1814                    "{code}: sanitised must not contain '{leaky}', got: {sanitized}"
1815                );
1816            }
1817        }
1818    }
1819
1820    #[test]
1821    fn test_sanitize_passthrough_for_bounded_variants() {
1822        // Bounded variants (no free-form `detail` field) pass through
1823        // their `diagnostic()` output unchanged: the message is built from
1824        // parser-validated identifiers, numeric limits, and constants that
1825        // the server itself generated — safe to surface to clients.
1826        let err = CalError::UnknownField {
1827            found: "titel".into(),
1828            span: Some(Span::new(10, 15, 1, 11)),
1829            suggestion: Some("did you mean \"title\"?".into()),
1830        };
1831        let sanitized = err.sanitize_for_client();
1832        assert_eq!(sanitized, err.diagnostic());
1833        assert!(sanitized.contains("CAL-E004"));
1834        assert!(sanitized.contains("titel"));
1835        assert!(sanitized.contains("at 1:11"));
1836        assert!(sanitized.contains("hint: did you mean \"title\"?"));
1837    }
1838
1839    #[test]
1840    fn test_warning_codes() {
1841        let w = CalWarning::UnknownRelation {
1842            relation: "foobar".into(),
1843            span: None,
1844        };
1845        assert_eq!(w.code(), "CAL-W001");
1846        assert!(w.to_string().starts_with("CAL-W001"));
1847    }
1848
1849    #[test]
1850    fn test_span_display() {
1851        let span = Span::new(10, 20, 3, 5);
1852        assert_eq!(format!("{}", span), "3:5");
1853    }
1854
1855    #[test]
1856    fn test_into_areev_error() {
1857        let err = CalError::EmptyQuery { span: None };
1858        let areev_err: areev_core::error::AreevError = err.into();
1859        match areev_err {
1860            areev_core::error::AreevError::Validation(msg) => {
1861                assert!(msg.contains("CAL-E014"));
1862            }
1863            other => panic!("expected Validation, got {:?}", other),
1864        }
1865    }
1866
1867    #[test]
1868    fn test_with_suggestion_on_non_suggestion_variant() {
1869        // Calling with_suggestion on a variant without a suggestion field
1870        // should return the error unchanged.
1871        let err = CalError::EmptyQuery { span: None };
1872        let err = err.with_suggestion("this should be ignored");
1873        assert!(err.suggestion().is_none());
1874    }
1875
1876    // -----------------------------------------------------------------------
1877    // Phase 2 error codes: verify code() matches Display prefix
1878    // -----------------------------------------------------------------------
1879
1880    #[test]
1881    fn test_phase2_error_codes_match_display() {
1882        let test_cases: Vec<(CalError, &str)> = vec![
1883            (
1884                CalError::AssembleTooManySources {
1885                    count: 10,
1886                    max: 8,
1887                    span: None,
1888                },
1889                "CAL-E032",
1890            ),
1891            (
1892                CalError::AssembleBudgetExceeded {
1893                    value: 200_000,
1894                    max: 100_000,
1895                    unit: "tokens".into(),
1896                    span: None,
1897                },
1898                "CAL-E033",
1899            ),
1900            (
1901                CalError::AssembleDuplicateLabel {
1902                    label: "src1".into(),
1903                    span: None,
1904                },
1905                "CAL-E034",
1906            ),
1907            (
1908                CalError::AssemblePriorityMismatch {
1909                    label: "src2".into(),
1910                    span: None,
1911                },
1912                "CAL-E035",
1913            ),
1914            (
1915                CalError::TooManyLetBindings {
1916                    count: 6,
1917                    max: 5,
1918                    span: None,
1919                },
1920                "CAL-E036",
1921            ),
1922            (
1923                CalError::LetCircularReference {
1924                    name: "x".into(),
1925                    span: None,
1926                },
1927                "CAL-E037",
1928            ),
1929            (
1930                CalError::LetDepthExceeded {
1931                    depth: 4,
1932                    max: 3,
1933                    span: None,
1934                },
1935                "CAL-E038",
1936            ),
1937            (
1938                CalError::CoalesceTooManyBranches {
1939                    count: 6,
1940                    max: 5,
1941                    span: None,
1942                },
1943                "CAL-E039",
1944            ),
1945            (
1946                // InvalidJsonCal lives at CAL-E120; CAL-E070 is InvalidUtf8.
1947                CalError::InvalidJsonCal {
1948                    detail: "bad json".into(),
1949                    span: None,
1950                },
1951                "CAL-E120",
1952            ),
1953            (
1954                CalError::NotAuthorized {
1955                    detail: "AUT-E001: principal agent:bot lacks write on namespace \"caller\"".into(),
1956                    span: None,
1957                },
1958                "CAL-E121",
1959            ),
1960            (
1961                CalError::AssembleTimeout {
1962                    elapsed_ms: 6000,
1963                    limit_ms: 5000,
1964                    span: None,
1965                },
1966                "CAL-E071",
1967            ),
1968            (CalError::MissingAccumulateOps { span: None }, "CAL-E080"),
1969            (
1970                CalError::AccumulateNonNumericField {
1971                    field: "alpha".into(),
1972                    current: "str".into(),
1973                    span: None,
1974                },
1975                "CAL-E081",
1976            ),
1977            (
1978                CalError::AccumulateTipNotFound {
1979                    subject: "x".into(),
1980                    relation: "y".into(),
1981                    span: None,
1982                },
1983                "CAL-E082",
1984            ),
1985        ];
1986        for (err, expected_code) in test_cases {
1987            assert_eq!(
1988                err.code(),
1989                expected_code,
1990                "code() mismatch for error: {}",
1991                err
1992            );
1993            assert!(
1994                err.to_string().starts_with(expected_code),
1995                "Display output should start with {}, got: {}",
1996                expected_code,
1997                err
1998            );
1999        }
2000    }
2001
2002    #[test]
2003    fn test_all_error_codes_have_unique_codes() {
2004        // Ensure no two error variants accidentally share the same code string.
2005        let errors: Vec<CalError> = vec![
2006            CalError::QueryTooLong {
2007                length: 0,
2008                max: 0,
2009                span: None,
2010            },
2011            CalError::UnexpectedToken {
2012                expected: "".into(),
2013                found: "".into(),
2014                span: None,
2015                suggestion: None,
2016            },
2017            CalError::UnknownGrainType {
2018                found: "".into(),
2019                span: None,
2020                suggestion: None,
2021            },
2022            CalError::UnknownField {
2023                found: "".into(),
2024                span: None,
2025                suggestion: None,
2026            },
2027            CalError::UnterminatedString { span: None },
2028            CalError::InvalidNumber {
2029                found: "".into(),
2030                span: None,
2031            },
2032            CalError::NestingTooDeep {
2033                depth: 0,
2034                max: 0,
2035                span: None,
2036            },
2037            CalError::UnboundParameter {
2038                name: "".into(),
2039                span: None,
2040            },
2041            CalError::DuplicateParameter {
2042                name: "".into(),
2043                span: None,
2044            },
2045            CalError::LimitExceeded {
2046                value: 0,
2047                max: 0,
2048                span: None,
2049            },
2050            CalError::InSetTooLarge {
2051                count: 0,
2052                max: 0,
2053                span: None,
2054            },
2055            CalError::TooManyPipelineStages {
2056                count: 0,
2057                max: 0,
2058                span: None,
2059            },
2060            CalError::TooManySetOperands {
2061                count: 0,
2062                max: 0,
2063                span: None,
2064            },
2065            CalError::EmptyQuery { span: None },
2066            CalError::InvalidHash {
2067                found: "".into(),
2068                span: None,
2069            },
2070            CalError::ReasonTooLong {
2071                length: 0,
2072                max: 0,
2073                span: None,
2074            },
2075            CalError::UnknownEvolveField {
2076                found: "".into(),
2077                span: None,
2078                suggestion: None,
2079            },
2080            CalError::MissingReason { span: None },
2081            CalError::MissingSetClause { span: None },
2082            CalError::IncompatibleTypes {
2083                left: "".into(),
2084                right: "".into(),
2085                span: None,
2086                suggestion: None,
2087            },
2088            CalError::PipelineTypeMismatch {
2089                stage: "".into(),
2090                expected: "".into(),
2091                found: "".into(),
2092                span: None,
2093            },
2094            CalError::ExtractorRequiresFacts {
2095                extractor: "".into(),
2096                found: "".into(),
2097                span: None,
2098            },
2099            CalError::BudgetExceeded {
2100                detail: "".into(),
2101                span: None,
2102            },
2103            CalError::QueryTimeout {
2104                elapsed_ms: 0,
2105                limit_ms: 0,
2106                span: None,
2107            },
2108            CalError::InvalidQuery {
2109                detail: "".into(),
2110                span: None,
2111            },
2112            CalError::FieldNotOnGrainType {
2113                field: "".into(),
2114                grain_type: "".into(),
2115                span: None,
2116                suggestion: None,
2117            },
2118            CalError::AssembleTooManySources {
2119                count: 0,
2120                max: 0,
2121                span: None,
2122            },
2123            CalError::AssembleBudgetExceeded {
2124                value: 0,
2125                max: 0,
2126                unit: "".into(),
2127                span: None,
2128            },
2129            CalError::AssembleDuplicateLabel {
2130                label: "".into(),
2131                span: None,
2132            },
2133            CalError::AssemblePriorityMismatch {
2134                label: "".into(),
2135                span: None,
2136            },
2137            CalError::TooManyLetBindings {
2138                count: 0,
2139                max: 0,
2140                span: None,
2141            },
2142            CalError::LetCircularReference {
2143                name: "".into(),
2144                span: None,
2145            },
2146            CalError::LetDepthExceeded {
2147                depth: 0,
2148                max: 0,
2149                span: None,
2150            },
2151            CalError::CoalesceTooManyBranches {
2152                count: 0,
2153                max: 0,
2154                span: None,
2155            },
2156            CalError::AssembleTimeout {
2157                elapsed_ms: 0,
2158                limit_ms: 0,
2159                span: None,
2160            },
2161            CalError::InvalidJsonCal {
2162                detail: "".into(),
2163                span: None,
2164            },
2165            CalError::TemplateTooLarge {
2166                size: 0,
2167                max: 0,
2168                span: None,
2169            },
2170            CalError::TemplateNestedEach { span: None },
2171            CalError::TemplateUnknownVariable {
2172                name: "".into(),
2173                span: None,
2174                suggestion: None,
2175            },
2176            CalError::TemplateUnknownFilter {
2177                name: "".into(),
2178                span: None,
2179            },
2180            CalError::TemplateInvalidName {
2181                name: "".into(),
2182                span: None,
2183            },
2184            CalError::TemplateNotFound {
2185                name: "".into(),
2186                span: None,
2187            },
2188            CalError::TemplateBuiltinImmutable {
2189                name: "".into(),
2190                span: None,
2191            },
2192            CalError::TemplateParentNotFound {
2193                name: "".into(),
2194                parent: "".into(),
2195                span: None,
2196            },
2197            CalError::TemplateInheritanceDepth {
2198                name: "".into(),
2199                span: None,
2200            },
2201            CalError::TemplateSyntaxError {
2202                detail: "".into(),
2203                span: None,
2204            },
2205            CalError::RenderOutputTooLarge {
2206                size: 0,
2207                max: 0,
2208                span: None,
2209            },
2210            CalError::UnsupportedVersion {
2211                version: 0,
2212                span: None,
2213            },
2214            CalError::TooManyFormats {
2215                count: 0,
2216                max: 0,
2217                span: None,
2218            },
2219            CalError::TooManyUserVars {
2220                count: 0,
2221                max: 0,
2222                span: None,
2223            },
2224            CalError::UserVarTooLarge {
2225                key: "".into(),
2226                size: 0,
2227                max: 0,
2228                span: None,
2229            },
2230            CalError::DuplicateFormatKey {
2231                key: "".into(),
2232                span: None,
2233            },
2234            CalError::MissingAccumulateOps { span: None },
2235            CalError::AccumulateNonNumericField {
2236                field: "".into(),
2237                current: "".into(),
2238                span: None,
2239            },
2240            CalError::AccumulateTipNotFound {
2241                subject: "".into(),
2242                relation: "".into(),
2243                span: None,
2244            },
2245        ];
2246        let mut codes = std::collections::HashSet::new();
2247        for err in &errors {
2248            let code = err.code();
2249            assert!(
2250                codes.insert(code),
2251                "Duplicate error code found: {} (shared between multiple variants)",
2252                code
2253            );
2254        }
2255        // 50 total CalError variants (27 Phase 1 + 9 Phase 2 + 11 Phase 4 + 1 multi-format + 2 user vars)
2256        assert_eq!(
2257            codes.len(),
2258            errors.len(),
2259            "all error variants should have unique codes"
2260        );
2261    }
2262
2263    #[test]
2264    fn test_phase2_with_span_preserves_fields() {
2265        let span = Span::new(10, 20, 1, 11);
2266        let err = CalError::TooManyLetBindings {
2267            count: 6,
2268            max: 5,
2269            span: None,
2270        };
2271        let err = err.with_span(span);
2272        assert_eq!(err.span(), Some(span));
2273        // Verify count and max are preserved through with_span
2274        match err {
2275            CalError::TooManyLetBindings { count, max, .. } => {
2276                assert_eq!(count, 6);
2277                assert_eq!(max, 5);
2278            }
2279            _ => panic!("wrong variant after with_span"),
2280        }
2281    }
2282}