Skip to main content

whipplescript_parser/
body.rs

1//! Rule and flow body parsing: a real AST over body text.
2//!
3//! Bodies were historically re-scanned line-by-line at lowering time, which
4//! made whitespace load-bearing and let unknown statement forms slip through
5//! silently. This module is the statement-form gate: every body must parse
6//! into [`BodyAst`], unknown tokens are spanned errors, and lowering consumes
7//! structure instead of strings.
8
9use crate::{parse_expression, Diagnostic, Expr, SourceSpan};
10
11/// Parses short durations: `<integer><unit>` with unit `s`, `m`, `h`, or `d`.
12pub fn parse_short_duration_seconds(value: &str) -> Option<u64> {
13    let unit = value.chars().last()?;
14    let number = value.get(..value.len() - 1)?.parse::<u64>().ok()?;
15    let multiplier = match unit {
16        's' => 1,
17        'm' => 60,
18        'h' => 3600,
19        'd' => 86400,
20        _ => return None,
21    };
22    number.checked_mul(multiplier)
23}
24
25/// Structural ISO-8601 instant check (`YYYY-MM-DDTHH:MM:SS[.fff](Z|±HH:MM)`)
26/// for `time` literals, with calendar-field range validation. Kept
27/// dependency-free: the runtime compares instants via SQLite `strftime`.
28pub fn is_iso8601_instant(value: &str) -> bool {
29    let bytes = value.as_bytes();
30    let digits = |range: std::ops::Range<usize>| {
31        bytes
32            .get(range)
33            .is_some_and(|slice| !slice.is_empty() && slice.iter().all(u8::is_ascii_digit))
34    };
35    let field = |range: std::ops::Range<usize>| -> u32 {
36        value
37            .get(range)
38            .and_then(|text| text.parse().ok())
39            .unwrap_or(u32::MAX)
40    };
41    if !(digits(0..4) && bytes.get(4) == Some(&b'-') && digits(5..7))
42        || bytes.get(7) != Some(&b'-')
43        || !digits(8..10)
44        || bytes.get(10) != Some(&b'T')
45        || !digits(11..13)
46        || bytes.get(13) != Some(&b':')
47        || !digits(14..16)
48        || bytes.get(16) != Some(&b':')
49        || !digits(17..19)
50    {
51        return false;
52    }
53    if !(1..=12).contains(&field(5..7))
54        || !(1..=31).contains(&field(8..10))
55        || field(11..13) > 23
56        || field(14..16) > 59
57        || field(17..19) > 60
58    {
59        return false;
60    }
61    let mut index = 19;
62    if bytes.get(index) == Some(&b'.') {
63        index += 1;
64        let start = index;
65        while bytes.get(index).is_some_and(u8::is_ascii_digit) {
66            index += 1;
67        }
68        if index == start {
69            return false;
70        }
71    }
72    match bytes.get(index) {
73        Some(b'Z') => index + 1 == bytes.len(),
74        Some(b'+') | Some(b'-') => {
75            digits(index + 1..index + 3)
76                && bytes.get(index + 3) == Some(&b':')
77                && digits(index + 4..index + 6)
78                && index + 6 == bytes.len()
79                && field(index + 1..index + 3) <= 23
80                && field(index + 4..index + 6) <= 59
81        }
82        _ => false,
83    }
84}
85
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct BodyAst {
88    pub statements: Vec<BodyStmt>,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub enum BodyStmt {
93    Record(RecordStmt),
94    /// `done x` / `done x -> record ...` — marks a fact terminal, optionally
95    /// replacing it with a record.
96    Done {
97        binding: String,
98        replacement: Option<RecordStmt>,
99        span: SourceSpan,
100    },
101    Effect(EffectStmt),
102    After(AfterBlock),
103    Region(RegionBlock),
104    Case(CaseBlock),
105    Terminal(TerminalStmt),
106    Cancel {
107        binding: String,
108        span: SourceSpan,
109    },
110    /// `emit milestone "<name>" of <PayloadClass> { fields }` (Family C,
111    /// child-milestone lifecycle): a synchronous durable fact the child workflow
112    /// projects mid-flight for an observing parent. It is NOT an async effect —
113    /// it derives a `workflow.milestone:<name>` fact in the child's own base at
114    /// rule-commit time, mirroring `record`. `payload_class` types the parent's
115    /// `after p reaches "<name>" as m` binding. See
116    /// spec/decision-records/discriminated-families-design.md section 7.3.
117    Milestone {
118        name: String,
119        payload_class: Option<String>,
120        fields: Vec<FieldAssign>,
121        span: SourceSpan,
122    },
123    /// `redact <source> keep [<field>, …] as <out>` (DR-0027 redact): an explicit,
124    /// audited PROJECTION of the record bound to `source` onto the kept field set,
125    /// producing a new binding `out`. It is the information-flow crossing the
126    /// rule-level opaque join box is refined at — the projection carries only the
127    /// labels of the KEPT fields (the dropped fields are non-interfering, proven in
128    /// models/lean/Whipple/Redaction.lean: `canRead_redact`). It is NOT an async
129    /// effect: it is a synchronous, pure restructure (like a record projection), so
130    /// it never becomes an `IrEffectKind` — it is rule metadata the IFC checker and
131    /// the runtime projection both read. `out`'s type is the source schema projected
132    /// to the kept fields (`redact.<rule>.<out>`); accessing a dropped field on `out`
133    /// is a type error.
134    Redact {
135        source: String,
136        keep: Vec<String>,
137        binding: String,
138        span: SourceSpan,
139    },
140}
141
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct RecordStmt {
144    pub schema: String,
145    pub from: Option<String>,
146    pub fields: Vec<FieldAssign>,
147    pub span: SourceSpan,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct FieldAssign {
152    pub name: String,
153    pub value: FieldValue,
154    pub span: SourceSpan,
155}
156
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub enum FieldValue {
159    /// Bare field in a `from` block: copy the same-named field.
160    Shorthand,
161    /// An expression, kept with its exact source text for template
162    /// rendering and lowering compatibility.
163    Expr { source: String, expr: Expr },
164    /// Nested typed payload, e.g. invoke input: `phase PhaseReview { ... }`.
165    Nested {
166        schema: String,
167        fields: Vec<FieldAssign>,
168    },
169}
170
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct EffectStmt {
173    pub kind: BodyEffectKind,
174    pub binding: Option<String>,
175    pub requires: Vec<String>,
176    /// `timeout <duration>` in seconds, creation-anchored.
177    pub timeout_seconds: Option<u64>,
178    pub prompt: Option<Prompt>,
179    pub span: SourceSpan,
180}
181
182/// Access grant metadata (`with access to <resource> { <grant clauses> }`) on an
183/// effect. On `tell`, it narrows the turn's effective authority per Proposal A
184/// (spec/agent-harness.md). On `invoke`, it is the explicit start-grant surface for
185/// narrowing the child workflow's authority.
186#[derive(Clone, Debug, Eq, PartialEq)]
187pub struct AccessGrant {
188    pub resource: String,
189    pub operations: Vec<AccessGrantOp>,
190    pub span: SourceSpan,
191}
192
193/// One operation clause inside a turn-access grant block — an operation name with its
194/// optional `for <target>` reference and/or `["glob", …]` path patterns (e.g.
195/// `recall for issue`, `read ["docs/**"]`).
196#[derive(Clone, Debug, Eq, PartialEq)]
197pub struct AccessGrantOp {
198    pub operation: String,
199    pub target: Option<String>,
200    pub globs: Vec<String>,
201    pub span: SourceSpan,
202}
203
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub enum BodyEffectKind {
206    Tell {
207        target: String,
208        access_grants: Vec<AccessGrant>,
209        /// Turn-scoped `with skills [...]` (context-assembly Phase 7): skills pinned
210        /// into this turn's provenance. Does NOT filter the discover-all catalogue.
211        skills: Vec<String>,
212    },
213    Coerce {
214        name: String,
215        args: Vec<String>,
216        /// the `endorsed` source marker (DR-0027 I-IFC3): the author declares this
217        /// coerce is an integrity-raising crossing, making the trusted surface
218        /// visible at the crossing point. Authorization still lives in governance.
219        endorsed: bool,
220        /// the `declassified` source marker (DR-0027 I-IFC3): the author declares
221        /// this coerce a confidentiality-lowering crossing. The coerce's OUTPUT
222        /// SCHEMA is the bounded type that bounds the leak — you cannot declassify
223        /// without passing through a bounded type. Authorization lives in governance.
224        declassified: bool,
225    },
226    /// Bare free-text model prompt: `prompt "<text>" [using <provider>] as x`.
227    /// It lowers through the same model/backend path as `coerce`, but its
228    /// completed value is a plain string.
229    Prompt {
230        provider: Option<String>,
231    },
232    /// Inline anonymous coercion: `decide "<prompt>" -> { field type, ... } as x`.
233    Decide {
234        result_fields: Vec<(String, String)>,
235    },
236    Call {
237        capability: String,
238        argument: Option<String>,
239    },
240    ConstructCapabilityCall {
241        keyword: String,
242        target_capability: String,
243        fields: Vec<ConstructUseField>,
244    },
245    Invoke {
246        workflow: String,
247        payload: Vec<FieldAssign>,
248        access_grants: Vec<AccessGrant>,
249    },
250    Timer {
251        duration_seconds: u64,
252        duration_source: String,
253        /// Absolute deadline expression (a time literal or a time-typed
254        /// path); `None` for a relative `timer <duration>`.
255        until: Option<String>,
256    },
257    Exec {
258        target: ExecTarget,
259        /// `-> Schema` / `-> each Schema`: deterministic JSON ingestion of
260        /// stdout at the effect-result boundary (spec/json-ingestion.md).
261        parse_target: Option<ExecParse>,
262    },
263    /// Work-queue verbs (`file issue into q { ... }`, `claim x`, `release x`,
264    /// `finish x [{ ... }]`).
265    TrackerFile {
266        queue: String,
267        fields: Vec<FieldAssign>,
268    },
269    TrackerClaim {
270        item: String,
271        /// `ttl <duration>`: the claim-TTL, in seconds. `Some(n)` records a
272        /// timed lease (`expires_at = now + n`) that `ready`/`claim` reclaim
273        /// once past-due; `None` is the untimed backstop lease (T3).
274        ttl_seconds: Option<u64>,
275    },
276    TrackerRelease {
277        item: String,
278    },
279    TrackerFinish {
280        item: String,
281        fields: Vec<FieldAssign>,
282    },
283    /// Coordination verbs (spec/coordination.md): one atomic attempt each,
284    /// with branchable sum-typed outcomes.
285    LeaseAcquire {
286        resource: String,
287        key_expr: String,
288        /// `until ttl`: fire-and-forget; TTL is the sole release.
289        until_ttl: bool,
290        /// `wait <duration>`: bounded retry on contention. `Some(seconds)` retries
291        /// the acquire until it is `held` or the wait elapses (then `contended`);
292        /// `None` reports `contended` on the first attempt.
293        wait_seconds: Option<u64>,
294    },
295    /// `renew <acquire-binding> [until <ttl>] as <b>`: extend a held lease's
296    /// TTL before it expires (spec/coordination.md). Names the acquire's `as`
297    /// binding and works on the same lease; `Renewed`/`NotHeld` outcomes.
298    LeaseRenew {
299        /// The `as` binding of the `acquire` this renew extends.
300        acquire_binding: String,
301        /// `until <duration>`: the new TTL in seconds. `None` reuses the
302        /// acquire's declared TTL.
303        ttl_seconds: Option<u64>,
304    },
305    LedgerAppend {
306        ledger: String,
307        schema: String,
308        fields: Vec<FieldAssign>,
309    },
310    CounterConsume {
311        counter: String,
312        key_expr: String,
313        amount_expr: String,
314    },
315    /// `emit signal <name> to <instance-expr> { payload }`: inject a typed,
316    /// durable event into a known peer instance — directed fire-and-forget
317    /// (spec/event-ingress.md, spec/coordination.md messaging).
318    Notify {
319        target_expr: String,
320        event: String,
321        /// S6: `emit signal <name> to <target> from <binding> { overrides }` —
322        /// copy the source binding's same-named fields (bounded to the signal's
323        /// declared fields), with the block overriding; mirrors `record … from`.
324        from: Option<String>,
325        fields: Vec<FieldAssign>,
326    },
327    /// `read <format> from <store> at <path> as <binding>` (std.files): a typed
328    /// file read lowering through `typed_effect_call`. v0 paths are literal
329    /// strings.
330    FileRead {
331        format: String,
332        store: String,
333        path: String,
334    },
335    /// `write <format> to <store> at <path> { body <expr> mode <mode> } as
336    /// <binding>` (std.files): a typed file write lowering through
337    /// `typed_effect_call`. v0 formats are `text`/`markdown` body codecs; the
338    /// `mode` (create/replace/upsert/append) is required (no silent overwrite),
339    /// and `body` is an expression resolved at effect-input time.
340    FileWrite {
341        format: String,
342        store: String,
343        path: String,
344        body: String,
345        mode: String,
346    },
347    /// `import <format> <Schema> from <store> at <path> as <binding>`
348    /// (std.files): decode a structured file into typed `<Schema>` facts (one per
349    /// row) via the platform fact-batch admission primitive. v0 formats are
350    /// `jsonl`/`json`/`csv`.
351    FileImport {
352        format: String,
353        schema: String,
354        store: String,
355        path: String,
356    },
357    /// `export <format> <Schema> to <store> at <path> { [where <pred>] mode
358    /// <mode> } as <binding>` (std.files): serialize the collection of `<Schema>`
359    /// facts (optionally filtered by `where`, per DR-0022 collection-valued
360    /// projections) to a structured file. v0 formats are `jsonl`/`json`/`csv`;
361    /// `mode` is required (no silent overwrite).
362    FileExport {
363        format: String,
364        schema: String,
365        store: String,
366        path: String,
367        predicate: Option<String>,
368        mode: String,
369    },
370}
371
372#[derive(Clone, Debug, Eq, PartialEq)]
373pub struct ConstructUseField {
374    pub name: String,
375    pub source: String,
376}
377
378// --- DR-0011 `effect_operation` meta-grammar (compiled-in table) -------------
379//
380// The shipped std package constructs (`recall`, `learn`, `curate`, `send`)
381// share one rule-body shape: `<keyword> [<connective> <slot>]* [{
382// <payload-field>* }]? as <binding>`. Rather than one hand-written parser per
383// keyword, each is described by an `EffectOperationSpec` row and parsed
384// generically by `parse_effect_operation`. The spec types below stay
385// hand-written; the table const is generated by build.rs from the embedded std
386// manifests' `grammar` objects (std/manifests/*.json — the single source of
387// grammar). See spec/construct-grammar.md, "DR-0011 Two-Shape Meta-Grammar
388// (S6 build)".
389
390/// A slot's value kind: a bare identifier or a value expression.
391#[derive(Clone, Copy, Debug)]
392enum SlotKind {
393    Identifier,
394    Expression,
395}
396
397/// The trailing `as <binding>` policy for an effect operation. Both shipped
398/// constructs require a binding; `Optional`/`None` complete the DR-0011 mode
399/// vocabulary and are enforced by `parse_effect_operation` when a construct
400/// registers them.
401#[derive(Clone, Copy, Debug)]
402#[allow(dead_code)]
403enum BindingMode {
404    Required,
405    Optional,
406    None,
407}
408
409/// One ordered slot: a named value, optionally introduced by a fixed connective
410/// word consumed before it (`recall <pool>` has none; `send via <channel>` uses
411/// `via`). Connectives are drawn from {`from`, `for`, `into`, `to`, `via`}.
412#[derive(Clone, Copy, Debug)]
413struct EffectSlotSpec {
414    name: &'static str,
415    kind: SlotKind,
416    connective: Option<&'static str>,
417}
418
419/// One field inside the optional `{ ... }` payload block: a named expression,
420/// required or not.
421#[derive(Clone, Copy, Debug)]
422struct PayloadFieldSpec {
423    name: &'static str,
424    required: bool,
425}
426
427/// The full grammar of one `effect_operation` construct.
428#[derive(Clone, Copy, Debug)]
429struct EffectOperationSpec {
430    keyword: &'static str,
431    slots: &'static [EffectSlotSpec],
432    payload: Option<&'static [PayloadFieldSpec]>,
433    binding: BindingMode,
434    target_capability: &'static str,
435}
436
437// The table itself is generated at build time from the canonical embedded std
438// manifests (std/manifests/*.json) by build.rs: each construct's DR-0011
439// `grammar` object transcribes into one `EffectOperationSpec` row, so the
440// manifests are the single source of parse grammar and the table can never
441// drift from them.
442include!(concat!(env!("OUT_DIR"), "/effect_operation_grammar.rs"));
443
444/// Look up the `effect_operation` grammar for a leading rule-body keyword.
445fn effect_operation_spec(keyword: &str) -> Option<&'static EffectOperationSpec> {
446    EFFECT_OPERATION_GRAMMAR
447        .iter()
448        .find(|spec| spec.keyword == keyword)
449}
450
451#[derive(Clone, Debug, Eq, PartialEq)]
452pub enum ExecTarget {
453    RawCommand(String),
454    Capability { name: String, stdin_binding: String },
455}
456
457/// The `->` ingestion contract on an `exec`: stdout must parse as `schema`
458/// (one object) or, with `each`, as a JSONL/array stream of `schema`.
459#[derive(Clone, Debug, Eq, PartialEq)]
460pub struct ExecParse {
461    pub schema: String,
462    pub each: bool,
463}
464
465#[derive(Clone, Debug, Eq, PartialEq)]
466pub struct Prompt {
467    pub text: String,
468    pub content_type: Option<String>,
469}
470
471/// DR-0043 Decision 5: a `during <cond> { … } on lapse [as x] { … }` region
472/// (`until <cond>` is the negated polarity). The region's steps commit only
473/// while the condition holds — checked atomically inside each advancing
474/// commit — and the first advancing commit under a broken condition commits
475/// the lapse arm instead, exactly once. Statements after the region are the
476/// point of no return.
477#[derive(Clone, Debug, Eq, PartialEq)]
478pub struct RegionBlock {
479    /// `until` negates: the region runs while the condition is FALSE and
480    /// lapses when it becomes true.
481    pub until: bool,
482    /// The condition's raw expression text (guard grammar; pure queries).
483    pub condition: String,
484    pub body: Vec<BodyStmt>,
485    /// `on lapse as <binding>`: the synthesized optional progress view.
486    pub lapse_binding: Option<String>,
487    pub lapse_body: Vec<BodyStmt>,
488    /// Source extent of the region BODY content (inside its braces), for the
489    /// compile-path variant splices.
490    pub body_span: SourceSpan,
491    /// Source extent of the lapse-arm content (inside its braces).
492    pub lapse_span: SourceSpan,
493    pub span: SourceSpan,
494}
495
496#[derive(Clone, Debug, Eq, PartialEq)]
497pub struct AfterBlock {
498    pub binding: String,
499    pub predicate: AfterPredicate,
500    pub alias: Option<String>,
501    /// For `after p reaches "<name>" as m`: the child milestone name being
502    /// observed (Family C). `None` for every other predicate. The name lives
503    /// here rather than on `AfterPredicate` so the predicate stays a fieldless
504    /// `Copy` enum (see `AfterPredicate::Reaches`).
505    pub milestone: Option<String>,
506    pub body: Vec<BodyStmt>,
507    pub span: SourceSpan,
508}
509
510impl AfterPredicate {
511    /// The kernel text-scanner's spelling of this predicate (what
512    /// `after <binding> <predicate>` looks like in body text).
513    pub fn kernel_str(&self) -> &'static str {
514        match self {
515            AfterPredicate::Succeeds => "succeeds",
516            AfterPredicate::Fails => "fails",
517            AfterPredicate::Completes => "completes",
518            AfterPredicate::Cancelled => "cancelled",
519            AfterPredicate::TimedOut => "times out",
520            AfterPredicate::Reaches => "reaches",
521            AfterPredicate::Held => "held",
522            AfterPredicate::Contended => "contended",
523            AfterPredicate::Ok => "ok",
524            AfterPredicate::Over => "over",
525        }
526    }
527}
528
529#[derive(Clone, Copy, Debug, Eq, PartialEq)]
530pub enum AfterPredicate {
531    Succeeds,
532    Fails,
533    Completes,
534    /// Terminal statuses from the canonical terminal union
535    /// (spec/expression-kernel.md): the effect reached a non-success terminal
536    /// state. `TimedOut` is spelled `times out`; `Cancelled` is `cancelled`.
537    TimedOut,
538    Cancelled,
539    /// Coordination outcomes (spec/coordination.md): the effect completed
540    /// and its sum-typed value carries the matching `variant`.
541    Held,
542    Contended,
543    Ok,
544    Over,
545    /// `after p reaches "<name>" as m` (Family C, child-milestone lifecycle): the
546    /// invoked child workflow `p` projected the named milestone mid-flight. The
547    /// milestone name is carried on `AfterBlock.milestone`, keeping this variant
548    /// fieldless/`Copy`. See spec/decision-records/discriminated-families-design.md
549    /// section 7.3.
550    Reaches,
551}
552
553impl AfterPredicate {
554    pub fn as_str(&self) -> &'static str {
555        match self {
556            Self::Succeeds => "succeeds",
557            Self::Fails => "fails",
558            Self::Completes => "completes",
559            Self::TimedOut => "times out",
560            Self::Cancelled => "cancelled",
561            Self::Held => "held",
562            Self::Contended => "contended",
563            Self::Ok => "ok",
564            Self::Over => "over",
565            // The milestone name is rendered separately by the serializer
566            // (it lives on `AfterBlock.milestone`), so the bare keyword is
567            // all `as_str` carries here.
568            Self::Reaches => "reaches",
569        }
570    }
571}
572
573#[derive(Clone, Debug, Eq, PartialEq)]
574pub struct CaseBlock {
575    pub scrutinee: String,
576    pub branches: Vec<CaseBranch>,
577    pub span: SourceSpan,
578}
579
580#[derive(Clone, Debug, Eq, PartialEq)]
581pub struct CaseBranch {
582    pub pattern: String,
583    pub binding: Option<String>,
584    pub guard: Option<String>,
585    pub body: Vec<BodyStmt>,
586    pub span: SourceSpan,
587}
588
589#[derive(Clone, Debug, Eq, PartialEq)]
590pub struct TerminalStmt {
591    pub kind: TerminalKind,
592    pub name: String,
593    /// `complete <T> from <binding>`: a bounded-type projection egress — the payload
594    /// is the source binding projected to `T`'s fields (the shorthand copies), the
595    /// dual of `record <T> from <binding>`. `None` for the ordinary explicit-field
596    /// form. Only meaningful for `Complete`.
597    pub from: Option<String>,
598    pub fields: Vec<FieldAssign>,
599    /// A bare scalar payload: `complete result 0.9` / `fail error "reason"`. Set
600    /// when the terminal is written without a `{ … }` block; mutually exclusive
601    /// with `fields` (which is empty) and `from` (a projection needs a block).
602    /// Validated against a scalar (`number`/`string`/`bool`) output/failure
603    /// contract. `None` for the ordinary field-block form.
604    pub scalar: Option<FieldValue>,
605    pub span: SourceSpan,
606}
607
608#[derive(Clone, Copy, Debug, Eq, PartialEq)]
609pub enum TerminalKind {
610    Complete,
611    Fail,
612}
613
614/// A field assignment extracted from a record/payload body without braces.
615/// `value` is `None` for shorthand-copy fields; otherwise it is the exact
616/// source text of the value expression.
617#[derive(Clone, Debug, Eq, PartialEq)]
618pub struct SplitFieldAssignment {
619    pub name: String,
620    pub value: Option<String>,
621}
622
623/// Token-level field splitting for record/terminal/table-row bodies. The
624/// structure comes from tokens, never from line breaks, so single-line and
625/// multi-line blocks behave identically. Shorthand (bare name, `from` blocks
626/// only at the call site) is line-delimited: a name with no same-line value
627/// is shorthand.
628pub fn split_field_assignments(source: &str) -> Vec<SplitFieldAssignment> {
629    let mut diagnostics = Vec::new();
630    let tokens = lex_body(source, 0, &mut diagnostics);
631    let mut parser = BodyParser {
632        source,
633        base: 0,
634        tokens,
635        pos: 0,
636        diagnostics,
637    };
638    let mut assignments = Vec::new();
639    while let Some(token) = parser.peek() {
640        let name_line = token.line;
641        let Tok::Ident(name) = token.tok.clone() else {
642            parser.pos += 1;
643            continue;
644        };
645        parser.pos += 1;
646        let is_shorthand = match parser.peek() {
647            None => true,
648            Some(next) => next.line != name_line,
649        };
650        if is_shorthand {
651            assignments.push(SplitFieldAssignment { name, value: None });
652            continue;
653        }
654        let value_start = parser.pos;
655        if !parser.consume_value_atom() {
656            parser.pos += 1;
657            continue;
658        }
659        loop {
660            match parser.peek().map(|t| t.tok.clone()) {
661                Some(Tok::Op(_)) | Some(Tok::Sym('+')) | Some(Tok::Sym('-'))
662                | Some(Tok::Sym('*')) | Some(Tok::Sym('/')) | Some(Tok::Sym('<'))
663                | Some(Tok::Sym('>')) => {
664                    parser.pos += 1;
665                    if !parser.consume_value_atom() {
666                        break;
667                    }
668                }
669                Some(Tok::Ident(word)) if word == "and" || word == "or" || word == "in" => {
670                    parser.pos += 1;
671                    if !parser.consume_value_atom() {
672                        break;
673                    }
674                }
675                Some(Tok::Sym('[')) => {
676                    parser.consume_balanced('[', ']');
677                }
678                // A brace body after a value atom is a nested payload —
679                // variant construction `Approved { score 0.9 }`
680                // (spec/sum-types.md) — captured whole, not flattened.
681                Some(Tok::Sym('{')) => {
682                    parser.consume_balanced('{', '}');
683                    break;
684                }
685                _ => break,
686            }
687        }
688        let first = &parser.tokens[value_start];
689        let last = &parser.tokens[parser.pos - 1];
690        assignments.push(SplitFieldAssignment {
691            name,
692            value: Some(source[first.start..last.end].to_owned()),
693        });
694    }
695    assignments
696}
697
698// ---------------------------------------------------------------------------
699// Lexer
700// ---------------------------------------------------------------------------
701
702#[derive(Clone, Debug, Eq, PartialEq)]
703enum Tok {
704    Ident(String),
705    Str(String),
706    TripleStr {
707        text: String,
708        content_type: Option<String>,
709    },
710    Number(String),
711    Sym(char),
712    Arrow,    // ->
713    FatArrow, // =>
714    Op(&'static str),
715}
716
717#[derive(Clone, Debug)]
718struct Token {
719    tok: Tok,
720    start: usize,
721    end: usize,
722    line: usize,
723}
724
725fn line_of(source: &str, offset: usize) -> usize {
726    source[..offset].bytes().filter(|b| *b == b'\n').count()
727}
728
729fn lex_body(source: &str, base: usize, diagnostics: &mut Vec<Diagnostic>) -> Vec<Token> {
730    let bytes = source.as_bytes();
731    let mut tokens = Vec::new();
732    let mut i = 0;
733    while i < bytes.len() {
734        let c = bytes[i] as char;
735        if c.is_whitespace() {
736            i += 1;
737            continue;
738        }
739        let start = i;
740        if source[i..].starts_with("\"\"\"") {
741            // Triple-quoted prompt with optional content-type on the opener.
742            let opener_end = source[i + 3..]
743                .find('\n')
744                .map(|offset| i + 3 + offset)
745                .unwrap_or(source.len());
746            let annotation = source[i + 3..opener_end].trim();
747            let content_type = (!annotation.is_empty()).then(|| annotation.to_owned());
748            let Some(close) = source[opener_end..].find("\"\"\"").map(|o| opener_end + o) else {
749                diagnostics.push(Diagnostic {
750                    related: Vec::new(),
751                    span: SourceSpan {
752                        start: base + start,
753                        end: base + source.len(),
754                    },
755                    message: "unterminated multiline string".to_owned(),
756                    suggestion: Some("close the prompt with `\"\"\"`".to_owned()),
757                });
758                break;
759            };
760            let raw = &source[opener_end..close];
761            let text = dedent_prompt(raw);
762            tokens.push(Token {
763                tok: Tok::TripleStr { text, content_type },
764                start,
765                end: close + 3,
766                line: line_of(source, start),
767            });
768            i = close + 3;
769            continue;
770        }
771        if c == '"' {
772            let mut j = i + 1;
773            let mut value = String::new();
774            let mut closed = false;
775            while j < bytes.len() {
776                let cj = bytes[j] as char;
777                if cj == '\\' && j + 1 < bytes.len() {
778                    value.push(bytes[j + 1] as char);
779                    j += 2;
780                    continue;
781                }
782                if cj == '"' {
783                    closed = true;
784                    break;
785                }
786                if cj == '\n' {
787                    break;
788                }
789                value.push(cj);
790                j += 1;
791            }
792            if !closed {
793                diagnostics.push(Diagnostic {
794                    related: Vec::new(),
795                    span: SourceSpan {
796                        start: base + start,
797                        end: base + j,
798                    },
799                    message: "unterminated string".to_owned(),
800                    suggestion: Some("close the string with `\"`".to_owned()),
801                });
802                i = j;
803                continue;
804            }
805            tokens.push(Token {
806                tok: Tok::Str(value),
807                start,
808                end: j + 1,
809                line: line_of(source, start),
810            });
811            i = j + 1;
812            continue;
813        }
814        if c.is_ascii_digit()
815            || (c == '-'
816                && bytes
817                    .get(i + 1)
818                    .is_some_and(|b| (*b as char).is_ascii_digit()))
819        {
820            let mut j = i + 1;
821            while j < bytes.len() {
822                let cj = bytes[j] as char;
823                if cj.is_ascii_alphanumeric() || cj == '.' || cj == '_' {
824                    j += 1;
825                } else {
826                    break;
827                }
828            }
829            tokens.push(Token {
830                tok: Tok::Number(source[i..j].to_owned()),
831                start,
832                end: j,
833                line: line_of(source, start),
834            });
835            i = j;
836            continue;
837        }
838        if c.is_ascii_alphabetic() || c == '_' {
839            let mut j = i + 1;
840            while j < bytes.len() {
841                let cj = bytes[j] as char;
842                if cj.is_ascii_alphanumeric() || cj == '_' || cj == '.' {
843                    j += 1;
844                } else {
845                    break;
846                }
847            }
848            // Trailing dots belong to punctuation, not identifiers.
849            let mut end = j;
850            while end > i && bytes[end - 1] as char == '.' {
851                end -= 1;
852            }
853            tokens.push(Token {
854                tok: Tok::Ident(source[i..end].to_owned()),
855                start,
856                end,
857                line: line_of(source, start),
858            });
859            i = end.max(i + 1);
860            continue;
861        }
862        if source[i..].starts_with("->") {
863            tokens.push(Token {
864                tok: Tok::Arrow,
865                start,
866                end: i + 2,
867                line: line_of(source, i),
868            });
869            i += 2;
870            continue;
871        }
872        if source[i..].starts_with("=>") {
873            tokens.push(Token {
874                tok: Tok::FatArrow,
875                start,
876                end: i + 2,
877                line: line_of(source, i),
878            });
879            i += 2;
880            continue;
881        }
882        let two_char = [
883            ("==", "=="),
884            ("!=", "!="),
885            ("<=", "<="),
886            (">=", ">="),
887            ("&&", "&&"),
888            ("||", "||"),
889        ]
890        .iter()
891        .find(|(text, _)| source[i..].starts_with(text))
892        .map(|(_, op)| *op);
893        if let Some(op) = two_char {
894            tokens.push(Token {
895                tok: Tok::Op(op),
896                start,
897                end: i + 2,
898                line: line_of(source, i),
899            });
900            i += 2;
901            continue;
902        }
903        if c == '#' || (c == '/' && bytes.get(i + 1) == Some(&b'/')) {
904            // Full-line `#` / `//` comments are legal in rule bodies (ruling
905            // 2026-07-21), matching the top-level lexer's two markers: a line
906            // whose first non-whitespace characters open a comment is skipped
907            // to its end. A comment after code on the same line falls through
908            // (trailing comments stay top-level-only; a mid-line `/` is the
909            // division operator).
910            let mut k = i;
911            let mut line_leading = true;
912            while k > 0 {
913                let prev = bytes[k - 1] as char;
914                if prev == '\n' {
915                    break;
916                }
917                if prev != ' ' && prev != '\t' {
918                    line_leading = false;
919                    break;
920                }
921                k -= 1;
922            }
923            if line_leading {
924                while i < bytes.len() && bytes[i] as char != '\n' {
925                    i += 1;
926                }
927                continue;
928            }
929        }
930        match c {
931            '{' | '}' | '[' | ']' | '(' | ')' | ',' | '.' | '+' | '-' | '*' | '/' | '<' | '>'
932            | '!' | ':' | ';' => {
933                tokens.push(Token {
934                    tok: Tok::Sym(c),
935                    start,
936                    end: i + 1,
937                    line: line_of(source, start),
938                });
939                i += 1;
940            }
941            _ => {
942                diagnostics.push(Diagnostic {
943                    related: Vec::new(),
944                    span: SourceSpan {
945                        start: base + i,
946                        end: base + i + 1,
947                    },
948                    message: format!("unexpected character `{c}` in rule body"),
949                    suggestion: None,
950                });
951                i += 1;
952            }
953        }
954    }
955    tokens
956}
957
958/// Blanks full-line `#` comments in rule-body TEXT, byte-preservingly: every
959/// byte of a comment line except its newline becomes a space, so all spans
960/// and offsets downstream still point at the original source. Raw-string
961/// (`"""`) interiors are untouched -- a markdown heading inside a prompt is
962/// content, not a comment. The compile path runs this once per rule body
963/// before action/`then` expansion, so the kernel and every line-based
964/// analysis see comment-free text, while `whip fmt` (which re-emits the raw
965/// body text) preserves the comments.
966pub fn blank_full_line_comments(text: &str) -> String {
967    let mut out: Vec<u8> = Vec::with_capacity(text.len());
968    let mut in_fence = false;
969    for line in text.split_inclusive('\n') {
970        let (content, has_newline) = match line.strip_suffix('\n') {
971            Some(content) => (content, true),
972            None => (line, false),
973        };
974        let lead = content.trim_start();
975        if !in_fence && (lead.starts_with('#') || lead.starts_with("//")) {
976            out.resize(out.len() + content.len(), b' ');
977        } else {
978            out.extend_from_slice(content.as_bytes());
979            if content.matches("\"\"\"").count() % 2 == 1 {
980                in_fence = !in_fence;
981            }
982        }
983        if has_newline {
984            out.push(b'\n');
985        }
986    }
987    String::from_utf8_lossy(&out).into_owned()
988}
989
990fn dedent_prompt(raw: &str) -> String {
991    let lines: Vec<&str> = raw.lines().collect();
992    let indent = lines
993        .iter()
994        .filter(|line| !line.trim().is_empty())
995        .map(|line| line.len() - line.trim_start().len())
996        .min()
997        .unwrap_or(0);
998    let mut text = lines
999        .iter()
1000        .map(|line| {
1001            if line.len() >= indent {
1002                &line[indent..]
1003            } else {
1004                line.trim_start()
1005            }
1006        })
1007        .collect::<Vec<_>>()
1008        .join("\n");
1009    while text.starts_with('\n') {
1010        text.remove(0);
1011    }
1012    while text.ends_with('\n') || text.ends_with(' ') {
1013        text.pop();
1014    }
1015    text
1016}
1017
1018// ---------------------------------------------------------------------------
1019// Parser
1020// ---------------------------------------------------------------------------
1021
1022/// Parses exactly ONE statement from the front of `source` (used by `then`
1023/// expansion to consume the chained effect statement without parsing — and
1024/// spuriously diagnosing — the remainder of the enclosing block). Returns the
1025/// statement and only the diagnostics that single parse produced.
1026pub fn parse_first_statement(source: &str, base: usize) -> (Option<BodyStmt>, Vec<Diagnostic>) {
1027    let mut diagnostics = Vec::new();
1028    let tokens = lex_body(source, base, &mut diagnostics);
1029    let mut parser = BodyParser {
1030        source,
1031        base,
1032        tokens,
1033        pos: 0,
1034        diagnostics,
1035    };
1036    let statement = parser.parse_statement();
1037    (statement, parser.diagnostics)
1038}
1039
1040pub fn parse_rule_body(source: &str, base: usize) -> (BodyAst, Vec<Diagnostic>) {
1041    let mut diagnostics = Vec::new();
1042    let tokens = lex_body(source, base, &mut diagnostics);
1043    let mut parser = BodyParser {
1044        source,
1045        base,
1046        tokens,
1047        pos: 0,
1048        diagnostics,
1049    };
1050    let statements = parser.parse_statements(false);
1051    (BodyAst { statements }, parser.diagnostics)
1052}
1053
1054struct BodyParser<'a> {
1055    source: &'a str,
1056    base: usize,
1057    tokens: Vec<Token>,
1058    pos: usize,
1059    diagnostics: Vec<Diagnostic>,
1060}
1061
1062impl<'a> BodyParser<'a> {
1063    fn peek(&self) -> Option<&Token> {
1064        self.tokens.get(self.pos)
1065    }
1066
1067    fn peek_at(&self, offset: usize) -> Option<&Token> {
1068        self.tokens.get(self.pos + offset)
1069    }
1070
1071    fn advance(&mut self) -> Option<Token> {
1072        let token = self.tokens.get(self.pos).cloned();
1073        if token.is_some() {
1074            self.pos += 1;
1075        }
1076        token
1077    }
1078
1079    fn at_ident(&self, value: &str) -> bool {
1080        matches!(self.peek().map(|t| &t.tok), Some(Tok::Ident(v)) if v == value)
1081    }
1082
1083    fn at_sym(&self, value: char) -> bool {
1084        matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym(v)) if *v == value)
1085    }
1086
1087    fn consume_ident(&mut self, value: &str) -> bool {
1088        if self.at_ident(value) {
1089            self.pos += 1;
1090            true
1091        } else {
1092            false
1093        }
1094    }
1095
1096    fn consume_sym(&mut self, value: char) -> bool {
1097        if self.at_sym(value) {
1098            self.pos += 1;
1099            true
1100        } else {
1101            false
1102        }
1103    }
1104
1105    fn span_here(&self) -> SourceSpan {
1106        match self.peek() {
1107            Some(token) => SourceSpan {
1108                start: self.base + token.start,
1109                end: self.base + token.end,
1110            },
1111            None => SourceSpan {
1112                start: self.base + self.source.len(),
1113                end: self.base + self.source.len(),
1114            },
1115        }
1116    }
1117
1118    fn span_from(&self, start_token: usize) -> SourceSpan {
1119        let start = self
1120            .tokens
1121            .get(start_token)
1122            .map(|t| self.base + t.start)
1123            .unwrap_or(self.base);
1124        let end = self
1125            .tokens
1126            .get(self.pos.saturating_sub(1))
1127            .map(|t| self.base + t.end)
1128            .unwrap_or(start);
1129        SourceSpan { start, end }
1130    }
1131
1132    fn error(&mut self, span: SourceSpan, message: impl Into<String>, suggestion: Option<String>) {
1133        self.diagnostics.push(Diagnostic {
1134            related: Vec::new(),
1135            span,
1136            message: message.into(),
1137            suggestion,
1138        });
1139    }
1140
1141    fn ident_text(&mut self, what: &str) -> Option<String> {
1142        match self.peek().map(|t| t.tok.clone()) {
1143            Some(Tok::Ident(value)) => {
1144                self.pos += 1;
1145                Some(value)
1146            }
1147            _ => {
1148                let span = self.span_here();
1149                self.error(span, format!("expected {what}"), None);
1150                None
1151            }
1152        }
1153    }
1154
1155    /// Skip to a safe resync point after an error: the next statement keyword
1156    /// at the current depth or a closing brace.
1157    fn recover(&mut self) {
1158        let mut depth = 0usize;
1159        while let Some(token) = self.peek() {
1160            match &token.tok {
1161                Tok::Sym('{') => depth += 1,
1162                Tok::Sym('}') if depth == 0 => return,
1163                Tok::Sym('}') => depth -= 1,
1164                Tok::Ident(value)
1165                    if depth == 0
1166                        && STATEMENT_KEYWORDS.contains(&value.as_str())
1167                        && self.pos != 0 =>
1168                {
1169                    return
1170                }
1171                _ => {}
1172            }
1173            self.pos += 1;
1174        }
1175    }
1176
1177    fn parse_statements(&mut self, in_block: bool) -> Vec<BodyStmt> {
1178        let mut statements = Vec::new();
1179        loop {
1180            if self.peek().is_none() {
1181                if in_block {
1182                    let span = self.span_here();
1183                    self.error(
1184                        span,
1185                        "unclosed block in rule body",
1186                        Some("add `}`".to_owned()),
1187                    );
1188                }
1189                return statements;
1190            }
1191            if self.at_sym('}') {
1192                if in_block {
1193                    self.pos += 1;
1194                }
1195                return statements;
1196            }
1197            let before = self.pos;
1198            if let Some(statement) = self.parse_statement() {
1199                statements.push(statement);
1200            }
1201            if self.pos == before {
1202                // No progress: recover to avoid an infinite loop.
1203                self.pos += 1;
1204                self.recover();
1205            }
1206        }
1207    }
1208
1209    fn parse_statement(&mut self) -> Option<BodyStmt> {
1210        let start = self.pos;
1211        let keyword = match self.peek().map(|t| t.tok.clone()) {
1212            Some(Tok::Ident(value)) => value,
1213            _ => {
1214                let span = self.span_here();
1215                let package_verbs = EFFECT_OPERATION_GRAMMAR
1216                    .iter()
1217                    .map(|spec| spec.keyword)
1218                    .collect::<Vec<_>>()
1219                    .join(", ");
1220                self.error(
1221                    span,
1222                    "expected a rule body statement".to_owned(),
1223                    Some(format!(
1224                        "statements start with record, done, consume, during, until, tell, \
1225                         coerce, prompt, decide, call, invoke, read, write, import, export, \
1226                         after, case, complete, fail, timer, cancel, exec, file, claim, \
1227                         release, finish, acquire, renew, append, emit, redact, or a package \
1228                         effect verb ({package_verbs})"
1229                    )),
1230                );
1231                self.recover();
1232                return None;
1233            }
1234        };
1235        // Data-driven `effect_operation` constructs (DR-0011): a leading keyword
1236        // registered in the compiled-in grammar table is parsed generically.
1237        if let Some(spec) = effect_operation_spec(&keyword) {
1238            return self.parse_effect_operation(spec);
1239        }
1240        match keyword.as_str() {
1241            "record" => self.parse_record_statement().map(BodyStmt::Record),
1242            // `consume <counter> for <key> ...` is the counter verb
1243            // (spec/coordination.md). The bare `consume <binding>` alias for
1244            // `done` was removed after its deprecation window (shipped v0.2).
1245            "consume" if self.looks_like_counter_consume() => self.parse_counter_consume(),
1246            "consume" => self.removed_consume_alias(),
1247            "done" => self.parse_done_statement(),
1248            "during" => self.parse_region(false),
1249            "until" => self.parse_region(true),
1250            "tell" => self.parse_tell(),
1251            "coerce" => self.parse_coerce_call(),
1252            "prompt" => self.parse_prompt_effect(),
1253            "decide" => self.parse_decide(),
1254            "call" => self.parse_call(),
1255            "invoke" => self.parse_invoke(),
1256            "read" => self.parse_read(),
1257            "write" => self.parse_write(),
1258            "import" => self.parse_import(),
1259            "export" => self.parse_export(),
1260            "after" => self.parse_after(),
1261            "case" => self.parse_case(),
1262            "complete" | "fail" => self.parse_terminal(),
1263            "timer" => self.parse_timer(),
1264            "cancel" => self.parse_cancel(),
1265            "exec" => self.parse_exec(),
1266            "file" => self.parse_tracker_file(),
1267            "claim" => self.parse_tracker_claim(),
1268            "release" => self.parse_tracker_release(),
1269            "finish" => self.parse_tracker_finish(),
1270            "acquire" => self.parse_lease_acquire(),
1271            "renew" => self.parse_lease_renew(),
1272            "append" => self.parse_ledger_append(),
1273            "emit" => self.parse_emit_signal(),
1274            "redact" => self.parse_redact(),
1275            "when" | "on" => {
1276                let span = self.span_here();
1277                self.error(
1278                    span,
1279                    format!("`{keyword}` blocks are not rule body statements"),
1280                    Some(
1281                        "branch with `case`, guard the rule's `when` clause, or chain \
1282                         effects with `then <binding> <- <effect>`"
1283                            .to_owned(),
1284                    ),
1285                );
1286                self.pos += 1;
1287                self.recover();
1288                None
1289            }
1290            other => {
1291                let span = self.span_here();
1292                self.error(
1293                    span,
1294                    format!("unknown rule body statement `{other}`"),
1295                    Some(
1296                        "statements start with record, done, tell, coerce, claim, \
1297                         release, finish, file, call, recall, invoke, emit, after, case, complete, \
1298                         fail, timer, cancel, decide, prompt, or exec"
1299                            .to_owned(),
1300                    ),
1301                );
1302                self.pos += 1;
1303                self.recover();
1304                None
1305            }
1306        }
1307        .inspect(|_| {
1308            let _ = start;
1309        })
1310    }
1311
1312    // -- record ------------------------------------------------------------
1313
1314    fn parse_record_statement(&mut self) -> Option<RecordStmt> {
1315        let start = self.pos;
1316        self.pos += 1; // record
1317        let schema = self.ident_text("class name after `record`")?;
1318        let from = if self.consume_ident("from") {
1319            Some(self.ident_text("binding name after `from`")?)
1320        } else {
1321            None
1322        };
1323        let fields = self.parse_field_block(from.is_some())?;
1324        Some(RecordStmt {
1325            schema,
1326            from,
1327            fields,
1328            span: self.span_from(start),
1329        })
1330    }
1331
1332    fn parse_done_statement(&mut self) -> Option<BodyStmt> {
1333        let start = self.pos;
1334        self.pos += 1; // `done`
1335        let binding = self.ident_text("fact binding after `done`")?;
1336        let replacement = if matches!(self.peek().map(|t| &t.tok), Some(Tok::Arrow)) {
1337            self.pos += 1;
1338            if !self.consume_ident("record") {
1339                let span = self.span_here();
1340                self.error(span, "expected `record` after `->`", None);
1341                return None;
1342            }
1343            self.pos -= 1; // parse_record_statement expects to consume `record`
1344            Some(self.parse_record_statement()?)
1345        } else {
1346            None
1347        };
1348        Some(BodyStmt::Done {
1349            binding,
1350            replacement,
1351            span: self.span_from(start),
1352        })
1353    }
1354
1355    /// The bare `consume <binding>` alias for `done` was removed after its
1356    /// deprecation window (one release; shipped in v0.2). Emit a clear
1357    /// diagnostic instead of the generic unknown-statement error. The live
1358    /// counter verb `consume <counter> for ...` is dispatched ahead of this by
1359    /// `looks_like_counter_consume`, so only the removed alias reaches here.
1360    fn removed_consume_alias(&mut self) -> Option<BodyStmt> {
1361        let span = self.span_here();
1362        self.error(
1363            span,
1364            "`consume` was removed; use `done`",
1365            Some("replace `consume` with `done`".to_owned()),
1366        );
1367        // Swallow the whole statement (binding and any `-> record { ... }`) so
1368        // the removed alias yields ONE diagnostic, not a cascade from the
1369        // leftover binding being re-scanned as an unknown statement.
1370        self.pos += 1; // past `consume`
1371        self.recover();
1372        None
1373    }
1374
1375    /// Parse `{ field value ... }`. Values are expressions; in `from` blocks a
1376    /// bare field name is shorthand-copy. Single-line and multi-line forms are
1377    /// equivalent: structure comes from tokens, never line breaks.
1378    fn parse_field_block(&mut self, allow_shorthand: bool) -> Option<Vec<FieldAssign>> {
1379        if !self.consume_sym('{') {
1380            let span = self.span_here();
1381            self.error(span, "expected `{` to open a field block", None);
1382            return None;
1383        }
1384        let mut fields = Vec::new();
1385        loop {
1386            if self.consume_sym('}') {
1387                return Some(fields);
1388            }
1389            if self.peek().is_none() {
1390                let span = self.span_here();
1391                self.error(span, "unclosed field block", Some("add `}`".to_owned()));
1392                return Some(fields);
1393            }
1394            let field_start = self.pos;
1395            let Some(name) = self.ident_text("field name") else {
1396                self.recover();
1397                continue;
1398            };
1399            // Nested typed payload: `binding Schema { ... }`.
1400            if matches!(self.peek().map(|t| &t.tok), Some(Tok::Ident(next))
1401                if next.chars().next().is_some_and(char::is_uppercase))
1402                && matches!(self.peek_at(1).map(|t| &t.tok), Some(Tok::Sym('{')))
1403            {
1404                let schema = self.ident_text("payload class name")?;
1405                let nested = self.parse_field_block(false)?;
1406                fields.push(FieldAssign {
1407                    name,
1408                    value: FieldValue::Nested {
1409                        schema,
1410                        fields: nested,
1411                    },
1412                    span: self.span_from(field_start),
1413                });
1414                continue;
1415            }
1416            // `from` blocks support shorthand: a bare field name copies the
1417            // same-named field. Shorthand is line-delimited (the historical
1418            // and documented form): a name is shorthand when the next token
1419            // sits on a different line or closes the block.
1420            if allow_shorthand {
1421                let name_line = self
1422                    .tokens
1423                    .get(field_start)
1424                    .map(|t| t.line)
1425                    .unwrap_or_default();
1426                let is_shorthand = match self.peek() {
1427                    None => true,
1428                    Some(token) => matches!(token.tok, Tok::Sym('}')) || token.line != name_line,
1429                };
1430                if is_shorthand {
1431                    fields.push(FieldAssign {
1432                        name,
1433                        value: FieldValue::Shorthand,
1434                        span: self.span_from(field_start),
1435                    });
1436                    continue;
1437                }
1438            }
1439            let Some((source, expr)) = self.parse_value_expression() else {
1440                self.recover();
1441                continue;
1442            };
1443            fields.push(FieldAssign {
1444                name,
1445                value: FieldValue::Expr { source, expr },
1446                span: self.span_from(field_start),
1447            });
1448        }
1449    }
1450
1451    /// Capture one expression's source slice by walking atoms and operators,
1452    /// then parse it with the shared expression parser.
1453    fn parse_value_expression(&mut self) -> Option<(String, Expr)> {
1454        let start_token = self.pos;
1455        if !self.consume_value_atom() {
1456            let span = self.span_here();
1457            self.error(span, "expected a field value expression", None);
1458            return None;
1459        }
1460        loop {
1461            match self.peek().map(|t| t.tok.clone()) {
1462                Some(Tok::Op(_)) | Some(Tok::Sym('+')) | Some(Tok::Sym('-'))
1463                | Some(Tok::Sym('*')) | Some(Tok::Sym('/')) | Some(Tok::Sym('<'))
1464                | Some(Tok::Sym('>')) => {
1465                    self.pos += 1;
1466                    if !self.consume_value_atom() {
1467                        let span = self.span_here();
1468                        self.error(span, "expected expression after operator", None);
1469                        return None;
1470                    }
1471                }
1472                Some(Tok::Ident(word)) if word == "and" || word == "or" || word == "in" => {
1473                    self.pos += 1;
1474                    if !self.consume_value_atom() {
1475                        let span = self.span_here();
1476                        self.error(span, "expected expression after operator", None);
1477                        return None;
1478                    }
1479                }
1480                Some(Tok::Sym('[')) => {
1481                    // index continuation
1482                    self.consume_balanced('[', ']');
1483                }
1484                _ => break,
1485            }
1486        }
1487        let first = self.tokens.get(start_token)?;
1488        let last = self.tokens.get(self.pos.saturating_sub(1))?;
1489        let source = self.source[first.start..last.end].to_owned();
1490        match parse_expression(&source) {
1491            Ok(expr) => Some((source, expr)),
1492            Err(message) => {
1493                let span = SourceSpan {
1494                    start: self.base + first.start,
1495                    end: self.base + last.end,
1496                };
1497                self.error(
1498                    span,
1499                    format!("invalid field value expression: {message}"),
1500                    None,
1501                );
1502                None
1503            }
1504        }
1505    }
1506
1507    fn consume_value_atom(&mut self) -> bool {
1508        match self.peek().map(|t| t.tok.clone()) {
1509            Some(Tok::Str(_)) | Some(Tok::Number(_)) | Some(Tok::TripleStr { .. }) => {
1510                self.pos += 1;
1511                true
1512            }
1513            Some(Tok::Sym('[')) => self.consume_balanced('[', ']'),
1514            Some(Tok::Sym('{')) => self.consume_balanced('{', '}'),
1515            Some(Tok::Sym('(')) => self.consume_balanced('(', ')'),
1516            Some(Tok::Sym('!')) | Some(Tok::Sym('-')) => {
1517                self.pos += 1;
1518                self.consume_value_atom()
1519            }
1520            Some(Tok::Ident(word)) if word == "not" => {
1521                self.pos += 1;
1522                self.consume_value_atom()
1523            }
1524            Some(Tok::Ident(_)) => {
1525                self.pos += 1;
1526                // call like count(...) / exists(...)
1527                if self.at_sym('(') {
1528                    self.consume_balanced('(', ')');
1529                }
1530                true
1531            }
1532            _ => false,
1533        }
1534    }
1535
1536    fn consume_balanced(&mut self, open: char, close: char) -> bool {
1537        if !self.consume_sym(open) {
1538            return false;
1539        }
1540        let mut depth = 1;
1541        while depth > 0 {
1542            match self.advance().map(|t| t.tok) {
1543                Some(Tok::Sym(c)) if c == open => depth += 1,
1544                Some(Tok::Sym(c)) if c == close => depth -= 1,
1545                Some(_) => {}
1546                None => {
1547                    let span = self.span_here();
1548                    self.error(span, format!("unclosed `{open}`"), None);
1549                    return false;
1550                }
1551            }
1552        }
1553        true
1554    }
1555
1556    // -- effects -----------------------------------------------------------
1557
1558    fn parse_effect_modifiers(
1559        &mut self,
1560        binding: &mut Option<String>,
1561        requires: &mut Vec<String>,
1562        timeout_seconds: &mut Option<u64>,
1563    ) -> bool {
1564        loop {
1565            if self.consume_ident("as") {
1566                match self.ident_text("binding name after `as`") {
1567                    Some(name) => *binding = Some(name),
1568                    None => return false,
1569                }
1570                continue;
1571            }
1572            if self.consume_ident("requires") {
1573                match self.parse_string_array() {
1574                    Some(values) => *requires = values,
1575                    None => return false,
1576                }
1577                continue;
1578            }
1579            if self.consume_ident("timeout") {
1580                let span = self.span_here();
1581                let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
1582                    self.error(
1583                        span,
1584                        "expected a duration after `timeout`".to_owned(),
1585                        Some(
1586                            "use `<n><unit>` with unit s, m, h, or d, e.g. `timeout 10m`"
1587                                .to_owned(),
1588                        ),
1589                    );
1590                    return false;
1591                };
1592                self.pos += 1;
1593                match parse_short_duration_seconds(&value) {
1594                    Some(seconds) if seconds > 0 => *timeout_seconds = Some(seconds),
1595                    _ => {
1596                        self.error(
1597                            span,
1598                            format!("invalid timeout duration `{value}`"),
1599                            Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
1600                        );
1601                        return false;
1602                    }
1603                }
1604                continue;
1605            }
1606            return true;
1607        }
1608    }
1609
1610    fn parse_string_array(&mut self) -> Option<Vec<String>> {
1611        if !self.consume_sym('[') {
1612            let span = self.span_here();
1613            self.error(span, "expected `[` to open a string list", None);
1614            return None;
1615        }
1616        let mut values = Vec::new();
1617        loop {
1618            if self.consume_sym(']') {
1619                return Some(values);
1620            }
1621            match self.advance().map(|t| t.tok) {
1622                Some(Tok::Str(value)) => values.push(value),
1623                Some(Tok::Sym(',')) => {}
1624                other => {
1625                    let span = self.span_here();
1626                    self.error(
1627                        span,
1628                        format!("expected a string in list, found {other:?}"),
1629                        None,
1630                    );
1631                    return None;
1632                }
1633            }
1634        }
1635    }
1636
1637    fn parse_prompt(&mut self) -> Option<Prompt> {
1638        match self.advance().map(|t| t.tok) {
1639            Some(Tok::Str(text)) => Some(Prompt {
1640                text,
1641                content_type: None,
1642            }),
1643            Some(Tok::TripleStr { text, content_type }) => Some(Prompt { text, content_type }),
1644            _ => {
1645                let span = self.span_here();
1646                self.error(span, "expected a prompt string", None);
1647                None
1648            }
1649        }
1650    }
1651
1652    fn parse_tell(&mut self) -> Option<BodyStmt> {
1653        let start = self.pos;
1654        self.pos += 1; // tell
1655        let target = self.ident_text("agent target after `tell`")?;
1656        let mut binding = None;
1657        let mut requires = Vec::new();
1658        let mut timeout_seconds = None;
1659        let mut access_grants = Vec::new();
1660        let mut skills = Vec::new();
1661        // Pre-prompt modifiers may interleave the standard ones (`as`/`requires`/
1662        // `timeout`) with `with access to` grants and `with skills [...]`.
1663        if !self.parse_effect_modifiers_with_access(
1664            &mut binding,
1665            &mut requires,
1666            &mut timeout_seconds,
1667            &mut access_grants,
1668            Some(&mut skills),
1669        ) {
1670            return None;
1671        }
1672        let prompt = self.parse_prompt()?;
1673        if !self.parse_effect_modifiers_with_access(
1674            &mut binding,
1675            &mut requires,
1676            &mut timeout_seconds,
1677            &mut access_grants,
1678            Some(&mut skills),
1679        ) {
1680            return None;
1681        }
1682        Some(BodyStmt::Effect(EffectStmt {
1683            kind: BodyEffectKind::Tell {
1684                target,
1685                access_grants,
1686                skills,
1687            },
1688            binding,
1689            requires,
1690            timeout_seconds,
1691            prompt: Some(prompt),
1692            span: self.span_from(start),
1693        }))
1694    }
1695
1696    /// Parse effect modifiers, interleaving the shared effect modifiers with
1697    /// `with access to` grants until neither matches.
1698    fn parse_effect_modifiers_with_access(
1699        &mut self,
1700        binding: &mut Option<String>,
1701        requires: &mut Vec<String>,
1702        timeout_seconds: &mut Option<u64>,
1703        access_grants: &mut Vec<AccessGrant>,
1704        mut skills: Option<&mut Vec<String>>,
1705    ) -> bool {
1706        loop {
1707            if !self.parse_effect_modifiers(binding, requires, timeout_seconds) {
1708                return false;
1709            }
1710            if self.at_ident("with") {
1711                // Turn-scoped `with skills [...]` (Phase 7) vs `with access to …`.
1712                // `with skills` is only valid where a skills accumulator is offered
1713                // (`tell`); elsewhere it falls through to the access-grant error.
1714                if matches!(self.peek_at(1).map(|t| &t.tok), Some(Tok::Ident(v)) if v == "skills") {
1715                    if let Some(acc) = skills.as_deref_mut() {
1716                        if !self.parse_with_skills(acc) {
1717                            return false;
1718                        }
1719                        continue;
1720                    }
1721                }
1722                if !self.parse_access_grant(access_grants) {
1723                    return false;
1724                }
1725                continue;
1726            }
1727            return true;
1728        }
1729    }
1730
1731    /// Parse `with skills ["a", "b"]` (context-assembly Phase 7): turn-scoped skills
1732    /// pinned into the turn's provenance. Assumes the cursor is at `with`.
1733    fn parse_with_skills(&mut self, skills: &mut Vec<String>) -> bool {
1734        self.pos += 1; // with
1735        self.pos += 1; // skills (peeked by the caller)
1736        if !self.at_sym('[') {
1737            let span = self.span_here();
1738            self.error(
1739                span,
1740                "expected `[\"skill\", …]` after `with skills`".to_owned(),
1741                None,
1742            );
1743            return false;
1744        }
1745        match self.parse_string_array() {
1746            Some(values) => {
1747                skills.extend(values);
1748                true
1749            }
1750            None => false,
1751        }
1752    }
1753
1754    /// Parse `with access to <resource> { <op clauses> }`, or the resource-less
1755    /// shorthand `with access to { <resource> { <op clauses> } ... }`. Each clause is
1756    /// an operation name with an optional `for <target>` ref and/or `["glob", …]`
1757    /// paths. `with context`/`with skills` modifiers are not yet supported and are
1758    /// reported as such.
1759    fn parse_access_grant(&mut self, grants: &mut Vec<AccessGrant>) -> bool {
1760        let start = self.pos;
1761        self.pos += 1; // with
1762        if !self.consume_ident("access") {
1763            let span = self.span_here();
1764            let detail = if self.at_ident("context") || self.at_ident("skills") {
1765                "`with context`/`with skills` turn modifiers are not supported yet"
1766            } else {
1767                "expected `access to <resource> { ... }` after `with`"
1768            };
1769            self.error(span, detail.to_owned(), None);
1770            return false;
1771        }
1772        if !self.consume_ident("to") {
1773            let span = self.span_here();
1774            self.error(span, "expected `to` after `with access`".to_owned(), None);
1775            return false;
1776        }
1777        if self.consume_sym('{') {
1778            let mut resources = 0usize;
1779            loop {
1780                if self.consume_sym('}') {
1781                    break;
1782                }
1783                resources += 1;
1784                let grant_start = self.pos;
1785                let Some(resource) =
1786                    self.ident_text("resource in the access-grant shorthand block")
1787                else {
1788                    return false;
1789                };
1790                if !self.consume_sym('{') {
1791                    let span = self.span_here();
1792                    self.error(
1793                        span,
1794                        "expected `{` to open the resource access-grant block".to_owned(),
1795                        None,
1796                    );
1797                    return false;
1798                }
1799                let Some(operations) = self.parse_access_grant_operations() else {
1800                    return false;
1801                };
1802                grants.push(AccessGrant {
1803                    resource,
1804                    operations,
1805                    span: self.span_from(grant_start),
1806                });
1807            }
1808            if resources == 0 {
1809                let span = self.span_from(start);
1810                self.error(
1811                    span,
1812                    "access-grant shorthand block grants no resources".to_owned(),
1813                    Some(
1814                        "write `with access to <resource> { ... }`, or add resource blocks inside the shorthand"
1815                            .to_owned(),
1816                    ),
1817                );
1818                return false;
1819            }
1820            return true;
1821        }
1822        let Some(resource) = self.ident_text("resource after `with access to`") else {
1823            return false;
1824        };
1825        if !self.consume_sym('{') {
1826            let span = self.span_here();
1827            self.error(
1828                span,
1829                "expected `{` to open the access-grant block".to_owned(),
1830                None,
1831            );
1832            return false;
1833        }
1834        let Some(operations) = self.parse_access_grant_operations() else {
1835            return false;
1836        };
1837        grants.push(AccessGrant {
1838            resource,
1839            operations,
1840            span: self.span_from(start),
1841        });
1842        true
1843    }
1844
1845    fn parse_access_grant_operations(&mut self) -> Option<Vec<AccessGrantOp>> {
1846        let mut operations = Vec::new();
1847        loop {
1848            if self.consume_sym('}') {
1849                return Some(operations);
1850            }
1851            let op_start = self.pos;
1852            let operation = self.ident_text("operation in the access-grant block")?;
1853            let mut target = None;
1854            if self.consume_ident("for") {
1855                target = Some(self.ident_text("target after `for`")?);
1856            }
1857            let mut globs = Vec::new();
1858            if self.at_sym('[') {
1859                globs = self.parse_string_array()?;
1860            }
1861            operations.push(AccessGrantOp {
1862                operation,
1863                target,
1864                globs,
1865                span: self.span_from(op_start),
1866            });
1867        }
1868    }
1869
1870    fn parse_coerce_call(&mut self) -> Option<BodyStmt> {
1871        let start = self.pos;
1872        self.pos += 1; // coerce
1873        let name = self.ident_text("coerce function name")?;
1874        if !self.consume_sym('(') {
1875            let span = self.span_here();
1876            self.error(span, "expected `(` after coerce function name", None);
1877            return None;
1878        }
1879        let mut args = Vec::new();
1880        loop {
1881            if self.consume_sym(')') {
1882                break;
1883            }
1884            if self.peek().is_none() {
1885                let span = self.span_here();
1886                self.error(span, "unclosed coerce argument list", None);
1887                return None;
1888            }
1889            let (source, _) = self.parse_value_expression()?;
1890            args.push(source);
1891            self.consume_sym(',');
1892        }
1893        let mut binding = None;
1894        let mut requires = Vec::new();
1895        let mut timeout_seconds = None;
1896        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
1897            return None;
1898        }
1899        // optional trailing source-crossing markers (I-IFC3); must come last.
1900        let mut endorsed = false;
1901        let mut declassified = false;
1902        loop {
1903            if self.consume_ident("endorsed") {
1904                endorsed = true;
1905            } else if self.consume_ident("declassified") {
1906                declassified = true;
1907            } else {
1908                break;
1909            }
1910        }
1911        Some(BodyStmt::Effect(EffectStmt {
1912            kind: BodyEffectKind::Coerce {
1913                name,
1914                args,
1915                endorsed,
1916                declassified,
1917            },
1918            binding,
1919            requires,
1920            timeout_seconds,
1921            prompt: None,
1922            span: self.span_from(start),
1923        }))
1924    }
1925
1926    fn parse_prompt_effect(&mut self) -> Option<BodyStmt> {
1927        let start = self.pos;
1928        self.pos += 1; // prompt
1929        let prompt = self.parse_prompt()?;
1930        let provider = if self.consume_ident("using") {
1931            Some(self.ident_text("provider after `using`")?)
1932        } else {
1933            None
1934        };
1935        let mut binding = None;
1936        let mut requires = Vec::new();
1937        let mut timeout_seconds = None;
1938        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
1939            return None;
1940        }
1941        if binding.is_none() {
1942            let span = self.span_from(start);
1943            self.error(
1944                span,
1945                "`prompt` requires an `as` binding".to_owned(),
1946                Some("write `prompt \"Summarize this\" as summary`".to_owned()),
1947            );
1948            return None;
1949        }
1950        Some(BodyStmt::Effect(EffectStmt {
1951            kind: BodyEffectKind::Prompt { provider },
1952            binding,
1953            requires,
1954            timeout_seconds,
1955            prompt: Some(prompt),
1956            span: self.span_from(start),
1957        }))
1958    }
1959
1960    fn parse_decide(&mut self) -> Option<BodyStmt> {
1961        let start = self.pos;
1962        self.pos += 1; // decide
1963        let prompt = self.parse_prompt()?;
1964        if !matches!(self.advance().map(|t| t.tok), Some(Tok::Arrow)) {
1965            let span = self.span_here();
1966            self.error(
1967                span,
1968                "expected `->` after the decide prompt".to_owned(),
1969                Some("write `decide \"...\" -> { field type, ... } as name`".to_owned()),
1970            );
1971            return None;
1972        }
1973        if !self.consume_sym('{') {
1974            let span = self.span_here();
1975            self.error(span, "expected `{` to open the decide result shape", None);
1976            return None;
1977        }
1978        let mut result_fields = Vec::new();
1979        loop {
1980            if self.consume_sym('}') {
1981                break;
1982            }
1983            let name = self.ident_text("result field name")?;
1984            let ty = self.ident_text("result field type")?;
1985            result_fields.push((name, ty));
1986            self.consume_sym(',');
1987        }
1988        let mut binding = None;
1989        let mut requires = Vec::new();
1990        let mut timeout_seconds = None;
1991        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
1992            return None;
1993        }
1994        if binding.is_none() {
1995            let span = self.span_from(start);
1996            self.error(
1997                span,
1998                "`decide` requires an `as` binding".to_owned(),
1999                Some(
2000                    "the typed result is only reachable through `after <binding> succeeds`"
2001                        .to_owned(),
2002                ),
2003            );
2004        }
2005        Some(BodyStmt::Effect(EffectStmt {
2006            kind: BodyEffectKind::Decide { result_fields },
2007            binding,
2008            requires,
2009            timeout_seconds,
2010            prompt: Some(prompt),
2011            span: self.span_from(start),
2012        }))
2013    }
2014
2015    fn parse_call(&mut self) -> Option<BodyStmt> {
2016        let start = self.pos;
2017        self.pos += 1; // call
2018        let capability = self.ident_text("package capability after `call`")?;
2019        let argument = if self.consume_ident("for") {
2020            Some(self.ident_text("argument binding after `for`")?)
2021        } else {
2022            None
2023        };
2024        let mut binding = None;
2025        let mut requires = Vec::new();
2026        let mut timeout_seconds = None;
2027        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2028            return None;
2029        }
2030        Some(BodyStmt::Effect(EffectStmt {
2031            kind: BodyEffectKind::Call {
2032                capability,
2033                argument,
2034            },
2035            binding,
2036            requires,
2037            timeout_seconds,
2038            prompt: None,
2039            span: self.span_from(start),
2040        }))
2041    }
2042
2043    /// Parse a data-driven `effect_operation` construct (DR-0011). Reproduces
2044    /// the byte-identical success lowering the hand-written `recall`/`send`
2045    /// parsers emitted: consume the keyword, then each slot (its connective, if
2046    /// any, then its value), then the optional payload block (required/unknown
2047    /// checks, expression-typed, in encounter order), then the effect modifiers,
2048    /// enforcing the binding mode, and build one `ConstructCapabilityCall` whose
2049    /// fields are the slots followed by the payload fields, in order.
2050    fn parse_effect_operation(&mut self, spec: &EffectOperationSpec) -> Option<BodyStmt> {
2051        let start = self.pos;
2052        self.pos += 1; // keyword
2053        let mut fields: Vec<ConstructUseField> = Vec::new();
2054        for slot in spec.slots {
2055            if let Some(connective) = slot.connective {
2056                if !self.consume_ident(connective) {
2057                    let span = self.span_here();
2058                    self.error(
2059                        span,
2060                        format!("expected `{connective}` after `{}`", spec.keyword),
2061                        None,
2062                    );
2063                    return None;
2064                }
2065            }
2066            let source = match slot.kind {
2067                SlotKind::Identifier => self.ident_text(slot.name)?,
2068                SlotKind::Expression => self.parse_value_expression()?.0,
2069            };
2070            fields.push(ConstructUseField {
2071                name: slot.name.to_owned(),
2072                source,
2073            });
2074        }
2075        if let Some(payload) = spec.payload {
2076            let block_fields = self.parse_field_block(false)?;
2077            let mut seen: Vec<&'static str> = Vec::new();
2078            for field in &block_fields {
2079                let Some(field_spec) = payload.iter().find(|f| f.name == field.name) else {
2080                    self.error(
2081                        field.span,
2082                        format!("unknown `{}` block field `{}`", spec.keyword, field.name),
2083                        None,
2084                    );
2085                    return None;
2086                };
2087                let FieldValue::Expr { source, .. } = &field.value else {
2088                    self.error(
2089                        field.span,
2090                        format!(
2091                            "`{}` field `{}` must be an expression",
2092                            spec.keyword, field.name
2093                        ),
2094                        None,
2095                    );
2096                    return None;
2097                };
2098                seen.push(field_spec.name);
2099                fields.push(ConstructUseField {
2100                    name: field.name.clone(),
2101                    source: source.clone(),
2102                });
2103            }
2104            for required in payload.iter().filter(|f| f.required) {
2105                if !seen.contains(&required.name) {
2106                    let span = self.span_from(start);
2107                    self.error(
2108                        span,
2109                        format!("`{}` requires a `{}` field", spec.keyword, required.name),
2110                        None,
2111                    );
2112                    return None;
2113                }
2114            }
2115        }
2116        let mut binding = None;
2117        let mut requires = Vec::new();
2118        let mut timeout_seconds = None;
2119        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2120            return None;
2121        }
2122        match spec.binding {
2123            BindingMode::Required if binding.is_none() => {
2124                let span = self.span_from(start);
2125                self.error(
2126                    span,
2127                    format!("`{}` requires an `as` binding", spec.keyword),
2128                    None,
2129                );
2130                return None;
2131            }
2132            BindingMode::None if binding.is_some() => {
2133                let span = self.span_from(start);
2134                self.error(
2135                    span,
2136                    format!("`{}` does not take an `as` binding", spec.keyword),
2137                    None,
2138                );
2139                return None;
2140            }
2141            _ => {}
2142        }
2143        Some(BodyStmt::Effect(EffectStmt {
2144            kind: BodyEffectKind::ConstructCapabilityCall {
2145                keyword: spec.keyword.to_owned(),
2146                target_capability: spec.target_capability.to_owned(),
2147                fields,
2148            },
2149            binding,
2150            requires,
2151            timeout_seconds,
2152            prompt: None,
2153            span: self.span_from(start),
2154        }))
2155    }
2156
2157    fn parse_read(&mut self) -> Option<BodyStmt> {
2158        let start = self.pos;
2159        self.pos += 1; // read
2160        let usage = "write `read <format> from <store> at <path> as <binding>`".to_owned();
2161        let format = self.ident_text("file format after `read`")?;
2162        // v0 `read` is a body read: `text`/`markdown` decode to a UTF-8 content
2163        // body. Structured codecs (json/jsonl/csv) are typed row/value data —
2164        // that is the `import` surface (fact-batch admission), not `read`; and
2165        // `bytes` (an artifact with a content hash) is a deferred read codec.
2166        // Reject anything else here so `read <format>` is honest rather than
2167        // silently decoding every format as text.
2168        if !matches!(format.as_str(), "text" | "markdown") {
2169            let span = self.span_from(start);
2170            self.error(
2171                span,
2172                format!(
2173                    "`read {format}` is not supported in v0 — `read` decodes only `text` or `markdown` bodies"
2174                ),
2175                Some(
2176                    "use `read text`/`read markdown` for a body, `import <format> <Schema>` for structured rows, or `read text` + `coerce` to interpret structured content".to_owned(),
2177                ),
2178            );
2179            return None;
2180        }
2181        if !self.consume_ident("from") {
2182            let span = self.span_here();
2183            self.error(
2184                span,
2185                "expected `from` after read format".to_owned(),
2186                Some(usage),
2187            );
2188            return None;
2189        }
2190        let store = self.ident_text("file store after `from`")?;
2191        if !self.consume_ident("at") {
2192            let span = self.span_here();
2193            self.error(
2194                span,
2195                "expected `at` after read store".to_owned(),
2196                Some(usage),
2197            );
2198            return None;
2199        }
2200        let (path, _) = self.parse_value_expression()?;
2201        let mut binding = None;
2202        let mut requires = Vec::new();
2203        let mut timeout_seconds = None;
2204        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2205            return None;
2206        }
2207        if binding.is_none() {
2208            let span = self.span_from(start);
2209            self.error(
2210                span,
2211                "`read` requires an `as` binding".to_owned(),
2212                Some(usage),
2213            );
2214            return None;
2215        }
2216        Some(BodyStmt::Effect(EffectStmt {
2217            kind: BodyEffectKind::FileRead {
2218                format,
2219                store,
2220                path,
2221            },
2222            binding,
2223            requires,
2224            timeout_seconds,
2225            prompt: None,
2226            span: self.span_from(start),
2227        }))
2228    }
2229
2230    fn parse_write(&mut self) -> Option<BodyStmt> {
2231        let start = self.pos;
2232        self.pos += 1; // write
2233        let usage =
2234            "write `write <format> to <store> at <path> { body <expr> mode <mode> } as <binding>`"
2235                .to_owned();
2236        let format = self.ident_text("file format after `write`")?;
2237        // v0 `write` renders the `text`/`markdown` body codecs (UTF-8 bodies).
2238        // Rendering typed values as json/csv is `export` (deferred, fact-batch).
2239        if !matches!(format.as_str(), "text" | "markdown") {
2240            let span = self.span_from(start);
2241            self.error(
2242                span,
2243                format!(
2244                    "`write {format}` is not supported in v0 — `write` renders only `text` or `markdown` bodies"
2245                ),
2246                Some(
2247                    "use `write text`/`write markdown` for a body; structured `export <format> <Schema>` is deferred".to_owned(),
2248                ),
2249            );
2250            return None;
2251        }
2252        if !self.consume_ident("to") {
2253            let span = self.span_here();
2254            self.error(
2255                span,
2256                "expected `to` after write format".to_owned(),
2257                Some(usage),
2258            );
2259            return None;
2260        }
2261        let store = self.ident_text("file store after `to`")?;
2262        if !self.consume_ident("at") {
2263            let span = self.span_here();
2264            self.error(
2265                span,
2266                "expected `at` after write store".to_owned(),
2267                Some(usage),
2268            );
2269            return None;
2270        }
2271        let (path, _) = self.parse_value_expression()?;
2272        let fields = self.parse_field_block(false)?;
2273        let mut body = None;
2274        let mut mode = None;
2275        for field in &fields {
2276            match field.name.as_str() {
2277                "body" => {
2278                    if let FieldValue::Expr { source, .. } = &field.value {
2279                        body = Some(source.clone());
2280                    }
2281                }
2282                "mode" => {
2283                    if let FieldValue::Expr { source, .. } = &field.value {
2284                        mode = Some(source.trim().trim_matches('"').to_owned());
2285                    }
2286                }
2287                other => {
2288                    self.error(
2289                        field.span,
2290                        format!(
2291                            "unknown `write` block field `{other}` (expected `body` or `mode`)"
2292                        ),
2293                        Some(usage.clone()),
2294                    );
2295                    return None;
2296                }
2297            }
2298        }
2299        let Some(body) = body else {
2300            let span = self.span_from(start);
2301            self.error(
2302                span,
2303                "`write` requires a `body` field".to_owned(),
2304                Some(usage),
2305            );
2306            return None;
2307        };
2308        // The mode is required: "no silent overwrite" (spec/files.md).
2309        let Some(mode) = mode else {
2310            let span = self.span_from(start);
2311            self.error(
2312                span,
2313                "`write` requires an explicit `mode` (create/replace/upsert/append) — no silent overwrite".to_owned(),
2314                Some(usage),
2315            );
2316            return None;
2317        };
2318        if !matches!(mode.as_str(), "create" | "replace" | "upsert" | "append") {
2319            let span = self.span_from(start);
2320            self.error(
2321                span,
2322                format!("unknown write mode `{mode}` (expected create/replace/upsert/append)"),
2323                Some(usage),
2324            );
2325            return None;
2326        }
2327        let mut binding = None;
2328        let mut requires = Vec::new();
2329        let mut timeout_seconds = None;
2330        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2331            return None;
2332        }
2333        if binding.is_none() {
2334            let span = self.span_from(start);
2335            self.error(
2336                span,
2337                "`write` requires an `as` binding".to_owned(),
2338                Some(usage),
2339            );
2340            return None;
2341        }
2342        Some(BodyStmt::Effect(EffectStmt {
2343            kind: BodyEffectKind::FileWrite {
2344                format,
2345                store,
2346                path,
2347                body,
2348                mode,
2349            },
2350            binding,
2351            requires,
2352            timeout_seconds,
2353            prompt: None,
2354            span: self.span_from(start),
2355        }))
2356    }
2357
2358    fn parse_import(&mut self) -> Option<BodyStmt> {
2359        let start = self.pos;
2360        self.pos += 1; // import
2361        let usage =
2362            "write `import <format> <Schema> from <store> at <path> as <binding>`".to_owned();
2363        let format = self.ident_text("import format after `import`")?;
2364        // v0 `import` decodes the structured row codecs into typed facts.
2365        if !matches!(format.as_str(), "jsonl" | "json" | "csv") {
2366            let span = self.span_from(start);
2367            self.error(
2368                span,
2369                format!(
2370                    "`import {format}` is not supported in v0 — `import` decodes `jsonl`, `json`, or `csv`"
2371                ),
2372                Some(usage),
2373            );
2374            return None;
2375        }
2376        let schema = self.ident_text("row schema after import format")?;
2377        if !self.consume_ident("from") {
2378            let span = self.span_here();
2379            self.error(
2380                span,
2381                "expected `from` after import schema".to_owned(),
2382                Some(usage),
2383            );
2384            return None;
2385        }
2386        let store = self.ident_text("file store after `from`")?;
2387        if !self.consume_ident("at") {
2388            let span = self.span_here();
2389            self.error(
2390                span,
2391                "expected `at` after import store".to_owned(),
2392                Some(usage),
2393            );
2394            return None;
2395        }
2396        let (path, _) = self.parse_value_expression()?;
2397        let mut binding = None;
2398        let mut requires = Vec::new();
2399        let mut timeout_seconds = None;
2400        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2401            return None;
2402        }
2403        if binding.is_none() {
2404            let span = self.span_from(start);
2405            self.error(
2406                span,
2407                "`import` requires an `as` binding".to_owned(),
2408                Some(usage),
2409            );
2410            return None;
2411        }
2412        Some(BodyStmt::Effect(EffectStmt {
2413            kind: BodyEffectKind::FileImport {
2414                format,
2415                schema,
2416                store,
2417                path,
2418            },
2419            binding,
2420            requires,
2421            timeout_seconds,
2422            prompt: None,
2423            span: self.span_from(start),
2424        }))
2425    }
2426
2427    fn parse_export(&mut self) -> Option<BodyStmt> {
2428        let start = self.pos;
2429        self.pos += 1; // export
2430        let usage =
2431            "write `export <format> <Schema> to <store> at <path> { [where <pred>] mode <mode> } as <binding>`"
2432                .to_owned();
2433        let format = self.ident_text("export format after `export`")?;
2434        if !matches!(format.as_str(), "jsonl" | "json" | "csv") {
2435            let span = self.span_from(start);
2436            self.error(
2437                span,
2438                format!(
2439                    "`export {format}` is not supported in v0 — `export` writes `jsonl`, `json`, or `csv`"
2440                ),
2441                Some(usage),
2442            );
2443            return None;
2444        }
2445        let schema = self.ident_text("row schema after export format")?;
2446        if !self.consume_ident("to") {
2447            let span = self.span_here();
2448            self.error(
2449                span,
2450                "expected `to` after export schema".to_owned(),
2451                Some(usage),
2452            );
2453            return None;
2454        }
2455        let store = self.ident_text("file store after `to`")?;
2456        if !self.consume_ident("at") {
2457            let span = self.span_here();
2458            self.error(
2459                span,
2460                "expected `at` after export store".to_owned(),
2461                Some(usage),
2462            );
2463            return None;
2464        }
2465        let (path, _) = self.parse_value_expression()?;
2466        if !self.consume_sym('{') {
2467            let span = self.span_here();
2468            self.error(
2469                span,
2470                "expected `{` to open the export block".to_owned(),
2471                Some(usage),
2472            );
2473            return None;
2474        }
2475        // Block: an optional `where <pred>` collection filter (DR-0022) + a
2476        // required `mode`. The schema's facts are the collection; `where` narrows
2477        // it. `mode` follows the `write` policy (no silent overwrite).
2478        let mut predicate = None;
2479        let mut mode = None;
2480        loop {
2481            if self.consume_sym('}') {
2482                break;
2483            }
2484            if self.peek().is_none() {
2485                let span = self.span_here();
2486                self.error(span, "unclosed export block".to_owned(), Some(usage));
2487                return None;
2488            }
2489            if self.consume_ident("where") {
2490                let (source, _) = self.parse_value_expression()?;
2491                predicate = Some(source);
2492            } else if self.consume_ident("mode") {
2493                let value = self.ident_text("write mode after `mode`")?;
2494                mode = Some(value);
2495            } else {
2496                let span = self.span_here();
2497                self.error(
2498                    span,
2499                    "unknown export block field (expected `where` or `mode`)".to_owned(),
2500                    Some(usage.clone()),
2501                );
2502                self.recover();
2503            }
2504        }
2505        let Some(mode) = mode else {
2506            let span = self.span_from(start);
2507            self.error(
2508                span,
2509                "`export` requires an explicit `mode` (create/replace/upsert/append) — no silent overwrite".to_owned(),
2510                Some(usage),
2511            );
2512            return None;
2513        };
2514        if !matches!(mode.as_str(), "create" | "replace" | "upsert" | "append") {
2515            let span = self.span_from(start);
2516            self.error(
2517                span,
2518                format!("unknown write mode `{mode}` (expected create/replace/upsert/append)"),
2519                Some(usage),
2520            );
2521            return None;
2522        }
2523        let mut binding = None;
2524        let mut requires = Vec::new();
2525        let mut timeout_seconds = None;
2526        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2527            return None;
2528        }
2529        if binding.is_none() {
2530            let span = self.span_from(start);
2531            self.error(
2532                span,
2533                "`export` requires an `as` binding".to_owned(),
2534                Some(usage),
2535            );
2536            return None;
2537        }
2538        Some(BodyStmt::Effect(EffectStmt {
2539            kind: BodyEffectKind::FileExport {
2540                format,
2541                schema,
2542                store,
2543                path,
2544                predicate,
2545                mode,
2546            },
2547            binding,
2548            requires,
2549            timeout_seconds,
2550            prompt: None,
2551            span: self.span_from(start),
2552        }))
2553    }
2554
2555    fn parse_invoke(&mut self) -> Option<BodyStmt> {
2556        let start = self.pos;
2557        self.pos += 1; // invoke
2558        let workflow = self.ident_text("workflow name after `invoke`")?;
2559        let payload = self.parse_field_block(false)?;
2560        let mut binding = None;
2561        let mut requires = Vec::new();
2562        let mut timeout_seconds = None;
2563        let mut access_grants = Vec::new();
2564        if !self.parse_effect_modifiers_with_access(
2565            &mut binding,
2566            &mut requires,
2567            &mut timeout_seconds,
2568            &mut access_grants,
2569            None,
2570        ) {
2571            return None;
2572        }
2573        Some(BodyStmt::Effect(EffectStmt {
2574            kind: BodyEffectKind::Invoke {
2575                workflow,
2576                payload,
2577                access_grants,
2578            },
2579            binding,
2580            requires,
2581            timeout_seconds,
2582            prompt: None,
2583            span: self.span_from(start),
2584        }))
2585    }
2586
2587    fn parse_timer(&mut self) -> Option<BodyStmt> {
2588        let start = self.pos;
2589        self.pos += 1; // timer
2590        let span = self.span_here();
2591        // Absolute deadline: `timer until <time-expr>` (spec/scheduled-time.md).
2592        if matches!(self.peek().map(|t| &t.tok), Some(Tok::Ident(word)) if word == "until") {
2593            self.pos += 1; // until
2594            let until = match self.peek().map(|t| t.tok.clone()) {
2595                Some(Tok::Str(literal)) => {
2596                    self.pos += 1;
2597                    if !is_iso8601_instant(&literal) {
2598                        self.error(
2599                            span,
2600                            format!("invalid time literal `{literal}`"),
2601                            Some(
2602                                "use an ISO-8601 instant such as `\"2026-06-15T09:00:00Z\"`"
2603                                    .to_owned(),
2604                            ),
2605                        );
2606                        return None;
2607                    }
2608                    literal
2609                }
2610                Some(Tok::Ident(path)) => {
2611                    // a time-typed path, possibly dotted
2612                    let mut text = path;
2613                    self.pos += 1;
2614                    while matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym('.'))) {
2615                        self.pos += 1;
2616                        if let Some(Tok::Ident(seg)) = self.peek().map(|t| t.tok.clone()) {
2617                            text.push('.');
2618                            text.push_str(&seg);
2619                            self.pos += 1;
2620                        } else {
2621                            break;
2622                        }
2623                    }
2624                    text
2625                }
2626                _ => {
2627                    self.error(
2628                        span,
2629                        "expected a time literal or path after `timer until`".to_owned(),
2630                        Some("e.g. `timer until \"2026-06-15T09:00:00Z\" as deadline` or `timer until ticket.dueAt as deadline`".to_owned()),
2631                    );
2632                    return None;
2633                }
2634            };
2635            let mut binding = None;
2636            let mut requires = Vec::new();
2637            let mut timeout_seconds = None;
2638            if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2639                return None;
2640            }
2641            if binding.is_none() {
2642                let span = self.span_from(start);
2643                self.error(
2644                    span,
2645                    "`timer` requires an `as` binding".to_owned(),
2646                    Some("rules react to the timer with `after <binding> succeeds`".to_owned()),
2647                );
2648            }
2649            return Some(BodyStmt::Effect(EffectStmt {
2650                kind: BodyEffectKind::Timer {
2651                    duration_seconds: 0,
2652                    duration_source: String::new(),
2653                    until: Some(until),
2654                },
2655                binding,
2656                requires,
2657                timeout_seconds,
2658                prompt: None,
2659                span: self.span_from(start),
2660            }));
2661        }
2662        let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
2663            self.error(
2664                span,
2665                "expected a duration after `timer`".to_owned(),
2666                Some(
2667                    "use `<n><unit>` with unit s, m, h, or d, e.g. `timer 24h as deadline`"
2668                        .to_owned(),
2669                ),
2670            );
2671            return None;
2672        };
2673        self.pos += 1;
2674        let Some(duration_seconds) = parse_short_duration_seconds(&value).filter(|s| *s > 0) else {
2675            self.error(
2676                span,
2677                format!("invalid timer duration `{value}`"),
2678                Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
2679            );
2680            return None;
2681        };
2682        let mut binding = None;
2683        let mut requires = Vec::new();
2684        let mut timeout_seconds = None;
2685        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2686            return None;
2687        }
2688        if binding.is_none() {
2689            let span = self.span_from(start);
2690            self.error(
2691                span,
2692                "`timer` requires an `as` binding".to_owned(),
2693                Some("rules react to the timer with `after <binding> succeeds`".to_owned()),
2694            );
2695        }
2696        Some(BodyStmt::Effect(EffectStmt {
2697            kind: BodyEffectKind::Timer {
2698                duration_seconds,
2699                duration_source: value,
2700                until: None,
2701            },
2702            binding,
2703            requires,
2704            timeout_seconds,
2705            prompt: None,
2706            span: self.span_from(start),
2707        }))
2708    }
2709
2710    fn parse_cancel(&mut self) -> Option<BodyStmt> {
2711        let start = self.pos;
2712        self.pos += 1; // cancel
2713        let binding = self.ident_text("effect binding after `cancel`")?;
2714        Some(BodyStmt::Cancel {
2715            binding,
2716            span: self.span_from(start),
2717        })
2718    }
2719
2720    /// `redact <source> keep [<field>, …] as <out>` (DR-0027): an explicit
2721    /// information-flow projection. Parses the source binding, the bracketed
2722    /// comma-separated kept-field list, and the `as` output binding. A redaction
2723    /// must keep at least one field (keeping nothing releases nothing).
2724    fn parse_redact(&mut self) -> Option<BodyStmt> {
2725        let start = self.pos;
2726        self.pos += 1; // redact
2727        let source = self.ident_text("binding to redact after `redact`")?;
2728        if !self.consume_ident("keep") {
2729            let span = self.span_here();
2730            self.error(
2731                span,
2732                "expected `keep [<field>, …]` after the binding".to_owned(),
2733                Some("write `redact customer keep [id, status] as safe`".to_owned()),
2734            );
2735            return None;
2736        }
2737        if !self.consume_sym('[') {
2738            let span = self.span_here();
2739            self.error(
2740                span,
2741                "expected `[` to open the kept-field list".to_owned(),
2742                Some("write `keep [id, status]`".to_owned()),
2743            );
2744            return None;
2745        }
2746        let mut keep = Vec::new();
2747        loop {
2748            if self.consume_sym(']') {
2749                break;
2750            }
2751            if self.peek().is_none() {
2752                let span = self.span_here();
2753                self.error(
2754                    span,
2755                    "unclosed kept-field list".to_owned(),
2756                    Some("add `]`".to_owned()),
2757                );
2758                return None;
2759            }
2760            let field = self.ident_text("kept field name")?;
2761            keep.push(field);
2762            if !self.consume_sym(',') && !self.at_sym(']') {
2763                let span = self.span_here();
2764                self.error(
2765                    span,
2766                    "expected `,` or `]` in the kept-field list".to_owned(),
2767                    None,
2768                );
2769                return None;
2770            }
2771        }
2772        if !self.consume_ident("as") {
2773            let span = self.span_here();
2774            self.error(
2775                span,
2776                "`redact` requires an `as <binding>`".to_owned(),
2777                Some("write `redact customer keep [id] as safe`".to_owned()),
2778            );
2779            return None;
2780        }
2781        let binding = self.ident_text("output binding after `as`")?;
2782        if keep.is_empty() {
2783            let span = self.span_from(start);
2784            self.error(
2785                span,
2786                "`redact` must keep at least one field".to_owned(),
2787                Some("a redaction that keeps nothing has no value to release".to_owned()),
2788            );
2789            return None;
2790        }
2791        Some(BodyStmt::Redact {
2792            source,
2793            keep,
2794            binding,
2795            span: self.span_from(start),
2796        })
2797    }
2798
2799    /// `acquire <lease> for <key-expr> [until ttl] as <slot>`: one atomic
2800    /// attempt with branchable `held`/`contended` outcomes
2801    /// (spec/coordination.md).
2802    fn parse_lease_acquire(&mut self) -> Option<BodyStmt> {
2803        let start = self.pos;
2804        self.pos += 1; // acquire
2805        let resource = self.ident_text("lease name after `acquire`")?;
2806        if !self.consume_ident("for") {
2807            let span = self.span_here();
2808            self.error(
2809                span,
2810                "expected `for <key>` after the lease name".to_owned(),
2811                Some("write `acquire deploy_slot for r.env as slot`".to_owned()),
2812            );
2813            return None;
2814        }
2815        let key_expr = self.dotted_path_text("lease key expression")?;
2816        let mut until_ttl = false;
2817        if self.at_ident("until") {
2818            self.pos += 1;
2819            if !self.consume_ident("ttl") {
2820                let span = self.span_here();
2821                self.error(
2822                    span,
2823                    "expected `ttl` after `until`".to_owned(),
2824                    Some("`acquire ... until ttl` is the fire-and-forget form".to_owned()),
2825                );
2826                return None;
2827            }
2828            until_ttl = true;
2829        }
2830        // `wait <duration>`: bounded retry on contention (spec/coordination.md). The
2831        // acquire re-attempts on each worker pass until it is `held` or the wait
2832        // elapses, then reports `contended`.
2833        let mut wait_seconds = None;
2834        if self.at_ident("wait") {
2835            self.pos += 1; // wait
2836            let span = self.span_here();
2837            let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
2838                self.error(
2839                    span,
2840                    "expected a duration after `wait`".to_owned(),
2841                    Some("use `<n><unit>` with unit s, m, h, or d, e.g. `wait 30s`".to_owned()),
2842                );
2843                return None;
2844            };
2845            self.pos += 1;
2846            match parse_short_duration_seconds(&value) {
2847                Some(seconds) if seconds > 0 => wait_seconds = Some(seconds),
2848                _ => {
2849                    self.error(
2850                        span,
2851                        format!("invalid wait duration `{value}`"),
2852                        Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
2853                    );
2854                    return None;
2855                }
2856            }
2857        }
2858        let mut binding = None;
2859        let mut requires = Vec::new();
2860        let mut timeout_seconds = None;
2861        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2862            return None;
2863        }
2864        if binding.is_none() {
2865            let span = self.span_from(start);
2866            self.error(
2867                span,
2868                "`acquire` requires an `as` binding".to_owned(),
2869                Some(
2870                    "branch on it with `after <binding> held` and `after <binding> contended`"
2871                        .to_owned(),
2872                ),
2873            );
2874        }
2875        Some(BodyStmt::Effect(EffectStmt {
2876            kind: BodyEffectKind::LeaseAcquire {
2877                resource,
2878                key_expr,
2879                until_ttl,
2880                wait_seconds,
2881            },
2882            binding,
2883            requires,
2884            timeout_seconds,
2885            prompt: None,
2886            span: self.span_from(start),
2887        }))
2888    }
2889
2890    /// `renew <acquire-binding> [until <ttl>] as <b>`: extend a held lease's
2891    /// TTL before it expires (spec/coordination.md). It names the `as` binding
2892    /// of the `acquire` it extends, so resource/key never drift, and yields a
2893    /// branchable `renewed`/`notHeld` outcome.
2894    fn parse_lease_renew(&mut self) -> Option<BodyStmt> {
2895        let start = self.pos;
2896        self.pos += 1; // renew
2897        let acquire_binding = self.ident_text("lease binding after `renew`")?;
2898        // `until <duration>`: the new TTL. Unlike `acquire`'s `until ttl` keyword
2899        // (fire-and-forget), renew's `until` takes a duration value, e.g.
2900        // `until 300s`.
2901        let mut ttl_seconds = None;
2902        if self.at_ident("until") {
2903            self.pos += 1; // until
2904            let span = self.span_here();
2905            let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
2906                self.error(
2907                    span,
2908                    "expected a duration after `until`".to_owned(),
2909                    Some("use `<n><unit>` with unit s, m, h, or d, e.g. `until 300s`".to_owned()),
2910                );
2911                return None;
2912            };
2913            self.pos += 1;
2914            match parse_short_duration_seconds(&value) {
2915                Some(seconds) if seconds > 0 => ttl_seconds = Some(seconds),
2916                _ => {
2917                    self.error(
2918                        span,
2919                        format!("invalid ttl duration `{value}`"),
2920                        Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
2921                    );
2922                    return None;
2923                }
2924            }
2925        }
2926        let mut binding = None;
2927        let mut requires = Vec::new();
2928        let mut timeout_seconds = None;
2929        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2930            return None;
2931        }
2932        if binding.is_none() {
2933            let span = self.span_from(start);
2934            self.error(
2935                span,
2936                "`renew` requires an `as` binding".to_owned(),
2937                Some(
2938                    "branch on it with `after <binding> renewed` and `after <binding> notHeld`"
2939                        .to_owned(),
2940                ),
2941            );
2942        }
2943        Some(BodyStmt::Effect(EffectStmt {
2944            kind: BodyEffectKind::LeaseRenew {
2945                acquire_binding,
2946                ttl_seconds,
2947            },
2948            binding,
2949            requires,
2950            timeout_seconds,
2951            prompt: None,
2952            span: self.span_from(start),
2953        }))
2954    }
2955
2956    /// `append <Schema> { fields } to <ledger> [as x]` (spec/coordination.md).
2957    fn parse_ledger_append(&mut self) -> Option<BodyStmt> {
2958        let start = self.pos;
2959        self.pos += 1; // append
2960        let schema = self.ident_text("entry schema after `append`")?;
2961        let fields = self.parse_field_block(false)?;
2962        if !self.consume_ident("to") {
2963            let span = self.span_here();
2964            self.error(
2965                span,
2966                "expected `to <ledger>` after the entry payload".to_owned(),
2967                Some("write `append Decision { ... } to decisions`".to_owned()),
2968            );
2969            return None;
2970        }
2971        let ledger = self.ident_text("ledger name after `to`")?;
2972        let mut binding = None;
2973        let mut requires = Vec::new();
2974        let mut timeout_seconds = None;
2975        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
2976            return None;
2977        }
2978        Some(BodyStmt::Effect(EffectStmt {
2979            kind: BodyEffectKind::LedgerAppend {
2980                ledger,
2981                schema,
2982                fields,
2983            },
2984            binding,
2985            requires,
2986            timeout_seconds,
2987            prompt: None,
2988            span: self.span_from(start),
2989        }))
2990    }
2991
2992    fn looks_like_counter_consume(&self) -> bool {
2993        matches!(self.peek_at(1).map(|t| &t.tok), Some(Tok::Ident(_)))
2994            && matches!(self.peek_at(2).map(|t| &t.tok), Some(Tok::Ident(word)) if word == "for")
2995    }
2996
2997    /// `consume <counter> for <key-expr> amount <expr> as <binding>`: one
2998    /// atomic consume with branchable `ok`/`over` outcomes
2999    /// (spec/coordination.md).
3000    fn parse_counter_consume(&mut self) -> Option<BodyStmt> {
3001        let start = self.pos;
3002        self.pos += 1; // consume
3003        let counter = self.ident_text("counter name after `consume`")?;
3004        if !self.consume_ident("for") {
3005            let span = self.span_here();
3006            self.error(
3007                span,
3008                "expected `for <key>` after the counter name".to_owned(),
3009                Some(
3010                    "write `consume model_budget for t.customer amount t.estTokens as spend`"
3011                        .to_owned(),
3012                ),
3013            );
3014            return None;
3015        }
3016        let key_expr = self.dotted_path_text("counter key expression")?;
3017        if !self.consume_ident("amount") {
3018            let span = self.span_here();
3019            self.error(
3020                span,
3021                "expected `amount <expr>` after the counter key".to_owned(),
3022                Some(
3023                    "write `consume model_budget for t.customer amount t.estTokens as spend`"
3024                        .to_owned(),
3025                ),
3026            );
3027            return None;
3028        }
3029        let amount_expr = match self.peek().map(|t| t.tok.clone()) {
3030            Some(Tok::Number(value)) => {
3031                self.pos += 1;
3032                value
3033            }
3034            Some(Tok::Ident(_)) => self.dotted_path_text("consume amount")?,
3035            _ => {
3036                let span = self.span_here();
3037                self.error(
3038                    span,
3039                    "expected a number or path after `amount`".to_owned(),
3040                    None,
3041                );
3042                return None;
3043            }
3044        };
3045        let mut binding = None;
3046        let mut requires = Vec::new();
3047        let mut timeout_seconds = None;
3048        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3049            return None;
3050        }
3051        if binding.is_none() {
3052            let span = self.span_from(start);
3053            self.error(
3054                span,
3055                "`consume` requires an `as` binding".to_owned(),
3056                Some(
3057                    "branch on it with `after <binding> ok` and `after <binding> over`".to_owned(),
3058                ),
3059            );
3060        }
3061        Some(BodyStmt::Effect(EffectStmt {
3062            kind: BodyEffectKind::CounterConsume {
3063                counter,
3064                key_expr,
3065                amount_expr,
3066            },
3067            binding,
3068            requires,
3069            timeout_seconds,
3070            prompt: None,
3071            span: self.span_from(start),
3072        }))
3073    }
3074
3075    /// `emit signal <dotted.name> to <instance-expr> { payload }`.
3076    fn parse_emit_signal(&mut self) -> Option<BodyStmt> {
3077        let start = self.pos;
3078        self.pos += 1; // emit
3079                       // `emit milestone "<name>" [of <PayloadClass>] { fields }` (Family C): a
3080                       // synchronous milestone projection, distinct from the directed
3081                       // `emit signal ... to ...` effect.
3082        if self.at_ident("milestone") {
3083            return self.parse_emit_milestone(start);
3084        }
3085        if !self.consume_ident("signal") {
3086            let span = self.span_here();
3087            self.error(
3088                span,
3089                "the bare `emit <name>` statement was removed from the language; \
3090                 `emit` must be followed by `signal` or `milestone`"
3091                    .to_owned(),
3092                Some("write `emit signal deploy.finished to peer.id { ... }`".to_owned()),
3093            );
3094            return None;
3095        }
3096        let event = self.dotted_path_text("signal name after `signal`")?;
3097        if !self.consume_ident("to") {
3098            let span = self.span_here();
3099            self.error(
3100                span,
3101                "expected `to <target>` after the signal name".to_owned(),
3102                Some("write `emit signal deploy.finished to peer.id { ... }`".to_owned()),
3103            );
3104            return None;
3105        }
3106        let target_expr = self.dotted_path_text("target instance after `to`")?;
3107        // S6: optional `from <binding>` projection (the `record … from`
3108        // precedent) — shorthand fields become allowed inside the block.
3109        let from = if self.consume_ident("from") {
3110            Some(self.ident_text("binding name after `from`")?)
3111        } else {
3112            None
3113        };
3114        let fields = if from.is_some() && !self.at_sym('{') {
3115            Vec::new()
3116        } else {
3117            self.parse_field_block(from.is_some())?
3118        };
3119        let mut binding = None;
3120        let mut requires = Vec::new();
3121        let mut timeout_seconds = None;
3122        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3123            return None;
3124        }
3125        Some(BodyStmt::Effect(EffectStmt {
3126            kind: BodyEffectKind::Notify {
3127                target_expr,
3128                event,
3129                from,
3130                fields,
3131            },
3132            binding,
3133            requires,
3134            timeout_seconds,
3135            prompt: None,
3136            span: self.span_from(start),
3137        }))
3138    }
3139
3140    /// `emit milestone "<name>" [of <PayloadClass>] { fields }` (Family C). The
3141    /// caller has consumed `emit`; `self` is positioned at the `milestone`
3142    /// keyword. `start` is the `emit` token index for span tracking.
3143    fn parse_emit_milestone(&mut self, start: usize) -> Option<BodyStmt> {
3144        self.pos += 1; // milestone
3145        let Some(Tok::Str(name)) = self.peek().map(|t| t.tok.clone()) else {
3146            let span = self.span_here();
3147            self.error(
3148                span,
3149                "expected a quoted milestone name after `milestone`".to_owned(),
3150                Some(
3151                    "write `emit milestone \"canary_live\" of CanaryInfo { region \"us\" }`"
3152                        .to_owned(),
3153                ),
3154            );
3155            return None;
3156        };
3157        self.pos += 1;
3158        // `of <PayloadClass>` is optional: a bare milestone carries no payload
3159        // and the parent observes it with `after p reaches "<name>"` (no `as`).
3160        let payload_class = if self.consume_ident("of") {
3161            Some(self.ident_text("payload class after `of`")?)
3162        } else {
3163            None
3164        };
3165        let fields = if matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym('{'))) {
3166            self.parse_field_block(false)?
3167        } else {
3168            Vec::new()
3169        };
3170        Some(BodyStmt::Milestone {
3171            name,
3172            payload_class,
3173            fields,
3174            span: self.span_from(start),
3175        })
3176    }
3177
3178    /// A possibly-dotted identifier path, returned as source text.
3179    fn dotted_path_text(&mut self, label: &str) -> Option<String> {
3180        let mut text = self.ident_text(label)?;
3181        while matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym('.'))) {
3182            self.pos += 1;
3183            let Some(Tok::Ident(segment)) = self.peek().map(|t| t.tok.clone()) else {
3184                break;
3185            };
3186            text.push('.');
3187            text.push_str(&segment);
3188            self.pos += 1;
3189        }
3190        Some(text)
3191    }
3192
3193    fn parse_exec(&mut self) -> Option<BodyStmt> {
3194        let start = self.pos;
3195        self.pos += 1; // exec
3196        let target = match self.advance().map(|t| t.tok) {
3197            Some(Tok::Str(value)) => ExecTarget::RawCommand(value),
3198            Some(Tok::Ident(name)) => {
3199                if !self.at_ident("with") {
3200                    let span = self.span_here();
3201                    self.error(
3202                        span,
3203                        "expected `with <binding>` after exec capability name".to_owned(),
3204                        Some(format!(
3205                            "write `exec {name} with input -> Report as result`"
3206                        )),
3207                    );
3208                    return None;
3209                }
3210                self.pos += 1; // with
3211                let Some(Tok::Ident(stdin_binding)) = self.peek().map(|t| t.tok.clone()) else {
3212                    let span = self.span_here();
3213                    self.error(
3214                        span,
3215                        "expected a record binding after `with`".to_owned(),
3216                        Some(format!(
3217                            "write `exec {name} with input -> Report as result`"
3218                        )),
3219                    );
3220                    return None;
3221                };
3222                self.pos += 1;
3223                ExecTarget::Capability {
3224                    name,
3225                    stdin_binding,
3226                }
3227            }
3228            _ => {
3229                let span = self.span_here();
3230                self.error(
3231                    span,
3232                    "expected a command string or capability name after `exec`".to_owned(),
3233                    Some(
3234                        "write `exec \"scripts/run-tests.sh\" as tests` or `exec backup_repo with input -> Report as result`"
3235                            .to_owned(),
3236                    ),
3237                );
3238                return None;
3239            }
3240        };
3241        // `-> Schema` / `-> each Schema`: typed stdout ingestion
3242        // (spec/json-ingestion.md).
3243        let mut parse_target = None;
3244        if matches!(self.peek().map(|t| &t.tok), Some(Tok::Arrow)) {
3245            self.pos += 1; // ->
3246            let each = if self.at_ident("each") {
3247                self.pos += 1;
3248                true
3249            } else {
3250                false
3251            };
3252            let Some(Tok::Ident(schema)) = self.peek().map(|t| t.tok.clone()) else {
3253                let span = self.span_here();
3254                self.error(
3255                    span,
3256                    "expected a schema name after `->`".to_owned(),
3257                    Some(
3258                        "write `exec \"report.sh\" -> Report as x` or `exec \"list.sh\" -> each WorkItem`"
3259                            .to_owned(),
3260                    ),
3261                );
3262                return None;
3263            };
3264            self.pos += 1;
3265            parse_target = Some(ExecParse { schema, each });
3266        }
3267        let mut binding = None;
3268        let mut requires = Vec::new();
3269        let mut timeout_seconds = None;
3270        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3271            return None;
3272        }
3273        match &parse_target {
3274            Some(parse) if parse.each && binding.is_some() => {
3275                let span = self.span_from(start);
3276                self.error(
3277                    span,
3278                    "`-> each` produces a stream of facts, not a single binding".to_owned(),
3279                    Some("drop the `as` binding and react with `when <Schema> as item`".to_owned()),
3280                );
3281            }
3282            Some(parse) if !parse.each && binding.is_none() => {
3283                let span = self.span_from(start);
3284                self.error(
3285                    span,
3286                    "`->` without `each` parses one value and needs an `as` binding".to_owned(),
3287                    Some("write `exec \"report.sh\" -> Report as x` and read it with `after x succeeds as r`".to_owned()),
3288                );
3289            }
3290            _ => {}
3291        }
3292        Some(BodyStmt::Effect(EffectStmt {
3293            kind: BodyEffectKind::Exec {
3294                target,
3295                parse_target,
3296            },
3297            binding,
3298            requires,
3299            timeout_seconds,
3300            prompt: None,
3301            span: self.span_from(start),
3302        }))
3303    }
3304
3305    // -- tracker verbs ---------------------------------------------------------
3306
3307    fn parse_tracker_file(&mut self) -> Option<BodyStmt> {
3308        let start = self.pos;
3309        self.pos += 1; // file
3310        if !self.consume_ident("issue") {
3311            let span = self.span_here();
3312            self.error(
3313                span,
3314                "expected `issue` after `file`",
3315                Some("write `file issue into <tracker> { ... }`".to_owned()),
3316            );
3317            return None;
3318        }
3319        if !self.consume_ident("into") {
3320            let span = self.span_here();
3321            self.error(span, "expected `into <tracker>` after `file issue`", None);
3322            return None;
3323        }
3324        let queue = self.ident_text("tracker name")?;
3325        let fields = self.parse_field_block(false)?;
3326        let mut binding = None;
3327        let mut requires = Vec::new();
3328        let mut timeout_seconds = None;
3329        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3330            return None;
3331        }
3332        Some(BodyStmt::Effect(EffectStmt {
3333            kind: BodyEffectKind::TrackerFile { queue, fields },
3334            binding,
3335            requires,
3336            timeout_seconds,
3337            prompt: None,
3338            span: self.span_from(start),
3339        }))
3340    }
3341
3342    fn parse_tracker_claim(&mut self) -> Option<BodyStmt> {
3343        let start = self.pos;
3344        self.pos += 1; // claim
3345        let item = self.ident_text("issue binding after `claim`")?;
3346        if self.at_ident("with") {
3347            let span = self.span_here();
3348            self.error(
3349                span,
3350                "`claim <issue> with ...` is not supported".to_owned(),
3351                Some("declare a `tracker` and write `claim <issue> [ttl <dur>] [as x]`".to_owned()),
3352            );
3353            self.pos += 1;
3354            let _ = self.advance();
3355        }
3356        // `ttl <duration>`: the claim-TTL clause (spec/std-tracker.md, T3). It
3357        // takes a duration value, e.g. `claim issue ttl 30m as c`.
3358        let mut ttl_seconds = None;
3359        if self.at_ident("ttl") {
3360            self.pos += 1; // ttl
3361            let span = self.span_here();
3362            let Some(Tok::Number(value)) = self.peek().map(|t| t.tok.clone()) else {
3363                self.error(
3364                    span,
3365                    "expected a duration after `ttl`".to_owned(),
3366                    Some("use `<n><unit>` with unit s, m, h, or d, e.g. `ttl 30m`".to_owned()),
3367                );
3368                return None;
3369            };
3370            self.pos += 1;
3371            match parse_short_duration_seconds(&value) {
3372                Some(seconds) if seconds > 0 => ttl_seconds = Some(seconds),
3373                _ => {
3374                    self.error(
3375                        span,
3376                        format!("invalid ttl duration `{value}`"),
3377                        Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
3378                    );
3379                    return None;
3380                }
3381            }
3382        }
3383        let mut binding = None;
3384        let mut requires = Vec::new();
3385        let mut timeout_seconds = None;
3386        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3387            return None;
3388        }
3389        Some(BodyStmt::Effect(EffectStmt {
3390            kind: BodyEffectKind::TrackerClaim { item, ttl_seconds },
3391            binding,
3392            requires,
3393            timeout_seconds,
3394            prompt: None,
3395            span: self.span_from(start),
3396        }))
3397    }
3398
3399    fn parse_tracker_release(&mut self) -> Option<BodyStmt> {
3400        let start = self.pos;
3401        self.pos += 1; // release
3402        let item = self.ident_text("issue binding after `release`")?;
3403        Some(BodyStmt::Effect(EffectStmt {
3404            kind: BodyEffectKind::TrackerRelease { item },
3405            binding: None,
3406            requires: Vec::new(),
3407            timeout_seconds: None,
3408            prompt: None,
3409            span: self.span_from(start),
3410        }))
3411    }
3412
3413    fn parse_tracker_finish(&mut self) -> Option<BodyStmt> {
3414        let start = self.pos;
3415        self.pos += 1; // finish
3416        let item = self.ident_text("issue binding after `finish`")?;
3417        let fields = if self.at_sym('{') {
3418            self.parse_field_block(false)?
3419        } else {
3420            Vec::new()
3421        };
3422        // `as <binding>` after the payload — required for `then x <- finish
3423        // item { … }`, whose desugar re-serializes the finish with a synthetic
3424        // handle and observes it with `after`.
3425        let mut binding = None;
3426        let mut requires = Vec::new();
3427        let mut timeout_seconds = None;
3428        if !self.parse_effect_modifiers(&mut binding, &mut requires, &mut timeout_seconds) {
3429            return None;
3430        }
3431        Some(BodyStmt::Effect(EffectStmt {
3432            kind: BodyEffectKind::TrackerFinish { item, fields },
3433            binding,
3434            requires,
3435            timeout_seconds,
3436            prompt: None,
3437            span: self.span_from(start),
3438        }))
3439    }
3440
3441    // -- blocks --------------------------------------------------------------
3442
3443    /// `during <cond> { … } on lapse [as x] { … }` / `until <cond> { … }`
3444    /// (DR-0043 Decision 5). The arm is mandatory; the parser accepts the arm
3445    /// on the region's closing line (`}} on lapse {{`) or on its own line —
3446    /// tokens carry no line structure.
3447    fn parse_region(&mut self, until: bool) -> Option<BodyStmt> {
3448        let start = self.pos;
3449        let keyword = if until { "until" } else { "during" };
3450        self.pos += 1;
3451        let cond_start = self.pos;
3452        while self.pos < self.tokens.len() && !self.at_sym('{') {
3453            self.pos += 1;
3454        }
3455        if !self.at_sym('{') {
3456            let span = self.span_here();
3457            self.error(
3458                span,
3459                format!("expected `{{` to open the `{keyword}` region"),
3460                Some(format!(
3461                    "write `{keyword} <condition> {{ … }} on lapse {{ … }}`"
3462                )),
3463            );
3464            return None;
3465        }
3466        let condition = if self.pos > cond_start {
3467            let from = self.tokens[cond_start].start;
3468            let to = self.tokens[self.pos - 1].end;
3469            self.source[from..to].trim().to_owned()
3470        } else {
3471            String::new()
3472        };
3473        if condition.is_empty() {
3474            let span = self.span_from(start);
3475            self.error(
3476                span,
3477                format!("`{keyword}` requires a condition"),
3478                Some("the condition is a pure query expression, like a guard".to_owned()),
3479            );
3480            return None;
3481        }
3482        let body_open = self.pos;
3483        self.pos += 1; // {
3484        let body_content_start = self
3485            .tokens
3486            .get(self.pos)
3487            .map(|token| self.base + token.start)
3488            .unwrap_or_else(|| self.base + self.tokens[body_open].end);
3489        let body = self.parse_statements(true);
3490        // parse_statements consumed the closing `}` (token before self.pos).
3491        let body_content_end = self
3492            .tokens
3493            .get(self.pos.saturating_sub(1))
3494            .map(|token| self.base + token.start)
3495            .unwrap_or(body_content_start);
3496        let body_span = SourceSpan {
3497            start: body_content_start,
3498            end: body_content_end,
3499        };
3500        if !(self.consume_ident("on") && self.consume_ident("lapse")) {
3501            let span = self.span_here();
3502            self.error(
3503                span,
3504                format!("a `{keyword}` region requires its `on lapse {{ … }}` arm"),
3505                Some(
3506                    "a reactive condition with no declared consequence would lapse silently; \
3507                     write `on lapse { … }` (optionally `on lapse as <view> { … }`)"
3508                        .to_owned(),
3509                ),
3510            );
3511            return None;
3512        }
3513        let lapse_binding = if self.consume_ident("as") {
3514            Some(self.ident_text("progress-view binding after `as`")?)
3515        } else {
3516            None
3517        };
3518        if !self.consume_sym('{') {
3519            let span = self.span_here();
3520            self.error(span, "expected `{` to open the `on lapse` arm", None);
3521            return None;
3522        }
3523        let lapse_open = self.pos - 1;
3524        let lapse_content_start = self
3525            .tokens
3526            .get(self.pos)
3527            .map(|token| self.base + token.start)
3528            .unwrap_or_else(|| self.base + self.tokens[lapse_open].end);
3529        let lapse_body = self.parse_statements(true);
3530        let lapse_content_end = self
3531            .tokens
3532            .get(self.pos.saturating_sub(1))
3533            .map(|token| self.base + token.start)
3534            .unwrap_or(lapse_content_start);
3535        Some(BodyStmt::Region(RegionBlock {
3536            until,
3537            condition,
3538            body,
3539            lapse_binding,
3540            lapse_body,
3541            body_span,
3542            lapse_span: SourceSpan {
3543                start: lapse_content_start,
3544                end: lapse_content_end,
3545            },
3546            span: self.span_from(start),
3547        }))
3548    }
3549
3550    fn parse_after(&mut self) -> Option<BodyStmt> {
3551        let start = self.pos;
3552        self.pos += 1; // after
3553        let binding = self.ident_text("effect binding after `after`")?;
3554        let mut milestone = None;
3555        let predicate = match self.advance().map(|t| t.tok) {
3556            Some(Tok::Ident(word)) => match word.as_str() {
3557                "succeeds" => AfterPredicate::Succeeds,
3558                "fails" => AfterPredicate::Fails,
3559                "completes" => AfterPredicate::Completes,
3560                "cancelled" => AfterPredicate::Cancelled,
3561                // `after p reaches "<name>" as m` (Family C): the next token is a
3562                // string literal naming the child milestone being observed. The
3563                // name is stashed on `AfterBlock.milestone`.
3564                "reaches" => {
3565                    let Some(Tok::Str(name)) = self.peek().map(|t| t.tok.clone()) else {
3566                        let span = self.span_here();
3567                        self.error(
3568                            span,
3569                            "expected a quoted milestone name after `reaches`".to_owned(),
3570                            Some("write `after p reaches \"canary_live\" as m { ... }`".to_owned()),
3571                        );
3572                        return None;
3573                    };
3574                    self.pos += 1;
3575                    milestone = Some(name);
3576                    AfterPredicate::Reaches
3577                }
3578                // `times out` is the two-token spelling of the `TimedOut`
3579                // terminal status (spec/expression-kernel.md).
3580                "times" => {
3581                    if !self.consume_ident("out") {
3582                        let span = self.span_here();
3583                        self.error(span, "expected `out` after `times`", None);
3584                        return None;
3585                    }
3586                    AfterPredicate::TimedOut
3587                }
3588                "held" => AfterPredicate::Held,
3589                "contended" => AfterPredicate::Contended,
3590                "ok" => AfterPredicate::Ok,
3591                "over" => AfterPredicate::Over,
3592                other => {
3593                    let span = self.span_from(start);
3594                    self.error(
3595                        span,
3596                        format!("unsupported `after` predicate `{other}`"),
3597                        Some(
3598                            "use `succeeds`, `fails`, `completes`, `times out`, `cancelled`, or a coordination outcome (`held`, `contended`, `ok`, `over`)"
3599                                .to_owned(),
3600                        ),
3601                    );
3602                    return None;
3603                }
3604            },
3605            _ => {
3606                let span = self.span_here();
3607                self.error(
3608                    span,
3609                    "expected `succeeds`, `fails`, `completes`, `times out`, or `cancelled`",
3610                    None,
3611                );
3612                return None;
3613            }
3614        };
3615        let alias = if self.consume_ident("as") {
3616            Some(self.ident_text("alias after `as`")?)
3617        } else {
3618            None
3619        };
3620        if !self.consume_sym('{') {
3621            let span = self.span_here();
3622            self.error(span, "expected `{` to open the `after` block", None);
3623            return None;
3624        }
3625        let body = self.parse_statements(true);
3626        Some(BodyStmt::After(AfterBlock {
3627            binding,
3628            predicate,
3629            alias,
3630            milestone,
3631            body,
3632            span: self.span_from(start),
3633        }))
3634    }
3635
3636    fn parse_case(&mut self) -> Option<BodyStmt> {
3637        let start = self.pos;
3638        self.pos += 1; // case
3639        let scrutinee = self.ident_text("case scrutinee path")?;
3640        if !self.consume_sym('{') {
3641            let span = self.span_here();
3642            self.error(span, "expected `{` to open the `case` block", None);
3643            return None;
3644        }
3645        let mut branches = Vec::new();
3646        loop {
3647            if self.consume_sym('}') {
3648                break;
3649            }
3650            if self.peek().is_none() {
3651                let span = self.span_here();
3652                self.error(span, "unclosed `case` block", Some("add `}`".to_owned()));
3653                break;
3654            }
3655            let branch_start = self.pos;
3656            let pattern = match self.advance().map(|t| t.tok) {
3657                Some(Tok::Ident(value)) => value,
3658                Some(Tok::Str(value)) => format!("{value:?}"),
3659                _ => {
3660                    let span = self.span_here();
3661                    self.error(span, "expected a case pattern", None);
3662                    self.recover();
3663                    continue;
3664                }
3665            };
3666            let binding = match self.peek().map(|t| t.tok.clone()) {
3667                // `Variant as binding` (sum types, spec/sum-types.md) — `as`
3668                // is how every other binding in the language is introduced.
3669                Some(Tok::Ident(value)) if value == "as" => {
3670                    self.pos += 1;
3671                    match self.peek().map(|t| t.tok.clone()) {
3672                        Some(Tok::Ident(name)) => {
3673                            self.pos += 1;
3674                            Some(name)
3675                        }
3676                        _ => {
3677                            let span = self.span_here();
3678                            self.error(
3679                                span,
3680                                "expected a binding name after `as`".to_owned(),
3681                                Some("write `Variant as payload => { ... }`".to_owned()),
3682                            );
3683                            None
3684                        }
3685                    }
3686                }
3687                Some(Tok::Ident(value)) if value != "where" => {
3688                    self.pos += 1;
3689                    Some(value)
3690                }
3691                _ => None,
3692            };
3693            let guard = if self.consume_ident("where") {
3694                let guard_start = self.pos;
3695                // Consume guard tokens up to `=>`.
3696                while self.peek().is_some()
3697                    && !matches!(self.peek().map(|t| &t.tok), Some(Tok::FatArrow))
3698                {
3699                    self.pos += 1;
3700                }
3701                let first = self.tokens.get(guard_start);
3702                let last = self.tokens.get(self.pos.saturating_sub(1));
3703                match (first, last) {
3704                    (Some(first), Some(last)) if guard_start < self.pos => {
3705                        Some(self.source[first.start..last.end].to_owned())
3706                    }
3707                    _ => None,
3708                }
3709            } else {
3710                None
3711            };
3712            if !matches!(self.advance().map(|t| t.tok), Some(Tok::FatArrow)) {
3713                let span = self.span_here();
3714                self.error(span, "expected `=>` after case pattern", None);
3715                self.recover();
3716                continue;
3717            }
3718            if !self.consume_sym('{') {
3719                let span = self.span_here();
3720                self.error(span, "expected `{` to open the case branch", None);
3721                self.recover();
3722                continue;
3723            }
3724            let body = self.parse_statements(true);
3725            branches.push(CaseBranch {
3726                pattern,
3727                binding,
3728                guard,
3729                body,
3730                span: self.span_from(branch_start),
3731            });
3732        }
3733        Some(BodyStmt::Case(CaseBlock {
3734            scrutinee,
3735            branches,
3736            span: self.span_from(start),
3737        }))
3738    }
3739
3740    fn parse_terminal(&mut self) -> Option<BodyStmt> {
3741        let start = self.pos;
3742        let keyword = match self.advance()?.tok {
3743            Tok::Ident(value) => value,
3744            _ => return None,
3745        };
3746        let kind = if keyword == "complete" {
3747            TerminalKind::Complete
3748        } else {
3749            TerminalKind::Fail
3750        };
3751        let name = self.ident_text("terminal contract name")?;
3752        // `complete <T> from <binding> { … }`: bounded-type projection. Only valid on
3753        // `complete` (a failure carries an explicit payload). Shorthand fields in the
3754        // block copy the source binding's same-named fields, as in `record … from`.
3755        let from = if kind == TerminalKind::Complete && self.consume_ident("from") {
3756            Some(self.ident_text("binding name after `from`")?)
3757        } else {
3758            None
3759        };
3760        // A field block (`complete result { … }`) is the class-shaped form; a bare
3761        // value (`complete result 0.9`) is the scalar form. `from` always projects
3762        // fields, so it requires a block.
3763        let (fields, scalar) =
3764            if from.is_none() && !matches!(self.peek().map(|t| &t.tok), Some(Tok::Sym('{'))) {
3765                let (source, expr) = self.parse_value_expression()?;
3766                (Vec::new(), Some(FieldValue::Expr { source, expr }))
3767            } else {
3768                (self.parse_field_block(from.is_some())?, None)
3769            };
3770        Some(BodyStmt::Terminal(TerminalStmt {
3771            kind,
3772            name,
3773            from,
3774            fields,
3775            scalar,
3776            span: self.span_from(start),
3777        }))
3778    }
3779}
3780
3781const STATEMENT_KEYWORDS: &[&str] = &[
3782    "record", "done", "consume", "tell", "coerce", "prompt", "claim", "release", "renew", "finish",
3783    "file", "call", "recall", "send", "invoke", "read", "write", "import", "export", "after",
3784    "case", "complete", "fail", "timer", "cancel", "decide", "exec", "when", "on", "else", "then",
3785    "redact",
3786];
3787
3788#[cfg(test)]
3789mod tests {
3790    use super::*;
3791
3792    fn parse_ok(source: &str) -> BodyAst {
3793        let (ast, diagnostics) = parse_rule_body(source, 0);
3794        assert!(diagnostics.is_empty(), "diagnostics: {diagnostics:?}");
3795        ast
3796    }
3797
3798    #[test]
3799    fn full_line_comments_tokenize_as_nothing() {
3800        let ast = parse_ok(
3801            "# leading comment\nrecord Done {\n  note \"x\"\n}\n  # indented comment with braces { } and \"quotes\"\n// slash comments match the top-level lexer\ndone item\n",
3802        );
3803        assert_eq!(ast.statements.len(), 2, "comments contribute no statements");
3804    }
3805
3806    #[test]
3807    fn trailing_hash_still_errors() {
3808        let (_, diagnostics) = parse_rule_body("done item # trailing\n", 0);
3809        assert!(
3810            diagnostics
3811                .iter()
3812                .any(|d| d.message.contains("unexpected character `#`")),
3813            "trailing comments stay illegal: {diagnostics:?}"
3814        );
3815    }
3816
3817    #[test]
3818    fn blank_full_line_comments_is_byte_preserving_and_fence_aware() {
3819        let text = "  # a comment\n  tell a as t \"\"\"markdown\n  # heading is content\n  \"\"\"\n  # after fence\n";
3820        let blanked = blank_full_line_comments(text);
3821        assert_eq!(blanked.len(), text.len(), "byte length preserved");
3822        assert!(!blanked.contains("# a comment"));
3823        assert!(!blanked.contains("# after fence"));
3824        assert!(
3825            blanked.contains("# heading is content"),
3826            "fence interior untouched: {blanked}"
3827        );
3828    }
3829
3830    #[test]
3831    fn generated_effect_operation_grammar_covers_the_std_constructs() {
3832        // Drift canary for the build.rs codegen: the table generated from the
3833        // embedded std manifests (std/manifests/*.json) must contain exactly
3834        // the four shipped effect_operation keywords with their target
3835        // capabilities. A manifest edit that adds, drops, or retargets a
3836        // keyword shows up here before it shows up in parse behavior.
3837        let table = EFFECT_OPERATION_GRAMMAR
3838            .iter()
3839            .map(|spec| (spec.keyword, spec.target_capability))
3840            .collect::<Vec<_>>();
3841        assert_eq!(
3842            table,
3843            vec![
3844                ("recall", "memory.query"),
3845                ("learn", "memory.write"),
3846                ("curate", "memory.curate"),
3847                ("send", "messaging.send"),
3848            ]
3849        );
3850    }
3851
3852    #[test]
3853    fn parses_redact_projection() {
3854        let ast = parse_ok("redact customer keep [id, status] as safe");
3855        let BodyStmt::Redact {
3856            source,
3857            keep,
3858            binding,
3859            ..
3860        } = &ast.statements[0]
3861        else {
3862            panic!("expected redact, got {:?}", ast.statements[0]);
3863        };
3864        assert_eq!(source, "customer");
3865        assert_eq!(keep, &["id".to_owned(), "status".to_owned()]);
3866        assert_eq!(binding, "safe");
3867    }
3868
3869    #[test]
3870    fn parses_complete_from_projection() {
3871        let ast = parse_ok("complete result from cust {\n  id\n  status\n}");
3872        let BodyStmt::Terminal(terminal) = &ast.statements[0] else {
3873            panic!("expected terminal, got {:?}", ast.statements[0]);
3874        };
3875        assert_eq!(terminal.kind, TerminalKind::Complete);
3876        assert_eq!(terminal.name, "result");
3877        assert_eq!(terminal.from.as_deref(), Some("cust"));
3878        assert_eq!(terminal.fields.len(), 2);
3879        assert!(terminal
3880            .fields
3881            .iter()
3882            .all(|f| matches!(f.value, FieldValue::Shorthand)));
3883    }
3884
3885    #[test]
3886    fn rejects_redact_keeping_nothing() {
3887        let (_, diagnostics) = parse_rule_body("redact customer keep [] as safe", 0);
3888        assert!(
3889            diagnostics
3890                .iter()
3891                .any(|d| d.message.contains("keep at least one field")),
3892            "expected empty-keep rejection, got {diagnostics:?}"
3893        );
3894    }
3895
3896    #[test]
3897    fn parses_single_line_record_fields() {
3898        let ast = parse_ok(r#"record Item { id "a" status "done" }"#);
3899        let BodyStmt::Record(record) = &ast.statements[0] else {
3900            panic!("expected record");
3901        };
3902        assert_eq!(record.schema, "Item");
3903        assert_eq!(record.fields.len(), 2);
3904        assert_eq!(record.fields[0].name, "id");
3905        assert_eq!(record.fields[1].name, "status");
3906    }
3907
3908    #[test]
3909    fn parses_multi_line_record_with_expressions() {
3910        let ast = parse_ok(
3911            "record Job {\n  id job.id\n  attempts job.attempts + 1\n  status \"pending\"\n}",
3912        );
3913        let BodyStmt::Record(record) = &ast.statements[0] else {
3914            panic!("expected record");
3915        };
3916        assert_eq!(record.fields[1].name, "attempts");
3917        let FieldValue::Expr { source, .. } = &record.fields[1].value else {
3918            panic!("expected expression value");
3919        };
3920        assert_eq!(source, "job.attempts + 1");
3921    }
3922
3923    #[test]
3924    fn parses_done_with_replacement() {
3925        let ast = parse_ok("done task -> record Done {\n  id task.id\n}");
3926        let BodyStmt::Done {
3927            binding,
3928            replacement,
3929            ..
3930        } = &ast.statements[0]
3931        else {
3932            panic!("expected done");
3933        };
3934        assert_eq!(binding, "task");
3935        assert!(replacement.is_some());
3936    }
3937
3938    #[test]
3939    fn consume_done_alias_is_removed() {
3940        // The bare `consume <binding>` alias for `done` was removed; it now
3941        // errors with a migration hint rather than parsing as a done terminal.
3942        let (ast, diagnostics) = parse_rule_body("consume task", 0);
3943        assert!(
3944            diagnostics
3945                .iter()
3946                .any(|d| d.message.contains("`consume` was removed")),
3947            "expected a removed-alias diagnostic, got {diagnostics:?}"
3948        );
3949        assert!(
3950            !matches!(ast.statements.first(), Some(BodyStmt::Done { .. })),
3951            "removed alias must not parse as a done terminal"
3952        );
3953    }
3954
3955    #[test]
3956    fn counter_consume_verb_still_parses() {
3957        // The live counter verb `consume <counter> for ...` is unaffected.
3958        let ast = parse_ok("consume budget for t.id amount 1 as spend");
3959        assert!(
3960            matches!(
3961                ast.statements.first(),
3962                Some(BodyStmt::Effect(EffectStmt {
3963                    kind: BodyEffectKind::CounterConsume { .. },
3964                    ..
3965                }))
3966            ),
3967            "counter consume must still parse, got {:?}",
3968            ast.statements.first()
3969        );
3970    }
3971
3972    #[test]
3973    fn parses_tell_with_modifiers_and_prompt() {
3974        let ast = parse_ok(
3975            "tell worker requires [\"agent.tell\"] as turn timeout 10m \"\"\"markdown\nDo it.\n\"\"\"",
3976        );
3977        let BodyStmt::Effect(effect) = &ast.statements[0] else {
3978            panic!("expected effect");
3979        };
3980        assert_eq!(effect.binding.as_deref(), Some("turn"));
3981        assert_eq!(effect.requires, vec!["agent.tell".to_owned()]);
3982        assert_eq!(effect.timeout_seconds, Some(600));
3983        let prompt = effect.prompt.as_ref().expect("prompt");
3984        assert_eq!(prompt.content_type.as_deref(), Some("markdown"));
3985        assert_eq!(prompt.text, "Do it.");
3986    }
3987
3988    #[test]
3989    fn parses_prompt_effect() {
3990        let ast = parse_ok(
3991            "prompt \"\"\"markdown\nSummarize this.\n\"\"\" using fixture requires [\"model.invoke\"] as answer timeout 10m",
3992        );
3993        let BodyStmt::Effect(effect) = &ast.statements[0] else {
3994            panic!("expected effect");
3995        };
3996        let BodyEffectKind::Prompt { provider } = &effect.kind else {
3997            panic!("expected prompt");
3998        };
3999        assert_eq!(provider.as_deref(), Some("fixture"));
4000        assert_eq!(effect.binding.as_deref(), Some("answer"));
4001        assert_eq!(effect.requires, vec!["model.invoke".to_owned()]);
4002        assert_eq!(effect.timeout_seconds, Some(600));
4003        let prompt = effect.prompt.as_ref().expect("prompt");
4004        assert_eq!(prompt.content_type.as_deref(), Some("markdown"));
4005        assert_eq!(prompt.text, "Summarize this.");
4006    }
4007
4008    #[test]
4009    fn parses_tell_with_access_grants() {
4010        let ast = parse_ok(
4011            "tell coder as turn\n  with access to project_memory {\n    recall for issue\n    learn for issue\n  }\n  with access to project_files {\n    read [\"docs/**\"]\n  }\n\"Work the issue.\"",
4012        );
4013        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4014            panic!("expected effect");
4015        };
4016        let BodyEffectKind::Tell {
4017            target,
4018            access_grants,
4019            ..
4020        } = &effect.kind
4021        else {
4022            panic!("expected tell");
4023        };
4024        assert_eq!(target, "coder");
4025        assert_eq!(effect.binding.as_deref(), Some("turn"));
4026        assert_eq!(access_grants.len(), 2);
4027
4028        let memory = &access_grants[0];
4029        assert_eq!(memory.resource, "project_memory");
4030        assert_eq!(memory.operations.len(), 2);
4031        assert_eq!(memory.operations[0].operation, "recall");
4032        assert_eq!(memory.operations[0].target.as_deref(), Some("issue"));
4033        assert_eq!(memory.operations[1].operation, "learn");
4034
4035        let files = &access_grants[1];
4036        assert_eq!(files.resource, "project_files");
4037        assert_eq!(files.operations.len(), 1);
4038        assert_eq!(files.operations[0].operation, "read");
4039        assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
4040    }
4041
4042    #[test]
4043    fn reports_unsupported_with_context_modifier() {
4044        let (_, diagnostics) = parse_rule_body("tell coder with context memory \"go\"", 0);
4045        assert!(
4046            diagnostics
4047                .iter()
4048                .any(|d| d.message.contains("not supported yet")),
4049            "{diagnostics:?}"
4050        );
4051    }
4052
4053    #[test]
4054    fn parses_tell_with_turn_scoped_skills() {
4055        // `with skills [...]` interleaves with `with access to` around the prompt.
4056        let ast = parse_ok(
4057            "tell coder as turn\n  with skills [\"review\", \"lint\"]\n  with access to project_files {\n    read [\"src/**\"]\n  }\n\"Work it.\"",
4058        );
4059        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4060            panic!("expected effect");
4061        };
4062        let BodyEffectKind::Tell {
4063            skills,
4064            access_grants,
4065            ..
4066        } = &effect.kind
4067        else {
4068            panic!("expected tell");
4069        };
4070        assert_eq!(skills, &vec!["review".to_owned(), "lint".to_owned()]);
4071        assert_eq!(
4072            access_grants.len(),
4073            1,
4074            "access grant still parsed alongside"
4075        );
4076
4077        // `invoke ... with skills` is NOT accepted (skills are tell-scoped).
4078        let (_, diagnostics) = parse_rule_body("invoke Build { x task.x } with skills [\"a\"]", 0);
4079        assert!(
4080            !diagnostics.is_empty(),
4081            "invoke must reject a turn-scoped skills pin"
4082        );
4083    }
4084
4085    #[test]
4086    fn rejects_unknown_statement() {
4087        let (_, diagnostics) = parse_rule_body("frobnicate task", 0);
4088        assert!(diagnostics.iter().any(|d| d
4089            .message
4090            .contains("unknown rule body statement `frobnicate`")));
4091    }
4092
4093    #[test]
4094    fn parses_emit_signal() {
4095        let ast = parse_ok(
4096            "emit signal deploy.finished to peer.id {\n  service deployed.service\n  status deployed.status\n} as sent",
4097        );
4098        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4099            panic!("expected effect");
4100        };
4101        assert_eq!(effect.binding.as_deref(), Some("sent"));
4102        let BodyEffectKind::Notify {
4103            target_expr,
4104            event,
4105            fields,
4106            ..
4107        } = &effect.kind
4108        else {
4109            panic!("expected signal delivery effect");
4110        };
4111        assert_eq!(target_expr, "peer.id");
4112        assert_eq!(event, "deploy.finished");
4113        assert_eq!(fields.len(), 2);
4114    }
4115
4116    #[test]
4117    fn rejects_emit_without_signal_delivery_shape() {
4118        let (_, diagnostics) = parse_rule_body("emit event.name", 0);
4119        assert!(diagnostics
4120            .iter()
4121            .any(|d| d.message.contains("was removed from the language")));
4122    }
4123
4124    #[test]
4125    fn parses_nested_after_blocks() {
4126        let ast = parse_ok(
4127            "tell worker as turn \"go\"\n\nafter turn succeeds as done {\n  coerce review(done.summary) as verdict\n\n  after verdict succeeds as v {\n    record Out {\n      ok v.ok\n    }\n  }\n}",
4128        );
4129        assert_eq!(ast.statements.len(), 2);
4130        let BodyStmt::After(after) = &ast.statements[1] else {
4131            panic!("expected after");
4132        };
4133        assert_eq!(after.predicate, AfterPredicate::Succeeds);
4134        assert_eq!(after.alias.as_deref(), Some("done"));
4135        assert!(matches!(after.body[1], BodyStmt::After(_)));
4136    }
4137
4138    #[test]
4139    fn parses_after_times_out_branch() {
4140        let ast = parse_ok(
4141            "exec \"report.sh\" -> Report as job\n\nafter job times out as t {\n  cancel job\n}",
4142        );
4143        let BodyStmt::After(after) = &ast.statements[1] else {
4144            panic!("expected after");
4145        };
4146        assert_eq!(after.predicate, AfterPredicate::TimedOut);
4147        assert_eq!(after.predicate.as_str(), "times out");
4148        assert_eq!(after.alias.as_deref(), Some("t"));
4149    }
4150
4151    #[test]
4152    fn parses_after_cancelled_branch() {
4153        let ast = parse_ok(
4154            "exec \"report.sh\" -> Report as job\n\nafter job cancelled as c {\n  cancel job\n}",
4155        );
4156        let BodyStmt::After(after) = &ast.statements[1] else {
4157            panic!("expected after");
4158        };
4159        assert_eq!(after.predicate, AfterPredicate::Cancelled);
4160        assert_eq!(after.predicate.as_str(), "cancelled");
4161        assert_eq!(after.alias.as_deref(), Some("c"));
4162    }
4163
4164    #[test]
4165    fn rejects_times_without_out() {
4166        let (_, diagnostics) = parse_rule_body("after job times { cancel job }", 0);
4167        assert!(diagnostics
4168            .iter()
4169            .any(|d| d.message.contains("expected `out` after `times`")));
4170    }
4171
4172    #[test]
4173    fn rejects_unknown_after_predicate() {
4174        let (_, diagnostics) = parse_rule_body("after job explodes { cancel job }", 0);
4175        assert!(diagnostics.iter().any(|d| d
4176            .message
4177            .contains("unsupported `after` predicate `explodes`")));
4178    }
4179
4180    #[test]
4181    fn parses_timer_and_cancel() {
4182        let ast =
4183            parse_ok("timer 24h as deadline\n\nafter deadline succeeds {\n  cancel signoff\n}");
4184        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4185            panic!("expected timer effect");
4186        };
4187        assert!(matches!(
4188            effect.kind,
4189            BodyEffectKind::Timer {
4190                duration_seconds: 86400,
4191                ..
4192            }
4193        ));
4194        let BodyStmt::After(after) = &ast.statements[1] else {
4195            panic!("expected after");
4196        };
4197        assert!(matches!(after.body[0], BodyStmt::Cancel { .. }));
4198    }
4199
4200    #[test]
4201    fn parses_decide_with_result_shape() {
4202        let ast = parse_ok("decide \"Fixed?\" -> { fixed bool, reason string } as verdict");
4203        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4204            panic!("expected effect");
4205        };
4206        let BodyEffectKind::Decide { result_fields } = &effect.kind else {
4207            panic!("expected decide");
4208        };
4209        assert_eq!(result_fields.len(), 2);
4210        assert_eq!(effect.binding.as_deref(), Some("verdict"));
4211    }
4212
4213    #[test]
4214    fn parses_tracker_verbs() {
4215        let ast = parse_ok(
4216            "file issue into backlog {\n  title \"Fix login\"\n  body \"Repro...\"\n}\n\nclaim item as lease\nrelease item\nfinish item {\n  summary turn.summary\n}",
4217        );
4218        assert_eq!(ast.statements.len(), 4);
4219        assert!(matches!(
4220            &ast.statements[0],
4221            BodyStmt::Effect(EffectStmt { kind: BodyEffectKind::TrackerFile { queue, .. }, .. }) if queue == "backlog"
4222        ));
4223        assert!(matches!(
4224            &ast.statements[1],
4225            BodyStmt::Effect(EffectStmt { kind: BodyEffectKind::TrackerClaim { .. }, binding: Some(b), .. }) if b == "lease"
4226        ));
4227    }
4228
4229    #[test]
4230    fn parses_exec() {
4231        let ast = parse_ok("exec \"scripts/run-tests.sh\" as tests timeout 5m");
4232        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4233            panic!("expected effect");
4234        };
4235        assert!(matches!(&effect.kind, BodyEffectKind::Exec {
4236                target: ExecTarget::RawCommand(command),
4237                ..
4238            } if command == "scripts/run-tests.sh"));
4239        assert_eq!(effect.timeout_seconds, Some(300));
4240    }
4241
4242    #[test]
4243    fn parses_coerce_endorsed_marker() {
4244        // the trailing `endorsed` source marker (I-IFC3) sets the flag.
4245        let ast = parse_ok("coerce classify(msg.content) as verdict endorsed");
4246        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4247            panic!("expected effect");
4248        };
4249        assert!(matches!(
4250            &effect.kind,
4251            BodyEffectKind::Coerce { name, endorsed: true, .. } if name == "classify"
4252        ));
4253        // without the marker, the flag is false.
4254        let plain = parse_ok("coerce classify(msg.content) as verdict");
4255        let BodyStmt::Effect(effect) = &plain.statements[0] else {
4256            panic!("expected effect");
4257        };
4258        assert!(matches!(
4259            &effect.kind,
4260            BodyEffectKind::Coerce {
4261                endorsed: false,
4262                declassified: false,
4263                ..
4264            }
4265        ));
4266        // `declassified` sets its flag; both markers may appear together.
4267        let both = parse_ok("coerce classify(msg.content) as verdict endorsed declassified");
4268        let BodyStmt::Effect(effect) = &both.statements[0] else {
4269            panic!("expected effect");
4270        };
4271        assert!(matches!(
4272            &effect.kind,
4273            BodyEffectKind::Coerce {
4274                endorsed: true,
4275                declassified: true,
4276                ..
4277            }
4278        ));
4279    }
4280
4281    #[test]
4282    fn parses_exec_capability() {
4283        let ast = parse_ok("exec backup_repo with request -> Report as result");
4284        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4285            panic!("expected effect");
4286        };
4287        assert!(matches!(&effect.kind, BodyEffectKind::Exec {
4288            target: ExecTarget::Capability { name, stdin_binding },
4289            parse_target: Some(ExecParse { schema, each: false }),
4290        } if name == "backup_repo" && stdin_binding == "request" && schema == "Report"));
4291        assert_eq!(effect.binding.as_deref(), Some("result"));
4292    }
4293
4294    #[test]
4295    fn parses_case_with_branches() {
4296        let ast = parse_ok(
4297            "after turn completes {\n  case turn {\n    Completed as done => {\n      record Ok {\n        summary done.summary\n      }\n    }\n    Failed as failure => {\n      record Bad {\n        reason failure.reason\n      }\n    }\n  }\n}",
4298        );
4299        let BodyStmt::After(after) = &ast.statements[0] else {
4300            panic!("expected after");
4301        };
4302        let BodyStmt::Case(case) = &after.body[0] else {
4303            panic!("expected case");
4304        };
4305        assert_eq!(case.branches.len(), 2);
4306        assert_eq!(case.branches[0].pattern, "Completed");
4307        assert_eq!(case.branches[0].binding.as_deref(), Some("done"));
4308    }
4309
4310    #[test]
4311    fn rule_mode_rejects_flow_statements() {
4312        let (_, diagnostics) = parse_rule_body("on fails {\n  cancel x\n}", 0);
4313        assert!(diagnostics
4314            .iter()
4315            .any(|d| d.message.contains("not rule body statements")));
4316    }
4317
4318    #[test]
4319    fn unknown_effect_modifier_is_rejected_with_span() {
4320        let (_, diagnostics) = parse_rule_body("tell worker as turn frobnicate \"go\"", 0);
4321        assert!(
4322            diagnostics
4323                .iter()
4324                .any(|d| d.message.contains("expected a prompt string")),
4325            "{diagnostics:?}"
4326        );
4327    }
4328
4329    #[test]
4330    fn from_block_supports_shorthand_and_overrides() {
4331        let ast = parse_ok(
4332            "done task -> record ReviewedPoem from task {\n  provider poet\n  language\n  topic\n  turn poemTurn\n  status \"reviewed\"\n}",
4333        );
4334        let BodyStmt::Done {
4335            replacement: Some(record),
4336            ..
4337        } = &ast.statements[0]
4338        else {
4339            panic!("expected replacement record");
4340        };
4341        assert_eq!(record.from.as_deref(), Some("task"));
4342        let names: Vec<_> = record.fields.iter().map(|f| f.name.as_str()).collect();
4343        assert_eq!(
4344            names,
4345            vec!["provider", "language", "topic", "turn", "status"]
4346        );
4347        assert!(matches!(record.fields[1].value, FieldValue::Shorthand));
4348        assert!(matches!(record.fields[3].value, FieldValue::Expr { .. }));
4349    }
4350
4351    #[test]
4352    fn invoke_with_nested_payload() {
4353        let ast = parse_ok(
4354            "invoke ReviewPhase {\n  phase PhaseReviewRequest {\n    id phase.id\n    title phase.title\n  }\n} as review",
4355        );
4356        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4357            panic!("expected effect");
4358        };
4359        let BodyEffectKind::Invoke {
4360            workflow, payload, ..
4361        } = &effect.kind
4362        else {
4363            panic!("expected invoke");
4364        };
4365        assert_eq!(workflow, "ReviewPhase");
4366        assert!(matches!(payload[0].value, FieldValue::Nested { .. }));
4367    }
4368
4369    #[test]
4370    fn parses_invoke_with_access_grants() {
4371        let ast = parse_ok(
4372            "invoke Child {\n  task Task { id ticket.id }\n}\n  with access to project_files {\n    read [\"docs/**\"]\n  }\n  as child",
4373        );
4374        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4375            panic!("expected effect");
4376        };
4377        let BodyEffectKind::Invoke {
4378            workflow,
4379            payload,
4380            access_grants,
4381        } = &effect.kind
4382        else {
4383            panic!("expected invoke");
4384        };
4385        assert_eq!(workflow, "Child");
4386        assert_eq!(effect.binding.as_deref(), Some("child"));
4387        assert!(matches!(payload[0].value, FieldValue::Nested { .. }));
4388        assert_eq!(access_grants.len(), 1);
4389        assert_eq!(access_grants[0].resource, "project_files");
4390        assert_eq!(access_grants[0].operations[0].operation, "read");
4391        assert_eq!(
4392            access_grants[0].operations[0].globs,
4393            vec!["docs/**".to_owned()]
4394        );
4395    }
4396
4397    #[test]
4398    fn parses_invoke_with_resource_less_access_grant_shorthand() {
4399        let ast = parse_ok(
4400            "invoke Child {\n  task Task { id ticket.id }\n}\n  with access to {\n    project_memory {\n      recall for ticket\n    }\n    project_files {\n      read [\"docs/**\"]\n    }\n  }\n  as child",
4401        );
4402        let BodyStmt::Effect(effect) = &ast.statements[0] else {
4403            panic!("expected effect");
4404        };
4405        let BodyEffectKind::Invoke { access_grants, .. } = &effect.kind else {
4406            panic!("expected invoke");
4407        };
4408        assert_eq!(effect.binding.as_deref(), Some("child"));
4409        assert_eq!(access_grants.len(), 2);
4410
4411        let memory = &access_grants[0];
4412        assert_eq!(memory.resource, "project_memory");
4413        assert_eq!(memory.operations[0].operation, "recall");
4414        assert_eq!(memory.operations[0].target.as_deref(), Some("ticket"));
4415
4416        let files = &access_grants[1];
4417        assert_eq!(files.resource, "project_files");
4418        assert_eq!(files.operations[0].operation, "read");
4419        assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
4420    }
4421
4422    #[test]
4423    fn rejects_empty_resource_less_access_grant_shorthand() {
4424        let (_, diagnostics) = parse_rule_body(
4425            "invoke Child { task task }\n  with access to {\n  }\n  as child",
4426            0,
4427        );
4428        assert!(
4429            diagnostics
4430                .iter()
4431                .any(|d| d.message.contains("grants no resources")),
4432            "{diagnostics:?}"
4433        );
4434    }
4435
4436    #[test]
4437    fn single_line_terminal_payload_parses() {
4438        let ast = parse_ok("complete result { total 2 }");
4439        let BodyStmt::Terminal(terminal) = &ast.statements[0] else {
4440            panic!("expected terminal");
4441        };
4442        assert_eq!(terminal.fields.len(), 1);
4443        assert_eq!(terminal.fields[0].name, "total");
4444    }
4445
4446    #[test]
4447    fn spans_are_absolute() {
4448        let (ast, _) = parse_rule_body("record Item {\n  id \"a\"\n}", 100);
4449        let BodyStmt::Record(record) = &ast.statements[0] else {
4450            panic!("expected record");
4451        };
4452        assert_eq!(record.span.start, 100);
4453        assert!(record.span.end > 100);
4454    }
4455}