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