Skip to main content

areev_cal/
parser.rs

1//! CAL recursive-descent parser.
2//!
3//! Transforms a flat `Vec<SpannedToken>` (from [`super::lexer::Lexer`]) into a
4//! typed [`super::ast::CalQuery`] AST.
5//!
6//! # Design principles
7//!
8//! - **All 12 statement types are parsed** even though Phase 1 only executes
9//!   `RECALL` and `EXISTS`.  This ensures that unimplemented-but-valid CAL
10//!   queries produce a useful "not yet supported" message instead of a cryptic
11//!   parse error.
12//! - **Resource limits** are checked during parsing so that pathological
13//!   queries are rejected before any engine work is done.
14//! - **Destructive keyword guard**: any identifier that matches a blocked word
15//!   (see [`super::lexer::is_destructive_keyword`]) is rejected immediately
16//!   with a clear diagnostic.
17//! - **Error messages** always include a [`Span`] and, where possible, a
18//!   `suggestion` to help the user correct the query.
19//!
20//! # Entry points
21//!
22//! ```text
23//! let query = parse("RECALL facts WHERE subject = \"john\"")?;
24//! let query = parse_with_params(input, &params)?;
25//! ```
26
27use std::collections::HashMap;
28
29use super::ast::{
30    AboutClause, AccumulateStmt, AccumulateTarget, AddStmt, AddWithOption, AddWorkflowStmt,
31    AliasedFormat, AssembleStmt, AssembleWithOption, BatchEntry, BatchStmt, BetweenClause,
32    BindClause, BudgetSpec, BudgetUnit, CalQuery, CalStatement, CalVersion, CoalesceBranch,
33    CoalesceStmt, Comparator, Condition, ContradictionsClause, DefineTemplateStmt, DeltaOp,
34    DescribeStmt, DescribeTarget, ExistsStmt, ExplainStmt, Extractor, FieldAssignment,
35    FormatClause, FormatSpec, GrainTypePlural, GrainTypeSingular, GraphEdge, HistoryStmt,
36    LetBinding, LikeClause, NamedSource, PipelineStage, PrioritySpec, ProjectField, RecallStmt,
37    RecentClause, RevertStmt, SetOp, SetOpStmt, Source, SupersedeStmt, SupersedeWorkflowStmt,
38    UntilClause, Value, WhereClause, WithOption,
39};
40use super::ast::{
41    DefineQueryStmt, DerivedFromStmt, DropQueryStmt, EntityAtStmt, ForgetStmt, ForgetTarget,
42    GovernanceStmt, GrantStmt, MergeStmt, NoveltyStmt, PurgeStmt, QueryParam, RelatedStmt,
43    RememberStmt, ReportSubjectStmt, RevokeStmt, RunLoopStmt, RunQueryStmt, RunTraceStmt,
44    RunsTouchingStmt, ShowForksStmt, ShowGrantsStmt, TemplateSectionSources,
45};
46use super::errors::{CalError, CalResult, CalWarning, Span};
47use super::lexer::{is_destructive_keyword, Lexer, SectionBody, SpannedToken, Token};
48
49/// The parsed pieces of a GRANT/REVOKE body:
50/// `(verbs, namespaces, principal, reason)`.
51type DclBody = (Vec<String>, Vec<String>, String, Option<String>);
52
53/// Parse a `RUN LOOP WITH if_stale("…")` duration — `"300s"`, `"90m"`,
54/// `"6h"`, `"2d"` — to milliseconds. `None` on anything else.
55fn parse_stale_duration_ms(s: &str) -> Option<i64> {
56    let s = s.trim();
57    if s.len() < 2 {
58        return None;
59    }
60    let (num, unit) = s.split_at(s.len() - 1);
61    let n: f64 = num.parse().ok()?;
62    if n < 0.0 || !n.is_finite() {
63        return None;
64    }
65    let ms = match unit {
66        "s" => n * 1000.0,
67        "m" => n * 60_000.0,
68        "h" => n * 3_600_000.0,
69        "d" => n * 86_400_000.0,
70        _ => return None,
71    };
72    Some(ms as i64)
73}
74
75// ---------------------------------------------------------------------------
76// Hard limits
77// ---------------------------------------------------------------------------
78
79/// Maximum CAL query byte length (prevents memory exhaustion before lexing).
80/// 64 KB accommodates agent system prompts, goal descriptions, and multi-turn
81/// context while still preventing abuse.  Aligned with embedding model context
82/// windows (~16K tokens ≈ 64 KB).
83pub(crate) const MAX_QUERY_LENGTH: usize = 65_536;
84
85/// Maximum nesting depth (parentheses, sub-queries).
86///
87/// Shared with the JSON wire-format pre-validator in `json.rs`. Set-op
88/// chains and pipelines cap independently at 4 / 5.
89pub(crate) const MAX_NESTING_DEPTH: usize = 8;
90
91/// Maximum value for an inline `LIMIT` or `| LIMIT n`.
92const MAX_LIMIT_VALUE: u64 = 1_000;
93
94/// Maximum number of values in an `IN (...)` set.
95const MAX_IN_SET_SIZE: usize = 100;
96
97/// Maximum pipeline stages in a single query.
98const MAX_PIPELINE_STAGES: usize = 5;
99
100/// Maximum operands in a UNION / INTERSECT / EXCEPT chain.
101const MAX_SET_OPERANDS: usize = 4;
102
103/// Maximum statements inside a `BATCH { ... }`.
104const MAX_BATCH_ENTRIES: usize = 10;
105
106/// Maximum byte length for a `REASON "..."` string.
107const MAX_REASON_LENGTH: usize = 500;
108
109// ---------------------------------------------------------------------------
110// Public entry points
111// ---------------------------------------------------------------------------
112
113/// Parse a CAL query string into a typed AST.
114///
115/// Applies the full pipeline: length check → bidi check → NFC normalization →
116/// lexing → recursive-descent parsing → limit validation.
117///
118/// # Errors
119///
120/// Any [`CalError`] variant.  Error messages include source spans and, where
121/// possible, a `suggestion`.
122pub fn parse(input: &str) -> CalResult<CalQuery> {
123    parse_with_params(input, &HashMap::new())
124}
125
126/// Parse a CAL query with pre-bound parameter values.
127///
128/// Parameters in the query (`$name`) that appear in `params` are *not*
129/// validated at parse time (that is the executor's job); this call merely
130/// makes them available for future inline substitution.
131///
132/// All other validation rules (length, bidi, nesting, limits) still apply.
133pub fn parse_with_params(input: &str, _params: &HashMap<String, Value>) -> CalResult<CalQuery> {
134    // Length check first — before any allocation.
135    if input.len() > MAX_QUERY_LENGTH {
136        return Err(CalError::QueryTooLong {
137            length: input.len(),
138            max: MAX_QUERY_LENGTH,
139            span: None,
140        });
141    }
142
143    let trimmed = input.trim();
144    if trimmed.is_empty() {
145        return Err(CalError::EmptyQuery { span: None });
146    }
147
148    // Lex (includes bidi check and NFC normalisation).
149    let tokens = Lexer::tokenize(input)?;
150
151    if tokens.is_empty() {
152        return Err(CalError::EmptyQuery {
153            span: Some(Span::zero()),
154        });
155    }
156
157    let mut parser = Parser::new(tokens, input);
158    parser.parse_query()
159}
160
161/// Rewrite every `$param` reference to a bare literal so a body whose
162/// parameters sit in positions that demand one (`RECENT $limit`) can still be
163/// parsed for SHAPE at DEFINE time. Only ever fed to the parser — never stored,
164/// never executed.
165///
166/// String literals are stepped over: a `"$5.00"` in a WHERE value is data, not
167/// a parameter, and rewriting it could fail a body that is perfectly valid.
168fn params_as_literals(body: &str) -> String {
169    let mut out = String::with_capacity(body.len());
170    let mut chars = body.chars().peekable();
171    let mut quote: Option<char> = None;
172    while let Some(c) = chars.next() {
173        match quote {
174            Some(q) => {
175                out.push(c);
176                if c == '\\' {
177                    // The escaped character cannot close the literal.
178                    if let Some(n) = chars.next() {
179                        out.push(n);
180                    }
181                } else if c == q {
182                    quote = None;
183                }
184            }
185            None if c == '"' || c == '\'' => {
186                quote = Some(c);
187                out.push(c);
188            }
189            None if c == '$' => {
190                while chars.peek().is_some_and(|n| n.is_ascii_alphanumeric() || *n == '_') {
191                    chars.next();
192                }
193                // `1` parses in both a numeric and a value position; the type
194                // mismatch it can imply (CAL-E020) belongs to the executor, so
195                // a shape check never sees it.
196                out.push('1');
197            }
198            None => out.push(c),
199        }
200    }
201    out
202}
203
204/// Recursively check that a statement is read-only (no writes, no RUN) — the
205/// enforcement behind the "saved-query bodies stay read-only" invariant. A free
206/// function so the executor can re-check a parsed RUN body at execution time,
207/// not only the parser at DEFINE time (defense in depth: the DEFINE-time scan
208/// runs a word-level guard BEFORE the parse, so it holds even for a body the
209/// parser has to see through placeholders).
210pub(crate) fn check_read_only_statement(stmt: &CalStatement, span: &Span) -> CalResult<()> {
211    let write = |s: &str| {
212        Err(CalError::WriteInQueryBody {
213            stmt: s.into(),
214            span: Some(*span),
215        })
216    };
217    match stmt {
218        CalStatement::Recall(_)
219        | CalStatement::SetOp(_)
220        | CalStatement::Exists(_)
221        | CalStatement::Assemble(_)
222        | CalStatement::History(_)
223        | CalStatement::Explain(_)
224        | CalStatement::Describe(_)
225        | CalStatement::Coalesce(_) => Ok(()),
226        CalStatement::Batch(batch) => {
227            for entry in &batch.statements {
228                check_read_only_statement(&entry.statement, span)?;
229            }
230            if let Some(labeled) = &batch.labeled {
231                for (_, entry) in labeled {
232                    check_read_only_statement(&entry.statement, span)?;
233                }
234            }
235            Ok(())
236        }
237        CalStatement::Add(_) | CalStatement::AddWorkflow(_) => write("ADD"),
238        CalStatement::Supersede(_) | CalStatement::SupersedeWorkflow(_) => write("SUPERSEDE"),
239        CalStatement::Accumulate(_) => write("ACCUMULATE"),
240        CalStatement::Revert(_) => write("REVERT"),
241        CalStatement::Remember(_) => write("REMEMBER"),
242        CalStatement::EntityAt(_)
243        | CalStatement::RunTrace(_)
244        | CalStatement::RunsTouching(_)
245        | CalStatement::DerivedFrom(_)
246        | CalStatement::ShowForks(_)
247        | CalStatement::Related(_)
248        | CalStatement::Novelty(_)
249        | CalStatement::ReportSubject(_) => Ok(()),
250        CalStatement::Merge(_) => write("MERGE"),
251        CalStatement::Forget(_) => write("FORGET"),
252        CalStatement::Purge(_) => write("PURGE"),
253        // No DCL inside saved-query bodies — a stored GRANT executing with
254        // the invoker's rights would be a confused deputy. SHOW GRANTS is a
255        // read and fine.
256        CalStatement::Grant(_) => write("GRANT"),
257        CalStatement::Revoke(_) => write("REVOKE"),
258        CalStatement::ShowGrants(_) => Ok(()),
259        // No governance in stored bodies either — the deliberate friction
260        // of one-statement-at-a-time review must not be macro-able.
261        CalStatement::Approve(_) => write("APPROVE"),
262        CalStatement::Reject(_) => write("REJECT"),
263        CalStatement::ApplyRec(_) => write("APPLY"),
264        CalStatement::RollbackRec(_) => write("ROLLBACK"),
265        CalStatement::RunLoop(_) => write("RUN LOOP"),
266        CalStatement::DefineTemplate(_) => write("DEFINE TEMPLATE"),
267        CalStatement::DropTemplate(_) => write("DROP TEMPLATE"),
268        CalStatement::DefineQuery(_) => write("DEFINE QUERY"),
269        CalStatement::DropQuery(_) => write("DROP QUERY"),
270        CalStatement::RunQuery(_) => Err(CalError::RecursiveQuery { span: Some(*span) }),
271    }
272}
273
274// ---------------------------------------------------------------------------
275// Parser
276// ---------------------------------------------------------------------------
277
278/// Internal recursive-descent parser state.
279struct Parser {
280    tokens: Vec<SpannedToken>,
281    pos: usize,
282    warnings: Vec<CalWarning>,
283    nesting_depth: usize,
284    /// Original input text — used by `reconstruct_body_text` for DEFINE QUERY bodies.
285    input: String,
286}
287
288impl Parser {
289    fn new(tokens: Vec<SpannedToken>, input: &str) -> Self {
290        Self {
291            tokens,
292            pos: 0,
293            warnings: vec![],
294            nesting_depth: 0,
295            input: input.to_string(),
296        }
297    }
298
299    // -- Cursor helpers ----------------------------------------------------
300
301    /// Peek at the current token without consuming it.
302    fn peek(&self) -> Option<&SpannedToken> {
303        self.tokens.get(self.pos)
304    }
305
306    /// Peek at the token `offset` positions ahead (0 = current).
307    fn peek_ahead(&self, offset: usize) -> Option<&SpannedToken> {
308        self.tokens.get(self.pos + offset)
309    }
310
311    /// Consume and return the current token.
312    fn advance(&mut self) -> Option<&SpannedToken> {
313        if self.pos < self.tokens.len() {
314            let tok = &self.tokens[self.pos];
315            self.pos += 1;
316            Some(tok)
317        } else {
318            None
319        }
320    }
321
322    fn at_end(&self) -> bool {
323        self.pos >= self.tokens.len()
324    }
325
326    /// Return the span of the current token (or zero if at end).
327    fn current_span(&self) -> Span {
328        self.peek().map(|st| st.span).unwrap_or_else(Span::zero)
329    }
330
331    /// Return the span of the previous token (or zero if at start).
332    fn prev_span(&self) -> Span {
333        if self.pos == 0 {
334            Span::zero()
335        } else {
336            self.tokens
337                .get(self.pos - 1)
338                .map(|st| st.span)
339                .unwrap_or_else(Span::zero)
340        }
341    }
342
343    /// Consume the current token if it matches `token` (by discriminant).
344    /// Returns `true` if consumed.
345    #[allow(dead_code)] // Reserved for Phase 3 parser features.
346    fn eat_if_token(&mut self, token: &Token) -> bool {
347        if let Some(st) = self.peek() {
348            if std::mem::discriminant(&st.token) == std::mem::discriminant(token) {
349                self.advance();
350                return true;
351            }
352        }
353        false
354    }
355
356    /// Consume the current token if it is exactly `token` (value equality).
357    fn eat_exact(&mut self, token: &Token) -> bool {
358        if let Some(st) = self.peek() {
359            if &st.token == token {
360                self.advance();
361                return true;
362            }
363        }
364        false
365    }
366
367    /// Require the next token to be exactly `expected` (value equality);
368    /// consume and return it, or return an error.
369    fn expect_exact(&mut self, expected: &Token) -> CalResult<SpannedToken> {
370        match self.peek() {
371            Some(st) if &st.token == expected => {
372                let st = st.clone();
373                self.advance();
374                Ok(st)
375            }
376            Some(st) => {
377                let found = st.token.description();
378                let span = st.span;
379                Err(CalError::UnexpectedToken {
380                    expected: expected.description(),
381                    found,
382                    span: Some(span),
383                    suggestion: None,
384                })
385            }
386            None => Err(CalError::UnexpectedToken {
387                expected: expected.description(),
388                found: "<end of query>".into(),
389                span: None,
390                suggestion: None,
391            }),
392        }
393    }
394
395    /// Require the next token to match `expected` by discriminant.
396    #[allow(dead_code)] // Reserved for Phase 3 parser features.
397    fn expect_token(&mut self, expected: &Token) -> CalResult<SpannedToken> {
398        match self.peek() {
399            Some(st) if std::mem::discriminant(&st.token) == std::mem::discriminant(expected) => {
400                let st = st.clone();
401                self.advance();
402                Ok(st)
403            }
404            Some(st) => {
405                let found = st.token.description();
406                let span = st.span;
407                Err(CalError::UnexpectedToken {
408                    expected: expected.description(),
409                    found,
410                    span: Some(span),
411                    suggestion: None,
412                })
413            }
414            None => Err(CalError::UnexpectedToken {
415                expected: expected.description(),
416                found: "<end of query>".into(),
417                span: None,
418                suggestion: None,
419            }),
420        }
421    }
422
423    /// Check whether the current token matches `expected` by discriminant
424    /// (without consuming).
425    fn at(&self, expected: &Token) -> bool {
426        match self.peek() {
427            Some(st) => std::mem::discriminant(&st.token) == std::mem::discriminant(expected),
428            None => false,
429        }
430    }
431
432    /// Check whether the current token is exactly `expected` (value equality).
433    fn at_exact(&self, expected: &Token) -> bool {
434        self.peek().map(|st| &st.token == expected).unwrap_or(false)
435    }
436
437    // -- Nesting depth guard ----------------------------------------------
438
439    fn enter_nesting(&mut self) -> CalResult<()> {
440        self.nesting_depth += 1;
441        if self.nesting_depth > MAX_NESTING_DEPTH {
442            return Err(CalError::NestingTooDeep {
443                depth: self.nesting_depth,
444                max: MAX_NESTING_DEPTH,
445                span: Some(self.current_span()),
446            });
447        }
448        Ok(())
449    }
450
451    fn leave_nesting(&mut self) {
452        self.nesting_depth = self.nesting_depth.saturating_sub(1);
453    }
454
455    // -- Literal helpers --------------------------------------------------
456
457    fn parse_string_literal(&mut self) -> CalResult<String> {
458        match self.peek() {
459            Some(SpannedToken {
460                token: Token::StringLiteral(_),
461                ..
462            }) => {
463                let st = self.advance().unwrap();
464                if let Token::StringLiteral(s) = &st.token {
465                    Ok(s.clone())
466                } else {
467                    unreachable!()
468                }
469            }
470            Some(st) => {
471                let found = st.token.description();
472                let span = st.span;
473                Err(CalError::UnexpectedToken {
474                    expected: "string literal".into(),
475                    found,
476                    span: Some(span),
477                    suggestion: Some("string values must be enclosed in double quotes".into()),
478                })
479            }
480            None => Err(CalError::UnexpectedToken {
481                expected: "string literal".into(),
482                found: "<end of query>".into(),
483                span: None,
484                suggestion: None,
485            }),
486        }
487    }
488
489    fn parse_number(&mut self) -> CalResult<f64> {
490        match self.peek() {
491            Some(SpannedToken {
492                token: Token::NumberLiteral(_),
493                ..
494            }) => {
495                let st = self.advance().unwrap();
496                if let Token::NumberLiteral(n) = &st.token {
497                    Ok(*n)
498                } else {
499                    unreachable!()
500                }
501            }
502            Some(st) => {
503                let found = st.token.description();
504                let span = st.span;
505                Err(CalError::UnexpectedToken {
506                    expected: "number literal".into(),
507                    found,
508                    span: Some(span),
509                    suggestion: None,
510                })
511            }
512            None => Err(CalError::UnexpectedToken {
513                expected: "number literal".into(),
514                found: "<end of query>".into(),
515                span: None,
516                suggestion: None,
517            }),
518        }
519    }
520
521    fn parse_u64(&mut self) -> CalResult<u64> {
522        let span = self.current_span();
523        let n = self.parse_number()?;
524        if n < 0.0 || n.fract() != 0.0 {
525            return Err(CalError::InvalidNumber {
526                found: n.to_string(),
527                span: Some(span),
528            });
529        }
530        Ok(n as u64)
531    }
532
533    fn parse_hash_literal(&mut self) -> CalResult<String> {
534        match self.peek() {
535            Some(SpannedToken {
536                token: Token::HashLiteral(_),
537                ..
538            }) => {
539                let st = self.advance().unwrap();
540                if let Token::HashLiteral(h) = &st.token {
541                    Ok(h.clone())
542                } else {
543                    unreachable!()
544                }
545            }
546            Some(st) => {
547                let found = st.token.description();
548                let span = st.span;
549                Err(CalError::UnexpectedToken {
550                    expected: "hash literal (sha256:...)".into(),
551                    found,
552                    span: Some(span),
553                    suggestion: Some("hash literals use the format sha256:<hex digits>".into()),
554                })
555            }
556            None => Err(CalError::UnexpectedToken {
557                expected: "hash literal (sha256:...)".into(),
558                found: "<end of query>".into(),
559                span: None,
560                suggestion: None,
561            }),
562        }
563    }
564
565    /// Parse a `$parameter` token and return its name.
566    fn parse_parameter(&mut self) -> CalResult<String> {
567        match self.peek() {
568            Some(SpannedToken {
569                token: Token::Parameter(_),
570                ..
571            }) => {
572                let st = self.advance().unwrap();
573                if let Token::Parameter(p) = &st.token {
574                    Ok(p.clone())
575                } else {
576                    unreachable!()
577                }
578            }
579            Some(st) => {
580                let found = st.token.description();
581                let span = st.span;
582                Err(CalError::UnexpectedToken {
583                    expected: "$parameter".into(),
584                    found,
585                    span: Some(span),
586                    suggestion: None,
587                })
588            }
589            None => Err(CalError::UnexpectedToken {
590                expected: "$parameter".into(),
591                found: "<end of query>".into(),
592                span: None,
593                suggestion: None,
594            }),
595        }
596    }
597
598    /// Parse any single scalar value (string, number, bool, hash, parameter).
599    fn parse_value(&mut self) -> CalResult<Value> {
600        match self.peek() {
601            Some(SpannedToken {
602                token: Token::StringLiteral(_),
603                ..
604            }) => Ok(Value::String {
605                value: self.parse_string_literal()?,
606            }),
607            Some(SpannedToken {
608                token: Token::NumberLiteral(_),
609                ..
610            }) => Ok(Value::Number {
611                value: self.parse_number()?,
612            }),
613            Some(SpannedToken {
614                token: Token::True, ..
615            }) => {
616                self.advance();
617                Ok(Value::Boolean { value: true })
618            }
619            Some(SpannedToken {
620                token: Token::False,
621                ..
622            }) => {
623                self.advance();
624                Ok(Value::Boolean { value: false })
625            }
626            Some(SpannedToken {
627                token: Token::HashLiteral(_),
628                ..
629            }) => Ok(Value::Hash {
630                value: self.parse_hash_literal()?,
631            }),
632            Some(SpannedToken {
633                token: Token::Parameter(_),
634                ..
635            }) => Ok(Value::Parameter {
636                name: self.parse_parameter()?,
637            }),
638            // Array literal: [ v, v, ... ]
639            Some(SpannedToken {
640                token: Token::LBracket,
641                ..
642            }) => {
643                self.advance(); // consume [
644                self.enter_nesting()?;
645                let mut values = vec![];
646                while !self.at_exact(&Token::RBracket) && !self.at_end() {
647                    values.push(self.parse_value()?);
648                    if !self.eat_exact(&Token::Comma) {
649                        break;
650                    }
651                }
652                self.leave_nesting();
653                self.expect_exact(&Token::RBracket)?;
654                Ok(Value::Array { values })
655            }
656            Some(st) => {
657                let found = st.token.description();
658                let span = st.span;
659                Err(CalError::UnexpectedToken {
660                    expected: "a value (string, number, boolean, hash, $parameter, or [array])"
661                        .into(),
662                    found,
663                    span: Some(span),
664                    suggestion: None,
665                })
666            }
667            None => Err(CalError::UnexpectedToken {
668                expected: "a value".into(),
669                found: "<end of query>".into(),
670                span: None,
671                suggestion: None,
672            }),
673        }
674    }
675
676    /// Parse a comma-separated list of values enclosed in `(` ... `)`.
677    fn parse_value_list(&mut self) -> CalResult<Vec<Value>> {
678        self.expect_exact(&Token::LParen)?;
679        self.enter_nesting()?;
680        let mut values = vec![];
681        while !self.at_exact(&Token::RParen) && !self.at_end() {
682            // Also accept $param as a shorthand for a whole parameter list.
683            if self.at(&Token::Parameter("".into())) {
684                // A single bare $param stands for an entire list.
685                let name = self.parse_parameter()?;
686                values.push(Value::Parameter { name });
687            } else {
688                values.push(self.parse_value()?);
689            }
690            if !self.eat_exact(&Token::Comma) {
691                break;
692            }
693        }
694        self.leave_nesting();
695        self.expect_exact(&Token::RParen)?;
696        // OMS §4 `value_list = value , { "," , value }` requires ≥1 element.
697        if values.is_empty() {
698            return Err(CalError::UnexpectedToken {
699                expected: "at least one value inside IN ( ... )".into(),
700                found: "empty IN list".into(),
701                span: Some(self.prev_span()),
702                suggestion: Some("IN requires one or more values.".into()),
703            });
704        }
705        if values.len() > MAX_IN_SET_SIZE {
706            return Err(CalError::InSetTooLarge {
707                count: values.len(),
708                max: MAX_IN_SET_SIZE,
709                span: Some(self.prev_span()),
710            });
711        }
712        Ok(values)
713    }
714
715    /// Parse an unquoted identifier (Ident token).
716    ///
717    /// Also accepts workflow-graph keywords (`ON`, `WHEN`, `BIND`) as
718    /// identifiers so they remain usable as field names in non-workflow
719    /// contexts (e.g. `WHERE on = "value"`).
720    fn parse_identifier(&mut self) -> CalResult<String> {
721        match self.peek() {
722            Some(SpannedToken {
723                token: Token::Ident(_),
724                ..
725            }) => {
726                let st = self.advance().unwrap();
727                if let Token::Ident(s) = &st.token {
728                    // Destructive keyword guard.
729                    if is_destructive_keyword(s) {
730                        return Err(CalError::UnexpectedToken {
731                            expected: "a field name or grain type".into(),
732                            found: s.clone(),
733                            span: Some(self.prev_span()),
734                            suggestion: Some(
735                                "CAL cannot destroy data in bulk. The only \
736                                 destructive CAL statement is FORGET <hash>; \
737                                 erasure, key rotation, and schema changes are \
738                                 host-level operations."
739                                    .into(),
740                            ),
741                        });
742                    }
743                    Ok(s.clone())
744                } else {
745                    unreachable!()
746                }
747            }
748            // Keywords that are also valid field names in WHERE/SELECT
749            // context (e.g. `WHERE on = "value"`, `WHERE priority = "high"`,
750            // `SELECT scope`).  Without this arm, the lexer's keyword
751            // matching would make these unusable as field names.
752            Some(SpannedToken {
753                token: Token::On | Token::When | Token::Bind | Token::Priority | Token::Scope,
754                ..
755            }) => {
756                let st = self.advance().unwrap();
757                Ok(st.token.description().to_ascii_lowercase())
758            }
759            Some(st) => {
760                let found = st.token.description();
761                let span = st.span;
762                Err(CalError::UnexpectedToken {
763                    expected: "identifier".into(),
764                    found,
765                    span: Some(span),
766                    suggestion: None,
767                })
768            }
769            None => Err(CalError::UnexpectedToken {
770                expected: "identifier".into(),
771                found: "<end of query>".into(),
772                span: None,
773                suggestion: None,
774            }),
775        }
776    }
777
778    /// Parse a label name — like `parse_identifier` but also accepts keyword
779    /// tokens.  Used in contexts where a keyword can serve as a user-chosen
780    /// name (e.g. multi-source ASSEMBLE labels, PRIORITY labels).
781    fn parse_label(&mut self) -> CalResult<String> {
782        match self.peek() {
783            Some(SpannedToken {
784                token: Token::Ident(_),
785                ..
786            }) => {
787                let st = self.advance().unwrap();
788                if let Token::Ident(s) = &st.token {
789                    Ok(s.clone())
790                } else {
791                    unreachable!()
792                }
793            }
794            // Accept any keyword token as a label.
795            Some(st) if Self::is_word_token(&st.token) => {
796                let label = st.token.description().to_ascii_lowercase();
797                self.advance();
798                Ok(label)
799            }
800            Some(st) => {
801                let found = st.token.description();
802                let span = st.span;
803                Err(CalError::UnexpectedToken {
804                    expected: "label name".into(),
805                    found,
806                    span: Some(span),
807                    suggestion: None,
808                })
809            }
810            None => Err(CalError::UnexpectedToken {
811                expected: "label name".into(),
812                found: "<end of query>".into(),
813                span: None,
814                suggestion: None,
815            }),
816        }
817    }
818
819    /// Returns true if the token is a word-like keyword that could serve as
820    /// a label in multi-source ASSEMBLE or PRIORITY clauses.
821    fn is_word_token(token: &Token) -> bool {
822        !matches!(
823            token,
824            Token::Eq
825                | Token::NotEq
826                | Token::Gte
827                | Token::Lte
828                | Token::Gt
829                | Token::Lt
830                | Token::LParen
831                | Token::RParen
832                | Token::LBracket
833                | Token::RBracket
834                | Token::LBrace
835                | Token::RBrace
836                | Token::Pipe
837                | Token::Comma
838                | Token::Colon
839                | Token::Dot
840                | Token::Slash
841                | Token::NumberLiteral(_)
842                | Token::StringLiteral(_)
843                | Token::HashLiteral(_)
844                | Token::Parameter(_)
845        )
846    }
847
848    /// Parse a plural grain type name from an `Ident` token, if present.
849    ///
850    /// Returns `None` (without consuming) if the current token is not a
851    /// plural grain-type word.
852    fn parse_grain_type_plural_opt(&mut self) -> CalResult<Option<GrainTypePlural>> {
853        // Check if the current Ident looks like a grain type.
854        if let Some(SpannedToken {
855            token: Token::Ident(s),
856            span,
857            ..
858        }) = self.peek()
859        {
860            let s = s.clone();
861            let span = *span;
862            if let Some(gt) = GrainTypePlural::parse(&s) {
863                self.advance();
864                return Ok(Some(gt));
865            }
866            // Check for the common mistake of using singular or old OMS 1.1 names.
867            let suggestion = suggest_grain_type_plural(&s);
868            // Only error if this looks like it should be a grain type.
869            if suggestion.is_some() {
870                return Err(CalError::UnknownGrainType {
871                    found: s,
872                    span: Some(span),
873                    suggestion,
874                });
875            }
876        }
877        // "*" wildcard.
878        if self.at_exact(&Token::All) {
879            self.advance();
880            return Ok(Some(GrainTypePlural::All));
881        }
882        Ok(None)
883    }
884
885    /// Parse a required plural grain type name.
886    fn parse_grain_type_plural(&mut self) -> CalResult<GrainTypePlural> {
887        let span = self.current_span();
888        // Check for bare identifier.
889        match self.peek() {
890            Some(SpannedToken {
891                token: Token::Ident(s),
892                ..
893            }) => {
894                let s = s.clone();
895                // Reject singular forms — RECALL position requires plural per spec EBNF.
896                let lower = s.to_ascii_lowercase();
897                if matches!(
898                    lower.as_str(),
899                    "fact"
900                        | "event"
901                        | "state"
902                        | "workflow"
903                        | "tool"
904                        | "observation"
905                        | "goal"
906                        | "reasoning"
907                        | "consensus"
908                        | "consent"
909                ) {
910                    let plural = match lower.as_str() {
911                        "fact" => "facts",
912                        "event" => "events",
913                        "state" => "states",
914                        "workflow" => "workflows",
915                        "tool" => "tools",
916                        "observation" => "observations",
917                        "goal" => "goals",
918                        "reasoning" => "reasonings",
919                        "consensus" => "consensuses",
920                        "consent" => "consents",
921                        _ => unreachable!(),
922                    };
923                    return Err(CalError::UnknownGrainType {
924                        found: s,
925                        span: Some(span),
926                        suggestion: Some(format!(
927                            "did you mean \"{}\"? RECALL requires the plural form.",
928                            plural
929                        )),
930                    });
931                }
932                if let Some(gt) = GrainTypePlural::parse(&s) {
933                    self.advance();
934                    return Ok(gt);
935                }
936                let suggestion = suggest_grain_type_plural(&s);
937                Err(CalError::UnknownGrainType {
938                    found: s,
939                    span: Some(span),
940                    suggestion,
941                })
942            }
943            Some(SpannedToken {
944                token: Token::All, ..
945            }) => {
946                self.advance();
947                Ok(GrainTypePlural::All)
948            }
949            Some(st) => {
950                let found = st.token.description();
951                Err(CalError::UnexpectedToken {
952                    expected: "grain type (facts, events, states, ...)".into(),
953                    found,
954                    span: Some(span),
955                    suggestion: Some(
956                        "valid grain types: facts, events, states, workflows, tools, \
957                         observations, goals, reasonings, consensuses, consents, skills, *"
958                            .into(),
959                    ),
960                })
961            }
962            None => Err(CalError::UnexpectedToken {
963                expected: "grain type".into(),
964                found: "<end of query>".into(),
965                span: None,
966                suggestion: None,
967            }),
968        }
969    }
970
971    /// Parse a required singular grain type name (used in ADD).
972    fn parse_grain_type_singular(&mut self) -> CalResult<GrainTypeSingular> {
973        let span = self.current_span();
974        match self.peek() {
975            Some(SpannedToken {
976                token: Token::Ident(s),
977                ..
978            }) => {
979                let s = s.clone();
980                if let Some(gt) = GrainTypeSingular::parse(&s) {
981                    self.advance();
982                    return Ok(gt);
983                }
984                let suggestion = suggest_grain_type_singular(&s);
985                Err(CalError::UnknownGrainType {
986                    found: s,
987                    span: Some(span),
988                    suggestion,
989                })
990            }
991            // "observation" is a keyword token (relation-category), so the
992            // lexer emits Token::Observation instead of Token::Ident.  Handle
993            // it explicitly so `ADD observation ...` works correctly.
994            Some(SpannedToken {
995                token: Token::Observation,
996                ..
997            }) => {
998                self.advance();
999                Ok(GrainTypeSingular::Observation)
1000            }
1001            Some(st) => {
1002                let found = st.token.description();
1003                Err(CalError::UnexpectedToken {
1004                    expected: "grain type (fact, event, state, ...)".into(),
1005                    found,
1006                    span: Some(span),
1007                    suggestion: None,
1008                })
1009            }
1010            None => Err(CalError::UnexpectedToken {
1011                expected: "grain type".into(),
1012                found: "<end of query>".into(),
1013                span: None,
1014                suggestion: None,
1015            }),
1016        }
1017    }
1018
1019    // -- Top-level parse --------------------------------------------------
1020
1021    /// Parse a complete `CalQuery`.
1022    ///
1023    /// Grammar:
1024    /// ```text
1025    /// query = [version_prefix] [let_block] statement [pipeline] [with_clause] [format_clause]
1026    /// ```
1027    fn parse_query(&mut self) -> CalResult<CalQuery> {
1028        // Optional version prefix: `CAL/1`.
1029        let version = self.parse_version_prefix()?;
1030
1031        // Optional LET bindings.
1032        let let_bindings = self.parse_let_block()?;
1033
1034        // Core statement.
1035        let (statement, inline_pipeline, inline_with, inline_format, user_vars) =
1036            self.parse_statement_full()?;
1037
1038        // A query is exactly one statement and must consume ALL input.
1039        // Trailing semicolons are tolerated (SQL habit), but any other
1040        // trailing token is an error — silently dropping it would partially
1041        // execute what the caller sent (e.g. `RECALL ...; <second stmt>`).
1042        while self.at_exact(&Token::Semicolon) {
1043            self.advance();
1044        }
1045        if let Some(st) = self.peek() {
1046            let found = st.token.description();
1047            let span = st.span;
1048            return Err(CalError::UnexpectedToken {
1049                expected: "end of query".into(),
1050                found,
1051                span: Some(span),
1052                suggestion: Some(
1053                    "a query is one statement; use BATCH { stmt1 ; stmt2 } to run several".into(),
1054                ),
1055            });
1056        }
1057
1058        Ok(CalQuery {
1059            // Execution state — the executor fills this once LET has run.
1060            let_values: Default::default(),
1061            version,
1062            statement,
1063            pipeline: inline_pipeline,
1064            with_options: inline_with,
1065            format: inline_format,
1066            let_bindings,
1067            user_vars,
1068            warnings: self.warnings.clone(),
1069        })
1070    }
1071
1072    /// Parse an optional `CAL/<n>` version prefix.
1073    fn parse_version_prefix(&mut self) -> CalResult<CalVersion> {
1074        if !self.at_exact(&Token::Cal) {
1075            return Ok(CalVersion::default());
1076        }
1077        let span = self.current_span();
1078        self.advance(); // consume CAL
1079                        // Expect `/`
1080        self.expect_exact(&Token::Slash)?;
1081        // Expect the version number.
1082        let n = self.parse_u64()?;
1083        if n != 1 {
1084            return Err(CalError::UnsupportedVersion {
1085                version: n as u32,
1086                span: Some(span),
1087            });
1088        }
1089        Ok(CalVersion(n as u32))
1090    }
1091
1092    /// Parse zero or more `LET $name = ... ;` bindings.
1093    fn parse_let_block(&mut self) -> CalResult<Vec<LetBinding>> {
1094        let mut bindings = vec![];
1095        while self.at_exact(&Token::Let) {
1096            let span_start = self.current_span();
1097            self.advance(); // consume LET
1098            let name = self.parse_parameter()?;
1099            self.expect_exact(&Token::Eq)?;
1100
1101            // Check for the `EXTRACTOR OF (RECALL ...)` form:
1102            //   LET $users = SUBJECTS OF (RECALL facts WHERE ...)
1103            // where the extractor precedes the recall statement.
1104            let (source_stmt, extractor) = if self.at_extractor() {
1105                let extractor = self.parse_extractor()?;
1106                // Optional OF keyword (Token::Of or legacy Ident "OF").
1107                if matches!(
1108                    self.peek(),
1109                    Some(SpannedToken {
1110                        token: Token::Of,
1111                        ..
1112                    })
1113                ) {
1114                    self.advance();
1115                } else if let Some(SpannedToken {
1116                    token: Token::Ident(id),
1117                    ..
1118                }) = self.peek()
1119                {
1120                    if id.eq_ignore_ascii_case("OF") {
1121                        self.advance();
1122                    }
1123                }
1124                // Sub-query may be parenthesised or bare RECALL.
1125                let source_stmt = if self.at_exact(&Token::LParen) {
1126                    self.advance();
1127                    self.enter_nesting()?;
1128                    let stmt = self.parse_recall_stmt()?;
1129                    self.leave_nesting();
1130                    self.expect_exact(&Token::RParen)?;
1131                    stmt
1132                } else {
1133                    self.parse_recall_stmt()?
1134                };
1135                (source_stmt, extractor)
1136            } else {
1137                // Original form: `LET $x = RECALL ... SUBJECTS`
1138                let source_stmt = self.parse_recall_stmt()?;
1139
1140                // Optional `SUBJECTS` / `OBJECTS` / `HASHES` extractor (pipe optional).
1141                let extractor = if self.at_exact(&Token::Pipe) || self.at_extractor() {
1142                    if self.eat_exact(&Token::Pipe) {
1143                        self.warnings.push(CalWarning::DeprecatedPipeOperator {
1144                            span: Some(self.current_span()),
1145                        });
1146                    }
1147                    self.parse_extractor()?
1148                } else {
1149                    Extractor::Hashes // default — callers can specify
1150                };
1151                (source_stmt, extractor)
1152            };
1153
1154            self.expect_exact(&Token::Semicolon)?;
1155
1156            let span_end = self.prev_span();
1157            let span = Span::new(
1158                span_start.start,
1159                span_end.end,
1160                span_start.line,
1161                span_start.col,
1162            );
1163
1164            bindings.push(LetBinding {
1165                name,
1166                extractor,
1167                source: Box::new(CalStatement::Recall(source_stmt)),
1168                span: Some(span),
1169            });
1170        }
1171        Ok(bindings)
1172    }
1173
1174    /// Check whether the current token starts an extractor.
1175    fn at_extractor(&self) -> bool {
1176        matches!(
1177            self.peek(),
1178            Some(SpannedToken {
1179                token: Token::Subjects | Token::Objects | Token::Hashes,
1180                ..
1181            })
1182        )
1183    }
1184
1185    fn parse_extractor(&mut self) -> CalResult<Extractor> {
1186        match self.peek() {
1187            Some(SpannedToken {
1188                token: Token::Subjects,
1189                ..
1190            }) => {
1191                self.advance();
1192                Ok(Extractor::Subjects)
1193            }
1194            Some(SpannedToken {
1195                token: Token::Objects,
1196                ..
1197            }) => {
1198                self.advance();
1199                Ok(Extractor::Objects)
1200            }
1201            Some(SpannedToken {
1202                token: Token::Hashes,
1203                ..
1204            }) => {
1205                self.advance();
1206                Ok(Extractor::Hashes)
1207            }
1208            Some(st) => {
1209                let found = st.token.description();
1210                let span = st.span;
1211                Err(CalError::UnexpectedToken {
1212                    expected: "SUBJECTS, OBJECTS, or HASHES".into(),
1213                    found,
1214                    span: Some(span),
1215                    suggestion: None,
1216                })
1217            }
1218            None => Err(CalError::UnexpectedToken {
1219                expected: "SUBJECTS, OBJECTS, or HASHES".into(),
1220                found: "<end of query>".into(),
1221                span: None,
1222                suggestion: None,
1223            }),
1224        }
1225    }
1226
1227    /// Parse a statement and its trailing pipeline / WITH / FORMAT clauses.
1228    ///
1229    /// Clause ordering is flexible: WITH options, FORMAT, WITH VARS, and
1230    /// post-pipeline WHERE can appear in any order after the pipeline stages.
1231    #[allow(clippy::type_complexity)]
1232    fn parse_statement_full(
1233        &mut self,
1234    ) -> CalResult<(
1235        CalStatement,
1236        Vec<PipelineStage>,
1237        Vec<WithOption>,
1238        Option<FormatClause>,
1239        HashMap<String, String>,
1240    )> {
1241        let stmt = self.parse_statement()?;
1242
1243        // After the statement body, check for pipeline.
1244        let mut pipeline = self.parse_pipeline()?;
1245
1246        // Parse trailing clauses in any order: WITH options, FORMAT, WITH VARS,
1247        // and post-pipeline WHERE filters.
1248        let mut with_options: Vec<WithOption> = Vec::new();
1249        let mut format = None;
1250        let mut user_vars = HashMap::new();
1251
1252        loop {
1253            if self.at_exact(&Token::Format) && format.is_none() {
1254                format = Some(self.parse_format()?);
1255            } else if self.at_exact(&Token::As) && format.is_none() && !self.peek_next_is_of() {
1256                format = Some(self.parse_as_format()?);
1257            } else if self.at_exact(&Token::With) {
1258                if self.peek_next_is_vars() {
1259                    if user_vars.is_empty() {
1260                        user_vars = self.parse_user_vars()?;
1261                    } else {
1262                        break;
1263                    }
1264                } else if with_options.is_empty() {
1265                    with_options = self.parse_with_clause()?;
1266                } else {
1267                    // Merge additional WITH clauses instead of silently ignoring them.
1268                    let extra = self.parse_with_clause()?;
1269                    with_options.extend(extra);
1270                }
1271            } else if self.at_exact(&Token::Where) {
1272                // WHERE after pipeline stages — add as a post-pipeline filter.
1273                let wc = self.parse_where_clause()?;
1274                if let Some(clause) = wc {
1275                    pipeline.push(PipelineStage::Filter {
1276                        condition: clause.condition,
1277                        span: clause.span,
1278                    });
1279                }
1280            } else {
1281                break;
1282            }
1283        }
1284
1285        Ok((stmt, pipeline, with_options, format, user_vars))
1286    }
1287
1288    // -- Statement dispatch -----------------------------------------------
1289
1290    fn parse_statement(&mut self) -> CalResult<CalStatement> {
1291        // Destructive keyword fast-reject before dispatch.
1292        if let Some(SpannedToken {
1293            token: Token::Ident(word),
1294            span,
1295            ..
1296        }) = self.peek()
1297        {
1298            if is_destructive_keyword(word) {
1299                let word = word.clone();
1300                let span = *span;
1301                return Err(CalError::UnexpectedToken {
1302                    expected: "RECALL, EXISTS, ASSEMBLE, HISTORY, EXPLAIN, DESCRIBE, BATCH, COALESCE, ADD, SUPERSEDE, REVERT, FORGET, DEFINE, DROP, or STREAM".into(),
1303                    found: word,
1304                    span: Some(span),
1305                    suggestion: Some(
1306                        "CAL cannot destroy data in bulk. The only destructive \
1307                         CAL statement is FORGET <hash>; erasure, key rotation, \
1308                         and schema changes are host-level operations."
1309                            .into(),
1310                    ),
1311                });
1312            }
1313        }
1314
1315        match self.peek() {
1316            Some(SpannedToken {
1317                token: Token::Explain,
1318                ..
1319            }) => self.parse_explain(),
1320            Some(SpannedToken {
1321                token: Token::Recall,
1322                ..
1323            }) => {
1324                let stmt = self.parse_recall_stmt()?;
1325                // Check for set operation.
1326                self.parse_set_op_tail(CalStatement::Recall(stmt))
1327            }
1328            Some(SpannedToken {
1329                token: Token::Assemble,
1330                ..
1331            }) => self.parse_assemble(),
1332            Some(SpannedToken {
1333                token: Token::Exists,
1334                ..
1335            }) => self.parse_exists(),
1336            Some(SpannedToken {
1337                token: Token::History,
1338                ..
1339            }) => self.parse_history(),
1340            Some(SpannedToken {
1341                token: Token::Describe,
1342                ..
1343            }) => self.parse_describe(),
1344            Some(SpannedToken {
1345                token: Token::Batch,
1346                ..
1347            }) => self.parse_batch(),
1348            Some(SpannedToken {
1349                token: Token::Coalesce,
1350                ..
1351            }) => self.parse_coalesce(),
1352            Some(SpannedToken {
1353                token: Token::Add, ..
1354            }) => self.parse_add(),
1355            Some(SpannedToken {
1356                token: Token::Accumulate,
1357                ..
1358            }) => self.parse_accumulate(),
1359            Some(SpannedToken {
1360                token: Token::Supersede,
1361                ..
1362            }) => self.parse_supersede(),
1363            Some(SpannedToken {
1364                token: Token::Revert,
1365                ..
1366            }) => self.parse_revert(),
1367            // FORGET <hash> / FORGET SUBJECT — Tier-2 destruction, capped by
1368            // `allow_destructive_ops` and authorized by the session's
1369            // delete/erase grants at execution.
1370            Some(SpannedToken {
1371                token: Token::Forget,
1372                ..
1373            }) => self.parse_forget(),
1374            // PURGE OLDER THAN — the retention sweep (CAL 1.3 §8.14),
1375            // authorization-gated at execution like every Tier-2 statement.
1376            Some(SpannedToken {
1377                token: Token::Purge,
1378                ..
1379            }) => self.parse_purge(),
1380            // REPORT SUBJECT — the read-only DSAR selection (OMS 1.6
1381            // draft): the erasure selector in show-me mode.
1382            Some(SpannedToken {
1383                token: Token::Report,
1384                ..
1385            }) => self.parse_report_subject(),
1386            // Governance (CAL 1.3 §8.16): the loop lifecycle in the
1387            // language, BECAUSE as syntax.
1388            Some(SpannedToken {
1389                token: Token::Approve,
1390                ..
1391            }) => Ok(CalStatement::Approve(
1392                self.parse_governance_body(&Token::Approve)?,
1393            )),
1394            Some(SpannedToken {
1395                token: Token::Reject,
1396                ..
1397            }) => Ok(CalStatement::Reject(
1398                self.parse_governance_body(&Token::Reject)?,
1399            )),
1400            Some(SpannedToken {
1401                token: Token::Apply,
1402                ..
1403            }) => Ok(CalStatement::ApplyRec(
1404                self.parse_governance_body(&Token::Apply)?,
1405            )),
1406            Some(SpannedToken {
1407                token: Token::Rollback,
1408                ..
1409            }) => Ok(CalStatement::RollbackRec(
1410                self.parse_governance_body(&Token::Rollback)?,
1411            )),
1412            // Tier-3 DCL (CAL 1.3 §8.15).
1413            Some(SpannedToken {
1414                token: Token::Grant,
1415                ..
1416            }) => self.parse_grant(),
1417            Some(SpannedToken {
1418                token: Token::Revoke,
1419                ..
1420            }) => self.parse_revoke(),
1421            Some(SpannedToken {
1422                token: Token::Show,
1423                ..
1424            }) => self.parse_show_grants(),
1425            Some(SpannedToken {
1426                token: Token::Remember,
1427                ..
1428            }) => self.parse_remember(),
1429            // Wave-2 reads (CAL 1.3).
1430            Some(SpannedToken {
1431                token: Token::Entity,
1432                ..
1433            }) => self.parse_entity_at(),
1434            Some(SpannedToken {
1435                token: Token::Runs,
1436                ..
1437            }) => self.parse_runs_touching(),
1438            Some(SpannedToken {
1439                token: Token::Derived,
1440                ..
1441            }) => self.parse_derived_from(),
1442            Some(SpannedToken {
1443                token: Token::Merge,
1444                ..
1445            }) => self.parse_merge(),
1446            Some(SpannedToken {
1447                token: Token::Related,
1448                ..
1449            }) => self.parse_related(),
1450            Some(SpannedToken {
1451                token: Token::Novelty,
1452                ..
1453            }) => self.parse_novelty(),
1454            Some(SpannedToken {
1455                token: Token::Define,
1456                ..
1457            }) => self.parse_define(),
1458            Some(SpannedToken {
1459                token: Token::Drop, ..
1460            }) => self.parse_drop(),
1461            Some(SpannedToken {
1462                token: Token::Run, ..
1463            }) => self.parse_run_query(),
1464            Some(SpannedToken {
1465                token: Token::Stream,
1466                ..
1467            }) => self.parse_stream_assemble(),
1468            // Parenthesised statement (set operation).
1469            Some(SpannedToken {
1470                token: Token::LParen,
1471                ..
1472            }) => {
1473                let stmt = self.parse_paren_statement()?;
1474                self.parse_set_op_tail(stmt)
1475            }
1476            Some(st) => {
1477                let found = st.token.description();
1478                let span = st.span;
1479                Err(CalError::UnexpectedToken {
1480                    expected: "RECALL, EXISTS, ASSEMBLE, HISTORY, EXPLAIN, DESCRIBE, BATCH, \
1481                         COALESCE, ADD, SUPERSEDE, REVERT, FORGET, DEFINE, DROP, or RUN"
1482                        .into(),
1483                    found,
1484                    span: Some(span),
1485                    suggestion: None,
1486                })
1487            }
1488            None => Err(CalError::EmptyQuery { span: None }),
1489        }
1490    }
1491
1492    /// Parse a statement wrapped in `( ... )`.
1493    fn parse_paren_statement(&mut self) -> CalResult<CalStatement> {
1494        self.expect_exact(&Token::LParen)?;
1495        self.enter_nesting()?;
1496        let stmt = self.parse_statement()?;
1497        self.leave_nesting();
1498        self.expect_exact(&Token::RParen)?;
1499        Ok(stmt)
1500    }
1501
1502    /// Parse an ASSEMBLE labeled-source sub-query, accepting an optional
1503    /// trailing `WITH ...` INSIDE the parens. Returns the statement plus any
1504    /// inside-paren WITH options.
1505    ///
1506    /// Scoped to the assemble-source path only — `parse_paren_statement` is
1507    /// shared with the set-op operand path and must NOT change.
1508    fn parse_assemble_source_query(&mut self) -> CalResult<(CalStatement, Vec<WithOption>)> {
1509        if self.at_exact(&Token::LParen) {
1510            self.expect_exact(&Token::LParen)?;
1511            self.enter_nesting()?;
1512            // parse_statement already consumes any UNION/INTERSECT/EXCEPT set-op tail.
1513            let stmt = self.parse_statement()?;
1514            self.leave_nesting();
1515            // Optional inside-paren WITH (not WITH VARS).
1516            let inside_with = if self.at_exact(&Token::With) && !self.peek_next_is_vars() {
1517                self.parse_with_clause()?
1518            } else {
1519                vec![]
1520            };
1521            self.expect_exact(&Token::RParen)?;
1522            Ok((stmt, inside_with))
1523        } else {
1524            // Bare RECALL with no parens — no inside-paren WITH possible.
1525            let stmt = self.parse_recall_stmt()?;
1526            Ok((CalStatement::Recall(stmt), vec![]))
1527        }
1528    }
1529
1530    /// Consume a UNION / INTERSECT / EXCEPT chain, if one follows.
1531    fn parse_set_op_tail(&mut self, first: CalStatement) -> CalResult<CalStatement> {
1532        let op = match self.peek() {
1533            Some(SpannedToken {
1534                token: Token::Union,
1535                ..
1536            }) => SetOp::Union,
1537            Some(SpannedToken {
1538                token: Token::Intersect,
1539                ..
1540            }) => SetOp::Intersect,
1541            Some(SpannedToken {
1542                token: Token::Except,
1543                ..
1544            }) => SetOp::Except,
1545            _ => return Ok(first),
1546        };
1547
1548        let span_start = self.current_span();
1549        self.advance(); // consume UNION/INTERSECT/EXCEPT
1550
1551        let mut operands = vec![first];
1552        // Parse the second operand (required).
1553        let rhs = if self.at_exact(&Token::LParen) {
1554            self.parse_paren_statement()?
1555        } else {
1556            let stmt = self.parse_recall_stmt()?;
1557            CalStatement::Recall(stmt)
1558        };
1559        operands.push(rhs);
1560
1561        // Continue consuming if the same operator follows.
1562        while matches!(self.peek(), Some(st) if std::mem::discriminant(&st.token) == std::mem::discriminant(&Token::Union)
1563            || std::mem::discriminant(&st.token) == std::mem::discriminant(&Token::Intersect)
1564            || std::mem::discriminant(&st.token) == std::mem::discriminant(&Token::Except))
1565        {
1566            if operands.len() >= MAX_SET_OPERANDS {
1567                return Err(CalError::TooManySetOperands {
1568                    count: operands.len() + 1,
1569                    max: MAX_SET_OPERANDS,
1570                    span: Some(self.current_span()),
1571                });
1572            }
1573            self.advance(); // consume set-op keyword
1574            let next = if self.at_exact(&Token::LParen) {
1575                self.parse_paren_statement()?
1576            } else {
1577                let stmt = self.parse_recall_stmt()?;
1578                CalStatement::Recall(stmt)
1579            };
1580            operands.push(next);
1581        }
1582
1583        let span_end = self.prev_span();
1584        Ok(CalStatement::SetOp(SetOpStmt {
1585            op,
1586            operands,
1587            span: Some(Span::new(
1588                span_start.start,
1589                span_end.end,
1590                span_start.line,
1591                span_start.col,
1592            )),
1593        }))
1594    }
1595
1596    // -- RECALL -----------------------------------------------------------
1597
1598    fn parse_recall_stmt(&mut self) -> CalResult<RecallStmt> {
1599        let span_start = self.current_span();
1600        self.expect_exact(&Token::Recall)?;
1601
1602        // Optional MY keyword (shorthand; emit a warning).
1603        let has_my = self.eat_exact(&Token::My);
1604        if has_my {
1605            self.warnings.push(CalWarning::UnknownRelation {
1606                relation: "MY (shorthand — will be desugared at execution time)".into(),
1607                span: Some(self.prev_span()),
1608            });
1609        }
1610
1611        // Grain type is optional per OMS-1.4 EBNF (§4); when absent, only the
1612        // common field set is available in WHERE clauses (§6.2). Bug 2.
1613        // When no grain type is provided we fall back to the `All` wildcard,
1614        // which already represents "no type filter" downstream.
1615        let grain_type = if self.eat_exact(&Token::Asterisk) {
1616            // `RECALL *` — the explicit spelling of "any grain type"; the
1617            // same thing omitting the grain type already means.
1618            GrainTypePlural::All
1619        } else if self.at_exact(&Token::Where)
1620            || self.at_exact(&Token::Recent)
1621            || self.at_exact(&Token::Since)
1622            || self.at_exact(&Token::Until)
1623            || self.at_exact(&Token::Between)
1624            || self.at_exact(&Token::About)
1625            || self.at_exact(&Token::Like)
1626            || self.at_exact(&Token::Limit)
1627            || self.at_exact(&Token::Pipe)
1628        {
1629            GrainTypePlural::All
1630        } else {
1631            self.parse_grain_type_plural()?
1632        };
1633
1634        // Optional clauses — order is flexible in the spec but we parse them
1635        // in a defined precedence to produce good error messages.
1636
1637        let about = self.parse_about_clause()?;
1638        let like = self.parse_like_clause()?;
1639        let since = self.parse_since_clause()?;
1640        let until = self.parse_until_clause()?;
1641        let between = self.parse_between_clause()?;
1642        let mut where_clause = self.parse_where_clause()?;
1643
1644        // If ABOUT was used without an explicit WHERE keyword but AND follows,
1645        // treat `AND condition` as an implicit WHERE clause continuation.
1646        // This handles: `RECALL facts ABOUT "topic" AND confidence >= 0.8 RECENT 10`
1647        if where_clause.is_none() && self.at_exact(&Token::And) {
1648            let wspan = self.current_span();
1649            self.advance(); // consume AND
1650            let cond = self.parse_condition_or()?;
1651            where_clause = Some(WhereClause {
1652                condition: cond,
1653                span: Some(wspan),
1654            });
1655        }
1656
1657        // SINCE/UNTIL may appear after WHERE (e.g. `ABOUT "x" WHERE subject = "y" SINCE "2024-01-01"`).
1658        // Re-try parsing them if not already found before WHERE.
1659        let since = if since.is_none() {
1660            self.parse_since_clause()?
1661        } else {
1662            since
1663        };
1664        let until = if until.is_none() {
1665            self.parse_until_clause()?
1666        } else {
1667            until
1668        };
1669
1670        let recent = self.parse_recent_clause()?;
1671
1672        // Optional inline LIMIT (before pipeline).
1673        let limit = if self.at_exact(&Token::Limit) {
1674            self.advance();
1675            let span = self.current_span();
1676            let v = self.parse_u64()?;
1677            if v == 0 {
1678                return Err(CalError::LimitExceeded {
1679                    value: 0,
1680                    max: MAX_LIMIT_VALUE,
1681                    span: Some(span),
1682                });
1683            }
1684            if v > MAX_LIMIT_VALUE {
1685                return Err(CalError::LimitExceeded {
1686                    value: v,
1687                    max: MAX_LIMIT_VALUE,
1688                    span: Some(span),
1689                });
1690            }
1691            Some(v)
1692        } else {
1693            None
1694        };
1695
1696        // Optional CONTRADICTIONS clause.
1697        let contradictions = self.parse_contradictions_clause()?;
1698
1699        // Enforce spec §9.8 shortcut-combination matrix: ambiguous pairs
1700        // emit CAL-E060 instead of silently picking one interpretation.
1701        let where_refers_to = |field: &str| -> bool {
1702            // Iterative walk over the condition tree — explicit stack so a
1703            // long left-leaning AND/OR chain (bounded only by query bytes)
1704            // cannot exhaust the tokio worker stack.
1705            fn cond_refs(root: &Condition, f: &str) -> bool {
1706                let mut stack: Vec<&Condition> = vec![root];
1707                while let Some(c) = stack.pop() {
1708                    match c {
1709                        Condition::Comparison { field, .. }
1710                        | Condition::In { field, .. }
1711                        | Condition::NotIn { field, .. }
1712                            if field == f =>
1713                        {
1714                            return true;
1715                        }
1716                        Condition::And { left, right, .. } | Condition::Or { left, right, .. } => {
1717                            stack.push(left);
1718                            stack.push(right);
1719                        }
1720                        Condition::Not { inner, .. } => stack.push(inner),
1721                        _ => {}
1722                    }
1723                }
1724                false
1725            }
1726            where_clause
1727                .as_ref()
1728                .map(|w| cond_refs(&w.condition, field))
1729                .unwrap_or(false)
1730        };
1731
1732        let report_conflict = |a: &str, b: &str, hint: &str| -> CalError {
1733            CalError::FieldNotOnGrainType {
1734                field: format!("{} + {}", a, b),
1735                grain_type: "RECALL".into(),
1736                span: Some(span_start),
1737                suggestion: Some(format!(
1738                    "Combination ambiguous per spec §9.8: {} with {}. {}",
1739                    a, b, hint
1740                )),
1741            }
1742        };
1743
1744        if about.is_some() && like.is_some() {
1745            return Err(report_conflict(
1746                "ABOUT",
1747                "LIKE",
1748                "use ABOUT for semantic search or LIKE for textual similarity, not both.",
1749            ));
1750        }
1751        if recent.is_some() && limit.is_some() {
1752            return Err(report_conflict(
1753                "RECENT",
1754                "LIMIT",
1755                "RECENT N already caps the result count; remove LIMIT.",
1756            ));
1757        }
1758        if since.is_some() && where_refers_to("time") {
1759            return Err(report_conflict(
1760                "SINCE",
1761                "WHERE time",
1762                "SINCE is shorthand for WHERE time — pick one.",
1763            ));
1764        }
1765        if since.is_some() && between.is_some() {
1766            return Err(report_conflict(
1767                "SINCE",
1768                "BETWEEN",
1769                "SINCE sets a lower bound; BETWEEN gives an explicit range — pick one.",
1770            ));
1771        }
1772        if has_my && where_refers_to("user_id") {
1773            return Err(report_conflict(
1774                "MY",
1775                "WHERE user_id",
1776                "MY desugars to `WHERE user_id = $current_user_id` — remove the explicit clause.",
1777            ));
1778        }
1779
1780        let span_end = self.prev_span();
1781        Ok(RecallStmt {
1782            grain_type,
1783            about,
1784            where_clause,
1785            recent,
1786            since,
1787            until,
1788            like,
1789            between,
1790            contradictions,
1791            limit,
1792            as_format: None,
1793            span: Some(Span::new(
1794                span_start.start,
1795                span_end.end,
1796                span_start.line,
1797                span_start.col,
1798            )),
1799        })
1800    }
1801
1802    fn parse_about_clause(&mut self) -> CalResult<Option<AboutClause>> {
1803        if !self.at_exact(&Token::About) {
1804            return Ok(None);
1805        }
1806        let span = self.current_span();
1807        self.advance();
1808        let text = self.parse_string_literal()?;
1809        Ok(Some(AboutClause {
1810            text,
1811            span: Some(span),
1812        }))
1813    }
1814
1815    fn parse_like_clause(&mut self) -> CalResult<Option<LikeClause>> {
1816        if !self.at_exact(&Token::Like) {
1817            return Ok(None);
1818        }
1819        let span = self.current_span();
1820        self.advance();
1821        let text = self.parse_string_literal()?;
1822        Ok(Some(LikeClause {
1823            text,
1824            span: Some(span),
1825        }))
1826    }
1827
1828    fn parse_since_clause(&mut self) -> CalResult<Option<SinceClause>> {
1829        if !self.at_exact(&Token::Since) {
1830            return Ok(None);
1831        }
1832        let span = self.current_span();
1833        self.advance();
1834        let expression = self.parse_string_literal()?;
1835        Ok(Some(SinceClause {
1836            expression,
1837            span: Some(span),
1838        }))
1839    }
1840
1841    fn parse_until_clause(&mut self) -> CalResult<Option<UntilClause>> {
1842        if !self.at_exact(&Token::Until) {
1843            return Ok(None);
1844        }
1845        let span = self.current_span();
1846        self.advance();
1847        let expression = self.parse_string_literal()?;
1848        Ok(Some(UntilClause {
1849            expression,
1850            span: Some(span),
1851        }))
1852    }
1853
1854    fn parse_between_clause(&mut self) -> CalResult<Option<BetweenClause>> {
1855        if !self.at_exact(&Token::Between) {
1856            return Ok(None);
1857        }
1858        let span = self.current_span();
1859        self.advance();
1860        let start = self.parse_string_literal()?;
1861        self.expect_exact(&Token::And)?;
1862        let end = self.parse_string_literal()?;
1863        Ok(Some(BetweenClause {
1864            start,
1865            end,
1866            span: Some(span),
1867        }))
1868    }
1869
1870    fn parse_recent_clause(&mut self) -> CalResult<Option<RecentClause>> {
1871        if !self.at_exact(&Token::Recent) {
1872            return Ok(None);
1873        }
1874        let span = self.current_span();
1875        self.advance();
1876        let count = self.parse_u64()?;
1877        Ok(Some(RecentClause {
1878            count,
1879            span: Some(span),
1880        }))
1881    }
1882
1883    fn parse_contradictions_clause(&mut self) -> CalResult<Option<ContradictionsClause>> {
1884        if !self.at_exact(&Token::Contradictions) {
1885            return Ok(None);
1886        }
1887        let span = self.current_span();
1888        self.advance();
1889        // CONTRADICTIONS is a bare terminal per spec; the `OF (sub-query)`
1890        // tail is an Areev extension and is optional.
1891        self.eat_exact(&Token::Of);
1892        let inner = if self.at_exact(&Token::LParen) {
1893            Some(Box::new(self.parse_paren_statement()?))
1894        } else {
1895            None
1896        };
1897        Ok(Some(ContradictionsClause {
1898            inner,
1899            span: Some(span),
1900        }))
1901    }
1902
1903    // -- WHERE ------------------------------------------------------------
1904
1905    fn parse_where_clause(&mut self) -> CalResult<Option<WhereClause>> {
1906        if !self.at_exact(&Token::Where) {
1907            return Ok(None);
1908        }
1909        let span = self.current_span();
1910        self.advance();
1911        let condition = self.parse_condition_or()?;
1912        Ok(Some(WhereClause {
1913            condition,
1914            span: Some(span),
1915        }))
1916    }
1917
1918    /// Parse an OR expression (lowest precedence inside WHERE).
1919    fn parse_condition_or(&mut self) -> CalResult<Condition> {
1920        let span_start = self.current_span();
1921        let mut left = self.parse_condition_and()?;
1922        while self.at_exact(&Token::Or) {
1923            self.advance();
1924            let right = self.parse_condition_and()?;
1925            let span_end = self.prev_span();
1926            left = Condition::Or {
1927                left: Box::new(left),
1928                right: Box::new(right),
1929                span: Some(Span::new(
1930                    span_start.start,
1931                    span_end.end,
1932                    span_start.line,
1933                    span_start.col,
1934                )),
1935            };
1936        }
1937        Ok(left)
1938    }
1939
1940    /// Parse an AND expression.
1941    fn parse_condition_and(&mut self) -> CalResult<Condition> {
1942        let span_start = self.current_span();
1943        let mut left = self.parse_condition_unary()?;
1944        while self.at_exact(&Token::And) {
1945            self.advance();
1946            let right = self.parse_condition_unary()?;
1947            let span_end = self.prev_span();
1948            left = Condition::And {
1949                left: Box::new(left),
1950                right: Box::new(right),
1951                span: Some(Span::new(
1952                    span_start.start,
1953                    span_end.end,
1954                    span_start.line,
1955                    span_start.col,
1956                )),
1957            };
1958        }
1959        Ok(left)
1960    }
1961
1962    /// Parse a NOT or primary condition.
1963    fn parse_condition_unary(&mut self) -> CalResult<Condition> {
1964        if self.at_exact(&Token::Not) {
1965            let span = self.current_span();
1966            self.advance();
1967            let inner = self.parse_condition_primary()?;
1968            return Ok(Condition::Not {
1969                inner: Box::new(inner),
1970                span: Some(span),
1971            });
1972        }
1973        self.parse_condition_primary()
1974    }
1975
1976    /// Parse a primary condition (comparison, IN, IS NULL, CONTAINS, etc.)
1977    /// or a parenthesised sub-condition.
1978    fn parse_condition_primary(&mut self) -> CalResult<Condition> {
1979        // Parenthesised sub-condition.
1980        if self.at_exact(&Token::LParen) {
1981            self.advance();
1982            self.enter_nesting()?;
1983            let cond = self.parse_condition_or()?;
1984            self.leave_nesting();
1985            self.expect_exact(&Token::RParen)?;
1986            return Ok(cond);
1987        }
1988
1989        let span_start = self.current_span();
1990
1991        // Expect a field name (Ident token).
1992        let field = self.parse_field_name()?;
1993
1994        // Determine the operator.
1995        match self.peek() {
1996            // `field = value` / `field != value` / `field >= value` / etc.
1997            Some(SpannedToken {
1998                token: Token::Eq | Token::NotEq | Token::Gte | Token::Lte | Token::Gt | Token::Lt,
1999                ..
2000            }) => {
2001                let st = self.advance().unwrap();
2002                let comparator = match &st.token {
2003                    Token::Eq => Comparator::Eq,
2004                    Token::NotEq => Comparator::NotEq,
2005                    Token::Gte => Comparator::Gte,
2006                    Token::Lte => Comparator::Lte,
2007                    Token::Gt => Comparator::Gt,
2008                    Token::Lt => Comparator::Lt,
2009                    _ => unreachable!(),
2010                };
2011                let value = self.parse_value()?;
2012                // Emit CAL-W001 when `relation = "mg:..."` references a
2013                // relation outside the standard vocabulary.
2014                if field == "relation" {
2015                    if let Value::String { value: ref s } = value {
2016                        if let Some(w) = super::relations::validate_relation(s) {
2017                            self.warnings.push(w);
2018                        }
2019                    }
2020                }
2021                let span_end = self.prev_span();
2022                Ok(Condition::Comparison {
2023                    field,
2024                    comparator,
2025                    value,
2026                    span: Some(Span::new(
2027                        span_start.start,
2028                        span_end.end,
2029                        span_start.line,
2030                        span_start.col,
2031                    )),
2032                })
2033            }
2034
2035            // `field IN (v1, v2, ...)`
2036            Some(SpannedToken {
2037                token: Token::In, ..
2038            }) => {
2039                self.advance();
2040                // Allow `IN ($param)` where $param is an entire list.
2041                if self.at(&Token::Parameter("".into())) {
2042                    // Peek ahead: if next-after-param is `)`, it's a single param list.
2043                    let name = self.parse_parameter()?;
2044                    let values = vec![Value::Parameter { name }];
2045                    let span_end = self.prev_span();
2046                    return Ok(Condition::In {
2047                        field,
2048                        values,
2049                        span: Some(Span::new(
2050                            span_start.start,
2051                            span_end.end,
2052                            span_start.line,
2053                            span_start.col,
2054                        )),
2055                    });
2056                }
2057                let values = self.parse_value_list()?;
2058                let span_end = self.prev_span();
2059                Ok(Condition::In {
2060                    field,
2061                    values,
2062                    span: Some(Span::new(
2063                        span_start.start,
2064                        span_end.end,
2065                        span_start.line,
2066                        span_start.col,
2067                    )),
2068                })
2069            }
2070
2071            // `field NOT IN (v1, v2, ...)`
2072            Some(SpannedToken {
2073                token: Token::Not, ..
2074            }) if self.peek_ahead(1).map(|t| &t.token) == Some(&Token::In) => {
2075                self.advance(); // NOT
2076                self.advance(); // IN
2077                let values = self.parse_value_list()?;
2078                let span_end = self.prev_span();
2079                Ok(Condition::NotIn {
2080                    field,
2081                    values,
2082                    span: Some(Span::new(
2083                        span_start.start,
2084                        span_end.end,
2085                        span_start.line,
2086                        span_start.col,
2087                    )),
2088                })
2089            }
2090
2091            // `field IS NULL` / `field IS NOT NULL` / `field IS CATEGORY`
2092            Some(SpannedToken {
2093                token: Token::Is, ..
2094            }) => {
2095                self.advance(); // IS
2096                let not_null = self.eat_exact(&Token::Not);
2097
2098                // Check for IS CATEGORY keywords (Preference, Knowledge, etc.)
2099                if !not_null {
2100                    if let Some(category) = self.try_parse_relation_category() {
2101                        let span_end = self.prev_span();
2102                        let span = Some(Span::new(
2103                            span_start.start,
2104                            span_end.end,
2105                            span_start.line,
2106                            span_start.col,
2107                        ));
2108                        return Ok(Condition::IsCategory {
2109                            field,
2110                            category,
2111                            span,
2112                        });
2113                    }
2114                }
2115
2116                self.expect_exact(&Token::Null)?;
2117                let span_end = self.prev_span();
2118                let span = Some(Span::new(
2119                    span_start.start,
2120                    span_end.end,
2121                    span_start.line,
2122                    span_start.col,
2123                ));
2124                if not_null {
2125                    Ok(Condition::IsNotNull { field, span })
2126                } else {
2127                    Ok(Condition::IsNull { field, span })
2128                }
2129            }
2130
2131            // `field INCLUDE [v, ...]` — desugar to `field IN [v, ...]` so
2132            // the executor's set-condition path handles it (mirrors EXCLUDE →
2133            // NotIn). Without this, single-element/array Comparison shapes
2134            // fall through to apply_where_clause's wildcard arm and emit a
2135            // spurious CAL-W010 warning even though the filter still works
2136            // via the type-specific extractor.
2137            Some(SpannedToken {
2138                token: Token::Include,
2139                ..
2140            }) => {
2141                self.advance();
2142                let include_span = self.current_span();
2143                let val = self.parse_value()?;
2144                // `tags INCLUDE` requires an array literal; reject scalars at parse time.
2145                let values = match val {
2146                    Value::Array { values } => values,
2147                    Value::Parameter { .. } => vec![val],
2148                    other => {
2149                        return Err(CalError::UnexpectedToken {
2150                            expected: "array literal `[...]` after INCLUDE".into(),
2151                            found: other.type_name().to_string(),
2152                            span: Some(include_span),
2153                            suggestion: Some(
2154                                "tags INCLUDE requires an array, e.g. tags INCLUDE [\"tag1\", \"tag2\"]".into(),
2155                            ),
2156                        });
2157                    }
2158                };
2159                let span_end = self.prev_span();
2160                Ok(Condition::In {
2161                    field,
2162                    values,
2163                    span: Some(Span::new(
2164                        span_start.start,
2165                        span_end.end,
2166                        span_start.line,
2167                        span_start.col,
2168                    )),
2169                })
2170            }
2171
2172            // `field EXCLUDE [v, ...]` — tags exclude set (desugared to NOT IN).
2173            Some(SpannedToken {
2174                token: Token::Exclude,
2175                ..
2176            }) => {
2177                self.advance();
2178                let exclude_span = self.current_span();
2179                let val = self.parse_value()?;
2180                // Same array-literal type check as INCLUDE above.
2181                let values = match val {
2182                    Value::Array { values } => values,
2183                    Value::Parameter { .. } => vec![val],
2184                    other => {
2185                        return Err(CalError::UnexpectedToken {
2186                            expected: "array literal `[...]` after EXCLUDE".into(),
2187                            found: other.type_name().to_string(),
2188                            span: Some(exclude_span),
2189                            suggestion: Some(
2190                                "tags EXCLUDE requires an array, e.g. tags EXCLUDE [\"tag1\"]"
2191                                    .into(),
2192                            ),
2193                        });
2194                    }
2195                };
2196                let span_end = self.prev_span();
2197                Ok(Condition::NotIn {
2198                    field,
2199                    values,
2200                    span: Some(Span::new(
2201                        span_start.start,
2202                        span_end.end,
2203                        span_start.line,
2204                        span_start.col,
2205                    )),
2206                })
2207            }
2208
2209            // `field CONTAINS "text"` — substring match.
2210            Some(SpannedToken {
2211                token: Token::Ident(id),
2212                ..
2213            }) if id.eq_ignore_ascii_case("CONTAINS") => {
2214                self.advance();
2215                let value = self.parse_string_literal()?;
2216                let span_end = self.prev_span();
2217                Ok(Condition::Contains {
2218                    field,
2219                    value,
2220                    span: Some(Span::new(
2221                        span_start.start,
2222                        span_end.end,
2223                        span_start.line,
2224                        span_start.col,
2225                    )),
2226                })
2227            }
2228
2229            // `field STARTS WITH "text"` — prefix match.
2230            Some(SpannedToken {
2231                token: Token::Ident(id),
2232                ..
2233            }) if id.eq_ignore_ascii_case("STARTS") => {
2234                self.advance(); // consume STARTS
2235                                // Optionally consume WITH keyword (handles both Token::With and Token::Ident("WITH")).
2236                let has_with_keyword = self.at_exact(&Token::With)
2237                    || matches!(self.peek(), Some(SpannedToken { token: Token::Ident(id), .. }) if id.eq_ignore_ascii_case("WITH"));
2238                if has_with_keyword {
2239                    self.advance();
2240                }
2241                let value = self.parse_string_literal()?;
2242                let span_end = self.prev_span();
2243                Ok(Condition::StartsWith {
2244                    field,
2245                    value,
2246                    span: Some(Span::new(
2247                        span_start.start,
2248                        span_end.end,
2249                        span_start.line,
2250                        span_start.col,
2251                    )),
2252                })
2253            }
2254
2255            Some(st) => {
2256                let found = st.token.description();
2257                let span = st.span;
2258                Err(CalError::UnexpectedToken {
2259                    expected:
2260                        "comparison operator (=, !=, >=, <=, >, <), IN, IS, CONTAINS, STARTS WITH, INCLUDE, or EXCLUDE"
2261                            .into(),
2262                    found,
2263                    span: Some(span),
2264                    suggestion: None,
2265                })
2266            }
2267            None => Err(CalError::UnexpectedToken {
2268                expected: "comparison operator".into(),
2269                found: "<end of query>".into(),
2270                span: None,
2271                suggestion: None,
2272            }),
2273        }
2274    }
2275
2276    /// Try to parse a relation category keyword (PREFERENCE, KNOWLEDGE, etc.)
2277    /// without consuming the token if it doesn't match. Returns the category
2278    /// string if matched.
2279    fn try_parse_relation_category(&mut self) -> Option<String> {
2280        match self.peek() {
2281            Some(SpannedToken {
2282                token: Token::Preference,
2283                ..
2284            }) => {
2285                self.advance();
2286                Some("preference".to_string())
2287            }
2288            Some(SpannedToken {
2289                token: Token::Knowledge,
2290                ..
2291            }) => {
2292                self.advance();
2293                Some("knowledge".to_string())
2294            }
2295            Some(SpannedToken {
2296                token: Token::Permission,
2297                ..
2298            }) => {
2299                self.advance();
2300                Some("permission".to_string())
2301            }
2302            Some(SpannedToken {
2303                token: Token::Interaction,
2304                ..
2305            }) => {
2306                self.advance();
2307                Some("interaction".to_string())
2308            }
2309            Some(SpannedToken {
2310                token: Token::Agency,
2311                ..
2312            }) => {
2313                self.advance();
2314                Some("agency".to_string())
2315            }
2316            Some(SpannedToken {
2317                token: Token::Lifecycle,
2318                ..
2319            }) => {
2320                self.advance();
2321                Some("lifecycle".to_string())
2322            }
2323            Some(SpannedToken {
2324                token: Token::Observation,
2325                ..
2326            }) => {
2327                self.advance();
2328                Some("observation".to_string())
2329            }
2330            // WORKFLOW and CONSENSUS are not dedicated keywords — they lex as Ident.
2331            Some(SpannedToken {
2332                token: Token::Ident(id),
2333                ..
2334            }) if id.eq_ignore_ascii_case("WORKFLOW") => {
2335                self.advance();
2336                Some("workflow".to_string())
2337            }
2338            Some(SpannedToken {
2339                token: Token::Ident(id),
2340                ..
2341            }) if id.eq_ignore_ascii_case("CONSENSUS") => {
2342                self.advance();
2343                Some("consensus".to_string())
2344            }
2345            Some(SpannedToken {
2346                token: Token::Ident(id),
2347                ..
2348            }) if id.eq_ignore_ascii_case("GOVERNANCE") => {
2349                self.advance();
2350                Some("governance".to_string())
2351            }
2352            _ => None,
2353        }
2354    }
2355
2356    /// Parse a field name — either an `Ident` token or a dotted path.
2357    ///
2358    /// Returns the field as a string (e.g. `"subject"`, `"metadata.source"`).
2359    fn parse_field_name(&mut self) -> CalResult<String> {
2360        let first = self.parse_identifier()?;
2361        if self.at_exact(&Token::Dot) {
2362            self.advance();
2363            let second = self.parse_identifier()?;
2364            Ok(format!("{}.{}", first, second))
2365        } else {
2366            Ok(first)
2367        }
2368    }
2369
2370    // -- PIPELINE ---------------------------------------------------------
2371
2372    /// Check whether the current token starts a pipeline stage.
2373    fn at_pipeline_stage(&self) -> bool {
2374        if matches!(
2375            self.peek(),
2376            Some(SpannedToken {
2377                token: Token::Select
2378                    | Token::Order
2379                    | Token::Limit
2380                    | Token::Offset
2381                    | Token::Count
2382                    | Token::First
2383                    | Token::Subjects
2384                    | Token::Objects
2385                    | Token::Hashes
2386                    | Token::Group
2387                    | Token::Project,
2388                ..
2389            })
2390        ) {
2391            return true;
2392        }
2393        // SORT is an alias for ORDER BY — treat as pipeline stage.
2394        matches!(
2395            self.peek(),
2396            Some(SpannedToken {
2397                token: Token::Ident(id),
2398                ..
2399            }) if id.eq_ignore_ascii_case("SORT")
2400        )
2401    }
2402
2403    fn parse_pipeline(&mut self) -> CalResult<Vec<PipelineStage>> {
2404        let mut stages = vec![];
2405        // Accept pipeline stages with or without leading `|`.
2406        while self.at_exact(&Token::Pipe) || self.at_pipeline_stage() {
2407            if stages.len() >= MAX_PIPELINE_STAGES {
2408                return Err(CalError::TooManyPipelineStages {
2409                    count: stages.len() + 1,
2410                    max: MAX_PIPELINE_STAGES,
2411                    span: Some(self.current_span()),
2412                });
2413            }
2414            // Consume optional `|` (backward compatibility) but warn.
2415            if self.eat_exact(&Token::Pipe) {
2416                self.warnings.push(CalWarning::DeprecatedPipeOperator {
2417                    span: Some(self.current_span()),
2418                });
2419            }
2420            let stage = self.parse_pipeline_stage()?;
2421            stages.push(stage);
2422        }
2423        Ok(stages)
2424    }
2425
2426    fn parse_pipeline_stage(&mut self) -> CalResult<PipelineStage> {
2427        let span = self.current_span();
2428        match self.peek() {
2429            Some(SpannedToken {
2430                token: Token::Select,
2431                ..
2432            }) => {
2433                self.advance();
2434                let mut fields = vec![];
2435                loop {
2436                    fields.push(self.parse_identifier()?);
2437                    if !self.eat_exact(&Token::Comma) {
2438                        break;
2439                    }
2440                }
2441                Ok(PipelineStage::Select {
2442                    fields,
2443                    span: Some(span),
2444                })
2445            }
2446            Some(SpannedToken {
2447                token: Token::Order,
2448                ..
2449            }) => {
2450                self.advance();
2451                self.expect_exact(&Token::By)?;
2452                let field = self.parse_identifier()?;
2453                let descending = if self.at_exact(&Token::Desc) {
2454                    self.advance();
2455                    true
2456                } else {
2457                    self.eat_exact(&Token::Asc);
2458                    false
2459                };
2460                Ok(PipelineStage::OrderBy {
2461                    field,
2462                    descending,
2463                    span: Some(span),
2464                })
2465            }
2466            Some(SpannedToken {
2467                token: Token::Limit,
2468                ..
2469            }) => {
2470                self.advance();
2471                let lspan = self.current_span();
2472                let value = self.parse_u64()?;
2473                if value == 0 {
2474                    return Err(CalError::LimitExceeded {
2475                        value: 0,
2476                        max: MAX_LIMIT_VALUE,
2477                        span: Some(lspan),
2478                    });
2479                }
2480                if value > MAX_LIMIT_VALUE {
2481                    return Err(CalError::LimitExceeded {
2482                        value,
2483                        max: MAX_LIMIT_VALUE,
2484                        span: Some(lspan),
2485                    });
2486                }
2487                Ok(PipelineStage::Limit {
2488                    value,
2489                    span: Some(span),
2490                })
2491            }
2492            Some(SpannedToken {
2493                token: Token::Offset,
2494                ..
2495            }) => {
2496                self.advance();
2497                let value = self.parse_u64()?;
2498                Ok(PipelineStage::Offset {
2499                    value,
2500                    span: Some(span),
2501                })
2502            }
2503            Some(SpannedToken {
2504                token: Token::Count,
2505                ..
2506            }) => {
2507                self.advance();
2508                Ok(PipelineStage::Count { span: Some(span) })
2509            }
2510            Some(SpannedToken {
2511                token: Token::First,
2512                ..
2513            }) => {
2514                self.advance();
2515                Ok(PipelineStage::First { span: Some(span) })
2516            }
2517            Some(SpannedToken {
2518                token: Token::Subjects,
2519                ..
2520            }) => {
2521                self.advance();
2522                Ok(PipelineStage::Subjects { span: Some(span) })
2523            }
2524            Some(SpannedToken {
2525                token: Token::Objects,
2526                ..
2527            }) => {
2528                self.advance();
2529                Ok(PipelineStage::Objects { span: Some(span) })
2530            }
2531            Some(SpannedToken {
2532                token: Token::Hashes,
2533                ..
2534            }) => {
2535                self.advance();
2536                Ok(PipelineStage::Hashes { span: Some(span) })
2537            }
2538            Some(SpannedToken {
2539                token: Token::Group,
2540                ..
2541            }) => {
2542                self.advance();
2543                self.expect_exact(&Token::By)?;
2544                let field = self.parse_identifier()?;
2545                Ok(PipelineStage::GroupBy {
2546                    field,
2547                    span: Some(span),
2548                })
2549            }
2550            Some(SpannedToken {
2551                token: Token::Project,
2552                ..
2553            }) => {
2554                self.advance();
2555                let mut fields = vec![];
2556                loop {
2557                    let field = self.parse_identifier()?;
2558                    let alias = if self.eat_exact(&Token::As) {
2559                        Some(self.parse_identifier()?)
2560                    } else {
2561                        None
2562                    };
2563                    fields.push(ProjectField { field, alias });
2564                    if !self.eat_exact(&Token::Comma) {
2565                        break;
2566                    }
2567                }
2568                Ok(PipelineStage::Project {
2569                    fields,
2570                    span: Some(span),
2571                })
2572            }
2573            // `SORT field [ASC|DESC]` — alias for ORDER BY.
2574            Some(SpannedToken {
2575                token: Token::Ident(id),
2576                ..
2577            }) if id.eq_ignore_ascii_case("SORT") => {
2578                self.advance(); // consume SORT
2579                let field = self.parse_identifier()?;
2580                let descending = if self.at_exact(&Token::Desc) {
2581                    self.advance();
2582                    true
2583                } else {
2584                    self.eat_exact(&Token::Asc);
2585                    false
2586                };
2587                Ok(PipelineStage::OrderBy {
2588                    field,
2589                    descending,
2590                    span: Some(span),
2591                })
2592            }
2593            Some(st) => {
2594                let found = st.token.description();
2595                let sp = st.span;
2596                Err(CalError::UnexpectedToken {
2597                    expected: "pipeline stage (SELECT, ORDER BY, LIMIT, OFFSET, COUNT, FIRST, \
2598                         SUBJECTS, OBJECTS, HASHES, GROUP BY, PROJECT)"
2599                        .into(),
2600                    found,
2601                    span: Some(sp),
2602                    suggestion: None,
2603                })
2604            }
2605            None => Err(CalError::UnexpectedToken {
2606                expected: "pipeline stage".into(),
2607                found: "<end of query>".into(),
2608                span: None,
2609                suggestion: None,
2610            }),
2611        }
2612    }
2613
2614    // -- WITH clause ------------------------------------------------------
2615
2616    fn parse_with_clause(&mut self) -> CalResult<Vec<WithOption>> {
2617        self.expect_exact(&Token::With)?;
2618        let mut options = vec![];
2619        loop {
2620            // I-6 fix: parse_with_option returns None for unknown options
2621            // (warning already emitted); we skip without pushing.
2622            if let Some(opt) = self.parse_with_option()? {
2623                options.push(opt);
2624            }
2625            if !self.eat_exact(&Token::Comma) {
2626                break;
2627            }
2628            // Allow trailing comma: if the next token is clearly not a
2629            // WITH option candidate, break.  We now accept identifiers
2630            // as potential unknown options (I-6: warn and skip).
2631            match self.peek() {
2632                None => break,
2633                Some(SpannedToken {
2634                    token: Token::Pipe, ..
2635                }) => break,
2636                Some(SpannedToken {
2637                    token: Token::Format,
2638                    ..
2639                }) => break,
2640                _ => {
2641                    // Continue parsing — could be a known or unknown option.
2642                }
2643            }
2644        }
2645        Ok(options)
2646    }
2647
2648    /// Parse a single WITH option.
2649    ///
2650    /// Returns `Ok(Some(option))` for recognized options, `Ok(None)` for
2651    /// unknown options (I-6 fix: warns and skips without pushing a
2652    /// placeholder), and `Err` for end-of-input.
2653    fn parse_with_option(&mut self) -> CalResult<Option<WithOption>> {
2654        match self.peek() {
2655            Some(SpannedToken {
2656                token: Token::Superseded,
2657                ..
2658            }) => {
2659                self.advance();
2660                Ok(Some(WithOption::Superseded))
2661            }
2662            Some(SpannedToken {
2663                token: Token::ScoreBreakdown,
2664                ..
2665            }) => {
2666                self.advance();
2667                Ok(Some(WithOption::ScoreBreakdown))
2668            }
2669            Some(SpannedToken {
2670                token: Token::Explanation,
2671                ..
2672            }) => {
2673                self.advance();
2674                Ok(Some(WithOption::Explanation))
2675            }
2676            Some(SpannedToken {
2677                token: Token::Provenance,
2678                ..
2679            }) => {
2680                self.advance();
2681                Ok(Some(WithOption::Provenance))
2682            }
2683            Some(SpannedToken {
2684                token: Token::ContradictionDetection,
2685                ..
2686            }) => {
2687                self.advance();
2688                Ok(Some(WithOption::ContradictionDetection))
2689            }
2690            Some(SpannedToken {
2691                token: Token::Diversity,
2692                ..
2693            }) => {
2694                self.advance();
2695                let lambda = if self.at_exact(&Token::LParen) {
2696                    self.advance();
2697                    let n = self.parse_number()?;
2698                    self.expect_exact(&Token::RParen)?;
2699                    Some(n)
2700                } else {
2701                    None
2702                };
2703                Ok(Some(WithOption::Diversity { lambda }))
2704            }
2705            Some(SpannedToken {
2706                token: Token::Dedup,
2707                ..
2708            }) => {
2709                self.advance();
2710                // EBNF: `"dedup" , "(" , field_name , ")"` — argument is a field name.
2711                let field = if self.at_exact(&Token::LParen) {
2712                    self.advance();
2713                    let f = self.parse_field_name()?;
2714                    self.expect_exact(&Token::RParen)?;
2715                    Some(f)
2716                } else {
2717                    None
2718                };
2719                Ok(Some(WithOption::Dedup { field }))
2720            }
2721            // OMS §4 `progressive_disclosure` / `progressive_disclosure(level)`.
2722            Some(SpannedToken {
2723                token: Token::ProgressiveDisclosure,
2724                span,
2725                ..
2726            }) => {
2727                let span = *span;
2728                self.advance();
2729                let level = if self.at_exact(&Token::LParen) {
2730                    self.advance();
2731                    let lvl = self.parse_identifier()?;
2732                    let lvl_lc = lvl.to_ascii_lowercase();
2733                    if !matches!(lvl_lc.as_str(), "summary" | "headlines" | "full") {
2734                        return Err(CalError::UnexpectedToken {
2735                            expected: "one of: summary, headlines, full".into(),
2736                            found: lvl,
2737                            span: Some(self.prev_span()),
2738                            suggestion: Some(
2739                                "progressive_disclosure level must be summary | headlines | full (spec §4).".into(),
2740                            ),
2741                        });
2742                    }
2743                    self.expect_exact(&Token::RParen)?;
2744                    Some(lvl_lc)
2745                } else {
2746                    None
2747                };
2748                let _ = span;
2749                Ok(Some(WithOption::ProgressiveDisclosure { level }))
2750            }
2751            // OMS §4 `consistency(level)` where level ∈ eventual|bounded|linearizable.
2752            Some(SpannedToken {
2753                token: Token::Consistency,
2754                span,
2755                ..
2756            }) => {
2757                let span = *span;
2758                self.advance();
2759                self.expect_exact(&Token::LParen)?;
2760                let lvl = self.parse_identifier()?;
2761                let lvl_lc = lvl.to_ascii_lowercase();
2762                if !matches!(lvl_lc.as_str(), "eventual" | "bounded" | "linearizable") {
2763                    return Err(CalError::UnexpectedToken {
2764                        expected: "one of: eventual, bounded, linearizable".into(),
2765                        found: lvl,
2766                        span: Some(self.prev_span()),
2767                        suggestion: Some(
2768                            "consistency level must be eventual | bounded | linearizable (spec §4).".into(),
2769                        ),
2770                    });
2771                }
2772                self.expect_exact(&Token::RParen)?;
2773                self.warnings.push(CalWarning::UnknownExtensionOption {
2774                    option: "consistency (parsed but executor no-op)".into(),
2775                    span: Some(span),
2776                });
2777                Ok(Some(WithOption::Consistency {
2778                    level: Some(lvl_lc),
2779                }))
2780            }
2781            // OMS §4 `locale("en-US")`.
2782            Some(SpannedToken {
2783                token: Token::Locale,
2784                span,
2785                ..
2786            }) => {
2787                let span = *span;
2788                self.advance();
2789                self.expect_exact(&Token::LParen)?;
2790                let tag = self.parse_string_literal()?;
2791                self.expect_exact(&Token::RParen)?;
2792                self.warnings.push(CalWarning::UnknownExtensionOption {
2793                    option: "locale (parsed but executor no-op)".into(),
2794                    span: Some(span),
2795                });
2796                Ok(Some(WithOption::Locale { tag }))
2797            }
2798            // OMS §4 `cache(ttl=300)`.
2799            Some(SpannedToken {
2800                token: Token::Cache,
2801                span,
2802                ..
2803            }) => {
2804                let span = *span;
2805                self.advance();
2806                self.expect_exact(&Token::LParen)?;
2807                // Accept either `ttl=N` (spec) or a bare positive integer.
2808                if self.at_exact(&Token::Ttl) {
2809                    self.advance();
2810                    self.expect_exact(&Token::Eq)?;
2811                }
2812                let ttl_seconds = self.parse_u64()?;
2813                self.expect_exact(&Token::RParen)?;
2814                self.warnings.push(CalWarning::UnknownExtensionOption {
2815                    option: "cache (parsed but executor no-op)".into(),
2816                    span: Some(span),
2817                });
2818                Ok(Some(WithOption::Cache { ttl_seconds }))
2819            }
2820            // -- Recall feature flags (parity with HTTP/gRPC/MCP/A2A) --------
2821            Some(SpannedToken {
2822                token: Token::Rerank,
2823                ..
2824            }) => {
2825                self.advance();
2826                let model = if self.at_exact(&Token::LParen) {
2827                    self.advance();
2828                    let m = self.parse_string_literal()?;
2829                    self.expect_exact(&Token::RParen)?;
2830                    Some(m)
2831                } else {
2832                    None
2833                };
2834                Ok(Some(WithOption::Rerank { model }))
2835            }
2836            Some(SpannedToken {
2837                token: Token::LlmRerank,
2838                ..
2839            }) => {
2840                self.advance();
2841                let model = if self.at_exact(&Token::LParen) {
2842                    self.advance();
2843                    let m = self.parse_string_literal()?;
2844                    self.expect_exact(&Token::RParen)?;
2845                    Some(m)
2846                } else {
2847                    None
2848                };
2849                Ok(Some(WithOption::LlmRerank { model }))
2850            }
2851            Some(SpannedToken {
2852                token: Token::QueryExpansion,
2853                ..
2854            }) => {
2855                self.advance();
2856                Ok(Some(WithOption::QueryExpansion))
2857            }
2858            Some(SpannedToken {
2859                token: Token::QueryDecompose,
2860                ..
2861            }) => {
2862                self.advance();
2863                Ok(Some(WithOption::QueryDecompose))
2864            }
2865            Some(SpannedToken {
2866                token: Token::Hyde, ..
2867            }) => {
2868                self.advance();
2869                Ok(Some(WithOption::Hyde))
2870            }
2871            Some(SpannedToken {
2872                token: Token::ConflictResolution,
2873                ..
2874            }) => {
2875                self.advance();
2876                Ok(Some(WithOption::ConflictResolution))
2877            }
2878            Some(SpannedToken {
2879                token: Token::IncludeSources,
2880                ..
2881            }) => {
2882                self.advance();
2883                Ok(Some(WithOption::IncludeSources))
2884            }
2885            Some(SpannedToken {
2886                token: Token::AnnotateRelativeTime,
2887                ..
2888            }) => {
2889                self.advance();
2890                Ok(Some(WithOption::AnnotateRelativeTime))
2891            }
2892            Some(SpannedToken {
2893                token: Token::RecencyWeight,
2894                ..
2895            }) => {
2896                self.advance();
2897                self.expect_exact(&Token::LParen)?;
2898                let weight = self.parse_number()?;
2899                self.expect_exact(&Token::RParen)?;
2900                Ok(Some(WithOption::RecencyWeight { weight }))
2901            }
2902            Some(SpannedToken {
2903                token: Token::MinScore,
2904                ..
2905            }) => {
2906                self.advance();
2907                self.expect_exact(&Token::LParen)?;
2908                let score = self.parse_number()?;
2909                self.expect_exact(&Token::RParen)?;
2910                Ok(Some(WithOption::MinScore { score }))
2911            }
2912            Some(SpannedToken {
2913                token: Token::MultiHop,
2914                ..
2915            }) => {
2916                self.advance();
2917                self.expect_exact(&Token::LParen)?;
2918                let hops = self.parse_u64()?;
2919                self.expect_exact(&Token::RParen)?;
2920                Ok(Some(WithOption::MultiHop { hops }))
2921            }
2922
2923            Some(SpannedToken {
2924                token: Token::SessionAffinity,
2925                ..
2926            }) => {
2927                self.advance();
2928                self.expect_exact(&Token::LParen)?;
2929                let boost = self.parse_number()?;
2930                self.expect_exact(&Token::RParen)?;
2931                Ok(Some(WithOption::SessionAffinity { boost }))
2932            }
2933            Some(SpannedToken {
2934                token: Token::SubjectAffinity,
2935                ..
2936            }) => {
2937                self.advance();
2938                self.expect_exact(&Token::LParen)?;
2939                let boost = self.parse_number()?;
2940                self.expect_exact(&Token::RParen)?;
2941                Ok(Some(WithOption::SubjectAffinity { boost }))
2942            }
2943            Some(SpannedToken {
2944                token: Token::SessionCoverage,
2945                ..
2946            }) => {
2947                self.advance();
2948                self.expect_exact(&Token::LParen)?;
2949                let min_per_ns = self.parse_u64()?;
2950                self.expect_exact(&Token::RParen)?;
2951                Ok(Some(WithOption::SessionCoverage { min_per_ns }))
2952            }
2953            Some(SpannedToken {
2954                token: Token::MaxNamespaces,
2955                ..
2956            }) => {
2957                self.advance();
2958                self.expect_exact(&Token::LParen)?;
2959                let max = self.parse_u64()?;
2960                self.expect_exact(&Token::RParen)?;
2961                Ok(Some(WithOption::MaxNamespaces { max }))
2962            }
2963            Some(SpannedToken {
2964                token: Token::Exhaustive,
2965                ..
2966            }) => {
2967                self.advance();
2968                // Optional parameter: `WITH exhaustive` or `WITH exhaustive(3)`
2969                let max_rounds = if self.peek().map(|st| &st.token) == Some(&Token::LParen) {
2970                    self.expect_exact(&Token::LParen)?;
2971                    let rounds = self.parse_u64()?;
2972                    self.expect_exact(&Token::RParen)?;
2973                    Some(rounds)
2974                } else {
2975                    None
2976                };
2977                Ok(Some(WithOption::Exhaustive { max_rounds }))
2978            }
2979            Some(SpannedToken {
2980                token: Token::SessionCensus,
2981                ..
2982            }) => {
2983                self.advance();
2984                // Optional positional parameters: `WITH session_census` or
2985                // `WITH session_census(2)` or `WITH session_census(2, 0.35)`
2986                let (mut min_per_session, mut min_score) = (None, None);
2987                if self.peek().map(|st| &st.token) == Some(&Token::LParen) {
2988                    self.expect_exact(&Token::LParen)?;
2989                    min_per_session = Some(self.parse_u64()?);
2990                    if self.peek().map(|st| &st.token) == Some(&Token::Comma) {
2991                        self.advance();
2992                        min_score = Some(self.parse_number()?);
2993                    }
2994                    self.expect_exact(&Token::RParen)?;
2995                }
2996                Ok(Some(WithOption::SessionCensus {
2997                    min_per_session,
2998                    min_score,
2999                }))
3000            }
3001            Some(SpannedToken {
3002                token: Token::AggregationIntent,
3003                ..
3004            }) => {
3005                self.advance();
3006                Ok(Some(WithOption::AggregationIntent))
3007            }
3008
3009            Some(SpannedToken {
3010                token: Token::PreferenceEnrichment,
3011                ..
3012            }) => {
3013                self.advance();
3014                Ok(Some(WithOption::PreferenceEnrichment))
3015            }
3016
3017            // -- Unknown option fallback ----------------------------------
3018            Some(st) => {
3019                let found = st.token.description();
3020                let span = st.span;
3021                self.warnings.push(CalWarning::UnknownExtensionOption {
3022                    option: found,
3023                    span: Some(span),
3024                });
3025                // I-6 fix: consume the unknown token so parsing can
3026                // continue, but return None so the caller does NOT push
3027                // a placeholder Superseded variant into the options list.
3028                self.advance();
3029                Ok(None)
3030            }
3031            None => Err(CalError::UnexpectedToken {
3032                expected: "WITH option".into(),
3033                found: "<end of query>".into(),
3034                span: None,
3035                suggestion: None,
3036            }),
3037        }
3038    }
3039
3040    // -- FORMAT -----------------------------------------------------------
3041
3042    /// Maximum number of formats in a multi-format list (CAL-E110).
3043    const MAX_MULTI_FORMATS: usize = 5;
3044
3045    fn parse_format(&mut self) -> CalResult<FormatClause> {
3046        self.expect_exact(&Token::Format)?;
3047        self.parse_format_value()
3048    }
3049
3050    /// Parse `AS <format>` — §7 `as_clause`, per-query format control.
3051    ///
3052    /// Same value grammar as `FORMAT`, including bracketed multi-format
3053    /// lists. `AS OF` belongs to HISTORY and is matched before this.
3054    fn parse_as_format(&mut self) -> CalResult<FormatClause> {
3055        self.expect_exact(&Token::As)?;
3056        self.parse_format_value()
3057    }
3058
3059    fn parse_format_value(&mut self) -> CalResult<FormatClause> {
3060        // Check for multi-format list: FORMAT [json, markdown, ...]
3061        if self.at_exact(&Token::LBracket) {
3062            return self.parse_format_list();
3063        }
3064
3065        // Parse first format spec.
3066        let spec = self.parse_single_format_spec()?;
3067
3068        // Check for comma-separated multi-format: FORMAT json, markdown
3069        // (aliases are only supported in bracketed lists)
3070        if self.at_exact(&Token::Comma) {
3071            let mut entries = vec![AliasedFormat { spec, alias: None }];
3072            while self.eat_exact(&Token::Comma) {
3073                let next = self.parse_single_format_spec()?;
3074                let entry = AliasedFormat {
3075                    spec: next,
3076                    alias: None,
3077                };
3078                if !entries.iter().any(|e| e.spec == entry.spec) {
3079                    entries.push(entry);
3080                }
3081            }
3082            if entries.len() > Self::MAX_MULTI_FORMATS {
3083                return Err(CalError::TooManyFormats {
3084                    count: entries.len(),
3085                    max: Self::MAX_MULTI_FORMATS,
3086                    span: Some(self.current_span()),
3087                });
3088            }
3089            return Ok(FormatClause::Multi(entries));
3090        }
3091
3092        Ok(FormatClause::Single(spec))
3093    }
3094
3095    /// Parse a bracketed multi-format list: `[json AS alias, markdown, ...]`.
3096    /// The opening `[` has been detected but not consumed.
3097    fn parse_format_list(&mut self) -> CalResult<FormatClause> {
3098        let span_start = self.current_span();
3099        self.expect_exact(&Token::LBracket)?;
3100
3101        // Empty list is a parse error.
3102        if self.at_exact(&Token::RBracket) {
3103            return Err(CalError::UnexpectedToken {
3104                expected: "at least one format type in format list".into(),
3105                found: "]".into(),
3106                span: Some(span_start),
3107                suggestion: Some("FORMAT [json] or FORMAT [markdown, json]".into()),
3108            });
3109        }
3110
3111        let mut entries = Vec::new();
3112        let mut seen_keys = std::collections::HashSet::new();
3113        loop {
3114            let spec = self.parse_single_format_spec()?;
3115
3116            // Optional alias: `AS <name>`. A user-chosen output name is a
3117            // label, not an identifier — several perfectly ordinary alias
3118            // words (DATA, READABLE, COMPACT) are keyword tokens, so
3119            // `parse_identifier` would reject the reference manual's own
3120            // example `FORMAT [json AS data, markdown AS readable]`.
3121            let alias = if self.eat_exact(&Token::As) {
3122                let span = self.current_span();
3123                let name = self.parse_label()?;
3124                // Defence in depth (invariant 3): an alias is only a key in the
3125                // output map and cannot destroy anything, but the destructive
3126                // vocabulary stays out of CAL text everywhere it can appear.
3127                if is_destructive_keyword(&name) {
3128                    return Err(CalError::UnexpectedToken {
3129                        expected: "an output name".into(),
3130                        found: name,
3131                        span: Some(span),
3132                        suggestion: Some(
3133                            "Choose a different alias — CAL keeps destructive \
3134                             words out of its grammar entirely."
3135                                .into(),
3136                        ),
3137                    });
3138                }
3139                Some(name)
3140            } else {
3141                None
3142            };
3143
3144            // Determine the effective key for dedup/collision detection.
3145            let key = alias.as_deref().unwrap_or(spec.canonical_key()).to_string();
3146            if !seen_keys.insert(key.clone()) {
3147                // If an explicit alias collides, that's an error (CAL-E113).
3148                // If no alias was given, silently deduplicate (backward compat).
3149                if alias.is_some() {
3150                    return Err(CalError::DuplicateFormatKey {
3151                        key,
3152                        span: Some(self.current_span()),
3153                    });
3154                }
3155                // Skip duplicate non-aliased format.
3156            } else {
3157                entries.push(AliasedFormat { spec, alias });
3158            }
3159
3160            if self.eat_exact(&Token::Comma) {
3161                continue;
3162            }
3163            break;
3164        }
3165
3166        self.expect_exact(&Token::RBracket)?;
3167
3168        // Validate max count (CAL-E110).
3169        if entries.len() > Self::MAX_MULTI_FORMATS {
3170            return Err(CalError::TooManyFormats {
3171                count: entries.len(),
3172                max: Self::MAX_MULTI_FORMATS,
3173                span: Some(span_start),
3174            });
3175        }
3176
3177        Ok(FormatClause::Multi(entries))
3178    }
3179
3180    /// Parse a single format spec (json, markdown, sml, etc.).
3181    fn parse_single_format_spec(&mut self) -> CalResult<FormatSpec> {
3182        match self.peek() {
3183            Some(SpannedToken {
3184                token: Token::Json, ..
3185            }) => {
3186                self.advance();
3187                Ok(FormatSpec::Json)
3188            }
3189            Some(SpannedToken {
3190                token: Token::Yaml, ..
3191            }) => {
3192                self.advance();
3193                Ok(FormatSpec::Yaml)
3194            }
3195            Some(SpannedToken {
3196                token: Token::Markdown,
3197                ..
3198            }) => {
3199                self.advance();
3200                Ok(FormatSpec::Markdown)
3201            }
3202            Some(SpannedToken {
3203                token: Token::Text, ..
3204            }) => {
3205                self.advance();
3206                Ok(FormatSpec::Text)
3207            }
3208            Some(SpannedToken {
3209                token: Token::Sml, ..
3210            }) => {
3211                self.advance();
3212                Ok(FormatSpec::Sml)
3213            }
3214            Some(SpannedToken {
3215                token: Token::Toon, ..
3216            }) => {
3217                self.advance();
3218                Ok(FormatSpec::Toon)
3219            }
3220            Some(SpannedToken {
3221                token: Token::Triples,
3222                ..
3223            }) => {
3224                self.advance();
3225                Ok(FormatSpec::Triples)
3226            }
3227            // §10.1 semantic presets. These are aliases, not distinct
3228            // renderers: `structured`/`sml`, `readable`/`markdown`,
3229            // `compact`/`text`, `data`/`json`.
3230            Some(SpannedToken {
3231                token: Token::Structured,
3232                ..
3233            }) => {
3234                self.advance();
3235                Ok(FormatSpec::Sml)
3236            }
3237            Some(SpannedToken {
3238                token: Token::Readable,
3239                ..
3240            }) => {
3241                self.advance();
3242                Ok(FormatSpec::Markdown)
3243            }
3244            Some(SpannedToken {
3245                token: Token::Compact,
3246                ..
3247            }) => {
3248                self.advance();
3249                Ok(FormatSpec::Text)
3250            }
3251            Some(SpannedToken {
3252                token: Token::Data, ..
3253            }) => {
3254                self.advance();
3255                Ok(FormatSpec::Json)
3256            }
3257            Some(SpannedToken {
3258                token: Token::Template,
3259                ..
3260            }) => {
3261                self.advance();
3262                // §10.6 / §10.6.1 — three forms, told apart by token class so
3263                // no lookahead is needed:
3264                //   TEMPLATE <ident>   a registered template
3265                //   TEMPLATE "<text>"  an inline ELEMENT shorthand
3266                //   TEMPLATE { ... }   inline sections
3267                match self.peek() {
3268                    // A name here is whatever `DEFINE TEMPLATE` accepted as
3269                    // one, which is `parse_label`, not `parse_identifier`:
3270                    // preset names (`structured`, `compact`, …) are keyword
3271                    // tokens. Matching only `Ident` made a template nameable
3272                    // but not referenceable.
3273                    Some(st) if Self::is_word_token(&st.token) => {
3274                        Ok(FormatSpec::TemplateRef {
3275                            name: self.parse_template_name()?,
3276                        })
3277                    }
3278                    Some(SpannedToken {
3279                        token: Token::LBrace,
3280                        ..
3281                    }) => {
3282                        self.advance();
3283                        let sections = self.parse_template_sections()?;
3284                        self.expect_exact(&Token::RBrace)?;
3285                        if sections.is_empty() {
3286                            return Err(CalError::TemplateSyntaxError {
3287                                detail: "inline TEMPLATE must define at least one section"
3288                                    .to_string(),
3289                                span: Some(self.current_span()),
3290                            });
3291                        }
3292                        Ok(FormatSpec::TemplateInline { sections })
3293                    }
3294                    _ => {
3295                        let template = self.parse_string_literal()?;
3296                        Ok(FormatSpec::Template { template })
3297                    }
3298                }
3299            }
3300            Some(SpannedToken {
3301                token: Token::Ident(name),
3302                ..
3303            }) if name.eq_ignore_ascii_case("preset") => {
3304                self.advance();
3305                // Parse preset("template_name") or preset "template_name".
3306                let preset_name = if self.at_exact(&Token::LParen) {
3307                    self.advance(); // consume (
3308                    let n = self.parse_string_literal()?;
3309                    self.expect_exact(&Token::RParen)?;
3310                    n
3311                } else {
3312                    self.parse_string_literal()?
3313                };
3314                Ok(FormatSpec::Preset { name: preset_name })
3315            }
3316            Some(SpannedToken {
3317                token: Token::Ident(name),
3318                ..
3319            }) if name.eq_ignore_ascii_case("csv") => {
3320                self.advance();
3321                Ok(FormatSpec::Csv)
3322            }
3323            Some(SpannedToken {
3324                token: Token::Ident(name),
3325                ..
3326            }) if name.eq_ignore_ascii_case("table") => {
3327                self.advance();
3328                Ok(FormatSpec::Table)
3329            }
3330            Some(SpannedToken {
3331                token: Token::Ident(name),
3332                ..
3333            }) => {
3334                let name = name.clone();
3335                let span = self.current_span();
3336                Err(CalError::UnexpectedToken {
3337                    expected: "format type (json, yaml, markdown, text, sml, toon, triples, csv, table, template, or preset)".into(),
3338                    found: name,
3339                    span: Some(span),
3340                    suggestion: Some("Valid formats: json, yaml, markdown, text, sml, toon, triples, csv, table, template \"...\", preset(\"...\")".into()),
3341                })
3342            }
3343            Some(st) => {
3344                let found = st.token.description();
3345                let span = st.span;
3346                Err(CalError::UnexpectedToken {
3347                    expected: "format type (json, yaml, markdown, text, sml, toon, triples, or preset name)".into(),
3348                    found,
3349                    span: Some(span),
3350                    suggestion: None,
3351                })
3352            }
3353            None => Err(CalError::UnexpectedToken {
3354                expected: "format type".into(),
3355                found: "<end of query>".into(),
3356                span: None,
3357                suggestion: None,
3358            }),
3359        }
3360    }
3361
3362    // -- WITH VARS --------------------------------------------------------
3363
3364    /// Maximum number of user variables in a `WITH VARS` clause.
3365    const MAX_USER_VARS: usize = 10;
3366
3367    /// Maximum size in bytes of a single user variable value.
3368    const MAX_USER_VAR_SIZE: usize = 1024;
3369
3370    /// Peek ahead to check if the token after `WITH` is `VARS`.
3371    ///
3372    /// Does NOT consume any tokens. Returns `false` if the current token
3373    /// is not `WITH` or if the next token is not `VARS`.
3374    /// True when the next token after `AS` is `OF` — the HISTORY temporal
3375    /// clause, which must not be mistaken for per-query format control.
3376    fn peek_next_is_of(&self) -> bool {
3377        matches!(
3378            self.tokens.get(self.pos + 1),
3379            Some(SpannedToken {
3380                token: Token::Of,
3381                ..
3382            })
3383        )
3384    }
3385
3386    fn peek_next_is_vars(&self) -> bool {
3387        if !self.at_exact(&Token::With) {
3388            return false;
3389        }
3390        // Look at the token after WITH.
3391        matches!(
3392            self.tokens.get(self.pos + 1),
3393            Some(SpannedToken {
3394                token: Token::Vars,
3395                ..
3396            })
3397        )
3398    }
3399
3400    /// Parse `WITH VARS { "key": "value", ... }`.
3401    ///
3402    /// The caller has verified that the current token is `WITH` and the
3403    /// next is `VARS` via `peek_next_is_vars()`.
3404    fn parse_user_vars(&mut self) -> CalResult<HashMap<String, String>> {
3405        let span_start = self.current_span();
3406        self.expect_exact(&Token::With)?;
3407        self.expect_exact(&Token::Vars)?;
3408        self.expect_exact(&Token::LBrace)?;
3409
3410        let mut vars = HashMap::new();
3411
3412        // Empty braces: WITH VARS { }
3413        if self.at_exact(&Token::RBrace) {
3414            self.advance();
3415            return Ok(vars);
3416        }
3417
3418        loop {
3419            // Key: must be a string literal.
3420            let key = self.parse_string_literal()?;
3421
3422            // Validate key is a valid identifier: [a-zA-Z_][a-zA-Z0-9_]*
3423            if key.is_empty()
3424                || (!key.as_bytes()[0].is_ascii_alphabetic() && key.as_bytes()[0] != b'_')
3425            {
3426                return Err(CalError::UnexpectedToken {
3427                    expected: "valid variable name (must start with a letter or underscore)".into(),
3428                    found: key,
3429                    span: Some(span_start),
3430                    suggestion: Some("variable names must match [a-zA-Z_][a-zA-Z0-9_]*".into()),
3431                });
3432            }
3433            if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
3434                return Err(CalError::UnexpectedToken {
3435                    expected: "valid variable name (alphanumeric and underscores only)".into(),
3436                    found: key,
3437                    span: Some(span_start),
3438                    suggestion: Some("variable names must match [a-zA-Z_][a-zA-Z0-9_]*".into()),
3439                });
3440            }
3441
3442            // Colon separator.
3443            self.expect_exact(&Token::Colon)?;
3444
3445            // Value: must be a string literal.
3446            let value = self.parse_string_literal()?;
3447
3448            // Validate value size (max 1KB).
3449            if value.len() > Self::MAX_USER_VAR_SIZE {
3450                return Err(CalError::UserVarTooLarge {
3451                    key,
3452                    size: value.len(),
3453                    max: Self::MAX_USER_VAR_SIZE,
3454                    span: Some(span_start),
3455                });
3456            }
3457
3458            vars.insert(key, value);
3459
3460            // Check max count.
3461            if vars.len() > Self::MAX_USER_VARS {
3462                return Err(CalError::TooManyUserVars {
3463                    count: vars.len(),
3464                    max: Self::MAX_USER_VARS,
3465                    span: Some(span_start),
3466                });
3467            }
3468
3469            // Comma or closing brace.
3470            if !self.eat_exact(&Token::Comma) {
3471                break;
3472            }
3473            // Allow trailing comma.
3474            if self.at_exact(&Token::RBrace) {
3475                break;
3476            }
3477        }
3478
3479        self.expect_exact(&Token::RBrace)?;
3480
3481        Ok(vars)
3482    }
3483
3484    // -- ASSEMBLE ---------------------------------------------------------
3485
3486    fn parse_assemble(&mut self) -> CalResult<CalStatement> {
3487        let span_start = self.current_span();
3488        self.expect_exact(&Token::Assemble)?;
3489
3490        // Optional topic/context name. EBNF `context_name = identifier`, so
3491        // both bare identifiers and quoted strings are accepted.
3492        let topic = if self.at(&Token::StringLiteral("".into())) {
3493            self.parse_string_literal()?
3494        } else if let Some(SpannedToken {
3495            token: Token::Ident(word),
3496            ..
3497        }) = self.peek()
3498        {
3499            let word = word.clone();
3500            self.advance();
3501            word
3502        } else {
3503            String::new()
3504        };
3505        // Per OMS §8.2 ASSEMBLE constraints: max context_name length 64.
3506        const MAX_CONTEXT_NAME_LEN: usize = 64;
3507        if topic.chars().count() > MAX_CONTEXT_NAME_LEN {
3508            return Err(CalError::UnexpectedToken {
3509                expected: format!("context name ≤ {} characters", MAX_CONTEXT_NAME_LEN),
3510                found: format!("{}-character context name", topic.chars().count()),
3511                span: Some(span_start),
3512                suggestion: Some(format!(
3513                    "Shorten the ASSEMBLE context name (spec §8.2: max {} chars).",
3514                    MAX_CONTEXT_NAME_LEN
3515                )),
3516            });
3517        }
3518        let context_name = if !topic.is_empty() {
3519            Some(topic.clone())
3520        } else {
3521            None
3522        };
3523
3524        // Optional `FOR "..."` — bounded at 256 chars per OMS §8.2.
3525        const MAX_FOR_LEN: usize = 256;
3526        let for_whom = if self.at_exact(&Token::For) {
3527            self.advance();
3528            let s = self.parse_string_literal()?;
3529            if s.chars().count() > MAX_FOR_LEN {
3530                return Err(CalError::UnexpectedToken {
3531                    expected: format!("FOR string ≤ {} characters", MAX_FOR_LEN),
3532                    found: format!("{}-character FOR clause", s.chars().count()),
3533                    span: Some(span_start),
3534                    suggestion: Some(format!(
3535                        "Shorten the ASSEMBLE FOR string (spec §8.2: max {} chars).",
3536                        MAX_FOR_LEN
3537                    )),
3538                });
3539            }
3540            Some(s)
3541        } else {
3542            None
3543        };
3544
3545        // `FROM source` — try multi-source first, fall back to single source.
3546        self.expect_exact(&Token::From)?;
3547        let (from, sources) = self.parse_assemble_from_clause()?;
3548
3549        // Issue 2: validate multi-source constraints (CAL-E032, CAL-E034).
3550        if let Some(ref srcs) = sources {
3551            // CAL-E032: Too many sources (max 8).
3552            if srcs.len() > 8 {
3553                return Err(CalError::AssembleTooManySources {
3554                    count: srcs.len(),
3555                    max: 8,
3556                    span: Some(span_start),
3557                });
3558            }
3559            // CAL-E034: Duplicate source labels.
3560            let mut seen_labels = std::collections::HashSet::new();
3561            for src in srcs {
3562                if !seen_labels.insert(&src.label) {
3563                    return Err(CalError::AssembleDuplicateLabel {
3564                        label: src.label.clone(),
3565                        span: src.span,
3566                    });
3567                }
3568            }
3569        }
3570
3571        // Optional WHERE.
3572        let where_clause = self.parse_where_clause()?;
3573
3574        // Issue 6: WHERE is not supported with multi-source ASSEMBLE.
3575        if sources.is_some() && where_clause.is_some() {
3576            return Err(CalError::UnexpectedToken {
3577                expected: "BUDGET, PRIORITY, FORMAT, or WITH".into(),
3578                found: "WHERE".into(),
3579                span: where_clause.as_ref().and_then(|wc| wc.span),
3580                suggestion: Some(
3581                    "WHERE is not supported with multi-source ASSEMBLE — apply WHERE inside each source's RECALL instead".into(),
3582                ),
3583            });
3584        }
3585
3586        // Optional BUDGET clause.
3587        let budget = if self.at_exact(&Token::Budget) {
3588            self.advance();
3589            let bspan = self.current_span();
3590            let raw = self.parse_u64()?;
3591            // Issue 7: range check before u64→u32 cast.
3592            if raw > u32::MAX as u64 {
3593                return Err(CalError::AssembleBudgetExceeded {
3594                    value: raw,
3595                    max: 16000,
3596                    unit: "tokens".into(),
3597                    span: Some(bspan),
3598                });
3599            }
3600            let tokens = raw as u32;
3601            // Issue 1: optionally consume a unit suffix (`tokens` or `grains`).
3602            let unit = if let Some(SpannedToken {
3603                token: Token::Ident(id),
3604                ..
3605            }) = self.peek()
3606            {
3607                match id.to_ascii_lowercase().as_str() {
3608                    "tokens" => {
3609                        self.advance();
3610                        BudgetUnit::Tokens
3611                    }
3612                    "grains" => {
3613                        self.advance();
3614                        BudgetUnit::Grains
3615                    }
3616                    _ => BudgetUnit::Tokens,
3617                }
3618            } else {
3619                BudgetUnit::Tokens
3620            };
3621            // Validate budget range (CAL-E033).
3622            if tokens == 0 || tokens > 16000 {
3623                return Err(CalError::AssembleBudgetExceeded {
3624                    value: tokens as u64,
3625                    max: 16000,
3626                    unit: match unit {
3627                        BudgetUnit::Tokens => "tokens",
3628                        BudgetUnit::Grains => "grains",
3629                    }
3630                    .into(),
3631                    span: Some(bspan),
3632                });
3633            }
3634            Some(BudgetSpec {
3635                tokens,
3636                unit,
3637                span: Some(bspan),
3638            })
3639        } else {
3640            None
3641        };
3642
3643        // Optional PRIORITY clause — supports two syntaxes:
3644        //   Weighted:  PRIORITY label1: 0.7, label2: 0.3
3645        //   Ordering:  PRIORITY label1 > label2 > label3
3646        let priority = if self.at_exact(&Token::Priority) {
3647            self.advance();
3648            // Peek after the first label to decide which syntax. Only the
3649            // weighted form requires a colon — anything else (including a
3650            // single label followed by FORMAT/WITH/end-of-stmt) is treated as
3651            // ordering. Without this, `PRIORITY label FORMAT ...` falls into
3652            // the weighted branch and fails with `expected :, found FORMAT`.
3653            let is_weighted = matches!(
3654                (self.peek_ahead(0), self.peek_ahead(1)),
3655                (
3656                    Some(_),
3657                    Some(SpannedToken {
3658                        token: Token::Colon,
3659                        ..
3660                    })
3661                )
3662            );
3663            if !is_weighted {
3664                // Ordering syntax: PRIORITY a > b > c
3665                let mut labels = vec![];
3666                loop {
3667                    let pspan = self.current_span();
3668                    let label = self.parse_label()?;
3669                    labels.push((label, pspan));
3670                    if !self.eat_exact(&Token::Gt) {
3671                        break;
3672                    }
3673                }
3674                // Assign evenly spaced weights: first=1.0, last=1/N.
3675                let n = labels.len() as f64;
3676                let specs = labels
3677                    .into_iter()
3678                    .enumerate()
3679                    .map(|(i, (label, pspan))| PrioritySpec {
3680                        label,
3681                        weight: (n - i as f64) / n,
3682                        span: Some(pspan),
3683                    })
3684                    .collect();
3685                Some(specs)
3686            } else {
3687                // Weighted syntax: PRIORITY label1: 0.7, label2: 0.3
3688                let mut specs = vec![];
3689                loop {
3690                    let pspan = self.current_span();
3691                    let label = self.parse_label()?;
3692                    self.expect_exact(&Token::Colon)?;
3693                    let weight = self.parse_number()?;
3694                    // Validate priority weight range 0.0..=1.0.
3695                    if !(0.0..=1.0).contains(&weight) {
3696                        return Err(CalError::UnexpectedToken {
3697                            expected: "weight between 0.0 and 1.0".into(),
3698                            found: format!("{}", weight),
3699                            span: Some(pspan),
3700                            suggestion: Some(
3701                                "PRIORITY weights must be in the range 0.0 to 1.0".into(),
3702                            ),
3703                        });
3704                    }
3705                    specs.push(PrioritySpec {
3706                        label,
3707                        weight,
3708                        span: Some(pspan),
3709                    });
3710                    if !self.eat_exact(&Token::Comma) {
3711                        break;
3712                    }
3713                }
3714                Some(specs)
3715            }
3716        } else {
3717            None
3718        };
3719
3720        // Issue 2: validate priority labels match source labels (CAL-E035).
3721        if let (Some(ref prio_specs), Some(ref srcs)) = (&priority, &sources) {
3722            let source_labels: std::collections::HashSet<&str> =
3723                srcs.iter().map(|s| s.label.as_str()).collect();
3724            for ps in prio_specs {
3725                if !source_labels.contains(ps.label.as_str()) {
3726                    return Err(CalError::AssemblePriorityMismatch {
3727                        label: ps.label.clone(),
3728                        span: ps.span,
3729                    });
3730                }
3731            }
3732        }
3733
3734        // Optional FORMAT clause (assemble-specific, before pipeline).
3735        let format = if self.at_exact(&Token::Format) {
3736            Some(self.parse_format()?)
3737        } else {
3738            None
3739        };
3740
3741        // Optional assemble-specific WITH clause.
3742        // Issue 1: only consume WITH if the next token is `dedup` (an
3743        // ASSEMBLE-specific WITH option).  Otherwise leave WITH for the
3744        // top-level WITH parser to handle (e.g. `WITH rerank`).
3745        //
3746        // The list may mix dedup with general recall-tuning options
3747        // (`WITH dedup, recency_weight(0.7)`), so parse it with the full WITH
3748        // parser: `dedup` stays in `assemble_with`, and the rest go onto the
3749        // AssembleStmt's OWN `with_options` — scoped to this assemble's recall.
3750        // (They used to be pushed onto the shared `pending_with_options` field
3751        // and drained by the enclosing `parse_statement_full`, which bound them
3752        // to the WRONG query when the ASSEMBLE was nested under EXPLAIN /
3753        // COALESCE / parens / brace / an assemble-source.)
3754        let (assemble_with, assemble_with_options) = if self.at_exact(&Token::With)
3755            && matches!(
3756                self.peek_ahead(1),
3757                Some(SpannedToken {
3758                    token: Token::Dedup,
3759                    ..
3760                })
3761            ) {
3762            let mut aw = Vec::new();
3763            let mut opts = Vec::new();
3764            for opt in self.parse_with_clause()? {
3765                match opt {
3766                    WithOption::Dedup { field } => aw.push(AssembleWithOption::Dedup { field }),
3767                    other => opts.push(other),
3768                }
3769            }
3770            (aw, opts)
3771        } else {
3772            (Vec::new(), Vec::new())
3773        };
3774
3775        // ── Out-of-order clauses are an error, not a silent detach ───────
3776        //
3777        // ASSEMBLE's clause order is fixed (OMS §8.2). Every clause is parsed
3778        // by an `if self.at_exact(...)` that simply doesn't fire when the
3779        // clause arrives late, so a misordered statement PARSED FINE and ran
3780        // with the clause missing: `… FORMAT markdown BUDGET 900` silently
3781        // dropped the budget and assembled to the 4000-token default. That is
3782        // a security-adjacent failure — the guard you wrote is not the guard
3783        // that ran — and it was a known bug (docs/facts/context-assembly.md
3784        // §12) rather than an unknown one. Anything still sitting here that
3785        // belongs to an earlier clause is now refused by name.
3786        if let Some(st) = self.peek() {
3787            let late = match st.token {
3788                Token::Budget => Some("BUDGET"),
3789                Token::Priority => Some("PRIORITY"),
3790                Token::For => Some("FOR"),
3791                Token::Where => Some("WHERE"),
3792                Token::Format => Some("FORMAT"),
3793                Token::From => Some("FROM"),
3794                _ => None,
3795            };
3796            if let Some(clause) = late {
3797                return Err(CalError::UnexpectedToken {
3798                    expected: "end of the ASSEMBLE statement".into(),
3799                    found: format!("{clause} clause out of order"),
3800                    span: Some(st.span),
3801                    suggestion: Some(
3802                        "ASSEMBLE clauses are ordered: ASSEMBLE \"name\" FOR \"…\" FROM … \
3803                         [WHERE …] BUDGET n PRIORITY … FORMAT … WITH dedup. A clause written \
3804                         out of order used to be dropped silently — put it in this order."
3805                            .into(),
3806                    ),
3807                });
3808            }
3809        }
3810
3811        let span_end = self.prev_span();
3812        Ok(CalStatement::Assemble(AssembleStmt {
3813            topic,
3814            from,
3815            where_clause,
3816            context_name,
3817            sources,
3818            budget,
3819            priority,
3820            format,
3821            for_whom,
3822            assemble_with,
3823            with_options: assemble_with_options,
3824            streaming: false,
3825            span: Some(Span::new(
3826                span_start.start,
3827                span_end.end,
3828                span_start.line,
3829                span_start.col,
3830            )),
3831        }))
3832    }
3833
3834    /// Parse the FROM clause of ASSEMBLE, supporting both single-source and
3835    /// multi-source (`label1: (RECALL ...), label2: (RECALL ...)`) forms.
3836    fn parse_assemble_from_clause(&mut self) -> CalResult<(Source, Option<Vec<NamedSource>>)> {
3837        // Peek ahead to check for `label:` pattern (multi-source).
3838        // Labels can be identifiers or keyword tokens (e.g. `recent:`).
3839        let is_multi_source = matches!(
3840            (self.peek(), self.peek_ahead(1)),
3841            (
3842                Some(st),
3843                Some(SpannedToken {
3844                    token: Token::Colon,
3845                    ..
3846                }),
3847            ) if matches!(st.token, Token::Ident(_)) || Self::is_word_token(&st.token)
3848        );
3849
3850        if is_multi_source {
3851            let mut sources = vec![];
3852            loop {
3853                let sspan = self.current_span();
3854                let label = self.parse_label()?;
3855                self.expect_exact(&Token::Colon)?;
3856                // `PIN` marks the source non-degradable. It sits between the
3857                // colon and the body so it reads as a property of the body —
3858                // `guardrail: PIN LITERAL "…"`, `turns: PIN (RECALL …)` — and
3859                // so a source may still be LABELLED `pin`.
3860                let pinned = self.eat_exact(&Token::Pin);
3861                // `LITERAL "…"` — host text instead of a query.
3862                if self.at_exact(&Token::Literal) {
3863                    self.advance();
3864                    let text = self.parse_string_literal()?;
3865                    sources.push(NamedSource {
3866                        label,
3867                        // Placeholder: a literal source is never executed. Keeps
3868                        // the serialized AST shape stable for existing readers.
3869                        query: Box::new(CalStatement::Assemble(AssembleStmt {
3870                            topic: String::new(),
3871                            from: Source::Parameter {
3872                                name: "_literal".to_string(),
3873                            },
3874                            where_clause: None,
3875                            context_name: None,
3876                            sources: None,
3877                            budget: None,
3878                            priority: None,
3879                            format: None,
3880                            for_whom: None,
3881                            assemble_with: Vec::new(),
3882                            with_options: Vec::new(),
3883                            streaming: false,
3884                            span: Some(sspan),
3885                        })),
3886                        literal: Some(text),
3887                        pinned,
3888                        with_options: Vec::new(),
3889                        span: Some(sspan),
3890                    });
3891                    if !self.eat_exact(&Token::Comma) {
3892                        break;
3893                    }
3894                    continue;
3895                }
3896                // Parse the sub-query (in parentheses or bare RECALL),
3897                // accepting an optional WITH clause INSIDE the parens.
3898                let (query, inside_with) = self.parse_assemble_source_query()?;
3899                // Parse optional outside-paren WITH options (back-compat).
3900                let mut with_options = inside_with;
3901                let outside_with = if self.at_exact(&Token::With) && !self.peek_next_is_vars() {
3902                    self.parse_with_clause()?
3903                } else {
3904                    vec![]
3905                };
3906                with_options.extend(outside_with); // Q1: inside-paren first, then outside-paren
3907                sources.push(NamedSource {
3908                    label,
3909                    query: Box::new(query),
3910                    literal: None,
3911                    pinned,
3912                    with_options,
3913                    span: Some(sspan),
3914                });
3915                if !self.eat_exact(&Token::Comma) {
3916                    break;
3917                }
3918            }
3919            // Use the first source as the single `from` for backward compat.
3920            let default_from = Source::Parameter {
3921                name: "_multi_source".to_string(),
3922            };
3923            Ok((default_from, Some(sources)))
3924        } else {
3925            let from = self.parse_assemble_source()?;
3926            Ok((from, None))
3927        }
3928    }
3929
3930    /// Parse ASSEMBLE-specific WITH options (dedup).
3931    fn parse_assemble_source(&mut self) -> CalResult<Source> {
3932        match self.peek() {
3933            Some(SpannedToken {
3934                token: Token::Parameter(_),
3935                ..
3936            }) => {
3937                let name = self.parse_parameter()?;
3938                Ok(Source::Parameter { name })
3939            }
3940            Some(SpannedToken {
3941                token: Token::LParen,
3942                ..
3943            }) => {
3944                // Sub-query.
3945                self.advance();
3946                self.enter_nesting()?;
3947                // Expect RECALL inside.
3948                let inner = self.parse_recall_stmt()?;
3949                self.leave_nesting();
3950                self.expect_exact(&Token::RParen)?;
3951                Ok(Source::Query(Box::new(inner)))
3952            }
3953            Some(SpannedToken {
3954                token: Token::HashLiteral(_),
3955                ..
3956            }) => {
3957                let mut hashes = vec![];
3958                while self.at(&Token::HashLiteral("".into())) {
3959                    hashes.push(self.parse_hash_literal()?);
3960                    if !self.eat_exact(&Token::Comma) {
3961                        break;
3962                    }
3963                }
3964                Ok(Source::Hashes(hashes))
3965            }
3966            Some(SpannedToken {
3967                token: Token::Recall,
3968                ..
3969            }) => {
3970                // Unparenthesised RECALL.
3971                let inner = self.parse_recall_stmt()?;
3972                Ok(Source::Query(Box::new(inner)))
3973            }
3974            // Bare grain type plural (e.g. `facts`, `events`) — synthesize an
3975            // implicit RECALL with optional WHERE and RECENT clauses.
3976            Some(SpannedToken {
3977                token: Token::Ident(id),
3978                ..
3979            }) if GrainTypePlural::parse(id).is_some() => {
3980                let span = self.current_span();
3981                // Extract the ident string and advance.
3982                let grain_type_str = if let Some(SpannedToken {
3983                    token: Token::Ident(id),
3984                    ..
3985                }) = self.advance()
3986                {
3987                    id.clone()
3988                } else {
3989                    "facts".to_string()
3990                };
3991                let grain_type =
3992                    GrainTypePlural::parse(&grain_type_str).unwrap_or(GrainTypePlural::Facts);
3993                let where_clause = self.parse_where_clause()?;
3994                let recent = self.parse_recent_clause()?;
3995                let inner = RecallStmt {
3996                    grain_type,
3997                    about: None,
3998                    where_clause,
3999                    recent,
4000                    since: None,
4001                    until: None,
4002                    like: None,
4003                    between: None,
4004                    contradictions: None,
4005                    limit: None,
4006                    as_format: None,
4007                    span: Some(span),
4008                };
4009                Ok(Source::Query(Box::new(inner)))
4010            }
4011            Some(st) => {
4012                let found = st.token.description();
4013                let span = st.span;
4014                Err(CalError::UnexpectedToken {
4015                    expected:
4016                        "source (RECALL ..., $parameter, sha256:..., grain type, or (RECALL ...))"
4017                            .into(),
4018                    found,
4019                    span: Some(span),
4020                    suggestion: None,
4021                })
4022            }
4023            None => Err(CalError::UnexpectedToken {
4024                expected: "FROM source".into(),
4025                found: "<end of query>".into(),
4026                span: None,
4027                suggestion: None,
4028            }),
4029        }
4030    }
4031
4032    // -- EXISTS -----------------------------------------------------------
4033
4034    fn parse_exists(&mut self) -> CalResult<CalStatement> {
4035        let span_start = self.current_span();
4036        self.expect_exact(&Token::Exists)?;
4037
4038        // EXISTS can be followed by:
4039        // - `sha256:...` hash literal — direct existence check
4040        // - `$param` — parameterised hash
4041        // - grain type + optional WHERE — set existence check
4042        match self.peek() {
4043            Some(SpannedToken {
4044                token: Token::HashLiteral(_),
4045                ..
4046            }) => {
4047                let hash = self.parse_hash_literal()?;
4048                // Desugar to ExistsStmt with a WHERE hash = hash condition.
4049                let span_end = self.prev_span();
4050                Ok(CalStatement::Exists(ExistsStmt {
4051                    grain_type: GrainTypePlural::All,
4052                    where_clause: Some(WhereClause {
4053                        condition: Condition::Comparison {
4054                            field: "hash".into(),
4055                            comparator: Comparator::Eq,
4056                            value: Value::Hash { value: hash },
4057                            span: None,
4058                        },
4059                        span: None,
4060                    }),
4061                    about: None,
4062                    span: Some(Span::new(
4063                        span_start.start,
4064                        span_end.end,
4065                        span_start.line,
4066                        span_start.col,
4067                    )),
4068                }))
4069            }
4070            Some(SpannedToken {
4071                token: Token::Parameter(_),
4072                ..
4073            }) => {
4074                let name = self.parse_parameter()?;
4075                let span_end = self.prev_span();
4076                Ok(CalStatement::Exists(ExistsStmt {
4077                    grain_type: GrainTypePlural::All,
4078                    where_clause: Some(WhereClause {
4079                        condition: Condition::Comparison {
4080                            field: "hash".into(),
4081                            comparator: Comparator::Eq,
4082                            value: Value::Parameter { name },
4083                            span: None,
4084                        },
4085                        span: None,
4086                    }),
4087                    about: None,
4088                    span: Some(Span::new(
4089                        span_start.start,
4090                        span_end.end,
4091                        span_start.line,
4092                        span_start.col,
4093                    )),
4094                }))
4095            }
4096            _ => {
4097                // Grain-type + optional WHERE form.
4098                let grain_type = self.parse_grain_type_plural()?;
4099                let about = self.parse_about_clause()?;
4100                let where_clause = self.parse_where_clause()?;
4101                let span_end = self.prev_span();
4102                Ok(CalStatement::Exists(ExistsStmt {
4103                    grain_type,
4104                    where_clause,
4105                    about,
4106                    span: Some(Span::new(
4107                        span_start.start,
4108                        span_end.end,
4109                        span_start.line,
4110                        span_start.col,
4111                    )),
4112                }))
4113            }
4114        }
4115    }
4116
4117    // -- HISTORY ----------------------------------------------------------
4118
4119    fn parse_history(&mut self) -> CalResult<CalStatement> {
4120        let span_start = self.current_span();
4121        self.expect_exact(&Token::History)?;
4122
4123        // Optional `OF` keyword.
4124        self.eat_exact(&Token::Of);
4125
4126        // Two forms:
4127        // 1. `HISTORY [OF] sha256:... [DIFF sha256:...]` — hash-based
4128        // 2. `HISTORY WHERE subject = ... AND relation = ...` — triple-based (Phase 2)
4129        if self.at_exact(&Token::Where) {
4130            // Phase 2: WHERE-based history lookup.
4131            let where_clause = self.parse_where_clause()?;
4132
4133            let diff_target: Option<String> = if self.at_exact(&Token::Diff) {
4134                self.advance();
4135                Some(self.parse_hash_literal()?)
4136            } else {
4137                None
4138            };
4139
4140            let span_end = self.prev_span();
4141            return Ok(CalStatement::History(HistoryStmt {
4142                hash: String::new(),
4143                where_clause,
4144                diff_target,
4145                span: Some(Span::new(
4146                    span_start.start,
4147                    span_end.end,
4148                    span_start.line,
4149                    span_start.col,
4150                )),
4151            }));
4152        }
4153
4154        // Phase 1: hash-based history lookup.
4155        let hash = self.parse_hash_literal()?;
4156
4157        // Optional `DIFF <hash>`.
4158        let diff_target: Option<String> = if self.at_exact(&Token::Diff) {
4159            self.advance();
4160            Some(self.parse_hash_literal()?)
4161        } else {
4162            None
4163        };
4164
4165        let span_end = self.prev_span();
4166        Ok(CalStatement::History(HistoryStmt {
4167            hash,
4168            where_clause: None,
4169            diff_target,
4170            span: Some(Span::new(
4171                span_start.start,
4172                span_end.end,
4173                span_start.line,
4174                span_start.col,
4175            )),
4176        }))
4177    }
4178
4179    // -- EXPLAIN ----------------------------------------------------------
4180
4181    fn parse_explain(&mut self) -> CalResult<CalStatement> {
4182        let span_start = self.current_span();
4183        self.expect_exact(&Token::Explain)?;
4184        let inner = self.parse_statement()?;
4185        let span_end = self.prev_span();
4186
4187        // EXPLAIN only wraps statements that produce an execution plan
4188        // (RECALL, ASSEMBLE, SetOp, ADD, SUPERSEDE, REVERT, BATCH, COALESCE).
4189        // DESCRIBE / template / saved-query statements have no plan.
4190        match &inner {
4191            CalStatement::Describe(_)
4192            | CalStatement::DefineTemplate(_)
4193            | CalStatement::DropTemplate(_)
4194            | CalStatement::DefineQuery(_)
4195            | CalStatement::DropQuery(_) => {
4196                return Err(CalError::UnexpectedToken {
4197                    expected: "RECALL, ASSEMBLE, set operation, ADD, SUPERSEDE, REVERT, BATCH, or COALESCE".into(),
4198                    found: format!("{} statement", match &inner {
4199                        CalStatement::Describe(_) => "DESCRIBE",
4200                        CalStatement::DefineTemplate(_) => "DEFINE TEMPLATE",
4201                        CalStatement::DropTemplate(_) => "DROP TEMPLATE",
4202                        CalStatement::DefineQuery(_) => "DEFINE QUERY",
4203                        CalStatement::DropQuery(_) => "DROP QUERY",
4204                        _ => unreachable!(),
4205                    }),
4206                    span: Some(span_start),
4207                    suggestion: Some(
4208                        "EXPLAIN may only wrap statements that produce an execution plan (spec §8.5).".into(),
4209                    ),
4210                });
4211            }
4212            _ => {}
4213        }
4214
4215        Ok(CalStatement::Explain(ExplainStmt {
4216            inner: Box::new(inner),
4217            span: Some(Span::new(
4218                span_start.start,
4219                span_end.end,
4220                span_start.line,
4221                span_start.col,
4222            )),
4223        }))
4224    }
4225
4226    // -- DESCRIBE ---------------------------------------------------------
4227
4228    fn parse_describe(&mut self) -> CalResult<CalStatement> {
4229        let span_start = self.current_span();
4230        self.expect_exact(&Token::Describe)?;
4231
4232        let target = match self.peek() {
4233            Some(SpannedToken {
4234                token: Token::Ident(s),
4235                ..
4236            }) => {
4237                let s = s.clone();
4238                // Try to parse as grain type first.
4239                if let Some(gt) = GrainTypePlural::parse(&s) {
4240                    self.advance();
4241                    DescribeTarget::GrainType(gt)
4242                } else if s.eq_ignore_ascii_case("schema") {
4243                    self.advance();
4244                    DescribeTarget::Schema
4245                } else if s.eq_ignore_ascii_case("capabilities") {
4246                    self.advance();
4247                    DescribeTarget::Capabilities
4248                } else if s.eq_ignore_ascii_case("server") {
4249                    self.advance();
4250                    DescribeTarget::Server
4251                } else if s.eq_ignore_ascii_case("fields") {
4252                    self.advance();
4253                    // Optional grain type after FIELDS.
4254                    let gt = self.parse_grain_type_plural_opt()?;
4255                    DescribeTarget::Fields(gt)
4256                } else if s.eq_ignore_ascii_case("templates") {
4257                    self.advance();
4258                    DescribeTarget::Templates
4259                } else if s.eq_ignore_ascii_case("grammar") {
4260                    self.advance();
4261                    DescribeTarget::Grammar
4262                } else if s.eq_ignore_ascii_case("queries") {
4263                    self.advance();
4264                    DescribeTarget::Queries
4265                } else if s.eq_ignore_ascii_case("principal") {
4266                    self.advance();
4267                    let name = self.parse_string_literal()?;
4268                    DescribeTarget::Principal(name)
4269                } else if s.eq_ignore_ascii_case("loop") {
4270                    self.advance();
4271                    DescribeTarget::Loop
4272                } else if s.eq_ignore_ascii_case("analyzers") {
4273                    self.advance();
4274                    DescribeTarget::Analyzers
4275                } else if s.eq_ignore_ascii_case("outcomes") {
4276                    self.advance();
4277                    DescribeTarget::Outcomes
4278                } else if s.eq_ignore_ascii_case("policy") {
4279                    self.advance();
4280                    DescribeTarget::LoopPolicy
4281                } else if s.eq_ignore_ascii_case("stats") {
4282                    self.advance();
4283                    DescribeTarget::Stats
4284                } else if s.eq_ignore_ascii_case("integrity") {
4285                    self.advance();
4286                    DescribeTarget::Integrity
4287                } else {
4288                    self.advance();
4289                    DescribeTarget::Schema
4290                }
4291            }
4292            // QUERY is a keyword token, not an Ident — handle DESCRIBE QUERY "name".
4293            Some(SpannedToken {
4294                token: Token::Query,
4295                ..
4296            }) => {
4297                self.advance(); // consume QUERY
4298                let name = self.parse_string_literal()?;
4299                DescribeTarget::Query(name)
4300            }
4301            _ => DescribeTarget::Schema,
4302        };
4303
4304        let span_end = self.prev_span();
4305        Ok(CalStatement::Describe(DescribeStmt {
4306            target,
4307            span: Some(Span::new(
4308                span_start.start,
4309                span_end.end,
4310                span_start.line,
4311                span_start.col,
4312            )),
4313        }))
4314    }
4315
4316    // -- BATCH ------------------------------------------------------------
4317
4318    fn parse_batch(&mut self) -> CalResult<CalStatement> {
4319        let span_start = self.current_span();
4320        self.expect_exact(&Token::Batch)?;
4321        self.expect_exact(&Token::LBrace)?;
4322        self.enter_nesting()?;
4323
4324        let mut entries = vec![];
4325        while !self.at_exact(&Token::RBrace) && !self.at_end() {
4326            if entries.len() >= MAX_BATCH_ENTRIES {
4327                return Err(CalError::TooManyPipelineStages {
4328                    count: entries.len() + 1,
4329                    max: MAX_BATCH_ENTRIES,
4330                    span: Some(self.current_span()),
4331                });
4332            }
4333            // Optional label: `label:` — any token followed by `:` that is
4334            // NOT itself a statement-starter is treated as a label.  The
4335            // label is discarded; only the statement that follows matters.
4336            // This handles both `ident:` and keyword-reused-as-label cases
4337            // like `recent:` or `exists:`.
4338            let is_label = match self.peek() {
4339                Some(st) if !st.token.is_statement_starter() => {
4340                    self.peek_ahead(1).map(|t| &t.token) == Some(&Token::Colon)
4341                }
4342                _ => false,
4343            };
4344            if is_label {
4345                self.advance(); // label token
4346                self.advance(); // colon
4347            }
4348            // Parse the full statement including pipeline, FORMAT, WITH options.
4349            let (stmt, pipeline, with_options, format, user_vars) = self.parse_statement_full()?;
4350            entries.push(BatchEntry {
4351                statement: stmt,
4352                pipeline,
4353                with_options,
4354                format,
4355                user_vars,
4356            });
4357            // Statements separated by comma or semicolon.
4358            self.eat_exact(&Token::Comma);
4359            self.eat_exact(&Token::Semicolon);
4360        }
4361
4362        self.leave_nesting();
4363        self.expect_exact(&Token::RBrace)?;
4364
4365        // BATCH requires ≥1 entry per spec §8.7.
4366        if entries.is_empty() {
4367            return Err(CalError::UnexpectedToken {
4368                expected: "at least one statement inside BATCH { ... }".into(),
4369                found: "empty batch".into(),
4370                span: Some(span_start),
4371                suggestion: Some("BATCH requires one or more statements (spec §8.7).".into()),
4372            });
4373        }
4374
4375        let span_end = self.prev_span();
4376        Ok(CalStatement::Batch(BatchStmt {
4377            statements: entries,
4378            labeled: None,
4379            span: Some(Span::new(
4380                span_start.start,
4381                span_end.end,
4382                span_start.line,
4383                span_start.col,
4384            )),
4385        }))
4386    }
4387
4388    // -- COALESCE ---------------------------------------------------------
4389
4390    fn parse_coalesce(&mut self) -> CalResult<CalStatement> {
4391        let span_start = self.current_span();
4392        self.expect_exact(&Token::Coalesce)?;
4393
4394        // Two forms:
4395        // Phase 1: `COALESCE(stmt1, stmt2, ...)`
4396        // Phase 2: `COALESCE { stmt } OR { stmt } [ELSE { stmt }]`
4397        if self.at_exact(&Token::LBrace) {
4398            // Phase 2: brace-delimited multi-branch form.
4399            return self.parse_coalesce_braces(span_start);
4400        }
4401
4402        // Phase 1: parenthesised form.
4403        self.expect_exact(&Token::LParen)?;
4404        self.enter_nesting()?;
4405
4406        let mut statements = vec![];
4407        while !self.at_exact(&Token::RParen) && !self.at_end() {
4408            let stmt = self.parse_statement()?;
4409            statements.push(stmt);
4410            if !self.eat_exact(&Token::Comma) {
4411                break;
4412            }
4413        }
4414
4415        self.leave_nesting();
4416        self.expect_exact(&Token::RParen)?;
4417
4418        // COALESCE(...) requires 2-5 branches per spec §8.13.
4419        const COALESCE_MIN: usize = 2;
4420        const COALESCE_MAX: usize = 5;
4421        if statements.len() < COALESCE_MIN {
4422            return Err(CalError::UnexpectedToken {
4423                expected: format!("at least {} branches in COALESCE(...)", COALESCE_MIN),
4424                found: format!("{} branches", statements.len()),
4425                span: Some(span_start),
4426                suggestion: Some("COALESCE requires 2-5 RECALL branches (spec §8.13).".into()),
4427            });
4428        }
4429        if statements.len() > COALESCE_MAX {
4430            return Err(CalError::CoalesceTooManyBranches {
4431                count: statements.len(),
4432                max: COALESCE_MAX,
4433                span: Some(span_start),
4434            });
4435        }
4436
4437        // Build CoalesceStmt with branches from the parsed inner statements.
4438        let grain_type = GrainTypePlural::All;
4439        let span_end = self.prev_span();
4440
4441        Ok(CalStatement::Coalesce(CoalesceStmt {
4442            grain_type,
4443            where_clause: None,
4444            branches: statements
4445                .into_iter()
4446                .map(|q| CoalesceBranch {
4447                    query: q,
4448                    span: None,
4449                })
4450                .collect(),
4451            else_branch: None,
4452            span: Some(Span::new(
4453                span_start.start,
4454                span_end.end,
4455                span_start.line,
4456                span_start.col,
4457            )),
4458        }))
4459    }
4460
4461    /// Parse Phase 2 brace-delimited COALESCE:
4462    /// `COALESCE { stmt } OR { stmt } [ELSE { stmt }]`
4463    fn parse_coalesce_braces(&mut self, span_start: Span) -> CalResult<CalStatement> {
4464        let mut branches = vec![];
4465
4466        // Parse first branch: `{ stmt }`.
4467        let branch = self.parse_brace_statement()?;
4468        branches.push(CoalesceBranch {
4469            span: Some(self.prev_span()),
4470            query: branch,
4471        });
4472
4473        // Parse additional `OR { stmt }` branches.
4474        while self.at_exact(&Token::Or) {
4475            self.advance(); // consume OR
4476            let branch = self.parse_brace_statement()?;
4477            branches.push(CoalesceBranch {
4478                span: Some(self.prev_span()),
4479                query: branch,
4480            });
4481        }
4482
4483        // Optional `ELSE { stmt }`.
4484        let else_branch = if self.at_exact(&Token::Ident("ELSE".into()))
4485            || self
4486                .peek()
4487                .map(|st| st.token.description().eq_ignore_ascii_case("ELSE"))
4488                .unwrap_or(false)
4489        {
4490            // Check if current token is "ELSE" identifier.
4491            if let Some(SpannedToken {
4492                token: Token::Ident(s),
4493                ..
4494            }) = self.peek()
4495            {
4496                if s.eq_ignore_ascii_case("else") {
4497                    self.advance();
4498                    let stmt = self.parse_brace_statement()?;
4499                    Some(Box::new(stmt))
4500                } else {
4501                    None
4502                }
4503            } else {
4504                None
4505            }
4506        } else {
4507            None
4508        };
4509
4510        let span_end = self.prev_span();
4511        Ok(CalStatement::Coalesce(CoalesceStmt {
4512            grain_type: GrainTypePlural::All,
4513            where_clause: None,
4514            branches,
4515            else_branch,
4516            span: Some(Span::new(
4517                span_start.start,
4518                span_end.end,
4519                span_start.line,
4520                span_start.col,
4521            )),
4522        }))
4523    }
4524
4525    /// Parse a statement enclosed in braces: `{ <statement> }`.
4526    fn parse_brace_statement(&mut self) -> CalResult<CalStatement> {
4527        self.expect_exact(&Token::LBrace)?;
4528        self.enter_nesting()?;
4529        let stmt = self.parse_statement()?;
4530        self.leave_nesting();
4531        self.expect_exact(&Token::RBrace)?;
4532        Ok(stmt)
4533    }
4534
4535    // -- ADD (Tier 1) -----------------------------------------------------
4536
4537    fn parse_add(&mut self) -> CalResult<CalStatement> {
4538        let span_start = self.current_span();
4539        self.expect_exact(&Token::Add)?;
4540
4541        // Check for `ADD workflow "name" ...` (graph syntax).
4542        if let Some(SpannedToken {
4543            token: Token::Ident(s),
4544            ..
4545        }) = self.peek()
4546        {
4547            if s.eq_ignore_ascii_case("workflow") {
4548                self.advance(); // consume "workflow"
4549                return self.parse_add_workflow(span_start);
4550            }
4551        }
4552
4553        let grain_type = self.parse_grain_type_singular()?;
4554
4555        // `SET field = value` clauses.
4556        let mut fields = vec![];
4557        let mut seen_fields = std::collections::HashSet::new();
4558        while self.at_exact(&Token::Set) {
4559            self.advance();
4560            let fspan = self.current_span();
4561            let field = self.parse_identifier()?;
4562            self.expect_exact(&Token::Eq)?;
4563            let value = self.parse_value()?;
4564            if !seen_fields.insert(field.clone()) {
4565                self.warnings.push(CalWarning::DuplicateSetField {
4566                    field: field.clone(),
4567                    span: Some(fspan),
4568                });
4569            }
4570            fields.push(FieldAssignment {
4571                field,
4572                value,
4573                span: Some(fspan),
4574            });
4575        }
4576
4577        if fields.is_empty() {
4578            return Err(CalError::MissingSetClause {
4579                span: Some(self.current_span()),
4580            });
4581        }
4582
4583        // Optional WITH clause for ADD options (must come before REASON).
4584        let with_options = if self.at_exact(&Token::With) {
4585            self.parse_add_with_clause()?
4586        } else {
4587            vec![]
4588        };
4589
4590        // Mandatory REASON / BECAUSE clause (required for all Tier 1 writes).
4591        let reason = self.parse_reason_clause()?;
4592
4593        let span_end = self.prev_span();
4594        Ok(CalStatement::Add(AddStmt {
4595            grain_type,
4596            fields,
4597            reason,
4598            with_options,
4599            span: Some(Span::new(
4600                span_start.start,
4601                span_end.end,
4602                span_start.line,
4603                span_start.col,
4604            )),
4605        }))
4606    }
4607
4608    // -- ADD WORKFLOW (graph syntax) ----------------------------------------
4609
4610    /// Parse `ADD workflow "name" [ON "trigger"] graph... [BIND ...] REASON "..."`
4611    fn parse_add_workflow(&mut self, span_start: Span) -> CalResult<CalStatement> {
4612        // Positional name: `ADD workflow "name"`
4613        let name = self.parse_string_literal()?;
4614
4615        // `ON "trigger"` was removed in 1.3. It set a free-text field that
4616        // nothing ever read — neither the scheduler nor the driver — so it
4617        // described an activation condition that could not activate anything.
4618        // Refuse it by name rather than silently ignoring it, so anyone with the
4619        // old syntax is told where triggers actually live now.
4620        if self.at_exact(&Token::On) {
4621            return Err(CalError::UnexpectedToken {
4622                expected: "the plan's graph — `ON \"...\"` was removed in 1.3; a trigger is now \
4623                           its own grain that points at this plan (see `areev trigger add`)"
4624                    .into(),
4625                found: "ON".into(),
4626                span: Some(self.current_span()),
4627                suggestion: Some(
4628                    "declare a trigger with `areev trigger add --workflow <this plan's hash>`"
4629                        .into(),
4630                ),
4631            });
4632        }
4633
4634        // Parse graph lines (edges).
4635        let (nodes, edges) = self.parse_workflow_graph()?;
4636
4637        // Parse BIND clauses.
4638        let mut bindings = vec![];
4639        while self.at_exact(&Token::Bind) {
4640            bindings.push(self.parse_bind_clause()?);
4641        }
4642
4643        // Optional WITH clause.
4644        let with_options = if self.at_exact(&Token::With) {
4645            self.parse_add_with_clause()?
4646        } else {
4647            vec![]
4648        };
4649
4650        // REASON / BECAUSE is required.
4651        let reason = self.parse_reason_clause()?;
4652
4653        let span_end = self.prev_span();
4654        Ok(CalStatement::AddWorkflow(AddWorkflowStmt {
4655            name,
4656            nodes,
4657            edges,
4658            bindings,
4659            reason,
4660            with_options,
4661            span: Some(Span::new(
4662                span_start.start,
4663                span_end.end,
4664                span_start.line,
4665                span_start.col,
4666            )),
4667        }))
4668    }
4669
4670    /// Parse the graph body: arrow chains building nodes + edges.
4671    ///
4672    /// Grammar:
4673    /// ```text
4674    /// graph       = chain { chain }
4675    /// chain       = node_or_group { "->" node_or_group [WHEN cond] [* n] }
4676    /// node_or_group = node | "(" node { "," node } ")"
4677    /// node        = identifier | string_literal
4678    /// ```
4679    fn parse_workflow_graph(&mut self) -> CalResult<(Vec<String>, Vec<GraphEdge>)> {
4680        let mut nodes: Vec<String> = Vec::new();
4681        let mut edges: Vec<GraphEdge> = Vec::new();
4682
4683        // Helper: ensure a node is recorded (deduplicated, preserves order).
4684        let ensure_node = |name: &str, nodes: &mut Vec<String>| {
4685            if !nodes.contains(&name.to_string()) {
4686                nodes.push(name.to_string());
4687            }
4688        };
4689
4690        // Parse at least one chain.
4691        let mut parsed_any = false;
4692        while self.is_graph_line_start() {
4693            parsed_any = true;
4694
4695            // Parse the first node or group in this chain.
4696            let mut sources = self.parse_node_or_group()?;
4697            for s in &sources {
4698                ensure_node(s, &mut nodes);
4699            }
4700
4701            // Parse chain: `-> target [WHEN "..."] [* N]`
4702            while self.eat_exact(&Token::Arrow) {
4703                // Detect double arrow: `a -> -> b`
4704                if self.at_exact(&Token::Arrow) {
4705                    return Err(CalError::UnexpectedToken {
4706                        expected: "node name after '->'".into(),
4707                        found: "another '->'".into(),
4708                        span: Some(self.current_span()),
4709                        suggestion: Some(
4710                            "remove the extra '->' — chain nodes with single arrows: a -> b -> c"
4711                                .into(),
4712                        ),
4713                    });
4714                }
4715                let targets = self.parse_node_or_group()?;
4716                for t in &targets {
4717                    ensure_node(t, &mut nodes);
4718                }
4719
4720                // Optional WHEN condition.
4721                let cond = if self.eat_exact(&Token::When) {
4722                    Some(self.parse_string_literal()?)
4723                } else {
4724                    None
4725                };
4726
4727                // Optional * N repeat.
4728                let repeat = if self.eat_exact(&Token::Asterisk) {
4729                    // Detect non-number after `*`: `a -> b * abc`
4730                    if !matches!(
4731                        self.peek(),
4732                        Some(SpannedToken {
4733                            token: Token::NumberLiteral(_),
4734                            ..
4735                        })
4736                    ) {
4737                        let found = self
4738                            .peek()
4739                            .map(|st| st.token.description())
4740                            .unwrap_or_else(|| "<end of query>".into());
4741                        return Err(CalError::UnexpectedToken {
4742                            expected: "number after '*' for retry count".into(),
4743                            found,
4744                            span: Some(self.current_span()),
4745                            suggestion: Some("* N sets the retry count — e.g.: a -> b * 3".into()),
4746                        });
4747                    }
4748                    let n = self.parse_number()? as u32;
4749                    if n == 0 {
4750                        return Err(CalError::UnexpectedToken {
4751                            expected: "repeat count >= 1".into(),
4752                            found: "0".into(),
4753                            span: Some(self.prev_span()),
4754                            suggestion: Some(
4755                                "repeat count must be at least 1 — e.g.: a -> b * 1".into(),
4756                            ),
4757                        });
4758                    }
4759                    Some(n)
4760                } else {
4761                    None
4762                };
4763
4764                // Create edges: every source -> every target.
4765                for s in &sources {
4766                    for t in &targets {
4767                        edges.push(GraphEdge {
4768                            src: s.clone(),
4769                            dst: t.clone(),
4770                            cond: cond.clone(),
4771                            repeat,
4772                        });
4773                    }
4774                }
4775
4776                // Targets become sources for the next `->` segment.
4777                // This handles `a -> b -> c` as a->b, b->c.
4778                sources = targets;
4779            }
4780        }
4781
4782        // Catch misplaced WHEN — it must follow a `->` edge, not a bare node.
4783        if self.at_exact(&Token::When) {
4784            return Err(CalError::UnexpectedToken {
4785                expected: "'->' edge before WHEN condition".into(),
4786                found: "WHEN".into(),
4787                span: Some(self.current_span()),
4788                suggestion: Some(
4789                    "WHEN must follow a -> edge, not a node — e.g.: a -> b WHEN \"condition\""
4790                        .into(),
4791                ),
4792            });
4793        }
4794        // Catch a stray arrow after the graph loop exited (shouldn't happen
4795        // normally since the inner while eats arrows, but guards edge cases).
4796        if self.at_exact(&Token::Arrow) && parsed_any {
4797            return Err(CalError::UnexpectedToken {
4798                expected: "node name after '->'".into(),
4799                found: self
4800                    .peek()
4801                    .map(|st| st.token.description())
4802                    .unwrap_or_else(|| "<end of query>".into()),
4803                span: Some(self.current_span()),
4804                suggestion: Some("expected a node name or parallel group after '->'".into()),
4805            });
4806        }
4807
4808        if !parsed_any {
4809            return Err(CalError::UnexpectedToken {
4810                expected: "at least one node in workflow graph".into(),
4811                found: self
4812                    .peek()
4813                    .map(|st| st.token.description())
4814                    .unwrap_or_else(|| "<end of query>".into()),
4815                span: Some(self.current_span()),
4816                suggestion: Some(
4817                    "workflow body must contain node names and arrows, e.g.: build -> test -> deploy"
4818                        .into(),
4819                ),
4820            });
4821        }
4822
4823        Ok((nodes, edges))
4824    }
4825
4826    /// Check if the current token could start a graph line
4827    /// (an identifier, string literal, or opening paren).
4828    fn is_graph_line_start(&self) -> bool {
4829        matches!(
4830            self.peek(),
4831            Some(SpannedToken {
4832                token: Token::Ident(_)
4833                    | Token::StringLiteral(_)
4834                    | Token::LParen
4835                    | Token::Run
4836                    | Token::Query,
4837                ..
4838            })
4839        )
4840    }
4841
4842    /// Parse a node or parallel group: `node` or `(node, node, ...)`.
4843    fn parse_node_or_group(&mut self) -> CalResult<Vec<String>> {
4844        if self.eat_exact(&Token::LParen) {
4845            self.enter_nesting()?;
4846            let mut group = vec![self.parse_node_name()?];
4847            while self.eat_exact(&Token::Comma) {
4848                group.push(self.parse_node_name()?);
4849            }
4850            self.leave_nesting();
4851            if !self.eat_exact(&Token::RParen) {
4852                let found = self
4853                    .peek()
4854                    .map(|st| st.token.description())
4855                    .unwrap_or_else(|| "<end of query>".into());
4856                return Err(CalError::UnexpectedToken {
4857                    expected: "')' to close parallel group".into(),
4858                    found,
4859                    span: Some(self.current_span()),
4860                    suggestion: Some(
4861                        "close the parallel group with ')' — e.g.: (build, test) -> deploy".into(),
4862                    ),
4863                });
4864            }
4865            Ok(group)
4866        } else {
4867            Ok(vec![self.parse_node_name()?])
4868        }
4869    }
4870
4871    /// Parse a single node name: bare identifier or quoted string.
4872    fn parse_node_name(&mut self) -> CalResult<String> {
4873        match self.peek() {
4874            Some(SpannedToken {
4875                token: Token::Ident(s),
4876                ..
4877            }) => {
4878                let s = s.clone();
4879                self.advance();
4880                Ok(s)
4881            }
4882            // Allow keywords that are also valid as workflow node names.
4883            Some(SpannedToken {
4884                token: Token::Run, ..
4885            }) => {
4886                self.advance();
4887                Ok("run".to_string())
4888            }
4889            Some(SpannedToken {
4890                token: Token::Query,
4891                ..
4892            }) => {
4893                self.advance();
4894                Ok("query".to_string())
4895            }
4896            // CAL 1.3 statement keywords that are everyday node names —
4897            // review/approval workflows use exactly these words.
4898            Some(SpannedToken {
4899                token:
4900                    t @ (Token::Approve
4901                    | Token::Reject
4902                    | Token::Apply
4903                    | Token::Rollback
4904                    | Token::Grant
4905                    | Token::Revoke
4906                    | Token::Show
4907                    | Token::Merge
4908                    | Token::Related
4909                    | Token::Novelty
4910                    | Token::Remember
4911                    | Token::Entity),
4912                ..
4913            }) => {
4914                let name = t.description().to_ascii_lowercase();
4915                self.advance();
4916                Ok(name)
4917            }
4918            Some(SpannedToken {
4919                token: Token::StringLiteral(_),
4920                ..
4921            }) => self.parse_string_literal(),
4922            Some(st) => {
4923                let found = st.token.description();
4924                let span = st.span;
4925                // Provide context-specific suggestions for common mistakes.
4926                let suggestion = match &st.token {
4927                    Token::Reason | Token::Because => Some(
4928                        "expected node name after '->' — the arrow is dangling. \
4929                         Add a target node: a -> b REASON \"...\""
4930                            .into(),
4931                    ),
4932                    Token::RParen => Some(
4933                        "parallel group must contain at least one node — \
4934                         e.g.: (build, test)"
4935                            .into(),
4936                    ),
4937                    _ => Some(
4938                        "reserved words (ON, WHEN, BIND, REASON) must be quoted to use as node names"
4939                            .into(),
4940                    ),
4941                };
4942                Err(CalError::UnexpectedToken {
4943                    expected: "node name (identifier or quoted string)".into(),
4944                    found,
4945                    span: Some(span),
4946                    suggestion,
4947                })
4948            }
4949            None => Err(CalError::UnexpectedToken {
4950                expected: "node name after '->'".into(),
4951                found: "<end of query>".into(),
4952                span: None,
4953                suggestion: Some("the arrow is dangling — add a target node: a -> b".into()),
4954            }),
4955        }
4956    }
4957
4958    /// Parse `BIND node = sha256:hash`.
4959    fn parse_bind_clause(&mut self) -> CalResult<BindClause> {
4960        self.expect_exact(&Token::Bind)?;
4961        let node = self.parse_node_name()?;
4962        self.expect_exact(&Token::Eq)?;
4963        let hash = self.parse_hash_literal()?;
4964        Ok(BindClause { node, hash })
4965    }
4966
4967    /// Parse `REASON "..."` or `BECAUSE "..."`.
4968    fn parse_reason_clause(&mut self) -> CalResult<String> {
4969        if !self.at_exact(&Token::Reason) && !self.at_exact(&Token::Because) {
4970            return Err(CalError::MissingReason {
4971                span: Some(self.current_span()),
4972            });
4973        }
4974        self.advance();
4975        let rspan = self.current_span();
4976        let reason = self.parse_string_literal()?;
4977        if reason.len() > MAX_REASON_LENGTH {
4978            return Err(CalError::ReasonTooLong {
4979                length: reason.len(),
4980                max: MAX_REASON_LENGTH,
4981                span: Some(rspan),
4982            });
4983        }
4984        Ok(reason)
4985    }
4986
4987    // -- ADD WITH clause --------------------------------------------------
4988
4989    fn parse_add_with_clause(&mut self) -> CalResult<Vec<AddWithOption>> {
4990        self.expect_exact(&Token::With)?;
4991        let mut options = vec![];
4992        loop {
4993            if let Some(opt) = self.parse_add_with_option()? {
4994                options.push(opt);
4995            }
4996            if !self.eat_exact(&Token::Comma) {
4997                break;
4998            }
4999        }
5000        Ok(options)
5001    }
5002
5003    fn parse_add_with_option(&mut self) -> CalResult<Option<AddWithOption>> {
5004        match self.peek() {
5005            Some(SpannedToken {
5006                token: Token::ExtractEventDate,
5007                ..
5008            }) => {
5009                self.advance();
5010                Ok(Some(AddWithOption::ExtractEventDate))
5011            }
5012            Some(SpannedToken {
5013                token: Token::AutoRelate,
5014                ..
5015            }) => {
5016                self.advance();
5017                Ok(Some(AddWithOption::AutoRelate))
5018            }
5019            Some(SpannedToken {
5020                token: Token::ExtractMemories,
5021                ..
5022            }) => {
5023                self.advance();
5024                Ok(Some(AddWithOption::ExtractMemories))
5025            }
5026            Some(SpannedToken {
5027                token: Token::SyncOption,
5028                ..
5029            }) => {
5030                self.advance();
5031                Ok(Some(AddWithOption::Sync))
5032            }
5033            Some(SpannedToken { token: Token::Ident(w), .. })
5034                if w.eq_ignore_ascii_case("occurrence") =>
5035            {
5036                self.advance();
5037                Ok(Some(AddWithOption::Occurrence))
5038            }
5039            Some(st) => {
5040                let found = st.token.description();
5041                let span = st.span;
5042                self.warnings.push(CalWarning::UnknownExtensionOption {
5043                    option: found,
5044                    span: Some(span),
5045                });
5046                self.advance();
5047                Ok(None)
5048            }
5049            None => Err(CalError::UnexpectedToken {
5050                expected:
5051                    "ADD WITH option (extract_event_date, auto_relate, extract_memories, sync)"
5052                        .into(),
5053                found: "<end of query>".into(),
5054                span: None,
5055                suggestion: None,
5056            }),
5057        }
5058    }
5059
5060    // -- ACCUMULATE (Tier 1) ----------------------------------------------
5061
5062    fn parse_accumulate(&mut self) -> CalResult<CalStatement> {
5063        let span_start = self.current_span();
5064        self.expect_exact(&Token::Accumulate)?;
5065
5066        // Grain type (required).
5067        let grain_type = self.parse_grain_type_singular()?;
5068
5069        // Resolution mode: hash literal OR WHERE clause.
5070        let target = if let Some(SpannedToken {
5071            token: Token::HashLiteral(_),
5072            ..
5073        }) = self.peek()
5074        {
5075            let hash = self.parse_hash_literal()?;
5076            AccumulateTarget::Hash { hash }
5077        } else if self.at_exact(&Token::Where) {
5078            self.advance();
5079            // Parse WHERE subject = "..." relation = "..." [namespace = "..."]
5080            let mut subject: Option<String> = None;
5081            let mut relation: Option<String> = None;
5082            let mut namespace: Option<String> = None;
5083            loop {
5084                // Skip optional AND connectors between field assignments.
5085                self.eat_exact(&Token::And);
5086                match self.peek() {
5087                    Some(SpannedToken {
5088                        token: Token::Ident(id),
5089                        ..
5090                    }) if id.eq_ignore_ascii_case("subject") => {
5091                        self.advance();
5092                        self.expect_exact(&Token::Eq)?;
5093                        subject = Some(self.parse_string_literal()?);
5094                    }
5095                    Some(SpannedToken {
5096                        token: Token::Ident(id),
5097                        ..
5098                    }) if id.eq_ignore_ascii_case("relation") => {
5099                        self.advance();
5100                        self.expect_exact(&Token::Eq)?;
5101                        relation = Some(self.parse_string_literal()?);
5102                    }
5103                    Some(SpannedToken {
5104                        token: Token::Ident(id),
5105                        ..
5106                    }) if id.eq_ignore_ascii_case("namespace") => {
5107                        self.advance();
5108                        self.expect_exact(&Token::Eq)?;
5109                        namespace = Some(self.parse_string_literal()?);
5110                    }
5111                    _ => break,
5112                }
5113            }
5114            let subject = subject.ok_or_else(|| CalError::UnexpectedToken {
5115                expected: "subject = \"...\" in WHERE clause".into(),
5116                found: self
5117                    .peek()
5118                    .map(|st| format!("{:?}", st.token))
5119                    .unwrap_or_else(|| "<end of query>".into()),
5120                span: Some(self.current_span()),
5121                suggestion: Some("ACCUMULATE WHERE requires subject = \"...\"".into()),
5122            })?;
5123            let relation = relation.ok_or_else(|| CalError::UnexpectedToken {
5124                expected: "relation = \"...\" in WHERE clause".into(),
5125                found: self
5126                    .peek()
5127                    .map(|st| format!("{:?}", st.token))
5128                    .unwrap_or_else(|| "<end of query>".into()),
5129                span: Some(self.current_span()),
5130                suggestion: Some("ACCUMULATE WHERE requires relation = \"...\"".into()),
5131            })?;
5132            AccumulateTarget::TipResolved {
5133                subject,
5134                relation,
5135                namespace,
5136            }
5137        } else {
5138            return Err(CalError::UnexpectedToken {
5139                expected: "hash literal or WHERE clause".into(),
5140                found: self
5141                    .peek()
5142                    .map(|st| format!("{:?}", st.token))
5143                    .unwrap_or_else(|| "<end of query>".into()),
5144                span: Some(self.current_span()),
5145                suggestion: Some(
5146                    "ACCUMULATE requires either a hash or WHERE subject = ... relation = ..."
5147                        .into(),
5148                ),
5149            });
5150        };
5151
5152        // Parse ADD and SET operations.
5153        let mut add_ops = Vec::new();
5154        let mut set_ops = Vec::new();
5155        loop {
5156            if self.at_exact(&Token::Add) {
5157                self.advance();
5158                let fspan = self.current_span();
5159                let field = self.parse_identifier()?;
5160                self.expect_exact(&Token::Eq)?;
5161                let value = self.parse_number()?;
5162                add_ops.push(DeltaOp {
5163                    field,
5164                    delta: value,
5165                    span: Some(fspan),
5166                });
5167            } else if self.at_exact(&Token::Set) {
5168                self.advance();
5169                let fspan = self.current_span();
5170                let field = self.parse_identifier()?;
5171                self.expect_exact(&Token::Eq)?;
5172                let value = self.parse_value()?;
5173                set_ops.push(FieldAssignment {
5174                    field,
5175                    value,
5176                    span: Some(fspan),
5177                });
5178            } else {
5179                break;
5180            }
5181        }
5182
5183        if add_ops.is_empty() {
5184            return Err(CalError::MissingAccumulateOps {
5185                span: Some(self.current_span()),
5186            });
5187        }
5188
5189        // REASON / BECAUSE is required.
5190        if !self.at_exact(&Token::Reason) && !self.at_exact(&Token::Because) {
5191            return Err(CalError::MissingReason {
5192                span: Some(self.current_span()),
5193            });
5194        }
5195        self.advance();
5196        let rspan = self.current_span();
5197        let reason = self.parse_string_literal()?;
5198        if reason.len() > MAX_REASON_LENGTH {
5199            return Err(CalError::ReasonTooLong {
5200                length: reason.len(),
5201                max: MAX_REASON_LENGTH,
5202                span: Some(rspan),
5203            });
5204        }
5205
5206        let span_end = self.prev_span();
5207        Ok(CalStatement::Accumulate(AccumulateStmt {
5208            grain_type,
5209            target,
5210            add_ops,
5211            set_ops,
5212            reason,
5213            span: Some(Span::new(
5214                span_start.start,
5215                span_end.end,
5216                span_start.line,
5217                span_start.col,
5218            )),
5219        }))
5220    }
5221
5222    // -- SUPERSEDE (Tier 1) -----------------------------------------------
5223
5224    fn parse_supersede(&mut self) -> CalResult<CalStatement> {
5225        let span_start = self.current_span();
5226        self.expect_exact(&Token::Supersede)?;
5227
5228        let hash = self.parse_hash_literal()?;
5229
5230        // Detect workflow graph supersede: after the hash, a graph line start
5231        // (identifier, string, paren) instead of SET means a workflow
5232        // supersede. `ON` no longer participates — see the note in
5233        // `parse_add_workflow`.
5234        if self.is_graph_line_start() {
5235            return self.parse_supersede_workflow(span_start, hash);
5236        }
5237
5238        // One or more `SET field = value` clauses.
5239        let mut set_clauses = vec![];
5240        while self.at_exact(&Token::Set) {
5241            self.advance();
5242            let fspan = self.current_span();
5243            let field = self.parse_identifier()?;
5244            self.expect_exact(&Token::Eq)?;
5245            let value = self.parse_value()?;
5246            set_clauses.push(FieldAssignment {
5247                field,
5248                value,
5249                span: Some(fspan),
5250            });
5251        }
5252
5253        if set_clauses.is_empty() {
5254            return Err(CalError::MissingSetClause {
5255                span: Some(self.current_span()),
5256            });
5257        }
5258
5259        // REASON / BECAUSE is required for write statements.
5260        if !self.at_exact(&Token::Reason) && !self.at_exact(&Token::Because) {
5261            return Err(CalError::MissingReason {
5262                span: Some(self.current_span()),
5263            });
5264        }
5265        self.advance();
5266        let rspan = self.current_span();
5267        let reason = self.parse_string_literal()?;
5268        if reason.len() > MAX_REASON_LENGTH {
5269            return Err(CalError::ReasonTooLong {
5270                length: reason.len(),
5271                max: MAX_REASON_LENGTH,
5272                span: Some(rspan),
5273            });
5274        }
5275
5276        let span_end = self.prev_span();
5277        Ok(CalStatement::Supersede(SupersedeStmt {
5278            hash,
5279            set_clauses,
5280            reason,
5281            span: Some(Span::new(
5282                span_start.start,
5283                span_end.end,
5284                span_start.line,
5285                span_start.col,
5286            )),
5287        }))
5288    }
5289
5290    /// Parse `SUPERSEDE <hash> [ON "trigger"] graph... [BIND ...] REASON "..."`
5291    fn parse_supersede_workflow(
5292        &mut self,
5293        span_start: Span,
5294        hash: String,
5295    ) -> CalResult<CalStatement> {
5296        let (nodes, edges) = self.parse_workflow_graph()?;
5297
5298        let mut bindings = vec![];
5299        while self.at_exact(&Token::Bind) {
5300            bindings.push(self.parse_bind_clause()?);
5301        }
5302
5303        let reason = self.parse_reason_clause()?;
5304
5305        let span_end = self.prev_span();
5306        Ok(CalStatement::SupersedeWorkflow(SupersedeWorkflowStmt {
5307            hash,
5308            nodes,
5309            edges,
5310            bindings,
5311            reason,
5312            span: Some(Span::new(
5313                span_start.start,
5314                span_end.end,
5315                span_start.line,
5316                span_start.col,
5317            )),
5318        }))
5319    }
5320
5321    // -- REVERT (Tier 1) -------------------------------------------------
5322
5323    /// `FORGET <hash>` — tombstone a single grain by content address.
5324    ///
5325    /// Only the hash form is reachable from CAL text. `FORGET USER`/`SCOPE`
5326    /// exist in the AST but have no store backing, and PURGE stays outside the
5327    /// text grammar. Execution is gated by `allow_destructive_ops`; the parser
5328    /// always accepts it and the executor returns `Unsupported` when disabled.
5329    fn parse_forget(&mut self) -> CalResult<CalStatement> {
5330        let span_start = self.current_span();
5331        self.expect_exact(&Token::Forget)?;
5332
5333        // `FORGET SUBJECT "<id>"` — identity-scoped erasure (CAL 1.3 §8.14).
5334        // USER/SCOPE remain out of the text grammar: SUBJECT is the one
5335        // spelled form, matching the host verbs and the spec.
5336        if let Some(SpannedToken { token: Token::Ident(word), span, .. }) = self.peek() {
5337            let word_up = word.to_ascii_uppercase();
5338            let span = *span;
5339            match word_up.as_str() {
5340                "SUBJECT" => {
5341                    self.advance();
5342                    let user_id = self.parse_string_literal()?;
5343                    // Optional `WITH text_mentions`.
5344                    let mut text_mentions = false;
5345                    if self.at_exact(&Token::With) {
5346                        self.advance();
5347                        match self.peek() {
5348                            Some(SpannedToken { token: Token::Ident(opt), .. })
5349                                if opt.eq_ignore_ascii_case("text_mentions") =>
5350                            {
5351                                self.advance();
5352                                text_mentions = true;
5353                            }
5354                            other => {
5355                                return Err(CalError::UnexpectedToken {
5356                                    expected: "text_mentions".into(),
5357                                    found: other
5358                                        .map(|t| t.token.description())
5359                                        .unwrap_or_else(|| "end of input".into()),
5360                                    span: other.map(|t| t.span),
5361                                    suggestion: Some(
5362                                        "FORGET SUBJECT supports exactly one option: \
5363                                         WITH text_mentions"
5364                                            .into(),
5365                                    ),
5366                                });
5367                            }
5368                        }
5369                    }
5370                    // BECAUSE is mandatory on the subject form — an identity
5371                    // erasure without a recorded reason is not auditable.
5372                    if !self.at_exact(&Token::Reason) && !self.at_exact(&Token::Because) {
5373                        return Err(CalError::MissingReason {
5374                            span: Some(self.current_span()),
5375                        });
5376                    }
5377                    self.advance();
5378                    let reason = self.parse_string_literal()?;
5379                    let span_end = self.prev_span();
5380                    return Ok(CalStatement::Forget(ForgetStmt {
5381                        target: ForgetTarget::User { user_id },
5382                        reason: Some(reason),
5383                        text_mentions,
5384                        span: Some(Span::new(
5385                            span_start.start,
5386                            span_end.end,
5387                            span_start.line,
5388                            span_start.col,
5389                        )),
5390                    }));
5391                }
5392                "USER" | "SCOPE" => {
5393                    return Err(CalError::UnexpectedToken {
5394                        expected: "a grain hash or SUBJECT".into(),
5395                        found: word_up,
5396                        span: Some(span),
5397                        suggestion: Some(
5398                            "identity erasure is spelled FORGET SUBJECT \"<id>\" \
5399                             BECAUSE \"<why>\"; scope erasure is not part of the \
5400                             text grammar"
5401                                .into(),
5402                        ),
5403                    });
5404                }
5405                _ => {}
5406            }
5407        }
5408
5409        let hash = self.parse_hash_literal()?;
5410        // BECAUSE is optional on the hash form (it predates the requirement)
5411        // but recorded when given.
5412        let reason = if self.at_exact(&Token::Reason) || self.at_exact(&Token::Because) {
5413            self.advance();
5414            Some(self.parse_string_literal()?)
5415        } else {
5416            None
5417        };
5418        let span_end = self.prev_span();
5419        Ok(CalStatement::Forget(ForgetStmt {
5420            target: ForgetTarget::Hash { hash },
5421            reason,
5422            text_mentions: false,
5423            span: Some(Span::new(
5424                span_start.start,
5425                span_end.end,
5426                span_start.line,
5427                span_start.col,
5428            )),
5429        }))
5430    }
5431
5432    /// Parse `REPORT SUBJECT "<id>" [WITH text_mentions]` — the read-only
5433    /// DSAR selection (OMS 1.6 draft). No BECAUSE: it is a pure read, and
5434    /// reads don't carry reasons.
5435    fn parse_report_subject(&mut self) -> CalResult<CalStatement> {
5436        let span_start = self.current_span();
5437        self.expect_exact(&Token::Report)?;
5438        match self.peek() {
5439            Some(SpannedToken { token: Token::Ident(word), .. })
5440                if word.eq_ignore_ascii_case("SUBJECT") =>
5441            {
5442                self.advance();
5443            }
5444            other => {
5445                return Err(CalError::UnexpectedToken {
5446                    expected: "SUBJECT".into(),
5447                    found: other
5448                        .map(|t| t.token.description())
5449                        .unwrap_or_else(|| "end of input".into()),
5450                    span: other.map(|t| t.span),
5451                    suggestion: Some(
5452                        "the DSAR read is spelled REPORT SUBJECT \"<id>\" \
5453                         [WITH text_mentions]"
5454                            .into(),
5455                    ),
5456                });
5457            }
5458        }
5459        let subject_id = self.parse_string_literal()?;
5460        let mut text_mentions = false;
5461        if self.at_exact(&Token::With) {
5462            self.advance();
5463            match self.peek() {
5464                Some(SpannedToken { token: Token::Ident(opt), .. })
5465                    if opt.eq_ignore_ascii_case("text_mentions") =>
5466                {
5467                    self.advance();
5468                    text_mentions = true;
5469                }
5470                other => {
5471                    return Err(CalError::UnexpectedToken {
5472                        expected: "text_mentions".into(),
5473                        found: other
5474                            .map(|t| t.token.description())
5475                            .unwrap_or_else(|| "end of input".into()),
5476                        span: other.map(|t| t.span),
5477                        suggestion: Some(
5478                            "REPORT SUBJECT supports exactly one option: \
5479                             WITH text_mentions"
5480                                .into(),
5481                        ),
5482                    });
5483                }
5484            }
5485        }
5486        let span_end = self.prev_span();
5487        Ok(CalStatement::ReportSubject(ReportSubjectStmt {
5488            subject_id,
5489            text_mentions,
5490            span: Some(Span::new(
5491                span_start.start,
5492                span_end.end,
5493                span_start.line,
5494                span_start.col,
5495            )),
5496        }))
5497    }
5498
5499    /// Parse `PURGE OLDER THAN <n><d|h|m> [TYPE <grain-type>]
5500    /// [IN "<namespace>"] [LIMIT <n>] BECAUSE "<why>"` (CAL 1.3 §8.14).
5501    fn parse_purge(&mut self) -> CalResult<CalStatement> {
5502        let span_start = self.current_span();
5503        self.expect_exact(&Token::Purge)?;
5504
5505        let expect_word = |me: &mut Self, word: &str| -> CalResult<()> {
5506            match me.peek() {
5507                Some(SpannedToken { token: Token::Ident(w), .. })
5508                    if w.eq_ignore_ascii_case(word) =>
5509                {
5510                    me.advance();
5511                    Ok(())
5512                }
5513                other => Err(CalError::UnexpectedToken {
5514                    expected: word.into(),
5515                    found: other
5516                        .map(|t| t.token.description())
5517                        .unwrap_or_else(|| "end of input".into()),
5518                    span: other.map(|t| t.span),
5519                    suggestion: Some(
5520                        "the retention sweep is spelled PURGE OLDER THAN <n><d|h|m> \
5521                         [TYPE <t>] [IN \"<ns>\"] BECAUSE \"<why>\""
5522                            .into(),
5523                    ),
5524                }),
5525            }
5526        };
5527        expect_word(self, "OLDER")?;
5528        expect_word(self, "THAN")?;
5529
5530        // `90d` lexes as NumberLiteral(90) + Ident("d").
5531        let n = match self.peek() {
5532            Some(SpannedToken { token: Token::NumberLiteral(n), .. }) if *n > 0.0 => {
5533                let n = *n;
5534                self.advance();
5535                n
5536            }
5537            other => {
5538                return Err(CalError::UnexpectedToken {
5539                    expected: "a positive age like 90d, 6h, or 30m".into(),
5540                    found: other
5541                        .map(|t| t.token.description())
5542                        .unwrap_or_else(|| "end of input".into()),
5543                    span: other.map(|t| t.span),
5544                    suggestion: None,
5545                });
5546            }
5547        };
5548        let min_age_days = match self.peek() {
5549            Some(SpannedToken { token: Token::Ident(u), .. }) => {
5550                let unit = u.to_ascii_lowercase();
5551                let days = match unit.as_str() {
5552                    "d" => n,
5553                    "h" => n / 24.0,
5554                    "m" => n / 1440.0,
5555                    _ => {
5556                        return Err(CalError::UnexpectedToken {
5557                            expected: "an age unit: d, h, or m".into(),
5558                            found: u.clone(),
5559                            span: self.peek().map(|t| t.span),
5560                            suggestion: None,
5561                        });
5562                    }
5563                };
5564                self.advance();
5565                days
5566            }
5567            other => {
5568                return Err(CalError::UnexpectedToken {
5569                    expected: "an age unit: d, h, or m".into(),
5570                    found: other
5571                        .map(|t| t.token.description())
5572                        .unwrap_or_else(|| "end of input".into()),
5573                    span: other.map(|t| t.span),
5574                    suggestion: Some("write the age as one word, e.g. 90d".into()),
5575                });
5576            }
5577        };
5578
5579        // Optional TYPE <grain-type>, IN "<ns>", LIMIT <n> — any order.
5580        let mut grain_type: Option<String> = None;
5581        let mut namespace: Option<String> = None;
5582        let mut limit: Option<usize> = None;
5583        loop {
5584            match self.peek() {
5585                Some(SpannedToken { token: Token::Ident(w), .. })
5586                    if w.eq_ignore_ascii_case("TYPE") && grain_type.is_none() =>
5587                {
5588                    self.advance();
5589                    match self.peek() {
5590                        Some(SpannedToken { token: Token::Ident(t), .. }) => {
5591                            grain_type = Some(t.to_ascii_lowercase());
5592                            self.advance();
5593                        }
5594                        other => {
5595                            return Err(CalError::UnexpectedToken {
5596                                expected: "a grain type, e.g. event".into(),
5597                                found: other
5598                                    .map(|t| t.token.description())
5599                                    .unwrap_or_else(|| "end of input".into()),
5600                                span: other.map(|t| t.span),
5601                                suggestion: None,
5602                            });
5603                        }
5604                    }
5605                }
5606                Some(SpannedToken { token: Token::In, .. }) if namespace.is_none() => {
5607                    self.advance();
5608                    namespace = Some(self.parse_string_literal()?);
5609                }
5610                Some(SpannedToken { token: Token::Limit, .. }) if limit.is_none() => {
5611                    self.advance();
5612                    match self.peek() {
5613                        Some(SpannedToken { token: Token::NumberLiteral(n), .. })
5614                            if *n >= 1.0 =>
5615                        {
5616                            limit = Some(*n as usize);
5617                            self.advance();
5618                        }
5619                        other => {
5620                            return Err(CalError::UnexpectedToken {
5621                                expected: "a positive LIMIT".into(),
5622                                found: other
5623                                    .map(|t| t.token.description())
5624                                    .unwrap_or_else(|| "end of input".into()),
5625                                span: other.map(|t| t.span),
5626                                suggestion: None,
5627                            });
5628                        }
5629                    }
5630                }
5631                _ => break,
5632            }
5633        }
5634
5635        // BECAUSE is mandatory — a retention sweep without a recorded reason
5636        // is not auditable.
5637        if !self.at_exact(&Token::Reason) && !self.at_exact(&Token::Because) {
5638            return Err(CalError::MissingReason {
5639                span: Some(self.current_span()),
5640            });
5641        }
5642        self.advance();
5643        let reason = self.parse_string_literal()?;
5644
5645        let span_end = self.prev_span();
5646        Ok(CalStatement::Purge(PurgeStmt {
5647            min_age_days: Some(min_age_days),
5648            namespace,
5649            limit,
5650            grain_type,
5651            reason: Some(reason),
5652            span: Some(Span::new(
5653                span_start.start,
5654                span_end.end,
5655                span_start.line,
5656                span_start.col,
5657            )),
5658        }))
5659    }
5660
5661    /// One DCL verb: an identifier, optionally dotted (`loop.review` lexes
5662    /// as Ident Dot Ident and is rejoined here). Validation against the
5663    /// verb registry happens at execution.
5664    fn parse_dcl_verb(&mut self) -> CalResult<String> {
5665        let head = match self.peek() {
5666            Some(SpannedToken { token: Token::Ident(w), .. }) => {
5667                let w = w.to_ascii_lowercase();
5668                self.advance();
5669                w
5670            }
5671            // RUN is a keyword token; the runtime verbs (`run.execute`,
5672            // `run.respond`, `run.cancel`) must still be grantable — a
5673            // driver that enforces run.respond while GRANT cannot spell it
5674            // is an approval boundary nobody can be admitted through.
5675            Some(SpannedToken { token: Token::Run, .. }) => {
5676                self.advance();
5677                "run".to_string()
5678            }
5679            other => {
5680                return Err(CalError::UnexpectedToken {
5681                    expected: "a verb (read, write, supersede, delete, erase, \
5682                               loop.run, loop.review, loop.apply, run.execute, \
5683                               run.respond, run.cancel, admin)"
5684                        .into(),
5685                    found: other
5686                        .map(|t| t.token.description())
5687                        .unwrap_or_else(|| "end of input".into()),
5688                    span: other.map(|t| t.span),
5689                    suggestion: None,
5690                });
5691            }
5692        };
5693        if self.at_exact(&Token::Dot) {
5694            self.advance();
5695            match self.peek() {
5696                Some(SpannedToken { token: Token::Ident(tail), .. }) => {
5697                    let tail = tail.to_ascii_lowercase();
5698                    self.advance();
5699                    Ok(format!("{head}.{tail}"))
5700                }
5701                other => Err(CalError::UnexpectedToken {
5702                    expected: "the dotted verb tail (run, review, apply, \
5703                               execute, respond, cancel)"
5704                        .into(),
5705                    found: other
5706                        .map(|t| t.token.description())
5707                        .unwrap_or_else(|| "end of input".into()),
5708                    span: other.map(|t| t.span),
5709                    suggestion: None,
5710                }),
5711            }
5712        } else {
5713            Ok(head)
5714        }
5715    }
5716
5717    /// The shared body of GRANT/REVOKE:
5718    /// `<verb>[, <verb>…] ON <ns|*>[, <ns>…] <TO|FROM> "<principal>"
5719    /// [WITH because("<why>")]`.
5720    fn parse_dcl_body(&mut self, recipient_token: &Token) -> CalResult<DclBody> {
5721        let mut verbs = vec![self.parse_dcl_verb()?];
5722        while self.at_exact(&Token::Comma) {
5723            self.advance();
5724            verbs.push(self.parse_dcl_verb()?);
5725        }
5726
5727        self.expect_exact(&Token::On)?;
5728        let mut namespaces = Vec::new();
5729        loop {
5730            match self.peek() {
5731                Some(SpannedToken { token: Token::Asterisk, .. }) => {
5732                    namespaces.push("*".to_string());
5733                    self.advance();
5734                }
5735                Some(SpannedToken { token: Token::Ident(ns), .. }) => {
5736                    namespaces.push(ns.clone());
5737                    self.advance();
5738                }
5739                Some(SpannedToken { token: Token::StringLiteral(ns), .. }) => {
5740                    namespaces.push(ns.clone());
5741                    self.advance();
5742                }
5743                other => {
5744                    return Err(CalError::UnexpectedToken {
5745                        expected: "a namespace or *".into(),
5746                        found: other
5747                            .map(|t| t.token.description())
5748                            .unwrap_or_else(|| "end of input".into()),
5749                        span: other.map(|t| t.span),
5750                        suggestion: None,
5751                    });
5752                }
5753            }
5754            if self.at_exact(&Token::Comma) {
5755                self.advance();
5756            } else {
5757                break;
5758            }
5759        }
5760
5761        self.expect_exact(recipient_token)?;
5762        // Principals are string literals: they carry `:` and often `-`,
5763        // which the identifier grammar does not.
5764        let principal = self.parse_string_literal()?;
5765
5766        // Optional `WITH because("<why>")` — BECAUSE is a keyword token, so
5767        // match it as one (the REASON alias works too).
5768        let mut reason = None;
5769        if self.at_exact(&Token::With) {
5770            self.advance();
5771            if self.at_exact(&Token::Because) || self.at_exact(&Token::Reason) {
5772                self.advance();
5773                self.expect_exact(&Token::LParen)?;
5774                reason = Some(self.parse_string_literal()?);
5775                self.expect_exact(&Token::RParen)?;
5776            } else {
5777                let other = self.peek();
5778                return Err(CalError::UnexpectedToken {
5779                    expected: "because(\"<why>\")".into(),
5780                    found: other
5781                        .map(|t| t.token.description())
5782                        .unwrap_or_else(|| "end of input".into()),
5783                    span: other.map(|t| t.span),
5784                    suggestion: Some(
5785                        "GRANT/REVOKE support exactly one option: \
5786                         WITH because(\"<why>\")"
5787                            .into(),
5788                    ),
5789                });
5790            }
5791        }
5792        Ok((verbs, namespaces, principal, reason))
5793    }
5794
5795    /// Parse `GRANT <verbs> ON <ns> TO "<principal>" [WITH because("…")]`.
5796    fn parse_grant(&mut self) -> CalResult<CalStatement> {
5797        let span_start = self.current_span();
5798        self.expect_exact(&Token::Grant)?;
5799        let (verbs, namespaces, principal, reason) = self.parse_dcl_body(&Token::To)?;
5800        let span_end = self.prev_span();
5801        Ok(CalStatement::Grant(GrantStmt {
5802            verbs,
5803            namespaces,
5804            principal,
5805            reason,
5806            span: Some(Span::new(
5807                span_start.start,
5808                span_end.end,
5809                span_start.line,
5810                span_start.col,
5811            )),
5812        }))
5813    }
5814
5815    /// Parse `REVOKE <verbs> ON <ns> FROM "<principal>" [WITH because("…")]`.
5816    fn parse_revoke(&mut self) -> CalResult<CalStatement> {
5817        let span_start = self.current_span();
5818        self.expect_exact(&Token::Revoke)?;
5819        let (verbs, namespaces, principal, reason) = self.parse_dcl_body(&Token::From)?;
5820        let span_end = self.prev_span();
5821        Ok(CalStatement::Revoke(RevokeStmt {
5822            verbs,
5823            namespaces,
5824            principal,
5825            reason,
5826            span: Some(Span::new(
5827                span_start.start,
5828                span_end.end,
5829                span_start.line,
5830                span_start.col,
5831            )),
5832        }))
5833    }
5834
5835    /// The shared body of the four governance statements (CAL 1.3 §8.16):
5836    /// `<hash> BECAUSE "<why>"`. BECAUSE is mandatory — a parse error, so
5837    /// the reason requirement is syntax, not convention.
5838    fn parse_governance_body(&mut self, keyword: &Token) -> CalResult<GovernanceStmt> {
5839        let span_start = self.current_span();
5840        self.expect_exact(keyword)?;
5841        let hash = self.parse_hash_literal()?;
5842        if !self.at_exact(&Token::Because) && !self.at_exact(&Token::Reason) {
5843            return Err(CalError::MissingReason {
5844                span: Some(self.current_span()),
5845            });
5846        }
5847        self.advance();
5848        let reason = self.parse_string_literal()?;
5849        let span_end = self.prev_span();
5850        Ok(GovernanceStmt {
5851            hash,
5852            reason,
5853            span: Some(Span::new(
5854                span_start.start,
5855                span_end.end,
5856                span_start.line,
5857                span_start.col,
5858            )),
5859        })
5860    }
5861
5862    /// Parse `RUN LOOP [FULL] [WITH min_new(N), if_stale("<dur>")]` — the
5863    /// caller has seen `RUN` followed by the ident `LOOP`.
5864    fn parse_run_loop(&mut self, span_start: Span) -> CalResult<CalStatement> {
5865        // Consume the LOOP ident.
5866        self.advance();
5867        let mut full_sweep = false;
5868        if let Some(SpannedToken { token: Token::Ident(w), .. }) = self.peek() {
5869            if w.eq_ignore_ascii_case("FULL") {
5870                full_sweep = true;
5871                self.advance();
5872            }
5873        }
5874        let mut min_new: Option<u64> = None;
5875        let mut if_stale_ms: Option<i64> = None;
5876        if self.at_exact(&Token::With) {
5877            self.advance();
5878            loop {
5879                match self.peek() {
5880                    Some(SpannedToken { token: Token::Ident(opt), .. })
5881                        if opt.eq_ignore_ascii_case("min_new") =>
5882                    {
5883                        self.advance();
5884                        self.expect_exact(&Token::LParen)?;
5885                        match self.peek() {
5886                            Some(SpannedToken { token: Token::NumberLiteral(n), .. })
5887                                if *n >= 0.0 =>
5888                            {
5889                                min_new = Some(*n as u64);
5890                                self.advance();
5891                            }
5892                            other => {
5893                                return Err(CalError::UnexpectedToken {
5894                                    expected: "a number".into(),
5895                                    found: other
5896                                        .map(|t| t.token.description())
5897                                        .unwrap_or_else(|| "end of input".into()),
5898                                    span: other.map(|t| t.span),
5899                                    suggestion: None,
5900                                });
5901                            }
5902                        }
5903                        self.expect_exact(&Token::RParen)?;
5904                    }
5905                    Some(SpannedToken { token: Token::Ident(opt), .. })
5906                        if opt.eq_ignore_ascii_case("if_stale") =>
5907                    {
5908                        self.advance();
5909                        self.expect_exact(&Token::LParen)?;
5910                        let dur = self.parse_string_literal()?;
5911                        if_stale_ms = Some(parse_stale_duration_ms(&dur).ok_or_else(|| {
5912                            CalError::UnexpectedToken {
5913                                expected: "a duration like \"6h\", \"90m\", or \"2d\"".into(),
5914                                found: dur.clone(),
5915                                span: Some(self.prev_span()),
5916                                suggestion: None,
5917                            }
5918                        })?);
5919                        self.expect_exact(&Token::RParen)?;
5920                    }
5921                    other => {
5922                        return Err(CalError::UnexpectedToken {
5923                            expected: "min_new(N) or if_stale(\"<dur>\")".into(),
5924                            found: other
5925                                .map(|t| t.token.description())
5926                                .unwrap_or_else(|| "end of input".into()),
5927                            span: other.map(|t| t.span),
5928                            suggestion: Some(
5929                                "RUN LOOP supports WITH min_new(N), if_stale(\"6h\") — model \
5930                                 and policy configuration is host-side, never statement text"
5931                                    .into(),
5932                            ),
5933                        });
5934                    }
5935                }
5936                if self.at_exact(&Token::Comma) {
5937                    self.advance();
5938                } else {
5939                    break;
5940                }
5941            }
5942        }
5943        let span_end = self.prev_span();
5944        Ok(CalStatement::RunLoop(RunLoopStmt {
5945            full_sweep,
5946            min_new,
5947            if_stale_ms,
5948            span: Some(Span::new(
5949                span_start.start,
5950                span_end.end,
5951                span_start.line,
5952                span_start.col,
5953            )),
5954        }))
5955    }
5956
5957    /// Parse `REMEMBER "<content>" [WITH session("<id>"), role("<r>"),
5958    /// run("<id>")]`.
5959    fn parse_remember(&mut self) -> CalResult<CalStatement> {
5960        let span_start = self.current_span();
5961        self.expect_exact(&Token::Remember)?;
5962        let content = self.parse_string_literal()?;
5963        let mut session_id = None;
5964        let mut role = None;
5965        let mut run_id = None;
5966        if self.at_exact(&Token::With) {
5967            self.advance();
5968            loop {
5969                let opt = match self.peek() {
5970                    Some(SpannedToken { token: Token::Ident(w), .. }) => w.to_ascii_lowercase(),
5971                    // RUN is a keyword token; `run("<id>")` must still work.
5972                    Some(SpannedToken { token: Token::Run, .. }) => "run".to_string(),
5973                    other => {
5974                        return Err(CalError::UnexpectedToken {
5975                            expected: "session(\"<id>\"), role(\"<r>\"), or run(\"<id>\")".into(),
5976                            found: other
5977                                .map(|t| t.token.description())
5978                                .unwrap_or_else(|| "end of input".into()),
5979                            span: other.map(|t| t.span),
5980                            suggestion: None,
5981                        });
5982                    }
5983                };
5984                match opt.as_str() {
5985                    "session" | "role" | "run" => {
5986                        self.advance();
5987                        self.expect_exact(&Token::LParen)?;
5988                        let value = self.parse_string_literal()?;
5989                        self.expect_exact(&Token::RParen)?;
5990                        match opt.as_str() {
5991                            "session" => session_id = Some(value),
5992                            "role" => role = Some(value),
5993                            _ => run_id = Some(value),
5994                        }
5995                    }
5996                    other => {
5997                        return Err(CalError::UnexpectedToken {
5998                            expected: "session(\"<id>\"), role(\"<r>\"), or run(\"<id>\")".into(),
5999                            found: other.to_string(),
6000                            span: self.peek().map(|t| t.span),
6001                            suggestion: Some(
6002                                "REMEMBER carries capture metadata only — fact extraction \
6003                                 is host configuration (areev remember --model …)"
6004                                    .into(),
6005                            ),
6006                        });
6007                    }
6008                }
6009                if self.at_exact(&Token::Comma) {
6010                    self.advance();
6011                } else {
6012                    break;
6013                }
6014            }
6015        }
6016        let span_end = self.prev_span();
6017        Ok(CalStatement::Remember(RememberStmt {
6018            content,
6019            session_id,
6020            role,
6021            run_id,
6022            span: Some(Span::new(
6023                span_start.start,
6024                span_end.end,
6025                span_start.line,
6026                span_start.col,
6027            )),
6028        }))
6029    }
6030
6031    /// One optional `<IDENT>(<n>)`-free numeric suffix keyword, e.g.
6032    /// `LIMIT 50` / `DEPTH 3`, where the keyword is a bare word.
6033    fn parse_word_number(&mut self, word: &str) -> CalResult<Option<usize>> {
6034        let matches_word = match self.peek() {
6035            Some(SpannedToken { token: Token::Ident(w), .. }) => w.eq_ignore_ascii_case(word),
6036            Some(SpannedToken { token: Token::Limit, .. }) => word.eq_ignore_ascii_case("LIMIT"),
6037            _ => false,
6038        };
6039        if !matches_word {
6040            return Ok(None);
6041        }
6042        self.advance();
6043        match self.peek() {
6044            Some(SpannedToken { token: Token::NumberLiteral(n), .. }) if *n >= 1.0 => {
6045                let n = *n as usize;
6046                self.advance();
6047                Ok(Some(n))
6048            }
6049            other => Err(CalError::UnexpectedToken {
6050                expected: format!("a positive number after {word}"),
6051                found: other
6052                    .map(|t| t.token.description())
6053                    .unwrap_or_else(|| "end of input".into()),
6054                span: other.map(|t| t.span),
6055                suggestion: None,
6056            }),
6057        }
6058    }
6059
6060    /// Expect a specific bare-word identifier (case-insensitive).
6061    fn expect_word(&mut self, word: &str, form: &str) -> CalResult<()> {
6062        match self.peek() {
6063            Some(SpannedToken { token: Token::Ident(w), .. })
6064                if w.eq_ignore_ascii_case(word) =>
6065            {
6066                self.advance();
6067                Ok(())
6068            }
6069            other => Err(CalError::UnexpectedToken {
6070                expected: word.into(),
6071                found: other
6072                    .map(|t| t.token.description())
6073                    .unwrap_or_else(|| "end of input".into()),
6074                span: other.map(|t| t.span),
6075                suggestion: Some(form.into()),
6076            }),
6077        }
6078    }
6079
6080    /// Parse `MERGE "<subject>" RELATION "<relation>" TO "<object>"
6081    /// [CONFIDENCE <n>] BECAUSE "<why>"` — close an open fork.
6082    fn parse_merge(&mut self) -> CalResult<CalStatement> {
6083        let span_start = self.current_span();
6084        self.expect_exact(&Token::Merge)?;
6085        const FORM: &str = "a merge is spelled MERGE \"<subject>\" RELATION \"<relation>\" \
6086                            TO \"<object>\" [CONFIDENCE <n>] BECAUSE \"<why>\"";
6087        let subject = self.parse_string_literal()?;
6088        self.expect_word("RELATION", FORM)?;
6089        let relation = self.parse_string_literal()?;
6090        self.expect_exact(&Token::To)?;
6091        let object = self.parse_string_literal()?;
6092        let mut confidence = None;
6093        if let Some(SpannedToken { token: Token::Ident(w), .. }) = self.peek() {
6094            if w.eq_ignore_ascii_case("CONFIDENCE") {
6095                self.advance();
6096                match self.peek() {
6097                    Some(SpannedToken { token: Token::NumberLiteral(n), .. })
6098                        if (0.0..=1.0).contains(n) =>
6099                    {
6100                        confidence = Some(*n);
6101                        self.advance();
6102                    }
6103                    other => {
6104                        return Err(CalError::UnexpectedToken {
6105                            expected: "a confidence between 0 and 1".into(),
6106                            found: other
6107                                .map(|t| t.token.description())
6108                                .unwrap_or_else(|| "end of input".into()),
6109                            span: other.map(|t| t.span),
6110                            suggestion: None,
6111                        });
6112                    }
6113                }
6114            }
6115        }
6116        // Every write carries its reason; a fork resolution doubly so.
6117        if !self.at_exact(&Token::Reason) && !self.at_exact(&Token::Because) {
6118            return Err(CalError::MissingReason { span: Some(self.current_span()) });
6119        }
6120        self.advance();
6121        let reason = self.parse_string_literal()?;
6122        let span_end = self.prev_span();
6123        Ok(CalStatement::Merge(MergeStmt {
6124            subject,
6125            relation,
6126            object,
6127            confidence,
6128            reason,
6129            span: Some(Span::new(span_start.start, span_end.end, span_start.line, span_start.col)),
6130        }))
6131    }
6132
6133    /// Parse `RELATED "<start>" VIA "<r1,r2>" [DIRECTION out|in|both]
6134    /// [DEPTH <n>] [LIMIT <n>]` — the bounded graph walk.
6135    fn parse_related(&mut self) -> CalResult<CalStatement> {
6136        let span_start = self.current_span();
6137        self.expect_exact(&Token::Related)?;
6138        const FORM: &str = "the graph walk is spelled RELATED \"<start>\" VIA \
6139                            \"<r1,r2>\" [DIRECTION out|in|both] [DEPTH <n>] [LIMIT <n>]";
6140        let start = self.parse_string_literal()?;
6141        self.expect_word("VIA", FORM)?;
6142        let relations = self.parse_string_literal()?;
6143        let mut direction = None;
6144        if let Some(SpannedToken { token: Token::Ident(w), .. }) = self.peek() {
6145            if w.eq_ignore_ascii_case("DIRECTION") {
6146                self.advance();
6147                match self.peek() {
6148                    Some(SpannedToken { token: Token::Ident(d), .. })
6149                        if ["out", "in", "both"].iter().any(|x| d.eq_ignore_ascii_case(x)) =>
6150                    {
6151                        direction = Some(d.to_ascii_lowercase());
6152                        self.advance();
6153                    }
6154                    // IN doubles as the list-membership keyword token.
6155                    Some(SpannedToken { token: Token::In, .. }) => {
6156                        direction = Some("in".to_string());
6157                        self.advance();
6158                    }
6159                    other => {
6160                        return Err(CalError::UnexpectedToken {
6161                            expected: "out, in, or both".into(),
6162                            found: other
6163                                .map(|t| t.token.description())
6164                                .unwrap_or_else(|| "end of input".into()),
6165                            span: other.map(|t| t.span),
6166                            suggestion: None,
6167                        });
6168                    }
6169                }
6170            }
6171        }
6172        let depth = self.parse_word_number("DEPTH")?;
6173        let limit = self.parse_word_number("LIMIT")?;
6174        let span_end = self.prev_span();
6175        Ok(CalStatement::Related(RelatedStmt {
6176            start,
6177            relations,
6178            direction,
6179            depth,
6180            limit,
6181            span: Some(Span::new(span_start.start, span_end.end, span_start.line, span_start.col)),
6182        }))
6183    }
6184
6185    /// Parse `NOVELTY "<text>" [SUBJECT "<s>"] [RELATION "<r>"]
6186    /// [LIMIT <k>]` — the paraphrase check.
6187    fn parse_novelty(&mut self) -> CalResult<CalStatement> {
6188        let span_start = self.current_span();
6189        self.expect_exact(&Token::Novelty)?;
6190        let text = self.parse_string_literal()?;
6191        let mut subject = None;
6192        let mut relation = None;
6193        loop {
6194            match self.peek() {
6195                Some(SpannedToken { token: Token::Ident(w), .. })
6196                    if w.eq_ignore_ascii_case("SUBJECT") && subject.is_none() =>
6197                {
6198                    self.advance();
6199                    subject = Some(self.parse_string_literal()?);
6200                }
6201                Some(SpannedToken { token: Token::Ident(w), .. })
6202                    if w.eq_ignore_ascii_case("RELATION") && relation.is_none() =>
6203                {
6204                    self.advance();
6205                    relation = Some(self.parse_string_literal()?);
6206                }
6207                _ => break,
6208            }
6209        }
6210        let limit = self.parse_word_number("LIMIT")?;
6211        let span_end = self.prev_span();
6212        Ok(CalStatement::Novelty(NoveltyStmt {
6213            text,
6214            subject,
6215            relation,
6216            limit,
6217            span: Some(Span::new(span_start.start, span_end.end, span_start.line, span_start.col)),
6218        }))
6219    }
6220
6221    /// Parse `ENTITY "<subject>" RELATION "<relation>" AT <epoch-ms>
6222    /// [AXIS world|knowledge]` (CAL 1.3 Wave 2 — the as-of read).
6223    fn parse_entity_at(&mut self) -> CalResult<CalStatement> {
6224        let span_start = self.current_span();
6225        self.expect_exact(&Token::Entity)?;
6226        let subject = self.parse_string_literal()?;
6227        match self.peek() {
6228            Some(SpannedToken { token: Token::Ident(w), .. })
6229                if w.eq_ignore_ascii_case("RELATION") =>
6230            {
6231                self.advance();
6232            }
6233            other => {
6234                return Err(CalError::UnexpectedToken {
6235                    expected: "RELATION".into(),
6236                    found: other
6237                        .map(|t| t.token.description())
6238                        .unwrap_or_else(|| "end of input".into()),
6239                    span: other.map(|t| t.span),
6240                    suggestion: Some(
6241                        "the as-of read is spelled ENTITY \"<subject>\" RELATION \
6242                         \"<relation>\" AT <epoch-ms> [AXIS world|knowledge]"
6243                            .into(),
6244                    ),
6245                });
6246            }
6247        }
6248        let relation = self.parse_string_literal()?;
6249        match self.peek() {
6250            Some(SpannedToken { token: Token::Ident(w), .. }) if w.eq_ignore_ascii_case("AT") => {
6251                self.advance();
6252            }
6253            other => {
6254                return Err(CalError::UnexpectedToken {
6255                    expected: "AT".into(),
6256                    found: other
6257                        .map(|t| t.token.description())
6258                        .unwrap_or_else(|| "end of input".into()),
6259                    span: other.map(|t| t.span),
6260                    suggestion: None,
6261                });
6262            }
6263        }
6264        let at_ms = match self.peek() {
6265            Some(SpannedToken { token: Token::NumberLiteral(n), .. }) if *n >= 0.0 => {
6266                let n = *n as i64;
6267                self.advance();
6268                n
6269            }
6270            other => {
6271                return Err(CalError::UnexpectedToken {
6272                    expected: "a point in time (epoch milliseconds)".into(),
6273                    found: other
6274                        .map(|t| t.token.description())
6275                        .unwrap_or_else(|| "end of input".into()),
6276                    span: other.map(|t| t.span),
6277                    suggestion: None,
6278                });
6279            }
6280        };
6281        let mut axis = None;
6282        if let Some(SpannedToken { token: Token::Ident(w), .. }) = self.peek() {
6283            if w.eq_ignore_ascii_case("AXIS") {
6284                self.advance();
6285                match self.peek() {
6286                    Some(SpannedToken { token: Token::Ident(a), .. })
6287                        if a.eq_ignore_ascii_case("world") || a.eq_ignore_ascii_case("knowledge") =>
6288                    {
6289                        axis = Some(a.to_ascii_lowercase());
6290                        self.advance();
6291                    }
6292                    // KNOWLEDGE doubles as the relation-category keyword, so
6293                    // it arrives as its own token, not an Ident.
6294                    Some(SpannedToken { token: Token::Knowledge, .. }) => {
6295                        axis = Some("knowledge".to_string());
6296                        self.advance();
6297                    }
6298                    other => {
6299                        return Err(CalError::UnexpectedToken {
6300                            expected: "world or knowledge".into(),
6301                            found: other
6302                                .map(|t| t.token.description())
6303                                .unwrap_or_else(|| "end of input".into()),
6304                            span: other.map(|t| t.span),
6305                            suggestion: None,
6306                        });
6307                    }
6308                }
6309            }
6310        }
6311        let span_end = self.prev_span();
6312        Ok(CalStatement::EntityAt(EntityAtStmt {
6313            subject,
6314            relation,
6315            at_ms,
6316            axis,
6317            span: Some(Span::new(
6318                span_start.start,
6319                span_end.end,
6320                span_start.line,
6321                span_start.col,
6322            )),
6323        }))
6324    }
6325
6326    /// Parse `RUN TRACE "<run-id>" [LIMIT <n>]` — the caller has seen
6327    /// `RUN` followed by the ident `TRACE`.
6328    fn parse_run_trace(&mut self, span_start: Span) -> CalResult<CalStatement> {
6329        self.advance(); // TRACE
6330        let run_id = self.parse_string_literal()?;
6331        let limit = self.parse_word_number("LIMIT")?;
6332        let span_end = self.prev_span();
6333        Ok(CalStatement::RunTrace(RunTraceStmt {
6334            run_id,
6335            limit,
6336            span: Some(Span::new(
6337                span_start.start,
6338                span_end.end,
6339                span_start.line,
6340                span_start.col,
6341            )),
6342        }))
6343    }
6344
6345    /// Parse `RUNS TOUCHING <hash> [DEPTH <n>]`.
6346    fn parse_runs_touching(&mut self) -> CalResult<CalStatement> {
6347        let span_start = self.current_span();
6348        self.expect_exact(&Token::Runs)?;
6349        match self.peek() {
6350            Some(SpannedToken { token: Token::Ident(w), .. })
6351                if w.eq_ignore_ascii_case("TOUCHING") =>
6352            {
6353                self.advance();
6354            }
6355            other => {
6356                return Err(CalError::UnexpectedToken {
6357                    expected: "TOUCHING".into(),
6358                    found: other
6359                        .map(|t| t.token.description())
6360                        .unwrap_or_else(|| "end of input".into()),
6361                    span: other.map(|t| t.span),
6362                    suggestion: Some("the reverse join is spelled RUNS TOUCHING <hash> [DEPTH <n>]".into()),
6363                });
6364            }
6365        }
6366        let hash = self.parse_hash_literal()?;
6367        let depth = self.parse_word_number("DEPTH")?;
6368        let span_end = self.prev_span();
6369        Ok(CalStatement::RunsTouching(RunsTouchingStmt {
6370            hash,
6371            depth,
6372            span: Some(Span::new(
6373                span_start.start,
6374                span_end.end,
6375                span_start.line,
6376                span_start.col,
6377            )),
6378        }))
6379    }
6380
6381    /// Parse `DERIVED FROM <hash>` — reverse provenance.
6382    fn parse_derived_from(&mut self) -> CalResult<CalStatement> {
6383        let span_start = self.current_span();
6384        self.expect_exact(&Token::Derived)?;
6385        self.expect_exact(&Token::From)?;
6386        let hash = self.parse_hash_literal()?;
6387        let span_end = self.prev_span();
6388        Ok(CalStatement::DerivedFrom(DerivedFromStmt {
6389            hash,
6390            span: Some(Span::new(
6391                span_start.start,
6392                span_end.end,
6393                span_start.line,
6394                span_start.col,
6395            )),
6396        }))
6397    }
6398
6399    /// Parse `SHOW GRANTS [FOR "<principal>"]`.
6400    fn parse_show_grants(&mut self) -> CalResult<CalStatement> {
6401        let span_start = self.current_span();
6402        self.expect_exact(&Token::Show)?;
6403        match self.peek() {
6404            Some(SpannedToken { token: Token::Ident(w), .. })
6405                if w.eq_ignore_ascii_case("GRANTS") =>
6406            {
6407                self.advance();
6408            }
6409            Some(SpannedToken { token: Token::Ident(w), .. })
6410                if w.eq_ignore_ascii_case("FORKS") =>
6411            {
6412                self.advance();
6413                let span_end = self.prev_span();
6414                return Ok(CalStatement::ShowForks(ShowForksStmt {
6415                    span: Some(Span::new(
6416                        span_start.start,
6417                        span_end.end,
6418                        span_start.line,
6419                        span_start.col,
6420                    )),
6421                }));
6422            }
6423            other => {
6424                return Err(CalError::UnexpectedToken {
6425                    expected: "GRANTS or FORKS".into(),
6426                    found: other
6427                        .map(|t| t.token.description())
6428                        .unwrap_or_else(|| "end of input".into()),
6429                    span: other.map(|t| t.span),
6430                    suggestion: Some("SHOW GRANTS [FOR \"<principal>\"] | SHOW FORKS".into()),
6431                });
6432            }
6433        }
6434        let principal = if self.at_exact(&Token::For) {
6435            self.advance();
6436            Some(self.parse_string_literal()?)
6437        } else {
6438            None
6439        };
6440        let span_end = self.prev_span();
6441        Ok(CalStatement::ShowGrants(ShowGrantsStmt {
6442            principal,
6443            span: Some(Span::new(
6444                span_start.start,
6445                span_end.end,
6446                span_start.line,
6447                span_start.col,
6448            )),
6449        }))
6450    }
6451
6452    fn parse_revert(&mut self) -> CalResult<CalStatement> {
6453        let span_start = self.current_span();
6454        self.expect_exact(&Token::Revert)?;
6455
6456        let hash = self.parse_hash_literal()?;
6457
6458        // REASON / BECAUSE is required.
6459        if !self.at_exact(&Token::Reason) && !self.at_exact(&Token::Because) {
6460            return Err(CalError::MissingReason {
6461                span: Some(self.current_span()),
6462            });
6463        }
6464        self.advance();
6465        let rspan = self.current_span();
6466        let reason = self.parse_string_literal()?;
6467        if reason.len() > MAX_REASON_LENGTH {
6468            return Err(CalError::ReasonTooLong {
6469                length: reason.len(),
6470                max: MAX_REASON_LENGTH,
6471                span: Some(rspan),
6472            });
6473        }
6474
6475        let span_end = self.prev_span();
6476        Ok(CalStatement::Revert(RevertStmt {
6477            hash,
6478            reason,
6479            span: Some(Span::new(
6480                span_start.start,
6481                span_end.end,
6482                span_start.line,
6483                span_start.col,
6484            )),
6485        }))
6486    }
6487
6488    // -- DROP (Tier 2) ----------------------------------------------------
6489
6490    fn parse_drop(&mut self) -> CalResult<CalStatement> {
6491        let span_start = self.current_span();
6492        self.expect_exact(&Token::Drop)?;
6493
6494        if self.at_exact(&Token::Template) {
6495            self.advance(); // consume TEMPLATE
6496            let name = self.parse_string_literal()?;
6497            let span_end = self.prev_span();
6498            Ok(CalStatement::DropTemplate(super::ast::DropTemplateStmt {
6499                name,
6500                span: Some(Span::new(
6501                    span_start.start,
6502                    span_end.end,
6503                    span_start.line,
6504                    span_start.col,
6505                )),
6506            }))
6507        } else if self.at_exact(&Token::Query) {
6508            self.advance(); // consume QUERY
6509            let name = self.parse_string_literal()?;
6510            let span_end = self.prev_span();
6511            Ok(CalStatement::DropQuery(DropQueryStmt {
6512                name,
6513                span: Some(Span::new(
6514                    span_start.start,
6515                    span_end.end,
6516                    span_start.line,
6517                    span_start.col,
6518                )),
6519            }))
6520        } else {
6521            Err(CalError::UnexpectedToken {
6522                expected: "TEMPLATE or QUERY after DROP".into(),
6523                found: self
6524                    .peek()
6525                    .map(|t| t.token.description())
6526                    .unwrap_or("end of input".into()),
6527                span: Some(self.current_span()),
6528                suggestion: Some("Use DROP TEMPLATE \"name\" or DROP QUERY \"name\".".into()),
6529            })
6530        }
6531    }
6532
6533    // -- DEFINE dispatcher -------------------------------------------------
6534
6535    /// Dispatch DEFINE to either DEFINE TEMPLATE or DEFINE QUERY.
6536    fn parse_define(&mut self) -> CalResult<CalStatement> {
6537        // Peek at the token after DEFINE to determine which variant.
6538        let next = self.tokens.get(self.pos + 1);
6539        match next {
6540            Some(SpannedToken {
6541                token: Token::Template,
6542                ..
6543            }) => self.parse_define_template(),
6544            Some(SpannedToken {
6545                token: Token::Query,
6546                ..
6547            }) => self.parse_define_query(),
6548            _ => Err(CalError::UnexpectedToken {
6549                expected: "TEMPLATE or QUERY after DEFINE".into(),
6550                found: next
6551                    .map(|t| t.token.description())
6552                    .unwrap_or("end of input".into()),
6553                span: Some(self.current_span()),
6554                suggestion: Some("Use DEFINE TEMPLATE or DEFINE QUERY.".into()),
6555            }),
6556        }
6557    }
6558
6559    // -- DEFINE TEMPLATE --------------------------------------------------
6560
6561    /// Parse `DEFINE TEMPLATE "name" [DESCRIPTION "..."] [EXTENDS "parent"] [FOR grain_types] AS "source"`.
6562    fn parse_define_template(&mut self) -> CalResult<CalStatement> {
6563        let span_start = self.current_span();
6564        self.expect_exact(&Token::Define)?;
6565        self.expect_exact(&Token::Template)?;
6566
6567        // §7 spells the name as a bare identifier. A quoted name is still
6568        // accepted — it was the only form Areev ever wrote, and it is the
6569        // only way to name a template containing a space.
6570        let name = self.parse_template_name()?;
6571
6572        let mut description = None;
6573        let mut parent = None;
6574        let mut grain_types = Vec::new();
6575
6576        // Parse optional clauses before AS.
6577        loop {
6578            match self.peek() {
6579                Some(SpannedToken {
6580                    token: Token::Ident(s),
6581                    ..
6582                }) if s.eq_ignore_ascii_case("DESCRIPTION") => {
6583                    self.advance();
6584                    description = Some(self.parse_string_literal()?);
6585                }
6586                Some(SpannedToken {
6587                    token: Token::Extends,
6588                    ..
6589                }) => {
6590                    self.advance();
6591                    parent = Some(self.parse_template_name()?);
6592                }
6593                Some(SpannedToken {
6594                    token: Token::For, ..
6595                }) => {
6596                    self.advance();
6597                    // Parse comma-separated grain type names.
6598                    loop {
6599                        let gt = match self.peek() {
6600                            Some(SpannedToken {
6601                                token: Token::Ident(s),
6602                                ..
6603                            }) => {
6604                                let s = s.clone();
6605                                self.advance();
6606                                s
6607                            }
6608                            Some(st) => {
6609                                let desc = st.token.description();
6610                                // Allow keywords that are also grain type names.
6611                                self.advance();
6612                                desc
6613                            }
6614                            None => break,
6615                        };
6616                        grain_types.push(gt.to_lowercase());
6617                        if self.at_exact(&Token::Comma) {
6618                            self.advance();
6619                        } else {
6620                            break;
6621                        }
6622                    }
6623                }
6624                _ => break,
6625            }
6626        }
6627
6628        // Body: either the `AS "<text>"` shorthand (§10.6.1) or a section
6629        // list (§10.6). Exactly one — a definition may not combine them.
6630        let (source, sections) = if self.at_exact(&Token::As) {
6631            self.advance();
6632            let source_span = self.current_span();
6633            let source = self.parse_string_literal()?;
6634            if source.is_empty() {
6635                return Err(CalError::TemplateSyntaxError {
6636                    detail: "template source cannot be empty".to_string(),
6637                    span: Some(source_span),
6638                });
6639            }
6640            // §10.6.1: `AS "<text>"` is exactly `ELEMENT { <text> }`, so it
6641            // desugars here and the rest of the pipeline only ever sees
6642            // sections.
6643            //
6644            // Compatibility shim: a body that drives its own iteration with
6645            // `{{#each grains}}` is a pre-§10.6 whole-result template. That
6646            // construct is meaningless inside an ELEMENT body (the engine
6647            // already supplies the grain) and reinterpreting it would render
6648            // the whole result set once per grain, so those bodies keep the
6649            // old semantics. Remove once no such templates remain in the wild.
6650            if source.contains("{{#each") {
6651                (source, None)
6652            } else {
6653                let sections = TemplateSectionSources {
6654                    element: Some(source),
6655                    ..Default::default()
6656                };
6657                (sections.to_source(), Some(sections))
6658            }
6659        } else {
6660            let sections = self.parse_template_sections()?;
6661            if sections.is_empty() {
6662                return Err(CalError::TemplateSyntaxError {
6663                    detail: "template body must be AS \"...\" or at least one \
6664                             section (HEADER, ELEMENT, ELEMENT_SUMMARY, \
6665                             ELEMENT_OMIT, SOURCE_BREAK, FOOTER)"
6666                        .to_string(),
6667                    span: Some(self.current_span()),
6668                });
6669            }
6670            (sections.to_source(), Some(sections))
6671        };
6672
6673        let span_end = self.prev_span();
6674        Ok(CalStatement::DefineTemplate(DefineTemplateStmt {
6675            name,
6676            description,
6677            parent,
6678            grain_types,
6679            source,
6680            sections,
6681            span: Some(Span::new(
6682                span_start.start,
6683                span_end.end,
6684                span_start.line,
6685                span_start.col,
6686            )),
6687        }))
6688    }
6689
6690    /// Parse a run of `HEADER { ... } ELEMENT { ... } ...` sections (§10.6).
6691    ///
6692    /// Bodies arrive from the lexer already captured as raw text — see
6693    /// [`super::lexer::SectionBody`] — so nothing here has to cope with
6694    /// template prose that is not valid CAL.
6695    fn parse_template_sections(&mut self) -> CalResult<TemplateSectionSources> {
6696        let mut out = TemplateSectionSources::default();
6697
6698        loop {
6699            let span = self.current_span();
6700            let Some(st) = self.peek() else { break };
6701            let (keyword, body) = match &st.token {
6702                Token::Header(b) => ("HEADER", b),
6703                Token::Element(b) => ("ELEMENT", b),
6704                Token::ElementSummary(b) => ("ELEMENT_SUMMARY", b),
6705                Token::ElementOmit(b) => ("ELEMENT_OMIT", b),
6706                Token::SourceBreak(b) => ("SOURCE_BREAK", b),
6707                Token::Footer(b) => ("FOOTER", b),
6708                _ => break,
6709            };
6710            // A bare keyword is an ordinary word here, not a section.
6711            let SectionBody::Body(text) = body else { break };
6712            let text = text.clone();
6713            self.advance();
6714
6715            let slot = match keyword {
6716                "HEADER" => &mut out.header,
6717                "ELEMENT" => &mut out.element,
6718                "ELEMENT_SUMMARY" => &mut out.element_summary,
6719                "ELEMENT_OMIT" => &mut out.element_omit,
6720                "SOURCE_BREAK" => &mut out.source_break,
6721                _ => &mut out.footer,
6722            };
6723            Self::set_section(slot, text, keyword, span)?;
6724        }
6725
6726        Ok(out)
6727    }
6728
6729    /// Parse a template name: a bare identifier (§7) or a quoted string.
6730    ///
6731    /// `template_name = identifier` in the spec. The quoted form stays
6732    /// accepted because it is what Areev has always written and the only way
6733    /// to spell a name containing a space; in `FORMAT` position the two are
6734    /// deliberately *not* interchangeable — there a quoted argument is a
6735    /// template body, never a name.
6736    fn parse_template_name(&mut self) -> CalResult<String> {
6737        if matches!(
6738            self.peek(),
6739            Some(SpannedToken {
6740                token: Token::StringLiteral(_),
6741                ..
6742            })
6743        ) {
6744            return self.parse_string_literal();
6745        }
6746
6747        let span = self.current_span();
6748        // `AS` here means the name was omitted; without this guard the label
6749        // parser would happily swallow it as the name.
6750        if self.at_exact(&Token::As) {
6751            return Err(CalError::UnexpectedToken {
6752                expected: "a template name".into(),
6753                found: "AS".into(),
6754                span: Some(span),
6755                suggestion: Some("Name the template: DEFINE TEMPLATE <name> AS \"...\"".into()),
6756            });
6757        }
6758
6759        // `parse_label`, not `parse_identifier`: preset names (`structured`,
6760        // `compact`, …) are keyword tokens, and `EXTENDS structured` has to
6761        // parse.
6762        let name = self.parse_label()?;
6763        // Defence in depth (invariant 3): a template name is inert, but the
6764        // destructive vocabulary stays out of CAL text everywhere it can
6765        // appear.
6766        if is_destructive_keyword(&name) {
6767            return Err(CalError::UnexpectedToken {
6768                expected: "a template name".into(),
6769                found: name,
6770                span: Some(span),
6771                suggestion: Some(
6772                    "Choose a different name — CAL keeps destructive words out \
6773                     of its grammar entirely."
6774                        .into(),
6775                ),
6776            });
6777        }
6778        Ok(name)
6779    }
6780
6781    /// Assign a section body, rejecting a second definition of the same one.
6782    fn set_section(
6783        slot: &mut Option<String>,
6784        text: String,
6785        keyword: &str,
6786        span: Span,
6787    ) -> CalResult<()> {
6788        if slot.is_some() {
6789            return Err(CalError::TemplateSyntaxError {
6790                detail: format!("section {keyword} is defined twice"),
6791                span: Some(span),
6792            });
6793        }
6794        *slot = Some(text);
6795        Ok(())
6796    }
6797
6798    // -- DEFINE QUERY -----------------------------------------------------
6799
6800    /// Parse `DEFINE QUERY "name" [($param [= default], ...)] [DESCRIPTION "..."] AS { body }`.
6801    fn parse_define_query(&mut self) -> CalResult<CalStatement> {
6802        let span_start = self.current_span();
6803        self.expect_exact(&Token::Define)?;
6804        self.expect_exact(&Token::Query)?;
6805
6806        // Parse query name (string literal).
6807        let name = self.parse_string_literal()?;
6808
6809        // Validate name format (reuse template name regex).
6810        if !crate::queries::QueryRegistry::is_valid_name(&name) {
6811            return Err(CalError::TemplateInvalidName {
6812                name,
6813                span: Some(self.current_span()),
6814            });
6815        }
6816
6817        // Optional parameter declarations: ($param1, $param2 = default, ...)
6818        let params = if self.at_exact(&Token::LParen) {
6819            self.parse_query_param_decls()?
6820        } else {
6821            Vec::new()
6822        };
6823
6824        // Validate param count.
6825        if params.len() > crate::queries::MAX_QUERY_PARAMS {
6826            return Err(CalError::TooManyQueryParams {
6827                count: params.len(),
6828                max: crate::queries::MAX_QUERY_PARAMS,
6829                span: Some(self.current_span()),
6830            });
6831        }
6832
6833        // Optional DESCRIPTION clause.
6834        let description = if matches!(
6835            self.peek(),
6836            Some(SpannedToken {
6837                token: Token::Ident(s),
6838                ..
6839            }) if s.eq_ignore_ascii_case("DESCRIPTION")
6840        ) {
6841            self.advance();
6842            Some(self.parse_string_literal()?)
6843        } else {
6844            None
6845        };
6846
6847        // AS keyword.
6848        self.expect_exact(&Token::As)?;
6849
6850        // Parse body: { ... }
6851        self.expect_exact(&Token::LBrace)?;
6852        let body_start = self.pos;
6853
6854        // Collect all tokens until matching closing brace.
6855        let mut brace_depth = 1u32;
6856        while brace_depth > 0 {
6857            match self.peek() {
6858                Some(SpannedToken {
6859                    token: Token::LBrace,
6860                    ..
6861                }) => {
6862                    brace_depth += 1;
6863                    self.advance();
6864                }
6865                Some(SpannedToken {
6866                    token: Token::RBrace,
6867                    ..
6868                }) => {
6869                    brace_depth -= 1;
6870                    if brace_depth > 0 {
6871                        self.advance();
6872                    }
6873                }
6874                None => {
6875                    return Err(CalError::UnexpectedToken {
6876                        expected: "closing '}' for DEFINE QUERY body".into(),
6877                        found: "end of input".into(),
6878                        span: Some(self.current_span()),
6879                        suggestion: None,
6880                    });
6881                }
6882                _ => {
6883                    self.advance();
6884                }
6885            }
6886        }
6887
6888        // Reconstruct body text from tokens between braces.
6889        let body_text = self.reconstruct_body_text(body_start);
6890
6891        // Validate body size.
6892        if body_text.len() > crate::queries::MAX_QUERY_BODY_SIZE {
6893            return Err(CalError::QueryBodyTooLarge {
6894                size: body_text.len(),
6895                max: crate::queries::MAX_QUERY_BODY_SIZE,
6896                span: Some(span_start),
6897            });
6898        }
6899
6900        // Validate body contains no write-tier or recursive statements.
6901        // We do a quick parse of the body to check.
6902        self.validate_query_body(&body_text, &span_start)?;
6903
6904        self.advance(); // consume closing }
6905
6906        let span_end = self.prev_span();
6907        Ok(CalStatement::DefineQuery(DefineQueryStmt {
6908            name,
6909            description,
6910            params,
6911            body: body_text,
6912            span: Some(Span::new(
6913                span_start.start,
6914                span_end.end,
6915                span_start.line,
6916                span_start.col,
6917            )),
6918        }))
6919    }
6920
6921    /// Parse parameter declarations: `($name [= default], ...)`.
6922    fn parse_query_param_decls(&mut self) -> CalResult<Vec<QueryParam>> {
6923        self.expect_exact(&Token::LParen)?;
6924        let mut params = Vec::new();
6925
6926        loop {
6927            if self.at_exact(&Token::RParen) {
6928                self.advance();
6929                break;
6930            }
6931            if !params.is_empty() {
6932                self.expect_exact(&Token::Comma)?;
6933            }
6934
6935            // Expect $name (lexed as Token::Parameter("name"))
6936            let name = match self.peek() {
6937                Some(SpannedToken {
6938                    token: Token::Parameter(n),
6939                    ..
6940                }) => {
6941                    let n = n.clone();
6942                    self.advance();
6943                    n
6944                }
6945                _ => {
6946                    return Err(CalError::UnexpectedToken {
6947                        expected: "parameter name ($name)".into(),
6948                        found: self
6949                            .peek()
6950                            .map(|t| t.token.description())
6951                            .unwrap_or("end of input".into()),
6952                        span: Some(self.current_span()),
6953                        suggestion: Some(
6954                            "Parameter declarations use $name syntax, e.g. ($user, $limit = 10)"
6955                                .into(),
6956                        ),
6957                    });
6958                }
6959            };
6960
6961            // Optional = default
6962            let default = if self.at_exact(&Token::Eq) {
6963                self.advance();
6964                Some(self.parse_value()?)
6965            } else {
6966                None
6967            };
6968
6969            params.push(QueryParam { name, default });
6970        }
6971
6972        Ok(params)
6973    }
6974
6975    /// Reconstruct body text from the source input between token positions.
6976    fn reconstruct_body_text(&self, body_start: usize) -> String {
6977        // Get the byte range from the first token after { to the token before }
6978        if body_start >= self.pos {
6979            return String::new();
6980        }
6981        let start_byte = self.tokens[body_start].span.start;
6982        let end_byte = self.tokens[self.pos - 1].span.end;
6983        self.input[start_byte..end_byte].trim().to_string()
6984    }
6985
6986    /// Validate that a query body contains only read-tier statements, and that
6987    /// it can actually parse.
6988    fn validate_query_body(&self, body: &str, span: &Span) -> CalResult<()> {
6989        // A `$`-bearing body still gets the evade-proof keyword scan below, and
6990        // then a real parse. It used to get ONLY the keyword scan, so any
6991        // syntax error in a parameterized body — the shape most saved queries
6992        // have — was stored unverified and first surfaced when a caller ran it.
6993        // A stored query that can never run is a landmine, and its first caller
6994        // is often an unattended agent.
6995        if body.contains('$') {
6996            // Word-level lexical scan (a `$`-body can't fully parse until RUN
6997            // substitutes params, so this guard must not be evadable). Split on
6998            // anything that isn't an identifier char so ANY whitespace or
6999            // punctuation between a keyword and its operand counts as a boundary
7000            // — `SUPERSEDE\n` no longer slips past a `"SUPERSEDE "` substring
7001            // test. Covers DROP/DEFINE (and the lexer-blocked destructive words)
7002            // that the old list missed. RUN is re-checked precisely at execution
7003            // via check_read_only_statement; this is only the DEFINE-time guard.
7004            let upper = body.to_ascii_uppercase();
7005            for word in upper.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
7006                if word == "RUN" {
7007                    return Err(CalError::RecursiveQuery { span: Some(*span) });
7008                }
7009                if matches!(
7010                    word,
7011                    "ADD" | "SUPERSEDE" | "ACCUMULATE" | "REVERT" | "FORGET" | "PURGE" | "DROP"
7012                        | "DEFINE" | "DELETE" | "ERASE" | "TRUNCATE" | "INSERT" | "UPDATE"
7013                        | "CREATE" | "GRANT"
7014                ) {
7015                    return Err(CalError::WriteInQueryBody {
7016                        stmt: word.to_string(),
7017                        span: Some(*span),
7018                    });
7019                }
7020            }
7021            // Now the shape. The reason the parse used to be skipped here is
7022            // real but narrow: a parameter in a NUMERIC position (`RECENT
7023            // $limit`) is not a valid number literal until RUN substitutes it,
7024            // so the body as written genuinely does not parse. Value positions
7025            // (`WHERE x = $p`) do. So try it as written, then retry with every
7026            // parameter standing in as a literal — a body that fails BOTH is
7027            // malformed no matter what RUN eventually substitutes.
7028            match super::parser::parse(body) {
7029                Ok(q) => return self.check_statement_read_only(&q.statement, span),
7030                Err(strict) => {
7031                    let filled = params_as_literals(body);
7032                    match super::parser::parse(&filled) {
7033                        Ok(q) => return self.check_statement_read_only(&q.statement, span),
7034                        // Report the error from the body as the author wrote
7035                        // it; the placeholder form is an internal probe and its
7036                        // spans would point at text the author never typed.
7037                        Err(_) => {
7038                            return Err(CalError::InvalidQueryBody {
7039                                detail: strict.to_string(),
7040                                span: Some(*span),
7041                            })
7042                        }
7043                    }
7044                }
7045            }
7046        }
7047
7048        // No parameters — safe to parse and validate fully.
7049        let parsed = match super::parser::parse(body) {
7050            Ok(q) => q,
7051            Err(e) => {
7052                return Err(CalError::InvalidQueryBody {
7053                    detail: e.to_string(),
7054                    span: Some(*span),
7055                });
7056            }
7057        };
7058
7059        // Check statement type — reject write-tier and recursive.
7060        self.check_statement_read_only(&parsed.statement, span)?;
7061        Ok(())
7062    }
7063
7064    /// Recursively check that a statement is read-only (no writes, no RUN).
7065    /// Delegates to the free [`check_read_only_statement`] so the executor can
7066    /// apply the identical gate to a parsed RUN body at execution time.
7067    fn check_statement_read_only(&self, stmt: &CalStatement, span: &Span) -> CalResult<()> {
7068        check_read_only_statement(stmt, span)
7069    }
7070
7071    // -- RUN ---------------------------------------------------------------
7072
7073    /// Parse `RUN "name" [($param = value, ...)]`.
7074    fn parse_run_query(&mut self) -> CalResult<CalStatement> {
7075        let span_start = self.current_span();
7076        self.expect_exact(&Token::Run)?;
7077
7078        // `RUN LOOP` (governance) and `RUN TRACE` (the run↔memory join)
7079        // ride the RUN prefix; a string literal keeps meaning a saved query.
7080        if let Some(SpannedToken { token: Token::Ident(w), .. }) = self.peek() {
7081            if w.eq_ignore_ascii_case("LOOP") {
7082                return self.parse_run_loop(span_start);
7083            }
7084            if w.eq_ignore_ascii_case("TRACE") {
7085                return self.parse_run_trace(span_start);
7086            }
7087        }
7088
7089        let name = self.parse_string_literal()?;
7090
7091        // Optional parameter bindings: ($name = value, ...)
7092        let bindings = if self.at_exact(&Token::LParen) {
7093            self.parse_query_param_bindings()?
7094        } else {
7095            Vec::new()
7096        };
7097
7098        let span_end = self.prev_span();
7099        Ok(CalStatement::RunQuery(RunQueryStmt {
7100            name,
7101            bindings,
7102            span: Some(Span::new(
7103                span_start.start,
7104                span_end.end,
7105                span_start.line,
7106                span_start.col,
7107            )),
7108        }))
7109    }
7110
7111    /// Parse parameter bindings: `($name = value, ...)`.
7112    fn parse_query_param_bindings(&mut self) -> CalResult<Vec<(String, Value)>> {
7113        self.expect_exact(&Token::LParen)?;
7114        let mut bindings = Vec::new();
7115
7116        loop {
7117            if self.at_exact(&Token::RParen) {
7118                self.advance();
7119                break;
7120            }
7121            if !bindings.is_empty() {
7122                self.expect_exact(&Token::Comma)?;
7123            }
7124
7125            // $name = value (lexed as Token::Parameter("name"))
7126            let name = match self.peek() {
7127                Some(SpannedToken {
7128                    token: Token::Parameter(n),
7129                    ..
7130                }) => {
7131                    let n = n.clone();
7132                    self.advance();
7133                    n
7134                }
7135                _ => {
7136                    return Err(CalError::UnexpectedToken {
7137                        expected: "parameter binding ($name = value)".into(),
7138                        found: self
7139                            .peek()
7140                            .map(|t| t.token.description())
7141                            .unwrap_or("end of input".into()),
7142                        span: Some(self.current_span()),
7143                        suggestion: Some(
7144                            "Parameter bindings use $name = value syntax, e.g. ($user = \"john\")"
7145                                .into(),
7146                        ),
7147                    });
7148                }
7149            };
7150            self.expect_exact(&Token::Eq)?;
7151            let value = self.parse_value()?;
7152            bindings.push((name, value));
7153        }
7154
7155        Ok(bindings)
7156    }
7157
7158    // -- STREAM ASSEMBLE --------------------------------------------------
7159
7160    /// Parse `STREAM ASSEMBLE ...` — delegates to `parse_assemble()` then sets streaming flag.
7161    fn parse_stream_assemble(&mut self) -> CalResult<CalStatement> {
7162        self.expect_exact(&Token::Stream)?;
7163        // parse_assemble() consumes the ASSEMBLE token and rest of the statement.
7164        let stmt = self.parse_assemble()?;
7165        match stmt {
7166            CalStatement::Assemble(mut asm) => {
7167                asm.streaming = true;
7168                Ok(CalStatement::Assemble(asm))
7169            }
7170            other => Ok(other), // Should not happen, but don't panic.
7171        }
7172    }
7173}
7174
7175// ---------------------------------------------------------------------------
7176// Suggestion helpers
7177// ---------------------------------------------------------------------------
7178
7179/// Return a human-readable suggestion for an unknown plural grain type string.
7180fn suggest_grain_type_plural(s: &str) -> Option<String> {
7181    let lower = s.to_ascii_lowercase();
7182    // Old OMS 1.1 names.
7183    match lower.as_str() {
7184        "beliefs" | "belief" => return Some("did you mean \"facts\"?".into()),
7185        "episodes" | "episode" => return Some("did you mean \"events\"?".into()),
7186        "checkpoints" | "checkpoint" => return Some("did you mean \"states\"?".into()),
7187        "actions" | "action" | "toolcalls" | "tool_calls" | "tool_call" => {
7188            return Some("did you mean \"tools\"?".into())
7189        }
7190        _ => {}
7191    }
7192    // NOTE: Singular grain type names (fact, event, reasoning, etc.) are now
7193    // accepted directly by `GrainTypePlural::parse()` and will never reach here.
7194    None
7195}
7196
7197/// Return a suggestion for an unknown singular grain type string.
7198fn suggest_grain_type_singular(s: &str) -> Option<String> {
7199    let lower = s.to_ascii_lowercase();
7200    match lower.as_str() {
7201        "belief" => return Some("did you mean \"fact\"?".into()),
7202        "episode" => return Some("did you mean \"event\"?".into()),
7203        "checkpoint" => return Some("did you mean \"state\"?".into()),
7204        "toolcall" | "tool_call" => return Some("did you mean \"tool\"?".into()),
7205        _ => {}
7206    }
7207    None
7208}
7209
7210// ---------------------------------------------------------------------------
7211// Re-export Span for the SinceClause import
7212// ---------------------------------------------------------------------------
7213
7214use super::ast::SinceClause;
7215
7216// ---------------------------------------------------------------------------
7217// Tests
7218// ---------------------------------------------------------------------------
7219
7220#[cfg(test)]
7221mod tests {
7222    use super::*;
7223
7224    fn p(input: &str) -> CalQuery {
7225        parse(input).expect(input)
7226    }
7227
7228    fn pe(input: &str) -> CalError {
7229        parse(input).expect_err(input)
7230    }
7231
7232    /// Shorthand to build an `AliasedFormat` without an alias.
7233    fn af(spec: FormatSpec) -> AliasedFormat {
7234        AliasedFormat { spec, alias: None }
7235    }
7236
7237    /// Shorthand to build an `AliasedFormat` with an alias.
7238    fn af_as(spec: FormatSpec, alias: &str) -> AliasedFormat {
7239        AliasedFormat {
7240            spec,
7241            alias: Some(alias.to_string()),
7242        }
7243    }
7244
7245    // ── 0. Whole-input consumption ────────────────────────────────────────
7246
7247    #[test]
7248    fn test_trailing_garbage_rejected() {
7249        let e = pe(r#"RECALL facts WHERE subject = "john" banana banana"#);
7250        assert!(e.to_string().contains("end of query"), "{e}");
7251    }
7252
7253    #[test]
7254    fn test_trailing_second_statement_rejected() {
7255        // A smuggled second statement must be an error, never silently dropped.
7256        let e = pe(r#"RECALL facts WHERE subject = "john"; RECALL facts WHERE subject = "mary""#);
7257        assert!(e.to_string().contains("end of query"), "{e}");
7258        let e = pe(r#"RECALL facts WHERE subject = "john"; DELETE FROM facts"#);
7259        assert!(e.to_string().contains("end of query"), "{e}");
7260    }
7261
7262    #[test]
7263    fn test_trailing_semicolons_allowed() {
7264        p(r#"RECALL facts WHERE subject = "john";"#);
7265        p(r#"RECALL facts WHERE subject = "john";;"#);
7266    }
7267
7268    #[test]
7269    fn test_assemble_with_mixed_dedup_list_keeps_all_options() {
7270        // `WITH dedup, <general options>` on ASSEMBLE: `dedup` stays
7271        // assemble-scoped, and the rest are kept on the AssembleStmt's OWN
7272        // `with_options` (not silently dropped, and not routed to the enclosing
7273        // query) so they scope to this assemble's recall.
7274        let q = p("ASSEMBLE \"ctx\" FROM a: (RECALL facts), b: (RECALL events) \
7275                   BUDGET 400 tokens FORMAT sml \
7276                   WITH dedup, recency_weight(0.7), provenance");
7277        let CalStatement::Assemble(a) = &q.statement else {
7278            panic!("expected Assemble, got {:?}", q.statement);
7279        };
7280        assert_eq!(a.assemble_with.len(), 1, "dedup stays assemble-scoped");
7281        assert!(
7282            a.with_options.iter().any(|o| matches!(o, WithOption::RecencyWeight { .. })),
7283            "recency_weight kept on the assemble: {:?}",
7284            a.with_options
7285        );
7286        assert!(
7287            a.with_options.iter().any(|o| matches!(o, WithOption::Provenance)),
7288            "provenance kept on the assemble: {:?}",
7289            a.with_options
7290        );
7291        assert!(
7292            q.with_options.is_empty(),
7293            "options stay assemble-scoped, not on the enclosing query: {:?}",
7294            q.with_options
7295        );
7296    }
7297
7298    /// Regression (#A3F2): a NESTED ASSEMBLE's `WITH dedup, <opts>` options stay
7299    /// on the inner ASSEMBLE — they must not leak to the enclosing EXPLAIN /
7300    /// COALESCE query (the shared `pending_with_options` field used to bind them
7301    /// to the wrong statement when the ASSEMBLE was nested).
7302    #[test]
7303    fn test_nested_assemble_with_options_do_not_leak() {
7304        fn inner_assemble(stmt: &CalStatement) -> &AssembleStmt {
7305            match stmt {
7306                CalStatement::Assemble(a) => a,
7307                CalStatement::Explain(e) => inner_assemble(&e.inner),
7308                CalStatement::Coalesce(c) => inner_assemble(&c.branches[0].query),
7309                other => panic!("expected an ASSEMBLE (possibly wrapped), got {:?}", other),
7310            }
7311        }
7312        // FORMAT before WITH forces the trailing clause onto the assemble-level
7313        // path (otherwise the last source greedily consumes it).
7314        let src = "ASSEMBLE \"ctx\" FROM a: (RECALL facts), b: (RECALL events) \
7315                   FORMAT sml WITH dedup, query_expansion";
7316        for wrapped in [format!("EXPLAIN {src}"), format!("COALESCE {{ {src} }} OR {{ RECALL facts }}")] {
7317            let q = p(&wrapped);
7318            assert!(
7319                q.with_options.is_empty(),
7320                "{wrapped:?}: options leaked to the wrapper query: {:?}",
7321                q.with_options
7322            );
7323            let a = inner_assemble(&q.statement);
7324            assert!(
7325                a.with_options.iter().any(|o| matches!(o, WithOption::QueryExpansion)),
7326                "{wrapped:?}: query_expansion must scope to the inner assemble: {:?}",
7327                a.with_options
7328            );
7329        }
7330    }
7331
7332    // ── 1. Simple RECALL ──────────────────────────────────────────────────
7333
7334    #[test]
7335    fn test_recall_beliefs() {
7336        let q = p("RECALL facts");
7337        match &q.statement {
7338            CalStatement::Recall(r) => {
7339                assert_eq!(r.grain_type, GrainTypePlural::Facts);
7340                assert!(r.where_clause.is_none());
7341                assert!(r.about.is_none());
7342            }
7343            other => panic!("expected Recall, got {:?}", other),
7344        }
7345    }
7346
7347    // ── 2. RECALL with WHERE ──────────────────────────────────────────────
7348
7349    #[test]
7350    fn test_recall_where_subject_eq() {
7351        let q = p(r#"RECALL facts WHERE subject = "john""#);
7352        match &q.statement {
7353            CalStatement::Recall(r) => {
7354                assert!(r.where_clause.is_some());
7355                let wc = r.where_clause.as_ref().unwrap();
7356                match &wc.condition {
7357                    Condition::Comparison {
7358                        field,
7359                        comparator,
7360                        value,
7361                        ..
7362                    } => {
7363                        assert_eq!(field, "subject");
7364                        assert_eq!(*comparator, Comparator::Eq);
7365                        assert_eq!(
7366                            *value,
7367                            Value::String {
7368                                value: "john".into()
7369                            }
7370                        );
7371                    }
7372                    other => panic!("expected Comparison, got {:?}", other),
7373                }
7374            }
7375            _ => panic!("expected Recall"),
7376        }
7377    }
7378
7379    // ── 3. RECALL with ABOUT ──────────────────────────────────────────────
7380
7381    #[test]
7382    fn test_recall_about() {
7383        let q = p(r#"RECALL facts ABOUT "john preferences""#);
7384        match &q.statement {
7385            CalStatement::Recall(r) => {
7386                assert_eq!(r.about.as_ref().unwrap().text, "john preferences");
7387            }
7388            _ => panic!("expected Recall"),
7389        }
7390    }
7391
7392    // ── 4. RECALL with RECENT ─────────────────────────────────────────────
7393
7394    #[test]
7395    fn test_recall_recent() {
7396        let q = p("RECALL events RECENT 5");
7397        match &q.statement {
7398            CalStatement::Recall(r) => {
7399                assert_eq!(r.grain_type, GrainTypePlural::Events);
7400                assert_eq!(r.recent.as_ref().unwrap().count, 5);
7401            }
7402            _ => panic!("expected Recall"),
7403        }
7404    }
7405
7406    // ── 5. RECALL with SINCE ──────────────────────────────────────────────
7407
7408    #[test]
7409    fn test_recall_since() {
7410        let q = p(r#"RECALL facts SINCE "3 days ago""#);
7411        match &q.statement {
7412            CalStatement::Recall(r) => {
7413                assert_eq!(r.since.as_ref().unwrap().expression, "3 days ago");
7414            }
7415            _ => panic!("expected Recall"),
7416        }
7417    }
7418
7419    // ── 6. RECALL with pipeline ───────────────────────────────────────────
7420
7421    #[test]
7422    fn test_recall_pipeline() {
7423        // Pipe-free syntax (canonical).
7424        let q = p(r#"RECALL facts WHERE subject = "john" ORDER BY confidence DESC LIMIT 10"#);
7425        assert_eq!(q.pipeline.len(), 2);
7426        match &q.pipeline[0] {
7427            PipelineStage::OrderBy {
7428                field, descending, ..
7429            } => {
7430                assert_eq!(field, "confidence");
7431                assert!(*descending);
7432            }
7433            other => panic!("expected OrderBy, got {:?}", other),
7434        }
7435        match &q.pipeline[1] {
7436            PipelineStage::Limit { value, .. } => assert_eq!(*value, 10),
7437            other => panic!("expected Limit, got {:?}", other),
7438        }
7439    }
7440
7441    #[test]
7442    fn test_recall_pipeline_with_pipe_backward_compat() {
7443        // Legacy pipe syntax must still parse identically.
7444        let q = p(r#"RECALL facts WHERE subject = "john" | ORDER BY confidence DESC | LIMIT 10"#);
7445        assert_eq!(q.pipeline.len(), 2);
7446        match &q.pipeline[0] {
7447            PipelineStage::OrderBy {
7448                field, descending, ..
7449            } => {
7450                assert_eq!(field, "confidence");
7451                assert!(*descending);
7452            }
7453            other => panic!("expected OrderBy, got {:?}", other),
7454        }
7455        match &q.pipeline[1] {
7456            PipelineStage::Limit { value, .. } => assert_eq!(*value, 10),
7457            other => panic!("expected Limit, got {:?}", other),
7458        }
7459    }
7460
7461    // ── 7. RECALL with WITH ───────────────────────────────────────────────
7462
7463    #[test]
7464    fn test_recall_with_superseded_and_score_breakdown() {
7465        let q = p("RECALL facts WITH superseded, score_breakdown");
7466        assert!(q.with_options.contains(&WithOption::Superseded));
7467        assert!(q.with_options.contains(&WithOption::ScoreBreakdown));
7468    }
7469
7470    // ── 8. RECALL with FORMAT ─────────────────────────────────────────────
7471
7472    #[test]
7473    fn test_recall_format_json() {
7474        let q = p("RECALL facts FORMAT json");
7475        assert_eq!(q.format, Some(FormatClause::Single(FormatSpec::Json)));
7476    }
7477
7478    // ── 9. EXISTS with hash ───────────────────────────────────────────────
7479
7480    #[test]
7481    fn test_exists_hash() {
7482        let q = p("EXISTS sha256:abc123def456");
7483        match &q.statement {
7484            CalStatement::Exists(e) => {
7485                let wc = e.where_clause.as_ref().unwrap();
7486                match &wc.condition {
7487                    Condition::Comparison { field, value, .. } => {
7488                        assert_eq!(field, "hash");
7489                        assert_eq!(
7490                            *value,
7491                            Value::Hash {
7492                                value: "abc123def456".into()
7493                            }
7494                        );
7495                    }
7496                    other => panic!("expected Comparison, got {:?}", other),
7497                }
7498            }
7499            other => panic!("expected Exists, got {:?}", other),
7500        }
7501    }
7502
7503    // ── 10. ASSEMBLE ──────────────────────────────────────────────────────
7504
7505    #[test]
7506    fn test_assemble() {
7507        let q = p(r#"ASSEMBLE "daily_summary" FROM (RECALL facts RECENT 10)"#);
7508        match &q.statement {
7509            CalStatement::Assemble(a) => {
7510                assert_eq!(a.topic, "daily_summary");
7511                match &a.from {
7512                    Source::Query(_) => {}
7513                    other => panic!("expected Query source, got {:?}", other),
7514                }
7515            }
7516            other => panic!("expected Assemble, got {:?}", other),
7517        }
7518    }
7519
7520    // ── 11. EXPLAIN ───────────────────────────────────────────────────────
7521
7522    #[test]
7523    fn test_explain() {
7524        let q = p(r#"EXPLAIN RECALL facts WHERE subject = "john""#);
7525        match &q.statement {
7526            CalStatement::Explain(e) => {
7527                matches!(*e.inner, CalStatement::Recall(_));
7528            }
7529            other => panic!("expected Explain, got {:?}", other),
7530        }
7531    }
7532
7533    // ── 12. DESCRIBE ──────────────────────────────────────────────────────
7534
7535    #[test]
7536    fn test_describe_grain_types() {
7537        let q = p("DESCRIBE grain_types");
7538        match &q.statement {
7539            CalStatement::Describe(_) => {}
7540            other => panic!("expected Describe, got {:?}", other),
7541        }
7542    }
7543
7544    // ── 13. BATCH ─────────────────────────────────────────────────────────
7545
7546    #[test]
7547    fn test_batch() {
7548        let q = p("BATCH { recent: RECALL facts RECENT 5, ex: EXISTS sha256:abc123def456 }");
7549        match &q.statement {
7550            CalStatement::Batch(b) => {
7551                assert_eq!(b.statements.len(), 2);
7552                assert!(matches!(
7553                    &b.statements[0].statement,
7554                    CalStatement::Recall(_)
7555                ));
7556                assert!(matches!(
7557                    &b.statements[1].statement,
7558                    CalStatement::Exists(_)
7559                ));
7560            }
7561            other => panic!("expected Batch, got {:?}", other),
7562        }
7563    }
7564
7565    // ── 14. COALESCE ──────────────────────────────────────────────────────
7566
7567    #[test]
7568    fn test_coalesce() {
7569        let q = p(
7570            r#"COALESCE(RECALL facts WHERE subject = "john", RECALL facts WHERE subject = "bob")"#,
7571        );
7572        assert!(matches!(q.statement, CalStatement::Coalesce(_)));
7573    }
7574
7575    // ── 15. Set operation (UNION) ─────────────────────────────────────────
7576
7577    #[test]
7578    fn test_union() {
7579        let q = p(
7580            r#"(RECALL facts WHERE subject = "john") UNION (RECALL facts WHERE subject = "bob")"#,
7581        );
7582        match &q.statement {
7583            CalStatement::SetOp(s) => {
7584                assert_eq!(s.op, SetOp::Union);
7585                assert_eq!(s.operands.len(), 2);
7586            }
7587            other => panic!("expected SetOp, got {:?}", other),
7588        }
7589    }
7590
7591    // ── 16. ADD (Tier 1) ──────────────────────────────────────────────────
7592
7593    #[test]
7594    fn test_add_fact() {
7595        let q = p(
7596            r#"ADD fact SET subject = "john" SET relation = "likes" SET object = "coffee" REASON "user preference""#,
7597        );
7598        match &q.statement {
7599            CalStatement::Add(a) => {
7600                assert_eq!(a.grain_type, GrainTypeSingular::Fact);
7601                assert_eq!(a.fields.len(), 3);
7602                assert_eq!(a.fields[0].field, "subject");
7603            }
7604            other => panic!("expected Add, got {:?}", other),
7605        }
7606    }
7607
7608    #[test]
7609    fn test_add_fact_reason_stored() {
7610        let q = p(
7611            r#"ADD fact SET subject = "john" SET relation = "likes" SET object = "coffee" REASON "user preference""#,
7612        );
7613        match &q.statement {
7614            CalStatement::Add(a) => {
7615                assert_eq!(a.reason, "user preference");
7616            }
7617            other => panic!("expected Add, got {:?}", other),
7618        }
7619    }
7620
7621    #[test]
7622    fn test_add_fact_because_accepted() {
7623        let q = p(
7624            r#"ADD fact SET subject = "bob" SET relation = "likes" SET object = "rust" BECAUSE "test reason""#,
7625        );
7626        match &q.statement {
7627            CalStatement::Add(a) => {
7628                assert_eq!(a.grain_type, GrainTypeSingular::Fact);
7629                assert_eq!(a.reason, "test reason");
7630            }
7631            other => panic!("expected Add, got {:?}", other),
7632        }
7633    }
7634
7635    #[test]
7636    fn test_add_fact_missing_reason_fails() {
7637        let input = r#"ADD fact SET subject = "john" SET relation = "likes" SET object = "coffee""#;
7638        let err = parse(input).unwrap_err();
7639        assert!(
7640            matches!(err, CalError::MissingReason { .. }),
7641            "expected MissingReason, got: {:?}",
7642            err
7643        );
7644    }
7645
7646    #[test]
7647    fn test_add_duplicate_set_field_warning() {
7648        let q = p(
7649            r#"ADD fact SET subject = "a" SET subject = "b" SET relation = "r" SET object = "o" REASON "test""#,
7650        );
7651        match &q.statement {
7652            CalStatement::Add(a) => {
7653                assert_eq!(a.fields.len(), 4);
7654                // The last value wins.
7655                assert_eq!(a.fields[0].field, "subject");
7656                assert_eq!(a.fields[1].field, "subject");
7657            }
7658            other => panic!("expected Add, got {:?}", other),
7659        }
7660        // Should have exactly one warning for duplicate "subject".
7661        assert_eq!(q.warnings.len(), 1);
7662        assert!(
7663            matches!(&q.warnings[0], CalWarning::DuplicateSetField { field, .. } if field == "subject"),
7664            "expected DuplicateSetField warning, got: {:?}",
7665            q.warnings
7666        );
7667    }
7668
7669    // ── 16b. ADD WORKFLOW (graph syntax) ────────────────────────────────
7670
7671    #[test]
7672    fn test_add_workflow_simple_linear() {
7673        let q = p(
7674            r#"ADD workflow "CI pipeline" build -> test -> deploy REASON "standard pipeline""#,
7675        );
7676        match &q.statement {
7677            CalStatement::AddWorkflow(wf) => {
7678                assert_eq!(wf.name, "CI pipeline");
7679                assert_eq!(wf.nodes, vec!["build", "test", "deploy"]);
7680                assert_eq!(wf.edges.len(), 2);
7681                assert_eq!(wf.edges[0].src, "build");
7682                assert_eq!(wf.edges[0].dst, "test");
7683                assert_eq!(wf.edges[1].src, "test");
7684                assert_eq!(wf.edges[1].dst, "deploy");
7685                assert_eq!(wf.reason, "standard pipeline");
7686            }
7687            other => panic!("expected AddWorkflow, got {:?}", other),
7688        }
7689    }
7690
7691    #[test]
7692    fn test_add_workflow_parallel() {
7693        let q = p(
7694            r#"ADD workflow "review" lint -> (security, compliance) -> evaluate REASON "parallel review""#,
7695        );
7696        match &q.statement {
7697            CalStatement::AddWorkflow(wf) => {
7698                assert_eq!(wf.nodes, vec!["lint", "security", "compliance", "evaluate"]);
7699                // lint -> security, lint -> compliance
7700                // security -> evaluate, compliance -> evaluate
7701                assert_eq!(wf.edges.len(), 4);
7702                assert_eq!(wf.edges[0].src, "lint");
7703                assert_eq!(wf.edges[0].dst, "security");
7704                assert_eq!(wf.edges[1].src, "lint");
7705                assert_eq!(wf.edges[1].dst, "compliance");
7706                assert_eq!(wf.edges[2].src, "security");
7707                assert_eq!(wf.edges[2].dst, "evaluate");
7708                assert_eq!(wf.edges[3].src, "compliance");
7709                assert_eq!(wf.edges[3].dst, "evaluate");
7710            }
7711            other => panic!("expected AddWorkflow, got {:?}", other),
7712        }
7713    }
7714
7715    #[test]
7716    fn test_add_workflow_conditional() {
7717        let q = p(
7718            r#"ADD workflow "gate" evaluate -> implement WHEN "approved" evaluate -> reject WHEN "rejected" REASON "approval routing""#,
7719        );
7720        match &q.statement {
7721            CalStatement::AddWorkflow(wf) => {
7722                assert_eq!(wf.nodes, vec!["evaluate", "implement", "reject"]);
7723                assert_eq!(wf.edges.len(), 2);
7724                assert_eq!(wf.edges[0].cond, Some("approved".into()));
7725                assert_eq!(wf.edges[1].cond, Some("rejected".into()));
7726            }
7727            other => panic!("expected AddWorkflow, got {:?}", other),
7728        }
7729    }
7730
7731    #[test]
7732    fn test_add_workflow_retry() {
7733        let q = p(
7734            r#"ADD workflow "deploy" build -> deploy * 3 -> notify REASON "retry deploy""#,
7735        );
7736        match &q.statement {
7737            CalStatement::AddWorkflow(wf) => {
7738                assert_eq!(wf.nodes, vec!["build", "deploy", "notify"]);
7739                assert_eq!(wf.edges.len(), 2);
7740                // build -> deploy (with repeat)
7741                assert_eq!(wf.edges[0].src, "build");
7742                assert_eq!(wf.edges[0].dst, "deploy");
7743                assert_eq!(wf.edges[0].repeat, Some(3));
7744                // deploy -> notify
7745                assert_eq!(wf.edges[1].src, "deploy");
7746                assert_eq!(wf.edges[1].dst, "notify");
7747            }
7748            other => panic!("expected AddWorkflow, got {:?}", other),
7749        }
7750    }
7751
7752    #[test]
7753    fn test_add_workflow_with_bindings() {
7754        let q = p(
7755            r#"ADD workflow "pipeline" build -> test BIND build = sha256:def11111 BIND test = sha256:def22222 REASON "bound pipeline""#,
7756        );
7757        match &q.statement {
7758            CalStatement::AddWorkflow(wf) => {
7759                assert_eq!(wf.bindings.len(), 2);
7760                assert_eq!(wf.bindings[0].node, "build");
7761                assert_eq!(wf.bindings[0].hash, "def11111");
7762                assert_eq!(wf.bindings[1].node, "test");
7763                assert_eq!(wf.bindings[1].hash, "def22222");
7764            }
7765            other => panic!("expected AddWorkflow, got {:?}", other),
7766        }
7767    }
7768
7769    #[test]
7770    fn test_add_workflow_no_trigger() {
7771        let q = p(r#"ADD workflow "checklist" review -> test -> merge REASON "template""#);
7772        match &q.statement {
7773            CalStatement::AddWorkflow(wf) => {
7774                assert_eq!(wf.name, "checklist");
7775                assert_eq!(wf.nodes, vec!["review", "test", "merge"]);
7776            }
7777            other => panic!("expected AddWorkflow, got {:?}", other),
7778        }
7779    }
7780
7781    #[test]
7782    fn test_add_workflow_quoted_node_names() {
7783        let q = p(
7784            r#"ADD workflow "onboarding" "send welcome email" -> "schedule orientation" REASON "onboard""#,
7785        );
7786        match &q.statement {
7787            CalStatement::AddWorkflow(wf) => {
7788                assert_eq!(wf.nodes, vec!["send welcome email", "schedule orientation"]);
7789            }
7790            other => panic!("expected AddWorkflow, got {:?}", other),
7791        }
7792    }
7793
7794    #[test]
7795    fn test_add_workflow_mixed_pipeline() {
7796        let q = p(
7797            r#"ADD workflow "release" build -> (unit_test, lint) -> integration_test integration_test -> stage_deploy * 3 stage_deploy -> approval approval -> prod_deploy WHEN "approved" approval -> rollback WHEN "rejected" REASON "release pipeline""#,
7798        );
7799        match &q.statement {
7800            CalStatement::AddWorkflow(wf) => {
7801                assert_eq!(
7802                    wf.nodes,
7803                    vec![
7804                        "build",
7805                        "unit_test",
7806                        "lint",
7807                        "integration_test",
7808                        "stage_deploy",
7809                        "approval",
7810                        "prod_deploy",
7811                        "rollback"
7812                    ]
7813                );
7814                // build->unit_test, build->lint, unit_test->integration_test, lint->integration_test,
7815                // integration_test->stage_deploy(*3), stage_deploy->approval,
7816                // approval->prod_deploy(WHEN approved), approval->rollback(WHEN rejected)
7817                assert_eq!(wf.edges.len(), 8);
7818            }
7819            other => panic!("expected AddWorkflow, got {:?}", other),
7820        }
7821    }
7822
7823    // ── 16c. ADD/SUPERSEDE WORKFLOW — exhaustive edge-case tests ────────
7824
7825    // ---- Happy-path edge cases (should parse successfully) ----
7826
7827    /// Single node, no edges — minimal valid workflow.
7828    #[test]
7829    fn test_add_workflow_single_node_no_edges() {
7830        let q = p(r#"ADD workflow "x" build REASON "y""#);
7831        match &q.statement {
7832            CalStatement::AddWorkflow(wf) => {
7833                assert_eq!(wf.name, "x");
7834                assert_eq!(wf.nodes, vec!["build"]);
7835                assert!(wf.edges.is_empty());
7836                assert_eq!(wf.reason, "y");
7837            }
7838            other => panic!("expected AddWorkflow, got {:?}", other),
7839        }
7840    }
7841
7842    /// Long chain: a -> b -> c -> d -> e -> f
7843    #[test]
7844    fn test_add_workflow_long_chain() {
7845        let q = p(r#"ADD workflow "long" a -> b -> c -> d -> e -> f REASON "chain""#);
7846        match &q.statement {
7847            CalStatement::AddWorkflow(wf) => {
7848                assert_eq!(wf.nodes, vec!["a", "b", "c", "d", "e", "f"]);
7849                assert_eq!(wf.edges.len(), 5);
7850                assert_eq!(wf.edges[0].src, "a");
7851                assert_eq!(wf.edges[0].dst, "b");
7852                assert_eq!(wf.edges[1].src, "b");
7853                assert_eq!(wf.edges[1].dst, "c");
7854                assert_eq!(wf.edges[2].src, "c");
7855                assert_eq!(wf.edges[2].dst, "d");
7856                assert_eq!(wf.edges[3].src, "d");
7857                assert_eq!(wf.edges[3].dst, "e");
7858                assert_eq!(wf.edges[4].src, "e");
7859                assert_eq!(wf.edges[4].dst, "f");
7860            }
7861            other => panic!("expected AddWorkflow, got {:?}", other),
7862        }
7863    }
7864
7865    /// Multiple separate chains (multiline in one query): a -> b then c -> d.
7866    /// These are disconnected subgraphs — parser should accept both chains.
7867    #[test]
7868    fn test_add_workflow_multiple_separate_chains() {
7869        let q = p(r#"ADD workflow "multi" a -> b c -> d REASON "two chains""#);
7870        match &q.statement {
7871            CalStatement::AddWorkflow(wf) => {
7872                assert_eq!(wf.nodes, vec!["a", "b", "c", "d"]);
7873                assert_eq!(wf.edges.len(), 2);
7874                assert_eq!(wf.edges[0].src, "a");
7875                assert_eq!(wf.edges[0].dst, "b");
7876                assert_eq!(wf.edges[1].src, "c");
7877                assert_eq!(wf.edges[1].dst, "d");
7878            }
7879            other => panic!("expected AddWorkflow, got {:?}", other),
7880        }
7881    }
7882
7883    /// Nested parallel: a -> (b, (c, d)) -> e — inner group (c, d) inside
7884    /// outer group. The parser should parse (c, d) as a nested group inside
7885    /// the outer group, but since parse_node_or_group calls parse_node_name
7886    /// for each element, and parse_node_name doesn't handle parens, this
7887    /// should actually fail. Let's verify the actual behavior.
7888    #[test]
7889    fn test_add_workflow_nested_parallel_fails() {
7890        // Nested groups like (b, (c, d)) are NOT supported because
7891        // parse_node_name doesn't handle LParen. The inner '(' would
7892        // be rejected as an unexpected token.
7893        let err = pe(r#"ADD workflow "x" a -> (b, (c, d)) -> e REASON "y""#);
7894        assert!(matches!(err, CalError::UnexpectedToken { .. }));
7895    }
7896
7897    /// WHEN + * N on same edge: a -> b WHEN "x" * 3 — precedence test.
7898    #[test]
7899    fn test_add_workflow_when_and_repeat_combined() {
7900        let q = p(r#"ADD workflow "x" a -> b WHEN "cond" * 3 REASON "y""#);
7901        match &q.statement {
7902            CalStatement::AddWorkflow(wf) => {
7903                assert_eq!(wf.edges.len(), 1);
7904                assert_eq!(wf.edges[0].src, "a");
7905                assert_eq!(wf.edges[0].dst, "b");
7906                assert_eq!(wf.edges[0].cond, Some("cond".into()));
7907                assert_eq!(wf.edges[0].repeat, Some(3));
7908            }
7909            other => panic!("expected AddWorkflow, got {:?}", other),
7910        }
7911    }
7912
7913    /// Node names that look like identifiers with numbers: step1 -> step2.
7914    #[test]
7915    fn test_add_workflow_alphanumeric_node_names() {
7916        let q = p(r#"ADD workflow "x" step1 -> step2 REASON "y""#);
7917        match &q.statement {
7918            CalStatement::AddWorkflow(wf) => {
7919                assert_eq!(wf.nodes, vec!["step1", "step2"]);
7920                assert_eq!(wf.edges.len(), 1);
7921            }
7922            other => panic!("expected AddWorkflow, got {:?}", other),
7923        }
7924    }
7925
7926    /// Underscore-heavy names: __init__ -> _cleanup_.
7927    #[test]
7928    fn test_add_workflow_underscore_names() {
7929        let q = p(r#"ADD workflow "x" __init__ -> _cleanup_ REASON "y""#);
7930        match &q.statement {
7931            CalStatement::AddWorkflow(wf) => {
7932                assert_eq!(wf.nodes, vec!["__init__", "_cleanup_"]);
7933                assert_eq!(wf.edges.len(), 1);
7934            }
7935            other => panic!("expected AddWorkflow, got {:?}", other),
7936        }
7937    }
7938
7939    /// Single-char names: a -> b -> c.
7940    #[test]
7941    fn test_add_workflow_single_char_names() {
7942        let q = p(r#"ADD workflow "x" a -> b -> c REASON "y""#);
7943        match &q.statement {
7944            CalStatement::AddWorkflow(wf) => {
7945                assert_eq!(wf.nodes, vec!["a", "b", "c"]);
7946                assert_eq!(wf.edges.len(), 2);
7947            }
7948            other => panic!("expected AddWorkflow, got {:?}", other),
7949        }
7950    }
7951
7952    /// BECAUSE as alternative to REASON.
7953    #[test]
7954    fn test_add_workflow_because_keyword() {
7955        let q = p(r#"ADD workflow "x" build -> test BECAUSE "alt keyword""#);
7956        match &q.statement {
7957            CalStatement::AddWorkflow(wf) => {
7958                assert_eq!(wf.reason, "alt keyword");
7959                assert_eq!(wf.nodes, vec!["build", "test"]);
7960            }
7961            other => panic!("expected AddWorkflow, got {:?}", other),
7962        }
7963    }
7964
7965    /// Mixed quoted and bare node names: build -> "run tests" -> deploy.
7966    #[test]
7967    fn test_add_workflow_mixed_bare_and_quoted() {
7968        let q = p(r#"ADD workflow "x" build -> "run tests" -> deploy REASON "y""#);
7969        match &q.statement {
7970            CalStatement::AddWorkflow(wf) => {
7971                assert_eq!(wf.nodes, vec!["build", "run tests", "deploy"]);
7972                assert_eq!(wf.edges.len(), 2);
7973            }
7974            other => panic!("expected AddWorkflow, got {:?}", other),
7975        }
7976    }
7977
7978    /// Parallel with single element: a -> (b) -> c — degenerates to just b.
7979    #[test]
7980    fn test_add_workflow_single_element_parallel() {
7981        let q = p(r#"ADD workflow "x" a -> (b) -> c REASON "y""#);
7982        match &q.statement {
7983            CalStatement::AddWorkflow(wf) => {
7984                assert_eq!(wf.nodes, vec!["a", "b", "c"]);
7985                assert_eq!(wf.edges.len(), 2);
7986                assert_eq!(wf.edges[0].src, "a");
7987                assert_eq!(wf.edges[0].dst, "b");
7988                assert_eq!(wf.edges[1].src, "b");
7989                assert_eq!(wf.edges[1].dst, "c");
7990            }
7991            other => panic!("expected AddWorkflow, got {:?}", other),
7992        }
7993    }
7994
7995    /// Same node referenced multiple times across chains (convergence): a -> c, b -> c.
7996    #[test]
7997    fn test_add_workflow_convergence_node() {
7998        let q = p(r#"ADD workflow "x" a -> c b -> c REASON "converge""#);
7999        match &q.statement {
8000            CalStatement::AddWorkflow(wf) => {
8001                // "c" should appear only once in nodes (deduplicated).
8002                assert_eq!(wf.nodes, vec!["a", "c", "b"]);
8003                assert_eq!(wf.edges.len(), 2);
8004                assert_eq!(wf.edges[0].src, "a");
8005                assert_eq!(wf.edges[0].dst, "c");
8006                assert_eq!(wf.edges[1].src, "b");
8007                assert_eq!(wf.edges[1].dst, "c");
8008            }
8009            other => panic!("expected AddWorkflow, got {:?}", other),
8010        }
8011    }
8012
8013    /// Empty bindings (no BIND clauses) — already tested implicitly,
8014    /// but verify the bindings field is empty.
8015    #[test]
8016    fn test_add_workflow_no_bind_clauses() {
8017        let q = p(r#"ADD workflow "x" a -> b REASON "no bindings""#);
8018        match &q.statement {
8019            CalStatement::AddWorkflow(wf) => {
8020                assert!(wf.bindings.is_empty());
8021            }
8022            other => panic!("expected AddWorkflow, got {:?}", other),
8023        }
8024    }
8025
8026    /// Many BIND clauses (10+).
8027    #[test]
8028    fn test_add_workflow_many_bind_clauses() {
8029        let q = p(concat!(
8030            r#"ADD workflow "x" a -> b -> c "#,
8031            "BIND a = sha256:aaa11111 ",
8032            "BIND b = sha256:bbb22222 ",
8033            "BIND c = sha256:ccc33333 ",
8034            "BIND a = sha256:aaa44444 ",
8035            "BIND b = sha256:bbb55555 ",
8036            "BIND c = sha256:ccc66666 ",
8037            "BIND a = sha256:aaa77777 ",
8038            "BIND b = sha256:bbb88888 ",
8039            "BIND c = sha256:ccc99999 ",
8040            "BIND a = sha256:aaa00000 ",
8041            r#"REASON "many bindings""#,
8042        ));
8043        match &q.statement {
8044            CalStatement::AddWorkflow(wf) => {
8045                assert_eq!(wf.bindings.len(), 10);
8046                assert_eq!(wf.bindings[0].node, "a");
8047                assert_eq!(wf.bindings[0].hash, "aaa11111");
8048                assert_eq!(wf.bindings[9].node, "a");
8049                assert_eq!(wf.bindings[9].hash, "aaa00000");
8050            }
8051            other => panic!("expected AddWorkflow, got {:?}", other),
8052        }
8053    }
8054
8055    /// `ON "..."` was removed in 1.3, and is refused by name.
8056    ///
8057    /// It set a free-text field nothing ever read — not the scheduler, not the
8058    /// driver — so it described an activation condition that could not activate
8059    /// anything. Silently ignoring the clause would leave an author believing
8060    /// they had scheduled something, which is the failure with no symptom.
8061    #[test]
8062    fn add_workflow_refuses_the_removed_on_clause_and_says_where_triggers_live() {
8063        let e = parse(r#"ADD workflow "cron" ON "cron * * * * *" run_job REASON "scheduled""#)
8064            .expect_err("ON must be refused, not ignored");
8065        let msg = e.to_string();
8066        assert!(msg.contains("removed in 1.3"), "{msg}");
8067        assert!(msg.contains("trigger"), "the message must point at the replacement: {msg}");
8068    }
8069
8070    /// Self-loop: a -> a — parser should accept (cycle detection is engine's job).
8071    #[test]
8072    fn test_add_workflow_self_loop() {
8073        let q = p(r#"ADD workflow "x" a -> a REASON "self loop""#);
8074        match &q.statement {
8075            CalStatement::AddWorkflow(wf) => {
8076                assert_eq!(wf.nodes, vec!["a"]);
8077                assert_eq!(wf.edges.len(), 1);
8078                assert_eq!(wf.edges[0].src, "a");
8079                assert_eq!(wf.edges[0].dst, "a");
8080            }
8081            other => panic!("expected AddWorkflow, got {:?}", other),
8082        }
8083    }
8084
8085    /// Repeat with value 1 — edge case, allowed.
8086    #[test]
8087    fn test_add_workflow_repeat_one() {
8088        let q = p(r#"ADD workflow "x" a -> b * 1 REASON "y""#);
8089        match &q.statement {
8090            CalStatement::AddWorkflow(wf) => {
8091                assert_eq!(wf.edges[0].repeat, Some(1));
8092            }
8093            other => panic!("expected AddWorkflow, got {:?}", other),
8094        }
8095    }
8096
8097    /// Large repeat count.
8098    #[test]
8099    fn test_add_workflow_repeat_large() {
8100        let q = p(r#"ADD workflow "x" a -> b * 100 REASON "y""#);
8101        match &q.statement {
8102            CalStatement::AddWorkflow(wf) => {
8103                assert_eq!(wf.edges[0].repeat, Some(100));
8104            }
8105            other => panic!("expected AddWorkflow, got {:?}", other),
8106        }
8107    }
8108
8109    /// Multiple edges with different WHEN conditions.
8110    #[test]
8111    fn test_add_workflow_multiple_when_conditions() {
8112        let q = p(
8113            r#"ADD workflow "x" a -> b WHEN "yes" a -> c WHEN "no" a -> d WHEN "maybe" REASON "branching""#,
8114        );
8115        match &q.statement {
8116            CalStatement::AddWorkflow(wf) => {
8117                assert_eq!(wf.nodes, vec!["a", "b", "c", "d"]);
8118                assert_eq!(wf.edges.len(), 3);
8119                assert_eq!(wf.edges[0].cond, Some("yes".into()));
8120                assert_eq!(wf.edges[1].cond, Some("no".into()));
8121                assert_eq!(wf.edges[2].cond, Some("maybe".into()));
8122            }
8123            other => panic!("expected AddWorkflow, got {:?}", other),
8124        }
8125    }
8126
8127    /// Parallel group at start of chain.
8128    #[test]
8129    fn test_add_workflow_parallel_at_start() {
8130        let q = p(r#"ADD workflow "x" (a, b) -> c REASON "y""#);
8131        match &q.statement {
8132            CalStatement::AddWorkflow(wf) => {
8133                assert_eq!(wf.nodes, vec!["a", "b", "c"]);
8134                assert_eq!(wf.edges.len(), 2);
8135                assert_eq!(wf.edges[0].src, "a");
8136                assert_eq!(wf.edges[0].dst, "c");
8137                assert_eq!(wf.edges[1].src, "b");
8138                assert_eq!(wf.edges[1].dst, "c");
8139            }
8140            other => panic!("expected AddWorkflow, got {:?}", other),
8141        }
8142    }
8143
8144    /// Parallel group at end of chain.
8145    #[test]
8146    fn test_add_workflow_parallel_at_end() {
8147        let q = p(r#"ADD workflow "x" a -> (b, c) REASON "y""#);
8148        match &q.statement {
8149            CalStatement::AddWorkflow(wf) => {
8150                assert_eq!(wf.nodes, vec!["a", "b", "c"]);
8151                assert_eq!(wf.edges.len(), 2);
8152                assert_eq!(wf.edges[0].src, "a");
8153                assert_eq!(wf.edges[0].dst, "b");
8154                assert_eq!(wf.edges[1].src, "a");
8155                assert_eq!(wf.edges[1].dst, "c");
8156            }
8157            other => panic!("expected AddWorkflow, got {:?}", other),
8158        }
8159    }
8160
8161    /// Parallel-to-parallel: (a, b) -> (c, d) — full cross-product.
8162    #[test]
8163    fn test_add_workflow_parallel_to_parallel() {
8164        let q = p(r#"ADD workflow "x" (a, b) -> (c, d) REASON "y""#);
8165        match &q.statement {
8166            CalStatement::AddWorkflow(wf) => {
8167                assert_eq!(wf.nodes, vec!["a", "b", "c", "d"]);
8168                // Cross product: a->c, a->d, b->c, b->d
8169                assert_eq!(wf.edges.len(), 4);
8170                assert_eq!(wf.edges[0].src, "a");
8171                assert_eq!(wf.edges[0].dst, "c");
8172                assert_eq!(wf.edges[1].src, "a");
8173                assert_eq!(wf.edges[1].dst, "d");
8174                assert_eq!(wf.edges[2].src, "b");
8175                assert_eq!(wf.edges[2].dst, "c");
8176                assert_eq!(wf.edges[3].src, "b");
8177                assert_eq!(wf.edges[3].dst, "d");
8178            }
8179            other => panic!("expected AddWorkflow, got {:?}", other),
8180        }
8181    }
8182
8183    /// WHEN condition with an empty string.
8184    #[test]
8185    fn test_add_workflow_when_empty_string() {
8186        let q = p(r#"ADD workflow "x" a -> b WHEN "" REASON "y""#);
8187        match &q.statement {
8188            CalStatement::AddWorkflow(wf) => {
8189                assert_eq!(wf.edges[0].cond, Some("".into()));
8190            }
8191            other => panic!("expected AddWorkflow, got {:?}", other),
8192        }
8193    }
8194
8195    /// Repeat on multiple edges in same chain.
8196    #[test]
8197    fn test_add_workflow_repeat_on_multiple_edges() {
8198        let q = p(r#"ADD workflow "x" a -> b * 2 -> c * 5 REASON "y""#);
8199        match &q.statement {
8200            CalStatement::AddWorkflow(wf) => {
8201                assert_eq!(wf.edges.len(), 2);
8202                assert_eq!(wf.edges[0].repeat, Some(2));
8203                assert_eq!(wf.edges[1].repeat, Some(5));
8204            }
8205            other => panic!("expected AddWorkflow, got {:?}", other),
8206        }
8207    }
8208
8209    /// Large parallel group (5+ elements).
8210    #[test]
8211    fn test_add_workflow_large_parallel_group() {
8212        let q = p(r#"ADD workflow "x" init -> (a, b, c, d, e) -> done REASON "y""#);
8213        match &q.statement {
8214            CalStatement::AddWorkflow(wf) => {
8215                assert_eq!(wf.nodes, vec!["init", "a", "b", "c", "d", "e", "done"]);
8216                // init->{a,b,c,d,e}: 5 edges + {a,b,c,d,e}->done: 5 edges = 10
8217                assert_eq!(wf.edges.len(), 10);
8218            }
8219            other => panic!("expected AddWorkflow, got {:?}", other),
8220        }
8221    }
8222
8223    /// Diamond pattern: a -> (b, c) -> d (fork-join).
8224    #[test]
8225    fn test_add_workflow_diamond_pattern() {
8226        let q = p(r#"ADD workflow "diamond" a -> (b, c) -> d REASON "fork-join""#);
8227        match &q.statement {
8228            CalStatement::AddWorkflow(wf) => {
8229                assert_eq!(wf.nodes, vec!["a", "b", "c", "d"]);
8230                assert_eq!(wf.edges.len(), 4);
8231                // a->b, a->c
8232                assert_eq!(wf.edges[0].src, "a");
8233                assert_eq!(wf.edges[0].dst, "b");
8234                assert_eq!(wf.edges[1].src, "a");
8235                assert_eq!(wf.edges[1].dst, "c");
8236                // b->d, c->d
8237                assert_eq!(wf.edges[2].src, "b");
8238                assert_eq!(wf.edges[2].dst, "d");
8239                assert_eq!(wf.edges[3].src, "c");
8240                assert_eq!(wf.edges[3].dst, "d");
8241            }
8242            other => panic!("expected AddWorkflow, got {:?}", other),
8243        }
8244    }
8245
8246    /// WITH sync clause on workflow.
8247    #[test]
8248    fn test_add_workflow_with_sync() {
8249        let q = p(r#"ADD workflow "x" a -> b WITH sync REASON "y""#);
8250        match &q.statement {
8251            CalStatement::AddWorkflow(wf) => {
8252                assert_eq!(wf.with_options.len(), 1);
8253                assert_eq!(wf.with_options[0], AddWithOption::Sync);
8254                assert_eq!(wf.reason, "y");
8255            }
8256            other => panic!("expected AddWorkflow, got {:?}", other),
8257        }
8258    }
8259
8260    /// WITH + BIND together on workflow.
8261    #[test]
8262    fn test_add_workflow_bind_and_with() {
8263        let q = p(concat!(
8264            r#"ADD workflow "x" a -> b "#,
8265            "BIND a = sha256:abc12345 ",
8266            "WITH sync ",
8267            r#"REASON "y""#,
8268        ));
8269        match &q.statement {
8270            CalStatement::AddWorkflow(wf) => {
8271                assert_eq!(wf.bindings.len(), 1);
8272                assert_eq!(wf.with_options.len(), 1);
8273            }
8274            other => panic!("expected AddWorkflow, got {:?}", other),
8275        }
8276    }
8277
8278    /// Node name that is same as workflow name (no collision).
8279    #[test]
8280    fn test_add_workflow_node_name_equals_workflow_name() {
8281        let q = p(r#"ADD workflow "build" build -> test REASON "y""#);
8282        match &q.statement {
8283            CalStatement::AddWorkflow(wf) => {
8284                assert_eq!(wf.name, "build");
8285                assert_eq!(wf.nodes, vec!["build", "test"]);
8286            }
8287            other => panic!("expected AddWorkflow, got {:?}", other),
8288        }
8289    }
8290
8291    /// Chain where WHEN applies only to one edge, not all.
8292    #[test]
8293    fn test_add_workflow_when_not_inherited() {
8294        let q = p(r#"ADD workflow "x" a -> b WHEN "ok" -> c REASON "y""#);
8295        match &q.statement {
8296            CalStatement::AddWorkflow(wf) => {
8297                assert_eq!(wf.edges.len(), 2);
8298                // a -> b (WHEN "ok")
8299                assert_eq!(wf.edges[0].cond, Some("ok".into()));
8300                // b -> c (no condition)
8301                assert_eq!(wf.edges[1].cond, None);
8302            }
8303            other => panic!("expected AddWorkflow, got {:?}", other),
8304        }
8305    }
8306
8307    /// Repeat (*) not inherited to next edge.
8308    #[test]
8309    fn test_add_workflow_repeat_not_inherited() {
8310        let q = p(r#"ADD workflow "x" a -> b * 3 -> c REASON "y""#);
8311        match &q.statement {
8312            CalStatement::AddWorkflow(wf) => {
8313                assert_eq!(wf.edges.len(), 2);
8314                assert_eq!(wf.edges[0].repeat, Some(3));
8315                assert_eq!(wf.edges[1].repeat, None);
8316            }
8317            other => panic!("expected AddWorkflow, got {:?}", other),
8318        }
8319    }
8320
8321    // ---- Error cases (should fail with clear errors) ----
8322
8323    /// Missing REASON clause.
8324    #[test]
8325    fn test_add_workflow_missing_reason() {
8326        let err = pe(r#"ADD workflow "x" a -> b"#);
8327        assert!(matches!(err, CalError::MissingReason { .. }));
8328    }
8329
8330    /// Missing workflow name — string expected after "workflow".
8331    #[test]
8332    fn test_add_workflow_missing_name() {
8333        let err = pe(r#"ADD workflow ON "trigger" a -> b REASON "y""#);
8334        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8335    }
8336
8337    /// Empty graph (no nodes) — REASON immediately after name.
8338    #[test]
8339    fn test_add_workflow_empty_graph() {
8340        let err = pe(r#"ADD workflow "x" REASON "y""#);
8341        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8342    }
8343
8344    /// Reserved word as bare node — ON is a keyword, not an identifier.
8345    #[test]
8346    fn test_add_workflow_reserved_word_as_node() {
8347        // "ON" is consumed as the ON keyword by the lexer, so it's not
8348        // seen as an ident for graph_line_start; it becomes the ON trigger
8349        // parse path. "WHEN" after graph start is consumed as WHEN condition.
8350        // Using BIND as a bare node name would be consumed as BIND clause start.
8351        // These are all keywords and can't be used as bare node names.
8352        let err = pe(r#"ADD workflow "x" ON -> WHEN REASON "y""#);
8353        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8354    }
8355
8356    /// Repeat zero: * 0 should fail.
8357    #[test]
8358    fn test_add_workflow_repeat_zero() {
8359        let err = pe(r#"ADD workflow "x" a -> b * 0 REASON "y""#);
8360        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8361    }
8362
8363    /// Repeat negative: * -1 should fail. The lexer yields -1 as
8364    /// NumberLiteral(-1.0); (-1.0f64 as u32) saturates to 0 in Rust,
8365    /// so it triggers the n == 0 check.
8366    #[test]
8367    fn test_add_workflow_repeat_negative() {
8368        let err = pe(r#"ADD workflow "x" a -> b * -1 REASON "y""#);
8369        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8370    }
8371
8372    /// Dangling arrow: a -> (nothing after).
8373    #[test]
8374    fn test_add_workflow_dangling_arrow() {
8375        let err = pe(r#"ADD workflow "x" a -> REASON "y""#);
8376        // After `->`, parser expects a node name or group. REASON is a keyword,
8377        // not an identifier, so parse_node_name fails with UnexpectedToken.
8378        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8379    }
8380
8381    /// Double arrow: a -> -> b.
8382    #[test]
8383    fn test_add_workflow_double_arrow() {
8384        let err = pe(r#"ADD workflow "x" a -> -> b REASON "y""#);
8385        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8386    }
8387
8388    /// Empty parallel group: a -> () -> b.
8389    #[test]
8390    fn test_add_workflow_empty_parallel_group() {
8391        let err = pe(r#"ADD workflow "x" a -> () -> b REASON "y""#);
8392        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8393    }
8394
8395    /// Unclosed paren: a -> (b, c REASON "y".
8396    #[test]
8397    fn test_add_workflow_unclosed_paren() {
8398        let err = pe(r#"ADD workflow "x" a -> (b, c REASON "y""#);
8399        // Parser expects RParen but finds REASON.
8400        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8401    }
8402
8403    /// BIND with non-hash: BIND build = "not_a_hash".
8404    #[test]
8405    fn test_add_workflow_bind_non_hash() {
8406        let err = pe(r#"ADD workflow "x" a -> b BIND a = "not_a_hash" REASON "y""#);
8407        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8408    }
8409
8410    /// WHEN without string: a -> b WHEN 42.
8411    #[test]
8412    fn test_add_workflow_when_without_string() {
8413        let err = pe(r#"ADD workflow "x" a -> b WHEN 42 REASON "y""#);
8414        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8415    }
8416
8417    /// Arrow at start with no source: -> a -> b.
8418    /// The `->` is an Arrow token; is_graph_line_start doesn't match Arrow,
8419    /// so parse_workflow_graph sees nothing and errors with "at least one node".
8420    #[test]
8421    fn test_add_workflow_arrow_at_start() {
8422        let err = pe(r#"ADD workflow "x" -> a -> b REASON "y""#);
8423        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8424    }
8425
8426    /// Trailing comma in parallel group: (a, b,).
8427    #[test]
8428    fn test_add_workflow_trailing_comma_in_group() {
8429        let err = pe(r#"ADD workflow "x" (a, b,) -> c REASON "y""#);
8430        // After the trailing comma, parser tries parse_node_name and gets ')'.
8431        assert!(matches!(err, CalError::UnexpectedToken { .. }));
8432    }
8433
8434    #[test]
8435    fn test_workflow_error_messages_quality() {
8436        // This test verifies that error messages are specific and helpful.
8437        let cases: Vec<(&str, &str, &str)> = vec![
8438            (r#"ADD workflow "x" a -> b"#, "missing_reason", "REASON"),
8439            (
8440                r#"ADD workflow ON "t" a -> b REASON "y""#,
8441                "missing_name",
8442                "string",
8443            ),
8444            (r#"ADD workflow "x" REASON "y""#, "empty_graph", "node"),
8445            (
8446                r#"ADD workflow "x" a -> b * 0 REASON "y""#,
8447                "repeat_zero",
8448                ">= 1",
8449            ),
8450            (
8451                r#"ADD workflow "x" a -> REASON "y""#,
8452                "dangling_arrow",
8453                "node name",
8454            ),
8455            (
8456                r#"ADD workflow "x" a -> -> b REASON "y""#,
8457                "double_arrow",
8458                "node name",
8459            ),
8460            (
8461                r#"ADD workflow "x" a -> () -> b REASON "y""#,
8462                "empty_parens",
8463                "node name",
8464            ),
8465            (
8466                r#"ADD workflow "x" a -> (b, c REASON "y""#,
8467                "unclosed_paren",
8468                ")",
8469            ),
8470            (
8471                r#"ADD workflow "x" a -> b WHEN 42 REASON "y""#,
8472                "when_no_string",
8473                "string",
8474            ),
8475        ];
8476        for (input, label, expected_substring) in &cases {
8477            let err = pe(input);
8478            let msg = format!("{}", err);
8479            assert!(
8480                msg.to_lowercase()
8481                    .contains(&expected_substring.to_lowercase()),
8482                "Error for '{}' should mention '{}', got: {}",
8483                label,
8484                expected_substring,
8485                msg
8486            );
8487        }
8488    }
8489
8490    /// Using a reserved word in a quoted node name (should work).
8491    #[test]
8492    fn test_add_workflow_reserved_word_quoted() {
8493        let q = p(r#"ADD workflow "x" "ON" -> "WHEN" -> "BIND" REASON "quoted keywords""#);
8494        match &q.statement {
8495            CalStatement::AddWorkflow(wf) => {
8496                assert_eq!(wf.nodes, vec!["ON", "WHEN", "BIND"]);
8497                assert_eq!(wf.edges.len(), 2);
8498            }
8499            other => panic!("expected AddWorkflow, got {:?}", other),
8500        }
8501    }
8502
8503    /// Repeat with fractional number: * 2.5 — parser casts to u32.
8504    #[test]
8505    fn test_add_workflow_repeat_fractional() {
8506        let q = p(r#"ADD workflow "x" a -> b * 2.5 REASON "y""#);
8507        match &q.statement {
8508            CalStatement::AddWorkflow(wf) => {
8509                // 2.5f64 as u32 = 2 (truncation).
8510                assert_eq!(wf.edges[0].repeat, Some(2));
8511            }
8512            other => panic!("expected AddWorkflow, got {:?}", other),
8513        }
8514    }
8515
8516    /// Duplicate node names across edges — parser deduplicates in nodes list.
8517    #[test]
8518    fn test_add_workflow_deduplicates_nodes() {
8519        let q = p(r#"ADD workflow "x" a -> b -> a -> c REASON "cycle""#);
8520        match &q.statement {
8521            CalStatement::AddWorkflow(wf) => {
8522                // "a" appears twice in edges, but should appear once in nodes.
8523                assert_eq!(wf.nodes, vec!["a", "b", "c"]);
8524                assert_eq!(wf.edges.len(), 3);
8525                assert_eq!(wf.edges[0].src, "a");
8526                assert_eq!(wf.edges[0].dst, "b");
8527                assert_eq!(wf.edges[1].src, "b");
8528                assert_eq!(wf.edges[1].dst, "a");
8529                assert_eq!(wf.edges[2].src, "a");
8530                assert_eq!(wf.edges[2].dst, "c");
8531            }
8532            other => panic!("expected AddWorkflow, got {:?}", other),
8533        }
8534    }
8535
8536    /// Disconnected subgraphs: a -> b and c -> d (no link between them).
8537    #[test]
8538    fn test_add_workflow_disconnected_subgraphs() {
8539        let q = p(r#"ADD workflow "x" a -> b c -> d REASON "disconnected""#);
8540        match &q.statement {
8541            CalStatement::AddWorkflow(wf) => {
8542                assert_eq!(wf.nodes, vec!["a", "b", "c", "d"]);
8543                assert_eq!(wf.edges.len(), 2);
8544                // Two independent edges, no connection between them.
8545                assert_eq!(wf.edges[0].src, "a");
8546                assert_eq!(wf.edges[0].dst, "b");
8547                assert_eq!(wf.edges[1].src, "c");
8548                assert_eq!(wf.edges[1].dst, "d");
8549            }
8550            other => panic!("expected AddWorkflow, got {:?}", other),
8551        }
8552    }
8553
8554    /// Single node in a separate chain (no edges) — just isolated nodes.
8555    #[test]
8556    fn test_add_workflow_isolated_nodes_multiple_chains() {
8557        let q = p(r#"ADD workflow "x" a b c REASON "isolated""#);
8558        match &q.statement {
8559            CalStatement::AddWorkflow(wf) => {
8560                assert_eq!(wf.nodes, vec!["a", "b", "c"]);
8561                assert!(wf.edges.is_empty());
8562            }
8563            other => panic!("expected AddWorkflow, got {:?}", other),
8564        }
8565    }
8566
8567    // ---- SUPERSEDE workflow tests ----
8568
8569    /// Basic SUPERSEDE workflow.
8570    #[test]
8571    fn test_supersede_workflow_basic() {
8572        let q = p(r#"SUPERSEDE sha256:abc12345 a -> b REASON "updated""#);
8573        match &q.statement {
8574            CalStatement::SupersedeWorkflow(wf) => {
8575                assert_eq!(wf.hash, "abc12345");
8576                assert_eq!(wf.nodes, vec!["a", "b"]);
8577                assert_eq!(wf.edges.len(), 1);
8578                assert_eq!(wf.reason, "updated");
8579            }
8580            other => panic!("expected SupersedeWorkflow, got {:?}", other),
8581        }
8582    }
8583
8584    /// SUPERSEDE workflow without ON trigger.
8585    #[test]
8586    fn test_supersede_workflow_no_trigger() {
8587        let q = p(r#"SUPERSEDE sha256:def45678 a -> b REASON "y""#);
8588        match &q.statement {
8589            CalStatement::SupersedeWorkflow(wf) => {
8590                assert_eq!(wf.hash, "def45678");
8591                assert_eq!(wf.nodes, vec!["a", "b"]);
8592            }
8593            other => panic!("expected SupersedeWorkflow, got {:?}", other),
8594        }
8595    }
8596
8597    /// SUPERSEDE workflow with BIND clauses.
8598    #[test]
8599    fn test_supersede_workflow_with_bindings() {
8600        let q = p(concat!(
8601            "SUPERSEDE sha256:abc12345 a -> b -> c ",
8602            "BIND a = sha256:def11111 ",
8603            "BIND b = sha256:def22222 ",
8604            r#"REASON "bound""#,
8605        ));
8606        match &q.statement {
8607            CalStatement::SupersedeWorkflow(wf) => {
8608                assert_eq!(wf.bindings.len(), 2);
8609                assert_eq!(wf.bindings[0].node, "a");
8610                assert_eq!(wf.bindings[0].hash, "def11111");
8611                assert_eq!(wf.bindings[1].node, "b");
8612                assert_eq!(wf.bindings[1].hash, "def22222");
8613            }
8614            other => panic!("expected SupersedeWorkflow, got {:?}", other),
8615        }
8616    }
8617
8618    /// SUPERSEDE workflow with parallel + WHEN + repeat.
8619    #[test]
8620    fn test_supersede_workflow_complex_graph() {
8621        let q = p(concat!(
8622            r#"SUPERSEDE sha256:aaaa1111 "#,
8623            r#"build -> (test, lint) -> deploy WHEN "pass" * 3 "#,
8624            r#"REASON "complex supersede""#,
8625        ));
8626        match &q.statement {
8627            CalStatement::SupersedeWorkflow(wf) => {
8628                assert_eq!(wf.nodes, vec!["build", "test", "lint", "deploy"]);
8629                // build->test, build->lint, test->deploy(WHEN+*3), lint->deploy(WHEN+*3)
8630                assert_eq!(wf.edges.len(), 4);
8631                assert_eq!(wf.edges[2].cond, Some("pass".into()));
8632                assert_eq!(wf.edges[2].repeat, Some(3));
8633                assert_eq!(wf.edges[3].cond, Some("pass".into()));
8634                assert_eq!(wf.edges[3].repeat, Some(3));
8635            }
8636            other => panic!("expected SupersedeWorkflow, got {:?}", other),
8637        }
8638    }
8639
8640    /// SUPERSEDE workflow missing REASON.
8641    #[test]
8642    fn test_supersede_workflow_missing_reason() {
8643        let err = pe("SUPERSEDE sha256:abc12345 a -> b");
8644        assert!(matches!(err, CalError::MissingReason { .. }));
8645    }
8646
8647    // ---- Integration with existing CAL features ----
8648
8649    /// ADD workflow inside BATCH.
8650    #[test]
8651    fn test_add_workflow_in_batch() {
8652        let q = p("BATCH { ADD workflow \"x\" a -> b REASON \"y\" }");
8653        match &q.statement {
8654            CalStatement::Batch(b) => {
8655                assert_eq!(b.statements.len(), 1);
8656                assert!(matches!(
8657                    &b.statements[0].statement,
8658                    CalStatement::AddWorkflow(_)
8659                ));
8660            }
8661            other => panic!("expected Batch, got {:?}", other),
8662        }
8663    }
8664
8665    /// EXPLAIN ADD workflow.
8666    #[test]
8667    fn test_explain_add_workflow() {
8668        let q = p(r#"EXPLAIN ADD workflow "x" a -> b REASON "y""#);
8669        match &q.statement {
8670            CalStatement::Explain(e) => {
8671                assert!(matches!(*e.inner, CalStatement::AddWorkflow(_)));
8672            }
8673            other => panic!("expected Explain, got {:?}", other),
8674        }
8675    }
8676
8677    /// Multiple workflows in BATCH.
8678    #[test]
8679    fn test_multiple_workflows_in_batch() {
8680        let q = p(concat!(
8681            "BATCH { ",
8682            r#"ADD workflow "first" a -> b REASON "r1" ; "#,
8683            r#"ADD workflow "second" c -> d REASON "r2" ; "#,
8684            "}",
8685        ));
8686        match &q.statement {
8687            CalStatement::Batch(b) => {
8688                assert_eq!(b.statements.len(), 2);
8689                match &b.statements[0].statement {
8690                    CalStatement::AddWorkflow(wf) => assert_eq!(wf.name, "first"),
8691                    other => panic!("expected AddWorkflow, got {:?}", other),
8692                }
8693                match &b.statements[1].statement {
8694                    CalStatement::AddWorkflow(wf) => assert_eq!(wf.name, "second"),
8695                    other => panic!("expected AddWorkflow, got {:?}", other),
8696                }
8697            }
8698            other => panic!("expected Batch, got {:?}", other),
8699        }
8700    }
8701
8702    /// SUPERSEDE workflow inside BATCH.
8703    #[test]
8704    fn test_supersede_workflow_in_batch() {
8705        let q = p(concat!(
8706            "BATCH { ",
8707            r#"SUPERSEDE sha256:abc12345 a -> b REASON "y" ; "#,
8708            "}",
8709        ));
8710        match &q.statement {
8711            CalStatement::Batch(b) => {
8712                assert_eq!(b.statements.len(), 1);
8713                assert!(matches!(
8714                    &b.statements[0].statement,
8715                    CalStatement::SupersedeWorkflow(_)
8716                ));
8717            }
8718            other => panic!("expected Batch, got {:?}", other),
8719        }
8720    }
8721
8722    /// EXPLAIN SUPERSEDE workflow.
8723    #[test]
8724    fn test_explain_supersede_workflow() {
8725        let q = p(r#"EXPLAIN SUPERSEDE sha256:abc12345 a -> b REASON "y""#);
8726        match &q.statement {
8727            CalStatement::Explain(e) => {
8728                assert!(matches!(*e.inner, CalStatement::SupersedeWorkflow(_)));
8729            }
8730            other => panic!("expected Explain, got {:?}", other),
8731        }
8732    }
8733
8734    /// Workflow with only quoted node names.
8735    #[test]
8736    fn test_add_workflow_all_quoted_names() {
8737        let q =
8738            p(r#"ADD workflow "proc" "step one" -> "step two" -> "step three" REASON "quoted""#);
8739        match &q.statement {
8740            CalStatement::AddWorkflow(wf) => {
8741                assert_eq!(wf.nodes, vec!["step one", "step two", "step three"]);
8742                assert_eq!(wf.edges.len(), 2);
8743            }
8744            other => panic!("expected AddWorkflow, got {:?}", other),
8745        }
8746    }
8747
8748    /// Parallel group with all quoted names.
8749    #[test]
8750    fn test_add_workflow_parallel_all_quoted() {
8751        let q = p(
8752            r#"ADD workflow "x" start -> ("unit tests", "integration tests") -> deploy REASON "y""#,
8753        );
8754        match &q.statement {
8755            CalStatement::AddWorkflow(wf) => {
8756                assert_eq!(
8757                    wf.nodes,
8758                    vec!["start", "unit tests", "integration tests", "deploy"]
8759                );
8760                assert_eq!(wf.edges.len(), 4);
8761            }
8762            other => panic!("expected AddWorkflow, got {:?}", other),
8763        }
8764    }
8765
8766    /// Three parallel groups in sequence: (a,b) -> (c,d) -> (e,f).
8767    #[test]
8768    fn test_add_workflow_three_parallel_groups() {
8769        let q = p(r#"ADD workflow "x" (a, b) -> (c, d) -> (e, f) REASON "y""#);
8770        match &q.statement {
8771            CalStatement::AddWorkflow(wf) => {
8772                assert_eq!(wf.nodes, vec!["a", "b", "c", "d", "e", "f"]);
8773                // (a,b)->(c,d): 4 edges; (c,d)->(e,f): 4 edges = 8 total
8774                assert_eq!(wf.edges.len(), 8);
8775            }
8776            other => panic!("expected AddWorkflow, got {:?}", other),
8777        }
8778    }
8779
8780    /// WHEN on edge from parallel group: (a,b) -> c WHEN "ok".
8781    /// Both a->c and b->c get the WHEN condition.
8782    #[test]
8783    fn test_add_workflow_when_on_parallel_to_single() {
8784        let q = p(r#"ADD workflow "x" (a, b) -> c WHEN "ok" REASON "y""#);
8785        match &q.statement {
8786            CalStatement::AddWorkflow(wf) => {
8787                assert_eq!(wf.edges.len(), 2);
8788                assert_eq!(wf.edges[0].cond, Some("ok".into()));
8789                assert_eq!(wf.edges[1].cond, Some("ok".into()));
8790            }
8791            other => panic!("expected AddWorkflow, got {:?}", other),
8792        }
8793    }
8794
8795    /// Repeat on edge to parallel group: a -> (b,c) * 3.
8796    /// Both a->b and a->c get the repeat count.
8797    #[test]
8798    fn test_add_workflow_repeat_on_single_to_parallel() {
8799        let q = p(r#"ADD workflow "x" a -> (b, c) * 3 REASON "y""#);
8800        match &q.statement {
8801            CalStatement::AddWorkflow(wf) => {
8802                assert_eq!(wf.edges.len(), 2);
8803                assert_eq!(wf.edges[0].repeat, Some(3));
8804                assert_eq!(wf.edges[1].repeat, Some(3));
8805            }
8806            other => panic!("expected AddWorkflow, got {:?}", other),
8807        }
8808    }
8809
8810    /// BIND to a node that doesn't exist in the graph — parser accepts
8811    /// (semantic validation is the engine's job).
8812    #[test]
8813    fn test_add_workflow_bind_nonexistent_node() {
8814        let q = p(r#"ADD workflow "x" a -> b BIND nonexistent = sha256:abc12345 REASON "y""#);
8815        match &q.statement {
8816            CalStatement::AddWorkflow(wf) => {
8817                assert_eq!(wf.bindings.len(), 1);
8818                assert_eq!(wf.bindings[0].node, "nonexistent");
8819            }
8820            other => panic!("expected AddWorkflow, got {:?}", other),
8821        }
8822    }
8823
8824    /// A `SUPERSEDE` of a plan also refuses the removed `ON` clause, rather
8825    /// than treating it as the start of a graph line and failing obscurely.
8826    #[test]
8827    fn supersede_workflow_refuses_the_removed_on_clause() {
8828        let e = parse(
8829            r#"SUPERSEDE sha256:1111111111111111111111111111111111111111111111111111111111111111 ON "x" a -> b REASON "y""#,
8830        )
8831        .expect_err("ON must not be accepted on a supersede either");
8832        assert!(!e.to_string().is_empty());
8833    }
8834
8835    /// Workflow name with unicode.
8836    #[test]
8837    fn test_add_workflow_unicode_name() {
8838        let q = p("ADD workflow \"\u{1F680} deploy\" a -> b REASON \"y\"");
8839        match &q.statement {
8840            CalStatement::AddWorkflow(wf) => {
8841                assert_eq!(wf.name, "\u{1F680} deploy");
8842            }
8843            other => panic!("expected AddWorkflow, got {:?}", other),
8844        }
8845    }
8846
8847    /// SUPERSEDE workflow — single node, no edges.
8848    #[test]
8849    fn test_supersede_workflow_single_node() {
8850        let q = p(r#"SUPERSEDE sha256:abc12345 run REASON "minimal""#);
8851        match &q.statement {
8852            CalStatement::SupersedeWorkflow(wf) => {
8853                assert_eq!(wf.nodes, vec!["run"]);
8854                assert!(wf.edges.is_empty());
8855            }
8856            other => panic!("expected SupersedeWorkflow, got {:?}", other),
8857        }
8858    }
8859
8860    /// SUPERSEDE workflow — BECAUSE instead of REASON.
8861    #[test]
8862    fn test_supersede_workflow_because() {
8863        let q = p(r#"SUPERSEDE sha256:abc12345 a -> b BECAUSE "alt""#);
8864        match &q.statement {
8865            CalStatement::SupersedeWorkflow(wf) => {
8866                assert_eq!(wf.reason, "alt");
8867            }
8868            other => panic!("expected SupersedeWorkflow, got {:?}", other),
8869        }
8870    }
8871
8872    /// Verify edge order in complex workflow: edges should follow
8873    /// chain-by-chain, segment-by-segment order.
8874    #[test]
8875    fn test_add_workflow_edge_order() {
8876        let q = p(concat!(
8877            r#"ADD workflow "x" "#,
8878            "a -> b -> c ",
8879            "d -> e ",
8880            r#"REASON "y""#,
8881        ));
8882        match &q.statement {
8883            CalStatement::AddWorkflow(wf) => {
8884                assert_eq!(wf.edges.len(), 3);
8885                // Chain 1: a->b, b->c
8886                assert_eq!(wf.edges[0].src, "a");
8887                assert_eq!(wf.edges[0].dst, "b");
8888                assert_eq!(wf.edges[1].src, "b");
8889                assert_eq!(wf.edges[1].dst, "c");
8890                // Chain 2: d->e
8891                assert_eq!(wf.edges[2].src, "d");
8892                assert_eq!(wf.edges[2].dst, "e");
8893            }
8894            other => panic!("expected AddWorkflow, got {:?}", other),
8895        }
8896    }
8897
8898    /// Node names with many underscores and numbers.
8899    #[test]
8900    fn test_add_workflow_complex_ident_names() {
8901        let q = p(r#"ADD workflow "x" a1_b2_c3 -> x99_y00 REASON "y""#);
8902        match &q.statement {
8903            CalStatement::AddWorkflow(wf) => {
8904                assert_eq!(wf.nodes, vec!["a1_b2_c3", "x99_y00"]);
8905            }
8906            other => panic!("expected AddWorkflow, got {:?}", other),
8907        }
8908    }
8909
8910    /// Only a parallel group, no edges.
8911    #[test]
8912    fn test_add_workflow_parallel_group_only() {
8913        let q = p(r#"ADD workflow "x" (a, b, c) REASON "y""#);
8914        match &q.statement {
8915            CalStatement::AddWorkflow(wf) => {
8916                assert_eq!(wf.nodes, vec!["a", "b", "c"]);
8917                assert!(wf.edges.is_empty());
8918            }
8919            other => panic!("expected AddWorkflow, got {:?}", other),
8920        }
8921    }
8922
8923    // ── Workflow hardening tests (audit issues) ────────────────────────────
8924
8925    /// Issue 2: WHEN on a bare node (not after an edge) should give a
8926    /// specific error telling the user WHEN must follow `->`.
8927    #[test]
8928    fn test_add_workflow_when_on_non_edge_context() {
8929        let err = pe(r#"ADD workflow "x" a WHEN "cond" -> b REASON "y""#);
8930        let msg = format!("{}", err);
8931        assert!(
8932            msg.to_lowercase().contains("when") && msg.to_lowercase().contains("edge"),
8933            "expected error about WHEN placement, got: {}",
8934            msg
8935        );
8936    }
8937
8938    /// Issue 3a: Dangling arrow — `a ->` followed by REASON.
8939    #[test]
8940    fn test_add_workflow_dangling_arrow_message() {
8941        let err = pe(r#"ADD workflow "x" a -> REASON "y""#);
8942        let msg = format!("{}", err);
8943        assert!(
8944            msg.to_lowercase().contains("node name"),
8945            "expected error mentioning 'node name', got: {}",
8946            msg
8947        );
8948    }
8949
8950    /// Issue 3b: Double arrow — `a -> -> b`.
8951    #[test]
8952    fn test_add_workflow_double_arrow_message() {
8953        let err = pe(r#"ADD workflow "x" a -> -> b REASON "y""#);
8954        let msg = format!("{}", err);
8955        assert!(
8956            msg.to_lowercase().contains("node name") && msg.to_lowercase().contains("->"),
8957            "expected error about node name after ->, got: {}",
8958            msg
8959        );
8960    }
8961
8962    /// Issue 3c: Empty group — `a -> () -> b`.
8963    #[test]
8964    fn test_add_workflow_empty_group_message() {
8965        let err = pe(r#"ADD workflow "x" a -> () -> b REASON "y""#);
8966        let msg = format!("{}", err);
8967        assert!(
8968            msg.to_lowercase().contains("node name"),
8969            "expected error about empty group needing a node, got: {}",
8970            msg
8971        );
8972    }
8973
8974    /// Issue 3d: Unclosed paren — `a -> (b, c REASON "y"`.
8975    #[test]
8976    fn test_add_workflow_unclosed_paren_message() {
8977        let err = pe(r#"ADD workflow "x" a -> (b, c REASON "y""#);
8978        let msg = format!("{}", err);
8979        assert!(
8980            msg.contains(")") || msg.to_lowercase().contains("close"),
8981            "expected error mentioning ')' to close group, got: {}",
8982            msg
8983        );
8984    }
8985
8986    /// Issue 3e: No graph body — just REASON after name.
8987    #[test]
8988    fn test_add_workflow_no_graph_message() {
8989        let err = pe(r#"ADD workflow "x" REASON "y""#);
8990        let msg = format!("{}", err);
8991        assert!(
8992            msg.to_lowercase().contains("node"),
8993            "expected error mentioning nodes needed, got: {}",
8994            msg
8995        );
8996    }
8997
8998    /// Issue 3f: Zero repeat — `* 0`.
8999    #[test]
9000    fn test_add_workflow_repeat_zero_message() {
9001        let err = pe(r#"ADD workflow "x" a -> b * 0 REASON "y""#);
9002        let msg = format!("{}", err);
9003        assert!(
9004            msg.contains(">= 1") || msg.to_lowercase().contains("at least"),
9005            "expected error about repeat count >= 1, got: {}",
9006            msg
9007        );
9008    }
9009
9010    /// Issue 3g: Non-number after `*` — `a -> b * abc`.
9011    #[test]
9012    fn test_add_workflow_repeat_non_number() {
9013        let err = pe(r#"ADD workflow "x" a -> b * abc REASON "y""#);
9014        let msg = format!("{}", err);
9015        assert!(
9016            msg.to_lowercase().contains("number"),
9017            "expected error about expecting number after *, got: {}",
9018            msg
9019        );
9020    }
9021
9022    /// Issue 4: ON/WHEN/BIND as field names in non-workflow contexts
9023    /// should still work — they must not be broken by the keyword tokens.
9024    #[test]
9025    fn test_on_as_field_name_in_where() {
9026        // `on` used as a field name in a RECALL WHERE clause.
9027        let q = p(r#"RECALL facts WHERE on = "something""#);
9028        match &q.statement {
9029            CalStatement::Recall(r) => {
9030                assert!(r.where_clause.is_some());
9031            }
9032            other => panic!("expected Recall, got {:?}", other),
9033        }
9034    }
9035
9036    #[test]
9037    fn test_when_as_field_name_in_where() {
9038        let q = p(r#"RECALL events WHERE when = "2025-01-01""#);
9039        match &q.statement {
9040            CalStatement::Recall(r) => {
9041                assert!(r.where_clause.is_some());
9042            }
9043            other => panic!("expected Recall, got {:?}", other),
9044        }
9045    }
9046
9047    #[test]
9048    fn test_bind_as_field_name_in_where() {
9049        let q = p(r#"RECALL facts WHERE bind = "test""#);
9050        match &q.statement {
9051            CalStatement::Recall(r) => {
9052                assert!(r.where_clause.is_some());
9053            }
9054            other => panic!("expected Recall, got {:?}", other),
9055        }
9056    }
9057
9058    #[test]
9059    fn test_on_as_field_name_in_add() {
9060        // `on` used as a field name in an ADD SET clause.
9061        let q = p(r#"ADD fact SET on = "value" REASON "y""#);
9062        match &q.statement {
9063            CalStatement::Add(a) => {
9064                assert!(a.fields.iter().any(|f| f.field == "on"));
9065            }
9066            other => panic!("expected Add, got {:?}", other),
9067        }
9068    }
9069
9070    /// Issue 5: `* N` attaches to dst node in retries map.
9071    /// In `a -> b * 3 -> c`, the retries map gets `"b": 3` and
9072    /// edges are `a->b`, `b->c` (both without max_cycles on them).
9073    #[test]
9074    fn test_add_workflow_repeat_attaches_to_target() {
9075        let q = p(r#"ADD workflow "x" a -> b * 3 -> c REASON "y""#);
9076        match &q.statement {
9077            CalStatement::AddWorkflow(wf) => {
9078                // 2 edges: a->b, b->c
9079                assert_eq!(wf.edges.len(), 2);
9080                assert_eq!(wf.edges[0].src, "a");
9081                assert_eq!(wf.edges[0].dst, "b");
9082                assert_eq!(wf.edges[0].repeat, Some(3));
9083                assert_eq!(wf.edges[1].src, "b");
9084                assert_eq!(wf.edges[1].dst, "c");
9085                assert_eq!(wf.edges[1].repeat, None);
9086            }
9087            other => panic!("expected AddWorkflow, got {:?}", other),
9088        }
9089    }
9090
9091    /// Issue 6: SUPERSEDE with SET (non-workflow) correctly routes to
9092    /// the SET path even after workflow detection is added.
9093    #[test]
9094    fn test_supersede_set_not_confused_with_workflow() {
9095        let q = p(r#"SUPERSEDE sha256:abc12345 SET object = "new" REASON "updated""#);
9096        match &q.statement {
9097            CalStatement::Supersede(s) => {
9098                assert_eq!(s.hash, "abc12345");
9099                assert_eq!(s.set_clauses.len(), 1);
9100                assert_eq!(s.set_clauses[0].field, "object");
9101                assert_eq!(s.reason, "updated");
9102            }
9103            other => panic!(
9104                "expected Supersede (not SupersedeWorkflow), got {:?}",
9105                other
9106            ),
9107        }
9108    }
9109
9110    /// Issue 5 edge case: repeat on parallel targets — each target gets
9111    /// its own retry count entry.
9112    #[test]
9113    fn test_add_workflow_repeat_on_parallel_targets() {
9114        let q = p(r#"ADD workflow "x" a -> (b, c) * 2 REASON "y""#);
9115        match &q.statement {
9116            CalStatement::AddWorkflow(wf) => {
9117                // 2 edges: a->b, a->c — both with repeat=2
9118                assert_eq!(wf.edges.len(), 2);
9119                assert_eq!(wf.edges[0].repeat, Some(2));
9120                assert_eq!(wf.edges[1].repeat, Some(2));
9121            }
9122            other => panic!("expected AddWorkflow, got {:?}", other),
9123        }
9124    }
9125
9126    /// ON/WHEN/BIND used as SELECT field names in pipelines.
9127    #[test]
9128    fn test_keyword_as_select_field() {
9129        let q = p(r#"RECALL facts WHERE subject = "x" | SELECT on, when, bind"#);
9130        match &q.pipeline.first() {
9131            Some(PipelineStage::Select { fields, .. }) => {
9132                assert_eq!(fields, &["on", "when", "bind"]);
9133            }
9134            other => panic!("expected Select pipeline stage, got {:?}", other),
9135        }
9136    }
9137
9138    #[test]
9139    fn test_priority_as_field_name_in_where() {
9140        let q = p(r#"RECALL goals WHERE priority = "high""#);
9141        match &q.statement {
9142            CalStatement::Recall(r) => {
9143                assert!(r.where_clause.is_some());
9144            }
9145            other => panic!("expected Recall, got {:?}", other),
9146        }
9147    }
9148
9149    #[test]
9150    fn test_scope_as_field_name_in_where() {
9151        let q = p(r#"RECALL consents WHERE scope = "read""#);
9152        match &q.statement {
9153            CalStatement::Recall(r) => {
9154                assert!(r.where_clause.is_some());
9155            }
9156            other => panic!("expected Recall, got {:?}", other),
9157        }
9158    }
9159
9160    #[test]
9161    fn test_priority_scope_as_select_fields() {
9162        let q = p(r#"RECALL goals WHERE subject = "x" | SELECT priority, scope"#);
9163        match &q.pipeline.first() {
9164            Some(PipelineStage::Select { fields, .. }) => {
9165                assert!(fields.contains(&"priority".to_string()));
9166                assert!(fields.contains(&"scope".to_string()));
9167            }
9168            other => panic!("expected Select pipeline stage, got {:?}", other),
9169        }
9170    }
9171
9172    #[test]
9173    fn test_multiple_with_clauses_merged() {
9174        let q = p("RECALL facts WITH superseded WITH score_breakdown WITH explanation");
9175        assert!(q.with_options.contains(&WithOption::Superseded));
9176        assert!(q.with_options.contains(&WithOption::ScoreBreakdown));
9177        assert!(q.with_options.contains(&WithOption::Explanation));
9178        assert_eq!(q.with_options.len(), 3);
9179    }
9180
9181    #[test]
9182    fn test_multiple_with_clauses_mixed_with_comma() {
9183        let q = p("RECALL facts WITH superseded, score_breakdown WITH explanation");
9184        assert!(q.with_options.contains(&WithOption::Superseded));
9185        assert!(q.with_options.contains(&WithOption::ScoreBreakdown));
9186        assert!(q.with_options.contains(&WithOption::Explanation));
9187        assert_eq!(q.with_options.len(), 3);
9188    }
9189
9190    // ── 17. SUPERSEDE (Tier 1) ────────────────────────────────────────────
9191
9192    #[test]
9193    fn test_supersede() {
9194        let q = p(
9195            r#"SUPERSEDE sha256:abc123def456 SET object = "light mode" REASON "changed preference""#,
9196        );
9197        match &q.statement {
9198            CalStatement::Supersede(s) => {
9199                assert_eq!(s.hash, "abc123def456");
9200                assert_eq!(s.reason, "changed preference");
9201                assert_eq!(s.set_clauses.len(), 1);
9202            }
9203            other => panic!("expected Supersede, got {:?}", other),
9204        }
9205    }
9206
9207    // ── 18. REVERT (Tier 1) ───────────────────────────────────────────────
9208
9209    #[test]
9210    fn test_revert() {
9211        let q = p(r#"REVERT sha256:abc123def456 REASON "mistake""#);
9212        match &q.statement {
9213            CalStatement::Revert(r) => {
9214                assert_eq!(r.hash, "abc123def456");
9215                assert_eq!(r.reason, "mistake");
9216            }
9217            other => panic!("expected Revert, got {:?}", other),
9218        }
9219    }
9220
9221    // ── 19. LET binding ───────────────────────────────────────────────────
9222
9223    #[test]
9224    fn test_let_binding() {
9225        let q = p("LET $users = RECALL facts SUBJECTS; RECALL events");
9226        assert_eq!(q.let_bindings.len(), 1);
9227        assert_eq!(q.let_bindings[0].name, "users");
9228        assert_eq!(q.let_bindings[0].extractor, Extractor::Subjects);
9229    }
9230
9231    #[test]
9232    fn test_let_binding_pipe_backward_compat() {
9233        let q = p("LET $users = RECALL facts | SUBJECTS; RECALL events");
9234        assert_eq!(q.let_bindings.len(), 1);
9235        assert_eq!(q.let_bindings[0].name, "users");
9236        assert_eq!(q.let_bindings[0].extractor, Extractor::Subjects);
9237    }
9238
9239    // ── 20. WHERE with AND/OR ─────────────────────────────────────────────
9240
9241    #[test]
9242    fn test_where_and() {
9243        let q = p(r#"RECALL facts WHERE subject = "john" AND confidence >= 0.8"#);
9244        match &q.statement {
9245            CalStatement::Recall(r) => {
9246                let cond = &r.where_clause.as_ref().unwrap().condition;
9247                assert!(matches!(cond, Condition::And { .. }));
9248            }
9249            _ => panic!("expected Recall"),
9250        }
9251    }
9252
9253    #[test]
9254    fn test_where_or() {
9255        let q = p(r#"RECALL facts WHERE subject = "john" OR subject = "bob""#);
9256        match &q.statement {
9257            CalStatement::Recall(r) => {
9258                let cond = &r.where_clause.as_ref().unwrap().condition;
9259                assert!(matches!(cond, Condition::Or { .. }));
9260            }
9261            _ => panic!("expected Recall"),
9262        }
9263    }
9264
9265    // ── 21. Complex nested query ──────────────────────────────────────────
9266
9267    #[test]
9268    fn test_complex_nested() {
9269        let q = p(
9270            r#"CAL/1 RECALL facts ABOUT "preferences" WHERE confidence >= 0.8 ORDER BY confidence DESC LIMIT 5 WITH score_breakdown FORMAT json"#,
9271        );
9272        assert_eq!(q.version, CalVersion(1));
9273        assert!(matches!(q.statement, CalStatement::Recall(_)));
9274        assert_eq!(q.pipeline.len(), 2);
9275        assert!(q.with_options.contains(&WithOption::ScoreBreakdown));
9276        assert_eq!(q.format, Some(FormatClause::Single(FormatSpec::Json)));
9277    }
9278
9279    // ── 22. Error: destructive keyword ────────────────────────────────────
9280
9281    #[test]
9282    fn test_error_destructive_keyword() {
9283        // DELETE and FORGET are now valid CAL statements; use ERASE instead.
9284        let err = pe("ERASE facts WHERE subject = \"john\"");
9285        assert!(matches!(err, CalError::UnexpectedToken { .. }));
9286        assert!(err.suggestion().is_some());
9287        let sug = err.suggestion().unwrap();
9288        assert!(sug.contains("CAL cannot destroy data in bulk"));
9289        // FORGET <hash> is real grammar and may be advertised; blocked
9290        // keywords and PURGE (token, but not text grammar) must not be.
9291        let msg = err.to_string();
9292        assert!(!msg.contains("PURGE"), "{msg}");
9293        assert!(!msg.contains("DELETE"), "{msg}");
9294    }
9295
9296    // ── 23. Error: unknown grain type ─────────────────────────────────────
9297
9298    #[test]
9299    fn test_error_unknown_grain_type_beliefs() {
9300        let err = pe("RECALL beliefs");
9301        match err {
9302            CalError::UnknownGrainType {
9303                found, suggestion, ..
9304            } => {
9305                assert_eq!(found, "beliefs");
9306                assert!(suggestion.unwrap().contains("facts"));
9307            }
9308            other => panic!("expected UnknownGrainType, got {:?}", other),
9309        }
9310    }
9311
9312    // ── 24. Version prefix ────────────────────────────────────────────────
9313
9314    #[test]
9315    fn test_version_prefix() {
9316        let q = p("CAL/1 RECALL facts");
9317        assert_eq!(q.version, CalVersion(1));
9318    }
9319
9320    #[test]
9321    fn test_unsupported_version() {
9322        let err = pe("CAL/2 RECALL facts");
9323        assert!(matches!(
9324            err,
9325            CalError::UnsupportedVersion { version: 2, .. }
9326        ));
9327    }
9328
9329    // ── 25. HISTORY ───────────────────────────────────────────────────────
9330
9331    #[test]
9332    fn test_history() {
9333        let q = p("HISTORY sha256:abc123def456");
9334        match &q.statement {
9335            CalStatement::History(h) => {
9336                assert_eq!(h.hash, "abc123def456");
9337            }
9338            other => panic!("expected History, got {:?}", other),
9339        }
9340    }
9341
9342    // ── 26. WHERE with IN ─────────────────────────────────────────────────
9343
9344    #[test]
9345    fn test_where_in_list() {
9346        let q = p(r#"RECALL facts WHERE subject IN ("john", "bob")"#);
9347        match &q.statement {
9348            CalStatement::Recall(r) => {
9349                let cond = &r.where_clause.as_ref().unwrap().condition;
9350                match cond {
9351                    Condition::In { field, values, .. } => {
9352                        assert_eq!(field, "subject");
9353                        assert_eq!(values.len(), 2);
9354                    }
9355                    other => panic!("expected In, got {:?}", other),
9356                }
9357            }
9358            _ => panic!("expected Recall"),
9359        }
9360    }
9361
9362    // ── 27. WHERE with multiple AND conditions ────────────────────────────
9363
9364    #[test]
9365    fn test_where_multiple_and() {
9366        let q = p(
9367            r#"RECALL facts WHERE subject = "john" AND confidence >= 0.8 AND tags INCLUDE ["preferences"]"#,
9368        );
9369        match &q.statement {
9370            CalStatement::Recall(r) => {
9371                let cond = &r.where_clause.as_ref().unwrap().condition;
9372                // Should be And(And(comparison, comparison), comparison)
9373                assert!(matches!(cond, Condition::And { .. }));
9374            }
9375            _ => panic!("expected Recall"),
9376        }
9377    }
9378
9379    // ── 28. Error: empty query ────────────────────────────────────────────
9380
9381    #[test]
9382    fn test_error_empty_query() {
9383        let err = pe("");
9384        assert!(matches!(err, CalError::EmptyQuery { .. }));
9385    }
9386
9387    // ── 29. Error: query too long ─────────────────────────────────────────
9388
9389    #[test]
9390    fn test_error_query_too_long() {
9391        // String must exceed MAX_QUERY_LENGTH (65536) to trigger the pre-parse length check.
9392        let huge = "RECALL facts WHERE subject = \"".to_string() + &"a".repeat(66_000) + "\"";
9393        let err = pe(&huge);
9394        assert!(matches!(err, CalError::QueryTooLong { .. }));
9395    }
9396
9397    // ── 30. WHERE IS NULL / IS NOT NULL ───────────────────────────────────
9398
9399    #[test]
9400    fn test_where_is_null() {
9401        let q = p("RECALL facts WHERE object IS NULL");
9402        match &q.statement {
9403            CalStatement::Recall(r) => {
9404                let cond = &r.where_clause.as_ref().unwrap().condition;
9405                assert!(matches!(cond, Condition::IsNull { field, .. } if field == "object"));
9406            }
9407            _ => panic!("expected Recall"),
9408        }
9409    }
9410
9411    #[test]
9412    fn test_where_is_not_null() {
9413        let q = p("RECALL facts WHERE object IS NOT NULL");
9414        match &q.statement {
9415            CalStatement::Recall(r) => {
9416                let cond = &r.where_clause.as_ref().unwrap().condition;
9417                assert!(matches!(cond, Condition::IsNotNull { field, .. } if field == "object"));
9418            }
9419            _ => panic!("expected Recall"),
9420        }
9421    }
9422
9423    // ── 31. Format TEMPLATE ───────────────────────────────────────────────
9424
9425    #[test]
9426    fn test_format_template() {
9427        let q = p(r#"RECALL facts FORMAT TEMPLATE "{{subject}}: {{object}}""#);
9428        match &q.format {
9429            Some(FormatClause::Single(FormatSpec::Template { template })) => {
9430                assert!(template.contains("subject"));
9431            }
9432            other => panic!("expected Template format, got {:?}", other),
9433        }
9434    }
9435
9436    // ── 32. Intersect set operation ───────────────────────────────────────
9437
9438    #[test]
9439    fn test_intersect() {
9440        let q = p(
9441            r#"(RECALL facts WHERE subject = "john") INTERSECT (RECALL facts WHERE confidence >= 0.9)"#,
9442        );
9443        match &q.statement {
9444            CalStatement::SetOp(s) => {
9445                assert_eq!(s.op, SetOp::Intersect);
9446            }
9447            other => panic!("expected SetOp, got {:?}", other),
9448        }
9449    }
9450
9451    // ── 33. EXCEPT set operation ──────────────────────────────────────────
9452
9453    #[test]
9454    fn test_except() {
9455        let q = p(
9456            r#"(RECALL facts WHERE subject = "john") EXCEPT (RECALL facts WHERE confidence < 0.5)"#,
9457        );
9458        match &q.statement {
9459            CalStatement::SetOp(s) => {
9460                assert_eq!(s.op, SetOp::Except);
9461            }
9462            other => panic!("expected SetOp, got {:?}", other),
9463        }
9464    }
9465
9466    // ── 34. NOT condition ─────────────────────────────────────────────────
9467
9468    #[test]
9469    fn test_where_not() {
9470        let q = p(r#"RECALL facts WHERE NOT subject = "john""#);
9471        match &q.statement {
9472            CalStatement::Recall(r) => {
9473                let cond = &r.where_clause.as_ref().unwrap().condition;
9474                assert!(matches!(cond, Condition::Not { .. }));
9475            }
9476            _ => panic!("expected Recall"),
9477        }
9478    }
9479
9480    // ── 35. BETWEEN clause ────────────────────────────────────────────────
9481
9482    #[test]
9483    fn test_between_clause() {
9484        let q = p(r#"RECALL events BETWEEN "2024-01-01" AND "2024-12-31""#);
9485        match &q.statement {
9486            CalStatement::Recall(r) => {
9487                let b = r.between.as_ref().unwrap();
9488                assert_eq!(b.start, "2024-01-01");
9489                assert_eq!(b.end, "2024-12-31");
9490            }
9491            _ => panic!("expected Recall"),
9492        }
9493    }
9494
9495    // ── 36. Error: whitespace-only input ─────────────────────────────────
9496
9497    #[test]
9498    fn test_error_whitespace_only_input() {
9499        let err = pe("   \t\n  ");
9500        assert!(matches!(err, CalError::EmptyQuery { .. }));
9501    }
9502
9503    // ── 37. Error: all destructive keywords produce clear errors ─────────
9504
9505    #[test]
9506    fn test_error_all_destructive_keywords() {
9507        let keywords = [
9508            "DELETE", "DROP", "FORGET", "ERASE", "DESTROY", "PURGE", "TRUNCATE", "INSERT",
9509            "CREATE", "WRITE", "STORE",
9510        ];
9511        for kw in &keywords {
9512            let input = format!("{} facts WHERE subject = \"john\"", kw);
9513            let result = parse(&input);
9514            assert!(result.is_err(), "'{}' should produce a parse error", kw);
9515            let err = result.unwrap_err();
9516            assert!(
9517                err.suggestion().is_some(),
9518                "'{}' error should have a suggestion",
9519                kw
9520            );
9521        }
9522    }
9523
9524    // ── 38. OR condition produces a warning ──────────────────────────────
9525
9526    #[test]
9527    fn test_where_or_produces_warning() {
9528        let q = p(r#"RECALL facts WHERE subject = "john" OR subject = "bob""#);
9529        // The parser should emit a warning about OR being partially supported.
9530        // The query should still parse successfully.
9531        assert!(matches!(q.statement, CalStatement::Recall(_)));
9532        // Warnings are collected in the CalQuery.warnings field.
9533        // (This test mainly verifies it parses without errors.)
9534    }
9535
9536    // ── 39. Query length boundary tests ─────────────────────────────────
9537
9538    #[test]
9539    fn test_query_well_under_max_length_succeeds() {
9540        // A query well under MAX_QUERY_LENGTH should parse fine.
9541        let input = format!("RECALL facts ABOUT \"{}\"", "a".repeat(100));
9542        assert!(input.len() < MAX_QUERY_LENGTH);
9543        let result = parse(&input);
9544        assert!(result.is_ok(), "query under MAX_QUERY_LENGTH should parse");
9545    }
9546
9547    #[test]
9548    fn test_query_over_max_length_rejected() {
9549        // A query exceeding MAX_QUERY_LENGTH (65536) must be rejected.
9550        let input = format!("RECALL facts ABOUT \"{}\"", "a".repeat(66_000));
9551        assert!(input.len() > MAX_QUERY_LENGTH);
9552        let result = parse(&input);
9553        assert!(result.is_err(), "query over MAX_QUERY_LENGTH should fail");
9554        assert!(matches!(result.unwrap_err(), CalError::QueryTooLong { .. }));
9555    }
9556
9557    #[test]
9558    fn test_max_query_length_constant_is_65536() {
9559        assert_eq!(MAX_QUERY_LENGTH, 65_536);
9560    }
9561
9562    // SEC (finding #7, CWE-674): the recursion guard MUST refuse input that
9563    // exceeds MAX_NESTING_DEPTH. We use parenthesised condition groups inside
9564    // a WHERE clause — `parse_condition_primary` calls `enter_nesting` for each
9565    // paren level. With depth 6, opening 20 parens must trip NestingTooDeep
9566    // long before any pathological input can deplete the worker thread stack.
9567    #[test]
9568    fn test_nesting_depth_overflow_is_rejected() {
9569        let mut input = String::from("RECALL facts WHERE ");
9570        for _ in 0..20 {
9571            input.push('(');
9572        }
9573        input.push_str("confidence > 0.5");
9574        for _ in 0..20 {
9575            input.push(')');
9576        }
9577        let result = parse(&input);
9578        assert!(
9579            matches!(result, Err(CalError::NestingTooDeep { .. })),
9580            "deeply nested parens must return NestingTooDeep, got {:?}",
9581            result
9582        );
9583    }
9584
9585    #[test]
9586    fn test_nesting_depth_constant_matches_spec() {
9587        // Text parser and JSON pre-validator must share one limit so a
9588        // query that parses via one wire format also parses via the other.
9589        const _: () = assert!(
9590            MAX_NESTING_DEPTH == 8,
9591            "MAX_NESTING_DEPTH must remain 8 to stay in sync with json.rs"
9592        );
9593    }
9594
9595    #[test]
9596    fn test_query_32kb_parses_successfully() {
9597        // 32KB queries (e.g. agent system prompts) must be accepted.
9598        // Use a spawned thread with larger stack — debug-mode recursive descent
9599        // on 32KB strings exceeds the default 8MB test thread stack.
9600        let result = std::thread::Builder::new()
9601            .stack_size(16 * 1024 * 1024) // 16 MB
9602            .spawn(|| {
9603                let query_text = format!("RECALL facts ABOUT \"{}\"", "a".repeat(32_000));
9604                assert!(query_text.len() > 32_000);
9605                assert!(query_text.len() < MAX_QUERY_LENGTH);
9606                let result = parse(&query_text);
9607                assert!(result.is_ok(), "32KB query should parse successfully");
9608            })
9609            .expect("spawn thread")
9610            .join();
9611        result.expect("32KB parse thread panicked");
9612    }
9613
9614    // ── 40. All 10 grain types parse (plural) ────────────────────────────
9615
9616    #[test]
9617    fn test_all_grain_type_plurals_parse() {
9618        let types = [
9619            "facts",
9620            "events",
9621            "states",
9622            "workflows",
9623            "tools",
9624            "observations",
9625            "goals",
9626            "reasonings",
9627            "consensuses",
9628            "consents",
9629        ];
9630        for gt in &types {
9631            let input = format!("RECALL {}", gt);
9632            let result = parse(&input);
9633            assert!(
9634                result.is_ok(),
9635                "RECALL {} should parse successfully, got: {:?}",
9636                gt,
9637                result.unwrap_err()
9638            );
9639        }
9640    }
9641
9642    // ── 41. Case insensitivity throughout the parser ─────────────────────
9643
9644    #[test]
9645    fn test_parser_case_insensitive_keywords() {
9646        // Mix of upper/lower/mixed case should all parse.
9647        let inputs = [
9648            "recall facts",
9649            "Recall Facts",
9650            "RECALL FACTS",
9651            "rEcAlL fAcTs",
9652        ];
9653        for input in &inputs {
9654            let result = parse(input);
9655            assert!(
9656                result.is_ok(),
9657                "'{}' should parse case-insensitively",
9658                input
9659            );
9660        }
9661    }
9662
9663    // ── 42. OMS 1.1 → 1.2 renamed types produce helpful errors ──────────
9664
9665    #[test]
9666    fn test_error_oms_1_1_renamed_types() {
9667        // "beliefs" (old name for "facts") should give a suggestion.
9668        let err = pe("RECALL beliefs");
9669        match err {
9670            CalError::UnknownGrainType {
9671                found, suggestion, ..
9672            } => {
9673                assert_eq!(found, "beliefs");
9674                assert!(
9675                    suggestion.as_ref().unwrap().contains("facts"),
9676                    "suggestion for 'beliefs' should mention 'facts'"
9677                );
9678            }
9679            other => panic!("expected UnknownGrainType, got {:?}", other),
9680        }
9681    }
9682
9683    // ========================================================================
9684    // Phase 2 tests — new parser syntax
9685    // ========================================================================
9686
9687    // ── 43. ASSEMBLE with multi-source FROM ─────────────────────────────
9688
9689    #[test]
9690    fn test_assemble_multi_source() {
9691        let q = p(
9692            r#"ASSEMBLE "context" FROM recent: (RECALL facts RECENT 5), background: (RECALL events RECENT 10)"#,
9693        );
9694        match &q.statement {
9695            CalStatement::Assemble(a) => {
9696                assert_eq!(a.topic, "context");
9697                assert_eq!(a.context_name.as_deref(), Some("context"));
9698                let sources = a.sources.as_ref().expect("should have named sources");
9699                assert_eq!(sources.len(), 2);
9700                assert_eq!(sources[0].label, "recent");
9701                assert_eq!(sources[1].label, "background");
9702                assert!(matches!(*sources[0].query, CalStatement::Recall(_)));
9703                assert!(matches!(*sources[1].query, CalStatement::Recall(_)));
9704            }
9705            other => panic!("expected Assemble, got {:?}", other),
9706        }
9707    }
9708
9709    // ── #609: per-source WITH inside ASSEMBLE source parens ─────────────
9710
9711    /// Extract the named sources from an ASSEMBLE query, panicking otherwise.
9712    fn assemble_sources(q: &CalQuery) -> Vec<NamedSource> {
9713        match &q.statement {
9714            CalStatement::Assemble(a) => a
9715                .sources
9716                .as_ref()
9717                .expect("should have named sources")
9718                .clone(),
9719            other => panic!("expected Assemble, got {:?}", other),
9720        }
9721    }
9722
9723    #[test]
9724    fn test_assemble_per_source_with_annotate_relative_time() {
9725        let q = p(
9726            r#"ASSEMBLE "ctx" FROM messages: (RECALL events RECENT 5 WITH annotate_relative_time)"#,
9727        );
9728        let sources = assemble_sources(&q);
9729        assert_eq!(sources.len(), 1);
9730        assert_eq!(sources[0].with_options.len(), 1);
9731        assert!(matches!(
9732            sources[0].with_options[0],
9733            WithOption::AnnotateRelativeTime
9734        ));
9735    }
9736
9737    #[test]
9738    fn test_assemble_per_source_with_rerank() {
9739        let q = p(r#"ASSEMBLE "ctx" FROM k: (RECALL facts LIMIT 10 WITH rerank)"#);
9740        let sources = assemble_sources(&q);
9741        assert!(matches!(
9742            sources[0].with_options.as_slice(),
9743            [WithOption::Rerank { .. }]
9744        ));
9745    }
9746
9747    #[test]
9748    fn test_assemble_per_source_with_conflict_resolution() {
9749        let q =
9750            p(r#"ASSEMBLE "ctx" FROM u: (RECALL facts ABOUT "alice" WITH conflict_resolution)"#);
9751        let sources = assemble_sources(&q);
9752        assert!(matches!(
9753            sources[0].with_options.as_slice(),
9754            [WithOption::ConflictResolution]
9755        ));
9756    }
9757
9758    #[test]
9759    fn test_assemble_per_source_with_dedup() {
9760        let q = p(r#"ASSEMBLE "ctx" FROM h: (RECALL tools RECENT 5 WITH dedup)"#);
9761        let sources = assemble_sources(&q);
9762        assert!(matches!(
9763            sources[0].with_options.as_slice(),
9764            [WithOption::Dedup { .. }]
9765        ));
9766    }
9767
9768    #[test]
9769    fn test_assemble_per_source_with_min_score() {
9770        let q = p(r#"ASSEMBLE "ctx" FROM k: (RECALL facts LIMIT 10 WITH min_score(0.5))"#);
9771        let sources = assemble_sources(&q);
9772        assert!(matches!(
9773            sources[0].with_options.as_slice(),
9774            [WithOption::MinScore { .. }]
9775        ));
9776    }
9777
9778    #[test]
9779    fn test_assemble_per_source_with_query_expansion() {
9780        let q = p(r#"ASSEMBLE "ctx" FROM k: (RECALL facts LIMIT 10 WITH query_expansion)"#);
9781        let sources = assemble_sources(&q);
9782        assert!(matches!(
9783            sources[0].with_options.as_slice(),
9784            [WithOption::QueryExpansion]
9785        ));
9786    }
9787
9788    #[test]
9789    fn test_assemble_per_source_with_recency_weight() {
9790        let q = p(r#"ASSEMBLE "ctx" FROM m: (RECALL events RECENT 5 WITH recency_weight(0.7))"#);
9791        let sources = assemble_sources(&q);
9792        assert!(matches!(
9793            sources[0].with_options.as_slice(),
9794            [WithOption::RecencyWeight { .. }]
9795        ));
9796    }
9797
9798    #[test]
9799    fn test_assemble_per_source_with_hyde() {
9800        let q = p(r#"ASSEMBLE "ctx" FROM k: (RECALL facts LIMIT 10 WITH hyde)"#);
9801        let sources = assemble_sources(&q);
9802        assert!(matches!(
9803            sources[0].with_options.as_slice(),
9804            [WithOption::Hyde]
9805        ));
9806    }
9807
9808    /// Q1: inside-paren options come first, then outside-paren options.
9809    #[test]
9810    fn test_assemble_per_source_with_inside_and_outside_merge_order() {
9811        let q = p(r#"ASSEMBLE "ctx" FROM k: (RECALL facts LIMIT 10 WITH rerank) WITH dedup"#);
9812        let sources = assemble_sources(&q);
9813        assert_eq!(sources[0].with_options.len(), 2);
9814        // Inside-paren first (rerank), then outside-paren (dedup).
9815        assert!(matches!(
9816            sources[0].with_options[0],
9817            WithOption::Rerank { .. }
9818        ));
9819        assert!(matches!(
9820            sources[0].with_options[1],
9821            WithOption::Dedup { .. }
9822        ));
9823    }
9824
9825    /// Inside-paren WITH on a set-op source: the UNION tail is consumed before
9826    /// the WITH.
9827    #[test]
9828    fn test_assemble_per_source_with_on_set_op_source() {
9829        let q =
9830            p(r#"ASSEMBLE "ctx" FROM combined: (RECALL facts UNION RECALL events WITH rerank)"#);
9831        let sources = assemble_sources(&q);
9832        assert_eq!(sources.len(), 1);
9833        assert!(
9834            matches!(*sources[0].query, CalStatement::SetOp(_)),
9835            "set-op tail should be consumed into the query, got {:?}",
9836            sources[0].query
9837        );
9838        assert!(matches!(
9839            sources[0].with_options.as_slice(),
9840            [WithOption::Rerank { .. }]
9841        ));
9842    }
9843
9844    /// Unknown option inside parens warns (CAL-W004) and is skipped, matching
9845    /// the outside-paren behavior.
9846    #[test]
9847    fn test_assemble_per_source_with_unknown_option_warns() {
9848        let q = p(r#"ASSEMBLE "ctx" FROM k: (RECALL facts LIMIT 10 WITH bogus_option)"#);
9849        let sources = assemble_sources(&q);
9850        assert!(
9851            sources[0].with_options.is_empty(),
9852            "unknown option must be skipped, not pushed"
9853        );
9854        assert!(
9855            q.warnings.iter().any(|w| w.code() == "CAL-W004"),
9856            "expected CAL-W004 warning, got {:?}",
9857            q.warnings
9858        );
9859    }
9860
9861    /// The ticket_context example from issue #609 parses without error, with
9862    /// each per-source WITH landing on its own source. Bound params (`$user`,
9863    /// `$session`) and `status IS OPEN` are shown in their runtime-resolved /
9864    /// parseable form (`ABOUT` + `WHERE` take string literals in the current
9865    /// grammar — unrelated to the #609 per-source-WITH change); the per-source
9866    /// WITH placement under test is preserved verbatim.
9867    #[test]
9868    fn test_assemble_issue_609_ticket_context_example() {
9869        let q = p(r#"ASSEMBLE "ticket_context"
9870  FROM
9871    task:      (RECALL goals ABOUT "session-1" WHERE status = "open" LIMIT 1),
9872    messages:  (RECALL events ABOUT "alice" RECENT 5 WITH annotate_relative_time),
9873    knowledge: (RECALL facts WHERE tags INCLUDE ["product","support"] LIMIT 10 WITH rerank),
9874    user:      (RECALL facts ABOUT "alice" WHERE relation IS PREFERENCE WITH conflict_resolution),
9875    history:   (RECALL tools ABOUT "alice" RECENT 5 WITH dedup)
9876  BUDGET 2500 tokens
9877  WITH provenance
9878  FORMAT TEMPLATE "ticket_brief""#);
9879        let sources = assemble_sources(&q);
9880        assert_eq!(sources.len(), 5);
9881        // task: no per-source WITH.
9882        assert_eq!(sources[0].label, "task");
9883        assert!(sources[0].with_options.is_empty());
9884        // messages: annotate_relative_time.
9885        assert_eq!(sources[1].label, "messages");
9886        assert!(matches!(
9887            sources[1].with_options.as_slice(),
9888            [WithOption::AnnotateRelativeTime]
9889        ));
9890        // knowledge: rerank.
9891        assert_eq!(sources[2].label, "knowledge");
9892        assert!(matches!(
9893            sources[2].with_options.as_slice(),
9894            [WithOption::Rerank { .. }]
9895        ));
9896        // user: conflict_resolution.
9897        assert_eq!(sources[3].label, "user");
9898        assert!(matches!(
9899            sources[3].with_options.as_slice(),
9900            [WithOption::ConflictResolution]
9901        ));
9902        // history: dedup.
9903        assert_eq!(sources[4].label, "history");
9904        assert!(matches!(
9905            sources[4].with_options.as_slice(),
9906            [WithOption::Dedup { .. }]
9907        ));
9908        // Top-level WITH provenance still attaches to the query.
9909        assert!(
9910            q.with_options
9911                .iter()
9912                .any(|w| matches!(w, WithOption::Provenance)),
9913            "top-level provenance should be in query with_options: {:?}",
9914            q.with_options
9915        );
9916    }
9917
9918    // ── 44. ASSEMBLE with BUDGET ────────────────────────────────────────
9919
9920    #[test]
9921    fn test_assemble_budget() {
9922        let q = p(r#"ASSEMBLE "summary" FROM (RECALL facts RECENT 10) BUDGET 2000"#);
9923        match &q.statement {
9924            CalStatement::Assemble(a) => {
9925                assert_eq!(a.topic, "summary");
9926                let budget = a.budget.as_ref().expect("should have budget");
9927                assert_eq!(budget.tokens, 2000);
9928            }
9929            other => panic!("expected Assemble, got {:?}", other),
9930        }
9931    }
9932
9933    // ── 45. ASSEMBLE with PRIORITY ──────────────────────────────────────
9934
9935    #[test]
9936    fn test_assemble_priority() {
9937        let q = p(
9938            r#"ASSEMBLE "ctx" FROM recent: (RECALL facts RECENT 5), bg: (RECALL events RECENT 10) PRIORITY recent: 0.8, bg: 0.2"#,
9939        );
9940        match &q.statement {
9941            CalStatement::Assemble(a) => {
9942                let priority = a.priority.as_ref().expect("should have priority");
9943                assert_eq!(priority.len(), 2);
9944                assert_eq!(priority[0].label, "recent");
9945                assert!((priority[0].weight - 0.8).abs() < f64::EPSILON);
9946                assert_eq!(priority[1].label, "bg");
9947                assert!((priority[1].weight - 0.2).abs() < f64::EPSILON);
9948            }
9949            other => panic!("expected Assemble, got {:?}", other),
9950        }
9951    }
9952
9953    // ── 46. ASSEMBLE with FORMAT ────────────────────────────────────────
9954
9955    #[test]
9956    fn test_assemble_format() {
9957        let q = p(r#"ASSEMBLE "summary" FROM (RECALL facts RECENT 10) FORMAT markdown"#);
9958        match &q.statement {
9959            CalStatement::Assemble(a) => {
9960                assert_eq!(a.format, Some(FormatClause::Single(FormatSpec::Markdown)));
9961            }
9962            other => panic!("expected Assemble, got {:?}", other),
9963        }
9964    }
9965
9966    // ── 47. ASSEMBLE with WITH options ──────────────────────────────────
9967
9968    #[test]
9969    fn test_assemble_with_options() {
9970        let q = p(r#"ASSEMBLE "summary" FROM (RECALL facts RECENT 10) WITH dedup(subject)"#);
9971        match &q.statement {
9972            CalStatement::Assemble(a) => {
9973                assert_eq!(a.assemble_with.len(), 1);
9974                let AssembleWithOption::Dedup { field } = &a.assemble_with[0];
9975                assert_eq!(field.as_deref(), Some("subject"));
9976            }
9977            other => panic!("expected Assemble, got {:?}", other),
9978        }
9979    }
9980
9981    // ── 48. ASSEMBLE with dedup (no field) ──────────────────────────────
9982
9983    #[test]
9984    fn test_assemble_with_dedup_no_field() {
9985        let q = p(r#"ASSEMBLE "summary" FROM (RECALL facts RECENT 10) WITH dedup"#);
9986        match &q.statement {
9987            CalStatement::Assemble(a) => {
9988                assert_eq!(a.assemble_with.len(), 1);
9989                let AssembleWithOption::Dedup { field } = &a.assemble_with[0];
9990                assert_eq!(*field, None);
9991            }
9992            other => panic!("expected Assemble, got {:?}", other),
9993        }
9994    }
9995
9996    // ── 49. HISTORY WHERE (Phase 2 triple-based) ────────────────────────
9997
9998    #[test]
9999    fn test_history_where() {
10000        let q = p(r#"HISTORY WHERE subject = "john" AND relation = "likes""#);
10001        match &q.statement {
10002            CalStatement::History(h) => {
10003                assert!(h.hash.is_empty(), "hash should be empty for WHERE-based");
10004                assert!(h.where_clause.is_some(), "should have where_clause");
10005            }
10006            other => panic!("expected History, got {:?}", other),
10007        }
10008    }
10009
10010    // ── 50. HISTORY with DIFF ───────────────────────────────────────────
10011
10012    #[test]
10013    fn test_history_diff() {
10014        let q = p("HISTORY sha256:abc123def456 DIFF sha256:def789abc012");
10015        match &q.statement {
10016            CalStatement::History(h) => {
10017                assert_eq!(h.hash, "abc123def456");
10018                assert_eq!(h.diff_target.as_deref(), Some("def789abc012"));
10019            }
10020            other => panic!("expected History, got {:?}", other),
10021        }
10022    }
10023
10024    // ── 51. HISTORY WHERE with DIFF ─────────────────────────────────────
10025
10026    #[test]
10027    fn test_history_where_with_diff() {
10028        let q =
10029            p(r#"HISTORY WHERE subject = "john" AND relation = "likes" DIFF sha256:abc123def456"#);
10030        match &q.statement {
10031            CalStatement::History(h) => {
10032                assert!(h.hash.is_empty());
10033                assert!(h.where_clause.is_some());
10034                assert_eq!(h.diff_target.as_deref(), Some("abc123def456"));
10035            }
10036            other => panic!("expected History, got {:?}", other),
10037        }
10038    }
10039
10040    // ── 52. DESCRIBE CAPABILITIES ───────────────────────────────────────
10041
10042    #[test]
10043    fn test_describe_capabilities() {
10044        let q = p("DESCRIBE capabilities");
10045        match &q.statement {
10046            CalStatement::Describe(d) => {
10047                assert!(matches!(d.target, DescribeTarget::Capabilities));
10048            }
10049            other => panic!("expected Describe, got {:?}", other),
10050        }
10051    }
10052
10053    // ── 53. DESCRIBE SERVER ─────────────────────────────────────────────
10054
10055    #[test]
10056    fn test_describe_server() {
10057        let q = p("DESCRIBE server");
10058        match &q.statement {
10059            CalStatement::Describe(d) => {
10060                assert!(matches!(d.target, DescribeTarget::Server));
10061            }
10062            other => panic!("expected Describe, got {:?}", other),
10063        }
10064    }
10065
10066    // ── 54. DESCRIBE FIELDS ─────────────────────────────────────────────
10067
10068    #[test]
10069    fn test_describe_fields() {
10070        let q = p("DESCRIBE fields");
10071        match &q.statement {
10072            CalStatement::Describe(d) => match &d.target {
10073                DescribeTarget::Fields(gt) => {
10074                    assert!(gt.is_none(), "bare FIELDS should have no grain type");
10075                }
10076                other => panic!("expected Fields, got {:?}", other),
10077            },
10078            other => panic!("expected Describe, got {:?}", other),
10079        }
10080    }
10081
10082    // ── 55. DESCRIBE FIELDS facts ─────────────────────────────────────
10083
10084    #[test]
10085    fn test_describe_fields_facts() {
10086        let q = p("DESCRIBE fields facts");
10087        match &q.statement {
10088            CalStatement::Describe(d) => match &d.target {
10089                DescribeTarget::Fields(gt) => {
10090                    assert_eq!(*gt, Some(GrainTypePlural::Facts));
10091                }
10092                other => panic!("expected Fields, got {:?}", other),
10093            },
10094            other => panic!("expected Describe, got {:?}", other),
10095        }
10096    }
10097
10098    // ── 56. DESCRIBE TEMPLATES ──────────────────────────────────────────
10099
10100    #[test]
10101    fn test_describe_templates() {
10102        let q = p("DESCRIBE templates");
10103        match &q.statement {
10104            CalStatement::Describe(d) => {
10105                assert!(matches!(d.target, DescribeTarget::Templates));
10106            }
10107            other => panic!("expected Describe, got {:?}", other),
10108        }
10109    }
10110
10111    // ── 57. DESCRIBE GRAMMAR ────────────────────────────────────────────
10112
10113    #[test]
10114    fn test_describe_grammar() {
10115        let q = p("DESCRIBE grammar");
10116        match &q.statement {
10117            CalStatement::Describe(d) => {
10118                assert!(matches!(d.target, DescribeTarget::Grammar));
10119            }
10120            other => panic!("expected Describe, got {:?}", other),
10121        }
10122    }
10123
10124    // ── 58. COALESCE with braces (Phase 2) ──────────────────────────────
10125
10126    #[test]
10127    fn test_coalesce_braces() {
10128        let q = p(
10129            r#"COALESCE { RECALL facts WHERE subject = "john" } OR { RECALL facts WHERE subject = "bob" }"#,
10130        );
10131        match &q.statement {
10132            CalStatement::Coalesce(c) => {
10133                assert_eq!(c.branches.len(), 2);
10134                assert!(matches!(c.branches[0].query, CalStatement::Recall(_)));
10135                assert!(matches!(c.branches[1].query, CalStatement::Recall(_)));
10136                assert!(c.else_branch.is_none());
10137            }
10138            other => panic!("expected Coalesce, got {:?}", other),
10139        }
10140    }
10141
10142    // ── 59. COALESCE with braces and ELSE ───────────────────────────────
10143
10144    #[test]
10145    fn test_coalesce_braces_with_else() {
10146        let q = p(
10147            r#"COALESCE { RECALL facts WHERE subject = "john" } OR { RECALL facts WHERE subject = "bob" } ELSE { RECALL facts RECENT 5 }"#,
10148        );
10149        match &q.statement {
10150            CalStatement::Coalesce(c) => {
10151                assert_eq!(c.branches.len(), 2);
10152                assert!(c.else_branch.is_some());
10153                let else_stmt = c.else_branch.as_ref().unwrap();
10154                assert!(matches!(**else_stmt, CalStatement::Recall(_)));
10155            }
10156            other => panic!("expected Coalesce, got {:?}", other),
10157        }
10158    }
10159
10160    // ── 60. COALESCE Phase 1 form stores branches ───────────────────────
10161
10162    #[test]
10163    fn test_coalesce_phase1_stores_branches() {
10164        let q = p(
10165            r#"COALESCE(RECALL facts WHERE subject = "john", RECALL facts WHERE subject = "bob")"#,
10166        );
10167        match &q.statement {
10168            CalStatement::Coalesce(c) => {
10169                assert_eq!(
10170                    c.branches.len(),
10171                    2,
10172                    "Phase 1 COALESCE should store branches"
10173                );
10174                assert!(matches!(c.branches[0].query, CalStatement::Recall(_)));
10175                assert!(matches!(c.branches[1].query, CalStatement::Recall(_)));
10176                assert!(c.else_branch.is_none());
10177            }
10178            other => panic!("expected Coalesce, got {:?}", other),
10179        }
10180    }
10181
10182    // ── 61. IS CATEGORY condition ───────────────────────────────────────
10183
10184    #[test]
10185    fn test_is_category_preference() {
10186        let q = p("RECALL facts WHERE relation IS PREFERENCE");
10187        match &q.statement {
10188            CalStatement::Recall(r) => {
10189                let cond = &r.where_clause.as_ref().unwrap().condition;
10190                match cond {
10191                    Condition::IsCategory {
10192                        field, category, ..
10193                    } => {
10194                        assert_eq!(field, "relation");
10195                        assert_eq!(category, "preference");
10196                    }
10197                    other => panic!("expected IsCategory, got {:?}", other),
10198                }
10199            }
10200            _ => panic!("expected Recall"),
10201        }
10202    }
10203
10204    // ── 62. IS CATEGORY — knowledge ─────────────────────────────────────
10205
10206    #[test]
10207    fn test_is_category_knowledge() {
10208        let q = p("RECALL facts WHERE relation IS KNOWLEDGE");
10209        match &q.statement {
10210            CalStatement::Recall(r) => {
10211                let cond = &r.where_clause.as_ref().unwrap().condition;
10212                match cond {
10213                    Condition::IsCategory {
10214                        field, category, ..
10215                    } => {
10216                        assert_eq!(field, "relation");
10217                        assert_eq!(category, "knowledge");
10218                    }
10219                    other => panic!("expected IsCategory, got {:?}", other),
10220                }
10221            }
10222            _ => panic!("expected Recall"),
10223        }
10224    }
10225
10226    // ── 63. IS CATEGORY — all 7 categories parse ────────────────────────
10227
10228    #[test]
10229    fn test_is_category_all_variants() {
10230        let categories = [
10231            "PREFERENCE",
10232            "KNOWLEDGE",
10233            "PERMISSION",
10234            "INTERACTION",
10235            "AGENCY",
10236            "LIFECYCLE",
10237            "OBSERVATION",
10238        ];
10239        for cat in &categories {
10240            let input = format!("RECALL facts WHERE relation IS {}", cat);
10241            let result = parse(&input);
10242            assert!(
10243                result.is_ok(),
10244                "IS {} should parse successfully, got: {:?}",
10245                cat,
10246                result.unwrap_err()
10247            );
10248            let q = result.unwrap();
10249            match &q.statement {
10250                CalStatement::Recall(r) => {
10251                    let cond = &r.where_clause.as_ref().unwrap().condition;
10252                    assert!(
10253                        matches!(cond, Condition::IsCategory { .. }),
10254                        "IS {} should produce IsCategory condition, got {:?}",
10255                        cat,
10256                        cond
10257                    );
10258                }
10259                _ => panic!("expected Recall for IS {}", cat),
10260            }
10261        }
10262    }
10263
10264    // ── 64. ASSEMBLE backward compat — Phase 1 single source ────────────
10265
10266    #[test]
10267    fn test_assemble_single_source_still_works() {
10268        // Phase 1 form should still work with Phase 2 code.
10269        let q = p(r#"ASSEMBLE "daily_summary" FROM (RECALL facts RECENT 10)"#);
10270        match &q.statement {
10271            CalStatement::Assemble(a) => {
10272                assert_eq!(a.topic, "daily_summary");
10273                assert!(
10274                    a.sources.is_none(),
10275                    "single source should have no named sources"
10276                );
10277                assert!(a.budget.is_none());
10278                assert!(a.priority.is_none());
10279                assert!(a.format.is_none());
10280                assert!(a.assemble_with.is_empty());
10281                match &a.from {
10282                    Source::Query(_) => {}
10283                    other => panic!("expected Query source, got {:?}", other),
10284                }
10285            }
10286            other => panic!("expected Assemble, got {:?}", other),
10287        }
10288    }
10289
10290    // ── 65. ASSEMBLE full Phase 2 kitchen sink ──────────────────────────
10291
10292    #[test]
10293    fn test_assemble_full_phase2() {
10294        let q = p(
10295            r#"ASSEMBLE "brief" FROM recent: (RECALL facts RECENT 5), bg: (RECALL events RECENT 10) BUDGET 1500 PRIORITY recent: 0.7, bg: 0.3 FORMAT json WITH dedup"#,
10296        );
10297        match &q.statement {
10298            CalStatement::Assemble(a) => {
10299                assert_eq!(a.topic, "brief");
10300                assert_eq!(a.sources.as_ref().unwrap().len(), 2);
10301                assert_eq!(a.budget.as_ref().unwrap().tokens, 1500);
10302                assert_eq!(a.priority.as_ref().unwrap().len(), 2);
10303                assert_eq!(a.format, Some(FormatClause::Single(FormatSpec::Json)));
10304                assert_eq!(a.assemble_with.len(), 1);
10305            }
10306            other => panic!("expected Assemble, got {:?}", other),
10307        }
10308    }
10309
10310    // ── 66. ASSEMBLE bug-fix regression tests ─────────────────────────
10311
10312    // Issue 1: WITH token stealing — `WITH rerank` must not be consumed
10313    // by the ASSEMBLE-specific WITH parser; it should flow to the top-level
10314    // WITH parser so `rerank` ends up in `query.with_options`.
10315    #[test]
10316    fn test_assemble_with_rerank_not_stolen() {
10317        let q = p(r#"ASSEMBLE "ctx" FROM (RECALL facts RECENT 10) WITH rerank FORMAT json"#);
10318        match &q.statement {
10319            CalStatement::Assemble(a) => {
10320                // rerank is NOT an assemble-specific WITH option.
10321                assert!(
10322                    a.assemble_with.is_empty(),
10323                    "rerank should not be in assemble_with"
10324                );
10325            }
10326            other => panic!("expected Assemble, got {:?}", other),
10327        }
10328        // rerank should be in the top-level query with_options.
10329        assert!(
10330            q.with_options
10331                .iter()
10332                .any(|w| matches!(w, WithOption::Rerank { .. })),
10333            "rerank should be in query with_options: {:?}",
10334            q.with_options
10335        );
10336    }
10337
10338    // Issue 1 corollary: WITH dedup should still work for ASSEMBLE-specific
10339    // WITH options.
10340    #[test]
10341    fn test_assemble_with_dedup_still_works() {
10342        let q = p(r#"ASSEMBLE "ctx" FROM (RECALL facts RECENT 10) WITH dedup(subject)"#);
10343        match &q.statement {
10344            CalStatement::Assemble(a) => {
10345                assert_eq!(a.assemble_with.len(), 1);
10346                let AssembleWithOption::Dedup { field } = &a.assemble_with[0];
10347                assert_eq!(field.as_deref(), Some("subject"));
10348            }
10349            other => panic!("expected Assemble, got {:?}", other),
10350        }
10351    }
10352
10353    // Issue 2: CAL-E032 — too many sources.
10354    #[test]
10355    fn test_assemble_too_many_sources() {
10356        let input = r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL facts), c: (RECALL facts), d: (RECALL facts), e: (RECALL facts), f: (RECALL facts), g: (RECALL facts), h: (RECALL facts), i: (RECALL facts)"#;
10357        let err = pe(input);
10358        assert_eq!(err.code(), "CAL-E032");
10359    }
10360
10361    // Issue 2: CAL-E034 — duplicate source labels.
10362    #[test]
10363    fn test_assemble_duplicate_labels() {
10364        let input = r#"ASSEMBLE "ctx" FROM a: (RECALL facts), a: (RECALL events)"#;
10365        let err = pe(input);
10366        assert_eq!(err.code(), "CAL-E034");
10367    }
10368
10369    // Issue 2: CAL-E035 — PRIORITY references unknown label.
10370    #[test]
10371    fn test_assemble_priority_unknown_label() {
10372        let input =
10373            r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL events) PRIORITY typo: 0.5"#;
10374        let err = pe(input);
10375        assert_eq!(err.code(), "CAL-E035");
10376    }
10377
10378    // Issue 2: CAL-E033 — BUDGET zero.
10379    #[test]
10380    fn test_assemble_budget_zero() {
10381        let input = r#"ASSEMBLE "ctx" FROM (RECALL facts) BUDGET 0"#;
10382        let err = pe(input);
10383        assert_eq!(err.code(), "CAL-E033");
10384    }
10385
10386    // Issue 2: CAL-E033 — BUDGET exceeds max.
10387    #[test]
10388    fn test_assemble_budget_exceeds_max() {
10389        let input = r#"ASSEMBLE "ctx" FROM (RECALL facts) BUDGET 20000"#;
10390        let err = pe(input);
10391        assert_eq!(err.code(), "CAL-E033");
10392    }
10393
10394    // `context_name = identifier` per spec EBNF: both bare identifier and
10395    // quoted-string topics must parse.
10396    #[test]
10397    fn test_assemble_unquoted_topic_accepted() {
10398        let q = parse(r#"ASSEMBLE user_context FROM (RECALL facts)"#)
10399            .expect("bare identifier topic must parse");
10400        match q.statement {
10401            CalStatement::Assemble(a) => {
10402                assert_eq!(a.context_name.as_deref(), Some("user_context"));
10403            }
10404            other => panic!("expected Assemble, got {:?}", other),
10405        }
10406        // Quoted form must continue to work too.
10407        let q2 = parse(r#"ASSEMBLE "quoted ctx" FROM (RECALL facts)"#)
10408            .expect("quoted topic must still parse");
10409        match q2.statement {
10410            CalStatement::Assemble(a) => {
10411                assert_eq!(a.context_name.as_deref(), Some("quoted ctx"));
10412            }
10413            other => panic!("expected Assemble, got {:?}", other),
10414        }
10415    }
10416
10417    // Issue 4: PRIORITY weight > 1.0 should fail.
10418    #[test]
10419    fn test_assemble_priority_weight_too_high() {
10420        let input = r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL events) PRIORITY a: 1.5"#;
10421        let err = pe(input);
10422        assert_eq!(err.code(), "CAL-E002");
10423        assert!(err.to_string().contains("0.0 and 1.0") || err.to_string().contains("weight"));
10424    }
10425
10426    // Issue 4: PRIORITY negative weight should fail.
10427    #[test]
10428    fn test_assemble_priority_weight_negative() {
10429        let input = r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL events) PRIORITY a: -0.3"#;
10430        // Negative numbers are parsed as an error by parse_number since
10431        // the lexer does not emit negative literals.
10432        let err = pe(input);
10433        // The exact error may be "unexpected token" because `-` is not a number.
10434        assert!(err.to_string().contains("CAL-E"));
10435    }
10436
10437    // Issue 5: FOR clause stored in for_whom field.
10438    #[test]
10439    fn test_assemble_for_clause_stored() {
10440        let q = p(r#"ASSEMBLE "ctx" FOR "john" FROM (RECALL facts)"#);
10441        match &q.statement {
10442            CalStatement::Assemble(a) => {
10443                assert_eq!(a.for_whom, Some("john".to_string()));
10444            }
10445            other => panic!("expected Assemble, got {:?}", other),
10446        }
10447    }
10448
10449    // Issue 5: FOR clause absent => for_whom is None.
10450    #[test]
10451    fn test_assemble_no_for_clause() {
10452        let q = p(r#"ASSEMBLE "ctx" FROM (RECALL facts)"#);
10453        match &q.statement {
10454            CalStatement::Assemble(a) => {
10455                assert_eq!(a.for_whom, None);
10456            }
10457            other => panic!("expected Assemble, got {:?}", other),
10458        }
10459    }
10460
10461    // Issue 6: WHERE on multi-source ASSEMBLE should fail.
10462    #[test]
10463    fn test_assemble_where_multi_source_rejected() {
10464        let input =
10465            r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL events) WHERE confidence >= 0.8"#;
10466        let err = pe(input);
10467        assert_eq!(err.code(), "CAL-E002");
10468        let suggestion = err.suggestion().unwrap_or("");
10469        assert!(
10470            suggestion.contains("not supported with multi-source"),
10471            "suggestion should explain WHERE is not supported: {}",
10472            suggestion
10473        );
10474    }
10475
10476    // Issue 6: WHERE on single-source ASSEMBLE should still work.
10477    #[test]
10478    fn test_assemble_where_single_source_still_works() {
10479        let q = p(r#"ASSEMBLE "ctx" FROM (RECALL facts) WHERE confidence >= 0.8"#);
10480        match &q.statement {
10481            CalStatement::Assemble(a) => {
10482                assert!(a.where_clause.is_some(), "single-source WHERE should work");
10483                assert!(a.sources.is_none());
10484            }
10485            other => panic!("expected Assemble, got {:?}", other),
10486        }
10487    }
10488
10489    // ── Issue 1: BUDGET unit suffix parsing ────────────────────────────
10490
10491    #[test]
10492    fn test_assemble_budget_with_tokens_unit() {
10493        let q = p(r#"ASSEMBLE "ctx" FROM (RECALL facts) BUDGET 2000 tokens"#);
10494        match &q.statement {
10495            CalStatement::Assemble(a) => {
10496                let budget = a.budget.as_ref().expect("should have budget");
10497                assert_eq!(budget.tokens, 2000);
10498                assert_eq!(budget.unit, BudgetUnit::Tokens);
10499            }
10500            other => panic!("expected Assemble, got {:?}", other),
10501        }
10502    }
10503
10504    #[test]
10505    fn test_assemble_budget_with_grains_unit() {
10506        let q = p(r#"ASSEMBLE "ctx" FROM (RECALL facts) BUDGET 50 grains"#);
10507        match &q.statement {
10508            CalStatement::Assemble(a) => {
10509                let budget = a.budget.as_ref().expect("should have budget");
10510                assert_eq!(budget.tokens, 50);
10511                assert_eq!(budget.unit, BudgetUnit::Grains);
10512            }
10513            other => panic!("expected Assemble, got {:?}", other),
10514        }
10515    }
10516
10517    #[test]
10518    fn test_assemble_budget_no_unit_defaults_to_tokens() {
10519        let q = p(r#"ASSEMBLE "ctx" FROM (RECALL facts) BUDGET 100"#);
10520        match &q.statement {
10521            CalStatement::Assemble(a) => {
10522                let budget = a.budget.as_ref().expect("should have budget");
10523                assert_eq!(budget.tokens, 100);
10524                assert_eq!(budget.unit, BudgetUnit::Tokens);
10525            }
10526            other => panic!("expected Assemble, got {:?}", other),
10527        }
10528    }
10529
10530    // ── Issue 2: PRIORITY ordering syntax ────────────────────────────────
10531
10532    #[test]
10533    fn test_assemble_priority_ordering_syntax() {
10534        let q = p(
10535            r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL events), c: (RECALL goals) PRIORITY a > b > c"#,
10536        );
10537        match &q.statement {
10538            CalStatement::Assemble(a) => {
10539                let priority = a.priority.as_ref().expect("should have priority");
10540                assert_eq!(priority.len(), 3);
10541                assert_eq!(priority[0].label, "a");
10542                assert!((priority[0].weight - 1.0).abs() < f64::EPSILON);
10543                assert_eq!(priority[1].label, "b");
10544                // 2/3 ≈ 0.6667
10545                assert!((priority[1].weight - 2.0 / 3.0).abs() < 0.001);
10546                assert_eq!(priority[2].label, "c");
10547                // 1/3 ≈ 0.3333
10548                assert!((priority[2].weight - 1.0 / 3.0).abs() < 0.001);
10549            }
10550            other => panic!("expected Assemble, got {:?}", other),
10551        }
10552    }
10553
10554    #[test]
10555    fn test_assemble_priority_weighted_syntax_still_works() {
10556        let q = p(
10557            r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL events) PRIORITY a: 0.7, b: 0.3"#,
10558        );
10559        match &q.statement {
10560            CalStatement::Assemble(a) => {
10561                let priority = a.priority.as_ref().expect("should have priority");
10562                assert_eq!(priority.len(), 2);
10563                assert_eq!(priority[0].label, "a");
10564                assert!((priority[0].weight - 0.7).abs() < f64::EPSILON);
10565                assert_eq!(priority[1].label, "b");
10566                assert!((priority[1].weight - 0.3).abs() < f64::EPSILON);
10567            }
10568            other => panic!("expected Assemble, got {:?}", other),
10569        }
10570    }
10571
10572    #[test]
10573    fn test_assemble_priority_ordering_two_labels() {
10574        let q = p(r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL events) PRIORITY a > b"#);
10575        match &q.statement {
10576            CalStatement::Assemble(a) => {
10577                let priority = a.priority.as_ref().expect("should have priority");
10578                assert_eq!(priority.len(), 2);
10579                assert_eq!(priority[0].label, "a");
10580                assert!((priority[0].weight - 1.0).abs() < f64::EPSILON);
10581                assert_eq!(priority[1].label, "b");
10582                assert!((priority[1].weight - 0.5).abs() < f64::EPSILON);
10583            }
10584            other => panic!("expected Assemble, got {:?}", other),
10585        }
10586    }
10587
10588    // PRIORITY with a single label and no BUDGET must parse — the colon-only
10589    // discriminator allows `PRIORITY label FORMAT ...` to fall into the
10590    // ordering branch rather than the weighted branch (which would expect ":").
10591    #[test]
10592    fn test_assemble_priority_single_label_no_budget_parses() {
10593        let q = p(
10594            r#"ASSEMBLE "ctx" FOR "topic" FROM s1: (RECALL facts RECENT 5) PRIORITY s1 FORMAT markdown"#,
10595        );
10596        match &q.statement {
10597            CalStatement::Assemble(a) => {
10598                let priority = a.priority.as_ref().expect("should have priority");
10599                assert_eq!(priority.len(), 1);
10600                assert_eq!(priority[0].label, "s1");
10601                assert!((priority[0].weight - 1.0).abs() < f64::EPSILON);
10602                assert!(a.format.is_some(), "FORMAT clause should also parse");
10603            }
10604            other => panic!("expected Assemble, got {:?}", other),
10605        }
10606    }
10607
10608    // PRIORITY ordering form (`a > b`) followed by FORMAT, with no BUDGET.
10609    #[test]
10610    fn test_assemble_priority_ordering_then_format_no_budget() {
10611        let q = p(
10612            r#"ASSEMBLE "ctx" FROM a: (RECALL facts), b: (RECALL events) PRIORITY a > b FORMAT markdown"#,
10613        );
10614        match &q.statement {
10615            CalStatement::Assemble(a) => {
10616                let priority = a.priority.as_ref().expect("should have priority");
10617                assert_eq!(priority.len(), 2);
10618                assert_eq!(priority[0].label, "a");
10619                assert_eq!(priority[1].label, "b");
10620                assert!(a.format.is_some(), "FORMAT clause should parse");
10621            }
10622            other => panic!("expected Assemble, got {:?}", other),
10623        }
10624    }
10625
10626    // `tags INCLUDE [...]` must desugar to `Condition::In` so the executor
10627    // routes it through the set-condition path (params.tags) instead of
10628    // falling through `Condition::Comparison`'s wildcard arm and emitting a
10629    // bogus CAL-W010 warning.
10630    #[test]
10631    fn test_where_tags_include_desugars_to_in() {
10632        let q = p(r#"RECALL facts WHERE tags INCLUDE ["strategy", "platform"]"#);
10633        match &q.statement {
10634            CalStatement::Recall(r) => {
10635                let cond = &r.where_clause.as_ref().unwrap().condition;
10636                match cond {
10637                    Condition::In { field, values, .. } => {
10638                        assert_eq!(field, "tags");
10639                        assert_eq!(values.len(), 2);
10640                    }
10641                    other => panic!("expected Condition::In, got {:?}", other),
10642                }
10643            }
10644            _ => panic!("expected Recall"),
10645        }
10646    }
10647
10648    // ── Issue 7: BUDGET u64→u32 overflow ─────────────────────────────────
10649
10650    #[test]
10651    fn test_assemble_budget_u32_overflow() {
10652        // 5_000_000_000 > u32::MAX (4_294_967_295), should fail with CAL-E033.
10653        let input = r#"ASSEMBLE "ctx" FROM (RECALL facts) BUDGET 5000000000"#;
10654        let err = pe(input);
10655        assert_eq!(err.code(), "CAL-E033");
10656    }
10657
10658    // ── Multi-format tests (CAL spec v1.0.1, Section 10.1.1) ─────────
10659
10660    #[test]
10661    fn test_format_multi_two_formats() {
10662        let q = p("RECALL facts FORMAT [markdown, json]");
10663        assert_eq!(
10664            q.format,
10665            Some(FormatClause::Multi(vec![
10666                af(FormatSpec::Markdown),
10667                af(FormatSpec::Json)
10668            ]))
10669        );
10670    }
10671
10672    #[test]
10673    fn test_format_multi_single_element_list() {
10674        // FORMAT [json] returns Multi with one element, not Single.
10675        let q = p("RECALL facts FORMAT [json]");
10676        assert_eq!(
10677            q.format,
10678            Some(FormatClause::Multi(vec![af(FormatSpec::Json)]))
10679        );
10680    }
10681
10682    #[test]
10683    fn test_format_multi_all_types() {
10684        let q = p("RECALL facts FORMAT [json, markdown, yaml, text, sml]");
10685        assert_eq!(
10686            q.format,
10687            Some(FormatClause::Multi(vec![
10688                af(FormatSpec::Json),
10689                af(FormatSpec::Markdown),
10690                af(FormatSpec::Yaml),
10691                af(FormatSpec::Text),
10692                af(FormatSpec::Sml),
10693            ]))
10694        );
10695    }
10696
10697    #[test]
10698    fn test_format_multi_deduplicates() {
10699        let q = p("RECALL facts FORMAT [json, json, markdown]");
10700        assert_eq!(
10701            q.format,
10702            Some(FormatClause::Multi(vec![
10703                af(FormatSpec::Json),
10704                af(FormatSpec::Markdown)
10705            ]))
10706        );
10707    }
10708
10709    #[test]
10710    fn test_format_multi_empty_list_errors() {
10711        let result = crate::parser::parse("RECALL facts FORMAT []");
10712        assert!(result.is_err());
10713        let err = result.unwrap_err();
10714        assert!(err.to_string().contains("at least one format type"));
10715    }
10716
10717    #[test]
10718    fn test_format_multi_too_many_errors() {
10719        let result = crate::parser::parse(
10720            "RECALL facts FORMAT [json, markdown, yaml, text, sml, toon]",
10721        );
10722        assert!(result.is_err());
10723        let err = result.unwrap_err();
10724        assert_eq!(err.code(), "CAL-E110");
10725    }
10726
10727    #[test]
10728    fn test_format_single_unchanged() {
10729        // Backward compat: single format still works.
10730        let q = p("RECALL facts FORMAT markdown");
10731        assert_eq!(q.format, Some(FormatClause::Single(FormatSpec::Markdown)));
10732    }
10733
10734    #[test]
10735    fn test_assemble_format_multi() {
10736        let q = p(r#"ASSEMBLE "ctx" FROM (RECALL facts RECENT 5) FORMAT [json, toon]"#);
10737        match &q.statement {
10738            CalStatement::Assemble(a) => {
10739                assert_eq!(
10740                    a.format,
10741                    Some(FormatClause::Multi(vec![
10742                        af(FormatSpec::Json),
10743                        af(FormatSpec::Toon)
10744                    ]))
10745                );
10746            }
10747            other => panic!("expected Assemble, got {:?}", other),
10748        }
10749    }
10750
10751    // ── Format aliases ────────────────────────────────────────────────
10752
10753    #[test]
10754    fn test_format_alias_single_alias() {
10755        let q = p("RECALL facts FORMAT [json AS customers]");
10756        assert_eq!(
10757            q.format,
10758            Some(FormatClause::Multi(vec![af_as(
10759                FormatSpec::Json,
10760                "customers"
10761            )]))
10762        );
10763    }
10764
10765    /// Regression: several natural alias words (DATA, READABLE, COMPACT) are
10766    /// keyword tokens, so an alias parsed as a bare identifier rejected the
10767    /// CAL reference manual's own example. An alias is a label, not an ident.
10768    #[test]
10769    fn test_format_alias_may_be_a_reserved_word() {
10770        let q = p("RECALL facts FORMAT [json AS data, markdown AS readable]");
10771        assert_eq!(
10772            q.format,
10773            Some(FormatClause::Multi(vec![
10774                af_as(FormatSpec::Json, "data"),
10775                af_as(FormatSpec::Markdown, "readable"),
10776            ]))
10777        );
10778        let q = p("RECALL facts FORMAT [sml AS compact, json AS raw]");
10779        assert_eq!(
10780            q.format,
10781            Some(FormatClause::Multi(vec![
10782                af_as(FormatSpec::Sml, "compact"),
10783                af_as(FormatSpec::Json, "raw"),
10784            ]))
10785        );
10786    }
10787
10788    /// Invariant 3: the destructive vocabulary stays out of CAL text
10789    /// everywhere it can appear, including as an output alias.
10790    #[test]
10791    fn test_format_alias_rejects_destructive_words() {
10792        // "grant"/"revoke" left this list in CAL 1.3 — they are DCL
10793        // keywords now, not blocked idents.
10794        for word in ["delete", "erase", "truncate", "insert", "create"] {
10795            let q = format!("RECALL facts FORMAT [json AS {word}]");
10796            parse(&q).expect_err(&format!("{word} must not be usable as an alias"));
10797        }
10798    }
10799
10800    /// A duplicate alias is still an error, reserved word or not.
10801    #[test]
10802    fn test_format_alias_reserved_word_duplicate_still_rejected() {
10803        let e = pe("RECALL facts FORMAT [json AS data, markdown AS data]");
10804        assert_eq!(e.code(), "CAL-E113", "expected duplicate-key error, got {e:?}");
10805    }
10806
10807    // ── RECALL * ──────────────────────────────────────────────────────
10808
10809    /// `RECALL *` is the documented explicit spelling of "any grain type"
10810    /// (CAL reference §4) and must mean the same as omitting the type.
10811    #[test]
10812    fn test_recall_star_is_all_grain_types() {
10813        let starred = p(r#"RECALL * WHERE namespace = "caller""#);
10814        let omitted = p(r#"RECALL WHERE namespace = "caller""#);
10815        match (&starred.statement, &omitted.statement) {
10816            (CalStatement::Recall(a), CalStatement::Recall(b)) => {
10817                assert_eq!(a.grain_type, GrainTypePlural::All);
10818                assert_eq!(a.grain_type, b.grain_type);
10819            }
10820            other => panic!("expected two Recall statements, got {other:?}"),
10821        }
10822    }
10823
10824    #[test]
10825    fn test_recall_star_through_a_pipeline() {
10826        let q = p(r#"RECALL * WHERE namespace = "caller" | COUNT"#);
10827        match &q.statement {
10828            CalStatement::Recall(r) => assert_eq!(r.grain_type, GrainTypePlural::All),
10829            other => panic!("expected Recall, got {other:?}"),
10830        }
10831        assert_eq!(q.pipeline.len(), 1);
10832    }
10833
10834    #[test]
10835    fn test_format_alias_mixed() {
10836        let q = p("RECALL facts FORMAT [json AS customers, markdown]");
10837        assert_eq!(
10838            q.format,
10839            Some(FormatClause::Multi(vec![
10840                af_as(FormatSpec::Json, "customers"),
10841                af(FormatSpec::Markdown),
10842            ]))
10843        );
10844    }
10845
10846    #[test]
10847    fn test_format_alias_template() {
10848        let q = p(
10849            r#"RECALL facts FORMAT [json AS customers, TEMPLATE "{{subject}}: {{object}}" AS summary]"#,
10850        );
10851        assert_eq!(
10852            q.format,
10853            Some(FormatClause::Multi(vec![
10854                af_as(FormatSpec::Json, "customers"),
10855                af_as(
10856                    FormatSpec::Template {
10857                        template: "{{subject}}: {{object}}".into()
10858                    },
10859                    "summary"
10860                ),
10861            ]))
10862        );
10863    }
10864
10865    #[test]
10866    fn test_format_alias_two_templates_different_aliases() {
10867        let q = p(
10868            r#"RECALL facts FORMAT [TEMPLATE "{{subject}}" AS names, TEMPLATE "{{object}}" AS values]"#,
10869        );
10870        assert_eq!(
10871            q.format,
10872            Some(FormatClause::Multi(vec![
10873                af_as(
10874                    FormatSpec::Template {
10875                        template: "{{subject}}".into()
10876                    },
10877                    "names"
10878                ),
10879                af_as(
10880                    FormatSpec::Template {
10881                        template: "{{object}}".into()
10882                    },
10883                    "values"
10884                ),
10885            ]))
10886        );
10887    }
10888
10889    #[test]
10890    fn test_format_alias_duplicate_alias_errors() {
10891        let err = pe("RECALL facts FORMAT [json AS customers, markdown AS customers]");
10892        assert_eq!(err.code(), "CAL-E113");
10893    }
10894
10895    #[test]
10896    fn test_format_alias_not_in_unbracketed() {
10897        // Unbracketed comma-separated multi-format does not support aliases.
10898        // `AS` after a comma-separated format is not consumed as an alias.
10899        let q = p("RECALL facts FORMAT json, markdown");
10900        assert_eq!(
10901            q.format,
10902            Some(FormatClause::Multi(vec![
10903                af(FormatSpec::Json),
10904                af(FormatSpec::Markdown)
10905            ]))
10906        );
10907    }
10908
10909    // ── WITH VARS ───────────────────────────────────────────────────────
10910
10911    #[test]
10912    fn test_with_vars_basic() {
10913        let q = p(r#"RECALL facts WITH VARS { "name": "John", "theme": "dark" }"#);
10914        assert_eq!(q.user_vars.len(), 2);
10915        assert_eq!(q.user_vars.get("name").unwrap(), "John");
10916        assert_eq!(q.user_vars.get("theme").unwrap(), "dark");
10917    }
10918
10919    #[test]
10920    fn test_with_vars_empty() {
10921        let q = p(r#"RECALL facts WITH VARS { }"#);
10922        assert!(q.user_vars.is_empty());
10923    }
10924
10925    #[test]
10926    fn test_with_vars_single() {
10927        let q = p(r#"RECALL facts WITH VARS { "key": "value" }"#);
10928        assert_eq!(q.user_vars.len(), 1);
10929        assert_eq!(q.user_vars.get("key").unwrap(), "value");
10930    }
10931
10932    #[test]
10933    fn test_with_vars_trailing_comma() {
10934        let q = p(r#"RECALL facts WITH VARS { "a": "1", "b": "2", }"#);
10935        assert_eq!(q.user_vars.len(), 2);
10936    }
10937
10938    #[test]
10939    fn test_with_vars_after_format() {
10940        let q = p(r#"RECALL facts FORMAT json WITH VARS { "x": "y" }"#);
10941        assert!(q.format.is_some());
10942        assert_eq!(q.user_vars.get("x").unwrap(), "y");
10943    }
10944
10945    #[test]
10946    fn test_with_vars_after_with_options_and_format() {
10947        let q = p(r#"RECALL facts WITH superseded FORMAT json WITH VARS { "a": "b" }"#);
10948        assert!(q.with_options.contains(&WithOption::Superseded));
10949        assert!(q.format.is_some());
10950        assert_eq!(q.user_vars.get("a").unwrap(), "b");
10951    }
10952
10953    #[test]
10954    fn test_with_vars_without_format() {
10955        let q = p(r#"RECALL facts WITH VARS { "key": "val" }"#);
10956        assert!(q.format.is_none());
10957        assert_eq!(q.user_vars.get("key").unwrap(), "val");
10958    }
10959
10960    #[test]
10961    fn test_with_vars_too_many() {
10962        let input = format!(
10963            r#"RECALL facts WITH VARS {{ {} }}"#,
10964            (0..11)
10965                .map(|i| format!(r#""k{}": "v{}""#, i, i))
10966                .collect::<Vec<_>>()
10967                .join(", ")
10968        );
10969        let err = pe(&input);
10970        assert_eq!(err.code(), "CAL-E111");
10971    }
10972
10973    #[test]
10974    fn test_with_vars_value_too_large() {
10975        let big_val = "x".repeat(1025);
10976        let input = format!(r#"RECALL facts WITH VARS {{ "k": "{}" }}"#, big_val);
10977        let err = pe(&input);
10978        assert_eq!(err.code(), "CAL-E112");
10979    }
10980
10981    #[test]
10982    fn test_with_vars_invalid_key_starts_with_digit() {
10983        let err = pe(r#"RECALL facts WITH VARS { "1bad": "val" }"#);
10984        assert_eq!(err.code(), "CAL-E002"); // InvalidSyntax for invalid key
10985    }
10986
10987    #[test]
10988    fn test_with_vars_coexists_with_pipeline() {
10989        let q = p(r#"RECALL facts | LIMIT 5 WITH VARS { "x": "1" }"#);
10990        assert!(!q.pipeline.is_empty());
10991        assert_eq!(q.user_vars.get("x").unwrap(), "1");
10992    }
10993
10994    #[test]
10995    fn test_with_vars_exactly_at_limit() {
10996        let input = format!(
10997            r#"RECALL facts WITH VARS {{ {} }}"#,
10998            (0..10)
10999                .map(|i| format!(r#""k{}": "v{}""#, i, i))
11000                .collect::<Vec<_>>()
11001                .join(", ")
11002        );
11003        let q = p(&input);
11004        assert_eq!(q.user_vars.len(), 10, "exactly 10 vars should be allowed");
11005    }
11006
11007    #[test]
11008    fn test_with_vars_value_exactly_at_size_limit() {
11009        let val = "x".repeat(1024);
11010        let input = format!(r#"RECALL facts WITH VARS {{ "k": "{}" }}"#, val);
11011        let q = p(&input);
11012        assert_eq!(q.user_vars.get("k").unwrap().len(), 1024);
11013    }
11014
11015    #[test]
11016    fn test_with_vars_underscore_prefixed_key() {
11017        let q = p(r#"RECALL facts WITH VARS { "_private": "value" }"#);
11018        assert_eq!(q.user_vars.get("_private").unwrap(), "value");
11019    }
11020
11021    #[test]
11022    fn test_with_vars_empty_value() {
11023        let q = p(r#"RECALL facts WITH VARS { "key": "" }"#);
11024        assert_eq!(q.user_vars.get("key").unwrap(), "");
11025    }
11026
11027    #[test]
11028    fn test_with_vars_duplicate_key_last_wins() {
11029        let q = p(r#"RECALL facts WITH VARS { "k": "first", "k": "second" }"#);
11030        assert_eq!(q.user_vars.len(), 1);
11031        assert_eq!(q.user_vars.get("k").unwrap(), "second");
11032    }
11033
11034    #[test]
11035    fn test_with_vars_invalid_key_with_hyphen() {
11036        let err = pe(r#"RECALL facts WITH VARS { "my-key": "val" }"#);
11037        assert_eq!(err.code(), "CAL-E002");
11038    }
11039
11040    #[test]
11041    fn test_with_vars_invalid_key_empty_string() {
11042        let err = pe(r#"RECALL facts WITH VARS { "": "val" }"#);
11043        assert_eq!(err.code(), "CAL-E002");
11044    }
11045
11046    // ── ACCUMULATE parser tests ──────────────────────────────────────────
11047
11048    #[test]
11049    fn test_accumulate_tip_resolved_basic() {
11050        let q = p(
11051            r#"ACCUMULATE fact WHERE subject = "john" relation = "score" ADD importance = 0.5 REASON "bump""#,
11052        );
11053        match &q.statement {
11054            CalStatement::Accumulate(acc) => {
11055                assert_eq!(acc.grain_type, GrainTypeSingular::Fact);
11056                match &acc.target {
11057                    AccumulateTarget::TipResolved {
11058                        subject,
11059                        relation,
11060                        namespace,
11061                    } => {
11062                        assert_eq!(subject, "john");
11063                        assert_eq!(relation, "score");
11064                        assert!(namespace.is_none());
11065                    }
11066                    other => panic!("expected TipResolved, got {:?}", other),
11067                }
11068                assert_eq!(acc.add_ops.len(), 1);
11069                assert_eq!(acc.add_ops[0].field, "importance");
11070                assert!((acc.add_ops[0].delta - 0.5).abs() < f64::EPSILON);
11071                assert!(acc.set_ops.is_empty());
11072                assert_eq!(acc.reason, "bump");
11073            }
11074            other => panic!("expected Accumulate, got {:?}", other),
11075        }
11076    }
11077
11078    #[test]
11079    fn test_accumulate_tip_resolved_with_namespace() {
11080        let q = p(
11081            r#"ACCUMULATE fact WHERE subject = "john" relation = "score" namespace = "team1" ADD confidence = 0.1 REASON "ns test""#,
11082        );
11083        match &q.statement {
11084            CalStatement::Accumulate(acc) => match &acc.target {
11085                AccumulateTarget::TipResolved {
11086                    subject,
11087                    relation,
11088                    namespace,
11089                } => {
11090                    assert_eq!(subject, "john");
11091                    assert_eq!(relation, "score");
11092                    assert_eq!(namespace.as_deref(), Some("team1"));
11093                }
11094                other => panic!("expected TipResolved, got {:?}", other),
11095            },
11096            other => panic!("expected Accumulate, got {:?}", other),
11097        }
11098    }
11099
11100    #[test]
11101    fn test_accumulate_hash_targeted() {
11102        let q = p(
11103            r#"ACCUMULATE fact sha256:0000000000000000000000000000000000000000000000000000000000000001 ADD importance = 0.25 REASON "hash test""#,
11104        );
11105        match &q.statement {
11106            CalStatement::Accumulate(acc) => {
11107                assert_eq!(acc.grain_type, GrainTypeSingular::Fact);
11108                match &acc.target {
11109                    AccumulateTarget::Hash { hash } => {
11110                        assert!(hash.contains(
11111                            "0000000000000000000000000000000000000000000000000000000000000001"
11112                        ));
11113                    }
11114                    other => panic!("expected Hash target, got {:?}", other),
11115                }
11116                assert_eq!(acc.add_ops.len(), 1);
11117                assert_eq!(acc.reason, "hash test");
11118            }
11119            other => panic!("expected Accumulate, got {:?}", other),
11120        }
11121    }
11122
11123    #[test]
11124    fn test_accumulate_multiple_add_ops() {
11125        let q = p(
11126            r#"ACCUMULATE fact WHERE subject = "x" relation = "y" ADD importance = 0.1 ADD confidence = 0.2 REASON "multi""#,
11127        );
11128        match &q.statement {
11129            CalStatement::Accumulate(acc) => {
11130                assert_eq!(acc.add_ops.len(), 2);
11131                assert_eq!(acc.add_ops[0].field, "importance");
11132                assert!((acc.add_ops[0].delta - 0.1).abs() < f64::EPSILON);
11133                assert_eq!(acc.add_ops[1].field, "confidence");
11134                assert!((acc.add_ops[1].delta - 0.2).abs() < f64::EPSILON);
11135            }
11136            other => panic!("expected Accumulate, got {:?}", other),
11137        }
11138    }
11139
11140    #[test]
11141    fn test_accumulate_with_set_ops() {
11142        let q = p(
11143            r#"ACCUMULATE fact WHERE subject = "x" relation = "y" ADD importance = 0.1 SET object = "new val" REASON "set test""#,
11144        );
11145        match &q.statement {
11146            CalStatement::Accumulate(acc) => {
11147                assert_eq!(acc.add_ops.len(), 1);
11148                assert_eq!(acc.set_ops.len(), 1);
11149                assert_eq!(acc.set_ops[0].field, "object");
11150                assert_eq!(
11151                    acc.set_ops[0].value,
11152                    Value::String {
11153                        value: "new val".into()
11154                    }
11155                );
11156            }
11157            other => panic!("expected Accumulate, got {:?}", other),
11158        }
11159    }
11160
11161    #[test]
11162    fn test_accumulate_because_alias() {
11163        let q = p(
11164            r#"ACCUMULATE fact WHERE subject = "x" relation = "y" ADD importance = 1 BECAUSE "alias""#,
11165        );
11166        match &q.statement {
11167            CalStatement::Accumulate(acc) => {
11168                assert_eq!(acc.reason, "alias");
11169            }
11170            other => panic!("expected Accumulate, got {:?}", other),
11171        }
11172    }
11173
11174    #[test]
11175    fn test_accumulate_negative_delta() {
11176        let q = p(
11177            r#"ACCUMULATE fact WHERE subject = "x" relation = "y" ADD importance = -0.3 REASON "decay""#,
11178        );
11179        match &q.statement {
11180            CalStatement::Accumulate(acc) => {
11181                assert_eq!(acc.add_ops.len(), 1);
11182                assert!((acc.add_ops[0].delta - (-0.3)).abs() < f64::EPSILON);
11183            }
11184            other => panic!("expected Accumulate, got {:?}", other),
11185        }
11186    }
11187
11188    #[test]
11189    fn test_accumulate_missing_add_ops_error() {
11190        let err = pe(
11191            r#"ACCUMULATE fact WHERE subject = "x" relation = "y" SET foo = "bar" REASON "no adds""#,
11192        );
11193        assert_eq!(err.code(), "CAL-E080");
11194    }
11195
11196    #[test]
11197    fn test_accumulate_missing_reason_error() {
11198        let err = pe(r#"ACCUMULATE fact WHERE subject = "x" relation = "y" ADD importance = 0.1"#);
11199        assert_eq!(err.code(), "CAL-E018");
11200    }
11201
11202    #[test]
11203    fn test_accumulate_missing_target_error() {
11204        let err = pe(r#"ACCUMULATE fact ADD importance = 0.1 REASON "no target""#);
11205        assert_eq!(err.code(), "CAL-E002");
11206    }
11207
11208    #[test]
11209    fn test_accumulate_case_insensitive() {
11210        let q = p(
11211            r#"accumulate fact WHERE subject = "x" relation = "y" ADD importance = 1 REASON "ci""#,
11212        );
11213        assert!(matches!(&q.statement, CalStatement::Accumulate(_)));
11214    }
11215}