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