Skip to main content

whipplescript_parser/
lib.rs

1//! Source parser for `.whip` programs.
2//!
3//! The v0 grammar is still stabilizing, so this crate uses a small
4//! hand-written parser. It preserves source spans and keeps rule/effect bodies
5//! as source text until the typed IR is ready to lower them.
6
7mod action_expand;
8pub mod body;
9mod body_print;
10mod then_expand;
11
12use std::{
13    collections::{BTreeMap, BTreeSet, VecDeque},
14    fmt,
15};
16use whipplescript_core::{
17    ContractRegistry, EffectContract, LibraryRegistration, TypedOutputValidation,
18};
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct SourceSpan {
22    pub start: usize,
23    pub end: usize,
24}
25
26impl SourceSpan {
27    fn join(self, other: Self) -> Self {
28        Self {
29            start: self.start,
30            end: other.end,
31        }
32    }
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct Diagnostic {
37    pub span: SourceSpan,
38    pub message: String,
39    pub suggestion: Option<String>,
40    /// Secondary spans carrying supporting context (spec/error-handling.md "Spans
41    /// And Labels"): a `note`-style related-information label pointing at a
42    /// definition, prior claim, or other related site. Empty for most
43    /// diagnostics; surfaced in CLI text, JSON reports, and LSP
44    /// `relatedInformation`.
45    pub related: Vec<RelatedInfo>,
46}
47
48/// A secondary span + short label attached to a [`Diagnostic`] as related
49/// information (never a top-level diagnostic of its own).
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct RelatedInfo {
52    pub span: SourceSpan,
53    pub message: String,
54}
55
56impl Diagnostic {
57    /// Attaches a related-information label at `span` (builder style, so the
58    /// common no-related case stays a plain struct literal that only needs the
59    /// new field defaulted).
60    pub fn with_related(mut self, span: SourceSpan, message: impl Into<String>) -> Self {
61        self.related.push(RelatedInfo {
62            span,
63            message: message.into(),
64        });
65        self
66    }
67}
68
69/// The marker that introduced a comment, preserved so a formatter can re-emit it
70/// faithfully.
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub enum CommentMarker {
73    /// `# …`
74    Hash,
75    /// `// …`
76    Slash,
77}
78
79/// A source comment captured by the lexer. Comments are kept out of the token
80/// stream (so the parser is unaffected) but retained here so tooling — `whip fmt`,
81/// the LSP — can preserve them. `text` is the trimmed content after the marker;
82/// `span` covers the marker through end of line (exclusive of the newline).
83#[derive(Clone, Debug, Eq, PartialEq)]
84pub struct Comment {
85    pub marker: CommentMarker,
86    pub text: String,
87    pub span: SourceSpan,
88}
89
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub struct Ident {
92    pub name: String,
93    pub span: SourceSpan,
94}
95
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct StringLiteral {
98    pub value: String,
99    pub span: SourceSpan,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct Program {
104    pub workflow: Option<Ident>,
105    pub workflow_tags: Vec<TagDecl>,
106    pub workflow_description: Option<StringLiteral>,
107    pub explicit_workflow_body: bool,
108    pub workflows: Vec<WorkflowDecl>,
109    pub patterns: Vec<PatternDecl>,
110    pub items: Vec<Item>,
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct WorkflowDecl {
115    pub name: Ident,
116    pub tags: Vec<TagDecl>,
117    pub description: Option<StringLiteral>,
118    pub items: Vec<Item>,
119    pub span: SourceSpan,
120}
121
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub enum Item {
124    Include(IncludeDecl),
125    Use(UseDecl),
126    Pattern(PatternDecl),
127    Apply(ApplyDecl),
128    WorkflowContract(WorkflowContractDecl),
129    Harness(HarnessDecl),
130    Tracker(TrackerDecl),
131    Channel(ChannelDecl),
132    Gauge(GaugeDecl),
133    Mark(MarkDecl),
134    Campaign(CampaignDecl),
135    FileStore(FileStoreDecl),
136    MemoryPool(MemoryPoolDecl),
137    Action(ActionDecl),
138    Agent(AgentDecl),
139    Enum(EnumDecl),
140    Event(EventDecl),
141    // Boxed: SourceDecl carries the ingress path/url/emit surface and is by far
142    // the largest Item variant; boxing keeps the enum small (clippy large_enum_variant).
143    Source(Box<SourceDecl>),
144    Test(TestDecl),
145    Lease(LeaseDecl),
146    Ledger(LedgerDecl),
147    Counter(CounterDecl),
148    Class(ClassDecl),
149    Table(TableDecl),
150    Coerce(CoerceDecl),
151    Assert(AssertDecl),
152    Rule(RuleDecl),
153}
154
155impl Item {
156    /// Source span of this top-level item, used to interleave preserved comments.
157    fn span(&self) -> SourceSpan {
158        match self {
159            Self::Include(decl) => decl.path.span,
160            Self::Use(decl) => decl.name.span,
161            Self::Pattern(decl) => decl.span,
162            Self::Apply(decl) => decl.span,
163            Self::WorkflowContract(decl) => decl.span,
164            Self::Harness(decl) => decl.span,
165            Self::Tracker(decl) => decl.span,
166            Self::Channel(decl) => decl.span,
167            Self::Gauge(decl) => decl.span,
168            Self::Mark(decl) => decl.span,
169            Self::Campaign(decl) => decl.span,
170            Self::FileStore(decl) => decl.span,
171            Self::MemoryPool(decl) => decl.span,
172            Self::Action(decl) => decl.span,
173            Self::Agent(decl) => decl.span,
174            Self::Enum(decl) => decl.span,
175            Self::Event(decl) => decl.span,
176            Self::Source(decl) => decl.span,
177            Self::Test(decl) => decl.span,
178            Self::Lease(decl) => decl.span,
179            Self::Ledger(decl) => decl.span,
180            Self::Counter(decl) => decl.span,
181            Self::Class(decl) => decl.span,
182            Self::Table(decl) => decl.span,
183            Self::Coerce(decl) => decl.span,
184            Self::Assert(decl) => decl.span,
185            Self::Rule(decl) => decl.span,
186        }
187    }
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct PatternDecl {
192    pub name: Ident,
193    pub type_params: Vec<Ident>,
194    pub items: Vec<Item>,
195    pub span: SourceSpan,
196}
197
198#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct ApplyDecl {
200    pub pattern: Ident,
201    pub type_args: Vec<TypeSyntax>,
202    pub alias: Ident,
203    pub body: BlockSource,
204    pub span: SourceSpan,
205}
206
207#[derive(Clone, Debug, Eq, PartialEq)]
208pub struct IncludeDecl {
209    pub path: StringLiteral,
210}
211
212#[derive(Clone, Debug, Eq, PartialEq)]
213pub struct WorkflowContractDecl {
214    pub kind: WorkflowContractKind,
215    pub name: Ident,
216    pub ty: TypeSyntax,
217    pub span: SourceSpan,
218}
219
220#[derive(Clone, Debug, Eq, PartialEq)]
221pub enum WorkflowContractKind {
222    Input,
223    Output,
224    Failure,
225}
226
227impl WorkflowContractKind {
228    fn as_str(&self) -> &'static str {
229        match self {
230            Self::Input => "input",
231            Self::Output => "output",
232            Self::Failure => "failure",
233        }
234    }
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
238pub struct AssertDecl {
239    pub tags: Vec<TagDecl>,
240    pub description: Option<StringLiteral>,
241    pub expr: String,
242    pub span: SourceSpan,
243}
244
245#[derive(Clone, Debug, Eq, PartialEq)]
246pub struct TagDecl {
247    pub name: String,
248    pub span: SourceSpan,
249}
250
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct UseDecl {
253    pub name: StringLiteral,
254}
255
256#[derive(Clone, Debug, Eq, PartialEq)]
257pub struct HarnessDecl {
258    pub name: Ident,
259    pub kind: Ident,
260    pub span: SourceSpan,
261}
262
263#[derive(Clone, Debug, Eq, PartialEq)]
264pub struct TrackerDecl {
265    pub name: Ident,
266    pub provider: Ident,
267    pub span: SourceSpan,
268}
269
270/// `channel <name> { provider <p> [workspace <w>] [destination "<d>"] }`
271/// (std.messaging): a named communication route through a provider. The bare
272/// `channel` construct shape is reserved by the platform for `std.messaging`
273/// (spec/messaging.md), so third-party packages cannot author channel-like
274/// semantics with weaker guarantees. Lowers to a `metadata_only` declaration
275/// (like `queue`); the runtime messaging provider is later-stage work.
276#[derive(Clone, Debug, Eq, PartialEq)]
277pub struct ChannelDecl {
278    pub name: Ident,
279    pub provider: Ident,
280    pub workspace: Option<Ident>,
281    pub destination: Option<StringLiteral>,
282    pub span: SourceSpan,
283}
284
285/// `mark "<name>" after <site>` (experimentation subsystem §4.2): a named
286/// cut point. The runtime stamps a `mark.reached` event when the named
287/// site commits on any run, so every run's meaningful moments are
288/// addressable — `whip pin <run> at <mark>` freezes the prefix as a
289/// scenario, and regeneration replays that prefix and re-executes only
290/// the suffix. Names are stable across edits (event offsets shift, marks
291/// don't). Deliberately a separate declaration from `milestone`
292/// (child→parent lifecycle signaling vs. event-log position).
293#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct MarkDecl {
295    pub name: StringLiteral,
296    /// The committing site the cut rides: a rule name (dotted for
297    /// flow-generated segments).
298    pub site: String,
299    pub site_span: SourceSpan,
300    pub span: SourceSpan,
301}
302
303/// `gauge <name> [on <site>] { judge via ... [expect ...] [inputs ...] }`
304/// (experimentation subsystem §4.2): a named quality dimension — a site, a
305/// judge, optionally a bar. The sibling of `test`: deterministic expectation
306/// vs. stochastic expectation, one family. Core grammar (hand-parsed): the
307/// `judge via` tagged union and the bar form are outside the declaration
308/// family's shape. Bars use the word forms `at least` / `at most` because
309/// the declaration tokenizer deliberately steps over `>=`/`<=` (the same
310/// reason field presence conditions use `is`).
311#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct GaugeDecl {
313    pub name: Ident,
314    /// Optional `on <dotted.site>` designation. v1 records it (identity and
315    /// forward-compat with site-scoped judging); ambient scoring judges the
316    /// run's terminal view.
317    pub site: Option<String>,
318    pub site_span: Option<SourceSpan>,
319    pub judge: GaugeJudge,
320    pub expect: Option<GaugeBar>,
321    /// Derived gauges: other gauges whose scores feed this gauge's exec
322    /// judge (`inputs a, b`). Deterministic composition — the settled cure
323    /// for composite objectives (no weights feature, ever).
324    pub inputs: Vec<GaugeRef>,
325    pub span: SourceSpan,
326}
327
328/// The generalized judge slot: `judge via coerce <Name>(<args>) |
329/// prompt "<t>" | exec "<cmd>" | labels "<source>"`. Coerce judges carry
330/// EXPLICIT argument paths (settled 2026-07-14): each names the record
331/// value feeding the parameter (`input.ticket.title`,
332/// `facts.Assessment.priority`), or the single reserved `record` passes
333/// the whole judge-input record — the binding is written down and
334/// versioned, never inferred.
335#[derive(Clone, Debug, Eq, PartialEq)]
336pub enum GaugeJudge {
337    Coerce(Ident, Vec<String>),
338    Prompt(StringLiteral),
339    Exec(StringLiteral),
340    Labels(StringLiteral),
341}
342
343/// An optional bar: the default decision bar for settle/campaign gates.
344/// `expect P(<field>) at least 0.9` (chance-shaped) or
345/// `expect p10 at least 0.7` / `expect mean at most 800` (stat-shaped).
346/// Thresholds keep their exact source text (`Eq`-safe, format-exact);
347/// consumers parse.
348#[derive(Clone, Debug, Eq, PartialEq)]
349pub struct GaugeBar {
350    pub subject: GaugeBarSubject,
351    /// `true` = `at least`, `false` = `at most`.
352    pub at_least: bool,
353    pub threshold: String,
354    pub span: SourceSpan,
355}
356
357#[derive(Clone, Debug, Eq, PartialEq)]
358pub enum GaugeBarSubject {
359    /// `P(<field>)`: probability the judge's boolean output field holds.
360    Chance { field: Ident },
361    /// A named statistic of the score distribution: `mean`, `p10`, `p90`, …
362    Stat { stat: Ident },
363}
364
365/// A (possibly dotted) gauge reference: user gauges are bare idents, the
366/// built-in resource gauges are namespaced (`std.spend` / `std.latency` /
367/// `std.tokens`).
368#[derive(Clone, Debug, Eq, PartialEq)]
369pub struct GaugeRef {
370    pub name: String,
371    pub span: SourceSpan,
372}
373
374/// `campaign <name> { ascend … [reach …] [guard …] [sacrifice …] }`
375/// (improve design note §3): versioned, diffable objective intent at higher
376/// ceremony than a CLI invocation — the partition of the gauge vector.
377/// Unnamed gauges are guarded by default; `guard` widens a band, `sacrifice`
378/// releases a gauge, `reach` sets a target that becomes a hard bound.
379#[derive(Clone, Debug, Eq, PartialEq)]
380pub struct CampaignDecl {
381    pub name: Ident,
382    pub ascend: Vec<GaugeRef>,
383    pub reach: Vec<CampaignReach>,
384    pub guard: Vec<CampaignGuard>,
385    pub sacrifice: Vec<GaugeRef>,
386    /// `proposer redacted`: campaign-attached stratified reflection — the
387    /// proposer sees aggregates only, never scenario contents (leakage
388    /// policy, improve note §7; settled 2026-07-11).
389    pub proposer_redacted: bool,
390    pub span: SourceSpan,
391}
392
393/// `reach <gauge> at least 0.9` / `reach std.latency at most 800ms`.
394#[derive(Clone, Debug, Eq, PartialEq)]
395pub struct CampaignReach {
396    pub gauge: GaugeRef,
397    pub at_least: bool,
398    pub threshold: String,
399    /// Optional trailing unit ident (`ms`, `s`); recorded verbatim.
400    pub unit: Option<String>,
401    pub span: SourceSpan,
402}
403
404/// `guard <gauge> within 2 percent`: an indifference-band override.
405#[derive(Clone, Debug, Eq, PartialEq)]
406pub struct CampaignGuard {
407    pub gauge: GaugeRef,
408    pub band_percent: String,
409    pub span: SourceSpan,
410}
411
412/// `file store <name> { root "<dir>" }` (std.files): a capability-scoped file
413/// store identity with a literal root directory. v0 is a local storage boundary.
414#[derive(Clone, Debug, Eq, PartialEq)]
415pub struct FileStoreDecl {
416    pub name: Ident,
417    pub root: String,
418    pub read_globs: Vec<String>,
419    pub write_globs: Vec<String>,
420    /// Optional `provider <name>` clause (std.files v1, spec/std-files.md
421    /// "Surface"): the store's backing provider, defaulting to `local` when
422    /// absent. Unknown providers are rejected at check time.
423    pub provider: Option<Ident>,
424    /// Source spans of each clause keyword (`root` / the `allow` of read / write),
425    /// so `whip fmt` can interleave own-line and trailing body comments by position
426    /// (the body otherwise rebuilds from the AST, dropping comments).
427    pub root_span: Option<SourceSpan>,
428    pub read_span: Option<SourceSpan>,
429    pub write_span: Option<SourceSpan>,
430    pub provider_span: Option<SourceSpan>,
431    pub span: SourceSpan,
432}
433
434/// `memory pool <name> { context limit <n> }` (std.memory, MEM-1): a named
435/// durable memory place. Mirrors `file store` as a `declaration_block` /
436/// `metadata_only` construct providing `Resource<MemoryPool>`. v1 pools are
437/// provider-less; `context limit <n>` (optional, non-negative) is the recall
438/// packing budget. Unknown clauses are rejected (file-store precedent).
439#[derive(Clone, Debug, Eq, PartialEq)]
440pub struct MemoryPoolDecl {
441    pub name: Ident,
442    pub context_limit: Option<u64>,
443    /// Source span of the `context` clause keyword, so `whip fmt` can interleave
444    /// body comments by position (file-store precedent).
445    pub context_limit_span: Option<SourceSpan>,
446    pub span: SourceSpan,
447}
448
449/// One typed parameter of an `action` template (DR-0023).
450#[derive(Clone, Debug, Eq, PartialEq)]
451pub struct ActionParam {
452    pub name: Ident,
453    pub ty: TypeSyntax,
454    pub span: SourceSpan,
455}
456
457/// `action <name>(<param: type>, …) { <effect chain> }` (DR-0023): a static,
458/// hygienic, inline-expanded template over rule-body effect chains. Consumed by
459/// `expand_action_calls` before lowering; never a runtime construct.
460#[derive(Clone, Debug, Eq, PartialEq)]
461pub struct ActionDecl {
462    pub name: Ident,
463    pub params: Vec<ActionParam>,
464    pub body: BlockSource,
465    pub span: SourceSpan,
466}
467
468#[derive(Clone, Debug, Eq, PartialEq)]
469pub struct AgentDecl {
470    pub name: Ident,
471    pub harness: Option<Ident>,
472    /// `agent Foo delegated to <provider>` (DR-0034 Decision 2): the surface
473    /// spelling of a Delegated agent. Names the foreign provider kind directly;
474    /// `agent Foo { … }` without it is Managed by default.
475    pub delegated_to: Option<Ident>,
476    pub fields: Vec<AgentField>,
477    pub span: SourceSpan,
478}
479
480#[derive(Clone, Debug, Eq, PartialEq)]
481pub enum AgentField {
482    Provider(Ident),
483    Profile(StringLiteral),
484    Capacity(u32, SourceSpan),
485    Skills(Vec<StringLiteral>, SourceSpan),
486    Capabilities(Vec<StringLiteral>, SourceSpan),
487    /// `requires [session.resume, turn.cancel]`: portable feature-class
488    /// requirements (DR-0015 taxonomy; spec/std-agent.md slice 6). Entries are
489    /// dotted feature-class names, validated for taxonomy membership at
490    /// lowering and against the provider's feature report by the CLI.
491    Requires(Vec<Ident>, SourceSpan),
492    /// `tools [Foo, Bar]`: the workflows this agent may invoke as typed tools
493    /// (DR-0025). Entries are workflow names resolved against the program/packages.
494    Tools(Vec<Ident>, SourceSpan),
495    /// `compaction <strategy>`: the owned-harness conversation-compaction strategy
496    /// (context-assembly Phase 5). One of `summarize`, `hard_reset`, `tool_results`,
497    /// `none`.
498    Compaction(Ident),
499    /// `thread <mode>`: owned-harness conversation continuation across tells
500    /// (the chat-shaped instance v1). `continue` seeds each
501    /// new tell from the agent's latest completed-turn transcript in this
502    /// instance; `fresh` (the default) starts every tell from scratch.
503    Thread(Ident),
504    /// `settings <sources>`: which ambient-config sources a Delegated harness may
505    /// read when assembling its own context (DR-0034 Decision 4). One of `project`,
506    /// `user`, `none`. Unset means the provider's own default.
507    Settings(Ident),
508    Unknown {
509        name: Ident,
510        span: SourceSpan,
511    },
512}
513
514#[derive(Clone, Debug, Eq, PartialEq)]
515pub struct EnumDecl {
516    pub name: Ident,
517    pub variants: Vec<EnumVariantDecl>,
518    pub span: SourceSpan,
519}
520
521/// One enum variant: bare (`Accept`) or data-carrying with a brace body that
522/// reuses the class field grammar (sum types, spec/sum-types.md).
523#[derive(Clone, Debug, Eq, PartialEq)]
524pub struct EnumVariantDecl {
525    pub name: Ident,
526    pub fields: Vec<ClassField>,
527    pub span: SourceSpan,
528}
529
530#[derive(Clone, Debug, Eq, PartialEq)]
531pub struct ClassDecl {
532    pub name: Ident,
533    pub fields: Vec<ClassField>,
534    pub span: SourceSpan,
535}
536
537/// Coordination resources (spec/coordination.md): a closed family of shared,
538/// workspace-scoped resources with typed keys, atomic branchable operations,
539/// and mandatory bounds (`ttl`/`retain`/`cap`+`reset`).
540#[derive(Clone, Debug, Eq, PartialEq)]
541pub struct LeaseDecl {
542    pub name: Ident,
543    pub key_type: Ident,
544    pub slots: u32,
545    pub ttl_seconds: u64,
546    pub shared: bool,
547    pub span: SourceSpan,
548}
549
550#[derive(Clone, Debug, Eq, PartialEq)]
551pub struct LedgerDecl {
552    pub name: Ident,
553    pub entry_schema: Ident,
554    pub partition_field: Ident,
555    pub retain_seconds: u64,
556    pub shared: bool,
557    pub span: SourceSpan,
558}
559
560#[derive(Clone, Debug, Eq, PartialEq)]
561pub struct CounterDecl {
562    pub name: Ident,
563    pub key_type: Ident,
564    pub cap: i64,
565    pub reset: String,
566    /// IANA timezone anchoring the reset-period boundary (std.coord slice 3);
567    /// `None` anchors to UTC and draws a default-UTC warning.
568    pub timezone: Option<String>,
569    pub shared: bool,
570    pub span: SourceSpan,
571}
572
573/// A typed external-signal declaration (`signal deploy.finished { ... }`):
574/// the ingress manifest naming a dotted event and its payload schema
575/// (spec/event-ingress.md).
576#[derive(Clone, Debug, Eq, PartialEq)]
577pub struct EventDecl {
578    /// Dotted lowercase signal name (`deploy.finished`).
579    pub name: String,
580    pub name_span: SourceSpan,
581    pub fields: Vec<ClassField>,
582    pub span: SourceSpan,
583}
584
585#[derive(Clone, Debug, Eq, PartialEq)]
586pub struct ClassField {
587    pub name: Ident,
588    pub ty: TypeSyntax,
589    /// `@key`: this field is the class's natural key (used for import per-row
590    /// idempotency, spec/std-library/files.md). At most one per class in v0.
591    pub is_key: bool,
592    /// Family B (discriminant-string schemas): `<field> <Type> when <disc> == "<lit>"`
593    /// — this field is present only when the literal-union discriminant field `disc`
594    /// equals `lit`. `(discriminant field name, required literal)`.
595    pub presence_condition: Option<(String, String)>,
596    pub span: SourceSpan,
597}
598
599/// A top-level source declaration: `source <provider> as <name> { ... }` or
600/// `source clock as <name> { ... }`. Lowers through the `source_declaration`
601/// construct family to a `signal_source` (generic provider) or `clock_source`
602/// (the `clock` provider) admission template (spec/std-time.md,
603/// spec/construct-grammar.md). A source admits a durable signal fact; it never
604/// fires a rule directly.
605#[derive(Clone, Debug, Eq, PartialEq)]
606pub struct SourceDecl {
607    /// `as <name>` — the source instance name.
608    pub name: Ident,
609    /// The provider keyword (`clock`) or a generic provider identifier.
610    pub provider: Ident,
611    /// Recurrence/timezone/missed policy; `Some` only for the `clock` provider.
612    pub clock: Option<ClockPolicy>,
613    /// `path "<file>"` — `file` provider, line mode (exactly one of
614    /// `path`/`watch`); rejected elsewhere. The file is read line-by-line; each
615    /// non-empty line is admitted once as a durable signal fact
616    /// (spec/std-time.md admission semantics, append-only).
617    pub path: Option<StringLiteral>,
618    /// `watch "<glob>"` — `file` provider, occurrence mode (exactly one of
619    /// `path`/`watch`); rejected elsewhere (spec/std-ingress.md I2a). Each
620    /// matched file is admitted once per new (path, content-hash) occurrence:
621    /// a dropped file admits once, an unchanged file never re-admits, a
622    /// content change re-admits. Content READING stays std.files.
623    pub watch: Option<StringLiteral>,
624    /// `url "<url>"` — required for the `http` provider, rejected elsewhere. The
625    /// URL is GET'd and its JSON-array body admitted one element per signal,
626    /// keyed by (source, element index) so re-polls are idempotent (append-only).
627    pub url: Option<StringLiteral>,
628    /// `dedup <observe>.<field>` — optional provider delivery-id source for
629    /// `file` (line mode) and `http` sources (spec/std-ingress.md I2a): the
630    /// named observation field becomes the admission key instead of the
631    /// positional ordinal, so a re-ordered or head-inserted feed still admits
632    /// each delivery exactly once.
633    pub dedup: Option<SourceValue>,
634    /// `observe as <binding>` — binds the provider observation schema.
635    pub observe_binding: Ident,
636    /// `emit <signal> { <field> <value> ... }` — maps the observation into the
637    /// declared signal payload.
638    pub emit: SourceEmit,
639    pub span: SourceSpan,
640}
641
642#[derive(Clone, Debug, Eq, PartialEq)]
643pub struct ClockPolicy {
644    pub recurrence: Recurrence,
645    pub timezone: Option<StringLiteral>,
646    pub missed: Option<MissedPolicy>,
647    pub span: SourceSpan,
648}
649
650/// Recurrence forms from spec/std-time.md (conservative first surface).
651#[derive(Clone, Debug, Eq, PartialEq)]
652pub enum Recurrence {
653    /// `at <hh:mm>` — a single scheduled occurrence.
654    At { time: TimeOfDay, span: SourceSpan },
655    /// `every <duration>` — interval occurrences.
656    EveryDuration {
657        seconds: u64,
658        source: String,
659        span: SourceSpan,
660    },
661    /// `every <calendar-pattern> at <hh:mm>` — calendar occurrences.
662    EveryCalendar {
663        pattern: CalendarPattern,
664        time: TimeOfDay,
665        span: SourceSpan,
666    },
667}
668
669#[derive(Clone, Copy, Debug, Eq, PartialEq)]
670pub enum CalendarPattern {
671    Day,
672    Weekday,
673    Weekly(Weekday),
674}
675
676#[derive(Clone, Copy, Debug, Eq, PartialEq)]
677pub enum Weekday {
678    Monday,
679    Tuesday,
680    Wednesday,
681    Thursday,
682    Friday,
683    Saturday,
684    Sunday,
685}
686
687#[derive(Clone, Copy, Debug, Eq, PartialEq)]
688pub struct TimeOfDay {
689    pub hour: u8,
690    pub minute: u8,
691    pub span: SourceSpan,
692}
693
694/// Missed-occurrence policy from spec/std-time.md. No silent default: a recurring
695/// source must declare one (enforced by the checker).
696#[derive(Clone, Copy, Debug, Eq, PartialEq)]
697pub enum MissedPolicy {
698    Skip,
699    Coalesce,
700    CatchUp { limit: u32 },
701}
702
703#[derive(Clone, Debug, Eq, PartialEq)]
704pub struct SourceEmit {
705    /// Dotted lowercase signal name materialized by this source.
706    pub signal: String,
707    pub signal_span: SourceSpan,
708    /// S6: `emit <signal> from <binding> [{ overrides }]` — copy the
709    /// observation's same-named fields, bounded to the signal's declared
710    /// fields, with the block overriding (the `record … from` semantics).
711    pub from: Option<Ident>,
712    pub fields: Vec<SourceEmitField>,
713    pub span: SourceSpan,
714}
715
716#[derive(Clone, Debug, Eq, PartialEq)]
717pub struct SourceEmitField {
718    pub name: Ident,
719    pub value: SourceValue,
720    pub span: SourceSpan,
721}
722
723/// A value mapped into an emitted signal field: an observation path
724/// (`tick.scheduled_at`) or a literal.
725#[derive(Clone, Debug, Eq, PartialEq)]
726pub enum SourceValue {
727    Path {
728        binding: Ident,
729        segments: Vec<Ident>,
730        span: SourceSpan,
731    },
732    String(StringLiteral),
733    Number(String, SourceSpan),
734}
735
736/// A deterministic test scenario (spec/workflow-testing.md). Validated by
737/// `whip check`; excluded from compile/run IR; executed by `whip test`.
738#[derive(Clone, Debug, Eq, PartialEq)]
739pub struct TestDecl {
740    pub name: StringLiteral,
741    /// Optional `workflow <Name>` header binding the scenario to one workflow
742    /// in a multi-workflow bundle (spec/workflow-testing.md). Single-workflow
743    /// files may omit it and bind implicitly.
744    pub workflow: Option<Ident>,
745    pub clauses: Vec<TestClause>,
746    pub span: SourceSpan,
747}
748
749#[derive(Clone, Debug, Eq, PartialEq)]
750pub enum TestClause {
751    Given(GivenClause),
752    Stub(StubClause),
753    Run(RunClause),
754    Expect(ExpectClause),
755}
756
757/// A `<field> <expr>` mapping inside a `given` record body. `value` is the source
758/// text of the expression (parsed via `parse_expression` when validated), matching
759/// how guards and assertions capture expressions.
760#[derive(Clone, Debug, Eq, PartialEq)]
761pub struct TestField {
762    pub name: Ident,
763    pub value: String,
764    pub span: SourceSpan,
765}
766
767#[derive(Clone, Debug, Eq, PartialEq)]
768pub enum GivenClause {
769    Input {
770        fields: Vec<TestField>,
771        span: SourceSpan,
772    },
773    Fact {
774        ty: Ident,
775        fields: Vec<TestField>,
776        span: SourceSpan,
777    },
778    Signal {
779        name: String,
780        fields: Vec<TestField>,
781        span: SourceSpan,
782    },
783    Clock {
784        at: StringLiteral,
785        span: SourceSpan,
786    },
787    Tracker {
788        tracker: String,
789        fields: Vec<TestField>,
790        span: SourceSpan,
791    },
792    /// `given file <store> at <path> "<content>"` seeds a fixture file in the
793    /// named `file store` so a `read` during `whip test` resolves deterministic
794    /// content (the harness redirects the store root to a temp dir).
795    File {
796        store: String,
797        path: StringLiteral,
798        content: StringLiteral,
799        span: SourceSpan,
800    },
801}
802
803/// `stub <surface…> <outcome> [record | string]`. The surface path and outcome
804/// are kept as tokens; provider-specific validation happens in the harness.
805#[derive(Clone, Debug, Eq, PartialEq)]
806pub struct StubClause {
807    /// Surface path segments (each may be dotted, e.g. `script.run`); the trailing
808    /// segment is the outcome.
809    pub surface: Vec<String>,
810    pub outcome: String,
811    pub payload: Option<StubPayload>,
812    pub span: SourceSpan,
813}
814
815#[derive(Clone, Debug, Eq, PartialEq)]
816pub enum StubPayload {
817    Record(Vec<TestField>),
818    Message(StringLiteral),
819}
820
821#[derive(Clone, Debug, Eq, PartialEq)]
822pub struct RunClause {
823    pub kind: RunKind,
824    pub span: SourceSpan,
825}
826
827#[derive(Clone, Debug, Eq, PartialEq)]
828pub enum RunKind {
829    UntilIdle,
830    UntilWorkflowCompleted,
831    UntilWorkflowFailed,
832    ForSteps(u32),
833}
834
835#[derive(Clone, Debug, Eq, PartialEq)]
836pub struct ExpectClause {
837    pub target: ExpectTarget,
838    pub span: SourceSpan,
839}
840
841#[derive(Clone, Debug, Eq, PartialEq)]
842pub enum ExpectTarget {
843    WorkflowCompleted,
844    WorkflowFailed { failure: Option<Ident> },
845    Rule { name: Ident, status: RuleStatus },
846    Effect { name: String, status: EffectStatus },
847    Diagnostic { code: String },
848    NoEffect { name: String },
849    Projection(ProjQuery),
850}
851
852#[derive(Clone, Debug, Eq, PartialEq)]
853pub enum RuleStatus {
854    Fired,
855    FiredTimes(u32),
856    DidNotFire,
857}
858
859#[derive(Clone, Debug, Eq, PartialEq)]
860pub enum EffectStatus {
861    Requested,
862    Completed,
863    Failed,
864}
865
866/// A projection query: `<noun> exists | count <predicate> is <N> | where <predicate>`.
867/// The predicate reuses the guard expression kernel, restricted to projection
868/// fields. The noun is a dotted fact name, so a scenario can assert over runtime
869/// facts such as `agent.turn.completed` as well as single-identifier user facts.
870#[derive(Clone, Debug, Eq, PartialEq)]
871pub struct ProjQuery {
872    pub noun: String,
873    pub kind: ProjQueryKind,
874    pub span: SourceSpan,
875}
876
877#[derive(Clone, Debug, Eq, PartialEq)]
878pub enum ProjQueryKind {
879    Exists,
880    Count { predicate: String, count: u32 },
881    Where { predicate: String },
882}
883
884#[derive(Clone, Debug, Eq, PartialEq)]
885pub struct TableDecl {
886    pub name: Ident,
887    pub tags: Vec<TagDecl>,
888    pub description: Option<StringLiteral>,
889    pub schema: Ident,
890    pub rows: Vec<TableRow>,
891    pub span: SourceSpan,
892}
893
894#[derive(Clone, Debug, Eq, PartialEq)]
895pub struct TableRow {
896    pub body: BlockSource,
897    pub span: SourceSpan,
898}
899
900#[derive(Clone, Debug, Eq, PartialEq)]
901pub struct CoerceDecl {
902    pub name: Ident,
903    pub params: Vec<ParamDecl>,
904    pub output: TypeSyntax,
905    pub body: BlockSource,
906    pub span: SourceSpan,
907}
908
909#[derive(Clone, Debug, Eq, PartialEq)]
910pub struct ParamDecl {
911    pub name: Ident,
912    pub ty: TypeSyntax,
913    pub span: SourceSpan,
914}
915
916#[derive(Clone, Debug, Eq, PartialEq)]
917pub enum TypeSyntax {
918    Primitive {
919        name: String,
920        span: SourceSpan,
921    },
922    LiteralString {
923        value: String,
924        span: SourceSpan,
925    },
926    Ref {
927        name: Ident,
928    },
929    AgentRef {
930        agents: Vec<Ident>,
931        span: SourceSpan,
932    },
933    Optional {
934        inner: Box<TypeSyntax>,
935        span: SourceSpan,
936    },
937    Array {
938        inner: Box<TypeSyntax>,
939        span: SourceSpan,
940    },
941    Map {
942        inner: Box<TypeSyntax>,
943        span: SourceSpan,
944    },
945    Union {
946        variants: Vec<TypeSyntax>,
947        span: SourceSpan,
948    },
949}
950
951impl TypeSyntax {
952    fn span(&self) -> SourceSpan {
953        match self {
954            Self::Primitive { span, .. }
955            | Self::LiteralString { span, .. }
956            | Self::Optional { span, .. }
957            | Self::Array { span, .. }
958            | Self::Map { span, .. }
959            | Self::Union { span, .. }
960            | Self::AgentRef { span, .. } => *span,
961            Self::Ref { name } => name.span,
962        }
963    }
964}
965
966#[derive(Clone, Debug, Eq, PartialEq)]
967pub struct RuleDecl {
968    pub name: Ident,
969    pub tags: Vec<TagDecl>,
970    pub description: Option<StringLiteral>,
971    pub whens: Vec<WhenClause>,
972    pub body: BlockSource,
973    pub span: SourceSpan,
974}
975
976#[derive(Clone, Debug, Eq, PartialEq)]
977pub struct WhenClause {
978    pub text: String,
979    pub span: SourceSpan,
980}
981
982#[derive(Clone, Debug, Eq, PartialEq)]
983pub struct BlockSource {
984    pub text: String,
985    pub span: SourceSpan,
986}
987
988#[derive(Clone, Debug, Eq, PartialEq)]
989pub struct ParseOutput {
990    pub program: Program,
991    pub diagnostics: Vec<Diagnostic>,
992}
993
994#[derive(Clone, Debug, Eq, PartialEq)]
995pub struct CompileOutput {
996    pub ir: Option<IrProgram>,
997    pub diagnostics: Vec<Diagnostic>,
998    /// Non-fatal diagnostics (deprecations, style); never block compilation.
999    pub warnings: Vec<Diagnostic>,
1000}
1001
1002#[derive(Clone, Debug, Eq, PartialEq)]
1003pub struct FormatOutput {
1004    pub formatted: Option<String>,
1005    pub diagnostics: Vec<Diagnostic>,
1006}
1007
1008#[derive(Clone, Debug, Eq, PartialEq)]
1009pub struct IrProgram {
1010    pub workflow: String,
1011    pub source_tags: Vec<IrSourceTag>,
1012    pub source_descriptions: Vec<IrSourceDescription>,
1013    pub includes: Vec<IrInclude>,
1014    pub pattern_applications: Vec<IrPatternApplication>,
1015    pub workflow_contracts: Vec<IrWorkflowContract>,
1016    pub uses: Vec<IrUse>,
1017    pub harnesses: Vec<IrHarness>,
1018    pub trackers: Vec<IrTracker>,
1019    pub channels: Vec<IrChannel>,
1020    pub gauges: Vec<IrGauge>,
1021    pub marks: Vec<IrMark>,
1022    pub campaigns: Vec<IrCampaign>,
1023    pub file_stores: Vec<IrFileStore>,
1024    pub memory_pools: Vec<IrMemoryPool>,
1025    pub events: Vec<IrEvent>,
1026    pub sources: Vec<IrSource>,
1027    pub tests: Vec<IrTest>,
1028    pub leases: Vec<IrLease>,
1029    pub ledgers: Vec<IrLedger>,
1030    pub counters: Vec<IrCounter>,
1031    pub shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
1032    pub schemas: Vec<IrSchema>,
1033    pub agents: Vec<IrAgent>,
1034    pub coerces: Vec<IrCoerce>,
1035    pub assertions: Vec<IrAssertion>,
1036    pub rules: Vec<IrRule>,
1037    pub rule_dependencies: Vec<IrRuleDependency>,
1038}
1039
1040#[derive(Clone, Debug, Eq, PartialEq)]
1041pub struct IrSharedCoordinationUsage {
1042    pub resource: String,
1043    pub workflow_principals: Vec<String>,
1044}
1045
1046#[derive(Clone, Debug, Eq, PartialEq)]
1047pub struct IrSourceTag {
1048    pub name: String,
1049    pub target_kind: String,
1050    pub target: String,
1051    pub span: SourceSpan,
1052}
1053
1054#[derive(Clone, Debug, Eq, PartialEq)]
1055pub struct IrSourceDescription {
1056    pub value: String,
1057    pub target_kind: String,
1058    pub target: String,
1059    pub span: SourceSpan,
1060}
1061
1062#[derive(Clone, Debug, Eq, PartialEq)]
1063pub struct IrPatternApplication {
1064    pub pattern: String,
1065    pub alias: String,
1066    pub type_args: Vec<IrType>,
1067    pub value_args: Vec<IrPatternArgument>,
1068    pub generated: Vec<String>,
1069    /// Source span of the `pattern <Name> { ... }` DEFINITION this application
1070    /// expanded, so provenance can point back at where the reused shape lives.
1071    pub definition_span: SourceSpan,
1072    /// Source span of the `apply <Name> as <alias> { ... }` APPLICATION site.
1073    pub application_span: SourceSpan,
1074}
1075
1076#[derive(Clone, Debug, Eq, PartialEq)]
1077pub struct IrPatternArgument {
1078    pub name: String,
1079    pub value: String,
1080}
1081
1082#[derive(Clone, Debug, Eq, PartialEq)]
1083pub struct IrWorkflowContract {
1084    pub kind: IrWorkflowContractKind,
1085    pub name: String,
1086    pub ty: IrType,
1087    pub span: SourceSpan,
1088}
1089
1090#[derive(Clone, Debug, Eq, PartialEq)]
1091pub enum IrWorkflowContractKind {
1092    Input,
1093    Output,
1094    Failure,
1095}
1096
1097impl IrWorkflowContractKind {
1098    fn as_str(&self) -> &'static str {
1099        match self {
1100            Self::Input => "input",
1101            Self::Output => "output",
1102            Self::Failure => "failure",
1103        }
1104    }
1105}
1106
1107#[derive(Clone, Debug, Eq, PartialEq)]
1108pub struct IrInclude {
1109    pub path: String,
1110    pub source_hash: Option<String>,
1111}
1112
1113#[derive(Clone, Debug, Eq, PartialEq)]
1114pub struct IrAssertion {
1115    pub expr: IrExpression,
1116    pub projection_reads: Vec<IrProjectionRead>,
1117}
1118
1119#[derive(Clone, Debug, Eq, PartialEq)]
1120pub struct IrExpression {
1121    pub source: String,
1122    pub expr: Expr,
1123    pub span: SourceSpan,
1124}
1125
1126#[derive(Clone, Debug, Eq, PartialEq)]
1127pub struct IrUse {
1128    pub kind: IrUseKind,
1129    pub name: String,
1130}
1131
1132#[derive(Clone, Debug, Eq, PartialEq)]
1133pub enum IrUseKind {
1134    Package,
1135}
1136
1137#[derive(Clone, Debug, Eq, PartialEq)]
1138pub struct IrTracker {
1139    pub name: String,
1140    pub provider: String,
1141    pub span: SourceSpan,
1142}
1143
1144/// A lowered `channel` declaration (std.messaging): the channel identity, its
1145/// provider, and optional workspace/destination config. Lowering class is
1146/// `metadata_only`; the runtime messaging provider consumes it later.
1147#[derive(Clone, Debug, Eq, PartialEq)]
1148pub struct IrChannel {
1149    pub name: String,
1150    pub provider: String,
1151    pub workspace: Option<String>,
1152    pub destination: Option<String>,
1153    pub span: SourceSpan,
1154}
1155
1156/// A lowered `mark` declaration: a named cut point riding a committing
1157/// site. `metadata_only`; the runtime stamps `mark.reached` events, the
1158/// improve store pins scenarios at them.
1159#[derive(Clone, Debug, Eq, PartialEq)]
1160pub struct IrMark {
1161    pub name: String,
1162    pub site: String,
1163    pub span: SourceSpan,
1164}
1165
1166/// A lowered `gauge` declaration (experimentation subsystem): the binding of
1167/// a judge to a quality dimension, versioning with the program. Lowering
1168/// class is `metadata_only`; the evidence engine (`whip evidence` /
1169/// `whip improve`) consumes it at runtime.
1170#[derive(Clone, Debug, Eq, PartialEq)]
1171pub struct IrGauge {
1172    pub name: String,
1173    pub site: Option<String>,
1174    /// `coerce` | `prompt` | `exec` | `labels`.
1175    pub judge_kind: String,
1176    /// The judge target: coerce name, prompt template, exec command, or
1177    /// labels source path.
1178    pub judge_target: String,
1179    /// Coerce judges only: the explicit record paths feeding the coerce
1180    /// function's parameters, in declaration order (`input.…`,
1181    /// `facts.<Class>.<field>`, or the single reserved `record`). Empty =
1182    /// declared without arguments (parses, but is not scoreable).
1183    pub judge_args: Vec<String>,
1184    pub expect: Option<IrGaugeBar>,
1185    pub inputs: Vec<String>,
1186    pub span: SourceSpan,
1187}
1188
1189/// A lowered gauge bar. `form` is `chance` (`P(field)`) or `stat`
1190/// (`p10`/`mean`/…); `op` is `>=` (`at least`) or `<=` (`at most`);
1191/// `threshold` keeps its exact source text — consumers parse.
1192#[derive(Clone, Debug, Eq, PartialEq)]
1193pub struct IrGaugeBar {
1194    pub form: String,
1195    pub subject: String,
1196    pub op: String,
1197    pub threshold: String,
1198}
1199
1200/// A lowered `campaign` declaration (improve design note §3): the named,
1201/// versioned partition of the gauge vector. `metadata_only`; consumed by
1202/// `whip improve <name>`.
1203#[derive(Clone, Debug, Eq, PartialEq)]
1204pub struct IrCampaign {
1205    pub name: String,
1206    pub ascend: Vec<String>,
1207    pub reach: Vec<IrCampaignReach>,
1208    pub guard: Vec<IrCampaignGuard>,
1209    pub sacrifice: Vec<String>,
1210    /// Campaign-attached stratified reflection (`proposer redacted`).
1211    pub proposer_redacted: bool,
1212    pub span: SourceSpan,
1213}
1214
1215#[derive(Clone, Debug, Eq, PartialEq)]
1216pub struct IrCampaignReach {
1217    pub gauge: String,
1218    pub op: String,
1219    pub threshold: String,
1220    pub unit: Option<String>,
1221}
1222
1223#[derive(Clone, Debug, Eq, PartialEq)]
1224pub struct IrCampaignGuard {
1225    pub gauge: String,
1226    pub band_percent: String,
1227}
1228
1229/// A lowered `file store` declaration (std.files): the store identity + its
1230/// literal local root directory, consumed by the runtime file provider.
1231#[derive(Clone, Debug, Eq, PartialEq)]
1232pub struct IrFileStore {
1233    pub name: String,
1234    pub root: String,
1235    /// Path globs (relative to `root`) a `read` may touch; empty = any path
1236    /// inside the root (mounting the root is the read consent). Enforced at
1237    /// runtime in addition to root-containment.
1238    pub read_globs: Vec<String>,
1239    /// Path globs a `write` may touch. S4: stores are READ-ONLY by default —
1240    /// empty means writes are DENIED (checked at compile time and enforced
1241    /// fail-closed at runtime); declaring `allow write [...]` permits and
1242    /// bounds them.
1243    pub write_globs: Vec<String>,
1244    /// Declared `provider <name>` clause; `None` = the default `local`
1245    /// provider (spec/std-files.md "Providers"). Serialized to the snapshot
1246    /// only when declared, so provider-less stores keep their prior `.ir`.
1247    pub provider: Option<String>,
1248}
1249
1250/// A lowered `memory pool` declaration (std.memory, MEM-1): the pool identity +
1251/// its optional recall context-limit budget. `metadata_only` — provides
1252/// `Resource<MemoryPool>`; providers read `context_limit` from the effect input.
1253#[derive(Clone, Debug, Eq, PartialEq)]
1254pub struct IrMemoryPool {
1255    pub name: String,
1256    /// Optional recall packing budget (`context limit <n>`); providers read it
1257    /// from the `capability.call` effect input like any other argument.
1258    pub context_limit: Option<u64>,
1259}
1260
1261#[derive(Clone, Debug, Eq, PartialEq)]
1262pub struct IrHarness {
1263    pub name: String,
1264    pub kind: String,
1265    pub span: SourceSpan,
1266}
1267
1268#[derive(Clone, Debug, Eq, PartialEq)]
1269pub enum IrSchema {
1270    Enum(IrEnum),
1271    Class(IrClass),
1272}
1273
1274#[derive(Clone, Debug, Eq, PartialEq)]
1275pub struct IrEnum {
1276    pub name: String,
1277    pub variants: Vec<String>,
1278    pub span: SourceSpan,
1279}
1280
1281#[derive(Clone, Debug, Eq, PartialEq)]
1282pub struct IrClass {
1283    pub name: String,
1284    pub fields: Vec<IrClassField>,
1285    pub span: SourceSpan,
1286}
1287
1288/// A declared external event: the typed ingress manifest
1289/// (spec/event-ingress.md). Dotted name, class-shaped payload.
1290#[derive(Clone, Debug, Eq, PartialEq)]
1291pub struct IrEvent {
1292    pub name: String,
1293    pub fields: Vec<IrClassField>,
1294    pub span: SourceSpan,
1295}
1296
1297/// A lowered source declaration (spec/std-time.md). `is_clock` selects the
1298/// `clock_source` lowering; otherwise `signal_source`. Both lower through the
1299/// `source_declaration` construct family and admit a durable signal fact.
1300#[derive(Clone, Debug, Eq, PartialEq)]
1301pub struct IrSource {
1302    pub name: String,
1303    pub provider: String,
1304    pub is_clock: bool,
1305    /// The `file` provider: reads `path` line-by-line and admits one signal per
1306    /// non-empty line, keyed by (source, line index) so re-reads are idempotent.
1307    pub is_file: bool,
1308    /// The `http` provider: GETs `url`, parses a JSON array, and admits one
1309    /// signal per element, keyed by (source, element index) so re-polls are
1310    /// idempotent.
1311    pub is_http: bool,
1312    pub recurrence: Option<Recurrence>,
1313    pub timezone: Option<String>,
1314    pub missed: Option<MissedPolicy>,
1315    /// `path "<file>"` — the file read line-by-line by a `file` source in line
1316    /// mode (`None` otherwise; exactly one of `path`/`watch`).
1317    pub path: Option<String>,
1318    /// `watch "<glob>"` — the glob a `file` source polls in occurrence mode
1319    /// (`None` otherwise): one signal per new (path, content-hash) occurrence.
1320    pub watch: Option<String>,
1321    /// `url "<url>"` — the endpoint GET'd by an `http` source (`None` otherwise).
1322    pub url: Option<String>,
1323    /// `dedup <observe>.<field>` — the observation field carrying the provider
1324    /// delivery id for `file` (line mode) / `http` sources; replaces the
1325    /// positional-ordinal admission key when declared.
1326    pub dedup_field: Option<String>,
1327    pub observe_binding: String,
1328    pub emit_signal: String,
1329    /// S6 `emit … from` — the projection source binding; when set, the
1330    /// signal's declared fields not overridden in `emit_fields` are expanded
1331    /// to copies off this binding after all declarations lower.
1332    pub emit_from: Option<String>,
1333    pub emit_fields: Vec<IrSourceEmitField>,
1334    pub span: SourceSpan,
1335}
1336
1337#[derive(Clone, Debug, Eq, PartialEq)]
1338pub struct IrSourceEmitField {
1339    pub name: String,
1340    pub value: SourceValue,
1341    pub span: SourceSpan,
1342}
1343
1344/// A lowered test scenario (spec/workflow-testing.md). Tests are excluded from
1345/// the executable IR (`compile`/`run` ignore them); `whip check` validates them
1346/// and `whip test` runs them. The clause detail is retained for the harness.
1347#[derive(Clone, Debug, Eq, PartialEq)]
1348pub struct IrTest {
1349    pub name: String,
1350    pub workflow: Option<String>,
1351    pub clauses: Vec<TestClause>,
1352    pub span: SourceSpan,
1353}
1354
1355/// Coordination resources (spec/coordination.md), lowered.
1356#[derive(Clone, Debug, Eq, PartialEq)]
1357pub struct IrLease {
1358    pub name: String,
1359    pub key_type: String,
1360    pub slots: u32,
1361    pub ttl_seconds: u64,
1362    pub shared: bool,
1363    pub span: SourceSpan,
1364}
1365
1366#[derive(Clone, Debug, Eq, PartialEq)]
1367pub struct IrLedger {
1368    pub name: String,
1369    pub entry_schema: String,
1370    pub partition_field: String,
1371    pub retain_seconds: u64,
1372    pub shared: bool,
1373    pub span: SourceSpan,
1374}
1375
1376#[derive(Clone, Debug, Eq, PartialEq)]
1377pub struct IrCounter {
1378    pub name: String,
1379    pub key_type: String,
1380    pub cap: i64,
1381    pub reset: String,
1382    /// IANA timezone anchoring the reset-period boundary; `None` = UTC.
1383    pub timezone: Option<String>,
1384    pub shared: bool,
1385    pub span: SourceSpan,
1386}
1387
1388#[derive(Clone, Debug, Eq, PartialEq)]
1389pub struct IrClassField {
1390    pub name: String,
1391    pub ty: IrType,
1392    /// `@key`: this field is the class's natural key (import per-row idempotency).
1393    pub is_key: bool,
1394    /// Family B presence condition: `(discriminant field name, required literal)`.
1395    /// When set, the field is present only when the discriminant equals the literal
1396    /// (spec/decision-records/discriminated-families-design.md §5.7).
1397    pub presence_condition: Option<(String, String)>,
1398    pub span: SourceSpan,
1399}
1400
1401#[derive(Clone, Debug, Eq, PartialEq)]
1402pub enum IrType {
1403    Primitive(IrPrimitiveType),
1404    LiteralString(String),
1405    Ref(String),
1406    AgentRef(Vec<String>),
1407    Object(Vec<IrClassField>),
1408    Optional(Box<IrType>),
1409    Array(Box<IrType>),
1410    Map(Box<IrType>),
1411    Union(Vec<IrType>),
1412}
1413
1414#[derive(Clone, Debug, Eq, PartialEq)]
1415pub enum IrPrimitiveType {
1416    String,
1417    Int,
1418    Float,
1419    Bool,
1420    Null,
1421    Duration,
1422    Time,
1423    Image,
1424    Audio,
1425    Pdf,
1426    Video,
1427}
1428
1429#[derive(Clone, Debug, Eq, PartialEq)]
1430pub struct IrAgent {
1431    pub name: String,
1432    pub harness: Option<String>,
1433    pub provider: Option<String>,
1434    pub profile: Option<String>,
1435    pub capacity: Option<u32>,
1436    pub skills: Vec<String>,
1437    pub capabilities: Vec<String>,
1438    /// Portable feature requirements (`requires [<feature.class>]`, DR-0015 /
1439    /// spec/std-agent.md slice 6): taxonomy classes the selected provider's
1440    /// accepted feature report must state as supported.
1441    pub requires: Vec<String>,
1442    /// Workflows this agent may invoke as typed tools (DR-0025 `tools [...]`).
1443    pub tools: Vec<String>,
1444    /// Owned-harness conversation-compaction strategy (context-assembly Phase 5):
1445    /// `summarize` (default), `hard_reset`, `tool_results`, or `none`. `None` uses
1446    /// the harness default.
1447    pub compaction: Option<String>,
1448    /// Owned-harness thread continuation across tells:
1449    /// `continue` or `fresh`. `None` = `fresh` (every tell starts from scratch).
1450    pub thread: Option<String>,
1451    /// Ambient-config sources a Delegated harness may read (DR-0034 Decision 4):
1452    /// `project`, `user`, or `none`. `None` means the provider's own default —
1453    /// deliberately NOT the crippled empty set.
1454    pub settings: Option<String>,
1455    /// The harness class (DR-0034): `Managed` (WhippleScript is the runtime) vs
1456    /// `Delegated` (a foreign runtime that assembles its own context). Derived from
1457    /// the resolved provider/harness kind at lowering.
1458    pub harness_class: HarnessClass,
1459}
1460
1461#[derive(Clone, Debug, Eq, PartialEq)]
1462pub struct IrCoerce {
1463    pub name: String,
1464    pub params: Vec<IrParam>,
1465    pub output: IrType,
1466    pub body: String,
1467}
1468
1469#[derive(Clone, Debug, Eq, PartialEq)]
1470pub struct IrParam {
1471    pub name: String,
1472    pub ty: IrType,
1473}
1474
1475#[derive(Clone, Debug, Eq, PartialEq)]
1476pub struct IrRule {
1477    pub name: String,
1478    pub whens: Vec<IrWhen>,
1479    pub body: String,
1480    pub metadata: IrRuleMetadata,
1481}
1482
1483#[derive(Clone, Debug, Eq, PartialEq)]
1484pub struct IrWhen {
1485    pub source: String,
1486    pub pattern: String,
1487    pub guard: Option<IrExpression>,
1488    pub span: SourceSpan,
1489}
1490
1491#[derive(Clone, Debug, Eq, PartialEq)]
1492pub struct IrRuleDependency {
1493    pub producer: String,
1494    pub consumer: String,
1495    pub fact: String,
1496}
1497
1498/// DR-0043 Decision 5: one effect the region contains, with the level-1
1499/// `after` scope the kernel keys its effect id under.
1500#[derive(Clone, Debug, Eq, PartialEq)]
1501pub struct IrRegionEffect {
1502    pub binding: String,
1503    pub scope: Option<(String, String)>,
1504}
1505
1506/// DR-0043 Decision 5: a rule's `during`/`until` region, pre-rendered as the
1507/// three body variants the kernel lowers against. `IrRule.body` itself is the
1508/// condition-HOLDS variant (region spliced inline), so every existing text
1509/// scanner and effect-id derivation is untouched; the kernel swaps in
1510/// `body_removed` (region gone -- post-lapse suppression) or `body_lapsed`
1511/// (region replaced by its arm) per the region's durable state. NOT rendered
1512/// into the .ir snapshot (derived, deterministic).
1513#[derive(Clone, Debug, Eq, PartialEq)]
1514pub struct IrRegion {
1515    pub until: bool,
1516    /// Guard-grammar condition text; the kernel parses and evaluates it
1517    /// atomically inside each advancing commit.
1518    pub condition: String,
1519    pub lapse_binding: Option<String>,
1520    pub effects: Vec<IrRegionEffect>,
1521    pub body_removed: String,
1522    pub body_lapsed: String,
1523}
1524
1525#[derive(Clone, Debug, Default, Eq, PartialEq)]
1526pub struct IrRuleMetadata {
1527    pub fact_reads: Vec<String>,
1528    pub projection_reads: Vec<IrProjectionRead>,
1529    pub fact_writes: Vec<String>,
1530    pub record_sources: Vec<IrRecordSource>,
1531    pub fact_consumes: Vec<String>,
1532    pub effects: Vec<IrEffectNode>,
1533    pub dependencies: Vec<IrEffectDependency>,
1534    /// DR-0043: the rule's `during`/`until` region (at most one in v1).
1535    pub region: Option<IrRegion>,
1536    pub case_branches: Vec<IrRuleCaseBranch>,
1537    pub terminal_outputs: Vec<IrTerminalOutput>,
1538    pub terminal_branches: Vec<IrTerminalCaseBranch>,
1539    /// The output bindings this rule `complete`s (the `name` of each `complete
1540    /// <binding> {…}` in the body, recursing into after/case/branch/handler blocks).
1541    /// Surfaced for the information-flow checker: a `complete result` returns a value
1542    /// to the workflow's invoker, an egress sink at the invoker boundary (DR-0030 X2).
1543    /// IFC-only — deliberately NOT rendered in the `.ir` snapshot, so it adds no
1544    /// golden/hash churn.
1545    pub terminal_completes: Vec<String>,
1546    /// The `redact <source> keep [..] as <out>` projections in this rule body
1547    /// (recursing into after/case/branch/handler blocks). Surfaced for the
1548    /// information-flow value-flow engine: a redaction is the explicit crossing at
1549    /// which the rule-level opaque join box is refined — the projected binding
1550    /// carries only the kept fields' labels (DR-0027, proven in
1551    /// models/lean/Whipple/Redaction.lean). IFC-only — NOT rendered in the `.ir`
1552    /// snapshot, so it adds no golden/hash churn.
1553    pub redactions: Vec<IrRedaction>,
1554    /// Per egress sink, the set of binding roots its payload references (union
1555    /// across branches), keyed by the sink string the IFC engine uses: a `complete
1556    /// <binding>` by its binding, a `record <Schema>` by `fact:<Schema>`, a `send via
1557    /// <channel>` by the channel. IFC-only (NOT in the `.ir` snapshot). The engine
1558    /// uses this to recognize a FULLY-REDACTED egress — one whose payload references
1559    /// only redaction outputs — and govern its leak check by the projection's
1560    /// per-field label rather than the rule's whole read set (DR-0027 redact, the
1561    /// static refinement).
1562    pub egress_payload_reads: BTreeMap<String, BTreeSet<String>>,
1563    /// The output roots of `coerce … declassified` crossings in this rule: the
1564    /// coerce's binding plus its `after <binding> succeeds|completes as <alias>`
1565    /// aliases (the names an egress payload actually references). The IFC engine
1566    /// waives the read×sink leak check for an egress carried ENTIRELY by these
1567    /// roots when a matching `grant declassify` covers the sink (DR-0027
1568    /// I-IFC3 — grants authorize marked crossings only). IFC-only (NOT in the
1569    /// `.ir` snapshot).
1570    pub declassified_roots: BTreeSet<String>,
1571    /// The `endorsed` dual of `declassified_roots`: output roots of `coerce …
1572    /// endorsed` crossings, and (DR-0051 §2) the claimed *item* of `claim …
1573    /// endorsed` crossings. Consulted by the inject check. IFC-only (NOT in the
1574    /// `.ir` snapshot).
1575    pub endorsed_roots: BTreeSet<String>,
1576    /// DR-0051 §3: the *item* bindings of `claim … endorsed` effects — the
1577    /// names a `when <tracker> has ready issue as <binding>` trigger bound, not
1578    /// the claim's own `as` binding.
1579    ///
1580    /// Carried separately from `endorsed_roots` because the two answer different
1581    /// questions. `endorsed_roots` says which values crossed; this says which
1582    /// queue the crossing drew its authority from, so the checker can refuse a
1583    /// marker whose tracker nobody vouched. IFC-only (NOT in the `.ir`
1584    /// snapshot).
1585    pub endorsed_claim_items: BTreeSet<String>,
1586    /// DR-0051 §4: per-field binding roots for each `record <Schema> { … }`
1587    /// egress — the same shape as `complete_field_reads`, keyed by
1588    /// `fact:<Schema>` and then by field name.
1589    ///
1590    /// `egress_payload_reads` collapses a record's roots to one set, which is
1591    /// enough to decide whether a *sink* is carried by a marked crossing but not
1592    /// which *field* it shaped. §4 needs the finer grain: a verdict field shaped
1593    /// by an endorsement must be schema-closed, while a sibling field holding a
1594    /// constant is nobody's business. IFC-only (NOT in the `.ir` snapshot).
1595    pub record_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1596    /// Per-coerce argument roots: for EVERY `coerce f(args…) as <binding>` in
1597    /// the rule (marked or not), the binding roots its argument expressions
1598    /// reference. The IFC engine resolves these to governed sources for
1599    /// input-side provenance narrowing at marked crossings — including chaining
1600    /// through unmarked coerces (a model call is a total mixing point: its
1601    /// output carries the join of all its inputs). IFC-only (NOT in the `.ir`
1602    /// snapshot).
1603    pub coerce_input_roots: BTreeMap<String, BTreeSet<String>>,
1604    /// `after <effect-binding> succeeds|completes as <alias>` → the effect
1605    /// binding, so the IFC engine can resolve payload and argument roots
1606    /// through the aliases bodies actually reference. IFC-only (NOT in the
1607    /// `.ir` snapshot).
1608    pub after_aliases: BTreeMap<String, String>,
1609    /// Per egress sink, the binding roots of every enclosing `case` scrutinee
1610    /// (DR-0046): a sink inside a `case` arm is INFLUENCED by the scrutinee —
1611    /// branching on model output and recording per-arm constants is the
1612    /// classic implicit channel. Covers record/complete/milestone/send/write
1613    /// uniformly. IFC-only (NOT in the `.ir` snapshot).
1614    pub egress_case_influence: BTreeMap<String, BTreeSet<String>>,
1615    /// Per `complete <binding>` egress, the binding roots each RESULT FIELD
1616    /// references — a two-level map `binding -> field -> {roots}`. Where
1617    /// `egress_payload_reads` joins all of a sink's fields into one set (enough for the
1618    /// fully-redacted recognizer), this keeps them SEPARATE so the IFC engine can
1619    /// compute a PER-FIELD flow signature (DR-0030 X2 v2): the reads reaching each
1620    /// result field, refined at fact granularity. IFC-only (NOT in the `.ir`
1621    /// snapshot). Union across branches; a `Shorthand` field resolves to the
1622    /// terminal's `from` binding.
1623    pub complete_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1624    /// Per `emit milestone "<name>"` egress, the binding roots each MILESTONE FIELD
1625    /// references — same shape and purpose as `complete_field_reads`, but keyed by
1626    /// milestone name. Milestone payloads are child-to-parent egresses, so IFC needs
1627    /// their per-field flow signature too (D3′). IFC-only (NOT in the `.ir`
1628    /// snapshot). Union across branches.
1629    pub milestone_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1630    /// Bounded-type projection egresses (`record <T> from <src>`): each is governed
1631    /// by the kept fields' per-field label join, like an explicit `redact`. IFC-only
1632    /// (NOT in the `.ir` snapshot). DR-0027 auto-redaction, the bounded-type reading.
1633    pub bounded_egresses: Vec<IrBoundedEgress>,
1634    /// Maximum nesting depth of `after` blocks in the rule body (0 = no `after`,
1635    /// 1 = a top-level `after`, 2 = an `after` inside an `after`, …). Surfaced for the
1636    /// `lint.deep_after_nesting` maintainability check.
1637    pub max_after_depth: usize,
1638}
1639
1640/// A bounded-type projection egress (`record <T> from <src>`): the recorded fact
1641/// keeps exactly `T`'s fields, copied from `src`, so the egress carries only the
1642/// kept fields' per-field labels — the "bounded-type" auto-redaction reading
1643/// (DR-0027). The bound is the declared target type `T`; the labels are the
1644/// SOURCE schema's (a target field mislabelled public is still caught against the
1645/// source's label). The IFC engine governs it exactly like an explicit `redact`.
1646#[derive(Clone, Debug, Eq, PartialEq)]
1647pub struct IrBoundedEgress {
1648    /// The engine's sink string (`fact:<T>` for a record).
1649    pub sink: String,
1650    /// The schema of the `from` source binding, whose per-field labels bound the
1651    /// projection.
1652    pub source_schema: String,
1653    /// The kept field names (the target type `T`'s fields).
1654    pub keep: Vec<String>,
1655}
1656
1657/// A `redact <source> keep [..] as <binding>` projection, surfaced for the
1658/// information-flow value-flow engine (DR-0027). `source` is the binding being
1659/// projected, `keep` the kept field names, `binding` the projected output.
1660#[derive(Clone, Debug, Eq, PartialEq)]
1661pub struct IrRedaction {
1662    pub source: String,
1663    pub keep: Vec<String>,
1664    pub binding: String,
1665    /// The schema of the source binding, when resolvable (a matched class, a
1666    /// coerce/decide/exec result, an `after … as` alias, or an earlier redaction's
1667    /// output). The information-flow engine derives the projection's confidentiality
1668    /// from the kept fields of this schema (`<schema>.<field>` labels), so a redacted
1669    /// egress needs only the kept fields' clearance, not the whole record's. `None`
1670    /// when the source type is not statically known (the engine then stays
1671    /// conservative for that redaction).
1672    pub source_schema: Option<String>,
1673}
1674
1675#[derive(Clone, Debug, Eq, PartialEq)]
1676pub struct IrRecordSource {
1677    pub schema: String,
1678    pub construct: String,
1679    pub span: SourceSpan,
1680}
1681
1682#[derive(Clone, Debug, Eq, PartialEq)]
1683pub struct IrProjectionRead {
1684    pub kind: QueryKind,
1685    pub head: String,
1686    pub guard: Option<String>,
1687}
1688
1689impl IrProjectionRead {
1690    fn to_snapshot(&self) -> String {
1691        let prefix = match self.kind {
1692            QueryKind::Fact => format!("fact:{}", self.head),
1693            QueryKind::Effect => format!("effect:{}", self.head),
1694        };
1695        match &self.guard {
1696            Some(guard) => format!("{prefix} where {guard}"),
1697            None => prefix,
1698        }
1699    }
1700}
1701
1702#[derive(Clone, Debug, Eq, PartialEq)]
1703pub struct IrEffectNode {
1704    pub id: String,
1705    pub kind: IrEffectKind,
1706    pub binding: Option<String>,
1707    pub required_capabilities: Vec<String>,
1708    pub construct_use: Option<IrConstructUse>,
1709    pub idempotency_key: String,
1710    pub span: SourceSpan,
1711    /// Creation-anchored deadline from a `timeout <duration>` clause.
1712    pub timeout_seconds: Option<u64>,
1713    /// Turn-access grants (`with access to …`) lowered onto an `agent.tell` effect as
1714    /// authority-narrowing metadata (Proposal A). Empty for non-grant effects.
1715    pub access_grants: Vec<IrAccessGrant>,
1716    /// Turn-scoped skills (`with skills [...]`) pinned onto an `agent.tell` effect as
1717    /// provenance (context-assembly Phase 7). Recorded, not enforced — the owned
1718    /// catalogue stays discover-all. Empty for effects without a skill pin.
1719    pub turn_skills: Vec<String>,
1720    /// The named resource (file store / channel) a direct effect touches, if any —
1721    /// e.g. the store of a `read`/`write`. Surfaced so information-flow analysis can
1722    /// see rule-body data flows, not just turn-access grants. `None` for effects
1723    /// that touch no named resource. Not part of the `.ir` snapshot.
1724    pub resource: Option<String>,
1725    /// The agent a `tell` addresses (its `target`), surfaced so information-flow
1726    /// analysis can model the turn's egress to that agent's provider. `None` for
1727    /// non-`tell` effects. Not part of the `.ir` snapshot.
1728    pub agent: Option<String>,
1729    /// The workflow an `invoke` addresses, surfaced so information-flow analysis can
1730    /// enumerate and govern invoke membrane ports. `None` for non-`invoke` effects.
1731    /// Not part of the `.ir` snapshot.
1732    pub workflow_target: Option<String>,
1733    /// The `endorsed` source marker (DR-0027 I-IFC3): the author declared this effect
1734    /// (a `coerce`) an integrity-raising crossing. Surfaced so the trusted surface is
1735    /// visible at the source crossing point. Not part of the `.ir` snapshot.
1736    pub endorsed: bool,
1737    /// The `declassified` source marker (DR-0027 I-IFC3): the author declared this
1738    /// `coerce` a confidentiality-lowering crossing (its output schema bounds the
1739    /// leak). Surfaced for audit. Not part of the `.ir` snapshot.
1740    pub declassified: bool,
1741    /// The innermost `case <scrutinee> { <pattern> => … }` arm this effect sits in,
1742    /// as `(scrutinee, pattern)` — the discriminated-families *selector*. Lets the
1743    /// IFC checker apply NMIF-on-the-selector: a crossing (`endorsed`/`declassified`)
1744    /// selected by a low-integrity discriminant is rejected (DR §5.6 / §7.4). `None`
1745    /// for effects outside any `case`. Not part of the `.ir` snapshot.
1746    pub selected_by: Option<(String, String)>,
1747    /// The `exec` surface form — raw command string vs manifest capability
1748    /// (spec/std-script.md "Static checks" item 2) — surfaced so check-time
1749    /// gates (hosted-raw demotion, manifest resolution) classify the effect
1750    /// from the AST instead of re-scanning rule-body text. `None` for
1751    /// non-`exec` effects. Not part of the `.ir` snapshot.
1752    pub exec_target: Option<IrExecTarget>,
1753}
1754
1755/// The two `exec` source forms (spec/std-script.md): a raw command string
1756/// (`exec "cmd"`, dev-profile only) or an operator-manifest capability
1757/// (`exec <name> with <record>`).
1758#[derive(Clone, Debug, Eq, PartialEq)]
1759pub enum IrExecTarget {
1760    Raw,
1761    Capability { name: String },
1762}
1763
1764/// A lowered turn-access grant: the granted operations narrow the turn's effective
1765/// authority on `resource` (modeled in `models/maude/turn-access-grant.maude`).
1766#[derive(Clone, Debug, Eq, PartialEq)]
1767pub struct IrAccessGrant {
1768    pub resource: String,
1769    pub operations: Vec<IrAccessGrantOp>,
1770}
1771
1772#[derive(Clone, Debug, Eq, PartialEq)]
1773pub struct IrAccessGrantOp {
1774    pub operation: String,
1775    pub target: Option<String>,
1776    pub globs: Vec<String>,
1777}
1778
1779#[derive(Clone, Debug, Eq, PartialEq)]
1780pub struct IrConstructUse {
1781    pub keyword: String,
1782    pub scope: String,
1783    pub construct_family: String,
1784    pub lowering_target: String,
1785    pub target_capability: String,
1786}
1787
1788#[derive(Clone, Debug, Eq, PartialEq)]
1789pub enum IrEffectKind {
1790    AgentTell,
1791    SchemaCoerce,
1792    CapabilityCall,
1793    EventEmit,
1794    WorkflowInvoke,
1795    TimerWait,
1796    ExecCommand,
1797    TrackerFile,
1798    TrackerClaim,
1799    TrackerRenew,
1800    TrackerRelease,
1801    TrackerFinish,
1802    LeaseAcquire,
1803    LeaseRenew,
1804    LedgerAppend,
1805    CounterConsume,
1806    SignalEmit,
1807    FileRead,
1808    FileWrite,
1809    FileImport,
1810    FileExport,
1811}
1812
1813#[derive(Clone, Debug, Eq, PartialEq)]
1814pub struct IrEffectDependency {
1815    pub upstream: String,
1816    pub predicate: DependencyPredicate,
1817    pub downstream: String,
1818}
1819
1820#[derive(Clone, Debug, Eq, PartialEq)]
1821pub struct IrRuleCaseBranch {
1822    pub scrutinee: String,
1823    pub scrutinee_type: IrType,
1824    pub pattern: IrCasePattern,
1825    pub guard: Option<IrExpression>,
1826    pub body_hash: String,
1827    pub pattern_span: SourceSpan,
1828}
1829
1830#[derive(Clone, Debug, Eq, PartialEq)]
1831pub enum IrCasePattern {
1832    EnumVariant(String),
1833    LiteralString(String),
1834    Agent(String),
1835    OptionalSome { binding: String },
1836    OptionalNone,
1837    Wildcard,
1838}
1839
1840impl IrCasePattern {
1841    fn to_snapshot(&self) -> String {
1842        match self {
1843            IrCasePattern::EnumVariant(value) => format!("enum:{value}"),
1844            IrCasePattern::LiteralString(value) => format!("literal:\"{value}\""),
1845            IrCasePattern::Agent(value) => format!("agent:{value}"),
1846            IrCasePattern::OptionalSome { binding } => format!("some:{binding}"),
1847            IrCasePattern::OptionalNone => "none".to_owned(),
1848            IrCasePattern::Wildcard => "_".to_owned(),
1849        }
1850    }
1851}
1852
1853#[derive(Clone, Debug, Eq, PartialEq)]
1854pub struct IrTerminalOutput {
1855    pub binding: String,
1856    pub alternatives: Vec<IrTerminalAlternative>,
1857    pub span: SourceSpan,
1858}
1859
1860#[derive(Clone, Debug, Eq, PartialEq)]
1861pub struct IrTerminalAlternative {
1862    pub tag: String,
1863    pub payload_type: IrType,
1864    pub source_span: SourceSpan,
1865}
1866
1867#[derive(Clone, Debug, Eq, PartialEq)]
1868pub struct IrTerminalCaseBranch {
1869    pub scrutinee: String,
1870    pub tag: Option<String>,
1871    pub binding: Option<String>,
1872    pub guard: Option<IrExpression>,
1873    pub body_hash: String,
1874    pub pattern_span: SourceSpan,
1875}
1876
1877#[derive(Clone, Debug, Eq, PartialEq)]
1878pub enum DependencyPredicate {
1879    Succeeds,
1880    Fails,
1881    TimedOut,
1882    Cancelled,
1883    Completes,
1884}
1885
1886#[derive(Clone, Debug)]
1887struct SemanticContext {
1888    workflow: Option<String>,
1889    schemas: SchemaIndex,
1890    agents: BTreeSet<String>,
1891    agent_capabilities: BTreeMap<String, BTreeSet<String>>,
1892    coerce_outputs: BTreeMap<String, TypeSyntax>,
1893    coerce_params: BTreeMap<String, Vec<ParamDecl>>,
1894    workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
1895    /// Declared coordination resources (spec/coordination.md).
1896    leases: BTreeSet<String>,
1897    ledgers: BTreeSet<String>,
1898    counters: BTreeSet<String>,
1899    /// Declared `channel` names (std.messaging); `send via <channel>` must name one.
1900    channels: BTreeSet<String>,
1901    /// Declared channel providers by channel name (std.messaging): the
1902    /// capability-report-conditioned checks (send requires outbound-capable,
1903    /// `when message from` requires inbound-capable) resolve the report
1904    /// through this map.
1905    channel_providers: BTreeMap<String, String>,
1906    /// Declared `memory pool` names (std.memory); `recall`/`learn`/`curate`
1907    /// must name one (MEM-1 check 1).
1908    memory_pools: BTreeSet<String>,
1909}
1910
1911#[derive(Clone, Debug, Default)]
1912struct WorkflowInputSurface {
1913    inputs: BTreeMap<String, TypeSyntax>,
1914    /// The workflow's `output` contract types by name. A parent's
1915    /// `after <invoke-binding> succeeds as r` binds `r` to this contract so
1916    /// `r.<field>` type-checks against the child's declared output (the runtime
1917    /// already carries the child's terminal payload into that binding).
1918    outputs: BTreeMap<String, TypeSyntax>,
1919    /// The workflow's `failure` contract types by name. A parent's
1920    /// `after <invoke-binding> fails as f` binds `f` to this contract (when it is
1921    /// a shared top-level class) so `f.<field>` type-checks against the child's
1922    /// declared failure shape, instead of the generic DR-0032 `TerminalFailed`
1923    /// base. Falls back to the base when the failure class is child-local or the
1924    /// child declares zero/several failures.
1925    failures: BTreeMap<String, TypeSyntax>,
1926    schemas: SchemaIndex,
1927    /// Milestones the workflow may project (Family C): name -> payload class
1928    /// (empty string for a bare, payload-less milestone). Derived by scanning the
1929    /// workflow's rule bodies for `emit milestone "<name>" [of <Class>]`. This is
1930    /// the `declared(S)` set a parent's `after p reaches "<name>"` validates
1931    /// against (reject-undeclared) and the source of the observing binding's type.
1932    milestones: BTreeMap<String, String>,
1933}
1934
1935#[derive(Clone, Debug, Default)]
1936struct SchemaIndex {
1937    classes: BTreeMap<String, BTreeMap<String, TypeSyntax>>,
1938    enums: BTreeMap<String, BTreeSet<String>>,
1939    /// Declared external signals (spec/event-ingress.md); their payload
1940    /// schemas live in `classes` keyed by the dotted signal name.
1941    events: BTreeSet<String>,
1942    /// Family B: per-schema field presence conditions, `schema -> field ->
1943    /// (discriminant field, required literal)`. A conditioned field is readable
1944    /// only inside a matching `case <root>.<disc>` arm.
1945    presence: BTreeMap<String, BTreeMap<String, (String, String)>>,
1946}
1947
1948#[derive(Clone, Debug, Eq, PartialEq)]
1949enum BlockFrame {
1950    After {
1951        binding: String,
1952        predicate: DependencyPredicate,
1953    },
1954}
1955
1956#[derive(Clone, Debug, Eq, PartialEq)]
1957enum LiteralExpr<'a> {
1958    String(&'a str),
1959    Number(&'a str),
1960    Bool,
1961    Null,
1962    Ident(&'a str),
1963}
1964
1965#[derive(Clone, Debug, Eq, PartialEq)]
1966enum ExprType {
1967    Bool,
1968    Int,
1969    Float,
1970    String,
1971    Duration,
1972    Time,
1973    Null,
1974    Object,
1975    Optional(Box<ExprType>),
1976    Array(Box<ExprType>),
1977    Map(Box<ExprType>),
1978    Finite { label: String, values: Vec<String> },
1979    Collection,
1980    Unknown,
1981}
1982
1983#[derive(Clone, Debug, Eq, PartialEq)]
1984pub enum Expr {
1985    Literal(ExprLiteral),
1986    Path(Vec<String>),
1987    Index {
1988        target: Box<Expr>,
1989        key: Box<Expr>,
1990    },
1991    Array(Vec<Expr>),
1992    Object(Vec<ExprObjectField>),
1993    Unary {
1994        op: UnaryOp,
1995        expr: Box<Expr>,
1996    },
1997    Binary {
1998        op: BinaryOp,
1999        left: Box<Expr>,
2000        right: Box<Expr>,
2001    },
2002    Call {
2003        name: String,
2004        args: Vec<Expr>,
2005    },
2006    Query {
2007        kind: QueryKind,
2008        head: String,
2009        guard: Option<Box<Expr>>,
2010    },
2011}
2012
2013#[derive(Clone, Debug, Eq, PartialEq)]
2014pub struct ExprObjectField {
2015    pub key: String,
2016    pub value: Expr,
2017}
2018
2019#[derive(Clone, Debug, Eq, PartialEq)]
2020pub enum ExprLiteral {
2021    String(String),
2022    Number(String),
2023    Bool(bool),
2024    Null,
2025    Ident(String),
2026}
2027
2028#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2029pub enum UnaryOp {
2030    Not,
2031}
2032
2033#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2034pub enum BinaryOp {
2035    Or,
2036    And,
2037    Eq,
2038    Ne,
2039    Lt,
2040    Le,
2041    Gt,
2042    Ge,
2043    In,
2044    NotIn,
2045    Add,
2046    Sub,
2047    Mul,
2048    Div,
2049}
2050
2051#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2052pub enum QueryKind {
2053    Fact,
2054    Effect,
2055}
2056
2057/// Parses a deterministic expression used by guards, assertions, and branch guards.
2058pub fn parse_expression(expr: &str) -> Result<Expr, String> {
2059    ExprParser::new(expr).parse()
2060}
2061
2062impl Expr {
2063    pub fn to_snapshot(&self) -> String {
2064        match self {
2065            Self::Literal(literal) => literal.to_snapshot(),
2066            Self::Path(path) => path.join("."),
2067            Self::Index { target, key } => {
2068                format!(
2069                    "{}[{}]",
2070                    target.to_snapshot_with_parentheses(),
2071                    key.to_snapshot()
2072                )
2073            }
2074            Self::Array(items) => {
2075                let items = items
2076                    .iter()
2077                    .map(Self::to_snapshot)
2078                    .collect::<Vec<_>>()
2079                    .join(", ");
2080                format!("[{items}]")
2081            }
2082            Self::Object(fields) => {
2083                let fields = fields
2084                    .iter()
2085                    .map(|field| format!("{} {}", field.key, field.value.to_snapshot()))
2086                    .collect::<Vec<_>>()
2087                    .join(", ");
2088                format!("{{{fields}}}")
2089            }
2090            Self::Unary { op, expr } => match op {
2091                UnaryOp::Not => format!("!{}", expr.to_snapshot_with_parentheses()),
2092            },
2093            Self::Binary { op, left, right } => format!(
2094                "{} {} {}",
2095                left.to_snapshot_with_parentheses(),
2096                op.to_snapshot(),
2097                right.to_snapshot_with_parentheses()
2098            ),
2099            Self::Call { name, args } => {
2100                let args = args
2101                    .iter()
2102                    .map(Self::to_snapshot)
2103                    .collect::<Vec<_>>()
2104                    .join(", ");
2105                format!("{name}({args})")
2106            }
2107            Self::Query { kind, head, guard } => {
2108                let prefix = match kind {
2109                    QueryKind::Fact => head.clone(),
2110                    QueryKind::Effect => format!("effect {head}"),
2111                };
2112                match guard {
2113                    Some(guard) => format!("{prefix} where {}", guard.to_snapshot()),
2114                    None => prefix,
2115                }
2116            }
2117        }
2118    }
2119
2120    fn to_snapshot_with_parentheses(&self) -> String {
2121        match self {
2122            Self::Binary { .. } => format!("({})", self.to_snapshot()),
2123            _ => self.to_snapshot(),
2124        }
2125    }
2126}
2127
2128impl ExprLiteral {
2129    fn to_snapshot(&self) -> String {
2130        match self {
2131            Self::String(value) => format!("{value:?}"),
2132            Self::Number(value) | Self::Ident(value) => value.clone(),
2133            Self::Bool(value) => value.to_string(),
2134            Self::Null => "null".to_owned(),
2135        }
2136    }
2137}
2138
2139impl BinaryOp {
2140    fn to_snapshot(self) -> &'static str {
2141        match self {
2142            Self::Or => "||",
2143            Self::And => "&&",
2144            Self::Eq => "==",
2145            Self::Ne => "!=",
2146            Self::Lt => "<",
2147            Self::Le => "<=",
2148            Self::Gt => ">",
2149            Self::Ge => ">=",
2150            Self::In => "in",
2151            Self::NotIn => "not in",
2152            Self::Add => "+",
2153            Self::Sub => "-",
2154            Self::Mul => "*",
2155            Self::Div => "/",
2156        }
2157    }
2158}
2159
2160/// Parses a source file into a recoverable AST plus diagnostics.
2161pub fn parse_program(source: &str) -> ParseOutput {
2162    let lexed = lex(source);
2163    let mut parser = Parser {
2164        source,
2165        tokens: lexed.tokens,
2166        pos: 0,
2167        diagnostics: lexed.diagnostics,
2168        pending_contract_classes: Vec::new(),
2169    };
2170
2171    let program = parser.parse_program();
2172    ParseOutput {
2173        program,
2174        diagnostics: parser.diagnostics,
2175    }
2176}
2177
2178/// Parses and lowers a source file into deterministic typed IR.
2179pub fn compile_program(source: &str) -> CompileOutput {
2180    compile_program_with_root(source, None)
2181}
2182
2183/// Parses and lowers a source bundle into deterministic typed IR with an
2184/// optional explicit root workflow selection.
2185pub fn compile_program_with_root(source: &str, root: Option<&str>) -> CompileOutput {
2186    let parsed = parse_program(source);
2187    if !parsed.diagnostics.is_empty() {
2188        return CompileOutput {
2189            ir: None,
2190            diagnostics: parsed.diagnostics,
2191            warnings: Vec::new(),
2192        };
2193    }
2194
2195    // Program-level static check over ALL workflows (before root selection):
2196    // transitive runtime invocation cycles have no compile-time convergence proof
2197    // and are rejected (RESOLVED 2026-07-01). Direct self-invocation is caught
2198    // per-rule during lowering.
2199    let mut invoke_recursion_diagnostics = Vec::new();
2200    detect_workflow_invoke_recursion(&parsed.program, &mut invoke_recursion_diagnostics);
2201    detect_private_workflow_invocations(&parsed.program, &mut invoke_recursion_diagnostics);
2202    if !invoke_recursion_diagnostics.is_empty() {
2203        return CompileOutput {
2204            ir: None,
2205            diagnostics: invoke_recursion_diagnostics,
2206            warnings: Vec::new(),
2207        };
2208    }
2209
2210    let workflow_inputs = collect_workflow_input_surfaces(&parsed.program);
2211    let shared_coordination_usage = collect_shared_coordination_usage(&parsed.program);
2212
2213    // Whole-program validation (RESOLVED 2026-07-01): when a program declares
2214    // more than one explicit `workflow`, validate EVERY workflow — not only the
2215    // selected root — so a broken sibling is caught in a single compile
2216    // regardless of which `--root` is chosen. Each workflow is lowered against
2217    // its own scope (top-level globals + that workflow's local block items),
2218    // which is exactly the scoped program `select_root_workflow` builds for that
2219    // name. Root selection below still produces the single entry IR for
2220    // `dev`/`deploy`; this pass only adds validation coverage and never changes
2221    // the emitted IR (when it finds no errors it returns nothing, so the root is
2222    // lowered once more, cleanly, below). See models/maude/workflow-scoping.maude.
2223    if parsed.program.workflows.len() > 1 {
2224        // Names declared at the top level are global (shared across every
2225        // workflow); names declared inside a `workflow { ... }` block are private
2226        // to it. Map each workflow-local name to its owning workflow(s) so that
2227        // when a workflow references a name that is really a sibling's local, the
2228        // resulting unknown-name error can point the author at where it lives —
2229        // the "names do not leak into sibling workflows" guarantee, surfaced.
2230        let global_names: BTreeSet<String> = parsed
2231            .program
2232            .items
2233            .iter()
2234            .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2235            .collect();
2236        let mut sibling_locals: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
2237        for workflow in &parsed.program.workflows {
2238            for item in &workflow.items {
2239                if let Some((name, span)) = referenced_decl_name(item) {
2240                    sibling_locals
2241                        .entry(name)
2242                        .or_default()
2243                        .push((workflow.name.name.clone(), span));
2244                }
2245            }
2246        }
2247
2248        let mut aggregated = Vec::new();
2249        for workflow in &parsed.program.workflows {
2250            let name = workflow.name.name.clone();
2251            let own_locals: BTreeSet<String> = workflow
2252                .items
2253                .iter()
2254                .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2255                .collect();
2256            let mut diagnostics = match select_root_workflow(parsed.program.clone(), Some(&name)) {
2257                Ok(scoped) => {
2258                    lower_program(
2259                        scoped,
2260                        workflow_inputs.clone(),
2261                        shared_coordination_usage.clone(),
2262                    )
2263                    .diagnostics
2264                }
2265                Err(diagnostics) => diagnostics,
2266            };
2267            for diagnostic in &mut diagnostics {
2268                annotate_cross_workflow_leak(
2269                    diagnostic,
2270                    &name,
2271                    &own_locals,
2272                    &global_names,
2273                    &sibling_locals,
2274                );
2275            }
2276            aggregated.extend(diagnostics);
2277        }
2278        if !aggregated.is_empty() {
2279            return CompileOutput {
2280                ir: None,
2281                diagnostics: aggregated,
2282                warnings: Vec::new(),
2283            };
2284        }
2285    }
2286
2287    match select_root_workflow(parsed.program, root) {
2288        Ok(program) => lower_program(program, workflow_inputs, shared_coordination_usage),
2289        Err(diagnostics) => CompileOutput {
2290            ir: None,
2291            diagnostics,
2292            warnings: Vec::new(),
2293        },
2294    }
2295}
2296
2297/// One top-level declaration for an editor outline (`whip lsp`'s
2298/// `textDocument/documentSymbol`): its name, a coarse kind tag, and source span.
2299#[derive(Clone, Debug, Eq, PartialEq)]
2300pub struct DeclSymbol {
2301    pub name: String,
2302    pub kind: &'static str,
2303    pub span: SourceSpan,
2304}
2305
2306/// Top-level declarations of `source` in source order, for an editor outline. On a
2307/// parse error it returns whatever declarations parsed (best-effort outline).
2308pub fn document_symbols(source: &str) -> Vec<DeclSymbol> {
2309    let program = parse_program(source).program;
2310    let mut symbols = Vec::new();
2311    if let Some(workflow) = &program.workflow {
2312        symbols.push(DeclSymbol {
2313            name: workflow.name.clone(),
2314            kind: "workflow",
2315            span: workflow.span,
2316        });
2317    }
2318    for workflow in &program.workflows {
2319        symbols.push(DeclSymbol {
2320            name: workflow.name.name.clone(),
2321            kind: "workflow",
2322            span: workflow.span,
2323        });
2324    }
2325    for pattern in &program.patterns {
2326        symbols.push(DeclSymbol {
2327            name: pattern.name.name.clone(),
2328            kind: "pattern",
2329            span: pattern.span,
2330        });
2331    }
2332    for item in &program.items {
2333        let symbol = match item {
2334            Item::Class(decl) => ("class", decl.name.name.clone(), decl.span),
2335            Item::Enum(decl) => ("enum", decl.name.name.clone(), decl.span),
2336            Item::Agent(decl) => ("agent", decl.name.name.clone(), decl.span),
2337            Item::Rule(decl) => ("rule", decl.name.name.clone(), decl.span),
2338            Item::Coerce(decl) => ("coerce", decl.name.name.clone(), decl.span),
2339            Item::Action(decl) => ("action", decl.name.name.clone(), decl.span),
2340            Item::Lease(decl) => ("lease", decl.name.name.clone(), decl.span),
2341            Item::Ledger(decl) => ("ledger", decl.name.name.clone(), decl.span),
2342            Item::Counter(decl) => ("counter", decl.name.name.clone(), decl.span),
2343            Item::Tracker(decl) => ("tracker", decl.name.name.clone(), decl.span),
2344            Item::Channel(decl) => ("channel", decl.name.name.clone(), decl.span),
2345            Item::FileStore(decl) => ("file store", decl.name.name.clone(), decl.span),
2346            Item::MemoryPool(decl) => ("memory pool", decl.name.name.clone(), decl.span),
2347            Item::Event(decl) => ("signal", decl.name.clone(), decl.span),
2348            Item::Table(decl) => ("table", decl.name.name.clone(), decl.span),
2349            Item::Gauge(decl) => ("gauge", decl.name.name.clone(), decl.span),
2350            Item::Campaign(decl) => ("campaign", decl.name.name.clone(), decl.span),
2351            Item::Mark(decl) => ("mark", decl.name.value.clone(), decl.span),
2352            _ => continue,
2353        };
2354        symbols.push(DeclSymbol {
2355            name: symbol.1,
2356            kind: symbol.0,
2357            span: symbol.2,
2358        });
2359    }
2360    symbols
2361}
2362
2363/// Formats the syntax tree without lowering or analyzing rule bodies.
2364pub fn format_program(source: &str) -> FormatOutput {
2365    let parsed = parse_program(source);
2366    if !parsed.diagnostics.is_empty() {
2367        return FormatOutput {
2368            formatted: None,
2369            diagnostics: parsed.diagnostics,
2370        };
2371    }
2372
2373    FormatOutput {
2374        formatted: Some(format_syntax(parsed.program)),
2375        diagnostics: Vec::new(),
2376    }
2377}
2378
2379/// Format `source` while preserving comments where they can be placed safely:
2380/// top-level **leading** comments (a `# …` or `// …` line above a declaration, or
2381/// a file-header block) and **trailing** comments on a single-line top-level
2382/// declaration (`workflow Demo  # …`, attached to that element's line); comments
2383/// inside raw-body declarations (`rule`/`apply`/`coerce`/`table`/`flow`, carried by
2384/// the body substring); and comments inside `class`/`agent`/`enum` bodies, including a
2385/// data-carrying `enum` variant's nested field block — both own-line (interleaved
2386/// by source position) and trailing comments on a field/variant line (appended to
2387/// it), and `signal`/`queue`/`file store` bodies the same way — even though those
2388/// bodies rebuild from the AST. Returns `None` when the program does not parse, or
2389/// when a comment has nowhere to attach — e.g. one trailing a declaration's
2390/// opening-brace line, with no field on that line. The caller refuses such files
2391/// rather than dropping comments.
2392pub fn format_program_preserving_comments(source: &str) -> Option<String> {
2393    let parsed = parse_program(source);
2394    if !parsed.diagnostics.is_empty() {
2395        return None;
2396    }
2397    let mut comments = lex_comments(source);
2398    if comments.is_empty() {
2399        return Some(format_syntax(parsed.program));
2400    }
2401    // Both the top-level interleave and the per-body interleave below assume
2402    // ascending source order.
2403    comments.sort_by_key(|comment| comment.span.start);
2404    let program = parsed.program;
2405
2406    // Each top-level element as (source span, formatted chunk), in source order.
2407    let mut elements: Vec<(SourceSpan, String)> = Vec::new();
2408    if let Some(workflow) = program.workflow {
2409        let mut chunk = String::new();
2410        format_tags(&program.workflow_tags, &mut chunk);
2411        format_description(program.workflow_description.as_ref(), &mut chunk);
2412        push_line(&mut chunk, format!("workflow {}", workflow.name));
2413        elements.push((workflow.span, chunk));
2414    }
2415    for pattern in program.patterns {
2416        let span = pattern.span;
2417        let mut chunk = String::new();
2418        format_pattern(pattern, &mut chunk);
2419        elements.push((span, chunk));
2420    }
2421    for item in program.items {
2422        let span = item.span();
2423        let mut chunk = String::new();
2424        // Field-list bodies (`class`/`agent`/`enum`) rebuild from the AST, which
2425        // drops comments. Interleave their own-line body comments here; a body
2426        // comment that cannot be placed safely refuses the whole file (the
2427        // raw-body formatters — rule/coerce/table — already carry their comments).
2428        let placed = match &item {
2429            Item::Class(class_decl) => Some(try_format_class_with_comments(
2430                class_decl, source, &comments, &mut chunk,
2431            )),
2432            Item::Agent(agent) => Some(try_format_agent_with_comments(
2433                agent, source, &comments, &mut chunk,
2434            )),
2435            Item::Enum(enum_decl) => Some(try_format_enum_with_comments(
2436                enum_decl, source, &comments, &mut chunk,
2437            )),
2438            Item::Event(event) => Some(try_format_event_with_comments(
2439                event, source, &comments, &mut chunk,
2440            )),
2441            Item::Tracker(queue) => Some(try_format_tracker_with_comments(
2442                queue, source, &comments, &mut chunk,
2443            )),
2444            Item::FileStore(file_store) => Some(try_format_filestore_with_comments(
2445                file_store, source, &comments, &mut chunk,
2446            )),
2447            _ => None,
2448        };
2449        match placed {
2450            Some(true) => {}
2451            Some(false) => return None,
2452            None => format_item(item, &mut chunk),
2453        }
2454        elements.push((span, chunk));
2455    }
2456    for workflow in program.workflows {
2457        let span = workflow.span;
2458        let mut chunk = String::new();
2459        format_workflow(workflow, &mut chunk);
2460        elements.push((span, chunk));
2461    }
2462    elements.sort_by_key(|(span, _)| span.start);
2463
2464    // Classify top-level comments. A comment INSIDE an element's span is preserved
2465    // by that element's body formatter — a raw `body.text` substring
2466    // (rule/coerce/table) or the per-body interleave above (class/agent/enum) — so
2467    // emitting it here too would duplicate it; skip it. Otherwise an own-line
2468    // comment is `leading` (interleaved between elements by position), and a
2469    // trailing comment (code before it) attaches to the element whose last source
2470    // line it shares — typically a single-line declaration (`workflow Demo  # x`).
2471    // A trailing comment with no such element has nowhere to attach, so the file is
2472    // refused rather than dropping it.
2473    let mut leading: Vec<&Comment> = Vec::new();
2474    let mut element_trailing: Vec<Option<&Comment>> = vec![None; elements.len()];
2475    for comment in &comments {
2476        let in_body = elements
2477            .iter()
2478            .any(|(span, _)| span.start < comment.span.start && comment.span.start < span.end);
2479        if in_body {
2480            continue;
2481        }
2482        let line_start = source[..comment.span.start]
2483            .rfind('\n')
2484            .map(|newline| newline + 1)
2485            .unwrap_or(0);
2486        if source[line_start..comment.span.start].trim().is_empty() {
2487            leading.push(comment);
2488            continue;
2489        }
2490        let comment_line = line_index(source, comment.span.start);
2491        let mut placed = false;
2492        for (index, (span, _)) in elements.iter().enumerate() {
2493            if line_index(source, span.end.saturating_sub(1)) == comment_line {
2494                if element_trailing[index].is_some() {
2495                    return None;
2496                }
2497                element_trailing[index] = Some(comment);
2498                placed = true;
2499                break;
2500            }
2501        }
2502        if !placed {
2503            return None;
2504        }
2505    }
2506
2507    let mut out = String::new();
2508    let mut next_comment = 0;
2509    let element_count = elements.len();
2510    for (index, (span, chunk)) in elements.iter().enumerate() {
2511        while next_comment < leading.len() && leading[next_comment].span.start < span.start {
2512            push_line(&mut out, format_comment(leading[next_comment]));
2513            next_comment += 1;
2514        }
2515        match element_trailing[index] {
2516            Some(comment) => {
2517                out.push_str(chunk.strip_suffix('\n').unwrap_or(chunk));
2518                out.push_str(&format!("  {}\n", format_comment(comment)));
2519            }
2520            None => out.push_str(chunk),
2521        }
2522        if index + 1 < element_count {
2523            out.push('\n');
2524        }
2525    }
2526    if next_comment < leading.len() {
2527        if element_count > 0 {
2528            out.push('\n');
2529        }
2530        while next_comment < leading.len() {
2531            push_line(&mut out, format_comment(leading[next_comment]));
2532            next_comment += 1;
2533        }
2534    }
2535
2536    // Safety net against silent data loss: in-body comments are left to each
2537    // element's body formatter, and some formatters rebuild from the AST (which
2538    // drops comments). The idempotency self-check can't catch a *consistent*
2539    // drop, so verify here that every source comment survives — refuse otherwise.
2540    if lex_comments(&out).len() != comments.len() {
2541        return None;
2542    }
2543    Some(out)
2544}
2545
2546fn format_comment(comment: &Comment) -> String {
2547    let marker = match comment.marker {
2548        CommentMarker::Hash => "#",
2549        CommentMarker::Slash => "//",
2550    };
2551    let text = comment.text.trim();
2552    if text.is_empty() {
2553        marker.to_owned()
2554    } else {
2555        format!("{marker} {text}")
2556    }
2557}
2558
2559/// Zero-based source line of a byte offset.
2560fn line_index(source: &str, offset: usize) -> usize {
2561    source.as_bytes()[..offset]
2562        .iter()
2563        .filter(|&&byte| byte == b'\n')
2564        .count()
2565}
2566
2567/// Classify the comments inside `body` (a field-list declaration's brace region)
2568/// against its `members` (each member's span + already-formatted lines, in source
2569/// order). Returns the own-line comments to interleave between members, plus a
2570/// per-member optional trailing comment (appended to that member's last line).
2571/// Returns `None` when a comment cannot be placed safely — a comment inside a
2572/// *multi-line* member's own body (a deeper level this pass does not place), or a
2573/// trailing comment with no single-line member on its line — so the caller refuses
2574/// the file rather than misplace it. `comments` must be sorted by `span.start`.
2575fn classify_body_comments<'a>(
2576    source: &str,
2577    body: SourceSpan,
2578    members: &[(SourceSpan, Vec<String>)],
2579    comments: &'a [Comment],
2580) -> Option<(Vec<&'a Comment>, Vec<Option<&'a Comment>>)> {
2581    let mut own_line: Vec<&Comment> = Vec::new();
2582    let mut trailing: Vec<Option<&Comment>> = vec![None; members.len()];
2583    for comment in comments {
2584        if comment.span.start <= body.start || comment.span.start >= body.end {
2585            continue;
2586        }
2587        // A comment inside a multi-line member's own braces is a deeper level we do
2588        // not place here (e.g. a data-carrying `enum` variant's nested field).
2589        if members.iter().any(|(span, lines)| {
2590            lines.len() > 1 && span.start < comment.span.start && comment.span.start < span.end
2591        }) {
2592            return None;
2593        }
2594        let line_start = source[..comment.span.start]
2595            .rfind('\n')
2596            .map(|index| index + 1)
2597            .unwrap_or(0);
2598        if source[line_start..comment.span.start].trim().is_empty() {
2599            own_line.push(comment);
2600            continue;
2601        }
2602        // Trailing: attach to a single-line member sharing the comment's line.
2603        let comment_line = line_index(source, comment.span.start);
2604        let mut placed = false;
2605        for (index, (span, lines)) in members.iter().enumerate() {
2606            if lines.len() == 1 && line_index(source, span.start) == comment_line {
2607                if trailing[index].is_some() {
2608                    return None;
2609                }
2610                trailing[index] = Some(comment);
2611                placed = true;
2612                break;
2613            }
2614        }
2615        if !placed {
2616            return None;
2617        }
2618    }
2619    Some((own_line, trailing))
2620}
2621
2622/// Emit each member's lines, interleaving `own_line` comments by source position
2623/// (at `indent`) and appending each member's `trailing` comment to its last line.
2624/// `members` and `own_line` must be in ascending `span.start` order; `trailing`
2625/// is parallel to `members`.
2626fn emit_members_with_comments(
2627    members: &[(SourceSpan, Vec<String>)],
2628    own_line: &[&Comment],
2629    trailing: &[Option<&Comment>],
2630    indent: &str,
2631    formatted: &mut String,
2632) {
2633    let mut next = 0;
2634    for (index, (span, lines)) in members.iter().enumerate() {
2635        while next < own_line.len() && own_line[next].span.start < span.start {
2636            push_line(
2637                formatted,
2638                format!("{indent}{}", format_comment(own_line[next])),
2639            );
2640            next += 1;
2641        }
2642        let last = lines.len().saturating_sub(1);
2643        for (offset, line) in lines.iter().enumerate() {
2644            match trailing[index] {
2645                Some(comment) if offset == last => {
2646                    push_line(formatted, format!("{line}  {}", format_comment(comment)));
2647                }
2648                _ => push_line(formatted, line.clone()),
2649            }
2650        }
2651    }
2652    while next < own_line.len() {
2653        push_line(
2654            formatted,
2655            format!("{indent}{}", format_comment(own_line[next])),
2656        );
2657        next += 1;
2658    }
2659}
2660
2661/// Format a `class` body with its own-line and trailing comments preserved.
2662/// Returns `false` (caller refuses the file) when a body comment cannot be placed
2663/// safely.
2664fn try_format_class_with_comments(
2665    class_decl: &ClassDecl,
2666    source: &str,
2667    comments: &[Comment],
2668    formatted: &mut String,
2669) -> bool {
2670    let members: Vec<(SourceSpan, Vec<String>)> = class_decl
2671        .fields
2672        .iter()
2673        .map(|field| {
2674            let key = if field.is_key { " @key" } else { "" };
2675            (
2676                field.span,
2677                vec![format!(
2678                    "  {} {}{key}",
2679                    field.name.name,
2680                    field.ty.to_source()
2681                )],
2682            )
2683        })
2684        .collect();
2685    let Some((own_line, trailing)) =
2686        classify_body_comments(source, class_decl.span, &members, comments)
2687    else {
2688        return false;
2689    };
2690    push_line(formatted, format!("class {} {{", class_decl.name.name));
2691    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2692    push_line(formatted, "}");
2693    true
2694}
2695
2696/// Format a `queue` body (its single `tracker` member) with own-line and trailing
2697/// comments preserved. Returns `false` (caller refuses the file) when a body
2698/// comment cannot be placed safely.
2699fn try_format_tracker_with_comments(
2700    queue: &TrackerDecl,
2701    source: &str,
2702    comments: &[Comment],
2703    formatted: &mut String,
2704) -> bool {
2705    let members: Vec<(SourceSpan, Vec<String>)> = vec![(
2706        queue.provider.span,
2707        vec![format!("  provider {}", queue.provider.name)],
2708    )];
2709    let Some((own_line, trailing)) = classify_body_comments(source, queue.span, &members, comments)
2710    else {
2711        return false;
2712    };
2713    push_line(formatted, format!("tracker {} {{", queue.name.name));
2714    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2715    push_line(formatted, "}");
2716    true
2717}
2718
2719/// Format a `file store` body (its `root` and optional `allow read`/`allow write`
2720/// clauses) with own-line and trailing comments preserved, interleaved by the
2721/// clause spans captured during parsing. Returns `false` (caller refuses the file)
2722/// when a body comment cannot be placed safely.
2723fn try_format_filestore_with_comments(
2724    file_store: &FileStoreDecl,
2725    source: &str,
2726    comments: &[Comment],
2727    formatted: &mut String,
2728) -> bool {
2729    let render = |globs: &[String]| {
2730        globs
2731            .iter()
2732            .map(|glob| format!("{glob:?}"))
2733            .collect::<Vec<_>>()
2734            .join(", ")
2735    };
2736    let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::new();
2737    if let Some(span) = file_store.root_span {
2738        members.push((span, vec![format!("  root {:?}", file_store.root)]));
2739    }
2740    if !file_store.read_globs.is_empty() {
2741        if let Some(span) = file_store.read_span {
2742            members.push((
2743                span,
2744                vec![format!("  allow read [{}]", render(&file_store.read_globs))],
2745            ));
2746        }
2747    }
2748    if !file_store.write_globs.is_empty() {
2749        if let Some(span) = file_store.write_span {
2750            members.push((
2751                span,
2752                vec![format!(
2753                    "  allow write [{}]",
2754                    render(&file_store.write_globs)
2755                )],
2756            ));
2757        }
2758    }
2759    if let Some(provider) = &file_store.provider {
2760        if let Some(span) = file_store.provider_span {
2761            members.push((span, vec![format!("  provider {}", provider.name)]));
2762        }
2763    }
2764    members.sort_by_key(|(span, _)| span.start);
2765    let Some((own_line, trailing)) =
2766        classify_body_comments(source, file_store.span, &members, comments)
2767    else {
2768        return false;
2769    };
2770    push_line(formatted, format!("file store {} {{", file_store.name.name));
2771    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2772    push_line(formatted, "}");
2773    true
2774}
2775
2776/// Format a `signal` body (a typed payload schema of `ClassField`s, like a class)
2777/// with its own-line and trailing comments preserved. Returns `false` (caller
2778/// refuses the file) when a body comment cannot be placed safely.
2779fn try_format_event_with_comments(
2780    event: &EventDecl,
2781    source: &str,
2782    comments: &[Comment],
2783    formatted: &mut String,
2784) -> bool {
2785    let members: Vec<(SourceSpan, Vec<String>)> = event
2786        .fields
2787        .iter()
2788        .map(|field| {
2789            (
2790                field.span,
2791                vec![format!("  {} {}", field.name.name, field.ty.to_source())],
2792            )
2793        })
2794        .collect();
2795    let Some((own_line, trailing)) = classify_body_comments(source, event.span, &members, comments)
2796    else {
2797        return false;
2798    };
2799    push_line(formatted, format!("signal {} {{", event.name));
2800    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2801    push_line(formatted, "}");
2802    true
2803}
2804
2805fn agent_field_span(field: &AgentField) -> SourceSpan {
2806    match field {
2807        AgentField::Provider(ident) => ident.span,
2808        AgentField::Profile(profile) => profile.span,
2809        AgentField::Capacity(_, span)
2810        | AgentField::Skills(_, span)
2811        | AgentField::Capabilities(_, span)
2812        | AgentField::Requires(_, span)
2813        | AgentField::Tools(_, span) => *span,
2814        AgentField::Compaction(strategy) => strategy.span,
2815        AgentField::Thread(mode) => mode.span,
2816        AgentField::Settings(sources) => sources.span,
2817        AgentField::Unknown { span, .. } => *span,
2818    }
2819}
2820
2821fn agent_field_line(field: &AgentField) -> String {
2822    match field {
2823        AgentField::Provider(provider) => format!("  provider {}", provider.name),
2824        AgentField::Profile(profile) => format!("  profile {:?}", profile.value),
2825        AgentField::Capacity(capacity, _) => format!("  capacity {capacity}"),
2826        AgentField::Skills(skills, _) => {
2827            let skills = skills
2828                .iter()
2829                .map(|skill| format!("{:?}", skill.value))
2830                .collect::<Vec<_>>()
2831                .join(", ");
2832            format!("  skills [{skills}]")
2833        }
2834        AgentField::Capabilities(capabilities, _) => {
2835            let capabilities = capabilities
2836                .iter()
2837                .map(|capability| format!("{:?}", capability.value))
2838                .collect::<Vec<_>>()
2839                .join(", ");
2840            format!("  capabilities [{capabilities}]")
2841        }
2842        AgentField::Requires(classes, _) => {
2843            let classes = classes
2844                .iter()
2845                .map(|class| class.name.as_str())
2846                .collect::<Vec<_>>()
2847                .join(", ");
2848            format!("  requires [{classes}]")
2849        }
2850        AgentField::Tools(tools, _) => {
2851            let tools = tools
2852                .iter()
2853                .map(|tool| tool.name.as_str())
2854                .collect::<Vec<_>>()
2855                .join(", ");
2856            format!("  tools [{tools}]")
2857        }
2858        AgentField::Compaction(strategy) => format!("  compaction {}", strategy.name),
2859        AgentField::Thread(mode) => format!("  thread {}", mode.name),
2860        AgentField::Settings(sources) => format!("  settings {}", sources.name),
2861        AgentField::Unknown { name, .. } => format!("  {}", name.name),
2862    }
2863}
2864
2865/// Format an `agent` body with its own-line and trailing comments preserved.
2866/// Returns `false` (caller refuses the file) when a body comment cannot be placed
2867/// safely.
2868fn try_format_agent_with_comments(
2869    agent: &AgentDecl,
2870    source: &str,
2871    comments: &[Comment],
2872    formatted: &mut String,
2873) -> bool {
2874    let members: Vec<(SourceSpan, Vec<String>)> = agent
2875        .fields
2876        .iter()
2877        .map(|field| (agent_field_span(field), vec![agent_field_line(field)]))
2878        .collect();
2879    let Some((own_line, trailing)) = classify_body_comments(source, agent.span, &members, comments)
2880    else {
2881        return false;
2882    };
2883    let harness = agent
2884        .harness
2885        .as_ref()
2886        .map(|harness| format!(" using {}", harness.name))
2887        .or_else(|| {
2888            agent
2889                .delegated_to
2890                .as_ref()
2891                .map(|delegate| format!(" delegated to {}", delegate.name))
2892        })
2893        .unwrap_or_default();
2894    push_line(
2895        formatted,
2896        format!("agent {}{} {{", agent.name.name, harness),
2897    );
2898    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2899    push_line(formatted, "}");
2900    true
2901}
2902
2903/// Lines for one enum variant, with comments inside a data-carrying variant's
2904/// nested field block preserved (own-line interleaved, trailing appended) — the
2905/// block is a field list in braces, so it reuses the same classify/emit one level
2906/// deeper. Returns `None` when a nested comment cannot be placed safely.
2907fn enum_variant_lines_with_comments(
2908    variant: &EnumVariantDecl,
2909    source: &str,
2910    comments: &[Comment],
2911) -> Option<Vec<String>> {
2912    if variant.fields.is_empty() {
2913        return Some(vec![format!("  {}", variant.name.name)]);
2914    }
2915    let members: Vec<(SourceSpan, Vec<String>)> = variant
2916        .fields
2917        .iter()
2918        .map(|field| {
2919            (
2920                field.span,
2921                vec![format!("    {} {}", field.name.name, field.ty.to_source())],
2922            )
2923        })
2924        .collect();
2925    // `comments` is filtered to this variant's span by classify (via variant.span).
2926    let (own_line, trailing) = classify_body_comments(source, variant.span, &members, comments)?;
2927    let mut block = String::new();
2928    emit_members_with_comments(&members, &own_line, &trailing, "    ", &mut block);
2929    let mut lines = vec![format!("  {} {{", variant.name.name)];
2930    lines.extend(block.lines().map(str::to_owned));
2931    lines.push("  }".to_owned());
2932    Some(lines)
2933}
2934
2935/// Format an `enum` body with its comments preserved at both levels: between
2936/// variants (own-line interleaved, trailing appended to a bare variant's line) and
2937/// inside a data-carrying variant's nested field block. Each brace-body filters
2938/// comments by its own span, so the two levels never double-count. Returns `false`
2939/// (caller refuses the file) when a comment cannot be placed safely.
2940fn try_format_enum_with_comments(
2941    enum_decl: &EnumDecl,
2942    source: &str,
2943    comments: &[Comment],
2944    formatted: &mut String,
2945) -> bool {
2946    let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::with_capacity(enum_decl.variants.len());
2947    for variant in &enum_decl.variants {
2948        let Some(lines) = enum_variant_lines_with_comments(variant, source, comments) else {
2949            return false;
2950        };
2951        members.push((variant.span, lines));
2952    }
2953    // Enum-body-level comments are those NOT inside a data variant's nested block
2954    // (those are placed by `enum_variant_lines_with_comments`); pass only those to
2955    // the body-level classify so the nested ones are not counted twice.
2956    let body_level: Vec<Comment> = comments
2957        .iter()
2958        .filter(|comment| {
2959            !enum_decl.variants.iter().any(|variant| {
2960                !variant.fields.is_empty()
2961                    && variant.span.start < comment.span.start
2962                    && comment.span.start < variant.span.end
2963            })
2964        })
2965        .cloned()
2966        .collect();
2967    let Some((own_line, trailing)) =
2968        classify_body_comments(source, enum_decl.span, &members, &body_level)
2969    else {
2970        return false;
2971    };
2972    push_line(formatted, format!("enum {} {{", enum_decl.name.name));
2973    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2974    push_line(formatted, "}");
2975    true
2976}
2977
2978/// The name a top-level named declaration introduces, paired with its span, when
2979/// it is a kind that another workflow can reference by name (schemas, agents,
2980/// coordination resources, signals). Rules/tests/asserts/apply/contracts/patterns
2981/// introduce no such cross-referenced name here. Mirrors `document_symbols`'
2982/// named-decl set. Used to attach a "declared in workflow B" note when a
2983/// workflow references a name that is really private to a sibling.
2984fn referenced_decl_name(item: &Item) -> Option<(String, SourceSpan)> {
2985    match item {
2986        Item::Class(decl) => Some((decl.name.name.clone(), decl.span)),
2987        Item::Enum(decl) => Some((decl.name.name.clone(), decl.span)),
2988        Item::Agent(decl) => Some((decl.name.name.clone(), decl.span)),
2989        Item::Coerce(decl) => Some((decl.name.name.clone(), decl.span)),
2990        Item::Lease(decl) => Some((decl.name.name.clone(), decl.span)),
2991        Item::Ledger(decl) => Some((decl.name.name.clone(), decl.span)),
2992        Item::Counter(decl) => Some((decl.name.name.clone(), decl.span)),
2993        Item::Tracker(decl) => Some((decl.name.name.clone(), decl.span)),
2994        Item::Channel(decl) => Some((decl.name.name.clone(), decl.span)),
2995        Item::FileStore(decl) => Some((decl.name.name.clone(), decl.span)),
2996        Item::MemoryPool(decl) => Some((decl.name.name.clone(), decl.span)),
2997        Item::Event(decl) => Some((decl.name.clone(), decl.span)),
2998        Item::Table(decl) => Some((decl.name.name.clone(), decl.span)),
2999        Item::Gauge(decl) => Some((decl.name.name.clone(), decl.span)),
3000        Item::Campaign(decl) => Some((decl.name.name.clone(), decl.span)),
3001        Item::Mark(decl) => Some((decl.name.value.clone(), decl.span)),
3002        _ => None,
3003    }
3004}
3005
3006/// If `diagnostic` (produced while validating workflow `current`) reports an
3007/// unknown name that is actually declared *private to a sibling workflow*, attach
3008/// a related note pointing at that sibling's declaration. This turns a bare
3009/// "unknown class `X`" into an actionable "…and `X` lives in workflow `B`; move
3010/// it to the top level to share it." A name that is global or one of `current`'s
3011/// own locals is legitimately in scope and never annotated.
3012fn annotate_cross_workflow_leak(
3013    diagnostic: &mut Diagnostic,
3014    current: &str,
3015    own_locals: &BTreeSet<String>,
3016    global_names: &BTreeSet<String>,
3017    sibling_locals: &BTreeMap<String, Vec<(String, SourceSpan)>>,
3018) {
3019    for (name, owners) in sibling_locals {
3020        if global_names.contains(name) || own_locals.contains(name) {
3021            continue;
3022        }
3023        // Only names actually referenced (as `` `name` ``) in this diagnostic, and
3024        // owned by some workflow other than the one being validated.
3025        if !diagnostic.message.contains(&format!("`{name}`")) {
3026            continue;
3027        }
3028        let Some((owner, span)) = owners.iter().find(|(owner, _)| owner != current) else {
3029            continue;
3030        };
3031        diagnostic.related.push(RelatedInfo {
3032            span: *span,
3033            message: format!(
3034                "`{name}` is declared inside workflow `{owner}`, which makes it \
3035                 private to that workflow; move it to a top-level declaration to \
3036                 share it across workflows"
3037            ),
3038        });
3039        return;
3040    }
3041}
3042
3043fn select_root_workflow(
3044    mut program: Program,
3045    root: Option<&str>,
3046) -> Result<Program, Vec<Diagnostic>> {
3047    // A runnable program requires at least one explicit `workflow`. The implicit
3048    // compatibility root is removed (RESOLVED 2026-07-01): a source that declares
3049    // no `workflow` at all (neither the header form nor a `workflow Name { ... }`
3050    // block) is a library fragment, not a program, and is rejected here rather
3051    // than silently compiled as an anonymous root.
3052    if program.workflow.is_none() && program.workflows.is_empty() {
3053        return Err(vec![Diagnostic {
3054            related: Vec::new(),
3055            span: SourceSpan { start: 0, end: 0 },
3056            message: "program declares no `workflow`".to_owned(),
3057            suggestion: Some(
3058                "add an explicit `workflow Name { ... }` declaration; a runnable \
3059                 program requires at least one workflow (files that only declare \
3060                 shared types or patterns are libraries, meant to be `include`d)"
3061                    .to_owned(),
3062            ),
3063        }]);
3064    }
3065
3066    if program.workflows.is_empty() {
3067        if let Some(root) = root {
3068            match program.workflow.as_ref() {
3069                Some(workflow) if workflow.name == root => {}
3070                Some(workflow) => {
3071                    return Err(vec![Diagnostic {
3072                        related: Vec::new(),
3073                        span: workflow.span,
3074                        message: format!("root workflow `{root}` was not found"),
3075                        suggestion: Some(format!("available workflow: `{}`", workflow.name)),
3076                    }]);
3077                }
3078                None => {
3079                    return Err(vec![Diagnostic {
3080                        related: Vec::new(),
3081                        span: SourceSpan { start: 0, end: 0 },
3082                        message: format!("root workflow `{root}` was not found"),
3083                        suggestion: Some(
3084                            "add an explicit `workflow Name { ... }` declaration".to_owned(),
3085                        ),
3086                    }]);
3087                }
3088            }
3089        }
3090        return Ok(program);
3091    }
3092
3093    let selected_index = match root {
3094        Some(root) => match program
3095            .workflows
3096            .iter()
3097            .position(|workflow| workflow.name.name == root)
3098        {
3099            Some(index) => index,
3100            None => {
3101                let names = program
3102                    .workflows
3103                    .iter()
3104                    .map(|workflow| format!("`{}`", workflow.name.name))
3105                    .collect::<Vec<_>>()
3106                    .join(", ");
3107                return Err(vec![Diagnostic {
3108                    related: Vec::new(),
3109                    span: SourceSpan { start: 0, end: 0 },
3110                    message: format!("root workflow `{root}` was not found"),
3111                    suggestion: Some(format!("available workflows: {names}")),
3112                }]);
3113            }
3114        },
3115        None if program.workflows.len() == 1 => 0,
3116        None => {
3117            let names = program
3118                .workflows
3119                .iter()
3120                .map(|workflow| format!("`{}`", workflow.name.name))
3121                .collect::<Vec<_>>()
3122                .join(", ");
3123            return Err(vec![Diagnostic {
3124                related: Vec::new(),
3125                span: SourceSpan { start: 0, end: 0 },
3126                message: "multiple workflow declarations require an explicit root".to_owned(),
3127                suggestion: Some(format!(
3128                    "pass `--root <name>`; available workflows: {names}"
3129                )),
3130            }]);
3131        }
3132    };
3133
3134    let selected = program.workflows.remove(selected_index);
3135    let mut items = program.items;
3136    let workflow_tags = selected.tags;
3137    let workflow_description = selected.description;
3138    items.extend(selected.items);
3139    Ok(Program {
3140        workflow: Some(selected.name),
3141        workflow_tags,
3142        workflow_description,
3143        explicit_workflow_body: true,
3144        workflows: Vec::new(),
3145        patterns: program.patterns,
3146        items,
3147    })
3148}
3149
3150impl IrProgram {
3151    pub fn construct_uses(&self) -> Vec<&IrConstructUse> {
3152        self.rules
3153            .iter()
3154            .flat_map(|rule| rule.metadata.effects.iter())
3155            .filter_map(|effect| effect.construct_use.as_ref())
3156            .collect()
3157    }
3158
3159    pub fn contract_registry(&self) -> ContractRegistry {
3160        let mut libraries = BTreeMap::<String, LibraryRegistration>::new();
3161        let mut contracts = BTreeMap::<(String, String), EffectContract>::new();
3162
3163        for use_decl in &self.uses {
3164            libraries
3165                .entry(use_decl.name.clone())
3166                .or_insert_with(|| LibraryRegistration {
3167                    id: use_decl.name.clone(),
3168                    version: "unlocked".to_owned(),
3169                    standard: false,
3170                });
3171        }
3172
3173        if !self.harnesses.is_empty() || !self.agents.is_empty() {
3174            register_standard_library(&mut libraries, "std.agent");
3175        }
3176        if !self.trackers.is_empty() {
3177            register_standard_library(&mut libraries, "std.tracker");
3178        }
3179        if !self.events.is_empty() {
3180            register_standard_library(&mut libraries, "std.ingress");
3181        }
3182        if !self.leases.is_empty() || !self.ledgers.is_empty() || !self.counters.is_empty() {
3183            register_standard_library(&mut libraries, "std.coord");
3184        }
3185        if !self.channels.is_empty() {
3186            register_standard_library(&mut libraries, "std.messaging");
3187        }
3188        // A bare `file store` declaration registers the owning library even
3189        // before any rule uses a file effect (spec/std-files.md "Manifest":
3190        // the declaration alone previously registered nothing).
3191        if !self.file_stores.is_empty() {
3192            register_standard_library(&mut libraries, "std.files");
3193        }
3194        if self.sources.iter().any(|source| source.is_clock) {
3195            register_standard_library(&mut libraries, "std.time");
3196        }
3197        if !self.coerces.is_empty() {
3198            register_standard_library(&mut libraries, "std.coercion");
3199            register_effect_contract(
3200                &mut libraries,
3201                &mut contracts,
3202                IrEffectKind::SchemaCoerce,
3203                Vec::new(),
3204            );
3205        }
3206
3207        for rule in &self.rules {
3208            for effect in &rule.metadata.effects {
3209                register_effect_contract(
3210                    &mut libraries,
3211                    &mut contracts,
3212                    effect.kind.clone(),
3213                    effect.required_capabilities.clone(),
3214                );
3215            }
3216        }
3217
3218        // Package-owned construct registrations (e.g. `send`, `recall`) are NOT
3219        // registered here: they come from a package manifest — embedded std
3220        // manifests included — merged in by the CLI when the owning package is
3221        // imported (`use std.messaging`). Modeled in
3222        // `models/maude/std-construct-authorization.maude`.
3223        ContractRegistry {
3224            libraries: libraries.into_values().collect(),
3225            constructs: Vec::new(),
3226            effect_contracts: contracts.into_values().collect(),
3227        }
3228    }
3229
3230    pub fn to_snapshot(&self) -> String {
3231        let mut snapshot = String::new();
3232        push_line(&mut snapshot, format!("workflow {}", self.workflow));
3233
3234        if !self.source_tags.is_empty() {
3235            push_line(&mut snapshot, "source_tags");
3236            for tag in &self.source_tags {
3237                push_line(
3238                    &mut snapshot,
3239                    format!("@{} {} {}", tag.name, tag.target_kind, tag.target),
3240                );
3241            }
3242        }
3243
3244        if !self.source_descriptions.is_empty() {
3245            push_line(&mut snapshot, "source_descriptions");
3246            for description in &self.source_descriptions {
3247                push_line(
3248                    &mut snapshot,
3249                    format!(
3250                        "{:?} {} {}",
3251                        description.value, description.target_kind, description.target
3252                    ),
3253                );
3254            }
3255        }
3256
3257        if !self.shared_coordination_usage.is_empty() {
3258            push_line(&mut snapshot, "shared_coordination_usage");
3259            for usage in &self.shared_coordination_usage {
3260                push_line(
3261                    &mut snapshot,
3262                    format!(
3263                        "{} <- {}",
3264                        usage.resource,
3265                        usage.workflow_principals.join(",")
3266                    ),
3267                );
3268            }
3269        }
3270
3271        if !self.includes.is_empty() {
3272            push_line(&mut snapshot, "includes");
3273            for include in &self.includes {
3274                match &include.source_hash {
3275                    Some(source_hash) => {
3276                        push_line(
3277                            &mut snapshot,
3278                            format!("  {} hash {}", include.path, source_hash),
3279                        );
3280                    }
3281                    None => push_line(&mut snapshot, format!("  {}", include.path)),
3282                }
3283            }
3284        }
3285
3286        if !self.pattern_applications.is_empty() {
3287            push_line(&mut snapshot, "pattern_applications");
3288            for application in &self.pattern_applications {
3289                let type_args = application
3290                    .type_args
3291                    .iter()
3292                    .map(IrType::to_snapshot)
3293                    .collect::<Vec<_>>()
3294                    .join(", ");
3295                push_line(
3296                    &mut snapshot,
3297                    format!(
3298                        "  {} as {}<{}>",
3299                        application.pattern, application.alias, type_args
3300                    ),
3301                );
3302                push_line(
3303                    &mut snapshot,
3304                    format!(
3305                        "    defined-at {}..{}",
3306                        application.definition_span.start, application.definition_span.end
3307                    ),
3308                );
3309                push_line(
3310                    &mut snapshot,
3311                    format!(
3312                        "    applied-at {}..{}",
3313                        application.application_span.start, application.application_span.end
3314                    ),
3315                );
3316                for argument in &application.value_args {
3317                    push_line(
3318                        &mut snapshot,
3319                        format!("    arg {} {}", argument.name, argument.value),
3320                    );
3321                }
3322                for generated in &application.generated {
3323                    push_line(&mut snapshot, format!("    generated {generated}"));
3324                }
3325            }
3326        }
3327
3328        if !self.workflow_contracts.is_empty() {
3329            push_line(&mut snapshot, "workflow_contracts");
3330            for contract in &self.workflow_contracts {
3331                push_line(
3332                    &mut snapshot,
3333                    format!(
3334                        "  {} {} {}",
3335                        contract.kind.as_str(),
3336                        contract.name,
3337                        contract.ty.to_snapshot()
3338                    ),
3339                );
3340            }
3341        }
3342
3343        if !self.uses.is_empty() {
3344            push_line(&mut snapshot, "uses");
3345            for use_decl in &self.uses {
3346                push_line(
3347                    &mut snapshot,
3348                    format!("  {} {}", use_decl.kind.as_str(), use_decl.name),
3349                );
3350            }
3351        }
3352
3353        if !self.schemas.is_empty() {
3354            push_line(&mut snapshot, "schemas");
3355            for schema in &self.schemas {
3356                match schema {
3357                    IrSchema::Enum(enum_decl) => {
3358                        push_line(
3359                            &mut snapshot,
3360                            format!(
3361                                "  enum {} {{ {} }}",
3362                                enum_decl.name,
3363                                enum_decl.variants.join(", ")
3364                            ),
3365                        );
3366                    }
3367                    IrSchema::Class(class_decl) => {
3368                        push_line(&mut snapshot, format!("  class {}", class_decl.name));
3369                        for field in &class_decl.fields {
3370                            // `@key` is serialized only when set, so non-keyed
3371                            // classes keep their prior snapshot (no ripple).
3372                            let key = if field.is_key { " @key" } else { "" };
3373                            push_line(
3374                                &mut snapshot,
3375                                format!("    {} {}{key}", field.name, field.ty.to_snapshot()),
3376                            );
3377                        }
3378                    }
3379                }
3380            }
3381        }
3382
3383        if !self.harnesses.is_empty() {
3384            push_line(&mut snapshot, "harnesses");
3385            for harness in &self.harnesses {
3386                push_line(
3387                    &mut snapshot,
3388                    format!("  harness {} kind={}", harness.name, harness.kind),
3389                );
3390            }
3391        }
3392        if !self.trackers.is_empty() {
3393            push_line(&mut snapshot, "trackers");
3394            for queue in &self.trackers {
3395                push_line(
3396                    &mut snapshot,
3397                    format!("  tracker {} provider={}", queue.name, queue.provider),
3398                );
3399            }
3400        }
3401
3402        if !self.channels.is_empty() {
3403            push_line(&mut snapshot, "channels");
3404            for channel in &self.channels {
3405                let mut line = format!("  channel {} provider={}", channel.name, channel.provider);
3406                if let Some(workspace) = &channel.workspace {
3407                    line.push_str(&format!(" workspace={workspace}"));
3408                }
3409                if let Some(destination) = &channel.destination {
3410                    line.push_str(&format!(" destination={destination:?}"));
3411                }
3412                push_line(&mut snapshot, line);
3413            }
3414        }
3415
3416        if !self.gauges.is_empty() {
3417            push_line(&mut snapshot, "gauges");
3418            for gauge in &self.gauges {
3419                let mut line = format!(
3420                    "  gauge {} judge={}:{}",
3421                    gauge.name, gauge.judge_kind, gauge.judge_target
3422                );
3423                if !gauge.judge_args.is_empty() {
3424                    line.push_str(&format!(" args=({})", gauge.judge_args.join(",")));
3425                }
3426                if let Some(site) = &gauge.site {
3427                    line.push_str(&format!(" site={site}"));
3428                }
3429                if let Some(bar) = &gauge.expect {
3430                    line.push_str(&format!(
3431                        " expect={}:{}{}{}",
3432                        bar.form, bar.subject, bar.op, bar.threshold
3433                    ));
3434                }
3435                if !gauge.inputs.is_empty() {
3436                    line.push_str(&format!(" inputs={}", gauge.inputs.join(",")));
3437                }
3438                push_line(&mut snapshot, line);
3439            }
3440        }
3441
3442        if !self.marks.is_empty() {
3443            push_line(&mut snapshot, "marks");
3444            for mark in &self.marks {
3445                push_line(
3446                    &mut snapshot,
3447                    format!("  mark {:?} after {}", mark.name, mark.site),
3448                );
3449            }
3450        }
3451
3452        if !self.campaigns.is_empty() {
3453            push_line(&mut snapshot, "campaigns");
3454            for campaign in &self.campaigns {
3455                let mut line = format!("  campaign {}", campaign.name);
3456                if !campaign.ascend.is_empty() {
3457                    line.push_str(&format!(" ascend={}", campaign.ascend.join(",")));
3458                }
3459                for reach in &campaign.reach {
3460                    line.push_str(&format!(
3461                        " reach={}{}{}{}",
3462                        reach.gauge,
3463                        reach.op,
3464                        reach.threshold,
3465                        reach.unit.as_deref().unwrap_or("")
3466                    ));
3467                }
3468                for guard in &campaign.guard {
3469                    line.push_str(&format!(
3470                        " guard={}:within:{}%",
3471                        guard.gauge, guard.band_percent
3472                    ));
3473                }
3474                if !campaign.sacrifice.is_empty() {
3475                    line.push_str(&format!(" sacrifice={}", campaign.sacrifice.join(",")));
3476                }
3477                if campaign.proposer_redacted {
3478                    line.push_str(" proposer=redacted");
3479                }
3480                push_line(&mut snapshot, line);
3481            }
3482        }
3483
3484        if !self.file_stores.is_empty() {
3485            push_line(&mut snapshot, "file_stores");
3486            for file_store in &self.file_stores {
3487                push_line(
3488                    &mut snapshot,
3489                    format!(
3490                        "  file store {} root={:?}",
3491                        file_store.name, file_store.root
3492                    ),
3493                );
3494                // Globs are serialized only when present, so stores without an
3495                // `allow` clause keep their prior snapshot (no ripple).
3496                if !file_store.read_globs.is_empty() {
3497                    push_line(
3498                        &mut snapshot,
3499                        format!("    allow read {:?}", file_store.read_globs),
3500                    );
3501                }
3502                if !file_store.write_globs.is_empty() {
3503                    push_line(
3504                        &mut snapshot,
3505                        format!("    allow write {:?}", file_store.write_globs),
3506                    );
3507                }
3508                // The provider likewise serializes only when declared (unset =
3509                // the `local` default), so provider-less stores keep their
3510                // prior `.ir` byte-identically (slice F5 zero-churn gate).
3511                if let Some(provider) = &file_store.provider {
3512                    push_line(&mut snapshot, format!("    provider {provider}"));
3513                }
3514            }
3515        }
3516
3517        if !self.memory_pools.is_empty() {
3518            push_line(&mut snapshot, "memory_pools");
3519            for pool in &self.memory_pools {
3520                push_line(&mut snapshot, format!("  memory pool {}", pool.name));
3521                // The context limit is serialized only when present, so pools
3522                // without it keep a minimal snapshot (no ripple).
3523                if let Some(limit) = pool.context_limit {
3524                    push_line(&mut snapshot, format!("    context limit {limit}"));
3525                }
3526            }
3527        }
3528
3529        if !self.agents.is_empty() {
3530            push_line(&mut snapshot, "agents");
3531            for agent in &self.agents {
3532                let profile = agent.profile.as_deref().unwrap_or("<missing>");
3533                let harness = agent.harness.as_deref().unwrap_or("<fallback>");
3534                let provider = agent.provider.as_deref().unwrap_or("<fallback>");
3535                let capacity = agent
3536                    .capacity
3537                    .map(|capacity| capacity.to_string())
3538                    .unwrap_or_else(|| "<missing>".to_owned());
3539                let skills = if agent.skills.is_empty() {
3540                    "[]".to_owned()
3541                } else {
3542                    format!("[{}]", agent.skills.join(", "))
3543                };
3544                let capabilities = if agent.capabilities.is_empty() {
3545                    "[]".to_owned()
3546                } else {
3547                    format!("[{}]", agent.capabilities.join(", "))
3548                };
3549                let tools = if agent.tools.is_empty() {
3550                    "[]".to_owned()
3551                } else {
3552                    format!("[{}]", agent.tools.join(", "))
3553                };
3554                // Feature requirements append only when declared, so agents
3555                // without `requires` keep an unchanged .ir snapshot (no ripple).
3556                let requires = if agent.requires.is_empty() {
3557                    String::new()
3558                } else {
3559                    format!(" requires=[{}]", agent.requires.join(", "))
3560                };
3561                // Compaction strategy appends only when set, so agents that take the
3562                // harness default keep an unchanged .ir snapshot (no ripple).
3563                let compaction = agent
3564                    .compaction
3565                    .as_deref()
3566                    .map(|strategy| format!(" compaction={strategy}"))
3567                    .unwrap_or_default();
3568                // Settings likewise appends only when set (unset = provider default).
3569                let settings = agent
3570                    .settings
3571                    .as_deref()
3572                    .map(|sources| format!(" settings={sources}"))
3573                    .unwrap_or_default();
3574                // Thread mode likewise appends only when set (unset = fresh).
3575                let thread = agent
3576                    .thread
3577                    .as_deref()
3578                    .map(|mode| format!(" thread={mode}"))
3579                    .unwrap_or_default();
3580                // Harness class (DR-0034): Managed is the default/substrate, so only
3581                // Delegated agents emit a class token — Managed agents' .ir is unchanged.
3582                let class = match agent.harness_class {
3583                    HarnessClass::Delegated => " class=delegated",
3584                    HarnessClass::Managed => "",
3585                };
3586                push_line(
3587                    &mut snapshot,
3588                    format!(
3589                        "  agent {} harness={} provider={} profile={} capacity={} skills={} capabilities={} tools={}{}{}{}{}{}",
3590                        agent.name, harness, provider, profile, capacity, skills, capabilities, tools, requires, compaction, settings, thread, class
3591                    ),
3592                );
3593            }
3594        }
3595
3596        if !self.coerces.is_empty() {
3597            push_line(&mut snapshot, "coerces");
3598            for coerce in &self.coerces {
3599                let params = coerce
3600                    .params
3601                    .iter()
3602                    .map(|param| format!("{} {}", param.name, param.ty.to_snapshot()))
3603                    .collect::<Vec<_>>()
3604                    .join(", ");
3605                push_line(
3606                    &mut snapshot,
3607                    format!(
3608                        "  coerce {}({}) -> {}",
3609                        coerce.name,
3610                        params,
3611                        coerce.output.to_snapshot()
3612                    ),
3613                );
3614            }
3615        }
3616
3617        if !self.assertions.is_empty() {
3618            push_line(&mut snapshot, "assertions");
3619            for assertion in &self.assertions {
3620                push_line(
3621                    &mut snapshot,
3622                    format!("  assert {}", assertion.expr.expr.to_snapshot()),
3623                );
3624                if !assertion.projection_reads.is_empty() {
3625                    push_line(&mut snapshot, "    reads");
3626                    for read in &assertion.projection_reads {
3627                        push_line(&mut snapshot, format!("      {}", read.to_snapshot()));
3628                    }
3629                }
3630            }
3631        }
3632
3633        if !self.rules.is_empty() {
3634            push_line(&mut snapshot, "rules");
3635            for rule in &self.rules {
3636                push_line(&mut snapshot, format!("  rule {}", rule.name));
3637                for when in &rule.whens {
3638                    match &when.guard {
3639                        Some(guard) => push_line(
3640                            &mut snapshot,
3641                            format!(
3642                                "    when {} where {}",
3643                                when.pattern,
3644                                guard.expr.to_snapshot()
3645                            ),
3646                        ),
3647                        None => push_line(&mut snapshot, format!("    when {}", when.pattern)),
3648                    }
3649                }
3650                if !rule.metadata.fact_reads.is_empty() {
3651                    push_line(&mut snapshot, "    reads");
3652                    for read in &rule.metadata.fact_reads {
3653                        push_line(&mut snapshot, format!("      {}", read));
3654                    }
3655                }
3656                if !rule.metadata.projection_reads.is_empty() {
3657                    push_line(&mut snapshot, "    projection_reads");
3658                    for read in &rule.metadata.projection_reads {
3659                        push_line(&mut snapshot, format!("      {}", read.to_snapshot()));
3660                    }
3661                }
3662                if !rule.metadata.fact_writes.is_empty() {
3663                    push_line(&mut snapshot, "    writes");
3664                    for write in &rule.metadata.fact_writes {
3665                        push_line(&mut snapshot, format!("      {}", write));
3666                    }
3667                }
3668                if !rule.metadata.record_sources.is_empty() {
3669                    push_line(&mut snapshot, "    record_sources");
3670                    for source in &rule.metadata.record_sources {
3671                        push_line(
3672                            &mut snapshot,
3673                            format!(
3674                                "      schema:{} construct={} span={}..{}",
3675                                source.schema, source.construct, source.span.start, source.span.end
3676                            ),
3677                        );
3678                    }
3679                }
3680                if !rule.metadata.fact_consumes.is_empty() {
3681                    push_line(&mut snapshot, "    consumes");
3682                    for consumed in &rule.metadata.fact_consumes {
3683                        push_line(&mut snapshot, format!("      {}", consumed));
3684                    }
3685                }
3686                if !rule.metadata.effects.is_empty() {
3687                    push_line(&mut snapshot, "    effects");
3688                    for effect in &rule.metadata.effects {
3689                        let binding = effect.binding.as_deref().unwrap_or("-");
3690                        let construct = effect
3691                            .construct_use
3692                            .as_ref()
3693                            .map(|form| {
3694                                format!(" construct={}->{}", form.keyword, form.target_capability)
3695                            })
3696                            .unwrap_or_default();
3697                        // Turn-access grants are appended only when present, so
3698                        // grant-free effects keep their existing snapshot shape.
3699                        let grants = if effect.access_grants.is_empty() {
3700                            String::new()
3701                        } else {
3702                            let rendered = effect
3703                                .access_grants
3704                                .iter()
3705                                .map(|grant| {
3706                                    let ops = grant
3707                                        .operations
3708                                        .iter()
3709                                        .map(|op| op.operation.as_str())
3710                                        .collect::<Vec<_>>()
3711                                        .join(",");
3712                                    format!("{}[{ops}]", grant.resource)
3713                                })
3714                                .collect::<Vec<_>>()
3715                                .join(";");
3716                            format!(" grants={rendered}")
3717                        };
3718                        // Turn-scoped skill pins (Phase 7) append only when present,
3719                        // so pin-free effects keep their existing snapshot shape.
3720                        let skills = if effect.turn_skills.is_empty() {
3721                            String::new()
3722                        } else {
3723                            format!(" skills={}", effect.turn_skills.join(","))
3724                        };
3725                        push_line(
3726                            &mut snapshot,
3727                            format!(
3728                                "      {} kind={} binding={}{} key={}{}{}",
3729                                effect.id,
3730                                effect.kind.as_str(),
3731                                binding,
3732                                construct,
3733                                effect.idempotency_key,
3734                                grants,
3735                                skills
3736                            ),
3737                        );
3738                    }
3739                }
3740                if !rule.metadata.dependencies.is_empty() {
3741                    push_line(&mut snapshot, "    dependencies");
3742                    for dependency in &rule.metadata.dependencies {
3743                        push_line(
3744                            &mut snapshot,
3745                            format!(
3746                                "      {} --{}--> {}",
3747                                dependency.upstream,
3748                                dependency.predicate.as_str(),
3749                                dependency.downstream
3750                            ),
3751                        );
3752                    }
3753                }
3754                if !rule.metadata.case_branches.is_empty() {
3755                    push_line(&mut snapshot, "    case_branches");
3756                    for branch in &rule.metadata.case_branches {
3757                        let guard = branch
3758                            .guard
3759                            .as_ref()
3760                            .map(|guard| guard.expr.to_snapshot())
3761                            .unwrap_or_else(|| "-".to_owned());
3762                        push_line(
3763                            &mut snapshot,
3764                            format!(
3765                                "      case {} type={} pattern={} guard={} body_hash={} span={}..{}",
3766                                branch.scrutinee,
3767                                branch.scrutinee_type.to_snapshot(),
3768                                branch.pattern.to_snapshot(),
3769                                guard,
3770                                branch.body_hash,
3771                                branch.pattern_span.start,
3772                                branch.pattern_span.end
3773                            ),
3774                        );
3775                    }
3776                }
3777                if !rule.metadata.terminal_outputs.is_empty() {
3778                    push_line(&mut snapshot, "    terminal_outputs");
3779                    for output in &rule.metadata.terminal_outputs {
3780                        push_line(
3781                            &mut snapshot,
3782                            format!(
3783                                "      {} span={}..{}",
3784                                output.binding, output.span.start, output.span.end
3785                            ),
3786                        );
3787                        for alternative in &output.alternatives {
3788                            push_line(
3789                                &mut snapshot,
3790                                format!(
3791                                    "        {} payload={} span={}..{}",
3792                                    alternative.tag,
3793                                    alternative.payload_type.to_snapshot(),
3794                                    alternative.source_span.start,
3795                                    alternative.source_span.end
3796                                ),
3797                            );
3798                        }
3799                    }
3800                }
3801                if !rule.metadata.terminal_branches.is_empty() {
3802                    push_line(&mut snapshot, "    terminal_branches");
3803                    for branch in &rule.metadata.terminal_branches {
3804                        let tag = branch.tag.as_deref().unwrap_or("_");
3805                        let binding = branch.binding.as_deref().unwrap_or("-");
3806                        let guard = branch
3807                            .guard
3808                            .as_ref()
3809                            .map(|guard| guard.expr.to_snapshot())
3810                            .unwrap_or_else(|| "-".to_owned());
3811                        push_line(
3812                            &mut snapshot,
3813                            format!(
3814                                "      case {} {} binding={} guard={} body_hash={} span={}..{}",
3815                                branch.scrutinee,
3816                                tag,
3817                                binding,
3818                                guard,
3819                                branch.body_hash,
3820                                branch.pattern_span.start,
3821                                branch.pattern_span.end
3822                            ),
3823                        );
3824                    }
3825                }
3826                push_line(
3827                    &mut snapshot,
3828                    format!("    body_hash {}", stable_hash(&rule.body)),
3829                );
3830            }
3831        }
3832
3833        if !self.rule_dependencies.is_empty() {
3834            push_line(&mut snapshot, "rule_dependencies");
3835            for dependency in &self.rule_dependencies {
3836                push_line(
3837                    &mut snapshot,
3838                    format!(
3839                        "  {} --{}--> {}",
3840                        dependency.producer, dependency.fact, dependency.consumer
3841                    ),
3842                );
3843            }
3844        }
3845
3846        snapshot
3847    }
3848}
3849
3850fn register_standard_library(libraries: &mut BTreeMap<String, LibraryRegistration>, id: &str) {
3851    libraries
3852        .entry(id.to_owned())
3853        .or_insert_with(|| LibraryRegistration {
3854            id: id.to_owned(),
3855            version: "0.1.0".to_owned(),
3856            standard: true,
3857        });
3858}
3859
3860fn register_effect_contract(
3861    libraries: &mut BTreeMap<String, LibraryRegistration>,
3862    contracts: &mut BTreeMap<(String, String), EffectContract>,
3863    kind: IrEffectKind,
3864    required_capabilities: Vec<String>,
3865) {
3866    let contract = effect_contract_for_kind(kind, required_capabilities);
3867    register_standard_library(libraries, contract.library_id.as_str());
3868    contracts
3869        .entry((contract.id.clone(), contract.version.clone()))
3870        .and_modify(|existing| {
3871            merge_unique(
3872                &mut existing.required_capabilities,
3873                &contract.required_capabilities,
3874            );
3875            merge_unique(&mut existing.provider_kinds, &contract.provider_kinds);
3876            merge_unique(&mut existing.source_forms, &contract.source_forms);
3877            merge_unique(&mut existing.projected_facts, &contract.projected_facts);
3878        })
3879        .or_insert(contract);
3880}
3881
3882fn merge_unique(target: &mut Vec<String>, values: &[String]) {
3883    for value in values {
3884        if !target.contains(value) {
3885            target.push(value.clone());
3886        }
3887    }
3888    target.sort();
3889}
3890
3891fn strings(values: &[&str]) -> Vec<String> {
3892    values.iter().map(|value| (*value).to_owned()).collect()
3893}
3894
3895fn effect_contract_for_kind(
3896    kind: IrEffectKind,
3897    required_capabilities: Vec<String>,
3898) -> EffectContract {
3899    let mut required_capabilities = required_capabilities;
3900    required_capabilities.sort();
3901    required_capabilities.dedup();
3902    let effect_kind = kind.as_str().to_owned();
3903
3904    let (
3905        library_id,
3906        source_forms,
3907        input_schema,
3908        output_schema,
3909        default_capabilities,
3910        provider_kinds,
3911        projected_facts,
3912        validation,
3913    ) = match kind {
3914        IrEffectKind::AgentTell => (
3915            "std.agent",
3916            strings(&["tell"]),
3917            Some("agent.turn.request"),
3918            Some("AgentTurn"),
3919            strings(&["agent.turn"]),
3920            strings(&["agent"]),
3921            strings(&["effect.output"]),
3922            TypedOutputValidation::RuntimeBoundary,
3923        ),
3924        IrEffectKind::SchemaCoerce => (
3925            "std.coercion",
3926            strings(&["coerce", "decide", "prompt"]),
3927            Some("schema.coerce.input"),
3928            Some("typed-provider-output"),
3929            // Capability id == effect kind (spec/std-coercion.md "Static
3930            // checks" 1: the never-enforced `model.invoke` died with the S2
3931            // rename), and the provider kind is the kernel's
3932            // `provider::PROVIDER_SCHEMA_COERCE` ("schema_coercer") string — a
3933            // schema coercer, not a generic model row.
3934            strings(&["schema.coerce"]),
3935            strings(&["schema_coercer"]),
3936            strings(&["effect.output"]),
3937            TypedOutputValidation::RuntimeBoundary,
3938        ),
3939        IrEffectKind::CapabilityCall => (
3940            "std.script",
3941            strings(&["call"]),
3942            Some("capability.call.input"),
3943            Some("capability.call.output"),
3944            Vec::new(),
3945            strings(&["capability"]),
3946            strings(&["effect.output"]),
3947            TypedOutputValidation::RuntimeBoundary,
3948        ),
3949        IrEffectKind::EventEmit => (
3950            "std.ingress",
3951            strings(&["emit"]),
3952            Some("event.emit.input"),
3953            None,
3954            Vec::new(),
3955            Vec::new(),
3956            Vec::new(),
3957            TypedOutputValidation::None,
3958        ),
3959        IrEffectKind::WorkflowInvoke => (
3960            "std.workflow",
3961            strings(&["invoke"]),
3962            Some("workflow.invoke.input"),
3963            Some("workflow.terminal"),
3964            Vec::new(),
3965            Vec::new(),
3966            strings(&["effect.output"]),
3967            TypedOutputValidation::RuntimeBoundary,
3968        ),
3969        IrEffectKind::TimerWait => (
3970            "std.time",
3971            strings(&["timer"]),
3972            Some("timer.wait.input"),
3973            Some("TimerElapsed"),
3974            Vec::new(),
3975            Vec::new(),
3976            strings(&["effect.output"]),
3977            TypedOutputValidation::None,
3978        ),
3979        IrEffectKind::ExecCommand => (
3980            "std.script",
3981            strings(&["exec"]),
3982            Some("exec.command.input"),
3983            Some("exec.command.output"),
3984            strings(&["exec.run"]),
3985            strings(&["script", "command"]),
3986            strings(&["effect.output"]),
3987            TypedOutputValidation::RuntimeBoundary,
3988        ),
3989        IrEffectKind::TrackerFile => (
3990            "std.tracker",
3991            strings(&["file"]),
3992            Some("tracker.file.input"),
3993            None,
3994            strings(&["tracker.file"]),
3995            Vec::new(),
3996            Vec::new(),
3997            TypedOutputValidation::None,
3998        ),
3999        IrEffectKind::TrackerClaim => (
4000            "std.tracker",
4001            strings(&["claim"]),
4002            Some("tracker.claim.input"),
4003            Some("TrackerClaim"),
4004            strings(&["tracker.claim"]),
4005            Vec::new(),
4006            strings(&["effect.output"]),
4007            TypedOutputValidation::None,
4008        ),
4009        // T3: holder-only renew of a claimed issue. No typed output schema (the
4010        // renewed/not_held outcome is a completed/failed terminal, mirroring
4011        // tracker.release), so the manifest contract row folds cleanly against
4012        // this compiled one.
4013        IrEffectKind::TrackerRenew => (
4014            "std.tracker",
4015            strings(&["renew"]),
4016            Some("tracker.renew.input"),
4017            None,
4018            strings(&["tracker.renew"]),
4019            Vec::new(),
4020            Vec::new(),
4021            TypedOutputValidation::None,
4022        ),
4023        IrEffectKind::TrackerRelease => (
4024            "std.tracker",
4025            strings(&["release"]),
4026            Some("tracker.release.input"),
4027            None,
4028            strings(&["tracker.release"]),
4029            Vec::new(),
4030            Vec::new(),
4031            TypedOutputValidation::None,
4032        ),
4033        IrEffectKind::TrackerFinish => (
4034            "std.tracker",
4035            strings(&["finish"]),
4036            Some("tracker.finish.input"),
4037            None,
4038            strings(&["tracker.finish"]),
4039            Vec::new(),
4040            Vec::new(),
4041            TypedOutputValidation::None,
4042        ),
4043        IrEffectKind::LeaseAcquire => (
4044            "std.coord",
4045            strings(&["acquire"]),
4046            Some("lease.acquire.input"),
4047            Some("LeaseAcquireOutcome"),
4048            Vec::new(),
4049            Vec::new(),
4050            strings(&["effect.output"]),
4051            TypedOutputValidation::None,
4052        ),
4053        IrEffectKind::LeaseRenew => (
4054            "std.coord",
4055            strings(&["renew"]),
4056            Some("lease.renew.input"),
4057            Some("LeaseRenewOutcome"),
4058            Vec::new(),
4059            Vec::new(),
4060            strings(&["effect.output"]),
4061            TypedOutputValidation::None,
4062        ),
4063        IrEffectKind::LedgerAppend => (
4064            "std.coord",
4065            strings(&["append"]),
4066            Some("ledger.append.input"),
4067            None,
4068            Vec::new(),
4069            Vec::new(),
4070            Vec::new(),
4071            TypedOutputValidation::None,
4072        ),
4073        IrEffectKind::CounterConsume => (
4074            "std.coord",
4075            strings(&["consume"]),
4076            Some("counter.consume.input"),
4077            Some("CounterConsumeOutcome"),
4078            Vec::new(),
4079            Vec::new(),
4080            strings(&["effect.output"]),
4081            TypedOutputValidation::None,
4082        ),
4083        IrEffectKind::SignalEmit => (
4084            "std.ingress",
4085            strings(&["emit", "signal"]),
4086            Some("signal.emit.input"),
4087            None,
4088            Vec::new(),
4089            Vec::new(),
4090            Vec::new(),
4091            TypedOutputValidation::None,
4092        ),
4093        // std.files capability ids EQUAL effect kinds (spec/std-files.md, M3
4094        // id==kind): each contract requires exactly its own kind string, which
4095        // the store's default-required-capability rule already derives for an
4096        // empty list — declaring it here makes the registry honest about it.
4097        IrEffectKind::FileRead => (
4098            "std.files",
4099            strings(&["read"]),
4100            Some("file.read.input"),
4101            Some("FileReadResult"),
4102            strings(&["file.read"]),
4103            Vec::new(),
4104            strings(&["effect.output"]),
4105            TypedOutputValidation::RuntimeBoundary,
4106        ),
4107        IrEffectKind::FileWrite => (
4108            "std.files",
4109            strings(&["write"]),
4110            Some("file.write.input"),
4111            Some("FileWriteResult"),
4112            strings(&["file.write"]),
4113            Vec::new(),
4114            strings(&["effect.output"]),
4115            TypedOutputValidation::RuntimeBoundary,
4116        ),
4117        IrEffectKind::FileImport => (
4118            "std.files",
4119            strings(&["import"]),
4120            Some("file.import.input"),
4121            Some("FileImportResult"),
4122            strings(&["file.import"]),
4123            Vec::new(),
4124            strings(&["effect.output"]),
4125            TypedOutputValidation::RuntimeBoundary,
4126        ),
4127        IrEffectKind::FileExport => (
4128            "std.files",
4129            strings(&["export"]),
4130            Some("file.export.input"),
4131            Some("FileExportResult"),
4132            strings(&["file.export"]),
4133            Vec::new(),
4134            strings(&["effect.output"]),
4135            TypedOutputValidation::RuntimeBoundary,
4136        ),
4137    };
4138
4139    merge_unique(&mut required_capabilities, &default_capabilities);
4140
4141    EffectContract {
4142        id: effect_kind.clone(),
4143        library_id: library_id.to_owned(),
4144        version: "0.1.0".to_owned(),
4145        effect_kind,
4146        source_forms,
4147        input_schema: input_schema.map(str::to_owned),
4148        output_schema: output_schema.map(str::to_owned),
4149        required_capabilities,
4150        provider_kinds,
4151        projected_facts,
4152        validation,
4153    }
4154}
4155
4156impl IrEffectKind {
4157    /// The single canonical `IrEffectKind` → effect-kind string map. Kernel and
4158    /// CLI delegate here (S0 dedup) so a rename touches exactly one match.
4159    pub fn as_str(&self) -> &'static str {
4160        match self {
4161            Self::AgentTell => "agent.tell",
4162            Self::SchemaCoerce => "schema.coerce",
4163            Self::CapabilityCall => "capability.call",
4164            Self::EventEmit => "event.emit",
4165            Self::WorkflowInvoke => "workflow.invoke",
4166            Self::TimerWait => "timer.wait",
4167            Self::ExecCommand => "exec.command",
4168            Self::TrackerFile => "tracker.file",
4169            Self::TrackerClaim => "tracker.claim",
4170            Self::TrackerRenew => "tracker.renew",
4171            Self::TrackerRelease => "tracker.release",
4172            Self::TrackerFinish => "tracker.finish",
4173            Self::LeaseAcquire => "lease.acquire",
4174            Self::LeaseRenew => "lease.renew",
4175            Self::LedgerAppend => "ledger.append",
4176            Self::CounterConsume => "counter.consume",
4177            Self::SignalEmit => "signal.emit",
4178            Self::FileRead => "file.read",
4179            Self::FileWrite => "file.write",
4180            Self::FileImport => "file.import",
4181            Self::FileExport => "file.export",
4182        }
4183    }
4184}
4185
4186impl DependencyPredicate {
4187    fn as_str(&self) -> &'static str {
4188        match self {
4189            Self::Succeeds => "succeeds",
4190            Self::Fails => "fails",
4191            Self::TimedOut => "timed_out",
4192            Self::Cancelled => "cancelled",
4193            Self::Completes => "completes",
4194        }
4195    }
4196}
4197
4198impl IrUseKind {
4199    fn as_str(&self) -> &'static str {
4200        match self {
4201            Self::Package => "package",
4202        }
4203    }
4204}
4205
4206impl IrType {
4207    /// A human-readable label for this type (e.g. `ref<TicketRequest>`), for use in
4208    /// diagnostics such as workflow-input errors.
4209    pub fn display_label(&self) -> String {
4210        self.to_snapshot()
4211    }
4212
4213    fn to_snapshot(&self) -> String {
4214        match self {
4215            Self::Primitive(primitive) => primitive.as_str().to_owned(),
4216            Self::LiteralString(value) => format!("literal<{value:?}>"),
4217            Self::Ref(name) => format!("ref<{name}>"),
4218            Self::AgentRef(agents) => format!("agentref<{}>", agents.join(" | ")),
4219            Self::Object(fields) => {
4220                let fields = fields
4221                    .iter()
4222                    .map(|field| format!("{} {}", field.name, field.ty.to_snapshot()))
4223                    .collect::<Vec<_>>()
4224                    .join(", ");
4225                format!("object<{{{fields}}}>")
4226            }
4227            Self::Optional(inner) => format!("optional<{}>", inner.to_snapshot()),
4228            Self::Array(inner) => format!("array<{}>", inner.to_snapshot()),
4229            Self::Map(inner) => format!("map<{}>", inner.to_snapshot()),
4230            Self::Union(variants) => {
4231                let variants = variants
4232                    .iter()
4233                    .map(Self::to_snapshot)
4234                    .collect::<Vec<_>>()
4235                    .join(" | ");
4236                format!("union<{variants}>")
4237            }
4238        }
4239    }
4240}
4241
4242impl IrPrimitiveType {
4243    fn as_str(&self) -> &'static str {
4244        match self {
4245            Self::String => "string",
4246            Self::Int => "int",
4247            Self::Float => "float",
4248            Self::Bool => "bool",
4249            Self::Null => "null",
4250            Self::Duration => "duration",
4251            Self::Time => "time",
4252            Self::Image => "image",
4253            Self::Audio => "audio",
4254            Self::Pdf => "pdf",
4255            Self::Video => "video",
4256        }
4257    }
4258}
4259
4260fn lower_program(
4261    program: Program,
4262    workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
4263    shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
4264) -> CompileOutput {
4265    let mut diagnostics = Vec::new();
4266    let mut warnings = Vec::new();
4267    let (program, pattern_applications) = expand_pattern_applications(program, &mut diagnostics);
4268    let pending_regions: BTreeMap<String, IrRegion>;
4269    let program = {
4270        let mut program = program;
4271        // DR-0023: collect `action` templates, then expand their calls inside
4272        // every rule body — including rules generated by flow expansion below.
4273        // Calls are rewritten into ordinary effect-chain text that re-enters the
4274        // normal lowering pipeline; the `action` declarations themselves are
4275        // consumed (never a runtime construct).
4276        let actions: Vec<ActionDecl> = program
4277            .items
4278            .iter()
4279            .filter_map(|item| match item {
4280                Item::Action(action) => Some(action.clone()),
4281                _ => None,
4282            })
4283            .collect();
4284        let mut expanded = Vec::with_capacity(program.items.len());
4285        for item in program.items {
4286            match item {
4287                Item::Action(_) => {}
4288                other => expanded.push(other),
4289            }
4290        }
4291        // Full-line `#` comments inside rule bodies are legal surface, but the
4292        // body text is re-scanned by many line-based passes (action/`then`
4293        // expansion, the kernel's effect scanner). Blank them ONCE here --
4294        // byte-preservingly, so every span still points at the source -- and
4295        // all downstream consumers see comment-free text. Compile path only:
4296        // `whip fmt` re-emits the raw body text and keeps the comments.
4297        for item in &mut expanded {
4298            if let Item::Rule(rule) = item {
4299                if rule.body.text.contains('#') {
4300                    rule.body.text = body::blank_full_line_comments(&rule.body.text);
4301                }
4302            }
4303        }
4304        action_expand::expand_action_calls(&mut expanded, &actions, &mut diagnostics);
4305        // R2: `then <binding> <- <effect>` continuation sugar desugars to the
4306        // nested `after … succeeds as` form here, before any analysis — the
4307        // rest of the pipeline (and the kernel) only ever sees the
4308        // traditional spelling. Runs after action expansion so action-injected
4309        // chains would compose; runs only in the compile path, so `whip fmt`
4310        // (which re-emits rule bodies from source) preserves the sugar.
4311        then_expand::expand_then_statements(&mut expanded, &mut diagnostics);
4312        // DR-0043 Decision 5: regions extract AFTER then-expansion (a `then`
4313        // inside a region folds within the region), rewriting rule bodies to
4314        // the condition-HOLDS variant and stashing the removed/lapsed
4315        // variants + effect scopes for the kernel.
4316        pending_regions = extract_rule_regions(&mut expanded, &mut diagnostics);
4317        program.items = expanded;
4318        program
4319    };
4320    let schema_names = collect_schema_names(&program, &mut diagnostics);
4321    let harness_kinds = collect_harness_kinds(&program, &mut diagnostics);
4322    let agent_names = collect_agent_names(&program, &mut diagnostics);
4323    let workflow_contract_names = collect_workflow_contract_names(&program, &mut diagnostics);
4324    let mut semantic = SemanticContext::from_program(&program, workflow_inputs);
4325    let workflow = match program.workflow {
4326        Some(workflow) => workflow.name,
4327        None => {
4328            diagnostics.push(Diagnostic {
4329                related: Vec::new(),
4330                span: SourceSpan { start: 0, end: 0 },
4331                message: "expected workflow declaration".to_owned(),
4332                suggestion: Some("add `workflow Name` before declarations".to_owned()),
4333            });
4334            "<missing>".to_owned()
4335        }
4336    };
4337
4338    let mut ir = IrProgram {
4339        workflow,
4340        source_tags: Vec::new(),
4341        source_descriptions: Vec::new(),
4342        includes: Vec::new(),
4343        pattern_applications,
4344        workflow_contracts: Vec::new(),
4345        uses: Vec::new(),
4346        harnesses: Vec::new(),
4347        trackers: Vec::new(),
4348        channels: Vec::new(),
4349        gauges: Vec::new(),
4350        marks: Vec::new(),
4351        campaigns: Vec::new(),
4352        file_stores: Vec::new(),
4353        memory_pools: Vec::new(),
4354        events: Vec::new(),
4355        sources: Vec::new(),
4356        tests: Vec::new(),
4357        leases: Vec::new(),
4358        ledgers: Vec::new(),
4359        counters: Vec::new(),
4360        shared_coordination_usage,
4361        schemas: Vec::new(),
4362        agents: Vec::new(),
4363        coerces: Vec::new(),
4364        assertions: Vec::new(),
4365        rules: Vec::new(),
4366        rule_dependencies: Vec::new(),
4367    };
4368    let workflow_tag_target = ir.workflow.clone();
4369    lower_source_tags(
4370        &program.workflow_tags,
4371        "workflow",
4372        &workflow_tag_target,
4373        &mut ir,
4374    );
4375    lower_source_description(
4376        program.workflow_description.as_ref(),
4377        "workflow",
4378        &workflow_tag_target,
4379        &mut ir,
4380    );
4381
4382    // Inline `decide -> { … } as <binding>` synthesizes a hygienic
4383    // `decide.<rule>.<binding>` class so its anonymous result shape flows like a
4384    // named `coerce -> Schema`. Done before the rule loop so `analyze_rule` sees
4385    // the class in the semantic index when type-checking field/`case` access.
4386    collect_inline_decide_schemas(&program.items, &mut semantic, &mut ir);
4387
4388    // `redact <source> keep [..] as <out>` synthesizes a hygienic
4389    // `redact.<rule>.<out>` class holding only the kept fields of the source
4390    // schema, so the projection cannot expose a dropped field. Run after the
4391    // decide synthesis so a redact whose source is a decide result resolves.
4392    collect_redact_schemas(&program.items, &mut semantic, &mut ir);
4393
4394    for item in program.items {
4395        match item {
4396            Item::Include(include) => lower_include(include, &mut ir),
4397            Item::WorkflowContract(contract) => lower_workflow_contract(
4398                contract,
4399                &mut ir,
4400                &schema_names,
4401                &agent_names,
4402                &mut diagnostics,
4403            ),
4404            Item::Use(use_decl) => lower_use(use_decl, &mut ir, &mut diagnostics),
4405            // Actions are consumed before this loop (DR-0023 slice 1 drops them;
4406            // slice 2 expands their calls); reaching one here is unreachable.
4407            Item::Action(action) => {
4408                let _ = action;
4409            }
4410            Item::Pattern(pattern) => diagnostics.push(Diagnostic {
4411                related: Vec::new(),
4412                span: pattern.span,
4413                message: format!(
4414                    "pattern `{}` is not allowed inside this declaration scope",
4415                    pattern.name.name
4416                ),
4417                suggestion: Some("declare patterns at source top level".to_owned()),
4418            }),
4419            Item::Apply(apply) => diagnostics.push(Diagnostic {
4420                related: Vec::new(),
4421                span: apply.span,
4422                message: format!(
4423                    "pattern application `{}` was not expanded",
4424                    apply.alias.name
4425                ),
4426                suggestion: Some(
4427                    "ensure the applied pattern is declared at source top level".to_owned(),
4428                ),
4429            }),
4430            Item::Harness(harness) => lower_harness(harness, &mut ir, &mut diagnostics),
4431            Item::Tracker(queue) => lower_tracker(queue, &mut ir, &mut diagnostics),
4432            Item::Channel(channel) => lower_channel(channel, &mut ir, &mut diagnostics),
4433            Item::Gauge(gauge) => lower_gauge(gauge, &mut ir, &mut diagnostics),
4434            Item::Mark(mark) => lower_mark(mark, &mut ir, &mut diagnostics),
4435            Item::Campaign(campaign) => lower_campaign(campaign, &mut ir, &mut diagnostics),
4436            // The `file store` declaration (capability-scoped store identity)
4437            // lowers to its name + literal root; the runtime file provider reads
4438            // `<root>/<path>` for `read` effects against this store.
4439            Item::FileStore(file_store) => {
4440                // Conditioned check (spec/std-files.md "Static checks"): the
4441                // optional `provider <name>` clause must name a known file
4442                // provider. v1 ships exactly one — `local`, the FileStore
4443                // host-projection seam — and it is also the default when the
4444                // clause is absent. Unknown providers would lower to a store
4445                // no runtime seam backs, so they are rejected at check time.
4446                if let Some(provider) = &file_store.provider {
4447                    if !FILE_STORE_PROVIDERS.contains(&provider.name.as_str()) {
4448                        diagnostics.push(Diagnostic {
4449                            related: Vec::new(),
4450                            span: provider.span,
4451                            message: format!(
4452                                "file store `{}` names unknown provider `{}`",
4453                                file_store.name.name, provider.name
4454                            ),
4455                            suggestion: Some(format!(
4456                                "declare one of the v1 file providers: {}",
4457                                FILE_STORE_PROVIDERS.join(", ")
4458                            )),
4459                        });
4460                        // Fall through: the store still lowers so read/write
4461                        // sites do not cascade an unknown-store error on top.
4462                    }
4463                }
4464                ir.file_stores.push(IrFileStore {
4465                    name: file_store.name.name,
4466                    root: file_store.root,
4467                    read_globs: file_store.read_globs,
4468                    write_globs: file_store.write_globs,
4469                    provider: file_store.provider.map(|provider| provider.name),
4470                });
4471            }
4472            // The `memory pool` declaration (std.memory, MEM-1) lowers to its
4473            // name + optional recall context-limit budget; providers read the
4474            // limit from the `capability.call` effect input.
4475            Item::MemoryPool(pool) => {
4476                ir.memory_pools.push(IrMemoryPool {
4477                    name: pool.name.name,
4478                    context_limit: pool.context_limit,
4479                });
4480            }
4481            Item::Agent(agent) => lower_agent(agent, &mut ir, &harness_kinds, &mut diagnostics),
4482            Item::Enum(enum_decl) => lower_enum(enum_decl, &mut ir, &mut diagnostics),
4483            Item::Event(event) => lower_event(event, &mut ir, &mut diagnostics),
4484            Item::Source(source) => {
4485                validate_source_emit_signal_declared(
4486                    &source,
4487                    &semantic.schemas.events,
4488                    &mut diagnostics,
4489                );
4490                lower_source(*source, &mut ir, &mut diagnostics)
4491            }
4492            Item::Test(test) => lower_test(test, &mut ir, &mut diagnostics),
4493            Item::Lease(lease) => {
4494                if !schema_names.contains(&lease.key_type.name) {
4495                    diagnostics.push(Diagnostic {
4496                        related: Vec::new(),
4497                        span: lease.key_type.span,
4498                        message: format!(
4499                            "lease `{}` keys on undeclared type `{}`",
4500                            lease.name.name, lease.key_type.name
4501                        ),
4502                        suggestion: Some(
4503                            "key a lease on an entity class the workflow already models".to_owned(),
4504                        ),
4505                    });
4506                }
4507                ir.leases.push(IrLease {
4508                    name: lease.name.name,
4509                    key_type: lease.key_type.name,
4510                    slots: lease.slots.max(1),
4511                    ttl_seconds: lease.ttl_seconds,
4512                    shared: lease.shared,
4513                    span: lease.span,
4514                });
4515            }
4516            Item::Ledger(ledger) => {
4517                if !schema_names.contains(&ledger.entry_schema.name) {
4518                    diagnostics.push(Diagnostic {
4519                        related: Vec::new(),
4520                        span: ledger.entry_schema.span,
4521                        message: format!(
4522                            "ledger `{}` records undeclared entry type `{}`",
4523                            ledger.name.name, ledger.entry_schema.name
4524                        ),
4525                        suggestion: Some("declare the entry class before the ledger".to_owned()),
4526                    });
4527                }
4528                ir.ledgers.push(IrLedger {
4529                    name: ledger.name.name,
4530                    entry_schema: ledger.entry_schema.name,
4531                    partition_field: ledger.partition_field.name,
4532                    retain_seconds: ledger.retain_seconds,
4533                    shared: ledger.shared,
4534                    span: ledger.span,
4535                });
4536            }
4537            Item::Counter(counter) => {
4538                if !schema_names.contains(&counter.key_type.name) {
4539                    diagnostics.push(Diagnostic {
4540                        related: Vec::new(),
4541                        span: counter.key_type.span,
4542                        message: format!(
4543                            "counter `{}` keys on undeclared type `{}`",
4544                            counter.name.name, counter.key_type.name
4545                        ),
4546                        suggestion: Some(
4547                            "key a counter on an entity class the workflow already models"
4548                                .to_owned(),
4549                        ),
4550                    });
4551                }
4552                ir.counters.push(IrCounter {
4553                    name: counter.name.name,
4554                    key_type: counter.key_type.name,
4555                    cap: counter.cap,
4556                    reset: counter.reset,
4557                    timezone: counter.timezone,
4558                    shared: counter.shared,
4559                    span: counter.span,
4560                });
4561            }
4562            Item::Class(class_decl) => lower_class(
4563                class_decl,
4564                &mut ir,
4565                &schema_names,
4566                &agent_names,
4567                &mut diagnostics,
4568            ),
4569            Item::Table(table) => {
4570                lower_source_tags(&table.tags, "table", &table.name.name, &mut ir);
4571                lower_source_description(
4572                    table.description.as_ref(),
4573                    "table",
4574                    &table.name.name,
4575                    &mut ir,
4576                );
4577                lower_table(
4578                    table,
4579                    &semantic,
4580                    &workflow_contract_names,
4581                    &mut ir,
4582                    &mut diagnostics,
4583                )
4584            }
4585            Item::Coerce(coerce) => lower_coerce(
4586                coerce,
4587                &mut ir,
4588                &schema_names,
4589                &agent_names,
4590                &mut diagnostics,
4591            ),
4592            Item::Assert(assertion) => {
4593                let assertion_target = stable_hash(&assertion.expr);
4594                lower_source_tags(&assertion.tags, "assertion", &assertion_target, &mut ir);
4595                lower_source_description(
4596                    assertion.description.as_ref(),
4597                    "assertion",
4598                    &assertion_target,
4599                    &mut ir,
4600                );
4601                lower_assert(assertion, &semantic, &mut ir, &mut diagnostics)
4602            }
4603            Item::Rule(rule) => {
4604                lower_source_tags(&rule.tags, "rule", &rule.name.name, &mut ir);
4605                lower_source_description(
4606                    rule.description.as_ref(),
4607                    "rule",
4608                    &rule.name.name,
4609                    &mut ir,
4610                );
4611                lower_rule(
4612                    rule,
4613                    &semantic,
4614                    &workflow_contract_names,
4615                    &mut ir,
4616                    &mut diagnostics,
4617                )
4618            }
4619        }
4620    }
4621
4622    ir.rule_dependencies = build_rule_dependencies(&ir.rules);
4623    validate_turn_access_grant_file_operations(&ir, &mut diagnostics);
4624    validate_turn_access_grant_memory_operations(&ir, &mut diagnostics);
4625    // DR-0043 Decision 5: attach extracted regions onto their lowered rules.
4626    for rule in &mut ir.rules {
4627        if let Some(region) = pending_regions.get(&rule.name) {
4628            rule.metadata.region = Some(region.clone());
4629        }
4630    }
4631    expand_source_emit_from(&mut ir, &mut diagnostics);
4632    validate_file_store_write_policy(&ir, &mut diagnostics);
4633    warn_inert_memory_grant_on_native_adapter(&ir, &mut warnings);
4634    warn_counter_without_timezone(&ir, &mut warnings);
4635    warn_unhandled_effect_failures(&ir, &mut warnings);
4636    validate_improve_declarations(&ir, &mut diagnostics);
4637
4638    CompileOutput {
4639        ir: diagnostics.is_empty().then_some(ir),
4640        diagnostics,
4641        warnings,
4642    }
4643}
4644
4645/// Post-lowering check: a turn-access grant whose resource is a declared `file store`
4646/// may only grant file operations (`read`/`write`/`import`/`export`). Runs after the
4647/// whole program is lowered so every file-store declaration is visible regardless of
4648/// source order. Grants whose resource is NOT a declared file store are left alone —
4649/// they may be package-provided resources whose operation vocabulary lives in the
4650/// capability registry (validated at the construct-graph layer), so this stays
4651/// zero-false-positive.
4652fn validate_turn_access_grant_file_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4653    const FILE_OPERATIONS: [&str; 4] = ["read", "write", "import", "export"];
4654    let file_stores: BTreeSet<&str> = ir
4655        .file_stores
4656        .iter()
4657        .map(|store| store.name.as_str())
4658        .collect();
4659    for rule in &ir.rules {
4660        for effect in &rule.metadata.effects {
4661            for grant in &effect.access_grants {
4662                if !file_stores.contains(grant.resource.as_str()) {
4663                    continue;
4664                }
4665                for op in &grant.operations {
4666                    if !FILE_OPERATIONS.contains(&op.operation.as_str()) {
4667                        diagnostics.push(Diagnostic { related: Vec::new(),
4668                            span: effect.span,
4669                            message: format!(
4670                                "rule `{}` grants `{}` on file store `{}`, which is not a file operation",
4671                                rule.name, op.operation, grant.resource
4672                            ),
4673                            suggestion: Some(
4674                                "file-store grants allow `read`, `write`, `import`, or `export`"
4675                                    .to_owned(),
4676                            ),
4677                        });
4678                    }
4679                }
4680            }
4681        }
4682    }
4683}
4684
4685/// Post-lowering check: a turn-access grant whose resource is a declared `memory
4686/// pool` (std.memory, MEM-1) may only grant memory operations
4687/// (`recall`/`learn`/`curate`). Runs after the whole program is lowered so every
4688/// pool declaration is visible regardless of source order. Grants whose resource
4689/// is NOT a declared memory pool are left alone — they may be file stores or
4690/// package-provided resources whose operation vocabulary lives elsewhere, so this
4691/// stays zero-false-positive. This closes the deliberate memory-grant-validation
4692/// deferral (there was no declared-pool list to key it off before MEM-1).
4693fn validate_turn_access_grant_memory_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4694    const MEMORY_OPERATIONS: [&str; 3] = ["recall", "learn", "curate"];
4695    let memory_pools: BTreeSet<&str> = ir
4696        .memory_pools
4697        .iter()
4698        .map(|pool| pool.name.as_str())
4699        .collect();
4700    for rule in &ir.rules {
4701        for effect in &rule.metadata.effects {
4702            for grant in &effect.access_grants {
4703                if !memory_pools.contains(grant.resource.as_str()) {
4704                    continue;
4705                }
4706                for op in &grant.operations {
4707                    if !MEMORY_OPERATIONS.contains(&op.operation.as_str()) {
4708                        diagnostics.push(Diagnostic {
4709                            related: Vec::new(),
4710                            span: effect.span,
4711                            message: format!(
4712                                "rule `{}` grants `{}` on memory pool `{}`, which is not a memory operation",
4713                                rule.name, op.operation, grant.resource
4714                            ),
4715                            suggestion: Some(
4716                                "memory-pool grants allow `recall`, `learn`, or `curate`".to_owned(),
4717                            ),
4718                        });
4719                    }
4720                }
4721            }
4722        }
4723    }
4724}
4725
4726/// std.coord slice 3: a counter without a declared `timezone` anchors its
4727/// reset-period boundary to UTC — legal, but a daily/weekly/monthly quota
4728/// silently rolling over at an operator-surprising hour is worth a warning.
4729/// S4 (file-store default posture): a store is READ-ONLY by default — a
4730/// `write`/`export` against a store with no `allow write [...]` policy will
4731/// fail closed at runtime, so surface it as a check error here ("catch before
4732/// deployment"). Reads/imports need no clause (mounting the root is the read
4733/// consent); `allow read [...]` narrows them.
4734fn validate_file_store_write_policy(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4735    let read_only: BTreeSet<&str> = ir
4736        .file_stores
4737        .iter()
4738        .filter(|store| store.write_globs.is_empty())
4739        .map(|store| store.name.as_str())
4740        .collect();
4741    if read_only.is_empty() {
4742        return;
4743    }
4744    fn walk(
4745        statements: &[body::BodyStmt],
4746        rule_name: &str,
4747        read_only: &BTreeSet<&str>,
4748        diagnostics: &mut Vec<Diagnostic>,
4749    ) {
4750        for statement in statements {
4751            match statement {
4752                body::BodyStmt::Effect(effect) => {
4753                    let store = match &effect.kind {
4754                        body::BodyEffectKind::FileWrite { store, .. }
4755                        | body::BodyEffectKind::FileExport { store, .. } => Some(store),
4756                        _ => None,
4757                    };
4758                    if let Some(store) = store {
4759                        if read_only.contains(store.as_str()) {
4760                            diagnostics.push(Diagnostic {
4761                                related: Vec::new(),
4762                                span: effect.span,
4763                                message: format!(
4764                                    "rule `{rule_name}` writes to store `{store}`, which permits \
4765                                     no writes — stores are read-only by default"
4766                                ),
4767                                suggestion: Some(format!(
4768                                    "declare `allow write [\"<glob>\", …]` on `file store {store}` \
4769                                     to permit (and bound) writes"
4770                                )),
4771                            });
4772                        }
4773                    }
4774                }
4775                body::BodyStmt::After(after) => {
4776                    walk(&after.body, rule_name, read_only, diagnostics)
4777                }
4778                body::BodyStmt::Case(case) => {
4779                    for branch in &case.branches {
4780                        walk(&branch.body, rule_name, read_only, diagnostics);
4781                    }
4782                }
4783                _ => {}
4784            }
4785        }
4786    }
4787    for rule in &ir.rules {
4788        let (ast, _) = body::parse_rule_body(&rule.body, 0);
4789        walk(&ast.statements, &rule.name, &read_only, diagnostics);
4790    }
4791}
4792
4793/// S6 `emit <signal> from <binding>` (source declarations): expand the
4794/// projection into concrete emit fields once every declaration has lowered
4795/// (the signal may be declared after the source). Each of the signal's
4796/// declared fields not overridden by the block becomes a copy off the `from`
4797/// binding — the `record … from` semantics. The `from` binding must be the
4798/// source's `observe` binding: it is the only binding in scope.
4799fn expand_source_emit_from(ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4800    let events: BTreeMap<String, Vec<String>> = ir
4801        .events
4802        .iter()
4803        .map(|event| {
4804            (
4805                event.name.clone(),
4806                event
4807                    .fields
4808                    .iter()
4809                    .map(|field| field.name.clone())
4810                    .collect(),
4811            )
4812        })
4813        .collect();
4814    for source in &mut ir.sources {
4815        let Some(from) = source.emit_from.clone() else {
4816            continue;
4817        };
4818        if from != source.observe_binding {
4819            diagnostics.push(Diagnostic {
4820                related: Vec::new(),
4821                span: source.span,
4822                message: format!(
4823                    "source `{}` emits `from {from}`, but the only binding in scope is the                      observe binding `{}`",
4824                    source.name, source.observe_binding
4825                ),
4826                suggestion: Some(format!("write `emit {} from {}`", source.emit_signal, source.observe_binding)),
4827            });
4828            continue;
4829        }
4830        let Some(signal_fields) = events.get(&source.emit_signal) else {
4831            // The undeclared-signal diagnostic is reported by the emit checks.
4832            continue;
4833        };
4834        for field in signal_fields {
4835            if source
4836                .emit_fields
4837                .iter()
4838                .any(|existing| &existing.name == field)
4839            {
4840                continue;
4841            }
4842            source.emit_fields.push(IrSourceEmitField {
4843                name: field.clone(),
4844                value: SourceValue::Path {
4845                    binding: Ident {
4846                        name: from.clone(),
4847                        span: source.span,
4848                    },
4849                    segments: vec![Ident {
4850                        name: field.clone(),
4851                        span: source.span,
4852                    }],
4853                    span: source.span,
4854                },
4855                span: source.span,
4856            });
4857        }
4858    }
4859}
4860
4861/// Auto-fail R1a — partiality made visible: an effect whose failure has no
4862/// observing `after` block in its rule will auto-fail the instance at runtime
4863/// (the rule-level net). That is SAFE, but the "handles only `succeeds`" signal
4864/// is load-bearing enough to surface prominently at check time — a warning, not
4865/// a buried lint advisory. `@service` workflows are exempt (they record a
4866/// durable diagnostic and keep running, so the auto-fail framing would be
4867/// wrong), timers are exempt (they cannot fail), and the compile-time observer
4868/// set is deliberately WIDER than the runtime net's: coordination outcome
4869/// predicates (`held`/`contended`/`ok`/`over`) count as observers here so
4870/// ordinary coordination code stays quiet, while the runtime net still catches
4871/// a genuine op failure underneath them.
4872fn warn_unhandled_effect_failures(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4873    let service = ir
4874        .source_tags
4875        .iter()
4876        .any(|tag| tag.target_kind == "workflow" && tag.name == "service");
4877    if service {
4878        return;
4879    }
4880    for rule in &ir.rules {
4881        for effect in &rule.metadata.effects {
4882            let Some(binding) = effect.binding.as_deref() else {
4883                continue;
4884            };
4885            if effect.kind == IrEffectKind::TimerWait {
4886                continue;
4887            }
4888            // A `then`-chained effect (synthetic `__then_*` handle) is an
4889            // explicit opt-in to auto-fail on failure (R2) — never a warning.
4890            if binding.starts_with(then_expand::THEN_BINDING_PREFIX) {
4891                continue;
4892            }
4893            let observed = rule.body.lines().any(|line| {
4894                let Some(rest) = line.trim().strip_prefix("after ") else {
4895                    return false;
4896                };
4897                let mut parts = rest.split_whitespace();
4898                if parts.next() != Some(binding) {
4899                    return false;
4900                }
4901                // `times` only occurs as the two-token predicate `times out`.
4902                matches!(
4903                    parts.next().map(|token| token.trim_end_matches('{')),
4904                    Some("fails" | "times" | "completes" | "held" | "contended" | "ok" | "over")
4905                )
4906            });
4907            if observed {
4908                continue;
4909            }
4910            warnings.push(Diagnostic {
4911                related: Vec::new(),
4912                span: effect.span,
4913                message: format!(
4914                    "effect `{binding}`'s failure is unhandled in rule `{}`; if it fails or \
4915                     times out, the instance will auto-fail with a generic reason",
4916                    rule.name
4917                ),
4918                suggestion: Some(format!(
4919                    "handle it with `after {binding} fails {{ … }}` (typed failure or recovery) \
4920                     or observe every outcome with `after {binding} completes`"
4921                )),
4922            });
4923        }
4924    }
4925}
4926
4927fn warn_counter_without_timezone(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4928    for counter in &ir.counters {
4929        if counter.timezone.is_none() {
4930            warnings.push(Diagnostic {
4931                related: Vec::new(),
4932                span: counter.span,
4933                message: format!(
4934                    "counter `{}` declares no `timezone`; its `{}` reset boundary anchors to UTC",
4935                    counter.name, counter.reset
4936                ),
4937                suggestion: Some(
4938                    "declare `timezone \"<IANA zone>\"` (e.g. `timezone \"America/New_York\"`) to anchor the period locally"
4939                        .to_owned(),
4940                ),
4941            });
4942        }
4943    }
4944}
4945
4946/// MEM-5 static check 4: a memory-pool grant on a `tell` whose agent runs a
4947/// NATIVE adapter (codex/claude/command) is inert — only the owned harness
4948/// exposes the granted memory tools. Warn instead of silently dropping the
4949/// author's intent (the inert-grant honesty the design eliminates).
4950fn warn_inert_memory_grant_on_native_adapter(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4951    let memory_pools: BTreeSet<&str> = ir
4952        .memory_pools
4953        .iter()
4954        .map(|pool| pool.name.as_str())
4955        .collect();
4956    if memory_pools.is_empty() {
4957        return;
4958    }
4959    let harness_kind_of: BTreeMap<&str, &str> = ir
4960        .harnesses
4961        .iter()
4962        .map(|harness| (harness.name.as_str(), harness.kind.as_str()))
4963        .collect();
4964    let agent_harness_kind: BTreeMap<&str, &str> = ir
4965        .agents
4966        .iter()
4967        .filter_map(|agent| {
4968            let harness = agent.harness.as_deref()?;
4969            Some((agent.name.as_str(), *harness_kind_of.get(harness)?))
4970        })
4971        .collect();
4972    for rule in &ir.rules {
4973        for effect in &rule.metadata.effects {
4974            let Some(agent) = effect.agent.as_deref() else {
4975                continue;
4976            };
4977            let Some(kind) = agent_harness_kind.get(agent) else {
4978                continue;
4979            };
4980            if !matches!(*kind, "codex" | "claude" | "command") {
4981                continue;
4982            }
4983            for grant in &effect.access_grants {
4984                if memory_pools.contains(grant.resource.as_str()) {
4985                    warnings.push(Diagnostic {
4986                        related: Vec::new(),
4987                        span: effect.span,
4988                        message: format!(
4989                            "rule `{}` grants memory pool `{}` on a tell to `{agent}`, whose \
4990                             harness kind `{kind}` is a native adapter — memory grants only \
4991                             take effect on the owned harness, so this grant is inert",
4992                            rule.name, grant.resource
4993                        ),
4994                        suggestion: Some(
4995                            "target an owned-harness agent, or drop the memory grant".to_owned(),
4996                        ),
4997                    });
4998                }
4999            }
5000        }
5001    }
5002}
5003
5004fn lower_source_tags(tags: &[TagDecl], target_kind: &str, target: &str, ir: &mut IrProgram) {
5005    for tag in tags {
5006        ir.source_tags.push(IrSourceTag {
5007            name: tag.name.clone(),
5008            target_kind: target_kind.to_owned(),
5009            target: target.to_owned(),
5010            span: tag.span,
5011        });
5012    }
5013}
5014
5015fn lower_source_description(
5016    description: Option<&StringLiteral>,
5017    target_kind: &str,
5018    target: &str,
5019    ir: &mut IrProgram,
5020) {
5021    if let Some(description) = description {
5022        ir.source_descriptions.push(IrSourceDescription {
5023            value: description.value.clone(),
5024            target_kind: target_kind.to_owned(),
5025            target: target.to_owned(),
5026            span: description.span,
5027        });
5028    }
5029}
5030
5031/// Detect recursive pattern application over the pattern-declaration graph and
5032/// emit `graph.unbounded_pattern_recursion` (severity error) for each expansion
5033/// cycle, naming the cycle. Returns the set of patterns that participate in a
5034/// cycle so the caller can suppress the generic "nested apply" message for them.
5035///
5036/// A pattern's body that `apply`s another pattern is an edge; a pattern that can
5037/// reach itself (directly via a self-apply, or transitively) cannot elaborate into
5038/// a finite first-order program, so v0 rejects it (spec/static-analysis.md). The
5039/// reachability closure mirrors `models/maude/pattern-recursion.maude`.
5040fn detect_pattern_recursion(
5041    patterns: &BTreeMap<String, PatternDecl>,
5042    diagnostics: &mut Vec<Diagnostic>,
5043) -> BTreeSet<String> {
5044    // Application edges: pattern name -> the patterns its body applies, with spans.
5045    let mut edges: BTreeMap<&str, Vec<(&str, SourceSpan)>> = BTreeMap::new();
5046    for pattern in patterns.values() {
5047        let mut applied = Vec::new();
5048        for item in &pattern.items {
5049            if let Item::Apply(apply) = item {
5050                applied.push((apply.pattern.name.as_str(), apply.span));
5051            }
5052        }
5053        edges.insert(pattern.name.name.as_str(), applied);
5054    }
5055
5056    // A pattern is recursive iff it can reach itself. Find a shortest cycle path
5057    // back to `start` via breadth-first search, tracking each node's predecessor.
5058    let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
5059        let mut queue: VecDeque<&str> = VecDeque::new();
5060        // predecessor[node] = (came_from, span_of_edge) used to first reach `node`.
5061        let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
5062        for &(target, span) in edges.get(start).into_iter().flatten() {
5063            if target == start {
5064                // Direct self-application.
5065                return Some((vec![start.to_owned(), start.to_owned()], span));
5066            }
5067            if predecessor.insert(target, (start, span)).is_none() {
5068                queue.push_back(target);
5069            }
5070        }
5071        while let Some(node) = queue.pop_front() {
5072            for &(target, span) in edges.get(node).into_iter().flatten() {
5073                if target == start {
5074                    // Reconstruct start -> ... -> node, then close back to start.
5075                    let mut path = vec![node.to_owned()];
5076                    let mut cursor = node;
5077                    while cursor != start {
5078                        let (from, _) = predecessor[cursor];
5079                        path.push(from.to_owned());
5080                        cursor = from;
5081                    }
5082                    path.reverse();
5083                    path.push(start.to_owned());
5084                    // Report at the first apply edge of `start` that enters the cycle.
5085                    let first = &path[1];
5086                    let entry_span = edges
5087                        .get(start)
5088                        .into_iter()
5089                        .flatten()
5090                        .find(|(target, _)| target == first)
5091                        .map(|(_, span)| *span)
5092                        .unwrap_or(span);
5093                    return Some((path, entry_span));
5094                }
5095                if predecessor.insert(target, (node, span)).is_none() {
5096                    queue.push_back(target);
5097                }
5098            }
5099        }
5100        None
5101    };
5102
5103    let mut recursive = BTreeSet::new();
5104    // Iterate patterns in declaration-name order for deterministic diagnostics, and
5105    // report each cycle once by skipping members already covered by a prior cycle.
5106    for name in patterns.keys() {
5107        if recursive.contains(name) {
5108            continue;
5109        }
5110        if let Some((cycle, span)) = find_cycle(name) {
5111            for member in &cycle {
5112                recursive.insert(member.clone());
5113            }
5114            diagnostics.push(Diagnostic { related: Vec::new(),
5115                span,
5116                message: format!(
5117                    "recursive pattern application is not allowed (graph.unbounded_pattern_recursion): expansion cycle {}",
5118                    cycle.join(" -> ")
5119                ),
5120                suggestion: Some(
5121                    "break the cycle: pattern expansion must elaborate into a finite program"
5122                        .to_owned(),
5123                ),
5124            });
5125        }
5126    }
5127    recursive
5128}
5129
5130/// Reject a *transitive* runtime workflow-invocation cycle (A invokes B invokes A,
5131/// or longer). RESOLVED 2026-07-01: the invoke-recursion policy is "as permissive
5132/// as provable convergence at compile time allows"; whipplescript has no
5133/// convergence proof for runtime `invoke` recursion (termination is data-dependent
5134/// and there is no decreasing-measure mechanism yet), so — exactly parallel to
5135/// `detect_pattern_recursion` — any cycle is rejected as
5136/// `graph.unbounded_workflow_invocation_recursion`. Direct self-invocation (a cycle
5137/// of length 1) is already rejected per-rule in `validate_workflow_invocations`, so
5138/// self-edges are excluded here and this catches only length >= 2 cycles. Modeled
5139/// as invoke-graph non-convergence in `models/maude/subworkflow-convergence.maude`.
5140fn detect_workflow_invoke_recursion(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
5141    // Invoke edges: workflow name -> the workflows its rules invoke, with the span
5142    // of the invoking rule body. Built over the raw AST (all workflows), so it is
5143    // independent of root selection. Self-edges are excluded (owned by the direct
5144    // per-rule recursion check).
5145    let mut edges: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
5146    let record_invokes =
5147        |name: &str, items: &[Item], edges: &mut BTreeMap<String, Vec<(String, SourceSpan)>>| {
5148            let entry = edges.entry(name.to_owned()).or_default();
5149            for item in items {
5150                let Item::Rule(rule) = item else {
5151                    continue;
5152                };
5153                for statement in workflow_invoke_statements(&rule.body.text) {
5154                    if let Some((target, _)) = invoke_statement_parts(&statement) {
5155                        if target != name {
5156                            entry.push((target.to_owned(), rule.body.span));
5157                        }
5158                    }
5159                }
5160            }
5161        };
5162    if let Some(root) = &program.workflow {
5163        record_invokes(&root.name, &program.items, &mut edges);
5164    }
5165    for workflow in &program.workflows {
5166        record_invokes(&workflow.name.name, &workflow.items, &mut edges);
5167    }
5168
5169    // A workflow is in a cycle iff it can reach itself over invoke edges. BFS for a
5170    // shortest path back to `start` (mirrors `detect_pattern_recursion`).
5171    let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
5172        let mut queue: VecDeque<&str> = VecDeque::new();
5173        let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
5174        for (target, span) in edges.get(start).into_iter().flatten() {
5175            if predecessor
5176                .insert(target.as_str(), (start, *span))
5177                .is_none()
5178            {
5179                queue.push_back(target.as_str());
5180            }
5181        }
5182        while let Some(node) = queue.pop_front() {
5183            for (target, span) in edges.get(node).into_iter().flatten() {
5184                if target == start {
5185                    let mut path = vec![node.to_owned()];
5186                    let mut cursor = node;
5187                    while cursor != start {
5188                        let (from, _) = predecessor[cursor];
5189                        path.push(from.to_owned());
5190                        cursor = from;
5191                    }
5192                    path.reverse();
5193                    path.push(start.to_owned());
5194                    let first = &path[1];
5195                    let entry_span = edges
5196                        .get(start)
5197                        .into_iter()
5198                        .flatten()
5199                        .find(|(target, _)| target == first)
5200                        .map(|(_, span)| *span)
5201                        .unwrap_or(*span);
5202                    return Some((path, entry_span));
5203                }
5204                if predecessor.insert(target.as_str(), (node, *span)).is_none() {
5205                    queue.push_back(target.as_str());
5206                }
5207            }
5208        }
5209        None
5210    };
5211
5212    let mut flagged: BTreeSet<String> = BTreeSet::new();
5213    for name in edges.keys() {
5214        if flagged.contains(name) {
5215            continue;
5216        }
5217        if let Some((cycle, span)) = find_cycle(name) {
5218            for member in &cycle {
5219                flagged.insert(member.clone());
5220            }
5221            diagnostics.push(Diagnostic {
5222                related: Vec::new(),
5223                span,
5224                message: format!(
5225                    "recursive workflow invocation is not allowed (graph.unbounded_workflow_invocation_recursion): invocation cycle {}",
5226                    cycle.join(" -> ")
5227                ),
5228                suggestion: Some(
5229                    "break the cycle: a runtime `invoke` cycle has no compile-time convergence proof; route the recurrence through an external event, clock, or durable boundary instead"
5230                        .to_owned(),
5231                ),
5232            });
5233        }
5234    }
5235}
5236
5237fn detect_private_workflow_invocations(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
5238    let private_workflows = program
5239        .workflows
5240        .iter()
5241        .filter(|workflow| workflow.tags.iter().any(|tag| tag.name == "private"))
5242        .map(|workflow| workflow.name.name.as_str())
5243        .collect::<BTreeSet<_>>();
5244    if private_workflows.is_empty() {
5245        return;
5246    }
5247
5248    let mut record_private_invokes = |caller: &str, items: &[Item]| {
5249        for item in items {
5250            let Item::Rule(rule) = item else {
5251                continue;
5252            };
5253            for statement in workflow_invoke_statements(&rule.body.text) {
5254                let Some((target, _)) = invoke_statement_parts(&statement) else {
5255                    continue;
5256                };
5257                if caller == target || !private_workflows.contains(target) {
5258                    continue;
5259                }
5260                diagnostics.push(Diagnostic {
5261                    related: Vec::new(),
5262                    span: rule.body.span,
5263                    message: format!(
5264                        "rule `{}` invokes private workflow `{target}`",
5265                        rule.name.name
5266                    ),
5267                    suggestion: Some(
5268                        "remove `@private` from the target workflow or expose a public wrapper workflow"
5269                            .to_owned(),
5270                    ),
5271                });
5272            }
5273        }
5274    };
5275
5276    if let Some(root) = &program.workflow {
5277        record_private_invokes(&root.name, &program.items);
5278    }
5279    for workflow in &program.workflows {
5280        record_private_invokes(&workflow.name.name, &workflow.items);
5281    }
5282}
5283
5284fn expand_pattern_applications(
5285    mut program: Program,
5286    diagnostics: &mut Vec<Diagnostic>,
5287) -> (Program, Vec<IrPatternApplication>) {
5288    let mut patterns = BTreeMap::new();
5289    for pattern in &program.patterns {
5290        if patterns
5291            .insert(pattern.name.name.clone(), pattern.clone())
5292            .is_some()
5293        {
5294            diagnostics.push(Diagnostic {
5295                related: Vec::new(),
5296                span: pattern.name.span,
5297                message: format!("pattern `{}` is declared more than once", pattern.name.name),
5298                suggestion: Some("rename one pattern declaration".to_owned()),
5299            });
5300        }
5301    }
5302
5303    // v0 forbids recursive pattern application (spec/static-analysis.md,
5304    // graph.unbounded_pattern_recursion): an `apply` that reaches, directly or
5305    // transitively, a pattern already on the active expansion stack can never
5306    // elaborate into a finite first-order program. Detect cycles up front so the
5307    // precise diagnostic is emitted and the generic "nested apply not supported
5308    // yet" message is suppressed for the recursive case.
5309    let recursive_patterns = detect_pattern_recursion(&patterns, diagnostics);
5310
5311    let mut expanded_items = Vec::new();
5312    let mut applications = Vec::new();
5313    for item in program.items {
5314        let Item::Apply(apply) = item else {
5315            expanded_items.push(item);
5316            continue;
5317        };
5318        let Some(pattern) = patterns.get(&apply.pattern.name) else {
5319            diagnostics.push(Diagnostic {
5320                related: Vec::new(),
5321                span: apply.pattern.span,
5322                message: format!("pattern `{}` was not found", apply.pattern.name),
5323                suggestion: Some("declare the pattern before applying it".to_owned()),
5324            });
5325            continue;
5326        };
5327        if pattern.type_params.len() != apply.type_args.len() {
5328            diagnostics.push(Diagnostic {
5329                related: Vec::new(),
5330                span: apply.span,
5331                message: format!(
5332                    "pattern `{}` expects {} type arguments but got {}",
5333                    pattern.name.name,
5334                    pattern.type_params.len(),
5335                    apply.type_args.len()
5336                ),
5337                suggestion: Some("match the pattern type parameter list".to_owned()),
5338            });
5339            continue;
5340        }
5341        let type_substitutions = pattern
5342            .type_params
5343            .iter()
5344            .map(|param| param.name.clone())
5345            .zip(apply.type_args.iter().cloned())
5346            .collect::<BTreeMap<_, _>>();
5347        let value_substitutions = parse_pattern_value_arguments(&apply, diagnostics);
5348        let local_names = pattern_local_names(pattern, &apply.alias.name);
5349        let definition_span = pattern.span;
5350        let application_span = apply.span;
5351        let mut generated = Vec::new();
5352        for pattern_item in pattern.items.iter().cloned() {
5353            // Enforce the pattern-body allow-list before expanding: a pattern
5354            // is a compile-time reuse fragment, not a workflow, so forbidden
5355            // constructs are rejected with a clear diagnostic and dropped.
5356            if let Some(diagnostic) = pattern_body_admission(&pattern_item, &recursive_patterns) {
5357                diagnostics.push(diagnostic);
5358                continue;
5359            }
5360            if let Some((generated_name, item)) = expand_pattern_item(
5361                pattern_item,
5362                &apply.alias.name,
5363                &type_substitutions,
5364                &value_substitutions,
5365                &local_names,
5366            ) {
5367                generated.push(generated_name);
5368                expanded_items.push(item);
5369            }
5370        }
5371        applications.push(IrPatternApplication {
5372            pattern: pattern.name.name.clone(),
5373            alias: apply.alias.name,
5374            type_args: apply.type_args.into_iter().map(lower_type).collect(),
5375            value_args: value_substitutions
5376                .into_iter()
5377                .map(|(name, value)| IrPatternArgument { name, value })
5378                .collect(),
5379            generated,
5380            definition_span,
5381            application_span,
5382        });
5383    }
5384    program.items = expanded_items;
5385    (program, applications)
5386}
5387
5388fn pattern_local_names(pattern: &PatternDecl, alias: &str) -> BTreeMap<String, String> {
5389    let mut names = BTreeMap::new();
5390    for item in &pattern.items {
5391        match item {
5392            Item::Harness(harness) => {
5393                names.insert(
5394                    harness.name.name.clone(),
5395                    generated_pattern_name(alias, &harness.name.name),
5396                );
5397            }
5398            Item::Agent(agent) => {
5399                names.insert(
5400                    agent.name.name.clone(),
5401                    generated_pattern_name(alias, &agent.name.name),
5402                );
5403            }
5404            Item::Enum(enum_decl) => {
5405                names.insert(
5406                    enum_decl.name.name.clone(),
5407                    generated_pattern_name(alias, &enum_decl.name.name),
5408                );
5409            }
5410            Item::Class(class_decl) => {
5411                names.insert(
5412                    class_decl.name.name.clone(),
5413                    generated_pattern_name(alias, &class_decl.name.name),
5414                );
5415            }
5416            Item::Coerce(coerce) => {
5417                names.insert(
5418                    coerce.name.name.clone(),
5419                    generated_pattern_name(alias, &coerce.name.name),
5420                );
5421            }
5422            Item::Rule(rule) => {
5423                names.insert(
5424                    rule.name.name.clone(),
5425                    generated_pattern_name(alias, &rule.name.name),
5426                );
5427            }
5428            _ => {}
5429        }
5430    }
5431    names
5432}
5433
5434fn generated_pattern_name(alias: &str, name: &str) -> String {
5435    format!("{alias}_{name}")
5436}
5437
5438fn parse_pattern_value_arguments(
5439    apply: &ApplyDecl,
5440    diagnostics: &mut Vec<Diagnostic>,
5441) -> BTreeMap<String, String> {
5442    let mut args = BTreeMap::new();
5443    for line in apply
5444        .body
5445        .text
5446        .lines()
5447        .map(str::trim)
5448        .filter(|line| !line.is_empty())
5449    {
5450        let mut parts = line.splitn(2, char::is_whitespace);
5451        let Some(name) = parts.next().filter(|name| is_identifier(name)) else {
5452            diagnostics.push(Diagnostic {
5453                related: Vec::new(),
5454                span: apply.body.span,
5455                message: format!(
5456                    "pattern application `{}` has malformed argument `{line}`",
5457                    apply.alias.name
5458                ),
5459                suggestion: Some("write pattern arguments as `name value`".to_owned()),
5460            });
5461            continue;
5462        };
5463        let Some(value) = parts
5464            .next()
5465            .map(str::trim)
5466            .filter(|value| !value.is_empty())
5467        else {
5468            diagnostics.push(Diagnostic {
5469                related: Vec::new(),
5470                span: apply.body.span,
5471                message: format!(
5472                    "pattern application `{}` argument `{name}` is missing a value",
5473                    apply.alias.name
5474                ),
5475                suggestion: Some("write pattern arguments as `name value`".to_owned()),
5476            });
5477            continue;
5478        };
5479        if args.insert(name.to_owned(), value.to_owned()).is_some() {
5480            diagnostics.push(Diagnostic {
5481                related: Vec::new(),
5482                span: apply.body.span,
5483                message: format!(
5484                    "pattern application `{}` passes argument `{name}` more than once",
5485                    apply.alias.name
5486                ),
5487                suggestion: Some("remove the duplicate pattern argument".to_owned()),
5488            });
5489        }
5490    }
5491    args
5492}
5493
5494/// The explicit allow-list gate for a `pattern { ... }` body.
5495///
5496/// A pattern is a compile-time reuse fragment, not a workflow. Its body MAY
5497/// declare the building blocks of a workflow -- rules, effects (`coerce`),
5498/// records (via a rule's `record`), local schemas (`class`/`enum`), tables,
5499/// agents/harnesses, and coordination resources -- but it MUST NOT contain:
5500///   * workflow contracts (`input`/`output`/`failure`) -- workflow-level shape,
5501///   * nested `pattern` declarations,
5502///   * nested `apply` (pattern applications inside pattern bodies), or
5503///   * rules that reach a workflow terminal (`complete`/`fail`): a reusable
5504///     fragment must not hard-code the enclosing workflow's terminal outcome.
5505///
5506/// Returns `Some(diagnostic)` for a forbidden construct; `None` when the item is
5507/// on the allow-list.
5508fn pattern_body_admission(
5509    item: &Item,
5510    recursive_patterns: &BTreeSet<String>,
5511) -> Option<Diagnostic> {
5512    match item {
5513        Item::WorkflowContract(contract) => Some(Diagnostic {
5514            related: Vec::new(),
5515            span: contract.span,
5516            message: "workflow contracts are not allowed in pattern bodies".to_owned(),
5517            suggestion: Some(
5518                "declare workflow inputs, outputs, and failures on the workflow".to_owned(),
5519            ),
5520        }),
5521        Item::Pattern(pattern) => Some(Diagnostic {
5522            related: Vec::new(),
5523            span: pattern.span,
5524            message: "nested pattern declarations are not supported in pattern bodies".to_owned(),
5525            suggestion: Some("declare reusable patterns at source top level".to_owned()),
5526        }),
5527        // A recursive nested apply was already rejected with the precise
5528        // graph.unbounded_pattern_recursion diagnostic by detect_pattern_recursion;
5529        // don't also emit the generic "not supported yet" message for it.
5530        Item::Apply(apply) if !recursive_patterns.contains(&apply.pattern.name) => Some(Diagnostic {
5531            related: Vec::new(),
5532            span: apply.span,
5533            message: "pattern applications inside pattern bodies are not supported yet".to_owned(),
5534            suggestion: Some(
5535                "apply patterns from workflow bodies only in this implementation slice".to_owned(),
5536            ),
5537        }),
5538        // Objective intent is top-level: a gauge binds a judge to this
5539        // program's sites and a campaign partitions this program's gauge
5540        // vector — neither is a reusable template fragment.
5541        Item::Gauge(gauge) => Some(Diagnostic {
5542            related: Vec::new(),
5543            span: gauge.span,
5544            message: "gauge declarations are not allowed in pattern bodies".to_owned(),
5545            suggestion: Some("declare gauges at source top level".to_owned()),
5546        }),
5547        Item::Campaign(campaign) => Some(Diagnostic {
5548            related: Vec::new(),
5549            span: campaign.span,
5550            message: "campaign declarations are not allowed in pattern bodies".to_owned(),
5551            suggestion: Some("declare campaigns at source top level".to_owned()),
5552        }),
5553        Item::Mark(mark) => Some(Diagnostic {
5554            related: Vec::new(),
5555            span: mark.span,
5556            message: "mark declarations are not allowed in pattern bodies".to_owned(),
5557            suggestion: Some("declare marks at source top level".to_owned()),
5558        }),
5559        Item::Rule(rule) => pattern_rule_terminal_span(rule).map(|span| Diagnostic {
5560            related: Vec::new(),
5561            span,
5562            message: format!(
5563                "rule `{}` in a pattern body cannot reach a workflow terminal (`complete`/`fail`)",
5564                rule.name.name
5565            ),
5566            suggestion: Some(
5567                "record a fact in the pattern rule and let a workflow rule decide the terminal outcome"
5568                    .to_owned(),
5569            ),
5570        }),
5571        _ => None,
5572    }
5573}
5574
5575/// Locate the first workflow-terminal statement (`complete`/`fail`) in a
5576/// pattern rule body, returning its source span for diagnostics.
5577fn pattern_rule_terminal_span(rule: &RuleDecl) -> Option<SourceSpan> {
5578    let mut offset = 0usize;
5579    for line in rule.body.text.split_inclusive('\n') {
5580        let trimmed_start = line.trim_start();
5581        let leading = line.len() - trimmed_start.len();
5582        let statement = trimmed_start.trim_end();
5583        if is_pattern_terminal_statement(statement) {
5584            let start = rule.body.span.start + offset + leading;
5585            return Some(SourceSpan {
5586                start,
5587                end: start + statement.len(),
5588            });
5589        }
5590        offset += line.len();
5591    }
5592    None
5593}
5594
5595/// A trimmed body line begins a workflow terminal iff it starts with the
5596/// `complete` or `fail` keyword followed by whitespace, `{`, or end of line.
5597fn is_pattern_terminal_statement(line: &str) -> bool {
5598    for keyword in ["complete", "fail"] {
5599        if let Some(rest) = line.strip_prefix(keyword) {
5600            if rest.is_empty() || rest.starts_with('{') || rest.starts_with(char::is_whitespace) {
5601                return true;
5602            }
5603        }
5604    }
5605    false
5606}
5607
5608fn expand_pattern_item(
5609    item: Item,
5610    alias: &str,
5611    type_substitutions: &BTreeMap<String, TypeSyntax>,
5612    value_substitutions: &BTreeMap<String, String>,
5613    local_names: &BTreeMap<String, String>,
5614) -> Option<(String, Item)> {
5615    match item {
5616        Item::Include(include) => Some((
5617            format!("include:{}", include.path.value),
5618            Item::Include(include),
5619        )),
5620        Item::Use(use_decl) => Some((format!("use:{}", use_decl.name.value), Item::Use(use_decl))),
5621        Item::Tracker(queue) => {
5622            Some((format!("tracker:{}", queue.name.name), Item::Tracker(queue)))
5623        }
5624        Item::Channel(channel) => Some((
5625            format!("channel:{}", channel.name.name),
5626            Item::Channel(channel),
5627        )),
5628        // Gauges, campaigns, and marks are rejected from pattern bodies by
5629        // `pattern_body_admission` (objective intent and cut points are
5630        // top-level); there is deliberately no expansion path for them.
5631        Item::Gauge(_) | Item::Campaign(_) | Item::Mark(_) => None,
5632        Item::FileStore(file_store) => Some((
5633            format!("file-store:{}", file_store.name.name),
5634            Item::FileStore(file_store),
5635        )),
5636        Item::MemoryPool(pool) => Some((
5637            format!("memory-pool:{}", pool.name.name),
5638            Item::MemoryPool(pool),
5639        )),
5640        Item::Event(event) => Some((format!("event:{}", event.name), Item::Event(event))),
5641        Item::Source(source) => {
5642            Some((format!("source:{}", source.name.name), Item::Source(source)))
5643        }
5644        Item::Test(test) => Some((format!("test:{}", test.name.value), Item::Test(test))),
5645        Item::Lease(lease) => Some((format!("lease:{}", lease.name.name), Item::Lease(lease))),
5646        Item::Ledger(ledger) => {
5647            Some((format!("ledger:{}", ledger.name.name), Item::Ledger(ledger)))
5648        }
5649        Item::Counter(counter) => Some((
5650            format!("counter:{}", counter.name.name),
5651            Item::Counter(counter),
5652        )),
5653        Item::Action(action) => {
5654            Some((format!("action:{}", action.name.name), Item::Action(action)))
5655        }
5656        Item::Harness(mut harness) => {
5657            let name = rename_ident(harness.name, alias, local_names);
5658            let generated = format!("harness:{}", name.name);
5659            harness.name = name;
5660            Some((generated, Item::Harness(harness)))
5661        }
5662        // Forbidden constructs are rejected up front by `pattern_body_admission`
5663        // (the explicit allow-list gate), so these arms are unreachable in
5664        // practice; they stay defensive and simply drop the item.
5665        Item::WorkflowContract(_) | Item::Pattern(_) | Item::Apply(_) => None,
5666        Item::Agent(mut agent) => {
5667            let name = rename_ident(agent.name, alias, local_names);
5668            let generated = format!("agent:{}", name.name);
5669            agent.name = name;
5670            if let Some(harness) = agent.harness {
5671                agent.harness = Some(Ident {
5672                    name: local_names
5673                        .get(&harness.name)
5674                        .cloned()
5675                        .unwrap_or(harness.name),
5676                    span: harness.span,
5677                });
5678            }
5679            Some((generated, Item::Agent(agent)))
5680        }
5681        Item::Enum(mut enum_decl) => {
5682            let name = rename_ident(enum_decl.name, alias, local_names);
5683            let generated = format!("enum:{}", name.name);
5684            enum_decl.name = name;
5685            Some((generated, Item::Enum(enum_decl)))
5686        }
5687        Item::Class(mut class_decl) => {
5688            let name = rename_ident(class_decl.name, alias, local_names);
5689            let generated = format!("class:{}", name.name);
5690            class_decl.name = name;
5691            for field in &mut class_decl.fields {
5692                field.ty =
5693                    substitute_pattern_type(field.ty.clone(), type_substitutions, local_names);
5694            }
5695            Some((generated, Item::Class(class_decl)))
5696        }
5697        Item::Table(mut table) => {
5698            let name = rename_ident(table.name, alias, local_names);
5699            let generated = format!("table:{}", name.name);
5700            table.name = name;
5701            for row in &mut table.rows {
5702                row.body.text = substitute_pattern_text(
5703                    &row.body.text,
5704                    type_substitutions,
5705                    value_substitutions,
5706                    local_names,
5707                );
5708            }
5709            Some((generated, Item::Table(table)))
5710        }
5711        Item::Coerce(mut coerce) => {
5712            let name = rename_ident(coerce.name, alias, local_names);
5713            let generated = format!("coerce:{}", name.name);
5714            coerce.name = name;
5715            for param in &mut coerce.params {
5716                param.ty =
5717                    substitute_pattern_type(param.ty.clone(), type_substitutions, local_names);
5718            }
5719            coerce.output =
5720                substitute_pattern_type(coerce.output.clone(), type_substitutions, local_names);
5721            coerce.body.text = substitute_pattern_text(
5722                &coerce.body.text,
5723                type_substitutions,
5724                value_substitutions,
5725                local_names,
5726            );
5727            Some((generated, Item::Coerce(coerce)))
5728        }
5729        Item::Assert(mut assertion) => {
5730            assertion.expr = substitute_pattern_text(
5731                &assertion.expr,
5732                type_substitutions,
5733                value_substitutions,
5734                local_names,
5735            );
5736            Some((format!("assert:{alias}"), Item::Assert(assertion)))
5737        }
5738        Item::Rule(mut rule) => {
5739            let name = rename_ident(rule.name, alias, local_names);
5740            let generated = format!("rule:{}", name.name);
5741            rule.name = name;
5742            for when in &mut rule.whens {
5743                when.text = substitute_pattern_text(
5744                    &when.text,
5745                    type_substitutions,
5746                    value_substitutions,
5747                    local_names,
5748                );
5749            }
5750            rule.body.text = substitute_pattern_text(
5751                &rule.body.text,
5752                type_substitutions,
5753                value_substitutions,
5754                local_names,
5755            );
5756            Some((generated, Item::Rule(rule)))
5757        }
5758    }
5759}
5760
5761fn rename_ident(ident: Ident, alias: &str, local_names: &BTreeMap<String, String>) -> Ident {
5762    Ident {
5763        name: local_names
5764            .get(&ident.name)
5765            .cloned()
5766            .unwrap_or_else(|| generated_pattern_name(alias, &ident.name)),
5767        span: ident.span,
5768    }
5769}
5770
5771fn substitute_pattern_type(
5772    ty: TypeSyntax,
5773    type_substitutions: &BTreeMap<String, TypeSyntax>,
5774    local_names: &BTreeMap<String, String>,
5775) -> TypeSyntax {
5776    match ty {
5777        TypeSyntax::Ref { name } => {
5778            if let Some(replacement) = type_substitutions.get(&name.name) {
5779                return replacement.clone();
5780            }
5781            TypeSyntax::Ref {
5782                name: Ident {
5783                    name: local_names.get(&name.name).cloned().unwrap_or(name.name),
5784                    span: name.span,
5785                },
5786            }
5787        }
5788        TypeSyntax::AgentRef { agents, span } => TypeSyntax::AgentRef {
5789            agents: agents
5790                .into_iter()
5791                .map(|agent| Ident {
5792                    name: local_names.get(&agent.name).cloned().unwrap_or(agent.name),
5793                    span: agent.span,
5794                })
5795                .collect(),
5796            span,
5797        },
5798        TypeSyntax::Optional { inner, span } => TypeSyntax::Optional {
5799            inner: Box::new(substitute_pattern_type(
5800                *inner,
5801                type_substitutions,
5802                local_names,
5803            )),
5804            span,
5805        },
5806        TypeSyntax::Array { inner, span } => TypeSyntax::Array {
5807            inner: Box::new(substitute_pattern_type(
5808                *inner,
5809                type_substitutions,
5810                local_names,
5811            )),
5812            span,
5813        },
5814        TypeSyntax::Map { inner, span } => TypeSyntax::Map {
5815            inner: Box::new(substitute_pattern_type(
5816                *inner,
5817                type_substitutions,
5818                local_names,
5819            )),
5820            span,
5821        },
5822        TypeSyntax::Union { variants, span } => TypeSyntax::Union {
5823            variants: variants
5824                .into_iter()
5825                .map(|variant| substitute_pattern_type(variant, type_substitutions, local_names))
5826                .collect(),
5827            span,
5828        },
5829        other => other,
5830    }
5831}
5832
5833fn substitute_pattern_text(
5834    text: &str,
5835    type_substitutions: &BTreeMap<String, TypeSyntax>,
5836    value_substitutions: &BTreeMap<String, String>,
5837    local_names: &BTreeMap<String, String>,
5838) -> String {
5839    let mut output = text.to_owned();
5840    for (name, replacement) in type_substitutions {
5841        output = replace_identifier(&output, name, &replacement.to_source());
5842    }
5843    for (name, replacement) in local_names {
5844        output = replace_identifier(&output, name, replacement);
5845    }
5846    for (name, replacement) in value_substitutions {
5847        output = replace_identifier(&output, name, replacement);
5848    }
5849    output
5850}
5851
5852fn replace_identifier(source: &str, from: &str, to: &str) -> String {
5853    let mut output = String::new();
5854    let mut index = 0usize;
5855    while let Some(offset) = source[index..].find(from) {
5856        let start = index + offset;
5857        let end = start + from.len();
5858        output.push_str(&source[index..start]);
5859        let before = source[..start].chars().next_back();
5860        let after = source[end..].chars().next();
5861        if before.is_none_or(|ch| !is_identifier_char(ch))
5862            && after.is_none_or(|ch| !is_identifier_char(ch))
5863        {
5864            output.push_str(to);
5865        } else {
5866            output.push_str(&source[start..end]);
5867        }
5868        index = end;
5869    }
5870    output.push_str(&source[index..]);
5871    output
5872}
5873
5874fn is_identifier_char(ch: char) -> bool {
5875    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
5876}
5877
5878fn lower_assert(
5879    assertion: AssertDecl,
5880    semantic: &SemanticContext,
5881    ir: &mut IrProgram,
5882    diagnostics: &mut Vec<Diagnostic>,
5883) {
5884    match parse_expression(&assertion.expr) {
5885        Ok(expr) => {
5886            validate_parsed_expression(
5887                &expr,
5888                semantic,
5889                &ExprScope::default(),
5890                &ExprValidationContext::assertion(assertion.span),
5891                "assertion",
5892                diagnostics,
5893            );
5894            let mut projection_reads = collect_projection_reads(&expr);
5895            sort_projection_reads(&mut projection_reads);
5896            ir.assertions.push(IrAssertion {
5897                expr: IrExpression {
5898                    source: assertion.expr,
5899                    expr,
5900                    span: assertion.span,
5901                },
5902                projection_reads,
5903            });
5904        }
5905        Err(message) => diagnostics.push(Diagnostic {
5906            related: Vec::new(),
5907            span: assertion.span,
5908            message: format!("invalid assertion expression: {message}"),
5909            suggestion: Some(
5910                "use a deterministic expression such as `count(Fact) == 1`".to_owned(),
5911            ),
5912        }),
5913    }
5914}
5915
5916fn lower_expression(source: &str, span: SourceSpan) -> Option<IrExpression> {
5917    parse_expression(source).ok().map(|expr| IrExpression {
5918        source: source.to_owned(),
5919        expr,
5920        span,
5921    })
5922}
5923
5924fn collect_projection_reads(expr: &Expr) -> Vec<IrProjectionRead> {
5925    let mut reads = Vec::new();
5926    collect_projection_reads_into(expr, &mut reads);
5927    reads
5928}
5929
5930fn collect_projection_reads_into(expr: &Expr, reads: &mut Vec<IrProjectionRead>) {
5931    match expr {
5932        Expr::Literal(_) | Expr::Path(_) => {}
5933        Expr::Index { target, key } => {
5934            collect_projection_reads_into(target, reads);
5935            collect_projection_reads_into(key, reads);
5936        }
5937        Expr::Array(items) => {
5938            for item in items {
5939                collect_projection_reads_into(item, reads);
5940            }
5941        }
5942        Expr::Object(fields) => {
5943            for field in fields {
5944                collect_projection_reads_into(&field.value, reads);
5945            }
5946        }
5947        Expr::Unary { expr, .. } => collect_projection_reads_into(expr, reads),
5948        Expr::Binary { left, right, .. } => {
5949            collect_projection_reads_into(left, reads);
5950            collect_projection_reads_into(right, reads);
5951        }
5952        Expr::Call { args, .. } => {
5953            for arg in args {
5954                collect_projection_reads_into(arg, reads);
5955            }
5956        }
5957        Expr::Query { kind, head, guard } => {
5958            reads.push(IrProjectionRead {
5959                kind: *kind,
5960                head: head.clone(),
5961                guard: guard.as_ref().map(|guard| guard.to_snapshot()),
5962            });
5963            if let Some(guard) = guard {
5964                collect_projection_reads_into(guard, reads);
5965            }
5966        }
5967    }
5968}
5969
5970fn sort_projection_reads(reads: &mut Vec<IrProjectionRead>) {
5971    reads.sort_by_key(IrProjectionRead::to_snapshot);
5972    reads.dedup();
5973}
5974
5975fn collect_schema_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
5976    let mut names = BTreeSet::new();
5977    // Track the first declaration span per name so a duplicate can point back to
5978    // it as related information ("first declared here").
5979    let mut first_spans: BTreeMap<String, SourceSpan> = BTreeMap::new();
5980    for item in &program.items {
5981        let name = match item {
5982            Item::Enum(enum_decl) => &enum_decl.name,
5983            Item::Class(class_decl) => &class_decl.name,
5984            _ => continue,
5985        };
5986
5987        if !names.insert(name.name.clone()) {
5988            let mut diagnostic = Diagnostic {
5989                related: Vec::new(),
5990                span: name.span,
5991                message: format!("schema `{}` is declared more than once", name.name),
5992                suggestion: Some("rename one declaration or merge the schemas".to_owned()),
5993            };
5994            if let Some(first) = first_spans.get(&name.name) {
5995                diagnostic = diagnostic.with_related(*first, "first declared here");
5996            }
5997            diagnostics.push(diagnostic);
5998        } else {
5999            first_spans.insert(name.name.clone(), name.span);
6000        }
6001    }
6002
6003    names
6004}
6005
6006fn collect_harness_kinds(
6007    program: &Program,
6008    diagnostics: &mut Vec<Diagnostic>,
6009) -> BTreeMap<String, String> {
6010    let mut kinds: BTreeMap<String, String> = BTreeMap::new();
6011    for item in &program.items {
6012        let Item::Harness(harness) = item else {
6013            continue;
6014        };
6015        if kinds
6016            .insert(harness.name.name.clone(), harness.kind.name.clone())
6017            .is_some()
6018        {
6019            diagnostics.push(Diagnostic {
6020                related: Vec::new(),
6021                span: harness.name.span,
6022                message: format!("harness `{}` is declared more than once", harness.name.name),
6023                suggestion: Some(
6024                    "rename one harness declaration or merge the harness settings".to_owned(),
6025                ),
6026            });
6027        }
6028    }
6029    kinds
6030}
6031
6032fn collect_agent_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
6033    let mut names = BTreeSet::new();
6034    for item in &program.items {
6035        let Item::Agent(agent) = item else {
6036            continue;
6037        };
6038        if !names.insert(agent.name.name.clone()) {
6039            diagnostics.push(Diagnostic {
6040                related: Vec::new(),
6041                span: agent.name.span,
6042                message: format!("agent `{}` is declared more than once", agent.name.name),
6043                suggestion: Some("rename one agent declaration or merge the settings".to_owned()),
6044            });
6045        }
6046    }
6047    names
6048}
6049
6050#[derive(Clone, Debug, Default, Eq, PartialEq)]
6051struct WorkflowContractNames {
6052    inputs: BTreeMap<String, TypeSyntax>,
6053    outputs: BTreeMap<String, TypeSyntax>,
6054    failures: BTreeMap<String, TypeSyntax>,
6055}
6056
6057fn collect_workflow_contract_names(
6058    program: &Program,
6059    diagnostics: &mut Vec<Diagnostic>,
6060) -> WorkflowContractNames {
6061    let mut names = WorkflowContractNames::default();
6062    for item in &program.items {
6063        let Item::WorkflowContract(contract) = item else {
6064            continue;
6065        };
6066        let set = match contract.kind {
6067            WorkflowContractKind::Input => &mut names.inputs,
6068            WorkflowContractKind::Output => &mut names.outputs,
6069            WorkflowContractKind::Failure => &mut names.failures,
6070        };
6071        if set
6072            .insert(contract.name.name.clone(), contract.ty.clone())
6073            .is_some()
6074        {
6075            diagnostics.push(Diagnostic {
6076                related: Vec::new(),
6077                span: contract.name.span,
6078                message: format!(
6079                    "workflow declares {} `{}` more than once",
6080                    contract.kind.as_str(),
6081                    contract.name.name
6082                ),
6083                suggestion: Some("remove the duplicate workflow contract".to_owned()),
6084            });
6085        }
6086    }
6087    names
6088}
6089
6090impl SemanticContext {
6091    fn from_program(
6092        program: &Program,
6093        workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
6094    ) -> Self {
6095        let mut schemas = SchemaIndex::with_builtins();
6096        let mut agents = BTreeSet::new();
6097        let mut agent_capabilities = BTreeMap::new();
6098        let mut coerce_outputs = BTreeMap::new();
6099        let mut coerce_params = BTreeMap::new();
6100        let mut leases = BTreeSet::new();
6101        let mut ledgers = BTreeSet::new();
6102        let mut counters = BTreeSet::new();
6103        let mut channels = BTreeSet::new();
6104        let mut channel_providers = BTreeMap::new();
6105        let mut memory_pools = BTreeSet::new();
6106
6107        for item in &program.items {
6108            schemas.insert_item(item);
6109            match item {
6110                Item::Agent(agent) => {
6111                    agents.insert(agent.name.name.clone());
6112                    let capabilities = agent
6113                        .fields
6114                        .iter()
6115                        .find_map(|field| match field {
6116                            AgentField::Capabilities(capabilities, _) => Some(
6117                                capabilities
6118                                    .iter()
6119                                    .map(|capability| capability.value.clone())
6120                                    .collect::<BTreeSet<_>>(),
6121                            ),
6122                            _ => None,
6123                        })
6124                        .unwrap_or_default();
6125                    agent_capabilities.insert(agent.name.name.clone(), capabilities);
6126                }
6127                Item::Coerce(coerce) => {
6128                    coerce_outputs.insert(coerce.name.name.clone(), coerce.output.clone());
6129                    coerce_params.insert(coerce.name.name.clone(), coerce.params.clone());
6130                }
6131                Item::Lease(lease) => {
6132                    leases.insert(lease.name.name.clone());
6133                }
6134                Item::Ledger(ledger) => {
6135                    ledgers.insert(ledger.name.name.clone());
6136                }
6137                Item::Counter(counter) => {
6138                    counters.insert(counter.name.name.clone());
6139                }
6140                Item::Channel(channel) => {
6141                    channels.insert(channel.name.name.clone());
6142                    channel_providers
6143                        .insert(channel.name.name.clone(), channel.provider.name.clone());
6144                }
6145                Item::MemoryPool(pool) => {
6146                    memory_pools.insert(pool.name.name.clone());
6147                }
6148                _ => {}
6149            }
6150        }
6151
6152        Self {
6153            workflow: program
6154                .workflow
6155                .as_ref()
6156                .map(|workflow| workflow.name.clone()),
6157            schemas,
6158            agents,
6159            agent_capabilities,
6160            coerce_outputs,
6161            coerce_params,
6162            workflow_inputs,
6163            leases,
6164            ledgers,
6165            counters,
6166            channels,
6167            channel_providers,
6168            memory_pools,
6169        }
6170    }
6171}
6172
6173fn collect_workflow_input_surfaces(program: &Program) -> BTreeMap<String, WorkflowInputSurface> {
6174    let mut surfaces = BTreeMap::new();
6175    let top_level_schemas = schema_index_for_items(&program.items);
6176
6177    if let Some(workflow) = &program.workflow {
6178        let inputs = workflow_inputs_for_items(&program.items);
6179        surfaces.insert(
6180            workflow.name.clone(),
6181            WorkflowInputSurface {
6182                inputs,
6183                outputs: workflow_outputs_for_items(&program.items),
6184                failures: workflow_failures_for_items(&program.items),
6185                schemas: top_level_schemas.clone(),
6186                milestones: collect_milestone_declarations(&program.items),
6187            },
6188        );
6189    }
6190
6191    for workflow in &program.workflows {
6192        let mut schemas = top_level_schemas.clone();
6193        schemas.merge(schema_index_for_items(&workflow.items));
6194        surfaces.insert(
6195            workflow.name.name.clone(),
6196            WorkflowInputSurface {
6197                inputs: workflow_inputs_for_items(&workflow.items),
6198                outputs: workflow_outputs_for_items(&workflow.items),
6199                failures: workflow_failures_for_items(&workflow.items),
6200                schemas,
6201                milestones: collect_milestone_declarations(&workflow.items),
6202            },
6203        );
6204    }
6205
6206    surfaces
6207}
6208
6209fn collect_shared_coordination_usage(program: &Program) -> Vec<IrSharedCoordinationUsage> {
6210    let global_shared = shared_coordination_declarations(&program.items);
6211    let mut usage: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6212
6213    let mut record_workflow = |workflow_name: &str, local_items: &[Item]| {
6214        let mut shared = global_shared.clone();
6215        shared.extend(shared_coordination_declarations(local_items));
6216        if shared.is_empty() {
6217            return;
6218        }
6219        let principal = format!("workflow:local/{workflow_name}");
6220        for resource in coordination_resources_used_by_items(&program.items)
6221            .into_iter()
6222            .chain(coordination_resources_used_by_items(local_items))
6223        {
6224            if shared.contains(&resource) {
6225                usage.entry(resource).or_default().insert(principal.clone());
6226            }
6227        }
6228    };
6229
6230    if let Some(workflow) = &program.workflow {
6231        record_workflow(&workflow.name, &[]);
6232    }
6233    for workflow in &program.workflows {
6234        record_workflow(&workflow.name.name, &workflow.items);
6235    }
6236
6237    usage
6238        .into_iter()
6239        .map(|(resource, principals)| IrSharedCoordinationUsage {
6240            resource: format!("resource:{resource}"),
6241            workflow_principals: principals.into_iter().collect(),
6242        })
6243        .collect()
6244}
6245
6246fn shared_coordination_declarations(items: &[Item]) -> BTreeSet<String> {
6247    items
6248        .iter()
6249        .filter_map(|item| match item {
6250            Item::Lease(lease) if lease.shared => Some(lease.name.name.clone()),
6251            Item::Ledger(ledger) if ledger.shared => Some(ledger.name.name.clone()),
6252            Item::Counter(counter) if counter.shared => Some(counter.name.name.clone()),
6253            _ => None,
6254        })
6255        .collect()
6256}
6257
6258fn coordination_resources_used_by_items(items: &[Item]) -> BTreeSet<String> {
6259    let mut resources = BTreeSet::new();
6260    for item in items {
6261        let Item::Rule(rule) = item else {
6262            continue;
6263        };
6264        let (body, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
6265        collect_coordination_resources_from_statements(&body.statements, &mut resources);
6266    }
6267    resources
6268}
6269
6270fn collect_coordination_resources_from_statements(
6271    statements: &[body::BodyStmt],
6272    resources: &mut BTreeSet<String>,
6273) {
6274    for statement in statements {
6275        match statement {
6276            body::BodyStmt::Effect(effect) => match &effect.kind {
6277                body::BodyEffectKind::LeaseAcquire { resource, .. } => {
6278                    resources.insert(resource.clone());
6279                }
6280                body::BodyEffectKind::LedgerAppend { ledger, .. } => {
6281                    resources.insert(ledger.clone());
6282                }
6283                body::BodyEffectKind::CounterConsume { counter, .. } => {
6284                    resources.insert(counter.clone());
6285                }
6286                _ => {}
6287            },
6288            body::BodyStmt::After(after) => {
6289                collect_coordination_resources_from_statements(&after.body, resources);
6290            }
6291            body::BodyStmt::Region(region) => {
6292                collect_coordination_resources_from_statements(&region.body, resources);
6293                collect_coordination_resources_from_statements(&region.lapse_body, resources);
6294            }
6295            body::BodyStmt::Case(case_stmt) => {
6296                for branch in &case_stmt.branches {
6297                    collect_coordination_resources_from_statements(&branch.body, resources);
6298                }
6299            }
6300            body::BodyStmt::Record(_)
6301            | body::BodyStmt::Done { .. }
6302            | body::BodyStmt::Terminal(_)
6303            | body::BodyStmt::Cancel { .. }
6304            | body::BodyStmt::Milestone { .. }
6305            | body::BodyStmt::Redact { .. } => {}
6306        }
6307    }
6308}
6309
6310/// Scans a workflow's rule bodies for `emit milestone "<name>" [of <Class>]`
6311/// projections (Family C) and returns the name -> payload-class map (empty class
6312/// string for a bare milestone). The emit statement IS the declaration — the
6313/// declared milestone set is exactly what the workflow's rules can project, which
6314/// is what a parent's `after p reaches "<name>"` is validated against.
6315fn collect_milestone_declarations(items: &[Item]) -> BTreeMap<String, String> {
6316    let mut milestones = BTreeMap::new();
6317    for item in items {
6318        let Item::Rule(rule) = item else {
6319            continue;
6320        };
6321        for (name, class) in milestone_emissions_in_body(&rule.body.text) {
6322            milestones.entry(name).or_insert(class);
6323        }
6324    }
6325    milestones
6326}
6327
6328/// Validates Family C milestone statements in a rule (spec/decision-records/
6329/// discriminated-families-design.md sections 6.4 / 7.3):
6330///   - child `emit milestone "<name>" of <Class>` — `<Class>` must be a declared
6331///     class (the payload the observing parent narrows into scope);
6332///   - parent `after <p> reaches "<name>"` — `<p>` must be a workflow-invoke
6333///     binding in this rule, and `<name>` must be a milestone that the invoked
6334///     child workflow actually declares (the reject-undeclared / terminal-only
6335///     observation invariant: a parent cannot observe a state the child never
6336///     projects).
6337fn validate_milestone_statements(
6338    rule: &RuleDecl,
6339    semantic: &SemanticContext,
6340    diagnostics: &mut Vec<Diagnostic>,
6341) {
6342    // Child side: every `emit milestone "<name>" of <Class>` payload class must
6343    // exist.
6344    for (name, class) in milestone_emissions_in_body(&rule.body.text) {
6345        if !class.is_empty() && !semantic.schemas.class_exists(&class) {
6346            diagnostics.push(Diagnostic {
6347                related: Vec::new(),
6348                span: rule.body.span,
6349                message: format!(
6350                    "rule `{}` emits milestone `{name}` with unknown payload class `{class}`",
6351                    rule.name.name
6352                ),
6353                suggestion: Some(format!("declare `class {class}` before projecting it")),
6354            });
6355        }
6356    }
6357
6358    // Parent side: every `after <p> reaches "<name>"` must name a milestone the
6359    // invoked child declares.
6360    for (binding, milestone) in milestone_reaches_in_body(&rule.body.text) {
6361        let Some(workflow) = invoke_binding_workflow(rule, &binding) else {
6362            diagnostics.push(Diagnostic {
6363                related: Vec::new(),
6364                span: rule.body.span,
6365                message: format!(
6366                    "rule `{}` has `after {binding} reaches \"{milestone}\"` for `{binding}`, which is not a workflow-invoke binding in this rule",
6367                    rule.name.name
6368                ),
6369                suggestion: Some(
6370                    "`reaches` observes a child workflow milestone; bind the child with `invoke W { ... } as <binding>` first"
6371                        .to_owned(),
6372                ),
6373            });
6374            continue;
6375        };
6376        let declared = semantic
6377            .workflow_inputs
6378            .get(&workflow)
6379            .map(|surface| surface.milestones.contains_key(&milestone))
6380            .unwrap_or(false);
6381        if !declared {
6382            let available = semantic
6383                .workflow_inputs
6384                .get(&workflow)
6385                .map(|surface| {
6386                    surface
6387                        .milestones
6388                        .keys()
6389                        .map(|name| format!("\"{name}\""))
6390                        .collect::<Vec<_>>()
6391                        .join(", ")
6392                })
6393                .unwrap_or_default();
6394            let suggestion = if available.is_empty() {
6395                format!("workflow `{workflow}` declares no milestones; add `emit milestone \"{milestone}\" ...` to it")
6396            } else {
6397                format!("workflow `{workflow}` declares: {available}")
6398            };
6399            diagnostics.push(Diagnostic {
6400                related: Vec::new(),
6401                span: rule.body.span,
6402                message: format!(
6403                    "rule `{}` reaches milestone `{milestone}` that workflow `{workflow}` does not declare",
6404                    rule.name.name
6405                ),
6406                suggestion: Some(suggestion),
6407            });
6408        }
6409    }
6410}
6411
6412/// Parses `after <binding> reaches "<name>"` headers out of a rule body's text,
6413/// returning (binding, milestone-name) pairs. Mirrors `milestone_emissions_in_body`.
6414fn milestone_reaches_in_body(body: &str) -> Vec<(String, String)> {
6415    let mut out = Vec::new();
6416    for raw in body.lines() {
6417        let trimmed = raw.trim();
6418        let Some(rest) = trimmed.strip_prefix("after ") else {
6419            continue;
6420        };
6421        let mut words = rest.split_whitespace();
6422        let Some(binding) = words.next() else {
6423            continue;
6424        };
6425        if words.next() != Some("reaches") {
6426            continue;
6427        }
6428        let Some(quoted) = words.next() else {
6429            continue;
6430        };
6431        if !(quoted.starts_with('"') && quoted.ends_with('"') && quoted.len() >= 2) {
6432            continue;
6433        }
6434        out.push((binding.to_owned(), quoted.trim_matches('"').to_owned()));
6435    }
6436    out
6437}
6438
6439/// Maps an `invoke <Workflow> { ... } as <binding>` binding to the invoked
6440/// workflow name within a single rule, so a sibling `after <binding> reaches`
6441/// can find the child workflow whose milestones it observes.
6442fn invoke_binding_workflow(rule: &RuleDecl, binding: &str) -> Option<String> {
6443    for statement in workflow_invoke_statements(&rule.body.text) {
6444        let (target, _) = invoke_statement_parts(&statement)?;
6445        if let Some(as_binding) = binding_after_as(&statement) {
6446            if as_binding == binding {
6447                return Some(target.to_owned());
6448            }
6449        }
6450    }
6451    None
6452}
6453
6454/// Resolves the payload class of a child milestone for `after <binding> reaches
6455/// "<milestone>"`: follow `binding` to its invoked workflow, then look up the
6456/// milestone in that workflow's declared set. `Some("")` means the milestone is
6457/// declared but payload-less; `None` means undeclared (reject) or unresolvable.
6458fn milestone_payload_class(
6459    rule: &RuleDecl,
6460    binding: &str,
6461    milestone: &str,
6462    semantic: &SemanticContext,
6463) -> Option<String> {
6464    let workflow = invoke_binding_workflow(rule, binding)?;
6465    let surface = semantic.workflow_inputs.get(&workflow)?;
6466    surface.milestones.get(milestone).cloned()
6467}
6468
6469/// Resolves the OUTPUT-contract class of the child workflow a `succeeds`/`completes`
6470/// invoke binding observes, so `after <binding> succeeds as r` can type `r` and
6471/// check `r.<field>`. `None` (leave the binding opaque, unchanged) when: the binding
6472/// is not an invoke; the child declares zero or several outputs (which output the
6473/// child completes is not statically known); the sole output is a scalar (no
6474/// fields); or the output class is not resolvable in THIS workflow's scope. That
6475/// last guard preserves workflow-local scoping — a child's private output class is
6476/// not visible to the parent, so only a shared top-level class is typed here.
6477fn invoke_output_class(
6478    rule: &RuleDecl,
6479    binding: &str,
6480    semantic: &SemanticContext,
6481) -> Option<String> {
6482    let workflow = invoke_binding_workflow(rule, binding)?;
6483    let surface = semantic.workflow_inputs.get(&workflow)?;
6484    if surface.outputs.len() != 1 {
6485        return None;
6486    }
6487    match surface.outputs.values().next()? {
6488        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
6489            Some(name.name.clone())
6490        }
6491        _ => None,
6492    }
6493}
6494
6495/// Resolves the FAILURE-contract class of the child workflow a `fails` invoke
6496/// binding observes, so `after <binding> fails as f` can type `f` to the child's
6497/// declared failure shape (and check `f.<field>`) instead of the generic DR-0032
6498/// `TerminalFailed` base. `None` (fall back to the base) when: the binding is not
6499/// an invoke; the child declares zero or several failures (which failure the child
6500/// raised is not statically known); the sole failure is a scalar (no fields); or
6501/// the failure class is not resolvable in THIS workflow's scope. That last guard
6502/// preserves workflow-local scoping — a child's private failure class is not
6503/// visible to the parent, so only a shared top-level class is typed here; anything
6504/// else keeps the base, which every failure structurally satisfies.
6505fn invoke_failure_class(
6506    rule: &RuleDecl,
6507    binding: &str,
6508    semantic: &SemanticContext,
6509) -> Option<String> {
6510    let workflow = invoke_binding_workflow(rule, binding)?;
6511    let surface = semantic.workflow_inputs.get(&workflow)?;
6512    if surface.failures.len() != 1 {
6513        return None;
6514    }
6515    match surface.failures.values().next()? {
6516        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
6517            Some(name.name.clone())
6518        }
6519        _ => None,
6520    }
6521}
6522
6523/// Parses `emit milestone "<name>" [of <Class>]` headers out of a rule body's
6524/// text, returning (name, class) pairs (class is empty for a bare milestone).
6525/// Text-based to mirror the other body scanners (`workflow_invoke_statements`)
6526/// and stay independent of flow-vs-rule body provenance.
6527fn milestone_emissions_in_body(body: &str) -> Vec<(String, String)> {
6528    let mut out = Vec::new();
6529    for raw in body.lines() {
6530        let trimmed = raw.trim();
6531        let Some(rest) = trimmed.strip_prefix("emit milestone ") else {
6532            continue;
6533        };
6534        // The name is a quoted string literal; take the text between the first
6535        // pair of quotes.
6536        let rest = rest.trim_start();
6537        if !rest.starts_with('"') {
6538            continue;
6539        }
6540        let Some(close) = rest[1..].find('"') else {
6541            continue;
6542        };
6543        let name = rest[1..=close].to_owned();
6544        let after_name = rest[close + 2..].trim_start();
6545        let class = after_name
6546            .strip_prefix("of ")
6547            .map(|tail| {
6548                tail.trim_start()
6549                    .split(|c: char| c.is_whitespace() || c == '{')
6550                    .next()
6551                    .unwrap_or("")
6552                    .to_owned()
6553            })
6554            .unwrap_or_default();
6555        out.push((name, class));
6556    }
6557    out
6558}
6559
6560fn schema_index_for_items(items: &[Item]) -> SchemaIndex {
6561    let mut schemas = SchemaIndex::with_builtins();
6562    for item in items {
6563        schemas.insert_item(item);
6564    }
6565    schemas
6566}
6567
6568fn workflow_inputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6569    items
6570        .iter()
6571        .filter_map(|item| match item {
6572            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Input => {
6573                Some((contract.name.name.clone(), contract.ty.clone()))
6574            }
6575            _ => None,
6576        })
6577        .collect()
6578}
6579
6580fn workflow_outputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6581    items
6582        .iter()
6583        .filter_map(|item| match item {
6584            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Output => {
6585                Some((contract.name.name.clone(), contract.ty.clone()))
6586            }
6587            _ => None,
6588        })
6589        .collect()
6590}
6591
6592fn workflow_failures_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6593    items
6594        .iter()
6595        .filter_map(|item| match item {
6596            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Failure => {
6597                Some((contract.name.name.clone(), contract.ty.clone()))
6598            }
6599            _ => None,
6600        })
6601        .collect()
6602}
6603
6604impl SchemaIndex {
6605    fn with_builtins() -> Self {
6606        let mut index = Self::default();
6607        index.insert_class(
6608            "AgentTurn",
6609            [
6610                ("id", string_ty()),
6611                ("summary", string_ty()),
6612                ("agent", string_ty()),
6613                ("provider", string_ty()),
6614                ("status", string_ty()),
6615                ("run_id", string_ty()),
6616                ("effect_id", string_ty()),
6617            ],
6618        );
6619        index.insert_class(
6620            "WorkItem",
6621            [
6622                ("id", string_ty()),
6623                ("title", string_ty()),
6624                ("body", string_ty()),
6625                ("queue", string_ty()),
6626                ("status", string_ty()),
6627                ("labels", array_ty(string_ty())),
6628            ],
6629        );
6630        index.insert_class(
6631            "Evidence",
6632            [
6633                ("title", string_ty()),
6634                ("path", string_ty()),
6635                ("summary", string_ty()),
6636            ],
6637        );
6638        index.insert_class(
6639            "TerminalFailed",
6640            [
6641                ("reason", string_ty()),
6642                ("summary", string_ty()),
6643                ("effect_id", string_ty()),
6644                ("run_id", string_ty()),
6645                // DR-0032: `kind` names the failing effect — the `EffectError` base
6646                // field that lets a future runtime union dispatch and that
6647                // telemetry reads. Static narrowing does not require it.
6648                ("kind", string_ty()),
6649            ],
6650        );
6651        // DR-0032 P3 (per-kind failure extras, DQ-2 static narrowing): each
6652        // kind's failure schema extends the base with the ruled v1 extras —
6653        // exec `exit_code`; schema.coerce `error_class` + optional
6654        // `http_status`; agent.tell `error_class`. The extras are reachable
6655        // ONLY when the binding's effect kind matches (the `fails`-arm
6656        // narrowing below), so each addition is additive by construction.
6657        index.insert_class(
6658            "TerminalFailedExec",
6659            [
6660                ("reason", string_ty()),
6661                ("summary", string_ty()),
6662                ("effect_id", string_ty()),
6663                ("run_id", string_ty()),
6664                ("kind", string_ty()),
6665                // Absent when the process could not be spawned (the emitters
6666                // only set it for a run that actually started) — the docs
6667                // said so; the type now agrees.
6668                ("exit_code", optional_ty(int_ty())),
6669            ],
6670        );
6671        index.insert_class(
6672            "TerminalFailedCoerce",
6673            [
6674                ("reason", string_ty()),
6675                ("summary", string_ty()),
6676                ("effect_id", string_ty()),
6677                ("run_id", string_ty()),
6678                ("kind", string_ty()),
6679                ("error_class", string_ty()),
6680                ("http_status", optional_ty(int_ty())),
6681            ],
6682        );
6683        index.insert_class(
6684            "TerminalFailedTell",
6685            [
6686                ("reason", string_ty()),
6687                ("summary", string_ty()),
6688                ("effect_id", string_ty()),
6689                ("run_id", string_ty()),
6690                ("kind", string_ty()),
6691                ("error_class", string_ty()),
6692            ],
6693        );
6694        index.insert_class(
6695            "TerminalTimedOut",
6696            [
6697                ("summary", string_ty()),
6698                ("effect_id", string_ty()),
6699                ("run_id", string_ty()),
6700            ],
6701        );
6702        index.insert_class(
6703            "TerminalCancelled",
6704            [
6705                ("summary", string_ty()),
6706                ("effect_id", string_ty()),
6707                ("run_id", string_ty()),
6708            ],
6709        );
6710        // The `after x completes as o` envelope: the runtime delivers the
6711        // terminal UNION {tag, status, summary, effect_id, run_id} (plus the
6712        // dynamically-shaped value/error read via `case o { Completed as v =>
6713        // … }`). Typing the alias as the effect's SUCCESS schema — the old
6714        // behavior — approved field reads that were null at runtime on every
6715        // non-success terminal.
6716        index.insert_class(
6717            "TerminalOutcome",
6718            [
6719                ("tag", string_ty()),
6720                ("status", string_ty()),
6721                ("summary", string_ty()),
6722                ("effect_id", string_ty()),
6723                ("run_id", string_ty()),
6724            ],
6725        );
6726        // The generic inbound messaging envelope (spec/messaging.md): a
6727        // `when message from <channel> as msg` binding sees a `Message`, never a
6728        // domain type. Structured sub-payloads (sender_claims, interaction,
6729        // correlation) are JSON-serialized strings here; provider-specific
6730        // payloads live in bounded evidence / `raw_ref`, not as untyped facts.
6731        index.insert_class(
6732            "Message",
6733            [
6734                ("message_id", string_ty()),
6735                ("channel", string_ty()),
6736                ("provider", string_ty()),
6737                ("received_at", string_ty()),
6738                ("sender", string_ty()),
6739                ("sender_claims", string_ty()),
6740                ("thread_id", string_ty()),
6741                ("text", string_ty()),
6742                ("markdown", string_ty()),
6743                ("attachments", array_ty(string_ty())),
6744                ("interaction", string_ty()),
6745                ("raw_ref", string_ty()),
6746                ("correlation", string_ty()),
6747            ],
6748        );
6749        // The typed receipt a `send via <channel> { ... } as r` binding sees
6750        // (std.messaging; the `messaging.send` contract's output schema —
6751        // spec/std-messaging.md "MessageSendReceipt"). Every provider returns
6752        // the full shape; correlation fields the provider cannot report
6753        // (`provider_message_id`, `thread_id`, `destination`) are empty
6754        // strings, and `accepted_at` is the provider-acknowledged instant.
6755        // Failure is NOT a receipt: it settles `capability.call.failed` with
6756        // the DR-0032 EffectError base and routes to `fails as`. `status` is
6757        // `accepted` in v1 (`delivered` is reserved for providers whose report
6758        // includes it; none exists yet).
6759        index.insert_class(
6760            "MessageSendReceipt",
6761            [
6762                ("message_id", string_ty()),
6763                ("channel", string_ty()),
6764                ("provider", string_ty()),
6765                ("status", string_ty()),
6766                ("provider_message_id", string_ty()),
6767                ("thread_id", string_ty()),
6768                ("destination", string_ty()),
6769                ("accepted_at", string_ty()),
6770            ],
6771        );
6772        index
6773    }
6774
6775    fn insert_class<const N: usize>(&mut self, name: &str, fields: [(&str, TypeSyntax); N]) {
6776        self.classes.insert(
6777            name.to_owned(),
6778            fields
6779                .into_iter()
6780                .map(|(field, ty)| (field.to_owned(), ty))
6781                .collect(),
6782        );
6783    }
6784
6785    fn insert_item(&mut self, item: &Item) {
6786        match item {
6787            Item::Enum(enum_decl) => {
6788                self.enums.insert(
6789                    enum_decl.name.name.clone(),
6790                    enum_decl
6791                        .variants
6792                        .iter()
6793                        .map(|variant| variant.name.name.clone())
6794                        .collect(),
6795                );
6796                // Data-carrying variants are visible as generated
6797                // `<Enum>.<Variant>` classes (spec/sum-types.md), so case
6798                // bindings type-check field access against them.
6799                for variant in &enum_decl.variants {
6800                    if variant.fields.is_empty() {
6801                        continue;
6802                    }
6803                    let mut fields = BTreeMap::new();
6804                    fields.insert(
6805                        "variant".to_owned(),
6806                        TypeSyntax::LiteralString {
6807                            value: variant.name.name.clone(),
6808                            span: variant.name.span,
6809                        },
6810                    );
6811                    for field in &variant.fields {
6812                        fields.insert(field.name.name.clone(), field.ty.clone());
6813                    }
6814                    self.classes.insert(
6815                        format!("{}.{}", enum_decl.name.name, variant.name.name),
6816                        fields,
6817                    );
6818                }
6819            }
6820            Item::Class(class_decl) => {
6821                self.classes.insert(
6822                    class_decl.name.name.clone(),
6823                    class_decl
6824                        .fields
6825                        .iter()
6826                        .map(|field| (field.name.name.clone(), field.ty.clone()))
6827                        .collect(),
6828                );
6829                self.insert_presence(&class_decl.name.name, &class_decl.fields);
6830            }
6831            Item::Event(event) => {
6832                self.events.insert(event.name.clone());
6833                // The payload schema is indexed under the dotted signal name,
6834                // unreachable from user class declarations, so bare `when
6835                // <signal> as x` bindings type-check field access.
6836                self.classes.insert(
6837                    event.name.clone(),
6838                    event
6839                        .fields
6840                        .iter()
6841                        .map(|field| (field.name.name.clone(), field.ty.clone()))
6842                        .collect(),
6843                );
6844                self.insert_presence(&event.name, &event.fields);
6845            }
6846            _ => {}
6847        }
6848    }
6849
6850    /// Record Family B presence conditions for a schema's fields (if any).
6851    fn insert_presence(&mut self, schema: &str, fields: &[ClassField]) {
6852        let conditions: BTreeMap<String, (String, String)> = fields
6853            .iter()
6854            .filter_map(|field| {
6855                field
6856                    .presence_condition
6857                    .clone()
6858                    .map(|condition| (field.name.name.clone(), condition))
6859            })
6860            .collect();
6861        if !conditions.is_empty() {
6862            self.presence.insert(schema.to_owned(), conditions);
6863        }
6864    }
6865
6866    /// The presence condition `(discriminant, literal)` for a schema field, if any.
6867    fn field_presence(&self, schema: &str, field: &str) -> Option<&(String, String)> {
6868        self.presence
6869            .get(schema)
6870            .and_then(|fields| fields.get(field))
6871    }
6872
6873    fn merge(&mut self, other: SchemaIndex) {
6874        self.classes.extend(other.classes);
6875        self.enums.extend(other.enums);
6876        self.presence.extend(other.presence);
6877    }
6878
6879    fn class_exists(&self, name: &str) -> bool {
6880        self.classes.contains_key(name)
6881    }
6882
6883    fn resolve_field_path(&self, root_schema: &str, path: &[String]) -> Result<TypeSyntax, String> {
6884        // Dotted runtime fact names (general `when fact <name>` matches) are
6885        // untyped — unless a declared `event` (or generated `<Enum>.<Variant>`
6886        // class) indexes a payload schema under the dotted name, in which
6887        // case field paths are statically validated against it.
6888        if root_schema.contains('.') && !self.classes.contains_key(root_schema) {
6889            return Ok(TypeSyntax::Ref {
6890                name: Ident {
6891                    name: root_schema.to_owned(),
6892                    span: zero_span(),
6893                },
6894            });
6895        }
6896        let mut schema = root_schema.to_owned();
6897        let mut current = TypeSyntax::Ref {
6898            name: Ident {
6899                name: schema.clone(),
6900                span: zero_span(),
6901            },
6902        };
6903
6904        for field in path {
6905            let Some(fields) = self.classes.get(&schema) else {
6906                return Err(format!("schema `{schema}` has no declared fields"));
6907            };
6908            let Some(field_ty) = fields.get(field) else {
6909                return Err(format!("schema `{schema}` has no field `{field}`"));
6910            };
6911
6912            current = field_ty.clone();
6913            match schema_name_for_path(&current) {
6914                Some(next_schema) => schema = next_schema,
6915                None if field != path.last().expect("path is non-empty") => {
6916                    return Err(format!("field `{field}` is not a schema value"));
6917                }
6918                None => {}
6919            }
6920        }
6921
6922        Ok(current)
6923    }
6924}
6925
6926fn zero_span() -> SourceSpan {
6927    SourceSpan { start: 0, end: 0 }
6928}
6929
6930fn string_ty() -> TypeSyntax {
6931    TypeSyntax::Primitive {
6932        name: "string".to_owned(),
6933        span: zero_span(),
6934    }
6935}
6936
6937fn int_ty() -> TypeSyntax {
6938    TypeSyntax::Primitive {
6939        name: "int".to_owned(),
6940        span: zero_span(),
6941    }
6942}
6943
6944fn optional_ty(inner: TypeSyntax) -> TypeSyntax {
6945    TypeSyntax::Optional {
6946        inner: Box::new(inner),
6947        span: zero_span(),
6948    }
6949}
6950
6951fn array_ty(inner: TypeSyntax) -> TypeSyntax {
6952    TypeSyntax::Array {
6953        inner: Box::new(inner),
6954        span: zero_span(),
6955    }
6956}
6957
6958fn schema_name_for_path(ty: &TypeSyntax) -> Option<String> {
6959    match ty {
6960        TypeSyntax::Ref { name } => Some(name.name.clone()),
6961        TypeSyntax::Optional { inner, .. } => schema_name_for_path(inner),
6962        _ => None,
6963    }
6964}
6965
6966fn lower_include(include: IncludeDecl, ir: &mut IrProgram) {
6967    ir.includes.push(IrInclude {
6968        path: include.path.value,
6969        source_hash: None,
6970    });
6971}
6972
6973fn lower_workflow_contract(
6974    contract: WorkflowContractDecl,
6975    ir: &mut IrProgram,
6976    schema_names: &BTreeSet<String>,
6977    agent_names: &BTreeSet<String>,
6978    diagnostics: &mut Vec<Diagnostic>,
6979) {
6980    validate_type_refs(&contract.ty, schema_names, agent_names, diagnostics);
6981    let kind = match contract.kind {
6982        WorkflowContractKind::Input => IrWorkflowContractKind::Input,
6983        WorkflowContractKind::Output => IrWorkflowContractKind::Output,
6984        WorkflowContractKind::Failure => IrWorkflowContractKind::Failure,
6985    };
6986    ir.workflow_contracts.push(IrWorkflowContract {
6987        kind,
6988        name: contract.name.name,
6989        ty: lower_type(contract.ty),
6990        span: contract.span,
6991    });
6992}
6993
6994/// The complete standard-package universe (the 13 std packages of the
6995/// standard-package campaign). `use std.<name>` outside this list is a check
6996/// error: std resolution is a built-in registry, so an unknown name can never
6997/// resolve later — a typo'd `use std.coercon` would otherwise silently import
6998/// nothing (and downstream missing-import bite is advisory only).
6999pub const STD_PACKAGE_IDS: &[&str] = &[
7000    "std.agent",
7001    "std.coercion",
7002    "std.coord",
7003    "std.files",
7004    "std.human",
7005    "std.ingress",
7006    "std.memory",
7007    "std.messaging",
7008    "std.script",
7009    "std.telemetry",
7010    "std.time",
7011    "std.tracker",
7012    "std.workflow",
7013];
7014
7015fn lower_use(use_decl: UseDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7016    // A std import is valid when its FAMILY (`std.<name>`) is a known package;
7017    // deeper segments select provider sub-packages (`std.agent.codex`,
7018    // `std.messaging.local`), whose availability the package layer owns.
7019    let std_family_known = |value: &str| {
7020        STD_PACKAGE_IDS.iter().any(|id| {
7021            value == *id
7022                || value
7023                    .strip_prefix(id)
7024                    .is_some_and(|rest| rest.starts_with('.'))
7025        })
7026    };
7027    if use_decl.name.value.starts_with("std.") && !std_family_known(&use_decl.name.value) {
7028        diagnostics.push(Diagnostic {
7029            related: Vec::new(),
7030            span: use_decl.name.span,
7031            message: format!("unknown standard package `{}`", use_decl.name.value),
7032            suggestion: Some(format!(
7033                "standard packages are {}",
7034                STD_PACKAGE_IDS.join(", ")
7035            )),
7036        });
7037    }
7038    let kind = IrUseKind::Package;
7039    ir.uses.push(IrUse {
7040        kind,
7041        name: use_decl.name.value,
7042    });
7043}
7044
7045fn lower_tracker(tracker: TrackerDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7046    if tracker.provider.name != "builtin" {
7047        diagnostics.push(Diagnostic {
7048            related: Vec::new(),
7049            span: tracker.provider.span,
7050            message: format!(
7051                "tracker `{}` uses unavailable provider `{}`",
7052                tracker.name.name, tracker.provider.name
7053            ),
7054            suggestion: Some(
7055                "`builtin` is the available provider; github/linear/jira are deferred bindings"
7056                    .to_owned(),
7057            ),
7058        });
7059    }
7060    ir.trackers.push(IrTracker {
7061        name: tracker.name.name,
7062        provider: tracker.provider.name,
7063        span: tracker.span,
7064    });
7065}
7066
7067fn lower_channel(channel: ChannelDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7068    // Two channels with the same name would make `send via <name>` / `when
7069    // message from <name>` ambiguous (a channel name is a routing identity).
7070    if let Some(existing) = ir
7071        .channels
7072        .iter()
7073        .find(|other| other.name == channel.name.name)
7074    {
7075        diagnostics.push(
7076            Diagnostic {
7077                related: Vec::new(),
7078                span: channel.name.span,
7079                message: format!("channel `{}` is declared more than once", channel.name.name),
7080                suggestion: Some("give each channel a unique name".to_owned()),
7081            }
7082            .with_related(existing.span, "first declared here"),
7083        );
7084        return;
7085    }
7086    // Conditioned check 5 (spec/std-messaging.md "Static checks"): a channel
7087    // `provider` identifier must resolve to a provider capability report —
7088    // short name or full binding provider id. Unknown identifiers would lower
7089    // to a `messaging.send` no binding can route (or an inbound observation no
7090    // provider delivers), so they are rejected at compile time.
7091    if channel_provider_report(&channel.provider.name).is_none() {
7092        let known = CHANNEL_PROVIDER_REPORTS
7093            .iter()
7094            .map(|report| report.short_name)
7095            .collect::<Vec<_>>()
7096            .join(", ");
7097        diagnostics.push(Diagnostic {
7098            related: Vec::new(),
7099            span: channel.provider.span,
7100            message: format!(
7101                "channel `{}` names unknown messaging provider `{}`",
7102                channel.name.name, channel.provider.name
7103            ),
7104            suggestion: Some(format!("declare one of the v1 providers: {known}")),
7105        });
7106        // Fall through: the channel still lowers so `send via`/`when message
7107        // from` sites do not cascade an unknown-channel error on top.
7108    }
7109    ir.channels.push(IrChannel {
7110        name: channel.name.name,
7111        provider: channel.provider.name,
7112        workspace: channel.workspace.map(|workspace| workspace.name),
7113        destination: channel.destination.map(|destination| destination.value),
7114        span: channel.span,
7115    });
7116}
7117
7118/// std.messaging v1 provider capability report (spec/std-messaging.md
7119/// "Capability reports + conditioned checks"). Reports are DATA, never code
7120/// (M8): these compiled constants are mirrored by the embedded std.messaging
7121/// manifest's `bindings[].config.report` rows, and the conditioned static
7122/// checks below admit syntax only when the selected provider's report
7123/// supports it. Report axes are messaging.md "Provider Capability Report"
7124/// narrowed for v1: `delivery_receipts` ⊆ {accepted, failed}; `identity` ⊆
7125/// {anonymous, claimed_actor} (no verified_actor provider exists, so any
7126/// check demanding verified identity fails closed); `content` ⊆
7127/// {text, markdown}.
7128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7129pub struct ChannelProviderReport {
7130    /// The short channel-declaration identifier (`provider <short_name>`).
7131    pub short_name: &'static str,
7132    /// The binding-row provider id the short name resolves to.
7133    pub provider_id: &'static str,
7134    /// `outbound_only` | `inbound_only` | `bidirectional`.
7135    pub direction: &'static str,
7136    /// `anonymous` | `claimed_actor`.
7137    pub identity: &'static str,
7138    /// Interaction families the provider can deliver callbacks for.
7139    pub interactions: &'static [&'static str],
7140    /// Payload content kinds the provider accepts.
7141    pub content: &'static [&'static str],
7142    /// Receipt statuses the provider can report.
7143    pub delivery_receipts: &'static [&'static str],
7144}
7145
7146/// The four v1 std.messaging providers (spec/std-messaging.md "Providers").
7147pub const CHANNEL_PROVIDER_REPORTS: &[ChannelProviderReport] = &[
7148    ChannelProviderReport {
7149        short_name: "fixture",
7150        provider_id: "fixture",
7151        direction: "bidirectional",
7152        identity: "claimed_actor",
7153        interactions: &["buttons", "reactions"],
7154        content: &["text", "markdown"],
7155        delivery_receipts: &["accepted", "failed"],
7156    },
7157    ChannelProviderReport {
7158        short_name: "local",
7159        provider_id: "std.messaging.local",
7160        direction: "bidirectional",
7161        identity: "claimed_actor",
7162        interactions: &["buttons", "reactions"],
7163        content: &["text", "markdown"],
7164        delivery_receipts: &["accepted", "failed"],
7165    },
7166    ChannelProviderReport {
7167        short_name: "desktop",
7168        provider_id: "std.messaging.desktop",
7169        direction: "outbound_only",
7170        identity: "anonymous",
7171        interactions: &[],
7172        content: &["text"],
7173        delivery_receipts: &["accepted", "failed"],
7174    },
7175    ChannelProviderReport {
7176        short_name: "stdio",
7177        provider_id: "std.messaging.stdio",
7178        direction: "bidirectional",
7179        identity: "claimed_actor",
7180        interactions: &["buttons"],
7181        content: &["text", "markdown"],
7182        delivery_receipts: &["accepted", "failed"],
7183    },
7184];
7185
7186/// Resolve a channel's declared `provider <p>` identifier against the v1
7187/// provider reports: the short name (`local`) or the full binding provider id
7188/// (`std.messaging.local`) both resolve. `None` = unknown identifier, a check
7189/// error (spec/std-messaging.md open question 2 resolved: short names resolved
7190/// against contributed provider kinds, unknown = check error).
7191pub fn channel_provider_report(provider: &str) -> Option<&'static ChannelProviderReport> {
7192    CHANNEL_PROVIDER_REPORTS
7193        .iter()
7194        .find(|report| report.short_name == provider || report.provider_id == provider)
7195}
7196
7197/// The built-in resource gauges: deterministic observables already in the
7198/// effect ledger, present without declaration (improve design note §3).
7199/// `std.cache_hit` is the provider prompt-cache hit rate (cache-read tokens /
7200/// total input-side tokens) — present only when the provider reports cache
7201/// usage (spec/inference-cache-note.md G2).
7202pub const BUILTIN_GAUGES: &[&str] = &["std.spend", "std.latency", "std.tokens", "std.cache_hit"];
7203
7204/// The v1 std.files store providers (spec/std-files.md "Providers"): `local`
7205/// is the FileStore host-projection seam (native + DO) and the default when a
7206/// `file store` declares no `provider` clause. Non-filesystem providers
7207/// (S3/GitHub/Drive) are deferred with cause; an unknown identifier is a
7208/// check error at the declaration.
7209pub const FILE_STORE_PROVIDERS: &[&str] = &["local"];
7210
7211fn lower_gauge(gauge: GaugeDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7212    if let Some(existing) = ir.gauges.iter().find(|other| other.name == gauge.name.name) {
7213        diagnostics.push(
7214            Diagnostic {
7215                related: Vec::new(),
7216                span: gauge.name.span,
7217                message: format!("gauge `{}` is declared more than once", gauge.name.name),
7218                suggestion: Some("give each gauge a unique name".to_owned()),
7219            }
7220            .with_related(existing.span, "first declared here"),
7221        );
7222        return;
7223    }
7224    let (judge_kind, judge_target, judge_args) = match &gauge.judge {
7225        GaugeJudge::Coerce(target, args) => ("coerce", target.name.clone(), args.clone()),
7226        GaugeJudge::Prompt(template) => ("prompt", template.value.clone(), Vec::new()),
7227        GaugeJudge::Exec(command) => ("exec", command.value.clone(), Vec::new()),
7228        GaugeJudge::Labels(source) => ("labels", source.value.clone(), Vec::new()),
7229    };
7230    let expect = gauge.expect.as_ref().map(|bar| IrGaugeBar {
7231        form: match &bar.subject {
7232            GaugeBarSubject::Chance { .. } => "chance".to_owned(),
7233            GaugeBarSubject::Stat { .. } => "stat".to_owned(),
7234        },
7235        subject: match &bar.subject {
7236            GaugeBarSubject::Chance { field } => field.name.clone(),
7237            GaugeBarSubject::Stat { stat } => stat.name.clone(),
7238        },
7239        op: if bar.at_least { ">=" } else { "<=" }.to_owned(),
7240        threshold: bar.threshold.clone(),
7241    });
7242    ir.gauges.push(IrGauge {
7243        name: gauge.name.name,
7244        site: gauge.site,
7245        judge_kind: judge_kind.to_owned(),
7246        judge_target,
7247        judge_args,
7248        expect,
7249        inputs: gauge.inputs.into_iter().map(|input| input.name).collect(),
7250        span: gauge.span,
7251    });
7252}
7253
7254fn lower_mark(mark: MarkDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7255    if let Some(existing) = ir.marks.iter().find(|other| other.name == mark.name.value) {
7256        diagnostics.push(
7257            Diagnostic {
7258                related: Vec::new(),
7259                span: mark.name.span,
7260                message: format!("mark `{}` is declared more than once", mark.name.value),
7261                suggestion: Some("give each mark a unique name".to_owned()),
7262            }
7263            .with_related(existing.span, "first declared here"),
7264        );
7265        return;
7266    }
7267    ir.marks.push(IrMark {
7268        name: mark.name.value,
7269        site: mark.site,
7270        span: mark.span,
7271    });
7272}
7273
7274fn lower_campaign(campaign: CampaignDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7275    if let Some(existing) = ir
7276        .campaigns
7277        .iter()
7278        .find(|other| other.name == campaign.name.name)
7279    {
7280        diagnostics.push(
7281            Diagnostic {
7282                related: Vec::new(),
7283                span: campaign.name.span,
7284                message: format!(
7285                    "campaign `{}` is declared more than once",
7286                    campaign.name.name
7287                ),
7288                suggestion: Some("give each campaign a unique name".to_owned()),
7289            }
7290            .with_related(existing.span, "first declared here"),
7291        );
7292        return;
7293    }
7294    ir.campaigns.push(IrCampaign {
7295        name: campaign.name.name,
7296        ascend: campaign
7297            .ascend
7298            .into_iter()
7299            .map(|gauge| gauge.name)
7300            .collect(),
7301        reach: campaign
7302            .reach
7303            .into_iter()
7304            .map(|reach| IrCampaignReach {
7305                gauge: reach.gauge.name,
7306                op: if reach.at_least { ">=" } else { "<=" }.to_owned(),
7307                threshold: reach.threshold,
7308                unit: reach.unit,
7309            })
7310            .collect(),
7311        guard: campaign
7312            .guard
7313            .into_iter()
7314            .map(|guard| IrCampaignGuard {
7315                gauge: guard.gauge.name,
7316                band_percent: guard.band_percent,
7317            })
7318            .collect(),
7319        sacrifice: campaign
7320            .sacrifice
7321            .into_iter()
7322            .map(|gauge| gauge.name)
7323            .collect(),
7324        proposer_redacted: campaign.proposer_redacted,
7325        span: campaign.span,
7326    });
7327}
7328
7329/// Cross-reference validation for the improve surface, run after the item
7330/// loop so declaration order never matters: judge `coerce` targets must
7331/// resolve, derived-gauge inputs and campaign gauge references must name a
7332/// declared gauge or a built-in resource gauge, and a campaign's partition
7333/// must be disjoint (a gauge cannot be both ascended and sacrificed).
7334fn validate_improve_declarations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7335    // A mark rides a committing site: its `after` target must be a rule
7336    // (flow segments have lowered to `flow.<name>.segN` rules by now).
7337    for mark in &ir.marks {
7338        if !ir.rules.iter().any(|rule| rule.name == mark.site) {
7339            diagnostics.push(Diagnostic {
7340                related: Vec::new(),
7341                span: mark.span,
7342                message: format!("mark `{}` rides unknown site `{}`", mark.name, mark.site),
7343                suggestion: Some(format!(
7344                    "declared rules: {}",
7345                    ir.rules
7346                        .iter()
7347                        .map(|rule| rule.name.as_str())
7348                        .collect::<Vec<_>>()
7349                        .join(", ")
7350                )),
7351            });
7352        }
7353    }
7354    let gauge_names: BTreeSet<&str> = ir.gauges.iter().map(|gauge| gauge.name.as_str()).collect();
7355    let resolves = |name: &str| gauge_names.contains(name) || BUILTIN_GAUGES.contains(&name);
7356    let unknown = |name: &str, span: SourceSpan, diagnostics: &mut Vec<Diagnostic>| {
7357        diagnostics.push(Diagnostic {
7358            related: Vec::new(),
7359            span,
7360            message: format!("unknown gauge `{name}`"),
7361            suggestion: Some(format!(
7362                "declare `gauge {name} {{ ... }}` or use a built-in gauge ({})",
7363                BUILTIN_GAUGES.join(", ")
7364            )),
7365        });
7366    };
7367    for gauge in &ir.gauges {
7368        if gauge.judge_kind == "coerce" {
7369            match ir
7370                .coerces
7371                .iter()
7372                .find(|coerce| coerce.name == gauge.judge_target)
7373            {
7374                None => {
7375                    diagnostics.push(Diagnostic {
7376                        related: Vec::new(),
7377                        span: gauge.span,
7378                        message: format!(
7379                            "gauge `{}` judges via undeclared coerce `{}`",
7380                            gauge.name, gauge.judge_target
7381                        ),
7382                        suggestion: Some("declare the coerce this gauge judges with".to_owned()),
7383                    });
7384                }
7385                // Explicit-argument binding (settled 2026-07-14): the
7386                // judge's data diet is written down, never inferred. The
7387                // single reserved `record` passes the whole judge-input
7388                // record to a one-parameter coerce; otherwise each path
7389                // (`input.…` / `facts.<Class>.<field>`) feeds the
7390                // parameter at its position, arity-checked here so a
7391                // drifted signature is a check error, not a silently
7392                // rebound judge.
7393                Some(coerce) if !gauge.judge_args.is_empty() => {
7394                    if gauge.judge_args.len() == 1 && gauge.judge_args[0] == "record" {
7395                        if coerce.params.len() != 1 {
7396                            diagnostics.push(Diagnostic {
7397                                related: Vec::new(),
7398                                span: gauge.span,
7399                                message: format!(
7400                                    "gauge `{}`: the reserved `(record)` form needs a \
7401                                     single-parameter coerce; `{}` takes {}",
7402                                    gauge.name,
7403                                    gauge.judge_target,
7404                                    coerce.params.len()
7405                                ),
7406                                suggestion: Some(
7407                                    "give the coerce one record-shaped parameter, or bind each \
7408                                     parameter to an explicit path"
7409                                        .to_owned(),
7410                                ),
7411                            });
7412                        }
7413                    } else {
7414                        for arg in &gauge.judge_args {
7415                            let head = arg.split('.').next().unwrap_or_default();
7416                            let valid = match head {
7417                                "record" => false, // reserved: only alone
7418                                "input" => true,
7419                                "facts" => arg.splitn(3, '.').count() == 3,
7420                                _ => false,
7421                            };
7422                            if !valid {
7423                                diagnostics.push(Diagnostic {
7424                                    related: Vec::new(),
7425                                    span: gauge.span,
7426                                    message: format!(
7427                                        "gauge `{}`: judge argument `{arg}` is not a record \
7428                                         path",
7429                                        gauge.name
7430                                    ),
7431                                    suggestion: Some(
7432                                        "arguments are `input.<path>`, \
7433                                         `facts.<Class>.<field...>`, or the single reserved \
7434                                         `record`"
7435                                            .to_owned(),
7436                                    ),
7437                                });
7438                            }
7439                        }
7440                        if gauge.judge_args.len() != coerce.params.len() {
7441                            diagnostics.push(Diagnostic {
7442                                related: Vec::new(),
7443                                span: gauge.span,
7444                                message: format!(
7445                                    "gauge `{}`: judge passes {} argument{} but coerce `{}` \
7446                                     takes {}",
7447                                    gauge.name,
7448                                    gauge.judge_args.len(),
7449                                    if gauge.judge_args.len() == 1 { "" } else { "s" },
7450                                    gauge.judge_target,
7451                                    coerce.params.len()
7452                                ),
7453                                suggestion: Some(
7454                                    "bind one path per coerce parameter, in order".to_owned(),
7455                                ),
7456                            });
7457                        }
7458                    }
7459                }
7460                Some(_) => {}
7461            }
7462        }
7463        if !gauge.inputs.is_empty() && gauge.judge_kind != "exec" {
7464            diagnostics.push(Diagnostic {
7465                related: Vec::new(),
7466                span: gauge.span,
7467                message: format!(
7468                    "derived gauge `{}` must judge via exec (its judge receives the input score vector)",
7469                    gauge.name
7470                ),
7471                suggestion: Some("use `judge via exec \"<validator>\"`".to_owned()),
7472            });
7473        }
7474        for input in &gauge.inputs {
7475            if input == &gauge.name {
7476                diagnostics.push(Diagnostic {
7477                    related: Vec::new(),
7478                    span: gauge.span,
7479                    message: format!("derived gauge `{}` cannot input itself", gauge.name),
7480                    suggestion: None,
7481                });
7482            } else if !resolves(input) {
7483                unknown(input, gauge.span, diagnostics);
7484            }
7485        }
7486    }
7487    for campaign in &ir.campaigns {
7488        let mut named: Vec<(&str, &'static str)> = Vec::new();
7489        for name in &campaign.ascend {
7490            named.push((name, "ascend"));
7491        }
7492        for reach in &campaign.reach {
7493            named.push((&reach.gauge, "reach"));
7494        }
7495        for guard in &campaign.guard {
7496            named.push((&guard.gauge, "guard"));
7497        }
7498        for name in &campaign.sacrifice {
7499            named.push((name, "sacrifice"));
7500        }
7501        let mut seen: BTreeMap<&str, &'static str> = BTreeMap::new();
7502        for (name, role) in named {
7503            if !resolves(name) {
7504                unknown(name, campaign.span, diagnostics);
7505            }
7506            if let Some(previous) = seen.insert(name, role) {
7507                let message = if previous == role {
7508                    format!(
7509                        "campaign `{}` names gauge `{name}` twice in {role}",
7510                        campaign.name
7511                    )
7512                } else {
7513                    format!(
7514                        "campaign `{}` names gauge `{name}` as both {previous} and {role}",
7515                        campaign.name
7516                    )
7517                };
7518                diagnostics.push(Diagnostic {
7519                    related: Vec::new(),
7520                    span: campaign.span,
7521                    message,
7522                    suggestion: Some("name each gauge once, in at most one clause".to_owned()),
7523                });
7524            }
7525        }
7526    }
7527}
7528
7529fn lower_harness(harness: HarnessDecl, ir: &mut IrProgram, _diagnostics: &mut [Diagnostic]) {
7530    // Provider/harness kind validity is registry-derived (spec/std-agent.md
7531    // "Open provider registry", DR-0009): the known kind set is contributed by
7532    // package manifests (embedded std + locked third-party), which the parser
7533    // cannot see. The CLI check validates kinds against that registry with the
7534    // M5 missing-package/missing-import diagnostic split; the parser accepts
7535    // the kind structurally.
7536    ir.harnesses.push(IrHarness {
7537        name: harness.name.name,
7538        kind: harness.kind.name,
7539        span: harness.span,
7540    });
7541}
7542
7543/// The harness class (DR-0034). `Managed` = WhippleScript is the agent runtime
7544/// (owned; hermetic context, full provenance, reproducible). `Delegated` = a foreign
7545/// runtime WhippleScript invokes, which assembles its own context. The guarantee is
7546/// two-valued, so the class is too.
7547#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7548pub enum HarnessClass {
7549    Managed,
7550    Delegated,
7551}
7552
7553impl HarnessClass {
7554    pub fn as_str(self) -> &'static str {
7555        match self {
7556            HarnessClass::Managed => "managed",
7557            HarnessClass::Delegated => "delegated",
7558        }
7559    }
7560}
7561
7562/// Classify a harness kind (DR-0034 Decision 6). Total over the supported kinds:
7563/// `owned` and the credential-free `fixture` model client are Managed; every other
7564/// kind (codex/claude sidecars, the `native-fixture` delegated adapter, `command`)
7565/// is Delegated. An unrecognized kind — validated registry-side by the CLI
7566/// (spec/std-agent.md "Open provider registry") — defaults to Delegated, never
7567/// granting the Managed guarantee to something unknown.
7568pub fn harness_class(kind: &str) -> HarnessClass {
7569    match kind {
7570        "owned" | "fixture" => HarnessClass::Managed,
7571        _ => HarnessClass::Delegated,
7572    }
7573}
7574
7575fn lower_agent(
7576    agent: AgentDecl,
7577    ir: &mut IrProgram,
7578    harness_kinds: &BTreeMap<String, String>,
7579    diagnostics: &mut Vec<Diagnostic>,
7580) {
7581    let mut lowered = IrAgent {
7582        name: agent.name.name.clone(),
7583        harness: agent.harness.as_ref().map(|harness| harness.name.clone()),
7584        provider: None,
7585        profile: None,
7586        capacity: None,
7587        skills: Vec::new(),
7588        capabilities: Vec::new(),
7589        requires: Vec::new(),
7590        tools: Vec::new(),
7591        compaction: None,
7592        thread: None,
7593        settings: None,
7594        // Filled after the field loop, once provider/harness are resolved.
7595        harness_class: HarnessClass::Managed,
7596    };
7597
7598    if let Some(harness) = &agent.harness {
7599        if !harness_kinds.contains_key(&harness.name) {
7600            diagnostics.push(Diagnostic {
7601                related: Vec::new(),
7602                span: harness.span,
7603                message: format!(
7604                    "agent `{}` uses unknown harness `{}`",
7605                    agent.name.name, harness.name
7606                ),
7607                suggestion: Some(format!(
7608                    "declare `harness {}: fixture` before using it",
7609                    harness.name
7610                )),
7611            });
7612        }
7613    }
7614
7615    // `delegated to <provider>` (DR-0034 Decision 2): the surface spelling of a
7616    // Delegated agent. It names the provider kind directly, so it must be a kind
7617    // that actually classifies Delegated — `delegated to owned` is a contradiction.
7618    // Kind existence is registry-validated by the CLI (spec/std-agent.md "Open
7619    // provider registry"); only the class contradiction is structural.
7620    if let Some(delegate) = &agent.delegated_to {
7621        if harness_class(&delegate.name) != HarnessClass::Delegated {
7622            diagnostics.push(Diagnostic {
7623                related: Vec::new(),
7624                span: delegate.span,
7625                message: format!(
7626                    "agent `{}` delegates to `{}`, which is a managed kind",
7627                    agent.name.name, delegate.name
7628                ),
7629                suggestion: Some(
7630                    "a plain `agent name { ... }` is managed by default; `delegated to` names a foreign runtime"
7631                        .to_owned(),
7632                ),
7633            });
7634        }
7635    }
7636
7637    // Knob spans held for the class-partition check below (DR-0034 Decision 3) —
7638    // the class is only resolved after the field loop.
7639    let mut compaction_span: Option<SourceSpan> = None;
7640    let mut thread_span: Option<SourceSpan> = None;
7641    let mut settings_span: Option<SourceSpan> = None;
7642
7643    for field in agent.fields {
7644        match field {
7645            AgentField::Provider(provider) => {
7646                if lowered.provider.is_some() {
7647                    diagnostics.push(Diagnostic {
7648                        related: Vec::new(),
7649                        span: provider.span,
7650                        message: format!(
7651                            "agent `{}` declares provider more than once",
7652                            agent.name.name
7653                        ),
7654                        suggestion: Some(
7655                            "keep exactly one `provider` field in the agent block".to_owned(),
7656                        ),
7657                    });
7658                }
7659                if agent.harness.is_some() {
7660                    diagnostics.push(Diagnostic { related: Vec::new(),
7661                        span: provider.span,
7662                        message: format!(
7663                            "agent `{}` declares both `using` harness and direct provider `{}`",
7664                            agent.name.name, provider.name
7665                        ),
7666                        suggestion: Some(
7667                            "use either `agent name using harness { ... }` or `provider codex`, not both"
7668                                .to_owned(),
7669                        ),
7670                    });
7671                }
7672                if agent.delegated_to.is_some() {
7673                    diagnostics.push(Diagnostic { related: Vec::new(),
7674                        span: provider.span,
7675                        message: format!(
7676                            "agent `{}` declares both `delegated to` and direct provider `{}`",
7677                            agent.name.name, provider.name
7678                        ),
7679                        suggestion: Some(
7680                            "use either `agent name delegated to <provider> { ... }` or a `provider` field, not both"
7681                                .to_owned(),
7682                        ),
7683                    });
7684                }
7685                // Provider-kind existence is registry-validated by the CLI
7686                // (spec/std-agent.md "Open provider registry"): the known kind
7687                // set is contributed by package manifests, not compiled in here.
7688                lowered.provider = Some(provider.name);
7689            }
7690            AgentField::Profile(profile) => lowered.profile = Some(profile.value),
7691            AgentField::Capacity(capacity, span) => {
7692                if capacity == 0 {
7693                    diagnostics.push(Diagnostic {
7694                        related: Vec::new(),
7695                        span,
7696                        message: format!(
7697                            "agent `{}` capacity must be greater than zero",
7698                            agent.name.name
7699                        ),
7700                        suggestion: Some("use `capacity 1` or a larger integer".to_owned()),
7701                    });
7702                }
7703                lowered.capacity = Some(capacity);
7704            }
7705            AgentField::Skills(skills, _) => {
7706                let mut seen = BTreeSet::new();
7707                for skill in skills {
7708                    if !seen.insert(skill.value.clone()) {
7709                        diagnostics.push(Diagnostic {
7710                            related: Vec::new(),
7711                            span: skill.span,
7712                            message: format!(
7713                                "agent `{}` attaches skill `{}` more than once",
7714                                agent.name.name, skill.value
7715                            ),
7716                            suggestion: Some("remove the duplicate skill entry".to_owned()),
7717                        });
7718                    }
7719                    lowered.skills.push(skill.value);
7720                }
7721            }
7722            AgentField::Capabilities(capabilities, _) => {
7723                let mut seen = BTreeSet::new();
7724                for capability in capabilities {
7725                    if !seen.insert(capability.value.clone()) {
7726                        diagnostics.push(Diagnostic {
7727                            related: Vec::new(),
7728                            span: capability.span,
7729                            message: format!(
7730                                "agent `{}` declares capability `{}` more than once",
7731                                agent.name.name, capability.value
7732                            ),
7733                            suggestion: Some("remove the duplicate capability entry".to_owned()),
7734                        });
7735                    }
7736                    lowered.capabilities.push(capability.value);
7737                }
7738            }
7739            AgentField::Requires(classes, _) => {
7740                let mut seen = BTreeSet::new();
7741                for class in classes {
7742                    // Taxonomy membership (DR-0015; spec/std-agent.md slice 6):
7743                    // a required class must be a member of the shared
7744                    // feature-class taxonomy. Validation against the selected
7745                    // provider's feature REPORT is the CLI's registry-side
7746                    // check — this is the vocabulary gate.
7747                    if !whipplescript_core::AGENT_FEATURE_CLASS_TAXONOMY
7748                        .contains(&class.name.as_str())
7749                    {
7750                        diagnostics.push(Diagnostic {
7751                            related: Vec::new(),
7752                            span: class.span,
7753                            message: format!(
7754                                "agent `{}` requires unknown feature class `{}`",
7755                                agent.name.name, class.name
7756                            ),
7757                            suggestion: Some(format!(
7758                                "feature classes come from the DR-0015 taxonomy: {}",
7759                                whipplescript_core::AGENT_FEATURE_CLASS_TAXONOMY.join(", ")
7760                            )),
7761                        });
7762                    }
7763                    if !seen.insert(class.name.clone()) {
7764                        diagnostics.push(Diagnostic {
7765                            related: Vec::new(),
7766                            span: class.span,
7767                            message: format!(
7768                                "agent `{}` requires feature class `{}` more than once",
7769                                agent.name.name, class.name
7770                            ),
7771                            suggestion: Some("remove the duplicate requires entry".to_owned()),
7772                        });
7773                    }
7774                    lowered.requires.push(class.name);
7775                }
7776            }
7777            AgentField::Tools(tools, _) => {
7778                let mut seen = BTreeSet::new();
7779                for tool in tools {
7780                    if !seen.insert(tool.name.clone()) {
7781                        diagnostics.push(Diagnostic {
7782                            related: Vec::new(),
7783                            span: tool.span,
7784                            message: format!(
7785                                "agent `{}` grants tool `{}` more than once",
7786                                agent.name.name, tool.name
7787                            ),
7788                            suggestion: Some("remove the duplicate tool entry".to_owned()),
7789                        });
7790                    }
7791                    lowered.tools.push(tool.name);
7792                }
7793            }
7794            AgentField::Compaction(strategy) => {
7795                const STRATEGIES: [&str; 4] = ["summarize", "hard_reset", "tool_results", "none"];
7796                if lowered.compaction.is_some() {
7797                    diagnostics.push(Diagnostic {
7798                        related: Vec::new(),
7799                        span: strategy.span,
7800                        message: format!(
7801                            "agent `{}` declares compaction more than once",
7802                            agent.name.name
7803                        ),
7804                        suggestion: Some("keep exactly one `compaction` field".to_owned()),
7805                    });
7806                }
7807                if !STRATEGIES.contains(&strategy.name.as_str()) {
7808                    diagnostics.push(Diagnostic {
7809                        related: Vec::new(),
7810                        span: strategy.span,
7811                        message: format!(
7812                            "agent `{}` uses unknown compaction strategy `{}`",
7813                            agent.name.name, strategy.name
7814                        ),
7815                        suggestion: Some(
7816                            "supported strategies are `summarize`, `hard_reset`, `tool_results`, and `none`"
7817                                .to_owned(),
7818                        ),
7819                    });
7820                }
7821                compaction_span = Some(strategy.span);
7822                lowered.compaction = Some(strategy.name);
7823            }
7824            AgentField::Thread(mode) => {
7825                const MODES: [&str; 2] = ["continue", "fresh"];
7826                if lowered.thread.is_some() {
7827                    diagnostics.push(Diagnostic {
7828                        related: Vec::new(),
7829                        span: mode.span,
7830                        message: format!(
7831                            "agent `{}` declares thread more than once",
7832                            agent.name.name
7833                        ),
7834                        suggestion: Some("keep exactly one `thread` field".to_owned()),
7835                    });
7836                }
7837                if !MODES.contains(&mode.name.as_str()) {
7838                    diagnostics.push(Diagnostic {
7839                        related: Vec::new(),
7840                        span: mode.span,
7841                        message: format!(
7842                            "agent `{}` uses unknown thread mode `{}`",
7843                            agent.name.name, mode.name
7844                        ),
7845                        suggestion: Some(
7846                            "supported thread modes are `continue` and `fresh`".to_owned(),
7847                        ),
7848                    });
7849                }
7850                thread_span = Some(mode.span);
7851                lowered.thread = Some(mode.name);
7852            }
7853            AgentField::Settings(sources) => {
7854                const SOURCES: [&str; 3] = ["project", "user", "none"];
7855                if lowered.settings.is_some() {
7856                    diagnostics.push(Diagnostic {
7857                        related: Vec::new(),
7858                        span: sources.span,
7859                        message: format!(
7860                            "agent `{}` declares settings more than once",
7861                            agent.name.name
7862                        ),
7863                        suggestion: Some("keep exactly one `settings` field".to_owned()),
7864                    });
7865                }
7866                if !SOURCES.contains(&sources.name.as_str()) {
7867                    diagnostics.push(Diagnostic {
7868                        related: Vec::new(),
7869                        span: sources.span,
7870                        message: format!(
7871                            "agent `{}` uses unknown settings source `{}`",
7872                            agent.name.name, sources.name
7873                        ),
7874                        suggestion: Some(
7875                            "supported settings sources are `project`, `user`, and `none`"
7876                                .to_owned(),
7877                        ),
7878                    });
7879                }
7880                settings_span = Some(sources.span);
7881                lowered.settings = Some(sources.name);
7882            }
7883            AgentField::Unknown { name, .. } => {
7884                diagnostics.push(Diagnostic { related: Vec::new(),
7885                    span: name.span,
7886                    message: format!(
7887                        "unknown agent field `{}` on agent `{}`",
7888                        name.name, agent.name.name
7889                    ),
7890                    suggestion: Some(
7891                        "supported agent fields are `provider`, `profile`, `capacity`, `skills`, `capabilities`, `tools`, `compaction`, and `settings`".to_owned(),
7892                    ),
7893                });
7894            }
7895        }
7896    }
7897
7898    // `delegated to` resolves as the provider binding. Applied after the field
7899    // loop so a duplicate `provider` field reports the `declares both` diagnostic
7900    // above rather than a spurious more-than-once. With no binding at all the
7901    // agent is Managed by default (DR-0034 Decision 2/6: managed is the
7902    // substrate, not a harness one binds).
7903    if lowered.provider.is_none() {
7904        if let Some(delegate) = &agent.delegated_to {
7905            lowered.provider = Some(delegate.name.clone());
7906        } else if lowered.harness.is_none() {
7907            lowered.provider = Some("owned".to_owned());
7908        }
7909    }
7910
7911    // Classify the harness (DR-0034 Decision 1/6): the class is derived from the
7912    // resolved kind — a direct `provider <kind>`, the `delegated to <provider>`
7913    // sugar, or the kind of the bound `using <harness>`. An agent with none of
7914    // these is Managed by default (Decision 6: managed is the substrate).
7915    let resolved_kind = lowered.provider.as_deref().or_else(|| {
7916        lowered
7917            .harness
7918            .as_deref()
7919            .and_then(|name| harness_kinds.get(name).map(String::as_str))
7920    });
7921    lowered.harness_class = resolved_kind
7922        .map(harness_class)
7923        .unwrap_or(HarnessClass::Managed);
7924
7925    // Partition the knobs (DR-0034 Decision 3): each class admits only its
7926    // meaningful knobs; the other class rejects them with a diagnostic rather than
7927    // silently ignoring them. Only enforced when the class actually resolved — an
7928    // agent with no provider binding already gets its own diagnostic below.
7929    if resolved_kind.is_some() {
7930        if lowered.harness_class == HarnessClass::Delegated {
7931            if let Some(span) = compaction_span {
7932                diagnostics.push(Diagnostic {
7933                    related: Vec::new(),
7934                    span,
7935                    message: format!(
7936                        "agent `{}` is delegated; `compaction` is a managed-harness knob",
7937                        agent.name.name
7938                    ),
7939                    suggestion: Some(
7940                        "remove `compaction` — a delegated harness compacts its own context"
7941                            .to_owned(),
7942                    ),
7943                });
7944            }
7945            if let Some(span) = thread_span {
7946                diagnostics.push(Diagnostic {
7947                    related: Vec::new(),
7948                    span,
7949                    message: format!(
7950                        "agent `{}` is delegated; `thread` is a managed-harness knob",
7951                        agent.name.name
7952                    ),
7953                    suggestion: Some(
7954                        "remove `thread` — a delegated harness owns its own conversation state"
7955                            .to_owned(),
7956                    ),
7957                });
7958            }
7959        } else if let Some(span) = settings_span {
7960            diagnostics.push(Diagnostic {
7961                related: Vec::new(),
7962                span,
7963                message: format!(
7964                    "agent `{}` is managed; `settings` is a delegated-harness knob",
7965                    agent.name.name
7966                ),
7967                suggestion: Some(
7968                    "remove `settings` — WhippleScript assembles a managed agent's context"
7969                        .to_owned(),
7970                ),
7971            });
7972        }
7973    }
7974
7975    // Defaults (2026-07-21): a bare `agent <name>` declaration is complete.
7976    // `capacity` defaults to 1 (serial); `profile` defaults to `no-repo`, the
7977    // least-authority builtin (no filesystem, no bash tools) — omitting the
7978    // field can never grant more than declaring one. `provider` was already
7979    // optional (managed by default, DR-0034).
7980    if lowered.profile.is_none() {
7981        lowered.profile = Some("no-repo".to_owned());
7982    }
7983
7984    if lowered.capacity.is_none() {
7985        lowered.capacity = Some(1);
7986    }
7987
7988    ir.agents.push(lowered);
7989}
7990
7991fn lower_enum(enum_decl: EnumDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7992    let mut variants = BTreeSet::new();
7993    for variant in &enum_decl.variants {
7994        if !variants.insert(variant.name.name.clone()) {
7995            diagnostics.push(Diagnostic {
7996                related: Vec::new(),
7997                span: variant.span,
7998                message: format!(
7999                    "enum `{}` declares variant `{}` more than once",
8000                    enum_decl.name.name, variant.name.name
8001                ),
8002                suggestion: Some(
8003                    "remove the duplicate variant or give it a distinct name".to_owned(),
8004                ),
8005            });
8006        }
8007        // The discriminant is synthesized from the variant name
8008        // (spec/sum-types.md): `variant` is reserved inside variant bodies.
8009        for field in &variant.fields {
8010            if field.name.name == "variant" {
8011                diagnostics.push(Diagnostic {
8012                    related: Vec::new(),
8013                    span: field.name.span,
8014                    message: format!(
8015                        "variant `{}` of enum `{}` declares reserved field `variant`",
8016                        variant.name.name, enum_decl.name.name
8017                    ),
8018                    suggestion: Some(
8019                        "the discriminant is synthesized from the variant name; rename the field"
8020                            .to_owned(),
8021                    ),
8022                });
8023            }
8024        }
8025    }
8026
8027    // Each data-carrying variant lowers to a generated `<Enum>.<Variant>`
8028    // class holding the literal `variant` discriminant plus its payload
8029    // (spec/sum-types.md). The dotted name is unreachable from user source.
8030    for variant in &enum_decl.variants {
8031        if variant.fields.is_empty() {
8032            continue;
8033        }
8034        let mut fields = vec![IrClassField {
8035            name: "variant".to_owned(),
8036            ty: IrType::LiteralString(variant.name.name.clone()),
8037            is_key: false,
8038            presence_condition: None,
8039            span: variant.name.span,
8040        }];
8041        fields.extend(variant.fields.iter().map(|field| IrClassField {
8042            name: field.name.name.clone(),
8043            ty: lower_type(field.ty.clone()),
8044            is_key: false,
8045            presence_condition: field.presence_condition.clone(),
8046            span: field.span,
8047        }));
8048        ir.schemas.push(IrSchema::Class(IrClass {
8049            name: format!("{}.{}", enum_decl.name.name, variant.name.name),
8050            fields,
8051            span: variant.span,
8052        }));
8053    }
8054
8055    ir.schemas.push(IrSchema::Enum(IrEnum {
8056        name: enum_decl.name.name,
8057        variants: enum_decl
8058            .variants
8059            .into_iter()
8060            .map(|variant| variant.name.name)
8061            .collect(),
8062        span: enum_decl.span,
8063    }));
8064}
8065
8066fn validate_test_expr_source(
8067    label: &str,
8068    source: &str,
8069    span: SourceSpan,
8070    diagnostics: &mut Vec<Diagnostic>,
8071) {
8072    if source.trim().is_empty() {
8073        diagnostics.push(Diagnostic {
8074            related: Vec::new(),
8075            span,
8076            message: format!("{label} is empty"),
8077            suggestion: Some("provide an expression".to_owned()),
8078        });
8079        return;
8080    }
8081    if let Err(error) = parse_expression(source) {
8082        diagnostics.push(Diagnostic {
8083            related: Vec::new(),
8084            span,
8085            message: format!("{label} is not a valid expression: {error}"),
8086            suggestion: None,
8087        });
8088    }
8089}
8090
8091fn lower_test(test: TestDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8092    if ir
8093        .tests
8094        .iter()
8095        .any(|existing| existing.name == test.name.value)
8096    {
8097        diagnostics.push(Diagnostic {
8098            related: Vec::new(),
8099            span: test.name.span,
8100            message: format!("test `{}` is declared more than once", test.name.value),
8101            suggestion: Some("give each test scenario a distinct name".to_owned()),
8102        });
8103    }
8104    if !test
8105        .clauses
8106        .iter()
8107        .any(|clause| matches!(clause, TestClause::Expect(_)))
8108    {
8109        diagnostics.push(Diagnostic {
8110            related: Vec::new(),
8111            span: test.span,
8112            message: format!("test `{}` has no `expect` clause", test.name.value),
8113            suggestion: Some("a test must assert at least one expected outcome".to_owned()),
8114        });
8115    }
8116    // Validate that captured expression source (given field values, projection
8117    // predicates) parses, so `whip check` catches malformed test scenarios.
8118    for clause in &test.clauses {
8119        match clause {
8120            TestClause::Given(
8121                GivenClause::Input { fields, .. }
8122                | GivenClause::Fact { fields, .. }
8123                | GivenClause::Signal { fields, .. },
8124            ) => {
8125                for field in fields {
8126                    validate_test_expr_source(
8127                        &format!("given field `{}`", field.name.name),
8128                        &field.value,
8129                        field.span,
8130                        diagnostics,
8131                    );
8132                }
8133            }
8134            TestClause::Expect(ExpectClause {
8135                target: ExpectTarget::Projection(query),
8136                ..
8137            }) => match &query.kind {
8138                ProjQueryKind::Count { predicate, .. } | ProjQueryKind::Where { predicate } => {
8139                    validate_test_expr_source(
8140                        &format!("predicate on `{}`", query.noun),
8141                        predicate,
8142                        query.span,
8143                        diagnostics,
8144                    );
8145                }
8146                ProjQueryKind::Exists => {}
8147            },
8148            _ => {}
8149        }
8150    }
8151    ir.tests.push(IrTest {
8152        name: test.name.value,
8153        workflow: test.workflow.map(|identifier| identifier.name),
8154        clauses: test.clauses,
8155        span: test.span,
8156    });
8157}
8158
8159fn lower_source(source: SourceDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8160    if ir
8161        .sources
8162        .iter()
8163        .any(|existing| existing.name == source.name.name)
8164    {
8165        diagnostics.push(Diagnostic {
8166            related: Vec::new(),
8167            span: source.name.span,
8168            message: format!("source `{}` is declared more than once", source.name.name),
8169            suggestion: Some("remove the duplicate source declaration".to_owned()),
8170        });
8171    }
8172    // Clock-source static checks (spec/std-time.md): a recurring schedule must
8173    // declare a missed policy (no silent default), and a calendar schedule should
8174    // declare a timezone.
8175    if let Some(clock) = &source.clock {
8176        let recurring = !matches!(clock.recurrence, Recurrence::At { .. });
8177        if recurring && clock.missed.is_none() {
8178            diagnostics.push(Diagnostic {
8179                related: Vec::new(),
8180                span: clock.span,
8181                message: format!(
8182                    "recurring source `{}` must declare a `missed` policy",
8183                    source.name.name
8184                ),
8185                suggestion: Some(
8186                    "add `missed skip`, `missed coalesce`, or `missed catch_up limit N`".to_owned(),
8187                ),
8188            });
8189        }
8190        if matches!(clock.recurrence, Recurrence::EveryCalendar { .. }) && clock.timezone.is_none()
8191        {
8192            diagnostics.push(Diagnostic { related: Vec::new(),
8193                span: clock.span,
8194                message: format!(
8195                    "calendar source `{}` should declare a `timezone`",
8196                    source.name.name
8197                ),
8198                suggestion: Some(
8199                    "add `timezone \"America/New_York\"`; a calendar schedule without one defaults to UTC".to_owned(),
8200                ),
8201            });
8202        }
8203    }
8204    // File-source static checks (spec/std-ingress.md "Static checks" #3,
8205    // closed clause set): a `file` source reads exactly one of a declared
8206    // `path` (line mode) or `watch` glob (occurrence mode); both clauses are
8207    // meaningful only for `file` (rejecting them elsewhere keeps provider
8208    // intent unambiguous, mirroring the clock-only-clause rejection).
8209    let is_file = source.provider.name == "file";
8210    if is_file && source.path.is_none() && source.watch.is_none() {
8211        diagnostics.push(Diagnostic {
8212            related: Vec::new(),
8213            span: source.span,
8214            message: format!(
8215                "`file` source `{}` requires a `path` or `watch` clause",
8216                source.name.name
8217            ),
8218            suggestion: Some(
8219                "add `path \"./inbox.txt\"` (one signal per line) or `watch \"./drops/*.json\"` \
8220                 (one signal per new file-content occurrence)"
8221                    .to_owned(),
8222            ),
8223        });
8224    }
8225    if is_file && source.path.is_some() && source.watch.is_some() {
8226        diagnostics.push(Diagnostic {
8227            related: Vec::new(),
8228            span: source
8229                .watch
8230                .as_ref()
8231                .map(|watch| watch.span)
8232                .unwrap_or(source.span),
8233            message: format!(
8234                "`file` source `{}` declares both `path` and `watch`; the modes are exclusive",
8235                source.name.name
8236            ),
8237            suggestion: Some(
8238                "keep `path` for line-by-line admission or `watch` for per-file-content \
8239                 occurrences, not both"
8240                    .to_owned(),
8241            ),
8242        });
8243    }
8244    if !is_file {
8245        if let Some(path) = &source.path {
8246            diagnostics.push(Diagnostic {
8247                related: Vec::new(),
8248                span: path.span,
8249                message: format!(
8250                    "source `{}` declares a `path` clause but its provider is `{}`, not `file`",
8251                    source.name.name, source.provider.name
8252                ),
8253                suggestion: Some(
8254                    "use `source file as ...` for a `path`, or remove the clause".to_owned(),
8255                ),
8256            });
8257        }
8258        if let Some(watch) = &source.watch {
8259            diagnostics.push(Diagnostic {
8260                related: Vec::new(),
8261                span: watch.span,
8262                message: format!(
8263                    "source `{}` declares a `watch` clause but its provider is `{}`, not `file`",
8264                    source.name.name, source.provider.name
8265                ),
8266                suggestion: Some(
8267                    "use `source file as ...` for a `watch` glob, or remove the clause".to_owned(),
8268                ),
8269            });
8270        }
8271    }
8272    // HTTP-source static checks: an `http` source GETs a declared url, and the
8273    // `url` clause is meaningful only for `http` (rejecting it elsewhere keeps
8274    // provider intent unambiguous, mirroring the file-only-clause rejection).
8275    let is_http = source.provider.name == "http";
8276    if is_http && source.url.is_none() {
8277        diagnostics.push(Diagnostic {
8278            related: Vec::new(),
8279            span: source.span,
8280            message: format!(
8281                "`http` source `{}` requires a `url` clause",
8282                source.name.name
8283            ),
8284            suggestion: Some("add `url \"https://example.com/feed.json\"`".to_owned()),
8285        });
8286    }
8287    // The runtime GETs the url with an HTTP client, so it must be an absolute
8288    // http(s) URL — catch a scheme-less or wrong-scheme url at `whip check`
8289    // rather than as a runtime request error.
8290    if is_http {
8291        if let Some(url) = &source.url {
8292            let scheme_ok = url.value.starts_with("http://") || url.value.starts_with("https://");
8293            if !scheme_ok {
8294                diagnostics.push(Diagnostic {
8295                    related: Vec::new(),
8296                    span: url.span,
8297                    message: format!(
8298                        "`http` source `{}` url `{}` is not an absolute http(s) URL",
8299                        source.name.name, url.value
8300                    ),
8301                    suggestion: Some(
8302                        "use an absolute `http://` or `https://` URL the runtime can GET"
8303                            .to_owned(),
8304                    ),
8305                });
8306            }
8307        }
8308    }
8309    if !is_http {
8310        if let Some(url) = &source.url {
8311            diagnostics.push(Diagnostic {
8312                related: Vec::new(),
8313                span: url.span,
8314                message: format!(
8315                    "source `{}` declares a `url` clause but its provider is `{}`, not `http`",
8316                    source.name.name, source.provider.name
8317                ),
8318                suggestion: Some(
8319                    "use `source http as ...` for a `url`, or remove the clause".to_owned(),
8320                ),
8321            });
8322        }
8323    }
8324    let is_clock = source.clock.is_some();
8325    let is_file = source.provider.name == "file";
8326    let is_http = source.provider.name == "http";
8327    // `dedup <observe>.<field>` static checks (spec/std-ingress.md I2a):
8328    // meaningful only where a provider delivery identity can replace the
8329    // positional ordinal — `file` line mode and `http`. A `watch` source is
8330    // already content-keyed, and clock/cli sources carry their own identity.
8331    // The value must be a one-segment path off the observation binding naming
8332    // a known observation field (the same rule the emit mapping resolves by,
8333    // `clock_emit_payload`), else the runtime key would be silently null.
8334    let mut dedup_field = None;
8335    if let Some(dedup) = &source.dedup {
8336        let span = match dedup {
8337            SourceValue::Path { span, .. } => *span,
8338            SourceValue::String(literal) => literal.span,
8339            SourceValue::Number(_, span) => *span,
8340        };
8341        if !(is_http || is_file && source.watch.is_none()) {
8342            diagnostics.push(Diagnostic {
8343                related: Vec::new(),
8344                span,
8345                message: format!(
8346                    "source `{}` declares a `dedup` clause but its provider is `{}`{}",
8347                    source.name.name,
8348                    source.provider.name,
8349                    if is_file {
8350                        " in `watch` mode, which is already content-keyed"
8351                    } else {
8352                        "; `dedup` applies to `file` (line mode) and `http` sources"
8353                    }
8354                ),
8355                suggestion: Some("remove the `dedup` clause".to_owned()),
8356            });
8357        } else {
8358            match dedup {
8359                SourceValue::Path {
8360                    binding, segments, ..
8361                } if binding.name == source.observe_binding.name && segments.len() == 1 => {
8362                    dedup_field = Some(segments[0].name.clone());
8363                }
8364                _ => {
8365                    diagnostics.push(Diagnostic {
8366                        related: Vec::new(),
8367                        span,
8368                        message: format!(
8369                            "source `{}` `dedup` must name one observation field off the \
8370                             `observe` binding (e.g. `dedup {}.line`)",
8371                            source.name.name, source.observe_binding.name
8372                        ),
8373                        suggestion: Some(format!(
8374                            "the observation binding is `{}` (declared by `observe as {}`)",
8375                            source.observe_binding.name, source.observe_binding.name
8376                        )),
8377                    });
8378                }
8379            }
8380        }
8381    }
8382    let path = source.path.as_ref().map(|literal| literal.value.clone());
8383    let watch = source.watch.as_ref().map(|literal| literal.value.clone());
8384    let url = source.url.as_ref().map(|literal| literal.value.clone());
8385    let recurrence = source.clock.as_ref().map(|clock| clock.recurrence.clone());
8386    let timezone = source
8387        .clock
8388        .as_ref()
8389        .and_then(|clock| clock.timezone.as_ref().map(|tz| tz.value.clone()));
8390    let missed = source.clock.as_ref().and_then(|clock| clock.missed);
8391    ir.sources.push(IrSource {
8392        name: source.name.name,
8393        provider: source.provider.name,
8394        is_clock,
8395        is_file,
8396        is_http,
8397        recurrence,
8398        timezone,
8399        missed,
8400        path,
8401        watch,
8402        url,
8403        dedup_field,
8404        observe_binding: source.observe_binding.name,
8405        emit_signal: source.emit.signal,
8406        emit_from: source.emit.from.as_ref().map(|ident| ident.name.clone()),
8407        emit_fields: source
8408            .emit
8409            .fields
8410            .into_iter()
8411            .map(|field| IrSourceEmitField {
8412                name: field.name.name,
8413                value: field.value,
8414                span: field.span,
8415            })
8416            .collect(),
8417        span: source.span,
8418    });
8419}
8420
8421fn lower_event(event: EventDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8422    if ir.events.iter().any(|existing| existing.name == event.name) {
8423        diagnostics.push(Diagnostic {
8424            related: Vec::new(),
8425            span: event.name_span,
8426            message: format!("signal `{}` is declared more than once", event.name),
8427            suggestion: Some("remove the duplicate signal declaration".to_owned()),
8428        });
8429    }
8430    let mut fields = BTreeSet::new();
8431    for field in &event.fields {
8432        if !fields.insert(field.name.name.clone()) {
8433            diagnostics.push(Diagnostic {
8434                related: Vec::new(),
8435                span: field.name.span,
8436                message: format!(
8437                    "signal `{}` declares field `{}` more than once",
8438                    event.name, field.name.name
8439                ),
8440                suggestion: Some(
8441                    "remove the duplicate field or give it a distinct name".to_owned(),
8442                ),
8443            });
8444        }
8445    }
8446    validate_presence_conditions(&event.name, &event.fields, diagnostics);
8447
8448    ir.events.push(IrEvent {
8449        name: event.name,
8450        fields: event
8451            .fields
8452            .into_iter()
8453            .map(|field| IrClassField {
8454                name: field.name.name,
8455                ty: lower_type(field.ty),
8456                is_key: false,
8457                presence_condition: field.presence_condition,
8458                span: field.span,
8459            })
8460            .collect(),
8461        span: event.span,
8462    });
8463}
8464
8465/// The string-literal values of a literal-union (or single-literal) type, or `None`
8466/// if the type is not a pure string-literal union. Used to validate Family B
8467/// discriminants.
8468fn literal_union_values(ty: &TypeSyntax) -> Option<Vec<String>> {
8469    match ty {
8470        TypeSyntax::LiteralString { value, .. } => Some(vec![value.clone()]),
8471        TypeSyntax::Union { variants, .. } => {
8472            let values = variants
8473                .iter()
8474                .filter_map(|variant| match variant {
8475                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
8476                    _ => None,
8477                })
8478                .collect::<Vec<_>>();
8479            (!values.is_empty() && values.len() == variants.len()).then_some(values)
8480        }
8481        _ => None,
8482    }
8483}
8484
8485/// Family B validation (spec/decision-records/discriminated-families-design.md §6.3):
8486/// every `<field> <T> when <disc> is "<lit>"` must name a same-schema discriminant
8487/// that is a string-literal union, and `<lit>` must be one of its values.
8488fn validate_presence_conditions(
8489    container: &str,
8490    fields: &[ClassField],
8491    diagnostics: &mut Vec<Diagnostic>,
8492) {
8493    for field in fields {
8494        let Some((disc, literal)) = &field.presence_condition else {
8495            continue;
8496        };
8497        let Some(disc_field) = fields.iter().find(|candidate| &candidate.name.name == disc) else {
8498            diagnostics.push(Diagnostic {
8499                related: Vec::new(),
8500                span: field.span,
8501                message: format!(
8502                    "`{container}` field `{}` is conditioned on unknown discriminant `{disc}`",
8503                    field.name.name
8504                ),
8505                suggestion: Some(
8506                    "`when <field> is \"...\"` must name a literal-union field of the same schema"
8507                        .to_owned(),
8508                ),
8509            });
8510            continue;
8511        };
8512        match literal_union_values(&disc_field.ty) {
8513            Some(values) if values.iter().any(|value| value == literal) => {}
8514            Some(values) => diagnostics.push(Diagnostic {
8515                related: Vec::new(),
8516                span: field.span,
8517                message: format!(
8518                    "`{container}` field `{}` is conditioned on `{disc} is \"{literal}\"`, which is not a value of `{disc}`",
8519                    field.name.name
8520                ),
8521                suggestion: Some(format!("use one of: {}", values.join(", "))),
8522            }),
8523            None => diagnostics.push(Diagnostic {
8524                related: Vec::new(),
8525                span: field.span,
8526                message: format!(
8527                    "`{container}` field `{}` is conditioned on `{disc}`, which is not a string-literal discriminant",
8528                    field.name.name
8529                ),
8530                suggestion: Some(
8531                    "the discriminant must be a string-literal union, e.g. `kind \"a\" | \"b\"`"
8532                        .to_owned(),
8533                ),
8534            }),
8535        }
8536    }
8537}
8538
8539fn lower_class(
8540    class_decl: ClassDecl,
8541    ir: &mut IrProgram,
8542    schema_names: &BTreeSet<String>,
8543    agent_names: &BTreeSet<String>,
8544    diagnostics: &mut Vec<Diagnostic>,
8545) {
8546    let mut fields = BTreeSet::new();
8547    for field in &class_decl.fields {
8548        if !fields.insert(field.name.name.clone()) {
8549            diagnostics.push(Diagnostic {
8550                related: Vec::new(),
8551                span: field.name.span,
8552                message: format!(
8553                    "class `{}` declares field `{}` more than once",
8554                    class_decl.name.name, field.name.name
8555                ),
8556                suggestion: Some(
8557                    "remove the duplicate field or give it a distinct name".to_owned(),
8558                ),
8559            });
8560        }
8561        validate_type_refs(&field.ty, schema_names, agent_names, diagnostics);
8562    }
8563
8564    // v0 allows a single natural key per class (import keys one fact per row).
8565    let key_fields = class_decl
8566        .fields
8567        .iter()
8568        .filter(|field| field.is_key)
8569        .collect::<Vec<_>>();
8570    if key_fields.len() > 1 {
8571        for field in key_fields.into_iter().skip(1) {
8572            diagnostics.push(Diagnostic {
8573                related: Vec::new(),
8574                span: field.span,
8575                message: format!(
8576                    "class `{}` declares more than one `@key` field",
8577                    class_decl.name.name
8578                ),
8579                suggestion: Some("a class has at most one `@key` natural key in v0".to_owned()),
8580            });
8581        }
8582    }
8583
8584    validate_presence_conditions(&class_decl.name.name, &class_decl.fields, diagnostics);
8585
8586    ir.schemas.push(IrSchema::Class(IrClass {
8587        name: class_decl.name.name,
8588        span: class_decl.span,
8589        fields: class_decl
8590            .fields
8591            .into_iter()
8592            .map(|field| IrClassField {
8593                name: field.name.name,
8594                ty: lower_type(field.ty),
8595                is_key: field.is_key,
8596                presence_condition: field.presence_condition,
8597                span: field.span,
8598            })
8599            .collect(),
8600    }));
8601}
8602
8603fn lower_table(
8604    table: TableDecl,
8605    semantic: &SemanticContext,
8606    workflow_contract_names: &WorkflowContractNames,
8607    ir: &mut IrProgram,
8608    diagnostics: &mut Vec<Diagnostic>,
8609) {
8610    if !semantic.schemas.class_exists(&table.schema.name) {
8611        diagnostics.push(Diagnostic {
8612            related: Vec::new(),
8613            span: table.schema.span,
8614            message: format!(
8615                "table `{}` targets unknown class `{}`",
8616                table.name.name, table.schema.name
8617            ),
8618            suggestion: Some("declare the class before seeding rows for it".to_owned()),
8619        });
8620        return;
8621    }
8622
8623    if table.rows.is_empty() {
8624        diagnostics.push(Diagnostic {
8625            related: Vec::new(),
8626            span: table.span,
8627            message: format!("table `{}` has no rows", table.name.name),
8628            suggestion: Some("add at least one `{ ... }` row".to_owned()),
8629        });
8630        return;
8631    }
8632
8633    let mut body = String::new();
8634    for row in &table.rows {
8635        push_line(&mut body, format!("record {} {{", table.schema.name));
8636        push_block_body(&row.body.text, &mut body);
8637        push_line(&mut body, "}");
8638        body.push('\n');
8639    }
8640    if body.ends_with('\n') {
8641        body.pop();
8642    }
8643
8644    let rule = RuleDecl {
8645        name: Ident {
8646            name: format!("table_{}", table.name.name),
8647            span: table.name.span,
8648        },
8649        tags: Vec::new(),
8650        description: None,
8651        whens: vec![WhenClause {
8652            text: "started".to_owned(),
8653            span: table.name.span,
8654        }],
8655        body: BlockSource {
8656            text: body,
8657            span: table.span,
8658        },
8659        span: table.span,
8660    };
8661
8662    let record_sources = table
8663        .rows
8664        .iter()
8665        .map(|row| IrRecordSource {
8666            schema: table.schema.name.clone(),
8667            construct: "table_row".to_owned(),
8668            span: row.span,
8669        })
8670        .collect::<Vec<_>>();
8671
8672    let rule_name = rule.name.name.clone();
8673    lower_rule(rule, semantic, workflow_contract_names, ir, diagnostics);
8674    if let Some(rule) = ir
8675        .rules
8676        .iter_mut()
8677        .rev()
8678        .find(|rule| rule.name == rule_name)
8679    {
8680        rule.metadata.record_sources = record_sources;
8681    }
8682}
8683
8684/// The coerce body is a clause list — `prompt` (single or multi-line) and
8685/// `provider <name>` — not free text. Reject anything else so a typo'd
8686/// `promt` (which would otherwise silently produce a coercion with NO prompt)
8687/// or a stray field fails at `check`, matching the agent-block posture.
8688fn validate_coerce_body_fields(coerce: &CoerceDecl, diagnostics: &mut Vec<Diagnostic>) {
8689    let mut in_prompt = false;
8690    let mut awaiting_opener = false;
8691    for line in coerce.body.text.lines() {
8692        let trimmed = line.trim();
8693        if in_prompt {
8694            // A line with an odd number of `"""` markers closes the prompt.
8695            if trimmed.matches("\"\"\"").count() % 2 == 1 {
8696                in_prompt = false;
8697            }
8698            continue;
8699        }
8700        if awaiting_opener {
8701            // Bare `prompt` on its own line: the opener is the next
8702            // non-empty line.
8703            if trimmed.is_empty() {
8704                continue;
8705            }
8706            awaiting_opener = false;
8707            if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
8708                if after_opener.matches("\"\"\"").count() % 2 == 0 {
8709                    in_prompt = true;
8710                }
8711                continue;
8712            }
8713            // fall through: not an opener — validate as a clause line
8714        }
8715        if trimmed.is_empty() || trimmed.starts_with('#') {
8716            continue;
8717        }
8718        if trimmed == "prompt" {
8719            awaiting_opener = true;
8720            continue;
8721        }
8722        if let Some(rest) = trimmed.strip_prefix("prompt ") {
8723            let rest = rest.trim_start();
8724            if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
8725                // `prompt """…` (optionally annotated): multi-line unless the
8726                // triple quote closes on the same line.
8727                if after_opener.matches("\"\"\"").count() % 2 == 0 {
8728                    in_prompt = true;
8729                }
8730            }
8731            // Single-quoted one-line prompts close on their own line.
8732            continue;
8733        }
8734        if let Some(rest) = trimmed.strip_prefix("provider ") {
8735            if rest.split_whitespace().count() != 1 {
8736                diagnostics.push(Diagnostic {
8737                    related: Vec::new(),
8738                    span: coerce.name.span,
8739                    message: format!(
8740                        "coerce `{}` has a malformed `provider` clause: `{trimmed}`",
8741                        coerce.name.name
8742                    ),
8743                    suggestion: Some("write `provider <name>`".to_owned()),
8744                });
8745            }
8746            continue;
8747        }
8748        let field = trimmed.split_whitespace().next().unwrap_or(trimmed);
8749        diagnostics.push(Diagnostic {
8750            related: Vec::new(),
8751            span: coerce.name.span,
8752            message: format!(
8753                "unknown coerce field `{field}` on coerce `{}`",
8754                coerce.name.name
8755            ),
8756            suggestion: Some("supported coerce fields are `prompt` and `provider`".to_owned()),
8757        });
8758    }
8759}
8760
8761fn lower_coerce(
8762    coerce: CoerceDecl,
8763    ir: &mut IrProgram,
8764    schema_names: &BTreeSet<String>,
8765    agent_names: &BTreeSet<String>,
8766    diagnostics: &mut Vec<Diagnostic>,
8767) {
8768    let mut params = BTreeSet::new();
8769    for param in &coerce.params {
8770        if !params.insert(param.name.name.clone()) {
8771            diagnostics.push(Diagnostic {
8772                related: Vec::new(),
8773                span: param.name.span,
8774                message: format!(
8775                    "coerce `{}` declares parameter `{}` more than once",
8776                    coerce.name.name, param.name.name
8777                ),
8778                suggestion: Some(
8779                    "remove the duplicate parameter or give it a distinct name".to_owned(),
8780                ),
8781            });
8782        }
8783        validate_type_refs(&param.ty, schema_names, agent_names, diagnostics);
8784    }
8785    validate_type_refs(&coerce.output, schema_names, agent_names, diagnostics);
8786    validate_coerce_prompt_content_type_annotations(&coerce, diagnostics);
8787    validate_coerce_body_fields(&coerce, diagnostics);
8788
8789    ir.coerces.push(IrCoerce {
8790        name: coerce.name.name,
8791        params: coerce
8792            .params
8793            .into_iter()
8794            .map(|param| IrParam {
8795                name: param.name.name,
8796                ty: lower_type(param.ty),
8797            })
8798            .collect(),
8799        output: lower_type(coerce.output),
8800        body: coerce.body.text,
8801    });
8802}
8803
8804fn validate_type_refs(
8805    ty: &TypeSyntax,
8806    schema_names: &BTreeSet<String>,
8807    agent_names: &BTreeSet<String>,
8808    diagnostics: &mut Vec<Diagnostic>,
8809) {
8810    match ty {
8811        TypeSyntax::Primitive { .. } | TypeSyntax::LiteralString { .. } => {}
8812        TypeSyntax::Ref { name } => {
8813            if !schema_names.contains(&name.name) && !is_builtin_schema_ref(&name.name) {
8814                diagnostics.push(Diagnostic {
8815                    related: Vec::new(),
8816                    span: name.span,
8817                    message: format!("unknown schema reference `{}`", name.name),
8818                    suggestion: Some(format!(
8819                        "declare `class {}` or `enum {}` before using it",
8820                        name.name, name.name
8821                    )),
8822                });
8823            }
8824        }
8825        TypeSyntax::AgentRef { agents, .. } => {
8826            let mut seen = BTreeSet::new();
8827            for agent in agents {
8828                if !seen.insert(agent.name.clone()) {
8829                    diagnostics.push(Diagnostic {
8830                        related: Vec::new(),
8831                        span: agent.span,
8832                        message: format!("AgentRef lists agent `{}` more than once", agent.name),
8833                        suggestion: Some(
8834                            "remove the duplicate agent from the AgentRef domain".to_owned(),
8835                        ),
8836                    });
8837                }
8838                if !agent_names.contains(&agent.name) {
8839                    diagnostics.push(Diagnostic {
8840                        related: Vec::new(),
8841                        span: agent.span,
8842                        message: format!("AgentRef references unknown agent `{}`", agent.name),
8843                        suggestion: Some(format!(
8844                            "declare `agent {}` before using it in AgentRef",
8845                            agent.name
8846                        )),
8847                    });
8848                }
8849            }
8850        }
8851        TypeSyntax::Optional { inner, .. }
8852        | TypeSyntax::Array { inner, .. }
8853        | TypeSyntax::Map { inner, .. } => {
8854            validate_type_refs(inner, schema_names, agent_names, diagnostics)
8855        }
8856        TypeSyntax::Union { variants, .. } => {
8857            for variant in variants {
8858                validate_type_refs(variant, schema_names, agent_names, diagnostics);
8859            }
8860        }
8861    }
8862}
8863
8864fn is_builtin_schema_ref(name: &str) -> bool {
8865    matches!(
8866        name,
8867        "AgentTurn"
8868            | "WorkItem"
8869            | "Evidence"
8870            | "TerminalFailed"
8871            | "TerminalTimedOut"
8872            | "TerminalCancelled"
8873            | "TerminalOutcome"
8874    )
8875}
8876
8877/// The terminal-family schemas are `origin = observer` (discriminated-families
8878/// design §5.4): the kernel projects them when it observes an effect or child
8879/// terminal, and user rules may only *eliminate* them (`after … fails/times
8880/// out/cancels as f`), never *construct* them. A rule that `record`s one would
8881/// forge a terminal outcome the kernel never produced, misleading the
8882/// `after`/terminal-case reaction machinery. Rejected at check time.
8883fn is_observer_only_schema(name: &str) -> bool {
8884    matches!(
8885        name,
8886        "TerminalFailed" | "TerminalTimedOut" | "TerminalCancelled" | "TerminalOutcome"
8887    )
8888}
8889
8890fn lower_rule(
8891    rule: RuleDecl,
8892    semantic: &SemanticContext,
8893    workflow_contract_names: &WorkflowContractNames,
8894    ir: &mut IrProgram,
8895    diagnostics: &mut Vec<Diagnostic>,
8896) {
8897    validate_canonical_rule_body_syntax(&rule, diagnostics);
8898    let metadata = analyze_rule(&rule, semantic, diagnostics);
8899    validate_workflow_terminal_actions(
8900        &rule,
8901        semantic,
8902        &binding_types_for_rule(&rule),
8903        &known_roots_for_rule(&rule),
8904        workflow_contract_names,
8905        diagnostics,
8906    );
8907    validate_effectful_self_trigger(&rule, &metadata, diagnostics);
8908    validate_send_channels(&rule, semantic, diagnostics);
8909    validate_message_from_channels(&rule, semantic, diagnostics);
8910    validate_evidence_fact_not_matched(&rule, diagnostics);
8911    validate_turn_access_grants(&rule, &metadata, diagnostics);
8912    ir.rules.push(IrRule {
8913        name: rule.name.name,
8914        whens: rule.whens.into_iter().map(lower_when_clause).collect(),
8915        body: rule.body.text,
8916        metadata,
8917    });
8918}
8919
8920fn lower_when_clause(when: WhenClause) -> IrWhen {
8921    let source = when.text;
8922    let (pattern, guard_source) = split_when_guard(&source);
8923    let pattern = pattern.to_owned();
8924    let guard = guard_source.and_then(|guard_source| {
8925        let guard_offset = source.find(guard_source).unwrap_or(0);
8926        lower_expression(
8927            guard_source,
8928            SourceSpan {
8929                start: when.span.start + guard_offset,
8930                end: when.span.start + guard_offset + guard_source.len(),
8931            },
8932        )
8933    });
8934    IrWhen {
8935        source,
8936        pattern,
8937        guard,
8938        span: when.span,
8939    }
8940}
8941
8942fn validate_canonical_rule_body_syntax(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
8943    for line in rule.body.text.lines().map(str::trim) {
8944        if line.starts_with("then ") {
8945            diagnostics.push(Diagnostic {
8946                related: Vec::new(),
8947                span: rule.body.span,
8948                message: format!(
8949                    "rule `{}` uses unsupported `then` sequencing",
8950                    rule.name.name
8951                ),
8952                suggestion: Some(
8953                    "use `after <effect> succeeds { ... }` blocks for effect sequencing".to_owned(),
8954                ),
8955            });
8956        }
8957        if line.starts_with("after ") && line.contains("=>") {
8958            diagnostics.push(Diagnostic {
8959                related: Vec::new(),
8960                span: rule.body.span,
8961                message: format!(
8962                    "rule `{}` uses unsupported `after ... =>` sequencing",
8963                    rule.name.name
8964                ),
8965                suggestion: Some("write `after <effect> succeeds { ... }`".to_owned()),
8966            });
8967        }
8968    }
8969}
8970
8971fn build_rule_dependencies(rules: &[IrRule]) -> Vec<IrRuleDependency> {
8972    let mut dependencies = Vec::new();
8973    for producer in rules {
8974        for produced_fact in &producer.metadata.fact_writes {
8975            for consumer in rules {
8976                if consumer.metadata.fact_reads.contains(produced_fact) {
8977                    dependencies.push(IrRuleDependency {
8978                        producer: producer.name.clone(),
8979                        consumer: consumer.name.clone(),
8980                        fact: produced_fact.clone(),
8981                    });
8982                }
8983            }
8984        }
8985    }
8986    dependencies.sort_by(|left, right| {
8987        (&left.producer, &left.consumer, &left.fact).cmp(&(
8988            &right.producer,
8989            &right.consumer,
8990            &right.fact,
8991        ))
8992    });
8993    dependencies
8994}
8995
8996/// `send via <channel>` (std.messaging) must name a declared `channel`. The
8997/// channel name is carried as the construct's `channel` field; an unknown channel
8998/// would lower to a `messaging.send` effect that no provider can route, so it is
8999/// rejected at compile time (mirrors `acquire`/`consume` resource-existence checks).
9000/// `when message from <channel> as msg` (spec/messaging.md) must name a declared
9001/// channel, mirroring the outbound `send via <channel>` check.
9002fn validate_message_from_channels(
9003    rule: &RuleDecl,
9004    semantic: &SemanticContext,
9005    diagnostics: &mut Vec<Diagnostic>,
9006) {
9007    for when in &rule.whens {
9008        let (pattern, _) = split_when_guard(&when.text);
9009        let Some(rest) = pattern.trim_start().strip_prefix("message from ") else {
9010            continue;
9011        };
9012        let Some(channel) = rest.split_whitespace().next() else {
9013            continue;
9014        };
9015        if !semantic.channels.iter().any(|c| c.as_str() == channel) {
9016            diagnostics.push(Diagnostic {
9017                related: Vec::new(),
9018                span: when.span,
9019                message: format!("`when message from {channel}` names an unknown channel"),
9020                suggestion: Some(
9021                    "declare it with `channel <name> { provider … }`, or correct the channel name"
9022                        .to_owned(),
9023                ),
9024            });
9025            continue;
9026        }
9027        // Capability-report-conditioned check (spec/std-messaging.md "Static
9028        // checks"): inbound observation requires the channel provider's report
9029        // `direction` ∈ {inbound_only, bidirectional}. Desktop channels are a
9030        // check error here — send/receive-capable are distinguishable (the v1
9031        // acceptance test). Unknown providers already errored at the channel
9032        // declaration, so they are not re-flagged here.
9033        if let Some(report) = semantic
9034            .channel_providers
9035            .get(channel)
9036            .and_then(|provider| channel_provider_report(provider))
9037        {
9038            if report.direction == "outbound_only" {
9039                diagnostics.push(Diagnostic {
9040                    related: Vec::new(),
9041                    span: when.span,
9042                    message: format!(
9043                        "`when message from {channel}` observes a channel whose provider `{}` is outbound-only (its capability report cannot deliver inbound messages)",
9044                        report.short_name
9045                    ),
9046                    suggestion: Some(
9047                        "route inbound observation through an inbound-capable provider (`local`, `stdio`, `fixture`)"
9048                            .to_owned(),
9049                    ),
9050                });
9051            }
9052        }
9053    }
9054}
9055
9056fn validate_send_channels(
9057    rule: &RuleDecl,
9058    semantic: &SemanticContext,
9059    diagnostics: &mut Vec<Diagnostic>,
9060) {
9061    let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9062    fn walk(
9063        statements: &[body::BodyStmt],
9064        semantic: &SemanticContext,
9065        diagnostics: &mut Vec<Diagnostic>,
9066    ) {
9067        for statement in statements {
9068            match statement {
9069                body::BodyStmt::Effect(effect) => {
9070                    if let body::BodyEffectKind::ConstructCapabilityCall {
9071                        keyword, fields, ..
9072                    } = &effect.kind
9073                    {
9074                        if keyword == "send" {
9075                            if let Some(channel) =
9076                                fields.iter().find(|field| field.name == "channel")
9077                            {
9078                                if !semantic.channels.contains(&channel.source) {
9079                                    diagnostics.push(Diagnostic {
9080                                        related: Vec::new(),
9081                                        span: effect.span,
9082                                        message: format!(
9083                                            "`send via {}` names an unknown channel",
9084                                            channel.source
9085                                        ),
9086                                        suggestion: Some(
9087                                            "declare it with `channel <name> { provider … }`, or correct the channel name"
9088                                                .to_owned(),
9089                                        ),
9090                                    });
9091                                } else if let Some(report) = semantic
9092                                    .channel_providers
9093                                    .get(&channel.source)
9094                                    .and_then(|provider| channel_provider_report(provider))
9095                                {
9096                                    // Capability-report-conditioned check
9097                                    // (spec/std-messaging.md "Static checks"):
9098                                    // outbound `send via` requires the provider
9099                                    // report `direction` ∈ {outbound_only,
9100                                    // bidirectional}. No v1 provider is
9101                                    // inbound-only, so this arm has no
9102                                    // reachable negative today; it exists so a
9103                                    // future inbound-only provider fails
9104                                    // closed at check time, not at dispatch.
9105                                    if report.direction == "inbound_only" {
9106                                        diagnostics.push(Diagnostic {
9107                                            related: Vec::new(),
9108                                            span: effect.span,
9109                                            message: format!(
9110                                                "`send via {}` targets a channel whose provider `{}` is inbound-only (its capability report cannot accept outbound sends)",
9111                                                channel.source, report.short_name
9112                                            ),
9113                                            suggestion: Some(
9114                                                "send through an outbound-capable provider (`local`, `desktop`, `stdio`, `fixture`)"
9115                                                    .to_owned(),
9116                                            ),
9117                                        });
9118                                    }
9119                                }
9120                            }
9121                        }
9122                        // MEM-1 check 1: the memory operations must name a
9123                        // DECLARED pool — the twin of the send-channel check.
9124                        if matches!(keyword.as_str(), "recall" | "learn" | "curate") {
9125                            if let Some(pool) = fields.iter().find(|field| field.name == "pool") {
9126                                if !semantic.memory_pools.contains(&pool.source) {
9127                                    diagnostics.push(Diagnostic {
9128                                        related: Vec::new(),
9129                                        span: effect.span,
9130                                        message: format!(
9131                                            "`{keyword}` names unknown memory pool `{}`",
9132                                            pool.source
9133                                        ),
9134                                        suggestion: Some(
9135                                            "declare it with `memory pool <name> { … }`, or correct the pool name"
9136                                                .to_owned(),
9137                                        ),
9138                                    });
9139                                }
9140                            }
9141                        }
9142                    }
9143                }
9144                body::BodyStmt::After(after) => walk(&after.body, semantic, diagnostics),
9145                body::BodyStmt::Case(case) => {
9146                    for branch in &case.branches {
9147                        walk(&branch.body, semantic, diagnostics);
9148                    }
9149                }
9150                _ => {}
9151            }
9152        }
9153    }
9154    walk(&ast.statements, semantic, diagnostics);
9155}
9156
9157/// In-turn agent observations — `agent.turn.streamed` (streamed progress),
9158/// `agent.turn.tool_requested` (in-turn tool call), and `agent.turn.artifact_captured`
9159/// (captured artifact/diff) — are recorded as EVIDENCE, never as rule-matchable facts
9160/// (spec/agent-harness.md). The rule-matchable lifecycle facts are
9161/// `agent.turn.started/completed/failed/timed_out/cancelled`. A `when` that matches an
9162/// evidence-only fact can never fire, so it is a compile-time error.
9163const EVIDENCE_ONLY_TURN_FACTS: [&str; 3] = [
9164    "agent.turn.streamed",
9165    "agent.turn.tool_requested",
9166    "agent.turn.artifact_captured",
9167];
9168
9169/// Structural well-formedness of access grants (`with access to <resource> { … }`):
9170/// a grant must grant at least one operation, and a single effect must not list the
9171/// same resource twice (merge them). The deeper "required
9172/// Resource/Operation/Capability ports" validation against the capability registry
9173/// is a separate construct-graph-layer concern, so this stays registry-independent
9174/// and zero-false-positive.
9175fn validate_turn_access_grants(
9176    rule: &RuleDecl,
9177    metadata: &IrRuleMetadata,
9178    diagnostics: &mut Vec<Diagnostic>,
9179) {
9180    for effect in &metadata.effects {
9181        if effect.access_grants.is_empty() {
9182            continue;
9183        }
9184        let mut seen = BTreeSet::new();
9185        for grant in &effect.access_grants {
9186            if grant.operations.is_empty() {
9187                diagnostics.push(Diagnostic {
9188                    related: Vec::new(),
9189                    span: effect.span,
9190                    message: format!(
9191                        "rule `{}` has a `with access to {}` grant that grants no operations",
9192                        rule.name.name, grant.resource
9193                    ),
9194                    suggestion: Some(
9195                        "list at least one operation in the grant block, or drop the grant"
9196                            .to_owned(),
9197                    ),
9198                });
9199            }
9200            if !seen.insert(grant.resource.clone()) {
9201                diagnostics.push(Diagnostic {
9202                    related: Vec::new(),
9203                    span: effect.span,
9204                    message: format!(
9205                        "rule `{}` lists access resource `{}` more than once on one effect",
9206                        rule.name.name, grant.resource
9207                    ),
9208                    suggestion: Some(
9209                        "merge the grant clauses for a resource into a single block".to_owned(),
9210                    ),
9211                });
9212            }
9213        }
9214    }
9215}
9216
9217fn validate_evidence_fact_not_matched(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
9218    for when in &rule.whens {
9219        let (pattern, _) = split_when_guard(&when.text);
9220        let Some(name) = runtime_fact_name_for_pattern(pattern) else {
9221            continue;
9222        };
9223        if EVIDENCE_ONLY_TURN_FACTS.contains(&name.as_str()) {
9224            diagnostics.push(Diagnostic { related: Vec::new(),
9225                span: when.span,
9226                message: format!(
9227                    "rule `{}` matches evidence-only fact `{name}`: in-turn observations are evidence, not rule-matchable facts",
9228                    rule.name.name
9229                ),
9230                suggestion: Some(
9231                    "match a lifecycle fact (`agent.turn.completed`/`failed`/`timed_out`/`cancelled`) and read in-turn detail from its evidence".to_owned(),
9232                ),
9233            });
9234        }
9235    }
9236}
9237
9238/// DR-0043 Decision 5: extracts each rule's `during`/`until` region (at most
9239/// one per rule in v1), REWRITES the rule body to the condition-HOLDS variant
9240/// (region content spliced inline — every downstream scanner and effect-id
9241/// derivation sees ordinary text), and returns the pre-rendered region
9242/// metadata (removed / lapsed variants, region effect scopes) to attach onto
9243/// the lowered `IrRule`s.
9244fn extract_rule_regions(
9245    items: &mut [Item],
9246    diagnostics: &mut Vec<Diagnostic>,
9247) -> BTreeMap<String, IrRegion> {
9248    let mut pending = BTreeMap::new();
9249    for item in items.iter_mut() {
9250        let Item::Rule(rule) = item else {
9251            continue;
9252        };
9253        let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9254        let mut regions = Vec::new();
9255        collect_region_blocks(&ast.statements, &mut regions);
9256        if regions.is_empty() {
9257            continue;
9258        }
9259        if regions.len() > 1 {
9260            diagnostics.push(Diagnostic {
9261                related: Vec::new(),
9262                span: regions[1].span,
9263                message: format!(
9264                    "rule `{}` declares more than one `during`/`until` region",
9265                    rule.name.name
9266                ),
9267                suggestion: Some(
9268                    "v1 supports one region per rule (including nested regions); split the \
9269                     rule or merge the conditions"
9270                        .to_owned(),
9271                ),
9272            });
9273            continue;
9274        }
9275        let region = regions[0].clone();
9276        if count_effect_statements(&region.body) == 0 {
9277            diagnostics.push(Diagnostic {
9278                related: Vec::new(),
9279                span: region.span,
9280                message: format!(
9281                    "the `{}` region in rule `{}` contains no progression",
9282                    if region.until { "until" } else { "during" },
9283                    rule.name.name
9284                ),
9285                suggestion: Some(
9286                    "a region around purely-atomic actions commits with admission and can \
9287                     never lapse between steps; it needs at least one effect with a \
9288                     continuation"
9289                        .to_owned(),
9290                ),
9291            });
9292            continue;
9293        }
9294        // Lapse-arm binding scope: the arm may run at ANY point inside the
9295        // region, so it may only reference bindings guaranteed at region
9296        // entry — never a binding the region itself introduces (the optional
9297        // progress view is the sanctioned window into those).
9298        let mut region_bindings = BTreeSet::new();
9299        collect_all_binding_names(&region.body, &mut region_bindings);
9300        if let Some(view) = &region.lapse_binding {
9301            region_bindings.remove(view);
9302        }
9303        let mut arm_roots = BTreeSet::new();
9304        collect_statement_roots(&region.lapse_body, &mut arm_roots);
9305        for root in &arm_roots {
9306            if region_bindings.contains(root) {
9307                diagnostics.push(Diagnostic {
9308                    related: Vec::new(),
9309                    span: region.span,
9310                    message: format!(
9311                        "the `on lapse` arm of rule `{}` references `{root}`, a binding the \
9312                         region introduces — it may not exist when the arm runs",
9313                        rule.name.name
9314                    ),
9315                    suggestion: Some(
9316                        "reference only bindings from before the region, or bind the \
9317                         progress view (`on lapse as got`) and read `got.<binding>` — its \
9318                         fields are present exactly if that step settled"
9319                            .to_owned(),
9320                    ),
9321                });
9322            }
9323        }
9324        // Variant surgery. All spans are absolute; rebase onto the body text.
9325        let base = rule.body.span.start;
9326        let text = rule.body.text.clone();
9327        let clamp = |offset: usize| offset.saturating_sub(base).min(text.len());
9328        let (r_start, r_end) = (clamp(region.span.start), clamp(region.span.end));
9329        let (b_start, b_end) = (clamp(region.body_span.start), clamp(region.body_span.end));
9330        let (l_start, l_end) = (clamp(region.lapse_span.start), clamp(region.lapse_span.end));
9331        if !(r_start <= b_start
9332            && b_start <= b_end
9333            && b_end <= l_start
9334            && l_start <= l_end
9335            && l_end <= r_end)
9336        {
9337            diagnostics.push(Diagnostic {
9338                related: Vec::new(),
9339                span: region.span,
9340                message: format!(
9341                    "internal: region span reconstruction failed for rule `{}`",
9342                    rule.name.name
9343                ),
9344                suggestion: None,
9345            });
9346            continue;
9347        }
9348        let body_content = &text[b_start..b_end];
9349        let arm_content = &text[l_start..l_end];
9350        let variant_holds = format!("{}{}{}", &text[..r_start], body_content, &text[r_end..]);
9351        let variant_removed = format!("{}{}", &text[..r_start], &text[r_end..]);
9352        let variant_lapsed = format!("{}{}{}", &text[..r_start], arm_content, &text[r_end..]);
9353        // Region effect scopes, computed on the HOLDS variant (the canonical
9354        // kernel body): each region-owned effect binding's LEVEL-1 `after`
9355        // ancestor is the scope the kernel keys its effect id under.
9356        let mut effect_bindings = BTreeSet::new();
9357        collect_effect_binding_names(&region.body, &mut effect_bindings);
9358        let (holds_ast, _) = body::parse_rule_body(&variant_holds, 0);
9359        let mut region_effects = Vec::new();
9360        assign_region_effect_scopes(
9361            &holds_ast.statements,
9362            None,
9363            &effect_bindings,
9364            &mut region_effects,
9365        );
9366        pending.insert(
9367            rule.name.name.clone(),
9368            IrRegion {
9369                until: region.until,
9370                condition: region.condition.clone(),
9371                lapse_binding: region.lapse_binding.clone(),
9372                effects: region_effects,
9373                body_removed: variant_removed,
9374                body_lapsed: variant_lapsed,
9375            },
9376        );
9377        rule.body.text = variant_holds;
9378    }
9379    pending
9380}
9381
9382fn collect_region_blocks(statements: &[body::BodyStmt], out: &mut Vec<body::RegionBlock>) {
9383    for statement in statements {
9384        match statement {
9385            body::BodyStmt::Region(region) => {
9386                out.push(region.clone());
9387                collect_region_blocks(&region.body, out);
9388                collect_region_blocks(&region.lapse_body, out);
9389            }
9390            body::BodyStmt::After(after) => collect_region_blocks(&after.body, out),
9391            body::BodyStmt::Case(case) => {
9392                for branch in &case.branches {
9393                    collect_region_blocks(&branch.body, out);
9394                }
9395            }
9396            _ => {}
9397        }
9398    }
9399}
9400
9401fn count_effect_statements(statements: &[body::BodyStmt]) -> usize {
9402    let mut count = 0;
9403    for statement in statements {
9404        match statement {
9405            body::BodyStmt::Effect(_) => count += 1,
9406            body::BodyStmt::After(after) => count += count_effect_statements(&after.body),
9407            body::BodyStmt::Case(case) => {
9408                for branch in &case.branches {
9409                    count += count_effect_statements(&branch.body);
9410                }
9411            }
9412            body::BodyStmt::Region(region) => {
9413                count += count_effect_statements(&region.body);
9414            }
9415            _ => {}
9416        }
9417    }
9418    count
9419}
9420
9421fn collect_effect_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
9422    for statement in statements {
9423        match statement {
9424            body::BodyStmt::Effect(effect) => {
9425                if let Some(binding) = &effect.binding {
9426                    out.insert(binding.clone());
9427                }
9428            }
9429            body::BodyStmt::After(after) => collect_effect_binding_names(&after.body, out),
9430            body::BodyStmt::Case(case) => {
9431                for branch in &case.branches {
9432                    collect_effect_binding_names(&branch.body, out);
9433                }
9434            }
9435            body::BodyStmt::Region(region) => {
9436                collect_effect_binding_names(&region.body, out);
9437            }
9438            _ => {}
9439        }
9440    }
9441}
9442
9443/// Walks the HOLDS-variant AST assigning each region-owned effect its LEVEL-1
9444/// `after` scope (the kernel's effect-id key component). `level1` is fixed at
9445/// the first `after` ancestor and inherited by everything deeper.
9446fn assign_region_effect_scopes(
9447    statements: &[body::BodyStmt],
9448    level1: Option<&(String, String)>,
9449    region_bindings: &BTreeSet<String>,
9450    out: &mut Vec<IrRegionEffect>,
9451) {
9452    for statement in statements {
9453        match statement {
9454            body::BodyStmt::Effect(effect) => {
9455                if let Some(binding) = &effect.binding {
9456                    if region_bindings.contains(binding)
9457                        && !out.iter().any(|known| &known.binding == binding)
9458                    {
9459                        out.push(IrRegionEffect {
9460                            binding: binding.clone(),
9461                            scope: level1.cloned(),
9462                        });
9463                    }
9464                }
9465            }
9466            body::BodyStmt::After(after) => {
9467                let own = (
9468                    after.binding.clone(),
9469                    after.predicate.kernel_str().to_owned(),
9470                );
9471                let next = level1.cloned().unwrap_or(own);
9472                assign_region_effect_scopes(&after.body, Some(&next), region_bindings, out);
9473            }
9474            body::BodyStmt::Case(case) => {
9475                for branch in &case.branches {
9476                    assign_region_effect_scopes(&branch.body, level1, region_bindings, out);
9477                }
9478            }
9479            body::BodyStmt::Region(region) => {
9480                assign_region_effect_scopes(&region.body, level1, region_bindings, out);
9481            }
9482            _ => {}
9483        }
9484    }
9485}
9486
9487/// Root identifiers referenced by a statement list's value positions: record
9488/// fields, terminal fields, done/cancel bindings, effect arguments, and
9489/// `{{ … }}` prompt interpolations. Used for the lapse-arm scope check.
9490fn collect_statement_roots(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
9491    fn roots_in_expr(source: &str, out: &mut BTreeSet<String>) {
9492        let bytes = source.as_bytes();
9493        let mut i = 0;
9494        let mut in_string = false;
9495        while i < bytes.len() {
9496            let c = bytes[i] as char;
9497            if c == '"' {
9498                in_string = !in_string;
9499                i += 1;
9500                continue;
9501            }
9502            if in_string {
9503                i += 1;
9504                continue;
9505            }
9506            if c.is_ascii_alphabetic() || c == '_' {
9507                let start = i;
9508                while i < bytes.len() {
9509                    let cj = bytes[i] as char;
9510                    if cj.is_ascii_alphanumeric() || cj == '_' {
9511                        i += 1;
9512                    } else {
9513                        break;
9514                    }
9515                }
9516                let preceded_by_dot = start > 0 && bytes[start - 1] as char == '.';
9517                if !preceded_by_dot {
9518                    out.insert(source[start..i].to_owned());
9519                }
9520                continue;
9521            }
9522            i += 1;
9523        }
9524    }
9525    fn roots_in_fields(fields: &[body::FieldAssign], out: &mut BTreeSet<String>) {
9526        for field in fields {
9527            match &field.value {
9528                body::FieldValue::Expr { source, .. } => roots_in_expr(source, out),
9529                body::FieldValue::Nested { fields, .. } => roots_in_fields(fields, out),
9530                body::FieldValue::Shorthand => {
9531                    out.insert(field.name.clone());
9532                }
9533            }
9534        }
9535    }
9536    fn roots_in_prompt(text: &str, out: &mut BTreeSet<String>) {
9537        let mut rest = text;
9538        while let Some(open) = rest.find("{{") {
9539            let tail = &rest[open + 2..];
9540            let Some(close) = tail.find("}}") else {
9541                break;
9542            };
9543            roots_in_expr(&tail[..close], out);
9544            rest = &tail[close + 2..];
9545        }
9546    }
9547    for statement in statements {
9548        match statement {
9549            body::BodyStmt::Record(record) => roots_in_fields(&record.fields, out),
9550            body::BodyStmt::Done {
9551                binding,
9552                replacement,
9553                ..
9554            } => {
9555                out.insert(binding.clone());
9556                if let Some(record) = replacement {
9557                    roots_in_fields(&record.fields, out);
9558                }
9559            }
9560            body::BodyStmt::Cancel { binding, .. } => {
9561                out.insert(binding.clone());
9562            }
9563            body::BodyStmt::Effect(effect) => {
9564                if let Some(prompt) = &effect.prompt {
9565                    roots_in_prompt(&prompt.text, out);
9566                }
9567                match &effect.kind {
9568                    body::BodyEffectKind::Coerce { args, .. } => {
9569                        for arg in args {
9570                            roots_in_expr(arg, out);
9571                        }
9572                    }
9573                    body::BodyEffectKind::TrackerFinish { item, fields } => {
9574                        out.insert(item.clone());
9575                        roots_in_fields(fields, out);
9576                    }
9577                    body::BodyEffectKind::TrackerRelease { item } => {
9578                        out.insert(item.clone());
9579                    }
9580                    _ => {}
9581                }
9582            }
9583            body::BodyStmt::Terminal(terminal) => {
9584                roots_in_fields(&terminal.fields, out);
9585                if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
9586                    roots_in_expr(source, out);
9587                }
9588            }
9589            body::BodyStmt::Milestone { fields, .. } => roots_in_fields(fields, out),
9590            body::BodyStmt::After(after) => collect_statement_roots(&after.body, out),
9591            body::BodyStmt::Case(case) => {
9592                roots_in_expr(&case.scrutinee, out);
9593                for branch in &case.branches {
9594                    collect_statement_roots(&branch.body, out);
9595                }
9596            }
9597            body::BodyStmt::Region(region) => {
9598                collect_statement_roots(&region.body, out);
9599                collect_statement_roots(&region.lapse_body, out);
9600            }
9601            body::BodyStmt::Redact { source, .. } => {
9602                out.insert(source.clone());
9603            }
9604        }
9605    }
9606}
9607
9608fn validate_effectful_self_trigger(
9609    rule: &RuleDecl,
9610    metadata: &IrRuleMetadata,
9611    diagnostics: &mut Vec<Diagnostic>,
9612) {
9613    if metadata.effects.is_empty() {
9614        return;
9615    }
9616
9617    for written_fact in &metadata.fact_writes {
9618        if metadata.fact_reads.contains(written_fact)
9619            && !metadata.fact_consumes.contains(written_fact)
9620        {
9621            diagnostics.push(Diagnostic { related: Vec::new(),
9622                span: rule.body.span,
9623                message: format!(
9624                    "effectful rule `{}` preserves trigger fact `{written_fact}`",
9625                    rule.name.name
9626                ),
9627                suggestion: Some(
9628                    "consume or advance the triggering fact, or move the next effect behind an external completion event"
9629                        .to_owned(),
9630                ),
9631            });
9632        }
9633    }
9634}
9635
9636fn binding_types_for_rule(rule: &RuleDecl) -> BTreeMap<String, String> {
9637    let mut binding_types = BTreeMap::new();
9638    for when in &rule.whens {
9639        if let Some((binding, schema)) = binding_from_when(&when.text) {
9640            binding_types.insert(binding, schema);
9641        }
9642    }
9643    binding_types
9644}
9645
9646fn validate_workflow_terminal_actions(
9647    rule: &RuleDecl,
9648    semantic: &SemanticContext,
9649    binding_types: &BTreeMap<String, String>,
9650    known_roots: &BTreeSet<String>,
9651    contracts: &WorkflowContractNames,
9652    diagnostics: &mut Vec<Diagnostic>,
9653) {
9654    for line in rule.body.text.lines().map(str::trim) {
9655        let terminal = line
9656            .strip_prefix("complete ")
9657            .map(|rest| ("complete", rest, &contracts.outputs))
9658            .or_else(|| {
9659                line.strip_prefix("fail ")
9660                    .map(|rest| ("fail", rest, &contracts.failures))
9661            });
9662        let Some((action, rest, declared)) = terminal else {
9663            continue;
9664        };
9665        // Scalar terminal form: `complete result 0.9` / `fail error "msg"` — a bare
9666        // value after the name, with no `{ }` block and no `from` projection.
9667        // Validated against a scalar (primitive) contract; class contracts still
9668        // require a field block (checked by the block path below).
9669        if !rest.contains('{') {
9670            let tokens: Vec<&str> = rest.split_whitespace().collect();
9671            let is_from = matches!(tokens.as_slice(), [_, "from", ..]) && action == "complete";
9672            if tokens.len() >= 2 && !is_from {
9673                let name = tokens[0];
9674                let value = rest.trim().get(name.len()..).unwrap_or("").trim();
9675                if !declared.contains_key(name) {
9676                    diagnostics.push(Diagnostic {
9677                        related: Vec::new(),
9678                        span: rule.body.span,
9679                        message: format!(
9680                            "rule `{}` {action}s unknown workflow terminal `{name}`",
9681                            rule.name.name
9682                        ),
9683                        suggestion: Some(format!(
9684                            "declare `{kind} {name} Type` on the workflow first",
9685                            kind = if action == "complete" {
9686                                "output"
9687                            } else {
9688                                "failure"
9689                            }
9690                        )),
9691                    });
9692                    continue;
9693                }
9694                if let Some(contract_ty) = declared.get(name) {
9695                    validate_scalar_terminal_payload(
9696                        rule,
9697                        action,
9698                        name,
9699                        value,
9700                        contract_ty,
9701                        semantic,
9702                        binding_types,
9703                        known_roots,
9704                        diagnostics,
9705                    );
9706                }
9707                continue;
9708            }
9709        }
9710        // Header is `<name>` or (for `complete`) `<name> from <binding>` — the
9711        // bounded-type projection form (DR-0027), whose payload copies the binding.
9712        let Some(name) = rest.split('{').next().and_then(|header| {
9713            let mut parts = header.split_whitespace();
9714            match (parts.next(), parts.next(), parts.next()) {
9715                (Some(name), None, _) => Some(name),
9716                (Some(name), Some("from"), Some(binding))
9717                    if action == "complete" && is_identifier(binding) =>
9718                {
9719                    Some(name)
9720                }
9721                _ => None,
9722            }
9723        }) else {
9724            diagnostics.push(Diagnostic {
9725                related: Vec::new(),
9726                span: rule.body.span,
9727                message: format!("rule `{}` has malformed `{action}` action", rule.name.name),
9728                suggestion: Some(format!(
9729                    "{action} a declared workflow terminal with a payload block"
9730                )),
9731            });
9732            continue;
9733        };
9734        if !declared.contains_key(name) {
9735            diagnostics.push(Diagnostic {
9736                related: Vec::new(),
9737                span: rule.body.span,
9738                message: format!(
9739                    "rule `{}` {action}s unknown workflow terminal `{name}`",
9740                    rule.name.name
9741                ),
9742                suggestion: Some(format!(
9743                    "declare `{kind} {name} Type` on the workflow first",
9744                    kind = if action == "complete" {
9745                        "output"
9746                    } else {
9747                        "failure"
9748                    }
9749                )),
9750            });
9751            continue;
9752        }
9753        let Some(contract_ty) = declared.get(name) else {
9754            continue;
9755        };
9756        validate_workflow_terminal_payload(
9757            rule,
9758            action,
9759            name,
9760            contract_ty,
9761            semantic,
9762            binding_types,
9763            known_roots,
9764            diagnostics,
9765        );
9766    }
9767}
9768
9769#[allow(clippy::too_many_arguments)]
9770fn validate_workflow_terminal_payload(
9771    rule: &RuleDecl,
9772    action: &str,
9773    terminal_name: &str,
9774    contract_ty: &TypeSyntax,
9775    semantic: &SemanticContext,
9776    binding_types: &BTreeMap<String, String>,
9777    known_roots: &BTreeSet<String>,
9778    diagnostics: &mut Vec<Diagnostic>,
9779) {
9780    let Some((_, _, body)) = workflow_terminal_blocks(&rule.body.text).into_iter().find(
9781        |(candidate_action, candidate_name, _)| {
9782            candidate_action == action && candidate_name == terminal_name
9783        },
9784    ) else {
9785        return;
9786    };
9787    let schema = match contract_ty {
9788        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => &name.name,
9789        TypeSyntax::Primitive { .. }
9790        | TypeSyntax::LiteralString { .. }
9791        | TypeSyntax::Union { .. } => {
9792            // A scalar (primitive/literal/union) contract takes a bare value, not
9793            // a field block.
9794            diagnostics.push(Diagnostic {
9795                related: Vec::new(),
9796                span: rule.body.span,
9797                message: format!(
9798                    "workflow terminal `{terminal_name}` has a scalar payload contract but is given a field block"
9799                ),
9800                suggestion: Some(format!(
9801                    "write a bare scalar value: `{action} {terminal_name} <value>`"
9802                )),
9803            });
9804            return;
9805        }
9806        _ => {
9807            diagnostics.push(Diagnostic { related: Vec::new(),
9808                span: rule.body.span,
9809                message: format!(
9810                    "workflow terminal `{terminal_name}` uses an unsupported payload contract type"
9811                ),
9812                suggestion: Some(
9813                    "declare the terminal payload as a class (field block) or a scalar type (number/string/bool)"
9814                        .to_owned(),
9815                ),
9816            });
9817            return;
9818        }
9819    };
9820    for assignment in collect_field_assignments(&body) {
9821        let (field, value) = match assignment {
9822            RecordFieldAssignment::Value { field, value } => (field, value),
9823            RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
9824        };
9825        let line = format!("{field} {value}");
9826        validate_record_field(
9827            rule,
9828            &line,
9829            schema,
9830            semantic,
9831            binding_types,
9832            known_roots,
9833            diagnostics,
9834        );
9835    }
9836    validate_required_terminal_fields(rule, schema, terminal_name, &body, semantic, diagnostics);
9837}
9838
9839/// Validates a bare-scalar terminal payload (`complete result 0.9` /
9840/// `fail error "reason"`) against a scalar output/failure contract. A class
9841/// contract is rejected (it needs a field block); a literal value is typechecked
9842/// against the primitive/enum/union contract, and a binding-expression value has
9843/// its roots and field path validated.
9844#[allow(clippy::too_many_arguments)]
9845fn validate_scalar_terminal_payload(
9846    rule: &RuleDecl,
9847    action: &str,
9848    terminal_name: &str,
9849    value: &str,
9850    contract_ty: &TypeSyntax,
9851    semantic: &SemanticContext,
9852    binding_types: &BTreeMap<String, String>,
9853    known_roots: &BTreeSet<String>,
9854    diagnostics: &mut Vec<Diagnostic>,
9855) {
9856    if let TypeSyntax::Ref { name } = contract_ty {
9857        if semantic.schemas.class_exists(&name.name) {
9858            diagnostics.push(Diagnostic {
9859                related: Vec::new(),
9860                span: rule.body.span,
9861                message: format!(
9862                    "workflow terminal `{terminal_name}` has a class payload contract `{}` but is given a bare scalar value",
9863                    name.name
9864                ),
9865                suggestion: Some(format!("write a field block: `{action} {terminal_name} {{ … }}`")),
9866            });
9867            return;
9868        }
9869    }
9870    if value.is_empty() {
9871        diagnostics.push(Diagnostic {
9872            related: Vec::new(),
9873            span: rule.body.span,
9874            message: format!("workflow terminal `{terminal_name}` is missing its scalar value"),
9875            suggestion: Some(format!("write `{action} {terminal_name} <value>`")),
9876        });
9877        return;
9878    }
9879    // A literal value is typechecked against the scalar contract; a binding
9880    // expression has its roots (and any field path) validated.
9881    validate_literal_assignment(
9882        rule,
9883        terminal_name,
9884        "value",
9885        contract_ty,
9886        value,
9887        semantic,
9888        diagnostics,
9889    );
9890    if let Some(root) = dangling_value_root(value, known_roots) {
9891        diagnostics.push(Diagnostic {
9892            related: Vec::new(),
9893            span: rule.body.span,
9894            message: format!(
9895                "rule `{}` has unknown binding `{root}` in `{action} {terminal_name}` value",
9896                rule.name.name
9897            ),
9898            suggestion: Some(
9899                "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
9900                    .to_owned(),
9901            ),
9902        });
9903    } else if let Some((root, path)) = expression_path(value) {
9904        if let Some(schema) = binding_types.get(&root) {
9905            if semantic.schemas.class_exists(schema) {
9906                if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
9907                    diagnostics.push(Diagnostic {
9908                        related: Vec::new(),
9909                        span: rule.body.span,
9910                        message: format!(
9911                            "rule `{}` has invalid field path `{root}.{}`: {message}",
9912                            rule.name.name,
9913                            path.join(".")
9914                        ),
9915                        suggestion: Some(
9916                            "use a field declared on the bound schema or add it to the class declaration"
9917                                .to_owned(),
9918                        ),
9919                    });
9920                }
9921            }
9922        }
9923    }
9924}
9925
9926fn validate_required_terminal_fields(
9927    rule: &RuleDecl,
9928    schema: &str,
9929    terminal_name: &str,
9930    body: &str,
9931    semantic: &SemanticContext,
9932    diagnostics: &mut Vec<Diagnostic>,
9933) {
9934    let Some(schema_fields) = semantic.schemas.classes.get(schema) else {
9935        return;
9936    };
9937    let seen = collect_field_assignments(body)
9938        .into_iter()
9939        .map(|assignment| match assignment {
9940            RecordFieldAssignment::Value { field, .. }
9941            | RecordFieldAssignment::Shorthand { field } => field,
9942        })
9943        .collect::<BTreeSet<_>>();
9944    for (required, ty) in schema_fields {
9945        if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
9946            continue;
9947        }
9948        diagnostics.push(Diagnostic { related: Vec::new(),
9949            span: rule.body.span,
9950            message: format!(
9951                "workflow terminal `{terminal_name}` is missing required field `{schema}.{required}`"
9952            ),
9953            suggestion: Some(format!("add `{required}` to the `{terminal_name}` payload")),
9954        });
9955    }
9956}
9957
9958/// Maximum nesting depth of `after` blocks across `statements` (an `after` inside an
9959/// `after` is depth 2, …). Other nesting (`case`/`when`/handlers) is descended into so
9960/// an `after` buried inside them still counts, but only `after` increments the depth —
9961/// it is `after`-chaining specifically that `lint.deep_after_nesting` advises moving to
9962/// a `flow`. Computed from the body AST so prompt braces never confuse it.
9963fn max_after_depth(statements: &[body::BodyStmt]) -> usize {
9964    use body::BodyStmt;
9965    statements
9966        .iter()
9967        .map(|statement| match statement {
9968            BodyStmt::After(after) => 1 + max_after_depth(&after.body),
9969            BodyStmt::Case(case) => case
9970                .branches
9971                .iter()
9972                .map(|branch| max_after_depth(&branch.body))
9973                .max()
9974                .unwrap_or(0),
9975            _ => 0,
9976        })
9977        .max()
9978        .unwrap_or(0)
9979}
9980
9981fn analyze_rule(
9982    rule: &RuleDecl,
9983    semantic: &SemanticContext,
9984    diagnostics: &mut Vec<Diagnostic>,
9985) -> IrRuleMetadata {
9986    // Statement-form gate: every body must parse into the body AST. Unknown
9987    // statements, malformed modifiers, and unclosed blocks are spanned
9988    // errors here rather than silent no-ops at lowering time.
9989    let (body_ast, body_diagnostics) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9990    diagnostics.extend(body_diagnostics);
9991    let mut metadata = IrRuleMetadata {
9992        fact_reads: rule
9993            .whens
9994            .iter()
9995            .map(|when| fact_read_from_when(&when.text))
9996            .collect(),
9997        max_after_depth: max_after_depth(&body_ast.statements),
9998        ..IrRuleMetadata::default()
9999    };
10000    let mut seen_bindings = BTreeSet::new();
10001    let mut binding_types = BTreeMap::new();
10002    for when in &rule.whens {
10003        // A pattern that binds (`... as x`) but maps to no known readiness
10004        // form would otherwise be a silently-dead rule.
10005        let (pattern_text, _) = split_when_guard(&when.text);
10006        if binding_after_as(pattern_text).is_some()
10007            && binding_from_when(&when.text).is_none()
10008            && !pattern_text.ends_with(" is available")
10009        {
10010            diagnostics.push(Diagnostic {
10011                related: Vec::new(),
10012                span: when.span,
10013                message: format!(
10014                    "rule `{}` has unknown readiness pattern `{pattern_text}`",
10015                    rule.name.name
10016                ),
10017                suggestion: Some(
10018                    "match a class (`when Class as x`) or a runtime fact (`when fact <name> as x`)"
10019                        .to_owned(),
10020                ),
10021            });
10022        }
10023        if let Some((binding, schema)) = binding_from_when(&when.text) {
10024            validate_binding_name(rule, &binding, when.span, diagnostics);
10025            if !schema.contains('.') && !semantic.schemas.class_exists(&schema) {
10026                let suggestion = match closest_name(&schema, semantic.schemas.classes.keys()) {
10027                    Some(candidate) => {
10028                        format!("did you mean `{candidate}`? otherwise declare `class {schema}`")
10029                    }
10030                    None => format!("declare `class {schema}` before matching it"),
10031                };
10032                diagnostics.push(Diagnostic {
10033                    related: Vec::new(),
10034                    span: when.span,
10035                    message: format!("rule `{}` matches unknown class `{schema}`", rule.name.name),
10036                    suggestion: Some(suggestion),
10037                });
10038            }
10039            // The bare dotted form is the typed signal reaction
10040            // (spec/event-ingress.md): it requires a declared `signal`;
10041            // undeclared dotted facts keep the untyped `when fact` form.
10042            if schema.contains('.')
10043                && !pattern_text.trim_start().starts_with("fact ")
10044                && !semantic.schemas.events.contains(&schema)
10045            {
10046                diagnostics.push(Diagnostic { related: Vec::new(),
10047                    span: when.span,
10048                    message: format!(
10049                        "rule `{}` reacts to undeclared signal `{schema}`",
10050                        rule.name.name
10051                    ),
10052                    suggestion: Some(format!(
10053                        "declare `signal {schema} {{ ... }}` for a typed reaction, or use `when fact {schema} as ...` for an untyped one"
10054                    )),
10055                });
10056            }
10057            binding_types.insert(binding, schema);
10058        }
10059    }
10060    let mut effect_payload_types = collect_effect_payload_types(rule, semantic, diagnostics);
10061    // `exec ... -> Schema as binding` is parsed from the AST (the command text
10062    // can itself contain `->`/` as `, so a text scan is unsafe), giving its
10063    // result the same after-binding type flow a named `coerce -> Schema` gets.
10064    collect_exec_payload_types(&body_ast.statements, semantic, &mut effect_payload_types);
10065    // Inline `decide … as <binding>` carries the synthesized
10066    // `decide.<rule>.<binding>` class (see `collect_inline_decide_schemas`), so
10067    // its result is `case`able / field-accessible like a named coerce result.
10068    collect_decide_payload_types(
10069        &body_ast.statements,
10070        &rule.name.name,
10071        &mut effect_payload_types,
10072    );
10073    collect_prompt_payload_types(&body_ast.statements, &mut effect_payload_types);
10074    // `redact … as <binding>` result carries the synthesized `redact.<rule>.<binding>`
10075    // projected class (see `collect_redact_schemas`), so access through it resolves
10076    // against the kept-only fields.
10077    collect_redact_payload_types(
10078        &body_ast.statements,
10079        &rule.name.name,
10080        &mut effect_payload_types,
10081    );
10082    for (binding, payload_type) in &effect_payload_types {
10083        if let IrType::Ref(schema) = payload_type {
10084            binding_types.insert(binding.clone(), schema.clone());
10085        }
10086    }
10087    // Effect-kind map for the `fails`-arm static narrowing (DR-0032 P3): the
10088    // failing effect's kind is always statically known at the read site. Scan
10089    // raw lines first (every single-line effect form), then the balanced
10090    // multi-line statements (a `coerce` whose arguments span lines carries its
10091    // binding on the closing line).
10092    let mut effect_binding_kinds: BTreeMap<String, IrEffectKind> = rule
10093        .body
10094        .text
10095        .lines()
10096        .filter_map(|line| {
10097            let line = line.trim();
10098            // `exec` is not in parse_effect_line (whose other callers feed the
10099            // rule-metadata effect count); match it here for kind narrowing.
10100            if line.starts_with("exec ") {
10101                return Some((binding_after_as(line)?, IrEffectKind::ExecCommand));
10102            }
10103            let (kind, binding) = parse_effect_line(line)?;
10104            Some((binding?, kind))
10105        })
10106        .collect();
10107    for statement in effect_payload_statements(&rule.body.text) {
10108        if let Some((kind, Some(binding))) = parse_effect_line(statement.trim()) {
10109            effect_binding_kinds.insert(binding, kind);
10110        }
10111    }
10112    // `after <binding> <predicate> as <alias>`: the alias carries the
10113    // effect's completed payload type, so case dispatch and field access
10114    // through it type-check.
10115    for line in rule.body.text.lines() {
10116        let Some(rest) = line.trim().strip_prefix("after ") else {
10117            continue;
10118        };
10119        let mut words = rest.split_whitespace();
10120        let Some(binding) = words.next() else {
10121            continue;
10122        };
10123        let Some(predicate) = words.next() else {
10124            continue;
10125        };
10126        // Coordination ops are completion-valued: an `acquire` COMPLETES with
10127        // variant Held|Contended (counter `consume` with Ok|Over), so the
10128        // generic `succeeds` arm would fire on the negative outcome too — a
10129        // workflow proceeding "as if holding" on Contended. Reject `succeeds`
10130        // and force the variant vocabulary; `fails` (infra failures) and
10131        // `completes` (deliberate catch-all) stay legal.
10132        if predicate == "succeeds" {
10133            match effect_binding_kinds.get(binding) {
10134                Some(IrEffectKind::LeaseAcquire) => {
10135                    diagnostics.push(Diagnostic {
10136                        related: Vec::new(),
10137                        span: rule.body.span,
10138                        message: format!(
10139                            "rule `{}` observes acquire `{binding}` with `succeeds`, which also \
10140                             matches a Contended outcome (the acquire op completes either way)",
10141                            rule.name.name
10142                        ),
10143                        suggestion: Some(format!(
10144                            "use `after {binding} held` / `after {binding} contended` for the \
10145                             outcome variants, or `after {binding} completes` for any settled \
10146                             outcome"
10147                        )),
10148                    });
10149                }
10150                Some(IrEffectKind::CounterConsume) => {
10151                    diagnostics.push(Diagnostic {
10152                        related: Vec::new(),
10153                        span: rule.body.span,
10154                        message: format!(
10155                            "rule `{}` observes counter consume `{binding}` with `succeeds`, \
10156                             which also matches an Over outcome (the consume op completes \
10157                             either way)",
10158                            rule.name.name
10159                        ),
10160                        suggestion: Some(format!(
10161                            "use `after {binding} ok` / `after {binding} over` for the outcome \
10162                             variants, or `after {binding} completes` for any settled outcome"
10163                        )),
10164                    });
10165                }
10166                _ => {}
10167            }
10168        }
10169        // `after p reaches "<name>" as m` (Family C): the milestone name sits
10170        // between the predicate and `as`, so the alias lands one token later.
10171        // Type `m` to the child's declared milestone payload class.
10172        if predicate == "reaches" {
10173            let Some(quoted) = words.next() else {
10174                continue;
10175            };
10176            let milestone = quoted.trim_matches('"');
10177            let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
10178                continue;
10179            };
10180            let alias = alias.trim_end_matches('{').trim();
10181            if alias.is_empty() {
10182                continue;
10183            }
10184            if let Some(class) = milestone_payload_class(rule, binding, milestone, semantic) {
10185                if !class.is_empty() {
10186                    binding_types.insert(alias.to_owned(), class);
10187                }
10188            }
10189            continue;
10190        }
10191        // `times out` is the only two-token predicate; skip its second word so
10192        // the `as <alias>` clause lines up.
10193        if predicate == "times" && words.next() != Some("out") {
10194            continue;
10195        }
10196        let (Some(keyword), Some(alias)) = (words.next(), words.next()) else {
10197            continue;
10198        };
10199        if keyword != "as" {
10200            continue;
10201        }
10202        let alias = alias.trim_end_matches('{').trim();
10203        if alias.is_empty() {
10204            continue;
10205        }
10206        // Bind the alias to the terminal payload schema that matches the
10207        // predicate, consistent with the case-tag payload schemas
10208        // (terminal_payload_schema_for_tag): `times out` -> `TerminalTimedOut`,
10209        // `cancelled` -> `TerminalCancelled`. Other predicates carry the
10210        // effect's completed payload schema.
10211        match predicate {
10212            "times" => {
10213                binding_types.insert(alias.to_owned(), "TerminalTimedOut".to_owned());
10214            }
10215            "cancelled" => {
10216                binding_types.insert(alias.to_owned(), "TerminalCancelled".to_owned());
10217            }
10218            // `completes` binds the terminal-union ENVELOPE, not the success
10219            // schema: the runtime delivers {tag, status, summary, …} for ANY
10220            // settled outcome, and the payload is read via `case o {
10221            // Completed as v => … }`. The old success-schema typing approved
10222            // reads that were null on every non-success terminal.
10223            "completes" => {
10224                binding_types.insert(alias.to_owned(), "TerminalOutcome".to_owned());
10225            }
10226            // DR-0032: the `fails` branch binds the EffectError family — the
10227            // base `{reason, summary, effect_id, run_id, kind}` plus per-kind
10228            // extras narrowed STATICALLY by the binding's effect kind (P3 /
10229            // DQ-2): exec adds `exit_code`; schema.coerce adds `error_class` +
10230            // optional `http_status`; agent.tell adds `error_class`. Every
10231            // other kind stays on the plain base.
10232            //
10233            // Exception (typed invoke failure): when this is an invoke binding
10234            // whose child declares a SOLE, shared top-level FAILURE contract class,
10235            // bind the alias to THAT class so `f.<field>` type-checks against the
10236            // child's declared failure shape (the runtime merges the child payload
10237            // under the base). Invoke bindings with a child-local/unresolvable
10238            // failure class keep the `TerminalFailed` base.
10239            "fails" => {
10240                if let Some(class) = invoke_failure_class(rule, binding, semantic) {
10241                    binding_types.insert(alias.to_owned(), class);
10242                } else {
10243                    let schema = match effect_binding_kinds.get(binding) {
10244                        Some(IrEffectKind::ExecCommand) => "TerminalFailedExec",
10245                        Some(IrEffectKind::SchemaCoerce) => "TerminalFailedCoerce",
10246                        Some(IrEffectKind::AgentTell) => "TerminalFailedTell",
10247                        _ => "TerminalFailed",
10248                    };
10249                    binding_types.insert(alias.to_owned(), schema.to_owned());
10250                }
10251            }
10252            _ => {
10253                if let Some(IrType::Ref(schema)) = effect_payload_types.get(binding) {
10254                    binding_types.insert(alias.to_owned(), schema.clone());
10255                } else if let Some(class) = invoke_output_class(rule, binding, semantic) {
10256                    // Typed invoke result: `after <child> succeeds/completes as r`
10257                    // binds r to the child workflow's OUTPUT contract class, so
10258                    // `r.<field>` type-checks. (The runtime already carries the
10259                    // child's terminal payload into this binding.) The `fails` arm
10260                    // above keeps the DR-0032 failure base.
10261                    binding_types.insert(alias.to_owned(), class);
10262                }
10263            }
10264        }
10265    }
10266    for when in &rule.whens {
10267        if let (_, Some(guard)) = split_when_guard(&when.text) {
10268            validate_expression(rule, guard, semantic, &binding_types, "guard", diagnostics);
10269            validate_known_field_paths(rule, guard, semantic, &binding_types, diagnostics);
10270            if let Some(expr) = lower_expression(guard, when.span) {
10271                metadata
10272                    .projection_reads
10273                    .extend(collect_projection_reads(&expr.expr));
10274            }
10275        }
10276        validate_availability_when(rule, &when.text, semantic, &binding_types, diagnostics);
10277    }
10278    validate_case_blocks(rule, semantic, &binding_types, diagnostics);
10279    metadata.case_branches =
10280        collect_rule_case_metadata(rule, semantic, &binding_types, diagnostics);
10281    let terminal_metadata = collect_terminal_case_metadata(
10282        rule,
10283        semantic,
10284        &binding_types,
10285        &effect_payload_types,
10286        diagnostics,
10287    );
10288    // Complete value-position root set: typed bindings plus every binding NAME
10289    // the body introduces (AST-collected, so multi-line-prompt `tell`/`exec`
10290    // results and `case` payloads are covered, which `binding_types` omits).
10291    let mut known_roots: BTreeSet<String> = binding_types.keys().cloned().collect();
10292    collect_all_binding_names(&body_ast.statements, &mut known_roots);
10293    validate_record_blocks(rule, semantic, &binding_types, &known_roots, diagnostics);
10294    validate_effect_payloads(rule, semantic, &binding_types, &known_roots, diagnostics);
10295    validate_effect_field_roots(rule, &body_ast.statements, &known_roots, diagnostics);
10296    validate_emit_signal_declarations(
10297        rule,
10298        &body_ast.statements,
10299        &semantic.schemas.events,
10300        diagnostics,
10301    );
10302    validate_workflow_invocations(rule, semantic, &binding_types, &known_roots, diagnostics);
10303    validate_milestone_statements(rule, semantic, diagnostics);
10304    let mut block_stack: Vec<BlockFrame> = Vec::new();
10305    let mut misplaced_effect_bindings = BTreeSet::new();
10306    seed_ast_only_effect_bindings(&body_ast.statements, &mut seen_bindings, &mut binding_types);
10307    validate_body_effect_operands(
10308        rule,
10309        &body_ast.statements,
10310        semantic,
10311        &binding_types,
10312        diagnostics,
10313    );
10314    validate_coordination_discipline(rule, &body_ast.statements, diagnostics);
10315    // `redact <source> keep [..] as <out>`: the source must resolve to a known
10316    // schema and every kept field must exist on it (fail-closed).
10317    validate_redactions(
10318        rule,
10319        &body_ast.statements,
10320        semantic,
10321        &binding_types,
10322        diagnostics,
10323    );
10324    // Family B read-narrowing: a presence-conditioned field is readable only inside a
10325    // matching `case <root>.<disc>` arm (starts with nothing allowed at the rule top).
10326    validate_conditioned_field_reads(
10327        rule,
10328        &body_ast.statements,
10329        semantic,
10330        &binding_types,
10331        &BTreeSet::new(),
10332        diagnostics,
10333    );
10334    let mut anonymous_effects = 0usize;
10335    let mut record_depth = 0i32;
10336
10337    for raw_line in rule.body.text.lines() {
10338        let line = raw_line.trim();
10339        if line.is_empty() {
10340            continue;
10341        }
10342
10343        if record_depth > 0 {
10344            record_depth += brace_delta(line);
10345            continue;
10346        }
10347
10348        if let Some(binding) = binding_after_multiline_string_end(line) {
10349            misplaced_effect_bindings.insert(binding.clone());
10350            diagnostics.push(Diagnostic { related: Vec::new(),
10351                span: rule.body.span,
10352                message: format!(
10353                    "rule `{}` places effect binding `{binding}` after a multiline string delimiter",
10354                    rule.name.name
10355                ),
10356                suggestion: Some(format!(
10357                    "move `as {binding}` onto the effect line, before the multiline string body"
10358                )),
10359            });
10360            continue;
10361        }
10362        validate_rule_prompt_content_type_annotation(rule, line, diagnostics);
10363
10364        if line.starts_with('}') {
10365            block_stack.pop();
10366            continue;
10367        }
10368
10369        if line.starts_with("case ") || (!line.starts_with("after ") && is_case_branch_start(line))
10370        {
10371            validate_known_field_paths(rule, line, semantic, &binding_types, diagnostics);
10372            continue;
10373        }
10374
10375        let active_afters = after_scopes(&block_stack);
10376        validate_binding_uses(rule, line, &seen_bindings, &active_afters, diagnostics);
10377        validate_known_field_paths(rule, line, semantic, &binding_types, diagnostics);
10378
10379        if let Some(binding) = parse_consume_line(line) {
10380            match binding_types.get(&binding) {
10381                Some(schema) => metadata.fact_consumes.push(format!("schema:{schema}")),
10382                None => diagnostics.push(Diagnostic {
10383                    related: Vec::new(),
10384                    span: rule.body.span,
10385                    message: format!(
10386                        "rule `{}` consumes unknown fact binding `{binding}`",
10387                        rule.name.name
10388                    ),
10389                    suggestion: Some(
10390                        "consume a binding introduced by a `when Class as binding` clause"
10391                            .to_owned(),
10392                    ),
10393                }),
10394            }
10395            if !line.contains("->") {
10396                continue;
10397            }
10398        }
10399
10400        if line.starts_with("after ") {
10401            if let Some(alias) = binding_after_as(line) {
10402                validate_binding_name(rule, &alias, rule.body.span, diagnostics);
10403            }
10404            match parse_after_line(line) {
10405                Some((binding, predicate)) => {
10406                    if !seen_bindings.contains(&binding) {
10407                        let suggestion = if misplaced_effect_bindings.contains(&binding) {
10408                            format!(
10409                                "move `as {binding}` onto the effect line before the multiline string"
10410                            )
10411                        } else {
10412                            format!("create an effect with `as {binding}` before the `after` block")
10413                        };
10414                        diagnostics.push(Diagnostic { related: Vec::new(),
10415                            span: rule.body.span,
10416                            message: format!(
10417                                "rule `{}` has `after` block for unknown effect binding `{binding}`",
10418                                rule.name.name
10419                            ),
10420                            suggestion: Some(suggestion),
10421                        });
10422                    }
10423                    block_stack.push(BlockFrame::After { binding, predicate });
10424                }
10425                None => {
10426                    diagnostics.push(Diagnostic { related: Vec::new(),
10427                        span: rule.body.span,
10428                        message: format!(
10429                            "rule `{}` has unsupported `after` dependency predicate",
10430                            rule.name.name
10431                        ),
10432                        suggestion: Some(
10433                            "use `after name succeeds`, `after name fails`, `after name completes`, `after name times out`, or `after name cancelled`"
10434                                .to_owned(),
10435                        ),
10436                    });
10437                }
10438            }
10439            continue;
10440        }
10441
10442        if let Some((schema, _)) = parse_record_start(line) {
10443            if is_observer_only_schema(&schema) {
10444                diagnostics.push(Diagnostic {
10445                    related: Vec::new(),
10446                    span: rule.body.span,
10447                    message: format!(
10448                        "rule `{}` cannot record kernel-owned terminal schema `{schema}`",
10449                        rule.name.name
10450                    ),
10451                    suggestion: Some(
10452                        "the terminal family (`TerminalFailed`/`TerminalTimedOut`/`TerminalCancelled`) is produced only by the kernel; to fail this workflow use `fail <failure> { ... }`, and to react to an effect terminal use `after <effect> fails/times out/cancels as f`"
10453                            .to_owned(),
10454                    ),
10455                });
10456            } else if !semantic.schemas.class_exists(&schema) {
10457                diagnostics.push(Diagnostic {
10458                    related: Vec::new(),
10459                    span: rule.body.span,
10460                    message: format!("rule `{}` records unknown class `{schema}`", rule.name.name),
10461                    suggestion: Some(format!("declare `class {schema}` before recording it")),
10462                });
10463            }
10464            metadata.fact_writes.push(format!("schema:{schema}"));
10465            record_depth = brace_delta(line).max(1);
10466            continue;
10467        }
10468
10469        if let Some((kind, binding)) = parse_effect_line(line) {
10470            validate_agent_tell_target(
10471                rule,
10472                line,
10473                &kind,
10474                semantic,
10475                &binding_types,
10476                &known_roots,
10477                diagnostics,
10478            );
10479            anonymous_effects += 1;
10480            let id = binding
10481                .clone()
10482                .unwrap_or_else(|| format!("effect{anonymous_effects}"));
10483            if let Some(binding) = &binding {
10484                validate_binding_name(rule, binding, rule.body.span, diagnostics);
10485                seen_bindings.insert(binding.clone());
10486                if let Some(schema) = effect_binding_schema(line, &kind, semantic) {
10487                    binding_types.insert(binding.clone(), schema);
10488                }
10489            }
10490            for (upstream, predicate) in after_scopes(&block_stack) {
10491                metadata.dependencies.push(IrEffectDependency {
10492                    upstream,
10493                    predicate,
10494                    downstream: id.clone(),
10495                });
10496            }
10497            let idempotency_key = effect_idempotency_key(&rule.name.name, &id, &kind, &binding);
10498            metadata.effects.push(IrEffectNode {
10499                id,
10500                kind,
10501                binding,
10502                required_capabilities: parse_required_capabilities(line),
10503                construct_use: None,
10504                idempotency_key,
10505                span: rule.body.span,
10506                timeout_seconds: None,
10507                // The line-scanner result is overwritten by collect_effects_from_ast
10508                // below (which carries the real grants); empty here is fine.
10509                access_grants: Vec::new(),
10510                turn_skills: Vec::new(),
10511                resource: None,
10512                agent: None,
10513                workflow_target: None,
10514                endorsed: false,
10515                declassified: false,
10516                selected_by: None,
10517                exec_target: None,
10518            });
10519        }
10520    }
10521
10522    let (ast_effects, ast_dependencies) =
10523        collect_effects_from_ast(&body_ast.statements, &rule.name.name);
10524    metadata.effects = ast_effects;
10525    metadata.dependencies = ast_dependencies;
10526
10527    // `exec ... -> each Schema` produces one `Schema` fact per stream element
10528    // (spec/json-ingestion.md) — a fact write for liveness and effect-graph
10529    // analysis, like `record`.
10530    push_ingest_fact_writes(&body_ast.statements, &mut metadata.fact_writes);
10531
10532    metadata.fact_reads.sort();
10533    metadata.fact_reads.dedup();
10534    sort_projection_reads(&mut metadata.projection_reads);
10535    metadata.fact_writes.sort();
10536    metadata.fact_writes.dedup();
10537    metadata.fact_consumes.sort();
10538    metadata.fact_consumes.dedup();
10539    metadata.terminal_outputs = terminal_metadata.outputs;
10540    metadata.terminal_branches = terminal_metadata.branches;
10541    // DR-0044 Q5 / P1: an after-arm `case … where <guard>` guard query observes
10542    // live fact state at continuation time — the same firing-decision implicit
10543    // flow as a `when`-guard query (the IFC checker reads `projection_reads` to
10544    // taint guard-gated egresses). Fold both case families' arm guards into
10545    // `projection_reads` so the analysis sees them; the `when`-guard queries were
10546    // added above.
10547    for branch in &metadata.case_branches {
10548        if let Some(guard) = &branch.guard {
10549            metadata
10550                .projection_reads
10551                .extend(collect_projection_reads(&guard.expr));
10552        }
10553    }
10554    for branch in &metadata.terminal_branches {
10555        if let Some(guard) = &branch.guard {
10556            metadata
10557                .projection_reads
10558                .extend(collect_projection_reads(&guard.expr));
10559        }
10560    }
10561    sort_projection_reads(&mut metadata.projection_reads);
10562    collect_terminal_complete_bindings(&body_ast.statements, &mut metadata.terminal_completes);
10563    metadata.terminal_completes.sort();
10564    metadata.terminal_completes.dedup();
10565    collect_redaction_metadata(
10566        &body_ast.statements,
10567        &binding_types,
10568        &mut metadata.redactions,
10569    );
10570    collect_bounded_egresses(
10571        &body_ast.statements,
10572        &binding_types,
10573        &mut metadata.bounded_egresses,
10574    );
10575    let mut egress_reads = Vec::new();
10576    collect_egress_payload_reads(&body_ast.statements, &mut egress_reads);
10577    for (sink, roots) in egress_reads {
10578        metadata
10579            .egress_payload_reads
10580            .entry(sink)
10581            .or_default()
10582            .extend(roots);
10583    }
10584    collect_complete_field_reads(&body_ast.statements, &mut metadata.complete_field_reads);
10585    collect_record_field_reads(&body_ast.statements, &mut metadata.record_field_reads);
10586    collect_milestone_field_reads(&body_ast.statements, &mut metadata.milestone_field_reads);
10587    collect_crossing_roots(
10588        &body_ast.statements,
10589        &mut metadata.declassified_roots,
10590        &mut metadata.endorsed_roots,
10591        &mut metadata.endorsed_claim_items,
10592    );
10593    collect_provenance_metadata(
10594        &body_ast.statements,
10595        &mut metadata.coerce_input_roots,
10596        &mut metadata.after_aliases,
10597    );
10598    collect_egress_case_influence(
10599        &body_ast.statements,
10600        &mut Vec::new(),
10601        &mut metadata.egress_case_influence,
10602    );
10603    // Redaction closure over marked roots (redact ∘ marked-crossing): a
10604    // `redact <marked-output> keep […] as out` projection is still the
10605    // crossing's carrier — a redaction can only NARROW what the marked
10606    // coercion released, and the kept fields are additionally held to their
10607    // per-field schema labels by the redact refinement. Fixpoint so
10608    // redactions of redactions chain.
10609    loop {
10610        let mut changed = false;
10611        for redaction in &metadata.redactions {
10612            if metadata.declassified_roots.contains(&redaction.source)
10613                && metadata
10614                    .declassified_roots
10615                    .insert(redaction.binding.clone())
10616            {
10617                changed = true;
10618            }
10619            if metadata.endorsed_roots.contains(&redaction.source)
10620                && metadata.endorsed_roots.insert(redaction.binding.clone())
10621            {
10622                changed = true;
10623            }
10624        }
10625        if !changed {
10626            break;
10627        }
10628    }
10629    metadata
10630}
10631
10632/// Collect, per egress sink, the binding roots of every enclosing `case`
10633/// scrutinee (DR-0046 selector influence). The active-scrutinee stack is
10634/// threaded through nesting; each egress statement records the union of the
10635/// stack at its position, keyed exactly like `collect_egress_payload_reads`.
10636fn collect_egress_case_influence(
10637    statements: &[body::BodyStmt],
10638    active: &mut Vec<BTreeSet<String>>,
10639    out: &mut BTreeMap<String, BTreeSet<String>>,
10640) {
10641    let record_sink = |sink: String,
10642                       active: &[BTreeSet<String>],
10643                       out: &mut BTreeMap<String, BTreeSet<String>>| {
10644        if active.is_empty() {
10645            return;
10646        }
10647        let entry = out.entry(sink).or_default();
10648        for roots in active {
10649            entry.extend(roots.iter().cloned());
10650        }
10651    };
10652    for statement in statements {
10653        match statement {
10654            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10655                record_sink(terminal.name.clone(), active, out);
10656            }
10657            body::BodyStmt::Record(record) => {
10658                record_sink(format!("fact:{}", record.schema), active, out);
10659            }
10660            body::BodyStmt::Done {
10661                replacement: Some(record),
10662                ..
10663            } => {
10664                record_sink(format!("fact:{}", record.schema), active, out);
10665            }
10666            body::BodyStmt::Milestone { name, .. } => {
10667                record_sink(format!("milestone:{name}"), active, out);
10668            }
10669            body::BodyStmt::Effect(effect) => match &effect.kind {
10670                body::BodyEffectKind::ConstructCapabilityCall {
10671                    keyword, fields, ..
10672                } if keyword == "send" => {
10673                    if let Some(channel) = fields
10674                        .iter()
10675                        .find(|field| field.name == "channel")
10676                        .map(|field| field.source.clone())
10677                    {
10678                        record_sink(channel, active, out);
10679                    }
10680                }
10681                body::BodyEffectKind::FileWrite { store, .. } => {
10682                    record_sink(store.clone(), active, out);
10683                }
10684                _ => {}
10685            },
10686            body::BodyStmt::After(after) => {
10687                collect_egress_case_influence(&after.body, active, out);
10688            }
10689            body::BodyStmt::Case(case) => {
10690                let mut roots = BTreeSet::new();
10691                if let Ok(expr) = parse_expression(&case.scrutinee) {
10692                    collect_expr_binding_roots(&expr, &mut roots);
10693                } else {
10694                    collect_template_binding_roots(&case.scrutinee, &mut roots);
10695                }
10696                active.push(roots);
10697                for branch in &case.branches {
10698                    collect_egress_case_influence(&branch.body, active, out);
10699                }
10700                active.pop();
10701            }
10702            _ => {}
10703        }
10704    }
10705}
10706
10707/// Collect the raw structure input-side provenance narrowing resolves over:
10708/// each coerce's argument-expression binding roots (template-scan fallback for
10709/// unparseable sources, same discipline as `send_payload_reads`), and the
10710/// `after … succeeds|completes as` alias map.
10711fn collect_provenance_metadata(
10712    statements: &[body::BodyStmt],
10713    coerce_input_roots: &mut BTreeMap<String, BTreeSet<String>>,
10714    after_aliases: &mut BTreeMap<String, String>,
10715) {
10716    for statement in statements {
10717        match statement {
10718            body::BodyStmt::Effect(effect) => {
10719                if let body::BodyEffectKind::Coerce { args, .. } = &effect.kind {
10720                    if let Some(binding) = &effect.binding {
10721                        let mut roots = BTreeSet::new();
10722                        for arg in args {
10723                            if let Ok(expr) = parse_expression(arg) {
10724                                collect_expr_binding_roots(&expr, &mut roots);
10725                            } else {
10726                                collect_template_binding_roots(arg, &mut roots);
10727                            }
10728                        }
10729                        coerce_input_roots
10730                            .entry(binding.clone())
10731                            .or_default()
10732                            .extend(roots);
10733                    }
10734                }
10735            }
10736            body::BodyStmt::After(after) => {
10737                if matches!(
10738                    after.predicate,
10739                    body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
10740                ) {
10741                    if let Some(alias) = &after.alias {
10742                        after_aliases.insert(alias.clone(), after.binding.clone());
10743                    }
10744                }
10745                collect_provenance_metadata(&after.body, coerce_input_roots, after_aliases);
10746            }
10747            body::BodyStmt::Case(case) => {
10748                for branch in &case.branches {
10749                    collect_provenance_metadata(&branch.body, coerce_input_roots, after_aliases);
10750                }
10751            }
10752            _ => {}
10753        }
10754    }
10755}
10756
10757/// Collect the output roots of marked crossings (`coerce … declassified` /
10758/// `coerce … endorsed`, DR-0027 I-IFC3): each marked coerce's binding, plus the
10759/// aliases its `after <binding> succeeds|completes as <alias>` branches bind —
10760/// the names an egress payload actually references. Two passes so an `after`
10761/// textually preceding nothing is impossible to miss; aliases of aliases cannot
10762/// occur (an `after` subject is always an effect binding).
10763fn collect_crossing_roots(
10764    statements: &[body::BodyStmt],
10765    declassified: &mut BTreeSet<String>,
10766    endorsed: &mut BTreeSet<String>,
10767    claim_items: &mut BTreeSet<String>,
10768) {
10769    fn collect_marked(
10770        statements: &[body::BodyStmt],
10771        declassified: &mut BTreeSet<String>,
10772        endorsed: &mut BTreeSet<String>,
10773        claim_items: &mut BTreeSet<String>,
10774    ) {
10775        for statement in statements {
10776            match statement {
10777                body::BodyStmt::Effect(effect) => {
10778                    if let body::BodyEffectKind::Coerce {
10779                        declassified: is_declassified,
10780                        endorsed: is_endorsed,
10781                        ..
10782                    } = &effect.kind
10783                    {
10784                        if let Some(binding) = &effect.binding {
10785                            if *is_declassified {
10786                                declassified.insert(binding.clone());
10787                            }
10788                            if *is_endorsed {
10789                                endorsed.insert(binding.clone());
10790                            }
10791                        }
10792                    }
10793                    // DR-0051 §2: an endorsed claim is a marked crossing of the
10794                    // same kind, so its output binding joins `endorsed_roots`
10795                    // and every downstream check — the narrowing, the grant
10796                    // consultation, NMIF-on-the-selector — applies unchanged.
10797                    if let body::BodyEffectKind::TrackerClaim {
10798                        endorsed: is_endorsed,
10799                        item,
10800                        ..
10801                    } = &effect.kind
10802                    {
10803                        if *is_endorsed {
10804                            // The crossed value is the *claimed item*, not the
10805                            // claim's `as` binding: `claim v as hold` binds a
10806                            // lease in `hold`, while the decision the program
10807                            // goes on to read lives in `v`. Marking the lease
10808                            // would mark a handle nothing reads.
10809                            endorsed.insert(item.clone());
10810                            claim_items.insert(item.clone());
10811                        }
10812                    }
10813                }
10814                body::BodyStmt::After(after) => {
10815                    collect_marked(&after.body, declassified, endorsed, claim_items)
10816                }
10817                body::BodyStmt::Case(case) => {
10818                    for branch in &case.branches {
10819                        collect_marked(&branch.body, declassified, endorsed, claim_items);
10820                    }
10821                }
10822                _ => {}
10823            }
10824        }
10825    }
10826    fn collect_aliases(
10827        statements: &[body::BodyStmt],
10828        declassified: &mut BTreeSet<String>,
10829        endorsed: &mut BTreeSet<String>,
10830    ) {
10831        for statement in statements {
10832            match statement {
10833                body::BodyStmt::After(after) => {
10834                    if matches!(
10835                        after.predicate,
10836                        body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
10837                    ) {
10838                        if let Some(alias) = &after.alias {
10839                            if declassified.contains(&after.binding) {
10840                                declassified.insert(alias.clone());
10841                            }
10842                            if endorsed.contains(&after.binding) {
10843                                endorsed.insert(alias.clone());
10844                            }
10845                        }
10846                    }
10847                    collect_aliases(&after.body, declassified, endorsed);
10848                }
10849                body::BodyStmt::Case(case) => {
10850                    for branch in &case.branches {
10851                        collect_aliases(&branch.body, declassified, endorsed);
10852                    }
10853                }
10854                _ => {}
10855            }
10856        }
10857    }
10858    collect_marked(statements, declassified, endorsed, claim_items);
10859    // Aliases can nest under other afters, so run the alias pass to a fixpoint
10860    // over the (small) statement tree: one extra pass suffices in practice, but
10861    // loop until stable so deep nesting cannot order-skip an alias.
10862    loop {
10863        let before = (declassified.len(), endorsed.len());
10864        collect_aliases(statements, declassified, endorsed);
10865        if (declassified.len(), endorsed.len()) == before {
10866            break;
10867        }
10868    }
10869}
10870
10871/// For each `complete <binding> { field: <expr>, … }` egress in a rule body
10872/// (recursing into nested blocks), the binding roots EACH result field references,
10873/// as `binding -> field -> {roots}`. A `Shorthand` field (`complete result from src
10874/// { f }`) resolves to the terminal's `from` binding. Unlike
10875/// `collect_egress_payload_reads` (which joins a sink's fields), this keeps fields
10876/// separate so the IFC engine can compute a per-field flow signature (DR-0030 X2
10877/// v2). Union across branches (a field completed in two arms references the union).
10878fn collect_complete_field_reads(
10879    statements: &[body::BodyStmt],
10880    out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
10881) {
10882    for statement in statements {
10883        match statement {
10884            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10885                let per_field = out.entry(terminal.name.clone()).or_default();
10886                for field in &terminal.fields {
10887                    let mut roots = BTreeSet::new();
10888                    match &field.value {
10889                        body::FieldValue::Shorthand => {
10890                            if let Some(root) = &terminal.from {
10891                                roots.insert(root.clone());
10892                            }
10893                        }
10894                        body::FieldValue::Expr { expr, .. } => {
10895                            collect_expr_binding_roots(expr, &mut roots)
10896                        }
10897                        body::FieldValue::Nested { fields, .. } => collect_payload_field_roots(
10898                            fields,
10899                            terminal.from.as_deref(),
10900                            &mut roots,
10901                        ),
10902                    }
10903                    per_field
10904                        .entry(field.name.clone())
10905                        .or_default()
10906                        .extend(roots);
10907                }
10908            }
10909            body::BodyStmt::After(after) => collect_complete_field_reads(&after.body, out),
10910            body::BodyStmt::Case(case) => {
10911                for branch in &case.branches {
10912                    collect_complete_field_reads(&branch.body, out);
10913                }
10914            }
10915            _ => {}
10916        }
10917    }
10918}
10919
10920/// For each `emit milestone "<name>" { field: <expr>, … }` egress in a rule body
10921/// (recursing into nested blocks), collect the binding roots EACH milestone field
10922/// references. This mirrors `collect_complete_field_reads`: the IFC checker uses it
10923/// to expose and gate a child-to-parent milestone payload with a per-field flow
10924/// signature (D3′).
10925fn collect_milestone_field_reads(
10926    statements: &[body::BodyStmt],
10927    out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
10928) {
10929    for statement in statements {
10930        match statement {
10931            body::BodyStmt::Milestone { name, fields, .. } => {
10932                let per_field = out.entry(name.clone()).or_default();
10933                for field in fields {
10934                    let mut roots = BTreeSet::new();
10935                    match &field.value {
10936                        body::FieldValue::Shorthand => {}
10937                        body::FieldValue::Expr { expr, .. } => {
10938                            collect_expr_binding_roots(expr, &mut roots)
10939                        }
10940                        body::FieldValue::Nested { fields, .. } => {
10941                            collect_payload_field_roots(fields, None, &mut roots)
10942                        }
10943                    }
10944                    per_field
10945                        .entry(field.name.clone())
10946                        .or_default()
10947                        .extend(roots);
10948                }
10949            }
10950            body::BodyStmt::After(after) => collect_milestone_field_reads(&after.body, out),
10951            body::BodyStmt::Case(case) => {
10952                for branch in &case.branches {
10953                    collect_milestone_field_reads(&branch.body, out);
10954                }
10955            }
10956            _ => {}
10957        }
10958    }
10959}
10960
10961/// Collects the `redact <source> keep [..] as <out>` projections of a rule body
10962/// (recursing into nested blocks) as IFC value-flow metadata, preserving body
10963/// order so a chained redaction's source resolves against the earlier projection.
10964/// `binding_types` (the rule's fully-resolved binding -> schema map, including
10965/// redaction outputs via their synthetic class) supplies each source's schema so
10966/// the IFC engine can derive the projection's per-field label.
10967fn collect_redaction_metadata(
10968    statements: &[body::BodyStmt],
10969    binding_types: &BTreeMap<String, String>,
10970    out: &mut Vec<IrRedaction>,
10971) {
10972    let mut redacts = Vec::new();
10973    collect_redact_effects(statements, &mut redacts);
10974    for (source, keep, binding, _span) in redacts {
10975        out.push(IrRedaction {
10976            source: source.to_owned(),
10977            keep: keep.to_vec(),
10978            binding: binding.to_owned(),
10979            source_schema: binding_types.get(source).cloned(),
10980        });
10981    }
10982}
10983
10984/// Collect the bounded-type projection egresses (`record <T> from <src>`) of a rule
10985/// body (recursing into nested blocks). A `record T from src` keeps exactly `T`'s
10986/// declared fields, copied from `src`, so the IFC engine can govern it by the kept
10987/// fields' per-field labels (sourced from `src`'s schema) — the "bounded-type"
10988/// auto-redaction reading. Only recorded when the source schema resolves and the
10989/// target type is declared; otherwise the egress stays conservative.
10990/// Records a bounded-type projection egress for a PURE `from` projection — a
10991/// `from <src>` egress every field of which is a shorthand copy of `src.<name>`.
10992/// The runtime materializes exactly these fields, so the kept set is their names,
10993/// governed by `src`'s schema per-field labels. `None` source schema, no `from`, or
10994/// any explicit value field → not a clean projection, so it stays conservative
10995/// (handled by the whole-read join). `sink` is the engine sink string
10996/// (`fact:<Schema>` for a record, the completed binding for a `complete`).
10997fn push_bounded_projection(
10998    from: Option<&str>,
10999    fields: &[body::FieldAssign],
11000    sink: String,
11001    binding_types: &BTreeMap<String, String>,
11002    out: &mut Vec<IrBoundedEgress>,
11003) {
11004    let Some(source_schema) = from.and_then(|src| binding_types.get(src)) else {
11005        return;
11006    };
11007    if fields.is_empty()
11008        || !fields
11009            .iter()
11010            .all(|field| matches!(field.value, body::FieldValue::Shorthand))
11011    {
11012        return;
11013    }
11014    out.push(IrBoundedEgress {
11015        sink,
11016        source_schema: source_schema.clone(),
11017        keep: fields.iter().map(|field| field.name.clone()).collect(),
11018    });
11019}
11020
11021fn push_bounded_record(
11022    record: &body::RecordStmt,
11023    binding_types: &BTreeMap<String, String>,
11024    out: &mut Vec<IrBoundedEgress>,
11025) {
11026    push_bounded_projection(
11027        record.from.as_deref(),
11028        &record.fields,
11029        format!("fact:{}", record.schema),
11030        binding_types,
11031        out,
11032    );
11033}
11034
11035fn collect_bounded_egresses(
11036    statements: &[body::BodyStmt],
11037    binding_types: &BTreeMap<String, String>,
11038    out: &mut Vec<IrBoundedEgress>,
11039) {
11040    for statement in statements {
11041        match statement {
11042            body::BodyStmt::Record(record) => push_bounded_record(record, binding_types, out),
11043            body::BodyStmt::Done {
11044                replacement: Some(record),
11045                ..
11046            } => push_bounded_record(record, binding_types, out),
11047            // `complete <T> from <src> { … }`: bounded-type projection to the invoker.
11048            // The engine sink for a complete is the completed binding (its name).
11049            body::BodyStmt::Terminal(terminal)
11050                if terminal.kind == body::TerminalKind::Complete && terminal.from.is_some() =>
11051            {
11052                push_bounded_projection(
11053                    terminal.from.as_deref(),
11054                    &terminal.fields,
11055                    terminal.name.clone(),
11056                    binding_types,
11057                    out,
11058                );
11059            }
11060            body::BodyStmt::After(after) => {
11061                collect_bounded_egresses(&after.body, binding_types, out)
11062            }
11063            body::BodyStmt::Case(case) => {
11064                for branch in &case.branches {
11065                    collect_bounded_egresses(&branch.body, binding_types, out);
11066                }
11067            }
11068            _ => {}
11069        }
11070    }
11071}
11072
11073/// Collect EVERY binding root referenced by an expression, for the information-flow
11074/// value-flow engine. SOUNDNESS: a missed reference under-approximates a payload's
11075/// sources — so this over-collects (an over-collected name that is not a relevant
11076/// binding contributes nothing downstream). It walks every `Expr` variant and, for
11077/// string literals, extracts `{{ … }}` interpolation roots (those refs live as raw
11078/// text inside the literal, not as structured nodes). A bare identifier parses as
11079/// `Literal(Ident)`, a dotted ref as `Path` — both are roots.
11080fn collect_expr_binding_roots(expr: &Expr, out: &mut BTreeSet<String>) {
11081    match expr {
11082        Expr::Literal(ExprLiteral::String(text)) => collect_template_binding_roots(text, out),
11083        Expr::Literal(ExprLiteral::Ident(name)) => {
11084            out.insert(name.clone());
11085        }
11086        Expr::Literal(ExprLiteral::Number(_) | ExprLiteral::Bool(_) | ExprLiteral::Null) => {}
11087        Expr::Path(segments) => {
11088            if let Some(root) = segments.first() {
11089                out.insert(root.clone());
11090            }
11091        }
11092        Expr::Index { target, key } => {
11093            collect_expr_binding_roots(target, out);
11094            collect_expr_binding_roots(key, out);
11095        }
11096        Expr::Array(items) => {
11097            for item in items {
11098                collect_expr_binding_roots(item, out);
11099            }
11100        }
11101        Expr::Object(fields) => {
11102            for field in fields {
11103                collect_expr_binding_roots(&field.value, out);
11104            }
11105        }
11106        Expr::Unary { expr, .. } => collect_expr_binding_roots(expr, out),
11107        Expr::Binary { left, right, .. } => {
11108            collect_expr_binding_roots(left, out);
11109            collect_expr_binding_roots(right, out);
11110        }
11111        Expr::Call { args, .. } => {
11112            for arg in args {
11113                collect_expr_binding_roots(arg, out);
11114            }
11115        }
11116        Expr::Query { head, guard, .. } => {
11117            out.insert(head.clone());
11118            if let Some(guard) = guard {
11119                collect_expr_binding_roots(guard, out);
11120            }
11121        }
11122    }
11123}
11124
11125/// Collect every binding root inside `{{ … }}` interpolations of a string. Unlike
11126/// `interpolation_roots` (first root per interpolation), value-flow needs EVERY
11127/// root, so `{{ a.b + c.d }}` yields both `a` and `c`. Each interpolation body is
11128/// parsed and walked; an unparseable body falls back to a conservative identifier
11129/// scan (over-collection is sound).
11130fn collect_template_binding_roots(text: &str, out: &mut BTreeSet<String>) {
11131    let mut rest = text;
11132    while let Some(open) = rest.find("{{") {
11133        let after_open = &rest[open + 2..];
11134        let Some(close) = after_open.find("}}") else {
11135            break;
11136        };
11137        let body = after_open[..close].trim();
11138        if let Ok(expr) = parse_expression(body) {
11139            collect_expr_binding_roots(&expr, out);
11140        } else {
11141            for token in body.split(|ch: char| !ch.is_alphanumeric() && ch != '_') {
11142                if token
11143                    .as_bytes()
11144                    .first()
11145                    .is_some_and(|byte| is_ident_start(*byte))
11146                {
11147                    out.insert(token.to_owned());
11148                }
11149            }
11150        }
11151        rest = &after_open[close + 2..];
11152    }
11153}
11154
11155/// Collect the binding roots a payload field list references, threading the
11156/// enclosing `from <binding>` source so a `Shorthand` field resolves to it.
11157fn collect_payload_field_roots(
11158    fields: &[body::FieldAssign],
11159    from_binding: Option<&str>,
11160    out: &mut BTreeSet<String>,
11161) {
11162    for field in fields {
11163        match &field.value {
11164            body::FieldValue::Shorthand => {
11165                if let Some(root) = from_binding {
11166                    out.insert(root.to_owned());
11167                }
11168            }
11169            body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, out),
11170            body::FieldValue::Nested { fields, .. } => {
11171                collect_payload_field_roots(fields, from_binding, out)
11172            }
11173        }
11174    }
11175}
11176
11177/// For each egress sink in a rule body (recursing into nested blocks), the set of
11178/// binding roots its payload references, keyed by the sink string the IFC engine
11179/// uses: a `complete <binding>` by its binding, a `record <Schema>` by
11180/// `fact:<Schema>`. Surfaced so the engine can recognize a FULLY-REDACTED egress —
11181/// one whose payload references only redaction outputs (and constants) — and
11182/// govern it by the projection's per-field label instead of the rule's whole read
11183/// set. A `record <Schema> from <binding>` references that `from` binding too (its
11184/// fields are copied). A sink with no recorded entry references nothing resolvable.
11185fn collect_egress_payload_reads(
11186    statements: &[body::BodyStmt],
11187    out: &mut Vec<(String, BTreeSet<String>)>,
11188) {
11189    for statement in statements {
11190        match statement {
11191            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
11192                let mut roots = BTreeSet::new();
11193                collect_payload_field_roots(&terminal.fields, None, &mut roots);
11194                // A bare scalar payload's value expression is the whole egress
11195                // value; its binding roots must join the sink's label (fail-closed
11196                // — otherwise `complete result secret.value` would under-report).
11197                if let Some(body::FieldValue::Expr { expr, .. }) = &terminal.scalar {
11198                    collect_expr_binding_roots(expr, &mut roots);
11199                }
11200                out.push((terminal.name.clone(), roots));
11201            }
11202            body::BodyStmt::Record(record) => out.push(record_payload_reads(record)),
11203            // `done <b> -> record <Schema> { … }` is also a record egress.
11204            body::BodyStmt::Done {
11205                replacement: Some(record),
11206                ..
11207            } => out.push(record_payload_reads(record)),
11208            body::BodyStmt::Milestone { name, fields, .. } => {
11209                let mut roots = BTreeSet::new();
11210                collect_payload_field_roots(fields, None, &mut roots);
11211                out.push((format!("milestone:{name}"), roots));
11212            }
11213            // `send via <channel> { text … }` egresses to the channel; its payload
11214            // fields (text/markdown/thread_id) are construct-use source text. Keyed by
11215            // the channel (the engine's send sink, per `resource_for_body`). A
11216            // `write … to <store>` is likewise an egress to the store: its body
11217            // AND path expressions are the payload (a path can encode data too),
11218            // keyed by the store handle.
11219            body::BodyStmt::Effect(effect) => match &effect.kind {
11220                body::BodyEffectKind::ConstructCapabilityCall {
11221                    keyword, fields, ..
11222                } if keyword == "send" => {
11223                    if let Some(reads) = send_payload_reads(fields) {
11224                        out.push(reads);
11225                    }
11226                }
11227                body::BodyEffectKind::FileWrite {
11228                    store, path, body, ..
11229                } => {
11230                    let mut roots = BTreeSet::new();
11231                    for source in [path, body] {
11232                        if let Ok(expr) = parse_expression(source) {
11233                            collect_expr_binding_roots(&expr, &mut roots);
11234                        } else {
11235                            collect_template_binding_roots(source, &mut roots);
11236                        }
11237                    }
11238                    out.push((store.clone(), roots));
11239                }
11240                _ => {}
11241            },
11242            body::BodyStmt::After(after) => collect_egress_payload_reads(&after.body, out),
11243            body::BodyStmt::Case(case) => {
11244                for branch in &case.branches {
11245                    collect_egress_payload_reads(&branch.body, out);
11246                }
11247            }
11248            _ => {}
11249        }
11250    }
11251}
11252
11253/// The channel sink key and the binding roots a `send` payload references. The
11254/// payload fields (`text`/`markdown`/`thread_id`) carry expression SOURCE TEXT, so
11255/// each is parsed and walked (a string literal's `{{ … }}` interpolations count);
11256/// the `channel` field names the sink. `None` if no channel is present.
11257fn send_payload_reads(fields: &[body::ConstructUseField]) -> Option<(String, BTreeSet<String>)> {
11258    let channel = fields
11259        .iter()
11260        .find(|field| field.name == "channel")
11261        .map(|field| field.source.clone())?;
11262    let mut roots = BTreeSet::new();
11263    for field in fields.iter().filter(|field| field.name != "channel") {
11264        if let Ok(expr) = parse_expression(&field.source) {
11265            collect_expr_binding_roots(&expr, &mut roots);
11266        } else {
11267            // Unparseable source: scan its interpolations conservatively.
11268            collect_template_binding_roots(&field.source, &mut roots);
11269        }
11270    }
11271    Some((channel, roots))
11272}
11273
11274/// The `fact:<Schema>` sink key and the binding roots a `record` payload
11275/// references — its explicit field values plus, for `record <S> from <b>`, the
11276/// copied-from binding `b`.
11277/// DR-0051 §4: per-field binding roots for every `record <Schema> { … }` in a
11278/// rule body, recursing into nested blocks. Mirrors
11279/// `collect_complete_field_reads`; see `record_field_reads`.
11280fn collect_record_field_reads(
11281    statements: &[body::BodyStmt],
11282    out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
11283) {
11284    fn record_fields(
11285        record: &body::RecordStmt,
11286        out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
11287    ) {
11288        let per_field = out.entry(format!("fact:{}", record.schema)).or_default();
11289        for field in &record.fields {
11290            let mut roots = BTreeSet::new();
11291            match &field.value {
11292                body::FieldValue::Shorthand => {
11293                    if let Some(root) = &record.from {
11294                        roots.insert(root.clone());
11295                    }
11296                }
11297                body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, &mut roots),
11298                body::FieldValue::Nested { fields, .. } => {
11299                    collect_payload_field_roots(fields, record.from.as_deref(), &mut roots)
11300                }
11301            }
11302            per_field
11303                .entry(field.name.clone())
11304                .or_default()
11305                .extend(roots);
11306        }
11307    }
11308    for statement in statements {
11309        match statement {
11310            body::BodyStmt::Record(record) => record_fields(record, out),
11311            body::BodyStmt::Done {
11312                replacement: Some(record),
11313                ..
11314            } => record_fields(record, out),
11315            body::BodyStmt::After(after) => collect_record_field_reads(&after.body, out),
11316            body::BodyStmt::Case(case) => {
11317                for branch in &case.branches {
11318                    collect_record_field_reads(&branch.body, out);
11319                }
11320            }
11321            _ => {}
11322        }
11323    }
11324}
11325
11326fn record_payload_reads(record: &body::RecordStmt) -> (String, BTreeSet<String>) {
11327    let mut roots = BTreeSet::new();
11328    if let Some(from) = &record.from {
11329        roots.insert(from.clone());
11330    }
11331    collect_payload_field_roots(&record.fields, record.from.as_deref(), &mut roots);
11332    (format!("fact:{}", record.schema), roots)
11333}
11334
11335#[derive(Clone, Debug, Default)]
11336struct TerminalMetadata {
11337    outputs: Vec<IrTerminalOutput>,
11338    branches: Vec<IrTerminalCaseBranch>,
11339}
11340
11341#[derive(Clone, Debug)]
11342struct TerminalBranchSource {
11343    scrutinee: String,
11344    pattern: String,
11345    guard: Option<String>,
11346    body: String,
11347    pattern_span: SourceSpan,
11348}
11349
11350#[derive(Clone, Debug)]
11351struct RuleCaseBranchSource {
11352    scrutinee: String,
11353    scrutinee_type: TypeSyntax,
11354    pattern: String,
11355    guard: Option<String>,
11356    body: String,
11357    pattern_span: SourceSpan,
11358}
11359
11360fn collect_effect_payload_types(
11361    rule: &RuleDecl,
11362    semantic: &SemanticContext,
11363    diagnostics: &mut Vec<Diagnostic>,
11364) -> BTreeMap<String, IrType> {
11365    let mut payloads = BTreeMap::new();
11366    for statement in effect_payload_statements(&rule.body.text) {
11367        let line = statement.trim();
11368        let Some((kind, Some(binding))) = parse_effect_line(line) else {
11369            continue;
11370        };
11371        let payload = terminal_completed_payload_type(line, &kind, semantic);
11372        // A binding name keys the per-rule payload map, so reusing it for two effects
11373        // with DIFFERENT result types makes `after <binding> …` ambiguous (§5.5).
11374        // Same-type reuse (and mutually-exclusive `case` arms, which never both run)
11375        // is harmless and left alone.
11376        match payloads.get(&binding) {
11377            Some(existing) if existing != &payload => {
11378                diagnostics.push(Diagnostic {
11379                    related: Vec::new(),
11380                    span: rule.body.span,
11381                    message: format!(
11382                        "rule `{}` reuses effect binding `{binding}` for effects with conflicting result types",
11383                        rule.name.name
11384                    ),
11385                    suggestion: Some(format!(
11386                        "give each effect a distinct binding — `as {binding}` is reused with a different result type, so `after {binding} …` is ambiguous"
11387                    )),
11388                });
11389            }
11390            Some(_) => {}
11391            None => {
11392                payloads.insert(binding, payload);
11393            }
11394        }
11395    }
11396
11397    payloads
11398}
11399
11400fn terminal_completed_payload_type(
11401    line: &str,
11402    kind: &IrEffectKind,
11403    semantic: &SemanticContext,
11404) -> IrType {
11405    match kind {
11406        IrEffectKind::SchemaCoerce if line.starts_with("prompt ") => {
11407            IrType::Primitive(IrPrimitiveType::String)
11408        }
11409        IrEffectKind::SchemaCoerce => parse_coerce_call_name(line)
11410            .and_then(|name| semantic.coerce_outputs.get(name))
11411            .cloned()
11412            .map(lower_type)
11413            .unwrap_or_else(terminal_unknown_payload_type),
11414        IrEffectKind::AgentTell => IrType::Ref("AgentTurn".to_owned()),
11415        IrEffectKind::CapabilityCall
11416        | IrEffectKind::EventEmit
11417        | IrEffectKind::WorkflowInvoke
11418        | IrEffectKind::TimerWait
11419        | IrEffectKind::ExecCommand
11420        | IrEffectKind::TrackerFile
11421        | IrEffectKind::TrackerClaim
11422        | IrEffectKind::TrackerRenew
11423        | IrEffectKind::TrackerRelease
11424        | IrEffectKind::TrackerFinish
11425        | IrEffectKind::LeaseAcquire
11426        | IrEffectKind::LeaseRenew
11427        | IrEffectKind::LedgerAppend
11428        | IrEffectKind::CounterConsume
11429        | IrEffectKind::SignalEmit
11430        | IrEffectKind::FileRead
11431        | IrEffectKind::FileWrite
11432        | IrEffectKind::FileImport
11433        | IrEffectKind::FileExport => terminal_unknown_payload_type(),
11434    }
11435}
11436
11437fn collect_rule_case_metadata(
11438    rule: &RuleDecl,
11439    semantic: &SemanticContext,
11440    binding_types: &BTreeMap<String, String>,
11441    diagnostics: &mut Vec<Diagnostic>,
11442) -> Vec<IrRuleCaseBranch> {
11443    let mut branches = Vec::new();
11444    for branch in rule_case_branch_sources(rule, semantic, binding_types) {
11445        let mut branch_scope = binding_types.clone();
11446        if let Some((binding, schema)) =
11447            case_branch_payload_binding(&branch.pattern, &branch.scrutinee_type, semantic)
11448        {
11449            branch_scope.insert(binding, schema);
11450        }
11451        if let Some(guard) = &branch.guard {
11452            validate_expression(
11453                rule,
11454                guard,
11455                semantic,
11456                &branch_scope,
11457                "case guard",
11458                diagnostics,
11459            );
11460            validate_known_field_paths_at_span(
11461                rule,
11462                guard,
11463                branch.pattern_span,
11464                semantic,
11465                &branch_scope,
11466                diagnostics,
11467            );
11468        }
11469        validate_known_field_paths_at_span(
11470            rule,
11471            &branch.body,
11472            branch.pattern_span,
11473            semantic,
11474            &branch_scope,
11475            diagnostics,
11476        );
11477        if let Some(pattern) = lower_case_pattern(&branch.pattern, &branch.scrutinee_type, semantic)
11478        {
11479            branches.push(IrRuleCaseBranch {
11480                scrutinee: branch.scrutinee,
11481                scrutinee_type: lower_type(branch.scrutinee_type),
11482                pattern,
11483                guard: branch.guard.as_ref().and_then(|guard| {
11484                    lower_expression(
11485                        guard,
11486                        SourceSpan {
11487                            start: branch.pattern_span.start,
11488                            end: branch.pattern_span.end,
11489                        },
11490                    )
11491                }),
11492                body_hash: stable_hash(&branch.body),
11493                pattern_span: branch.pattern_span,
11494            });
11495        }
11496    }
11497    branches.sort_by(|left, right| {
11498        (left.scrutinee.as_str(), left.pattern_span.start)
11499            .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
11500    });
11501    branches
11502}
11503
11504fn rule_case_branch_sources(
11505    rule: &RuleDecl,
11506    semantic: &SemanticContext,
11507    binding_types: &BTreeMap<String, String>,
11508) -> Vec<RuleCaseBranchSource> {
11509    let lines = rule
11510        .body
11511        .text
11512        .lines()
11513        .scan(0usize, |offset, line| {
11514            let current = *offset;
11515            *offset += line.len() + 1;
11516            Some((line, current))
11517        })
11518        .collect::<Vec<_>>();
11519    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
11520    let mut branches = Vec::new();
11521    let mut index = 0usize;
11522    while index < lines.len() {
11523        let (line, _) = lines[index];
11524        let trimmed = line.trim();
11525        let Some(scrutinee) = case_scrutinee(trimmed) else {
11526            index += 1;
11527            continue;
11528        };
11529        if active_completes_binding_for_case(&text_lines, index, scrutinee) {
11530            index += 1;
11531            continue;
11532        }
11533        let Some(scrutinee_type) = expression_type(scrutinee, semantic, binding_types) else {
11534            index += 1;
11535            continue;
11536        };
11537        let mut depth = brace_delta(trimmed).max(1);
11538        index += 1;
11539        while index < lines.len() && depth > 0 {
11540            let (branch_line, branch_line_offset) = lines[index];
11541            let branch_trimmed = branch_line.trim();
11542            if depth == 1 {
11543                if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
11544                    let pattern_column = case_pattern_column(branch_line, pattern);
11545                    let pattern_span = SourceSpan {
11546                        start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
11547                        end: rule_body_text_start(rule)
11548                            + branch_line_offset
11549                            + pattern_column
11550                            + pattern.len(),
11551                    };
11552                    let mut body_lines = Vec::new();
11553                    let mut branch_depth = brace_delta(body_start).max(1);
11554                    index += 1;
11555                    while index < lines.len() && branch_depth > 0 {
11556                        let body_line = lines[index].0;
11557                        let next_depth = branch_depth + brace_delta(body_line);
11558                        if next_depth >= 1 {
11559                            body_lines.push(body_line.to_owned());
11560                        }
11561                        branch_depth = next_depth;
11562                        index += 1;
11563                    }
11564                    branches.push(RuleCaseBranchSource {
11565                        scrutinee: scrutinee.to_owned(),
11566                        scrutinee_type: scrutinee_type.clone(),
11567                        pattern: pattern.to_owned(),
11568                        guard,
11569                        body: body_lines.join("\n"),
11570                        pattern_span,
11571                    });
11572                    continue;
11573                }
11574            }
11575            depth += brace_delta(branch_trimmed);
11576            index += 1;
11577        }
11578    }
11579    branches
11580}
11581
11582fn lower_case_pattern(
11583    pattern: &str,
11584    scrutinee_type: &TypeSyntax,
11585    semantic: &SemanticContext,
11586) -> Option<IrCasePattern> {
11587    if is_fallback_pattern(pattern) {
11588        return Some(IrCasePattern::Wildcard);
11589    }
11590    if pattern == "None" {
11591        return Some(IrCasePattern::OptionalNone);
11592    }
11593    if let Some(binding) = pattern.strip_prefix("Some ").map(str::trim) {
11594        if !binding.is_empty() {
11595            return Some(IrCasePattern::OptionalSome {
11596                binding: binding.to_owned(),
11597            });
11598        }
11599    }
11600    match scrutinee_type {
11601        TypeSyntax::Ref { name } if semantic.schemas.enums.contains_key(&name.name) => {
11602            // The IR pattern is the variant name; the payload binding is a
11603            // branch-scope concern, not part of dispatch identity.
11604            let (variant, _) = sum_case_pattern_parts(pattern);
11605            Some(IrCasePattern::EnumVariant(variant.to_owned()))
11606        }
11607        TypeSyntax::Union { .. } => parse_literal_expr(pattern).and_then(|literal| match literal {
11608            LiteralExpr::String(value) => Some(IrCasePattern::LiteralString(value.to_owned())),
11609            LiteralExpr::Ident(value) => Some(IrCasePattern::LiteralString(value.to_owned())),
11610            _ => None,
11611        }),
11612        TypeSyntax::AgentRef { .. } => {
11613            parse_literal_expr(pattern).and_then(|literal| match literal {
11614                LiteralExpr::String(value) | LiteralExpr::Ident(value) => {
11615                    Some(IrCasePattern::Agent(value.to_owned()))
11616                }
11617                _ => None,
11618            })
11619        }
11620        TypeSyntax::Optional { inner, .. } => lower_case_pattern(pattern, inner, semantic),
11621        _ => None,
11622    }
11623}
11624
11625fn case_branch_payload_binding(
11626    pattern: &str,
11627    scrutinee_type: &TypeSyntax,
11628    semantic: &SemanticContext,
11629) -> Option<(String, String)> {
11630    // Sum types: `Variant as b` binds the payload typed as the generated
11631    // `<Enum>.<Variant>` class (spec/sum-types.md).
11632    if let TypeSyntax::Ref { name } = scrutinee_type {
11633        if semantic.schemas.enums.contains_key(&name.name) {
11634            let (variant, binding) = sum_case_pattern_parts(pattern);
11635            let binding = binding?;
11636            let generated = format!("{}.{variant}", name.name);
11637            if binding.is_empty() || !semantic.schemas.class_exists(&generated) {
11638                return None;
11639            }
11640            return Some((binding.to_owned(), generated));
11641        }
11642    }
11643    let binding = pattern.strip_prefix("Some ").map(str::trim)?;
11644    if binding.is_empty() {
11645        return None;
11646    }
11647    let TypeSyntax::Optional { inner, .. } = scrutinee_type else {
11648        return None;
11649    };
11650    let schema = match inner.as_ref() {
11651        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
11652            Some(name.name.clone())
11653        }
11654        _ => None,
11655    }?;
11656    Some((binding.to_owned(), schema))
11657}
11658
11659fn collect_terminal_case_metadata(
11660    rule: &RuleDecl,
11661    semantic: &SemanticContext,
11662    binding_types: &BTreeMap<String, String>,
11663    effect_payload_types: &BTreeMap<String, IrType>,
11664    diagnostics: &mut Vec<Diagnostic>,
11665) -> TerminalMetadata {
11666    let mut metadata = TerminalMetadata::default();
11667    let mut output_bindings = BTreeSet::new();
11668
11669    for branch in terminal_case_branch_sources(rule) {
11670        if output_bindings.insert(branch.scrutinee.clone()) {
11671            let completed_payload = effect_payload_types
11672                .get(&branch.scrutinee)
11673                .cloned()
11674                .unwrap_or_else(terminal_unknown_payload_type);
11675            metadata.outputs.push(IrTerminalOutput {
11676                binding: branch.scrutinee.clone(),
11677                alternatives: terminal_alternatives(completed_payload, branch.pattern_span),
11678                span: branch.pattern_span,
11679            });
11680        }
11681
11682        let (tag, binding) = parse_terminal_pattern_parts(&branch.pattern);
11683        let mut branch_scope = binding_types.clone();
11684        if let (Some(tag), Some(binding)) = (&tag, &binding) {
11685            if let Some(schema) =
11686                terminal_payload_schema_for_tag(tag, &branch.scrutinee, effect_payload_types)
11687            {
11688                branch_scope.insert(binding.clone(), schema);
11689            }
11690        }
11691        if let Some(guard) = &branch.guard {
11692            validate_expression(
11693                rule,
11694                guard,
11695                semantic,
11696                &branch_scope,
11697                "case guard",
11698                diagnostics,
11699            );
11700            validate_known_field_paths(rule, guard, semantic, &branch_scope, diagnostics);
11701        }
11702        validate_known_field_paths(rule, &branch.body, semantic, &branch_scope, diagnostics);
11703        metadata.branches.push(IrTerminalCaseBranch {
11704            scrutinee: branch.scrutinee,
11705            tag,
11706            binding,
11707            guard: branch.guard.as_ref().and_then(|guard| {
11708                lower_expression(
11709                    guard,
11710                    SourceSpan {
11711                        start: branch.pattern_span.start,
11712                        end: branch.pattern_span.end,
11713                    },
11714                )
11715            }),
11716            body_hash: stable_hash(&branch.body),
11717            pattern_span: branch.pattern_span,
11718        });
11719    }
11720
11721    metadata
11722        .outputs
11723        .sort_by(|left, right| left.binding.cmp(&right.binding));
11724    metadata.branches.sort_by(|left, right| {
11725        (left.scrutinee.as_str(), left.pattern_span.start)
11726            .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
11727    });
11728    metadata
11729}
11730
11731fn terminal_case_branch_sources(rule: &RuleDecl) -> Vec<TerminalBranchSource> {
11732    let lines = rule
11733        .body
11734        .text
11735        .lines()
11736        .scan(0usize, |offset, line| {
11737            let current = *offset;
11738            *offset += line.len() + 1;
11739            Some((line, current))
11740        })
11741        .collect::<Vec<_>>();
11742    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
11743    let mut branches = Vec::new();
11744    let mut index = 0usize;
11745    while index < lines.len() {
11746        let (line, line_offset) = lines[index];
11747        let trimmed = line.trim();
11748        let Some(scrutinee) = case_scrutinee(trimmed) else {
11749            index += 1;
11750            continue;
11751        };
11752        if !active_completes_binding_for_case(&text_lines, index, scrutinee) {
11753            index += 1;
11754            continue;
11755        }
11756        let mut depth = brace_delta(trimmed).max(1);
11757        index += 1;
11758        while index < lines.len() && depth > 0 {
11759            let (branch_line, branch_line_offset) = lines[index];
11760            let branch_trimmed = branch_line.trim();
11761            if depth == 1 {
11762                if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
11763                    let pattern_column = case_pattern_column(branch_line, pattern);
11764                    let pattern_span = SourceSpan {
11765                        start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
11766                        end: rule_body_text_start(rule)
11767                            + branch_line_offset
11768                            + pattern_column
11769                            + pattern.len(),
11770                    };
11771                    let mut body_lines = Vec::new();
11772                    let mut branch_depth = brace_delta(body_start).max(1);
11773                    index += 1;
11774                    while index < lines.len() && branch_depth > 0 {
11775                        let body_line = lines[index].0;
11776                        let next_depth = branch_depth + brace_delta(body_line);
11777                        if next_depth >= 1 {
11778                            body_lines.push(body_line.to_owned());
11779                        }
11780                        branch_depth = next_depth;
11781                        index += 1;
11782                    }
11783                    branches.push(TerminalBranchSource {
11784                        scrutinee: scrutinee.to_owned(),
11785                        pattern: pattern.to_owned(),
11786                        guard,
11787                        body: body_lines.join("\n"),
11788                        pattern_span,
11789                    });
11790                    continue;
11791                }
11792            }
11793            depth += brace_delta(branch_trimmed);
11794            index += 1;
11795        }
11796        let _ = line_offset;
11797    }
11798    branches
11799}
11800
11801fn rule_body_text_start(rule: &RuleDecl) -> usize {
11802    rule.body.span.end.saturating_sub(2 + rule.body.text.len())
11803}
11804
11805fn terminal_branch_header(line: &str) -> Option<(&str, Option<String>, &str)> {
11806    let (head, body_start) = line.split_once("=>")?;
11807    let body_start = body_start.trim();
11808    if !body_start.starts_with('{') {
11809        return None;
11810    }
11811    let head = head.trim();
11812    let (pattern, guard) = match head.split_once(" where ") {
11813        Some((pattern, guard)) => (pattern.trim(), Some(guard.trim().to_owned())),
11814        None => (head, None),
11815    };
11816    Some((pattern, guard, body_start))
11817}
11818
11819fn case_pattern_column(line: &str, pattern: &str) -> usize {
11820    line.find(pattern).unwrap_or_else(|| {
11821        let indent = line.len().saturating_sub(line.trim_start().len());
11822        indent + line.trim_start().find(pattern).unwrap_or(0)
11823    })
11824}
11825
11826fn parse_terminal_pattern_parts(pattern: &str) -> (Option<String>, Option<String>) {
11827    if is_fallback_pattern(pattern) {
11828        return (None, None);
11829    }
11830    let mut parts = pattern.split_whitespace();
11831    let tag = parts.next().map(str::to_owned);
11832    // Binding is `Tag as binding` (Stage 1b: the space form `Tag binding` is gone).
11833    let second = parts.next();
11834    let binding = match second {
11835        Some("as") => parts.next().map(str::to_owned),
11836        Some(_) => return (tag, None),
11837        None => None,
11838    };
11839    if parts.next().is_some() {
11840        return (tag, None);
11841    }
11842    (tag, binding)
11843}
11844
11845fn terminal_payload_schema_for_tag(
11846    tag: &str,
11847    scrutinee: &str,
11848    effect_payload_types: &BTreeMap<String, IrType>,
11849) -> Option<String> {
11850    match tag {
11851        "Completed" => match effect_payload_types.get(scrutinee) {
11852            Some(IrType::Ref(schema)) => Some(schema.clone()),
11853            _ => None,
11854        },
11855        "Failed" => Some("TerminalFailed".to_owned()),
11856        "TimedOut" => Some("TerminalTimedOut".to_owned()),
11857        "Cancelled" => Some("TerminalCancelled".to_owned()),
11858        _ => None,
11859    }
11860}
11861
11862fn terminal_alternatives(
11863    completed_payload: IrType,
11864    span: SourceSpan,
11865) -> Vec<IrTerminalAlternative> {
11866    [
11867        ("Completed", completed_payload),
11868        ("Failed", terminal_failure_payload_type()),
11869        ("TimedOut", terminal_timeout_payload_type()),
11870        ("Cancelled", terminal_cancelled_payload_type()),
11871    ]
11872    .into_iter()
11873    .map(|(tag, payload_type)| IrTerminalAlternative {
11874        tag: tag.to_owned(),
11875        payload_type,
11876        source_span: span,
11877    })
11878    .collect()
11879}
11880
11881fn terminal_failure_payload_type() -> IrType {
11882    IrType::Object(vec![
11883        ir_field("reason", IrType::Primitive(IrPrimitiveType::String)),
11884        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11885        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11886        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11887    ])
11888}
11889
11890fn terminal_timeout_payload_type() -> IrType {
11891    IrType::Object(vec![
11892        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11893        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11894        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11895    ])
11896}
11897
11898fn terminal_cancelled_payload_type() -> IrType {
11899    IrType::Object(vec![
11900        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11901        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11902        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11903    ])
11904}
11905
11906fn terminal_unknown_payload_type() -> IrType {
11907    IrType::Object(vec![
11908        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11909        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11910        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11911    ])
11912}
11913
11914fn ir_field(name: &str, ty: IrType) -> IrClassField {
11915    IrClassField {
11916        name: name.to_owned(),
11917        ty,
11918        is_key: false,
11919        presence_condition: None,
11920        span: SourceSpan { start: 0, end: 0 },
11921    }
11922}
11923
11924/// Lower parsed access grants to IR for effects that carry authority-narrowing
11925/// metadata (`tell` turns and `invoke` start grants).
11926fn ir_access_grants_for_body(kind: &body::BodyEffectKind) -> Vec<IrAccessGrant> {
11927    match kind {
11928        body::BodyEffectKind::Tell { access_grants, .. }
11929        | body::BodyEffectKind::Invoke { access_grants, .. } => access_grants
11930            .iter()
11931            .map(|grant| IrAccessGrant {
11932                resource: grant.resource.clone(),
11933                operations: grant
11934                    .operations
11935                    .iter()
11936                    .map(|op| IrAccessGrantOp {
11937                        operation: op.operation.clone(),
11938                        target: op.target.clone(),
11939                        globs: op.globs.clone(),
11940                    })
11941                    .collect(),
11942            })
11943            .collect(),
11944        _ => Vec::new(),
11945    }
11946}
11947
11948fn ir_effect_kind_for_body(kind: &body::BodyEffectKind) -> IrEffectKind {
11949    match kind {
11950        body::BodyEffectKind::Tell { .. } => IrEffectKind::AgentTell,
11951        body::BodyEffectKind::Coerce { .. }
11952        | body::BodyEffectKind::Prompt { .. }
11953        | body::BodyEffectKind::Decide { .. } => IrEffectKind::SchemaCoerce,
11954        body::BodyEffectKind::Call { .. }
11955        | body::BodyEffectKind::ConstructCapabilityCall { .. } => IrEffectKind::CapabilityCall,
11956        body::BodyEffectKind::Invoke { .. } => IrEffectKind::WorkflowInvoke,
11957        body::BodyEffectKind::Timer { .. } => IrEffectKind::TimerWait,
11958        body::BodyEffectKind::Exec { .. } => IrEffectKind::ExecCommand,
11959        body::BodyEffectKind::TrackerFile { .. } => IrEffectKind::TrackerFile,
11960        body::BodyEffectKind::TrackerClaim { .. } => IrEffectKind::TrackerClaim,
11961        body::BodyEffectKind::TrackerRelease { .. } => IrEffectKind::TrackerRelease,
11962        body::BodyEffectKind::TrackerFinish { .. } => IrEffectKind::TrackerFinish,
11963        body::BodyEffectKind::LeaseAcquire { .. } => IrEffectKind::LeaseAcquire,
11964        body::BodyEffectKind::LeaseRenew { .. } => IrEffectKind::LeaseRenew,
11965        body::BodyEffectKind::LedgerAppend { .. } => IrEffectKind::LedgerAppend,
11966        body::BodyEffectKind::CounterConsume { .. } => IrEffectKind::CounterConsume,
11967        body::BodyEffectKind::Notify { .. } => IrEffectKind::SignalEmit,
11968        body::BodyEffectKind::FileRead { .. } => IrEffectKind::FileRead,
11969        body::BodyEffectKind::FileWrite { .. } => IrEffectKind::FileWrite,
11970        body::BodyEffectKind::FileImport { .. } => IrEffectKind::FileImport,
11971        body::BodyEffectKind::FileExport { .. } => IrEffectKind::FileExport,
11972    }
11973}
11974
11975/// The agent a `tell` addresses, surfaced for information-flow analysis of the
11976/// turn's egress to the agent's provider. `None` for non-`tell` effects.
11977fn agent_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11978    match kind {
11979        body::BodyEffectKind::Tell { target, .. } => Some(target.clone()),
11980        _ => None,
11981    }
11982}
11983
11984/// Turn-scoped `with skills [...]` pinned onto an `agent.tell` effect (Phase 7).
11985fn turn_skills_for_body(kind: &body::BodyEffectKind) -> Vec<String> {
11986    match kind {
11987        body::BodyEffectKind::Tell { skills, .. } => skills.clone(),
11988        _ => Vec::new(),
11989    }
11990}
11991
11992/// The workflow an `invoke` targets, surfaced for IFC membrane-door enumeration.
11993fn workflow_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11994    match kind {
11995        body::BodyEffectKind::Invoke { workflow, .. } => Some(workflow.clone()),
11996        _ => None,
11997    }
11998}
11999
12000/// The `exec` surface form (raw command vs manifest capability), surfaced so
12001/// check-time gates classify exec effects without re-scanning rule-body text.
12002fn exec_target_for_body(kind: &body::BodyEffectKind) -> Option<IrExecTarget> {
12003    match kind {
12004        body::BodyEffectKind::Exec { target, .. } => Some(match target {
12005            body::ExecTarget::RawCommand(_) => IrExecTarget::Raw,
12006            body::ExecTarget::Capability { name, .. } => {
12007                IrExecTarget::Capability { name: name.clone() }
12008            }
12009        }),
12010        _ => None,
12011    }
12012}
12013
12014/// Whether an effect carries the `endorsed` source marker (I-IFC3) — a `coerce` the
12015/// author declared an integrity-raising crossing.
12016fn endorsed_for_body(kind: &body::BodyEffectKind) -> bool {
12017    matches!(kind, body::BodyEffectKind::Coerce { endorsed: true, .. })
12018}
12019
12020/// Whether an effect carries the `declassified` source marker (I-IFC3) — a `coerce`
12021/// the author declared a confidentiality-lowering crossing.
12022fn declassified_for_body(kind: &body::BodyEffectKind) -> bool {
12023    matches!(
12024        kind,
12025        body::BodyEffectKind::Coerce {
12026            declassified: true,
12027            ..
12028        }
12029    )
12030}
12031
12032/// The named resource a direct file/channel effect touches, surfaced for
12033/// information-flow analysis. `None` for effects with no named resource.
12034fn resource_for_body(kind: &body::BodyEffectKind) -> Option<String> {
12035    match kind {
12036        body::BodyEffectKind::FileRead { store, .. }
12037        | body::BodyEffectKind::FileWrite { store, .. }
12038        | body::BodyEffectKind::FileImport { store, .. }
12039        | body::BodyEffectKind::FileExport { store, .. } => Some(store.clone()),
12040        // `send via <channel>` carries the channel as a construct field.
12041        body::BodyEffectKind::ConstructCapabilityCall {
12042            keyword, fields, ..
12043        } if keyword == "send" => fields
12044            .iter()
12045            .find(|field| field.name == "channel")
12046            .map(|field| field.source.clone()),
12047        // `emit signal <name> to <peer>` touches the signal port `signal:<name>` (the
12048        // emit-port door, DR-0027 E6/H8); surfaced so the IFC checker can carry the
12049        // emitter's label to the receiver and enumerate the port in the surface.
12050        body::BodyEffectKind::Notify { event, .. } => Some(format!("signal:{event}")),
12051        // Coordination is governed as a resource label under E-COORD. The IFC
12052        // checker decides whether this declaration is partitioned or `shared`.
12053        body::BodyEffectKind::LeaseAcquire { resource, .. } => Some(format!("resource:{resource}")),
12054        body::BodyEffectKind::LedgerAppend { ledger, .. } => Some(format!("resource:{ledger}")),
12055        body::BodyEffectKind::CounterConsume { counter, .. } => Some(format!("resource:{counter}")),
12056        _ => None,
12057    }
12058}
12059
12060fn construct_use_for_body(kind: &body::BodyEffectKind) -> Option<IrConstructUse> {
12061    match kind {
12062        body::BodyEffectKind::ConstructCapabilityCall {
12063            keyword,
12064            target_capability,
12065            ..
12066        } => Some(IrConstructUse {
12067            keyword: keyword.clone(),
12068            scope: "rule_body".to_owned(),
12069            construct_family: "effect_operation".to_owned(),
12070            lowering_target: "capability_call".to_owned(),
12071            target_capability: target_capability.clone(),
12072        }),
12073        _ => None,
12074    }
12075}
12076
12077fn is_ast_only_effect_kind(kind: &body::BodyEffectKind) -> bool {
12078    // `send via <channel> { … } as x` closes its `as` on the block line (unlike
12079    // `recall`, whose `as` is inline), so the line scanner cannot see the binding;
12080    // seed it from the AST. Other `ConstructCapabilityCall`s (e.g. `recall`) are
12081    // line-visible and must NOT be treated as AST-only.
12082    if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = kind {
12083        return keyword == "send";
12084    }
12085    matches!(
12086        kind,
12087        body::BodyEffectKind::Prompt { .. }
12088            | body::BodyEffectKind::Timer { .. }
12089            | body::BodyEffectKind::Exec { .. }
12090            | body::BodyEffectKind::Decide { .. }
12091            | body::BodyEffectKind::TrackerFile { .. }
12092            | body::BodyEffectKind::TrackerClaim { .. }
12093            | body::BodyEffectKind::TrackerRelease { .. }
12094            | body::BodyEffectKind::TrackerFinish { .. }
12095            | body::BodyEffectKind::LeaseAcquire { .. }
12096            | body::BodyEffectKind::LeaseRenew { .. }
12097            | body::BodyEffectKind::LedgerAppend { .. }
12098            | body::BodyEffectKind::CounterConsume { .. }
12099            | body::BodyEffectKind::Notify { .. }
12100            // `invoke` payload blocks put post-payload modifiers on the closing line
12101            // in flow-generated rules, so the line scanner may miss `as <binding>`.
12102            | body::BodyEffectKind::Invoke { .. }
12103            // `write`/`export` put their `as <binding>` on the block's closing
12104            // line, so the line-based scanner cannot see it; seed it from the AST
12105            // so `after <binding>` blocks and sequence checks resolve.
12106            | body::BodyEffectKind::FileWrite { .. }
12107            | body::BodyEffectKind::FileExport { .. }
12108    )
12109}
12110
12111/// Bindings introduced by AST-only effect kinds are unknown to the
12112/// line-based scanner; seed them so sequence checks and `after` blocks see
12113/// them. Binding types for typed outputs are registered where known.
12114fn seed_ast_only_effect_bindings(
12115    statements: &[body::BodyStmt],
12116    seen_bindings: &mut BTreeSet<String>,
12117    binding_types: &mut BTreeMap<String, String>,
12118) {
12119    for statement in statements {
12120        match statement {
12121            body::BodyStmt::Effect(effect) if is_ast_only_effect_kind(&effect.kind) => {
12122                if let Some(binding) = &effect.binding {
12123                    seen_bindings.insert(binding.clone());
12124                    let _ = binding_types;
12125                }
12126            }
12127            body::BodyStmt::After(after) => {
12128                seed_ast_only_effect_bindings(&after.body, seen_bindings, binding_types)
12129            }
12130            body::BodyStmt::Case(case) => {
12131                for branch in &case.branches {
12132                    seed_ast_only_effect_bindings(&branch.body, seen_bindings, binding_types);
12133                }
12134            }
12135            _ => {}
12136        }
12137    }
12138}
12139
12140/// Derives effect nodes and dependency edges from the body AST, in document
12141/// order, with ids and idempotency keys identical to the historical
12142/// line-scanner derivation.
12143/// Collect the output bindings a rule `complete`s, recursing through the body's
12144/// nested blocks (after / case / branch / handler). A `complete <binding> {…}` is the
12145/// workflow's output to its invoker; the IFC checker treats it as an egress sink at
12146/// the invoker boundary (DR-0030 X2). `fail` terminals are NOT collected —
12147/// they carry an error to the runtime, not a value to the invoker.
12148fn collect_terminal_complete_bindings(statements: &[body::BodyStmt], out: &mut Vec<String>) {
12149    for statement in statements {
12150        match statement {
12151            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
12152                out.push(terminal.name.clone());
12153            }
12154            body::BodyStmt::After(after) => collect_terminal_complete_bindings(&after.body, out),
12155            body::BodyStmt::Case(case) => {
12156                for branch in &case.branches {
12157                    collect_terminal_complete_bindings(&branch.body, out);
12158                }
12159            }
12160            _ => {}
12161        }
12162    }
12163}
12164
12165fn collect_effects_from_ast(
12166    statements: &[body::BodyStmt],
12167    rule_name: &str,
12168) -> (Vec<IrEffectNode>, Vec<IrEffectDependency>) {
12169    let mut effects = Vec::new();
12170    let mut dependencies = Vec::new();
12171    let mut counter = 0usize;
12172    let mut after_stack: Vec<(String, DependencyPredicate)> = Vec::new();
12173    let mut case_stack: Vec<(String, String)> = Vec::new();
12174    // Renew disambiguation (T3, mirroring the shipped `release` split): a
12175    // `renew <binding>` whose binding names a same-rule `claim <issue> as
12176    // <binding>` is a tracker claim-renew (`tracker.renew`); one naming an
12177    // `acquire ... as <binding>` stays a lease renew (`lease.renew`). Collect
12178    // the claim `as` bindings up front so the walk can re-classify.
12179    let claim_bindings = collect_claim_bindings(statements);
12180    walk_effects(
12181        statements,
12182        rule_name,
12183        &claim_bindings,
12184        &mut counter,
12185        &mut after_stack,
12186        &mut case_stack,
12187        &mut effects,
12188        &mut dependencies,
12189    );
12190    (effects, dependencies)
12191}
12192
12193/// The `as` bindings of every `claim <issue> as <binding>` in a rule body — the
12194/// referent set the renew disambiguation flips `lease.renew` to `tracker.renew`
12195/// against (the claim result binding, whose output fact carries the issue id).
12196fn collect_claim_bindings(statements: &[body::BodyStmt]) -> BTreeSet<String> {
12197    let mut bindings = BTreeSet::new();
12198    for_each_body(statements, &mut |stmt| {
12199        if let body::BodyStmt::Effect(effect) = stmt {
12200            if matches!(effect.kind, body::BodyEffectKind::TrackerClaim { .. }) {
12201                if let Some(binding) = &effect.binding {
12202                    bindings.insert(binding.clone());
12203                }
12204            }
12205        }
12206    });
12207    bindings
12208}
12209
12210#[allow(clippy::too_many_arguments)]
12211fn walk_effects(
12212    statements: &[body::BodyStmt],
12213    rule_name: &str,
12214    claim_bindings: &BTreeSet<String>,
12215    counter: &mut usize,
12216    after_stack: &mut Vec<(String, DependencyPredicate)>,
12217    case_stack: &mut Vec<(String, String)>,
12218    effects: &mut Vec<IrEffectNode>,
12219    dependencies: &mut Vec<IrEffectDependency>,
12220) {
12221    for statement in statements {
12222        match statement {
12223            body::BodyStmt::Effect(effect) => {
12224                *counter += 1;
12225                let id = effect
12226                    .binding
12227                    .clone()
12228                    .unwrap_or_else(|| format!("effect{counter}"));
12229                // A `renew` naming a claim binding lowers to `tracker.renew`;
12230                // otherwise it stays the coord `lease.renew` its parser produced.
12231                let kind = match &effect.kind {
12232                    body::BodyEffectKind::LeaseRenew {
12233                        acquire_binding, ..
12234                    } if claim_bindings.contains(acquire_binding) => IrEffectKind::TrackerRenew,
12235                    other => ir_effect_kind_for_body(other),
12236                };
12237                for (upstream, predicate) in after_stack.iter() {
12238                    dependencies.push(IrEffectDependency {
12239                        upstream: upstream.clone(),
12240                        predicate: predicate.clone(),
12241                        downstream: id.clone(),
12242                    });
12243                }
12244                let idempotency_key =
12245                    effect_idempotency_key(rule_name, &id, &kind, &effect.binding);
12246                let mut required_capabilities = effect.requires.clone();
12247                match &effect.kind {
12248                    body::BodyEffectKind::Call { capability, .. } => {
12249                        required_capabilities.push(capability.clone());
12250                    }
12251                    body::BodyEffectKind::ConstructCapabilityCall {
12252                        target_capability, ..
12253                    } => {
12254                        required_capabilities.push(target_capability.clone());
12255                    }
12256                    _ => {}
12257                }
12258                required_capabilities.sort();
12259                required_capabilities.dedup();
12260                let construct_use = construct_use_for_body(&effect.kind);
12261                let access_grants = ir_access_grants_for_body(&effect.kind);
12262                let turn_skills = turn_skills_for_body(&effect.kind);
12263                let resource = resource_for_body(&effect.kind);
12264                let agent = agent_for_body(&effect.kind);
12265                let workflow_target = workflow_target_for_body(&effect.kind);
12266                let endorsed = endorsed_for_body(&effect.kind);
12267                let declassified = declassified_for_body(&effect.kind);
12268                let exec_target = exec_target_for_body(&effect.kind);
12269                effects.push(IrEffectNode {
12270                    id,
12271                    kind,
12272                    binding: effect.binding.clone(),
12273                    required_capabilities,
12274                    construct_use,
12275                    idempotency_key,
12276                    span: effect.span,
12277                    timeout_seconds: effect.timeout_seconds,
12278                    access_grants,
12279                    turn_skills,
12280                    resource,
12281                    agent,
12282                    workflow_target,
12283                    endorsed,
12284                    declassified,
12285                    selected_by: case_stack.last().cloned(),
12286                    exec_target,
12287                });
12288            }
12289            body::BodyStmt::After(after) => {
12290                let predicate = match after.predicate {
12291                    body::AfterPredicate::Succeeds => DependencyPredicate::Succeeds,
12292                    body::AfterPredicate::Fails => DependencyPredicate::Fails,
12293                    // `times out` / `cancelled` are distinct non-success terminal
12294                    // statuses, so the downstream effect releases only on that
12295                    // specific status (mirroring succeeds/fails), not on any
12296                    // terminal.
12297                    body::AfterPredicate::TimedOut => DependencyPredicate::TimedOut,
12298                    body::AfterPredicate::Cancelled => DependencyPredicate::Cancelled,
12299                    // Coordination outcomes are completion-valued: the downstream
12300                    // depends on the op reaching a terminal state; the outcome
12301                    // variant selects the arm at lowering.
12302                    body::AfterPredicate::Completes
12303                    | body::AfterPredicate::Held
12304                    | body::AfterPredicate::Contended
12305                    | body::AfterPredicate::Ok
12306                    | body::AfterPredicate::Over => DependencyPredicate::Completes,
12307                    // `reaches "<name>"` (Family C) is completion-shaped for the
12308                    // construct-graph provenance edge; the milestone-specific
12309                    // gating happens at runtime against the
12310                    // `workflow.invoke.reached:<name>` fact (text-keyed, see
12311                    // `fact_matches_after_predicate`), so this IR predicate is
12312                    // metadata only.
12313                    body::AfterPredicate::Reaches => DependencyPredicate::Completes,
12314                };
12315                after_stack.push((after.binding.clone(), predicate));
12316                walk_effects(
12317                    &after.body,
12318                    rule_name,
12319                    claim_bindings,
12320                    counter,
12321                    after_stack,
12322                    case_stack,
12323                    effects,
12324                    dependencies,
12325                );
12326                after_stack.pop();
12327            }
12328            body::BodyStmt::Case(case) => {
12329                for branch in &case.branches {
12330                    // Record the selector: an effect in this arm is gated by
12331                    // `case <scrutinee> { <pattern> => … }` (DR §7.4).
12332                    case_stack.push((case.scrutinee.clone(), branch.pattern.clone()));
12333                    walk_effects(
12334                        &branch.body,
12335                        rule_name,
12336                        claim_bindings,
12337                        counter,
12338                        after_stack,
12339                        case_stack,
12340                        effects,
12341                        dependencies,
12342                    );
12343                    case_stack.pop();
12344                }
12345            }
12346            _ => {}
12347        }
12348    }
12349}
12350
12351fn effect_idempotency_key(
12352    rule_name: &str,
12353    effect_id: &str,
12354    kind: &IrEffectKind,
12355    binding: &Option<String>,
12356) -> String {
12357    stable_hash(&format!(
12358        "rule={rule_name};effect={effect_id};kind={};binding={}",
12359        kind.as_str(),
12360        binding.as_deref().unwrap_or("-")
12361    ))
12362}
12363
12364fn validate_coerce_call(
12365    rule: &RuleDecl,
12366    line: &str,
12367    semantic: &SemanticContext,
12368    binding_types: &BTreeMap<String, String>,
12369    known_roots: &BTreeSet<String>,
12370    diagnostics: &mut Vec<Diagnostic>,
12371) {
12372    let Some((function_name, args)) = parse_coerce_call(line) else {
12373        diagnostics.push(Diagnostic {
12374            related: Vec::new(),
12375            span: rule.body.span,
12376            message: format!("rule `{}` has malformed coerce call", rule.name.name),
12377            suggestion: Some("write `coerce functionName(arg, ...) as name`".to_owned()),
12378        });
12379        return;
12380    };
12381    let Some(params) = semantic.coerce_params.get(function_name) else {
12382        diagnostics.push(Diagnostic {
12383            related: Vec::new(),
12384            span: rule.body.span,
12385            message: format!(
12386                "rule `{}` calls unknown coerce function `{function_name}`",
12387                rule.name.name
12388            ),
12389            suggestion: Some(format!(
12390                "declare `coerce {function_name}(...) -> Output {{ ... }}` before using it"
12391            )),
12392        });
12393        return;
12394    };
12395    if args.len() != params.len() {
12396        diagnostics.push(Diagnostic {
12397            related: Vec::new(),
12398            span: rule.body.span,
12399            message: format!(
12400                "rule `{}` calls coerce `{function_name}` with {} argument(s), expected {}",
12401                rule.name.name,
12402                args.len(),
12403                params.len()
12404            ),
12405            suggestion: Some("pass one argument for each declared coerce parameter".to_owned()),
12406        });
12407        return;
12408    }
12409    let scope = ExprScope::from_bindings(binding_types);
12410    for (arg, param) in args.iter().zip(params) {
12411        // Dangling-root check (mirrors record/terminal value validation): an arg
12412        // whose root is not a known binding is a typo/unbound reference, which the
12413        // type-checker below accepts leniently.
12414        if let Some(root) = dangling_value_root(arg, known_roots) {
12415            diagnostics.push(Diagnostic { related: Vec::new(),
12416                span: rule.body.span,
12417                message: format!(
12418                    "rule `{}` has unknown binding `{root}` in coerce `{function_name}` argument",
12419                    rule.name.name
12420                ),
12421                suggestion: Some(
12422                    "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
12423                        .to_owned(),
12424                ),
12425            });
12426        }
12427        validate_expr_source_against_type(
12428            rule,
12429            &format!("coerce `{function_name}`"),
12430            &param.name.name,
12431            &param.ty,
12432            arg,
12433            semantic,
12434            &scope,
12435            diagnostics,
12436        );
12437    }
12438}
12439
12440fn validate_effect_payloads(
12441    rule: &RuleDecl,
12442    semantic: &SemanticContext,
12443    binding_types: &BTreeMap<String, String>,
12444    known_roots: &BTreeSet<String>,
12445    diagnostics: &mut Vec<Diagnostic>,
12446) {
12447    for statement in effect_payload_statements(&rule.body.text) {
12448        let trimmed = statement.trim();
12449        if trimmed.starts_with("coerce ") {
12450            validate_coerce_call(
12451                rule,
12452                trimmed,
12453                semantic,
12454                binding_types,
12455                known_roots,
12456                diagnostics,
12457            );
12458        }
12459    }
12460}
12461
12462fn validate_workflow_invocations(
12463    rule: &RuleDecl,
12464    semantic: &SemanticContext,
12465    binding_types: &BTreeMap<String, String>,
12466    known_roots: &BTreeSet<String>,
12467    diagnostics: &mut Vec<Diagnostic>,
12468) {
12469    for statement in workflow_invoke_statements(&rule.body.text) {
12470        let Some((target, body)) = invoke_statement_parts(&statement) else {
12471            diagnostics.push(Diagnostic {
12472                related: Vec::new(),
12473                span: rule.body.span,
12474                message: format!(
12475                    "rule `{}` has malformed workflow invocation",
12476                    rule.name.name
12477                ),
12478                suggestion: Some("write `invoke Workflow { input value } as binding`".to_owned()),
12479            });
12480            continue;
12481        };
12482        if semantic.workflow.as_deref() == Some(target) {
12483            diagnostics.push(Diagnostic {
12484                related: Vec::new(),
12485                span: rule.body.span,
12486                message: format!(
12487                    "rule `{}` recursively invokes workflow `{target}`",
12488                    rule.name.name
12489                ),
12490                suggestion: Some(
12491                    "split recursive orchestration into an explicit bounded scheduler workflow"
12492                        .to_owned(),
12493                ),
12494            });
12495            continue;
12496        }
12497        let Some(surface) = semantic.workflow_inputs.get(target) else {
12498            diagnostics.push(Diagnostic {
12499                related: Vec::new(),
12500                span: rule.body.span,
12501                message: format!(
12502                    "rule `{}` invokes unknown workflow `{target}`",
12503                    rule.name.name
12504                ),
12505                suggestion: Some("invoke a workflow declared in this source bundle".to_owned()),
12506            });
12507            continue;
12508        };
12509
12510        let mut invocation_semantic = semantic.clone();
12511        invocation_semantic.schemas.merge(surface.schemas.clone());
12512        let assignments = collect_field_assignments(body);
12513        let mut seen = BTreeSet::new();
12514        for assignment in assignments {
12515            let (field, value) = match assignment {
12516                RecordFieldAssignment::Value { field, value } => (field, value),
12517                RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
12518            };
12519            if !seen.insert(field.clone()) {
12520                diagnostics.push(Diagnostic {
12521                    related: Vec::new(),
12522                    span: rule.body.span,
12523                    message: format!("workflow invocation `{target}` repeats input `{field}`"),
12524                    suggestion: Some("remove the duplicate invocation input".to_owned()),
12525                });
12526                continue;
12527            }
12528            let Some(input_ty) = surface.inputs.get(&field) else {
12529                let known = surface
12530                    .inputs
12531                    .keys()
12532                    .map(|input| format!("`{input}`"))
12533                    .collect::<Vec<_>>()
12534                    .join(", ");
12535                diagnostics.push(Diagnostic {
12536                    related: Vec::new(),
12537                    span: rule.body.span,
12538                    message: format!("workflow `{target}` has no input `{field}`"),
12539                    suggestion: Some(if known.is_empty() {
12540                        "remove the invocation payload; the target declares no inputs".to_owned()
12541                    } else {
12542                        format!("pass one of: {known}")
12543                    }),
12544                });
12545                continue;
12546            };
12547            if let Some(root) = dangling_value_root(&value, known_roots) {
12548                diagnostics.push(Diagnostic { related: Vec::new(),
12549                    span: rule.body.span,
12550                    message: format!(
12551                        "rule `{}` has unknown binding `{root}` in `invoke {target}` input `{field}`",
12552                        rule.name.name
12553                    ),
12554                    suggestion: Some(
12555                        "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
12556                            .to_owned(),
12557                    ),
12558                });
12559            }
12560            validate_expr_source_against_type(
12561                rule,
12562                target,
12563                &field,
12564                input_ty,
12565                &value,
12566                &invocation_semantic,
12567                &ExprScope::from_bindings(binding_types),
12568                diagnostics,
12569            );
12570        }
12571        for input in surface.inputs.keys() {
12572            if seen.contains(input) {
12573                continue;
12574            }
12575            diagnostics.push(Diagnostic {
12576                related: Vec::new(),
12577                span: rule.body.span,
12578                message: format!("workflow invocation `{target}` is missing input `{input}`"),
12579                suggestion: Some(format!(
12580                    "add `{input}` to the `{target}` invocation payload"
12581                )),
12582            });
12583        }
12584    }
12585}
12586
12587fn validate_agent_tell_target(
12588    rule: &RuleDecl,
12589    line: &str,
12590    kind: &IrEffectKind,
12591    semantic: &SemanticContext,
12592    binding_types: &BTreeMap<String, String>,
12593    known_roots: &BTreeSet<String>,
12594    diagnostics: &mut Vec<Diagnostic>,
12595) {
12596    if kind != &IrEffectKind::AgentTell {
12597        return;
12598    }
12599    let Some(target) = parse_tell_target(line) else {
12600        diagnostics.push(Diagnostic {
12601            related: Vec::new(),
12602            span: rule.body.span,
12603            message: format!("rule `{}` has malformed tell target", rule.name.name),
12604            suggestion: Some("write `tell agentName ...` or `tell task.agentRef ...`".to_owned()),
12605        });
12606        return;
12607    };
12608    if target.starts_with('"') {
12609        diagnostics.push(Diagnostic {
12610            related: Vec::new(),
12611            span: rule.body.span,
12612            message: format!(
12613                "rule `{}` uses a string literal as a tell target",
12614                rule.name.name
12615            ),
12616            suggestion: Some("use a declared agent name or an AgentRef field".to_owned()),
12617        });
12618        return;
12619    }
12620    let required_capabilities = parse_required_capabilities(line);
12621    if target.contains('.') {
12622        let Some(ty) = expression_type(target, semantic, binding_types) else {
12623            // Unknown type can mean a dangling root (the path's binding does not
12624            // exist) — caught here since the type lookup returns None silently. A
12625            // known root with a bad path is left to other validation.
12626            if let Some(root) = dangling_value_root(target, known_roots) {
12627                diagnostics.push(Diagnostic { related: Vec::new(),
12628                    span: rule.body.span,
12629                    message: format!(
12630                        "rule `{}` has unknown binding `{root}` in tell target `{target}`",
12631                        rule.name.name
12632                    ),
12633                    suggestion: Some(
12634                        "reference a binding from a `when ... as name` clause or an effect `as` binding"
12635                            .to_owned(),
12636                    ),
12637                });
12638            }
12639            return;
12640        };
12641        if let TypeSyntax::AgentRef { agents, .. } = ty {
12642            for agent in agents {
12643                validate_agent_capabilities(
12644                    rule,
12645                    &agent.name,
12646                    &required_capabilities,
12647                    semantic,
12648                    diagnostics,
12649                );
12650            }
12651        } else {
12652            diagnostics.push(Diagnostic {
12653                related: Vec::new(),
12654                span: rule.body.span,
12655                message: format!(
12656                    "rule `{}` uses non-AgentRef dynamic tell target `{target}`",
12657                    rule.name.name
12658                ),
12659                suggestion: Some(
12660                    "declare the field as `AgentRef<...>` before using it as a tell target"
12661                        .to_owned(),
12662                ),
12663            });
12664        }
12665        return;
12666    }
12667    if !semantic.agents.contains(target) {
12668        diagnostics.push(Diagnostic {
12669            related: Vec::new(),
12670            span: rule.body.span,
12671            message: format!("rule `{}` tells unknown agent `{target}`", rule.name.name),
12672            suggestion: Some("declare the target agent before telling it".to_owned()),
12673        });
12674        return;
12675    }
12676    validate_agent_capabilities(rule, target, &required_capabilities, semantic, diagnostics);
12677}
12678
12679fn validate_agent_capabilities(
12680    rule: &RuleDecl,
12681    agent: &str,
12682    required_capabilities: &[String],
12683    semantic: &SemanticContext,
12684    diagnostics: &mut Vec<Diagnostic>,
12685) {
12686    if required_capabilities.is_empty() {
12687        return;
12688    }
12689    let declared = semantic
12690        .agent_capabilities
12691        .get(agent)
12692        .cloned()
12693        .unwrap_or_default();
12694    for capability in required_capabilities {
12695        if !declared.contains(capability) {
12696            diagnostics.push(Diagnostic { related: Vec::new(),
12697                span: rule.body.span,
12698                message: format!(
12699                    "rule `{}` tells agent `{agent}` requiring undeclared capability `{capability}`",
12700                    rule.name.name
12701                ),
12702                suggestion: Some(format!(
12703                    "add `{capability}` to agent `{agent}` capabilities or choose another AgentRef target"
12704                )),
12705            });
12706        }
12707    }
12708}
12709
12710fn validate_availability_when(
12711    rule: &RuleDecl,
12712    when: &str,
12713    semantic: &SemanticContext,
12714    binding_types: &BTreeMap<String, String>,
12715    diagnostics: &mut Vec<Diagnostic>,
12716) {
12717    let (pattern, _) = split_when_guard(when);
12718    let Some(target) = pattern.strip_suffix(" is available").map(str::trim) else {
12719        return;
12720    };
12721    if target.contains('.') {
12722        let Some(ty) = expression_type(target, semantic, binding_types) else {
12723            return;
12724        };
12725        if !matches!(ty, TypeSyntax::AgentRef { .. }) {
12726            diagnostics.push(Diagnostic {
12727                related: Vec::new(),
12728                span: rule.body.span,
12729                message: format!(
12730                    "rule `{}` checks availability for non-AgentRef `{target}`",
12731                    rule.name.name
12732                ),
12733                suggestion: Some(
12734                    "availability checks must name a declared agent or an AgentRef field"
12735                        .to_owned(),
12736                ),
12737            });
12738        }
12739        return;
12740    }
12741    if !semantic.agents.contains(target) {
12742        diagnostics.push(Diagnostic {
12743            related: Vec::new(),
12744            span: rule.body.span,
12745            message: format!("rule `{}` checks unknown agent `{target}`", rule.name.name),
12746            suggestion: Some("declare the target agent before checking availability".to_owned()),
12747        });
12748    }
12749}
12750
12751#[derive(Clone, Debug, Default)]
12752struct ExprScope {
12753    binding_types: BTreeMap<String, String>,
12754    implicit_schema: Option<String>,
12755}
12756
12757impl ExprScope {
12758    fn from_bindings(binding_types: &BTreeMap<String, String>) -> Self {
12759        Self {
12760            binding_types: binding_types.clone(),
12761            implicit_schema: None,
12762        }
12763    }
12764
12765    fn with_implicit_schema(&self, schema: String) -> Self {
12766        let mut scope = self.clone();
12767        scope.implicit_schema = Some(schema);
12768        scope
12769    }
12770}
12771
12772#[derive(Clone, Debug)]
12773struct ExprValidationContext {
12774    subject: String,
12775    span: SourceSpan,
12776}
12777
12778impl ExprValidationContext {
12779    fn rule(rule: &RuleDecl) -> Self {
12780        Self {
12781            subject: format!("rule `{}`", rule.name.name),
12782            span: rule.body.span,
12783        }
12784    }
12785
12786    fn assertion(span: SourceSpan) -> Self {
12787        Self {
12788            subject: "assertion".to_owned(),
12789            span,
12790        }
12791    }
12792}
12793
12794fn validate_expression(
12795    rule: &RuleDecl,
12796    expr: &str,
12797    semantic: &SemanticContext,
12798    binding_types: &BTreeMap<String, String>,
12799    label: &str,
12800    diagnostics: &mut Vec<Diagnostic>,
12801) {
12802    match parse_expression(expr) {
12803        Ok(expr) => {
12804            validate_parsed_expression(
12805                &expr,
12806                semantic,
12807                &ExprScope::from_bindings(binding_types),
12808                &ExprValidationContext::rule(rule),
12809                label,
12810                diagnostics,
12811            );
12812        }
12813        Err(message) => diagnostics.push(Diagnostic { related: Vec::new(),
12814            span: rule.body.span,
12815            message: format!("rule `{}` has invalid {label} expression: {message}", rule.name.name),
12816            suggestion: Some("use deterministic field paths, literals, boolean operators, comparisons, membership, count, or exists".to_owned()),
12817        }),
12818    }
12819}
12820
12821fn validate_parsed_expression(
12822    expr: &Expr,
12823    semantic: &SemanticContext,
12824    scope: &ExprScope,
12825    context: &ExprValidationContext,
12826    label: &str,
12827    diagnostics: &mut Vec<Diagnostic>,
12828) {
12829    let presence_proofs = BTreeSet::new();
12830    validate_expr_node(
12831        expr,
12832        semantic,
12833        scope,
12834        context,
12835        &presence_proofs,
12836        diagnostics,
12837    );
12838    let ty = infer_expr_type(expr, semantic, scope, context, diagnostics);
12839    if ty != ExprType::Bool && ty != ExprType::Unknown {
12840        diagnostics.push(Diagnostic {
12841            related: Vec::new(),
12842            span: context.span,
12843            message: format!("{} has non-boolean {label} expression", context.subject),
12844            suggestion: Some(format!("{label} expressions must evaluate to bool")),
12845        });
12846    }
12847}
12848
12849fn validate_expr_node(
12850    expr: &Expr,
12851    semantic: &SemanticContext,
12852    scope: &ExprScope,
12853    context: &ExprValidationContext,
12854    presence_proofs: &BTreeSet<String>,
12855    diagnostics: &mut Vec<Diagnostic>,
12856) {
12857    match expr {
12858        Expr::Path(path) => {
12859            if path.len() < 2 {
12860                return;
12861            }
12862            let root = &path[0];
12863            let Some(schema) = scope.binding_types.get(root) else {
12864                if let Some(schema) = &scope.implicit_schema {
12865                    if let Err(message) =
12866                        validate_optional_path_access(schema, path, semantic, presence_proofs)
12867                    {
12868                        diagnostics.push(Diagnostic {
12869                            related: Vec::new(),
12870                            span: context.span,
12871                            message: format!(
12872                                "{} has unsafe optional path `{}`: {message}",
12873                                context.subject,
12874                                path.join(".")
12875                            ),
12876                            suggestion: Some(
12877                                "prove the optional value is present before reading through it"
12878                                    .to_owned(),
12879                            ),
12880                        });
12881                        return;
12882                    }
12883                    if let Err(message) = semantic.schemas.resolve_field_path(schema, path) {
12884                        diagnostics.push(Diagnostic {
12885                            related: Vec::new(),
12886                            span: context.span,
12887                            message: format!(
12888                                "{} has invalid expression path `{}`: {message}",
12889                                context.subject,
12890                                path.join(".")
12891                            ),
12892                            suggestion: Some(
12893                                "use a field declared on the queried schema".to_owned(),
12894                            ),
12895                        });
12896                    }
12897                    return;
12898                }
12899                diagnostics.push(Diagnostic {
12900                    related: Vec::new(),
12901                    span: context.span,
12902                    message: format!("{} has unknown expression root `{root}`", context.subject),
12903                    suggestion: Some(
12904                        "use a binding introduced by a `when ... as name` clause".to_owned(),
12905                    ),
12906                });
12907                return;
12908            };
12909            if let Err(message) =
12910                validate_optional_path_access(schema, &path[1..], semantic, presence_proofs)
12911            {
12912                diagnostics.push(Diagnostic {
12913                    related: Vec::new(),
12914                    span: context.span,
12915                    message: format!(
12916                        "{} has unsafe optional path `{}`: {message}",
12917                        context.subject,
12918                        path.join(".")
12919                    ),
12920                    suggestion: Some(
12921                        "prove the optional value is present before reading through it".to_owned(),
12922                    ),
12923                });
12924                return;
12925            }
12926            if let Err(message) = semantic.schemas.resolve_field_path(schema, &path[1..]) {
12927                diagnostics.push(Diagnostic {
12928                    related: Vec::new(),
12929                    span: context.span,
12930                    message: format!(
12931                        "{} has invalid expression path `{}`: {message}",
12932                        context.subject,
12933                        path.join(".")
12934                    ),
12935                    suggestion: Some("use a field declared on the bound schema".to_owned()),
12936                });
12937            }
12938        }
12939        Expr::Index { target, key } => {
12940            validate_expr_node(
12941                target,
12942                semantic,
12943                scope,
12944                context,
12945                presence_proofs,
12946                diagnostics,
12947            );
12948            validate_expr_node(key, semantic, scope, context, presence_proofs, diagnostics);
12949            let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
12950            if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
12951                diagnostics.push(Diagnostic {
12952                    related: Vec::new(),
12953                    span: context.span,
12954                    message: format!("{} indexes a map with a non-string key", context.subject),
12955                    suggestion: Some(
12956                        "use a string literal or string expression as the map key".to_owned(),
12957                    ),
12958                });
12959            }
12960        }
12961        Expr::Array(items) => {
12962            for item in items {
12963                validate_expr_node(item, semantic, scope, context, presence_proofs, diagnostics);
12964            }
12965        }
12966        Expr::Object(fields) => {
12967            diagnostics.push(Diagnostic {
12968                related: Vec::new(),
12969                span: context.span,
12970                message: format!(
12971                    "{} uses an object literal without an expected object or map type",
12972                    context.subject
12973                ),
12974                suggestion: Some(
12975                    "use object literals only in typed record fields or typed effect arguments"
12976                        .to_owned(),
12977                ),
12978            });
12979            for field in fields {
12980                validate_expr_node(
12981                    &field.value,
12982                    semantic,
12983                    scope,
12984                    context,
12985                    presence_proofs,
12986                    diagnostics,
12987                );
12988            }
12989        }
12990        Expr::Unary { expr, .. } => {
12991            validate_expr_node(expr, semantic, scope, context, presence_proofs, diagnostics)
12992        }
12993        Expr::Binary {
12994            op: BinaryOp::And,
12995            left,
12996            right,
12997        } => {
12998            validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
12999            let mut right_proofs = presence_proofs.clone();
13000            collect_presence_proofs(left, &mut right_proofs);
13001            validate_expr_node(right, semantic, scope, context, &right_proofs, diagnostics);
13002        }
13003        Expr::Binary { op, left, right } => {
13004            validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
13005            validate_expr_node(
13006                right,
13007                semantic,
13008                scope,
13009                context,
13010                presence_proofs,
13011                diagnostics,
13012            );
13013            validate_unknown_implicit_idents(
13014                *op,
13015                left,
13016                right,
13017                semantic,
13018                scope,
13019                context,
13020                diagnostics,
13021            );
13022            validate_finite_domain_expr(*op, left, right, semantic, scope, context, diagnostics);
13023        }
13024        Expr::Call { name, args } => {
13025            validate_function_call(name, args, semantic, scope, context, diagnostics);
13026            for arg in args {
13027                validate_expr_node(arg, semantic, scope, context, presence_proofs, diagnostics);
13028            }
13029        }
13030        Expr::Query { guard, .. } => {
13031            validate_query_expr(expr, semantic, scope, context, diagnostics);
13032            if let Some(guard) = guard {
13033                let guard_scope = query_guard_scope(expr, semantic, scope);
13034                validate_expr_node(
13035                    guard,
13036                    semantic,
13037                    &guard_scope,
13038                    context,
13039                    presence_proofs,
13040                    diagnostics,
13041                );
13042            }
13043        }
13044        Expr::Literal(_) => {}
13045    }
13046}
13047
13048fn validate_unknown_implicit_idents(
13049    op: BinaryOp,
13050    left: &Expr,
13051    right: &Expr,
13052    semantic: &SemanticContext,
13053    scope: &ExprScope,
13054    context: &ExprValidationContext,
13055    diagnostics: &mut Vec<Diagnostic>,
13056) {
13057    if !matches!(
13058        op,
13059        BinaryOp::Eq | BinaryOp::Ne | BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge
13060    ) {
13061        return;
13062    }
13063    validate_unknown_implicit_ident(left, right, semantic, scope, context, diagnostics);
13064    validate_unknown_implicit_ident(right, left, semantic, scope, context, diagnostics);
13065}
13066
13067fn validate_unknown_implicit_ident(
13068    expr: &Expr,
13069    other: &Expr,
13070    semantic: &SemanticContext,
13071    scope: &ExprScope,
13072    context: &ExprValidationContext,
13073    diagnostics: &mut Vec<Diagnostic>,
13074) {
13075    let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
13076        return;
13077    };
13078    let Some(schema) = &scope.implicit_schema else {
13079        return;
13080    };
13081    let field_exists = semantic
13082        .schemas
13083        .classes
13084        .get(schema)
13085        .is_some_and(|fields| fields.contains_key(name));
13086    if field_exists
13087        || expr_domain(other, semantic, scope).is_some()
13088        || implicit_ident_field_exists(other, semantic, scope)
13089    {
13090        return;
13091    }
13092    diagnostics.push(Diagnostic {
13093        related: Vec::new(),
13094        span: context.span,
13095        message: format!(
13096            "{} fact query `{schema}` has unknown field `{name}`",
13097            context.subject
13098        ),
13099        suggestion: Some(format!(
13100            "use a field declared on `{schema}` inside the query `where` expression"
13101        )),
13102    });
13103}
13104
13105fn implicit_ident_field_exists(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> bool {
13106    let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
13107        return false;
13108    };
13109    let Some(schema) = &scope.implicit_schema else {
13110        return false;
13111    };
13112    semantic
13113        .schemas
13114        .classes
13115        .get(schema)
13116        .is_some_and(|fields| fields.contains_key(name))
13117}
13118
13119fn validate_function_call(
13120    name: &str,
13121    args: &[Expr],
13122    semantic: &SemanticContext,
13123    scope: &ExprScope,
13124    context: &ExprValidationContext,
13125    diagnostics: &mut Vec<Diagnostic>,
13126) {
13127    match name {
13128        "count" => {
13129            if args.len() != 1 {
13130                diagnostics.push(Diagnostic { related: Vec::new(),
13131                    span: context.span,
13132                    message: format!(
13133                        "{} calls `count` with {} arguments, expected 1",
13134                        context.subject,
13135                        args.len()
13136                    ),
13137                    suggestion: Some(
13138                        "call `count` with exactly one array, map, fact query, or effect query argument"
13139                            .to_owned(),
13140                    ),
13141                });
13142                return;
13143            }
13144            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13145            if !is_countable_type(&ty) {
13146                diagnostics.push(Diagnostic {
13147                    related: Vec::new(),
13148                    span: context.span,
13149                    message: format!(
13150                        "{} calls `count` with unsupported argument type `{}`",
13151                        context.subject,
13152                        expr_type_label(&ty)
13153                    ),
13154                    suggestion: Some(
13155                        "use `count` only with arrays, maps, fact queries, or effect queries"
13156                            .to_owned(),
13157                    ),
13158                });
13159            }
13160        }
13161        "exists" => {
13162            if args.len() != 1 {
13163                diagnostics.push(Diagnostic {
13164                    related: Vec::new(),
13165                    span: context.span,
13166                    message: format!(
13167                        "{} calls `exists` with {} arguments, expected 1",
13168                        context.subject,
13169                        args.len()
13170                    ),
13171                    suggestion: Some("call `exists` with exactly one argument".to_owned()),
13172                });
13173                return;
13174            }
13175            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13176            if !matches!(args[0], Expr::Index { .. }) && !is_exists_type(&ty) {
13177                diagnostics.push(Diagnostic { related: Vec::new(),
13178                    span: context.span,
13179                    message: format!(
13180                        "{} calls `exists` with unsupported argument type `{}`",
13181                        context.subject,
13182                        expr_type_label(&ty)
13183                    ),
13184                    suggestion: Some(
13185                        "use `exists path` for optional/map presence checks or pass an array, map, fact query, or effect query"
13186                            .to_owned(),
13187                    ),
13188                });
13189            }
13190        }
13191        "empty" => {
13192            if args.len() != 1 {
13193                diagnostics.push(Diagnostic {
13194                    related: Vec::new(),
13195                    span: context.span,
13196                    message: format!(
13197                        "{} calls `empty` with {} arguments, expected 1",
13198                        context.subject,
13199                        args.len()
13200                    ),
13201                    suggestion: Some(
13202                        "call `empty` with exactly one array, map, string, fact query, or effect query argument"
13203                            .to_owned(),
13204                    ),
13205                });
13206                return;
13207            }
13208            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13209            if !is_emptiable_type(&ty) {
13210                // An optional gets its own message: the inner type is what
13211                // makes it unsupported (spec: `empty(Optional<T>)` is defined
13212                // only when `empty(T)` is).
13213                let optional = matches!(ty, ExprType::Optional(_));
13214                diagnostics.push(Diagnostic {
13215                    related: Vec::new(),
13216                    span: context.span,
13217                    message: format!(
13218                        "{} calls `empty` with unsupported {}argument type `{}`",
13219                        context.subject,
13220                        if optional { "optional " } else { "" },
13221                        expr_type_label(&ty)
13222                    ),
13223                    suggestion: Some(
13224                        "use `empty` only with arrays, maps, strings, fact queries, effect queries, null, or supported optional values"
13225                            .to_owned(),
13226                    ),
13227                });
13228            }
13229        }
13230        _ => {}
13231    }
13232}
13233
13234fn validate_query_expr(
13235    expr: &Expr,
13236    semantic: &SemanticContext,
13237    scope: &ExprScope,
13238    context: &ExprValidationContext,
13239    diagnostics: &mut Vec<Diagnostic>,
13240) {
13241    let Expr::Query { kind, head, guard } = expr else {
13242        return;
13243    };
13244    if *kind == QueryKind::Fact {
13245        let Some(schema) = query_head_schema(head, semantic) else {
13246            diagnostics.push(Diagnostic {
13247                related: Vec::new(),
13248                span: context.span,
13249                message: format!(
13250                    "{} queries unknown fact schema `{}`",
13251                    context.subject,
13252                    head.trim()
13253                ),
13254                suggestion: Some("use a declared class name in fact queries".to_owned()),
13255            });
13256            return;
13257        };
13258        if let Some(guard) = guard {
13259            let guard_scope = scope.with_implicit_schema(schema);
13260            let ty = infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
13261            if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
13262                diagnostics.push(Diagnostic {
13263                    related: Vec::new(),
13264                    span: context.span,
13265                    message: format!(
13266                        "{} fact query `{}` has non-boolean `where` expression",
13267                        context.subject,
13268                        head.trim()
13269                    ),
13270                    suggestion: Some("query `where` expressions must evaluate to bool".to_owned()),
13271                });
13272            }
13273        }
13274    }
13275}
13276
13277fn validate_optional_path_access(
13278    root_schema: &str,
13279    path: &[String],
13280    semantic: &SemanticContext,
13281    presence_proofs: &BTreeSet<String>,
13282) -> Result<(), String> {
13283    let mut schema = root_schema.to_owned();
13284    let mut prefix = Vec::new();
13285    for (index, field) in path.iter().enumerate() {
13286        let Some(fields) = semantic.schemas.classes.get(&schema) else {
13287            return Ok(());
13288        };
13289        let Some(field_ty) = fields.get(field) else {
13290            return Ok(());
13291        };
13292        prefix.push(field.clone());
13293        if let TypeSyntax::Optional { inner, .. } = field_ty {
13294            if index + 1 < path.len() && !presence_proofs.contains(&prefix.join(".")) {
13295                return Err(format!(
13296                    "`{}` must be proven present before accessing `{}`",
13297                    prefix.join("."),
13298                    path[index + 1..].join(".")
13299                ));
13300            }
13301            if let Some(next_schema) = schema_name_for_path(inner) {
13302                schema = next_schema;
13303            }
13304            continue;
13305        }
13306        if let Some(next_schema) = schema_name_for_path(field_ty) {
13307            schema = next_schema;
13308        }
13309    }
13310    Ok(())
13311}
13312
13313fn collect_presence_proofs(expr: &Expr, proofs: &mut BTreeSet<String>) {
13314    match expr {
13315        Expr::Binary {
13316            op: BinaryOp::Ne,
13317            left,
13318            right,
13319        } => {
13320            if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
13321                if let Some(path) = expr_path_key(left) {
13322                    proofs.insert(path);
13323                }
13324            }
13325            if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
13326                if let Some(path) = expr_path_key(right) {
13327                    proofs.insert(path);
13328                }
13329            }
13330        }
13331        Expr::Unary {
13332            op: UnaryOp::Not,
13333            expr,
13334        } => {
13335            if let Expr::Binary {
13336                op: BinaryOp::Eq,
13337                left,
13338                right,
13339            } = expr.as_ref()
13340            {
13341                if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
13342                    if let Some(path) = expr_path_key(left) {
13343                        proofs.insert(path);
13344                    }
13345                }
13346                if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
13347                    if let Some(path) = expr_path_key(right) {
13348                        proofs.insert(path);
13349                    }
13350                }
13351            }
13352        }
13353        Expr::Call { name, args } if name == "exists" && args.len() == 1 => {
13354            if let Some(path) = expr_path_key(&args[0]) {
13355                proofs.insert(path);
13356            }
13357        }
13358        Expr::Binary {
13359            op: BinaryOp::And,
13360            left,
13361            right,
13362        } => {
13363            collect_presence_proofs(left, proofs);
13364            collect_presence_proofs(right, proofs);
13365        }
13366        _ => {}
13367    }
13368}
13369
13370fn expr_path_key(expr: &Expr) -> Option<String> {
13371    match expr {
13372        Expr::Literal(ExprLiteral::Ident(name)) => Some(name.clone()),
13373        Expr::Path(path) if path.len() >= 2 => Some(path[1..].join(".")),
13374        Expr::Index { target, key } => {
13375            let target = expr_path_key(target)?;
13376            let key = match key.as_ref() {
13377                Expr::Literal(ExprLiteral::String(value) | ExprLiteral::Ident(value)) => value,
13378                _ => return None,
13379            };
13380            Some(format!("{target}[{key:?}]"))
13381        }
13382        _ => None,
13383    }
13384}
13385
13386fn query_guard_scope(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> ExprScope {
13387    let Expr::Query {
13388        kind: QueryKind::Fact,
13389        head,
13390        ..
13391    } = expr
13392    else {
13393        return scope.clone();
13394    };
13395    query_head_schema(head, semantic)
13396        .map(|schema| scope.with_implicit_schema(schema))
13397        .unwrap_or_else(|| scope.clone())
13398}
13399
13400fn query_head_schema(head: &str, semantic: &SemanticContext) -> Option<String> {
13401    let mut parts = head.split_whitespace();
13402    let schema = parts.next()?;
13403    if parts.next().is_some() {
13404        return None;
13405    }
13406    semantic
13407        .schemas
13408        .class_exists(schema)
13409        .then(|| schema.to_owned())
13410}
13411
13412fn implicit_field_type(
13413    name: &str,
13414    semantic: &SemanticContext,
13415    scope: &ExprScope,
13416) -> Option<TypeSyntax> {
13417    let schema = scope.implicit_schema.as_ref()?;
13418    semantic
13419        .schemas
13420        .resolve_field_path(schema, &[name.to_owned()])
13421        .ok()
13422}
13423
13424fn infer_expr_type(
13425    expr: &Expr,
13426    semantic: &SemanticContext,
13427    scope: &ExprScope,
13428    context: &ExprValidationContext,
13429    diagnostics: &mut Vec<Diagnostic>,
13430) -> ExprType {
13431    match expr {
13432        Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)
13433            .map(|ty| expr_type_from_type_syntax(&ty, semantic))
13434            .unwrap_or_else(|| expr_literal_type(&ExprLiteral::Ident(name.clone()))),
13435        Expr::Literal(literal) => expr_literal_type(literal),
13436        Expr::Path(path) => expr_path_type(path, semantic, scope).unwrap_or(ExprType::Unknown),
13437        Expr::Index { target, key } => {
13438            let target_ty = infer_expr_type(target, semantic, scope, context, diagnostics);
13439            let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
13440            if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
13441                diagnostics.push(Diagnostic {
13442                    related: Vec::new(),
13443                    span: context.span,
13444                    message: format!("{} indexes a map with a non-string key", context.subject),
13445                    suggestion: Some(
13446                        "use a string literal or string expression as the map key".to_owned(),
13447                    ),
13448                });
13449            }
13450            match target_ty {
13451                ExprType::Map(inner) => *inner,
13452                ExprType::Unknown => ExprType::Unknown,
13453                _ => {
13454                    diagnostics.push(Diagnostic {
13455                        related: Vec::new(),
13456                        span: context.span,
13457                        message: format!("{} indexes a non-map expression", context.subject),
13458                        suggestion: Some("use indexing only on map values".to_owned()),
13459                    });
13460                    ExprType::Unknown
13461                }
13462            }
13463        }
13464        Expr::Array(items) => infer_array_type(items, semantic, scope, context, diagnostics),
13465        Expr::Object(fields) => {
13466            for field in fields {
13467                infer_expr_type(&field.value, semantic, scope, context, diagnostics);
13468            }
13469            ExprType::Object
13470        }
13471        Expr::Unary {
13472            op: UnaryOp::Not,
13473            expr,
13474        } => {
13475            let inner = infer_expr_type(expr, semantic, scope, context, diagnostics);
13476            if !matches!(inner, ExprType::Bool | ExprType::Unknown) {
13477                diagnostics.push(Diagnostic {
13478                    related: Vec::new(),
13479                    span: context.span,
13480                    message: format!(
13481                        "{} applies `!` to a non-boolean expression",
13482                        context.subject
13483                    ),
13484                    suggestion: Some("use `!` only with boolean expressions".to_owned()),
13485                });
13486            }
13487            ExprType::Bool
13488        }
13489        Expr::Binary { op, left, right } => {
13490            infer_binary_type(*op, left, right, semantic, scope, context, diagnostics)
13491        }
13492        Expr::Call { name, args } => match name.as_str() {
13493            "count" => ExprType::Int,
13494            "exists" => ExprType::Bool,
13495            "empty" => ExprType::Bool,
13496            _ => {
13497                diagnostics.push(Diagnostic {
13498                    related: Vec::new(),
13499                    span: context.span,
13500                    message: format!(
13501                        "{} calls unsupported expression function `{name}`",
13502                        context.subject
13503                    ),
13504                    suggestion: Some("use `count`, `exists`, or `empty`".to_owned()),
13505                });
13506                for arg in args {
13507                    infer_expr_type(arg, semantic, scope, context, diagnostics);
13508                }
13509                ExprType::Unknown
13510            }
13511        },
13512        Expr::Query { guard, .. } => {
13513            if let Some(guard) = guard {
13514                let guard_scope = query_guard_scope(expr, semantic, scope);
13515                infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
13516            }
13517            ExprType::Collection
13518        }
13519    }
13520}
13521
13522fn infer_binary_type(
13523    op: BinaryOp,
13524    left: &Expr,
13525    right: &Expr,
13526    semantic: &SemanticContext,
13527    scope: &ExprScope,
13528    context: &ExprValidationContext,
13529    diagnostics: &mut Vec<Diagnostic>,
13530) -> ExprType {
13531    let left_ty = infer_expr_type(left, semantic, scope, context, diagnostics);
13532    let right_ty = infer_expr_type(right, semantic, scope, context, diagnostics);
13533    match op {
13534        BinaryOp::And | BinaryOp::Or => {
13535            for ty in [&left_ty, &right_ty] {
13536                if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
13537                    diagnostics.push(Diagnostic {
13538                        related: Vec::new(),
13539                        span: context.span,
13540                        message: format!(
13541                            "{} uses boolean operator with non-boolean operand",
13542                            context.subject
13543                        ),
13544                        suggestion: Some(
13545                            "use `&&` and `||` only with boolean expressions".to_owned(),
13546                        ),
13547                    });
13548                    break;
13549                }
13550            }
13551            ExprType::Bool
13552        }
13553        BinaryOp::Eq | BinaryOp::Ne => {
13554            if !types_comparable(&left_ty, &right_ty) {
13555                diagnostics.push(Diagnostic {
13556                    related: Vec::new(),
13557                    span: context.span,
13558                    message: format!("{} compares incompatible expression types", context.subject),
13559                    suggestion: Some(
13560                        "compare values with compatible scalar or finite-domain types".to_owned(),
13561                    ),
13562                });
13563            }
13564            ExprType::Bool
13565        }
13566        BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
13567            if !is_orderable_pair(&left_ty, &right_ty) {
13568                diagnostics.push(Diagnostic {
13569                    related: Vec::new(),
13570                    span: context.span,
13571                    message: format!("{} orders non-orderable expression values", context.subject),
13572                    suggestion: Some(
13573                        "use ordering only with int, float, duration, or time values".to_owned(),
13574                    ),
13575                });
13576            }
13577            ExprType::Bool
13578        }
13579        BinaryOp::In | BinaryOp::NotIn => {
13580            match &right_ty {
13581                ExprType::Array(item_ty) => {
13582                    if !types_comparable(&left_ty, item_ty) {
13583                        diagnostics.push(Diagnostic {
13584                            related: Vec::new(),
13585                            span: context.span,
13586                            message: format!(
13587                                "{} uses membership with incompatible item type",
13588                                context.subject
13589                            ),
13590                            suggestion: Some(
13591                                "make the left value compatible with the array item type"
13592                                    .to_owned(),
13593                            ),
13594                        });
13595                    }
13596                }
13597                ExprType::Map(_) => {
13598                    if !is_string_like_key_type(&left_ty) {
13599                        diagnostics.push(Diagnostic {
13600                            related: Vec::new(),
13601                            span: context.span,
13602                            message: format!(
13603                                "{} uses map membership with a non-string key",
13604                                context.subject
13605                            ),
13606                            suggestion: Some(
13607                                "use a string value on the left side of map membership".to_owned(),
13608                            ),
13609                        });
13610                    }
13611                }
13612                ExprType::Unknown => {}
13613                _ => diagnostics.push(Diagnostic {
13614                    related: Vec::new(),
13615                    span: context.span,
13616                    message: format!(
13617                        "{} uses membership against a non-array/non-map expression",
13618                        context.subject
13619                    ),
13620                    suggestion: Some(
13621                        "use `in` with an array literal, array value, or map value".to_owned(),
13622                    ),
13623                }),
13624            }
13625            ExprType::Bool
13626        }
13627        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
13628            for ty in [&left_ty, &right_ty] {
13629                if !matches!(ty, ExprType::Int | ExprType::Float | ExprType::Unknown) {
13630                    diagnostics.push(Diagnostic {
13631                        related: Vec::new(),
13632                        span: context.span,
13633                        message: format!(
13634                            "{} uses arithmetic with a non-numeric operand",
13635                            context.subject
13636                        ),
13637                        suggestion: Some("use `+ - * /` only with int or float values".to_owned()),
13638                    });
13639                    break;
13640                }
13641            }
13642            if matches!(left_ty, ExprType::Float) || matches!(right_ty, ExprType::Float) {
13643                ExprType::Float
13644            } else if matches!(left_ty, ExprType::Int) && matches!(right_ty, ExprType::Int) {
13645                ExprType::Int
13646            } else {
13647                ExprType::Unknown
13648            }
13649        }
13650    }
13651}
13652
13653fn infer_array_type(
13654    items: &[Expr],
13655    semantic: &SemanticContext,
13656    scope: &ExprScope,
13657    context: &ExprValidationContext,
13658    diagnostics: &mut Vec<Diagnostic>,
13659) -> ExprType {
13660    let mut item_ty: Option<ExprType> = None;
13661    for item in items {
13662        let ty = infer_expr_type(item, semantic, scope, context, diagnostics);
13663        if matches!(ty, ExprType::Unknown) {
13664            continue;
13665        }
13666        match &item_ty {
13667            None => item_ty = Some(ty),
13668            Some(existing) if types_comparable(existing, &ty) => {}
13669            Some(_) => {
13670                diagnostics.push(Diagnostic {
13671                    related: Vec::new(),
13672                    span: context.span,
13673                    message: format!("{} has mixed-type array literal", context.subject),
13674                    suggestion: Some("use array literals whose elements share one type".to_owned()),
13675                });
13676                return ExprType::Array(Box::new(ExprType::Unknown));
13677            }
13678        }
13679    }
13680    ExprType::Array(Box::new(item_ty.unwrap_or(ExprType::Unknown)))
13681}
13682
13683fn expr_path_type(
13684    path: &[String],
13685    semantic: &SemanticContext,
13686    scope: &ExprScope,
13687) -> Option<ExprType> {
13688    if path.len() < 2 {
13689        return None;
13690    }
13691    if let Some(schema) = scope.binding_types.get(&path[0]) {
13692        if schema.contains('.') {
13693            // Untyped runtime fact binding (general `when fact <name>`).
13694            return Some(ExprType::Unknown);
13695        }
13696        return semantic
13697            .schemas
13698            .resolve_field_path(schema, &path[1..])
13699            .ok()
13700            .map(|ty| expr_type_from_type_syntax(&ty, semantic));
13701    }
13702    let schema = scope.implicit_schema.as_ref()?;
13703    if schema.contains('.') {
13704        return Some(ExprType::Unknown);
13705    }
13706    semantic
13707        .schemas
13708        .resolve_field_path(schema, path)
13709        .ok()
13710        .map(|ty| expr_type_from_type_syntax(&ty, semantic))
13711}
13712
13713fn expr_type_from_type_syntax(ty: &TypeSyntax, semantic: &SemanticContext) -> ExprType {
13714    match ty {
13715        TypeSyntax::Primitive { name, .. } => match name.as_str() {
13716            "bool" => ExprType::Bool,
13717            "int" => ExprType::Int,
13718            "float" => ExprType::Float,
13719            "string" => ExprType::String,
13720            "duration" => ExprType::Duration,
13721            "time" => ExprType::Time,
13722            _ => ExprType::Unknown,
13723        },
13724        TypeSyntax::LiteralString { value, .. } => ExprType::Finite {
13725            label: "literal".to_owned(),
13726            values: vec![value.clone()],
13727        },
13728        TypeSyntax::AgentRef { agents, .. } => ExprType::Finite {
13729            label: "AgentRef".to_owned(),
13730            values: agents.iter().map(|agent| agent.name.clone()).collect(),
13731        },
13732        TypeSyntax::Ref { name } => semantic
13733            .schemas
13734            .enums
13735            .get(&name.name)
13736            .map(|variants| ExprType::Finite {
13737                label: format!("enum `{}`", name.name),
13738                values: variants.iter().cloned().collect(),
13739            })
13740            .unwrap_or(ExprType::Object),
13741        TypeSyntax::Optional { inner, .. } => {
13742            ExprType::Optional(Box::new(expr_type_from_type_syntax(inner, semantic)))
13743        }
13744        TypeSyntax::Array { inner, .. } => {
13745            ExprType::Array(Box::new(expr_type_from_type_syntax(inner, semantic)))
13746        }
13747        TypeSyntax::Map { inner, .. } => {
13748            ExprType::Map(Box::new(expr_type_from_type_syntax(inner, semantic)))
13749        }
13750        TypeSyntax::Union { variants, .. } => {
13751            let values = variants
13752                .iter()
13753                .filter_map(|variant| match variant {
13754                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
13755                    _ => None,
13756                })
13757                .collect::<Vec<_>>();
13758            if values.len() == variants.len() && !values.is_empty() {
13759                ExprType::Finite {
13760                    label: "literal union".to_owned(),
13761                    values,
13762                }
13763            } else {
13764                ExprType::Unknown
13765            }
13766        }
13767    }
13768}
13769
13770fn expr_literal_type(literal: &ExprLiteral) -> ExprType {
13771    match literal {
13772        ExprLiteral::String(_) | ExprLiteral::Ident(_) => ExprType::String,
13773        ExprLiteral::Number(value) if value.contains('.') => ExprType::Float,
13774        ExprLiteral::Number(_) => ExprType::Int,
13775        ExprLiteral::Bool(_) => ExprType::Bool,
13776        ExprLiteral::Null => ExprType::Null,
13777    }
13778}
13779
13780fn types_comparable(left: &ExprType, right: &ExprType) -> bool {
13781    if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
13782        return true;
13783    }
13784    if matches!(left, ExprType::Null) || matches!(right, ExprType::Null) {
13785        return true;
13786    }
13787    if is_numeric_type(left) && is_numeric_type(right) {
13788        return true;
13789    }
13790    match (left, right) {
13791        (ExprType::Optional(left), right) | (right, ExprType::Optional(left)) => {
13792            types_comparable(left, right)
13793        }
13794        (ExprType::Finite { .. }, ExprType::String)
13795        | (ExprType::String, ExprType::Finite { .. })
13796        | (ExprType::Finite { .. }, ExprType::Finite { .. }) => true,
13797        _ => left == right,
13798    }
13799}
13800
13801fn is_numeric_type(ty: &ExprType) -> bool {
13802    matches!(ty, ExprType::Int | ExprType::Float)
13803}
13804
13805fn is_string_like_key_type(ty: &ExprType) -> bool {
13806    match ty {
13807        ExprType::String | ExprType::Unknown | ExprType::Finite { .. } => true,
13808        ExprType::Optional(inner) => is_string_like_key_type(inner),
13809        _ => false,
13810    }
13811}
13812
13813fn is_orderable_pair(left: &ExprType, right: &ExprType) -> bool {
13814    if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
13815        return true;
13816    }
13817    (is_numeric_type(left) && is_numeric_type(right))
13818        || matches!(
13819            (left, right),
13820            (ExprType::Duration, ExprType::Duration)
13821                | (ExprType::Time, ExprType::Time)
13822                // A quoted ISO-8601 string in a time-typed comparison is a
13823                // time literal (spec/scheduled-time.md).
13824                | (ExprType::Time, ExprType::String)
13825                | (ExprType::String, ExprType::Time)
13826        )
13827}
13828
13829fn is_countable_type(ty: &ExprType) -> bool {
13830    matches!(
13831        ty,
13832        ExprType::Array(_) | ExprType::Map(_) | ExprType::Collection | ExprType::Unknown
13833    )
13834}
13835
13836fn is_exists_type(ty: &ExprType) -> bool {
13837    matches!(
13838        ty,
13839        ExprType::Array(_)
13840            | ExprType::Map(_)
13841            | ExprType::Collection
13842            | ExprType::Optional(_)
13843            | ExprType::Unknown
13844    )
13845}
13846
13847/// Spec "Count And Empty": `empty` is a structural emptiness test for arrays,
13848/// maps, strings, fact/effect queries, and null; `empty(Optional<T>)` is
13849/// defined only when `empty(T)` is (so `empty(string?)` works, `empty(int?)`
13850/// does not). It never coerces scalars, objects, enum variants, or agent refs.
13851fn is_emptiable_type(ty: &ExprType) -> bool {
13852    match ty {
13853        ExprType::Array(_)
13854        | ExprType::Map(_)
13855        | ExprType::String
13856        | ExprType::Collection
13857        | ExprType::Null
13858        | ExprType::Unknown => true,
13859        ExprType::Optional(inner) => is_emptiable_type(inner),
13860        _ => false,
13861    }
13862}
13863
13864fn expr_type_label(ty: &ExprType) -> String {
13865    match ty {
13866        ExprType::Bool => "bool".to_owned(),
13867        ExprType::Int => "int".to_owned(),
13868        ExprType::Float => "float".to_owned(),
13869        ExprType::String => "string".to_owned(),
13870        ExprType::Finite { label, values } => format!("{label}<{}>", values.join(" | ")),
13871        ExprType::Duration => "duration".to_owned(),
13872        ExprType::Time => "time".to_owned(),
13873        ExprType::Null => "null".to_owned(),
13874        ExprType::Object => "object".to_owned(),
13875        ExprType::Array(inner) => format!("{}[]", expr_type_label(inner)),
13876        ExprType::Map(inner) => format!("map<{}>", expr_type_label(inner)),
13877        ExprType::Optional(inner) => format!("{}?", expr_type_label(inner)),
13878        ExprType::Collection => "query".to_owned(),
13879        ExprType::Unknown => "unknown".to_owned(),
13880    }
13881}
13882
13883fn validate_finite_domain_expr(
13884    op: BinaryOp,
13885    left: &Expr,
13886    right: &Expr,
13887    semantic: &SemanticContext,
13888    scope: &ExprScope,
13889    context: &ExprValidationContext,
13890    diagnostics: &mut Vec<Diagnostic>,
13891) {
13892    if !matches!(
13893        op,
13894        BinaryOp::Eq | BinaryOp::Ne | BinaryOp::In | BinaryOp::NotIn
13895    ) {
13896        return;
13897    }
13898    let Some((domain, literals)) = finite_domain_comparison(left, right, semantic, scope)
13899        .or_else(|| finite_domain_comparison(right, left, semantic, scope))
13900    else {
13901        validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
13902        return;
13903    };
13904    for literal in literals.into_iter().flatten() {
13905        if !domain.iter().any(|value| value == &literal) {
13906            diagnostics.push(Diagnostic {
13907                related: Vec::new(),
13908                span: context.span,
13909                message: format!(
13910                    "{} compares finite-domain value to unknown `{literal}`",
13911                    context.subject
13912                ),
13913                suggestion: Some(format!("use one of: {}", domain.join(", "))),
13914            });
13915        }
13916    }
13917    validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
13918}
13919
13920fn validate_finite_domain_relation(
13921    op: BinaryOp,
13922    left: &Expr,
13923    right: &Expr,
13924    semantic: &SemanticContext,
13925    scope: &ExprScope,
13926    context: &ExprValidationContext,
13927    diagnostics: &mut Vec<Diagnostic>,
13928) {
13929    match op {
13930        BinaryOp::Eq => {
13931            let Some(left_domain) = expr_domain(left, semantic, scope) else {
13932                return;
13933            };
13934            let Some(right_domain) = expr_domain(right, semantic, scope) else {
13935                return;
13936            };
13937            if left_domain
13938                .iter()
13939                .all(|value| !right_domain.iter().any(|right| right == value))
13940            {
13941                diagnostics.push(Diagnostic {
13942                    related: Vec::new(),
13943                    span: context.span,
13944                    message: format!(
13945                        "{} has statically unsatisfiable finite-domain equality",
13946                        context.subject
13947                    ),
13948                    suggestion: Some(format!(
13949                        "compare domains with at least one shared value; left: {}, right: {}",
13950                        left_domain.join(", "),
13951                        right_domain.join(", ")
13952                    )),
13953                });
13954            }
13955        }
13956        BinaryOp::In => {
13957            let Some(domain) = expr_domain(left, semantic, scope) else {
13958                return;
13959            };
13960            let Some(literals) = literal_array_values(right) else {
13961                return;
13962            };
13963            if literals
13964                .iter()
13965                .all(|literal| !domain.iter().any(|value| value == literal))
13966            {
13967                diagnostics.push(Diagnostic {
13968                    related: Vec::new(),
13969                    span: context.span,
13970                    message: format!(
13971                        "{} has statically unsatisfiable finite-domain membership",
13972                        context.subject
13973                    ),
13974                    suggestion: Some(format!("use one of: {}", domain.join(", "))),
13975                });
13976            }
13977        }
13978        BinaryOp::NotIn => {
13979            let Some(domain) = expr_domain(left, semantic, scope) else {
13980                return;
13981            };
13982            let Some(literals) = literal_array_values(right) else {
13983                return;
13984            };
13985            if !domain.is_empty()
13986                && domain
13987                    .iter()
13988                    .all(|value| literals.iter().any(|literal| literal == value))
13989            {
13990                diagnostics.push(Diagnostic {
13991                    related: Vec::new(),
13992                    span: context.span,
13993                    message: format!(
13994                        "{} has statically unsatisfiable finite-domain exclusion",
13995                        context.subject
13996                    ),
13997                    suggestion: Some(
13998                        "leave at least one domain value outside the exclusion set".to_owned(),
13999                    ),
14000                });
14001            }
14002        }
14003        _ => {}
14004    }
14005}
14006
14007fn finite_domain_comparison(
14008    domain_expr: &Expr,
14009    literal_expr: &Expr,
14010    semantic: &SemanticContext,
14011    scope: &ExprScope,
14012) -> Option<(Vec<String>, Vec<Option<String>>)> {
14013    let domain = expr_domain(domain_expr, semantic, scope)?;
14014    let literals = match literal_expr {
14015        Expr::Literal(literal) => vec![expr_literal_name(literal)],
14016        Expr::Array(items) => items
14017            .iter()
14018            .filter_map(|item| match item {
14019                Expr::Literal(literal) => Some(expr_literal_name(literal)),
14020                _ => None,
14021            })
14022            .collect(),
14023        _ => Vec::new(),
14024    };
14025    Some((domain, literals))
14026}
14027
14028fn expr_domain(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> Option<Vec<String>> {
14029    let ty = match expr {
14030        Expr::Path(path) => {
14031            let root = path.first()?;
14032            if let Some(schema) = scope.binding_types.get(root) {
14033                semantic
14034                    .schemas
14035                    .resolve_field_path(schema, path.get(1..)?)
14036                    .ok()?
14037            } else {
14038                let schema = scope.implicit_schema.as_ref()?;
14039                semantic.schemas.resolve_field_path(schema, path).ok()?
14040            }
14041        }
14042        Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)?,
14043        _ => return None,
14044    };
14045    finite_expr_domain(&ty, semantic)
14046}
14047
14048fn finite_expr_domain(ty: &TypeSyntax, semantic: &SemanticContext) -> Option<Vec<String>> {
14049    match ty {
14050        TypeSyntax::Ref { name } => semantic
14051            .schemas
14052            .enums
14053            .get(&name.name)
14054            .map(|variants| variants.iter().cloned().collect()),
14055        TypeSyntax::Union { variants, .. } => {
14056            let values = variants
14057                .iter()
14058                .filter_map(|variant| match variant {
14059                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
14060                    _ => None,
14061                })
14062                .collect::<Vec<_>>();
14063            (!values.is_empty()).then_some(values)
14064        }
14065        TypeSyntax::AgentRef { agents, .. } => {
14066            Some(agents.iter().map(|agent| agent.name.clone()).collect())
14067        }
14068        _ => None,
14069    }
14070}
14071
14072fn expr_literal_name(literal: &ExprLiteral) -> Option<String> {
14073    match literal {
14074        ExprLiteral::String(value) | ExprLiteral::Ident(value) => Some(value.clone()),
14075        _ => None,
14076    }
14077}
14078
14079fn literal_array_values(expr: &Expr) -> Option<Vec<String>> {
14080    let Expr::Array(items) = expr else {
14081        return None;
14082    };
14083    items
14084        .iter()
14085        .map(|item| match item {
14086            Expr::Literal(literal) => expr_literal_name(literal),
14087            _ => None,
14088        })
14089        .collect()
14090}
14091
14092fn parse_tell_target(line: &str) -> Option<&str> {
14093    line.strip_prefix("tell ")?
14094        .split_whitespace()
14095        .next()
14096        .filter(|target| !target.is_empty())
14097}
14098
14099fn parse_required_capabilities(line: &str) -> Vec<String> {
14100    let Some(rest) = line.split_once(" requires ") else {
14101        return Vec::new();
14102    };
14103    let Some(list) = rest.1.trim_start().strip_prefix('[') else {
14104        return Vec::new();
14105    };
14106    let Some((items, _)) = list.split_once(']') else {
14107        return Vec::new();
14108    };
14109    let mut capabilities = items
14110        .split(',')
14111        .filter_map(|item| {
14112            let value = item.trim().trim_matches('"');
14113            (!value.is_empty()).then(|| value.to_owned())
14114        })
14115        .collect::<Vec<_>>();
14116    capabilities.sort();
14117    capabilities.dedup();
14118    capabilities
14119}
14120
14121fn validate_case_blocks(
14122    rule: &RuleDecl,
14123    semantic: &SemanticContext,
14124    binding_types: &BTreeMap<String, String>,
14125    diagnostics: &mut Vec<Diagnostic>,
14126) {
14127    let lines = rule
14128        .body
14129        .text
14130        .lines()
14131        .scan(0usize, |offset, line| {
14132            let current = *offset;
14133            *offset += line.len() + 1;
14134            Some((line, current))
14135        })
14136        .collect::<Vec<_>>();
14137    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
14138    let mut index = 0usize;
14139    while index < lines.len() {
14140        let trimmed = lines[index].0.trim();
14141        let Some(scrutinee) = case_scrutinee(trimmed) else {
14142            index += 1;
14143            continue;
14144        };
14145        let scrutinee_ty = expression_type(scrutinee, semantic, binding_types);
14146        let terminal_case = scrutinee_ty.is_none()
14147            && active_completes_binding_for_case(&text_lines, index, scrutinee);
14148        if scrutinee_ty.is_none() && !terminal_case {
14149            diagnostics.push(Diagnostic {
14150                related: Vec::new(),
14151                span: rule.body.span,
14152                message: format!(
14153                    "rule `{}` has case scrutinee `{scrutinee}` that is not a typed path",
14154                    rule.name.name
14155                ),
14156                suggestion: Some("match on a bound field such as `task.provider`".to_owned()),
14157            });
14158        }
14159        let mut depth = brace_delta(trimmed).max(1);
14160        let mut case_index = index + 1;
14161        let mut branches = Vec::new();
14162        while case_index < lines.len() && depth > 0 {
14163            let (raw_line, line_offset) = lines[case_index];
14164            let line = raw_line.trim();
14165            if depth == 1 {
14166                if let Some(branch) = parse_case_branch_head(line) {
14167                    let pattern_column = case_pattern_column(raw_line, branch.pattern);
14168                    let branch = SpanCaseBranchHead {
14169                        pattern: branch.pattern,
14170                        guard: branch.guard,
14171                        pattern_span: SourceSpan {
14172                            start: rule_body_text_start(rule) + line_offset + pattern_column,
14173                            end: rule_body_text_start(rule)
14174                                + line_offset
14175                                + pattern_column
14176                                + branch.pattern.len(),
14177                        },
14178                    };
14179                    branches.push(branch);
14180                    if terminal_case {
14181                        validate_terminal_case_pattern(
14182                            rule,
14183                            branch.pattern,
14184                            branch.pattern_span,
14185                            diagnostics,
14186                        );
14187                    } else {
14188                        validate_case_pattern(
14189                            rule,
14190                            branch.pattern,
14191                            scrutinee_ty.as_ref(),
14192                            branch.pattern_span,
14193                            semantic,
14194                            diagnostics,
14195                        );
14196                    }
14197                    // Terminal-case guards are validated by
14198                    // `collect_terminal_case_metadata`, which is the only path
14199                    // with `effect_payload_types` and so the only one that can
14200                    // bind the tag-refined payload (`Completed as result where
14201                    // result.x ...`) into the guard scope. Validating them here
14202                    // too would reject that binding as an unknown root.
14203                    if let Some(guard) = branch.guard.filter(|_| !terminal_case) {
14204                        let mut branch_scope = binding_types.clone();
14205                        if let Some(scrutinee_ty) = scrutinee_ty.as_ref() {
14206                            if let Some((binding, schema)) =
14207                                case_branch_payload_binding(branch.pattern, scrutinee_ty, semantic)
14208                            {
14209                                branch_scope.insert(binding, schema);
14210                            }
14211                        }
14212                        validate_expression(
14213                            rule,
14214                            guard,
14215                            semantic,
14216                            &branch_scope,
14217                            "case guard",
14218                            diagnostics,
14219                        );
14220                        validate_known_field_paths_at_span(
14221                            rule,
14222                            guard,
14223                            branch.pattern_span,
14224                            semantic,
14225                            &branch_scope,
14226                            diagnostics,
14227                        );
14228                    }
14229                }
14230            }
14231            depth += brace_delta(line);
14232            case_index += 1;
14233        }
14234        if terminal_case {
14235            validate_terminal_case_coverage(rule, &branches, diagnostics);
14236        } else {
14237            validate_case_coverage(
14238                rule,
14239                scrutinee_ty.as_ref(),
14240                &branches,
14241                semantic,
14242                diagnostics,
14243            );
14244        }
14245        index += 1;
14246    }
14247}
14248
14249fn active_completes_binding_for_case(lines: &[&str], case_index: usize, scrutinee: &str) -> bool {
14250    let mut scopes: Vec<(String, DependencyPredicate, i32)> = Vec::new();
14251    for line in lines.iter().take(case_index) {
14252        let trimmed = line.trim();
14253        if let Some((binding, predicate)) = parse_after_line(trimmed) {
14254            scopes.push((binding, predicate, brace_delta(trimmed).max(1)));
14255        } else {
14256            let delta = brace_delta(trimmed);
14257            for (_, _, depth) in &mut scopes {
14258                *depth += delta;
14259            }
14260            scopes.retain(|(_, _, depth)| *depth > 0);
14261        }
14262    }
14263    scopes.iter().any(|(binding, predicate, _)| {
14264        binding == scrutinee && predicate == &DependencyPredicate::Completes
14265    })
14266}
14267
14268fn brace_delta(line: &str) -> i32 {
14269    line.chars().fold(0, |depth, ch| match ch {
14270        '{' => depth + 1,
14271        '}' => depth - 1,
14272        _ => depth,
14273    })
14274}
14275
14276fn case_scrutinee(line: &str) -> Option<&str> {
14277    let rest = line.strip_prefix("case ")?;
14278    let expr = rest.strip_suffix('{').unwrap_or(rest).trim();
14279    (!expr.is_empty()).then_some(expr)
14280}
14281
14282fn is_case_branch_start(line: &str) -> bool {
14283    line.contains("=>")
14284}
14285
14286#[derive(Clone, Copy)]
14287struct CaseBranchHead<'a> {
14288    pattern: &'a str,
14289    guard: Option<&'a str>,
14290}
14291
14292#[derive(Clone, Copy)]
14293struct SpanCaseBranchHead<'a> {
14294    pattern: &'a str,
14295    guard: Option<&'a str>,
14296    pattern_span: SourceSpan,
14297}
14298
14299fn parse_case_branch_head(line: &str) -> Option<CaseBranchHead<'_>> {
14300    let (pattern, _) = line.split_once("=>")?;
14301    let pattern = pattern.trim();
14302    if pattern.is_empty() {
14303        return None;
14304    }
14305    match pattern.split_once(" where ") {
14306        Some((pattern, guard)) => Some(CaseBranchHead {
14307            pattern: pattern.trim(),
14308            guard: Some(guard.trim()),
14309        }),
14310        None => Some(CaseBranchHead {
14311            pattern,
14312            guard: None,
14313        }),
14314    }
14315}
14316
14317fn expression_type(
14318    expr: &str,
14319    semantic: &SemanticContext,
14320    binding_types: &BTreeMap<String, String>,
14321) -> Option<TypeSyntax> {
14322    // A bare enum-typed binding is a valid scrutinee: `case o` dispatches a
14323    // sum-type payload (spec/sum-types.md). Class-typed bare bindings stay
14324    // untyped here so the "match on a bound field" guidance still fires.
14325    let is_bare_ident = !expr.is_empty()
14326        && expr.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
14327        && expr.chars().next().is_some_and(char::is_alphabetic);
14328    if is_bare_ident {
14329        let schema = binding_types.get(expr)?;
14330        if semantic.schemas.enums.contains_key(schema) {
14331            return Some(TypeSyntax::Ref {
14332                name: Ident {
14333                    name: schema.clone(),
14334                    span: zero_span(),
14335                },
14336            });
14337        }
14338        return None;
14339    }
14340    let (root, path) = expression_path(expr)?;
14341    let schema = binding_types.get(&root)?;
14342    semantic.schemas.resolve_field_path(schema, &path).ok()
14343}
14344
14345fn validate_case_pattern(
14346    rule: &RuleDecl,
14347    pattern: &str,
14348    scrutinee_ty: Option<&TypeSyntax>,
14349    span: SourceSpan,
14350    semantic: &SemanticContext,
14351    diagnostics: &mut Vec<Diagnostic>,
14352) {
14353    if matches!(pattern, "_" | "default") {
14354        return;
14355    }
14356    if pattern == "None" {
14357        if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
14358            diagnostics.push(Diagnostic {
14359                related: Vec::new(),
14360                span,
14361                message: format!(
14362                    "rule `{}` uses `None` for a non-optional case",
14363                    rule.name.name
14364                ),
14365                suggestion: Some("use `None` only when matching an optional field".to_owned()),
14366            });
14367        }
14368        return;
14369    }
14370    if pattern.starts_with("Some ") {
14371        if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
14372            diagnostics.push(Diagnostic {
14373                related: Vec::new(),
14374                span,
14375                message: format!(
14376                    "rule `{}` uses `Some` for a non-optional case",
14377                    rule.name.name
14378                ),
14379                suggestion: Some("use `Some name` only when matching an optional field".to_owned()),
14380            });
14381        }
14382        return;
14383    }
14384    let Some(scrutinee_ty) = scrutinee_ty else {
14385        return;
14386    };
14387    match scrutinee_ty {
14388        TypeSyntax::Ref { name } => {
14389            let Some(variants) = semantic.schemas.enums.get(&name.name) else {
14390                return;
14391            };
14392            let (variant, binding) = sum_case_pattern_parts(pattern);
14393            if !variants.contains(variant) {
14394                diagnostics.push(Diagnostic {
14395                    related: Vec::new(),
14396                    span,
14397                    message: format!("enum `{}` has no variant `{variant}`", name.name),
14398                    suggestion: Some(format!(
14399                        "use one of: {}",
14400                        variants.iter().cloned().collect::<Vec<_>>().join(", ")
14401                    )),
14402                });
14403                return;
14404            }
14405            // `as` binds a data-carrying variant's payload (spec/sum-types.md);
14406            // a bare variant has no payload to bind.
14407            if binding.is_some()
14408                && !semantic
14409                    .schemas
14410                    .class_exists(&format!("{}.{variant}", name.name))
14411            {
14412                diagnostics.push(Diagnostic {
14413                    related: Vec::new(),
14414                    span,
14415                    message: format!(
14416                        "variant `{variant}` of enum `{}` carries no payload to bind",
14417                        name.name
14418                    ),
14419                    suggestion: Some(format!("write `{variant} => {{ ... }}` without `as`")),
14420                });
14421            }
14422        }
14423        TypeSyntax::Union { variants, .. } => {
14424            let Some(literal) = parse_literal_expr(pattern) else {
14425                diagnostics.push(Diagnostic {
14426                    related: Vec::new(),
14427                    span,
14428                    message: format!(
14429                        "rule `{}` has unsupported case pattern `{pattern}`",
14430                        rule.name.name
14431                    ),
14432                    suggestion: Some("use a literal branch value or `_`".to_owned()),
14433                });
14434                return;
14435            };
14436            validate_union_case_pattern(rule, variants, &literal, span, diagnostics);
14437        }
14438        TypeSyntax::AgentRef { agents, .. } => {
14439            let Some(literal) = parse_literal_expr(pattern) else {
14440                diagnostics.push(Diagnostic {
14441                    related: Vec::new(),
14442                    span,
14443                    message: format!(
14444                        "rule `{}` has unsupported AgentRef case pattern `{pattern}`",
14445                        rule.name.name
14446                    ),
14447                    suggestion: Some(
14448                        "use a declared agent name, a string literal, or `_`".to_owned(),
14449                    ),
14450                });
14451                return;
14452            };
14453            validate_agent_ref_case_pattern(rule, agents, &literal, span, diagnostics);
14454        }
14455        TypeSyntax::Optional { inner, .. } => {
14456            validate_case_pattern(rule, pattern, Some(inner), span, semantic, diagnostics);
14457        }
14458        // `case` over a `bool` field: only the two literals `true`/`false` (plus
14459        // the `_`/`default` fallbacks already handled above) are valid patterns.
14460        TypeSyntax::Primitive { name, .. } if name == "bool" => {
14461            if !matches!(pattern, "true" | "false") {
14462                diagnostics.push(Diagnostic {
14463                    related: Vec::new(),
14464                    span,
14465                    message: format!(
14466                        "rule `{}` has case pattern `{pattern}` that is not a `bool` value",
14467                        rule.name.name
14468                    ),
14469                    suggestion: Some("match `true`, `false`, or `_`".to_owned()),
14470                });
14471            }
14472        }
14473        _ => {
14474            diagnostics.push(Diagnostic {
14475                related: Vec::new(),
14476                span,
14477                message: format!(
14478                    "rule `{}` cannot pattern-match this scrutinee type",
14479                    rule.name.name
14480                ),
14481                suggestion: Some(
14482                    "match an enum, literal union, optional, or tagged output union".to_owned(),
14483                ),
14484            });
14485        }
14486    }
14487}
14488
14489fn terminal_case_tags() -> [&'static str; 4] {
14490    ["Completed", "Failed", "TimedOut", "Cancelled"]
14491}
14492
14493fn validate_terminal_case_pattern(
14494    rule: &RuleDecl,
14495    pattern: &str,
14496    span: SourceSpan,
14497    diagnostics: &mut Vec<Diagnostic>,
14498) {
14499    if is_fallback_pattern(pattern) {
14500        return;
14501    }
14502    let mut parts = pattern.split_whitespace();
14503    let Some(tag) = parts.next() else {
14504        return;
14505    };
14506    // Binding is `Tag as binding` (Stage 1b: the legacy space form `Tag binding` is
14507    // no longer accepted — it aligns terminal cases with enum-variant `as` binding).
14508    let second = parts.next();
14509    let binding = match second {
14510        Some("as") => parts.next(),
14511        other => other,
14512    };
14513    let uses_as = matches!(second, Some("as"));
14514    if parts.next().is_some() || binding.is_none() || !uses_as {
14515        diagnostics.push(Diagnostic { related: Vec::new(),
14516            span,
14517            message: format!(
14518                "rule `{}` has malformed terminal-output case pattern `{pattern}`",
14519                rule.name.name
14520            ),
14521            suggestion: Some("write `Completed as result`, `Failed as failure`, `TimedOut as timeout`, or `Cancelled as cancel` (the `as` is required)".to_owned()),
14522        });
14523        return;
14524    }
14525    let tags = terminal_case_tags();
14526    if !tags.contains(&tag) {
14527        diagnostics.push(Diagnostic {
14528            related: Vec::new(),
14529            span,
14530            message: format!(
14531                "rule `{}` terminal-output case pattern cannot be `{tag}`",
14532                rule.name.name
14533            ),
14534            suggestion: Some(format!("use one of: {}", tags.join(", "))),
14535        });
14536    }
14537}
14538
14539fn validate_terminal_case_coverage(
14540    rule: &RuleDecl,
14541    branches: &[SpanCaseBranchHead<'_>],
14542    diagnostics: &mut Vec<Diagnostic>,
14543) {
14544    validate_unreachable_after_fallback(rule, branches, diagnostics);
14545    if branches.is_empty()
14546        || branches
14547            .iter()
14548            .any(|branch| is_fallback_pattern(branch.pattern))
14549    {
14550        validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
14551        return;
14552    }
14553    validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
14554    let covered = branches
14555        .iter()
14556        .filter(|branch| branch.guard.is_none())
14557        .filter_map(|branch| normalized_terminal_case_pattern(branch.pattern))
14558        .collect::<BTreeSet<_>>();
14559    let missing = terminal_case_tags()
14560        .iter()
14561        .filter(|tag| !covered.contains(**tag))
14562        .copied()
14563        .collect::<Vec<_>>();
14564    if !missing.is_empty() {
14565        diagnostics.push(Diagnostic {
14566            related: Vec::new(),
14567            span: rule.body.span,
14568            message: format!(
14569                "rule `{}` has non-exhaustive terminal-output case; missing {}",
14570                rule.name.name,
14571                missing.join(", ")
14572            ),
14573            suggestion: Some(
14574                "add terminal branches for every value or add `_ => { ... }`".to_owned(),
14575            ),
14576        });
14577    }
14578}
14579
14580fn validate_duplicate_terminal_case_patterns(
14581    rule: &RuleDecl,
14582    branches: &[SpanCaseBranchHead<'_>],
14583    diagnostics: &mut Vec<Diagnostic>,
14584) {
14585    let mut seen = BTreeSet::new();
14586    for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
14587        let Some(pattern) = normalized_terminal_case_pattern(branch.pattern) else {
14588            continue;
14589        };
14590        if !seen.insert(pattern.to_owned()) {
14591            diagnostics.push(Diagnostic {
14592                related: Vec::new(),
14593                span: branch.pattern_span,
14594                message: format!(
14595                    "rule `{}` has duplicate unguarded terminal-output case pattern `{pattern}`",
14596                    rule.name.name
14597                ),
14598                suggestion: Some(
14599                    "remove the duplicate branch or add mutually exclusive `where` guards"
14600                        .to_owned(),
14601                ),
14602            });
14603        }
14604    }
14605}
14606
14607fn validate_case_coverage(
14608    rule: &RuleDecl,
14609    scrutinee_ty: Option<&TypeSyntax>,
14610    branches: &[SpanCaseBranchHead<'_>],
14611    semantic: &SemanticContext,
14612    diagnostics: &mut Vec<Diagnostic>,
14613) {
14614    validate_unreachable_after_fallback(rule, branches, diagnostics);
14615    if branches.is_empty()
14616        || branches
14617            .iter()
14618            .any(|branch| is_fallback_pattern(branch.pattern))
14619    {
14620        validate_duplicate_case_patterns(rule, branches, diagnostics);
14621        return;
14622    }
14623    validate_duplicate_case_patterns(rule, branches, diagnostics);
14624
14625    let Some(domain) = finite_case_domain(scrutinee_ty, semantic) else {
14626        return;
14627    };
14628    let covered = branches
14629        .iter()
14630        .filter(|branch| branch.guard.is_none())
14631        .filter_map(|branch| normalized_case_pattern(branch.pattern))
14632        .collect::<BTreeSet<_>>();
14633    let missing = domain
14634        .iter()
14635        .filter(|value| !covered.contains(value.as_str()))
14636        .cloned()
14637        .collect::<Vec<_>>();
14638    if !missing.is_empty() {
14639        diagnostics.push(Diagnostic {
14640            related: Vec::new(),
14641            span: rule.body.span,
14642            message: format!(
14643                "rule `{}` has non-exhaustive case; missing {}",
14644                rule.name.name,
14645                missing.join(", ")
14646            ),
14647            suggestion: Some("add branches for every value or add `_ => { ... }`".to_owned()),
14648        });
14649    }
14650}
14651
14652fn validate_duplicate_case_patterns(
14653    rule: &RuleDecl,
14654    branches: &[SpanCaseBranchHead<'_>],
14655    diagnostics: &mut Vec<Diagnostic>,
14656) {
14657    let mut seen = BTreeSet::new();
14658    for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
14659        let Some(pattern) = normalized_case_pattern(branch.pattern) else {
14660            continue;
14661        };
14662        if !seen.insert(pattern.to_owned()) {
14663            diagnostics.push(Diagnostic {
14664                related: Vec::new(),
14665                span: branch.pattern_span,
14666                message: format!(
14667                    "rule `{}` has duplicate unguarded case pattern `{pattern}`",
14668                    rule.name.name
14669                ),
14670                suggestion: Some(
14671                    "remove the duplicate branch or add mutually exclusive `where` guards"
14672                        .to_owned(),
14673                ),
14674            });
14675        }
14676    }
14677}
14678
14679/// Flags case branches that can never be reached because an earlier *unguarded*
14680/// wildcard (`_`/`default`) already matches everything. Shared by rule cases and
14681/// terminal-output cases. Mirrors case-family.maude inv c (redundant-postwild): any
14682/// arm after the wildcard is redundant. A *guarded* fallback (`_ where g`) does not
14683/// shadow, since its guard can fail at runtime.
14684fn validate_unreachable_after_fallback(
14685    rule: &RuleDecl,
14686    branches: &[SpanCaseBranchHead<'_>],
14687    diagnostics: &mut Vec<Diagnostic>,
14688) {
14689    let mut ordered: Vec<&SpanCaseBranchHead<'_>> = branches.iter().collect();
14690    ordered.sort_by_key(|branch| branch.pattern_span.start);
14691    let mut fallback_span: Option<SourceSpan> = None;
14692    for branch in ordered {
14693        if let Some(prior) = fallback_span {
14694            diagnostics.push(
14695                Diagnostic {
14696                    related: Vec::new(),
14697                    span: branch.pattern_span,
14698                    message: format!(
14699                        "rule `{}` has an unreachable case branch after the `_` wildcard",
14700                        rule.name.name
14701                    ),
14702                    suggestion: Some(
14703                        "move this branch before the wildcard, or remove it".to_owned(),
14704                    ),
14705                }
14706                .with_related(
14707                    prior,
14708                    "this unguarded wildcard already matches every remaining value",
14709                ),
14710            );
14711        } else if branch.guard.is_none() && is_fallback_pattern(branch.pattern) {
14712            fallback_span = Some(branch.pattern_span);
14713        }
14714    }
14715}
14716
14717fn finite_case_domain(
14718    scrutinee_ty: Option<&TypeSyntax>,
14719    semantic: &SemanticContext,
14720) -> Option<Vec<String>> {
14721    match scrutinee_ty? {
14722        TypeSyntax::Ref { name } => semantic
14723            .schemas
14724            .enums
14725            .get(&name.name)
14726            .map(|variants| variants.iter().cloned().collect()),
14727        TypeSyntax::Union { variants, .. } => {
14728            let values = variants
14729                .iter()
14730                .filter_map(|variant| match variant {
14731                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
14732                    _ => None,
14733                })
14734                .collect::<Vec<_>>();
14735            (!values.is_empty()).then_some(values)
14736        }
14737        TypeSyntax::Optional { .. } => Some(vec!["Some".to_owned(), "None".to_owned()]),
14738        TypeSyntax::AgentRef { agents, .. } => {
14739            Some(agents.iter().map(|agent| agent.name.clone()).collect())
14740        }
14741        // `bool` is a finite two-value domain: an exhaustive `case` over it must
14742        // cover both `true` and `false` (or carry a `_`).
14743        TypeSyntax::Primitive { name, .. } if name == "bool" => {
14744            Some(vec!["true".to_owned(), "false".to_owned()])
14745        }
14746        _ => None,
14747    }
14748}
14749
14750/// Splits a sum-type case pattern `Variant as binding` into variant and
14751/// binding (spec/sum-types.md); a plain pattern returns no binding.
14752fn sum_case_pattern_parts(pattern: &str) -> (&str, Option<&str>) {
14753    match pattern.split_once(" as ") {
14754        Some((variant, binding)) => (variant.trim(), Some(binding.trim())),
14755        None => (pattern.trim(), None),
14756    }
14757}
14758
14759fn normalized_case_pattern(pattern: &str) -> Option<&str> {
14760    if is_fallback_pattern(pattern) {
14761        return None;
14762    }
14763    if pattern.starts_with("Some ") {
14764        return Some("Some");
14765    }
14766    if pattern == "None" {
14767        return Some("None");
14768    }
14769    // Coverage counts the variant, not its payload binding.
14770    let (pattern, _) = sum_case_pattern_parts(pattern);
14771    // `bool` literals parse to the value-less `LiteralExpr::Bool`; return them
14772    // verbatim so they count toward `true`/`false` coverage.
14773    if matches!(pattern, "true" | "false") {
14774        return Some(pattern);
14775    }
14776    parse_literal_expr(pattern).and_then(|literal| match literal {
14777        LiteralExpr::String(value) | LiteralExpr::Ident(value) => Some(value),
14778        _ => None,
14779    })
14780}
14781
14782fn normalized_terminal_case_pattern(pattern: &str) -> Option<&str> {
14783    if is_fallback_pattern(pattern) {
14784        return None;
14785    }
14786    pattern.split_whitespace().next()
14787}
14788
14789fn is_fallback_pattern(pattern: &str) -> bool {
14790    matches!(pattern, "_" | "default")
14791}
14792
14793fn validate_union_case_pattern(
14794    rule: &RuleDecl,
14795    variants: &[TypeSyntax],
14796    literal: &LiteralExpr<'_>,
14797    span: SourceSpan,
14798    diagnostics: &mut Vec<Diagnostic>,
14799) {
14800    let allowed = variants
14801        .iter()
14802        .filter_map(|variant| match variant {
14803            TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
14804            _ => None,
14805        })
14806        .collect::<Vec<_>>();
14807    if allowed.is_empty() {
14808        return;
14809    }
14810    let LiteralExpr::String(value) = literal else {
14811        diagnostics.push(Diagnostic {
14812            related: Vec::new(),
14813            span,
14814            message: format!(
14815                "rule `{}` case pattern must be one of its literal variants",
14816                rule.name.name
14817            ),
14818            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14819        });
14820        return;
14821    };
14822    if !allowed.contains(value) {
14823        diagnostics.push(Diagnostic {
14824            related: Vec::new(),
14825            span,
14826            message: format!("rule `{}` case pattern cannot be `{value}`", rule.name.name),
14827            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14828        });
14829    }
14830}
14831
14832fn validate_agent_ref_case_pattern(
14833    rule: &RuleDecl,
14834    agents: &[Ident],
14835    literal: &LiteralExpr<'_>,
14836    span: SourceSpan,
14837    diagnostics: &mut Vec<Diagnostic>,
14838) {
14839    let allowed = agents
14840        .iter()
14841        .map(|agent| agent.name.as_str())
14842        .collect::<Vec<_>>();
14843    let (LiteralExpr::String(value) | LiteralExpr::Ident(value)) = literal else {
14844        diagnostics.push(Diagnostic {
14845            related: Vec::new(),
14846            span,
14847            message: format!("rule `{}` has non-agent case pattern", rule.name.name),
14848            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14849        });
14850        return;
14851    };
14852    if !allowed.contains(value) {
14853        diagnostics.push(Diagnostic {
14854            related: Vec::new(),
14855            span,
14856            message: format!("AgentRef has no agent `{value}`"),
14857            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14858        });
14859    }
14860}
14861
14862fn validate_binding_uses(
14863    rule: &RuleDecl,
14864    line: &str,
14865    seen_bindings: &BTreeSet<String>,
14866    scope_stack: &[(String, DependencyPredicate)],
14867    diagnostics: &mut Vec<Diagnostic>,
14868) {
14869    for root in interpolation_roots(line) {
14870        if !seen_bindings.contains(&root) {
14871            continue;
14872        }
14873        if scope_stack.iter().any(|(binding, _)| binding == &root) {
14874            continue;
14875        }
14876
14877        diagnostics.push(Diagnostic { related: Vec::new(),
14878            span: rule.body.span,
14879            message: format!(
14880                "rule `{}` uses effect output `{root}` outside a matching `after {root} ...` block",
14881                rule.name.name
14882            ),
14883            suggestion: Some(format!(
14884                "move this use into `after {root} succeeds {{ ... }}` or another matching terminal branch"
14885            )),
14886        });
14887    }
14888}
14889
14890fn after_scopes(block_stack: &[BlockFrame]) -> Vec<(String, DependencyPredicate)> {
14891    block_stack
14892        .iter()
14893        .map(|frame| match frame {
14894            BlockFrame::After { binding, predicate } => (binding.clone(), predicate.clone()),
14895        })
14896        .collect()
14897}
14898
14899/// The single lowering table for readiness sugar: maps a `when` pattern to
14900/// the runtime fact name it matches. The general form is
14901/// `when fact <name> as x`; the English phrases are documented abbreviations
14902/// of it.
14903pub fn runtime_fact_name_for_pattern(pattern: &str) -> Option<String> {
14904    let pattern = pattern.trim();
14905    if let Some(rest) = pattern.strip_prefix("fact ") {
14906        let name = rest.split_whitespace().next()?;
14907        return Some(name.to_owned());
14908    }
14909    // Inbound messaging (spec/messaging.md): `message from <channel>` matches the
14910    // channel-specific `message.<channel>` fact ingested by `whip message`.
14911    if let Some(rest) = pattern.strip_prefix("message from ") {
14912        if let Some(channel) = rest.split_whitespace().next() {
14913            return Some(format!("message.{channel}"));
14914        }
14915    }
14916    let mut words = pattern.split_whitespace();
14917    let first = words.next()?;
14918    if words.next() == Some("completed") && words.next() == Some("turn") {
14919        let _ = first;
14920        return Some("agent.turn.completed".to_owned());
14921    }
14922    {
14923        let mut words = pattern.split_whitespace();
14924        let _tracker = words.next();
14925        if words.next() == Some("has")
14926            && words.next() == Some("ready")
14927            && words.next() == Some("issue")
14928        {
14929            return Some("tracker.issue.ready".to_owned());
14930        }
14931    }
14932    if first.chars().next().is_some_and(char::is_uppercase) {
14933        return Some(first.to_owned());
14934    }
14935    None
14936}
14937
14938/// The schema used to type-check fields on the pattern's binding. Dotted
14939/// runtime fact names are untyped (no class declares them); the sugar forms
14940/// map to their builtin schemas.
14941fn binding_from_when(when: &str) -> Option<(String, String)> {
14942    let (pattern, _) = split_when_guard(when);
14943    let binding = binding_after_as(pattern)?;
14944    let first = pattern.split_whitespace().next()?;
14945    let completed_turn = {
14946        let mut words = pattern.split_whitespace();
14947        words.next();
14948        words.next() == Some("completed") && words.next() == Some("turn")
14949    };
14950    let has_ready_issue = {
14951        let mut words = pattern.split_whitespace();
14952        words.next();
14953        words.next() == Some("has")
14954            && words.next() == Some("ready")
14955            && words.next() == Some("issue")
14956    };
14957    let schema = if let Some(rest) = pattern.strip_prefix("fact ") {
14958        rest.split_whitespace().next()?.to_owned()
14959    } else if first.chars().next().is_some_and(char::is_uppercase) {
14960        first.to_owned()
14961    } else if first.contains('.') {
14962        // Bare dotted reaction `when deploy.finished as d` — typed against a
14963        // declared `event` (validated at the call site,
14964        // spec/event-ingress.md).
14965        first.to_owned()
14966    } else if completed_turn {
14967        "AgentTurn".to_owned()
14968    } else if has_ready_issue {
14969        "WorkItem".to_owned()
14970    } else if pattern.starts_with("message from ") {
14971        // Inbound messaging (spec/messaging.md): `when message from <channel> as
14972        // msg` binds the generic `Message` envelope, never a domain type.
14973        "Message".to_owned()
14974    } else {
14975        return None;
14976    };
14977
14978    Some((binding, schema))
14979}
14980
14981fn split_when_guard(when: &str) -> (&str, Option<&str>) {
14982    match when.split_once(" where ") {
14983        Some((pattern, guard)) => (pattern.trim(), Some(guard.trim())),
14984        None => (when.trim(), None),
14985    }
14986}
14987
14988fn effect_binding_schema(
14989    line: &str,
14990    kind: &IrEffectKind,
14991    semantic: &SemanticContext,
14992) -> Option<String> {
14993    match kind {
14994        IrEffectKind::SchemaCoerce => parse_coerce_call_name(line).and_then(|name| {
14995            semantic
14996                .coerce_outputs
14997                .get(name)
14998                .and_then(schema_name_for_path)
14999        }),
15000        IrEffectKind::AgentTell
15001        | IrEffectKind::CapabilityCall
15002        | IrEffectKind::EventEmit
15003        | IrEffectKind::WorkflowInvoke
15004        | IrEffectKind::TimerWait
15005        | IrEffectKind::ExecCommand
15006        | IrEffectKind::TrackerFile
15007        | IrEffectKind::TrackerClaim
15008        | IrEffectKind::TrackerRenew
15009        | IrEffectKind::TrackerRelease
15010        | IrEffectKind::TrackerFinish
15011        | IrEffectKind::LeaseAcquire
15012        | IrEffectKind::LeaseRenew
15013        | IrEffectKind::LedgerAppend
15014        | IrEffectKind::CounterConsume
15015        | IrEffectKind::SignalEmit
15016        | IrEffectKind::FileRead
15017        | IrEffectKind::FileWrite
15018        | IrEffectKind::FileImport
15019        | IrEffectKind::FileExport => None,
15020    }
15021}
15022
15023fn parse_coerce_call_name(line: &str) -> Option<&str> {
15024    let rest = line.strip_prefix("coerce ")?;
15025    rest.split_once('(').map(|(name, _)| name.trim())
15026}
15027
15028fn parse_coerce_call(line: &str) -> Option<(&str, Vec<&str>)> {
15029    let rest = line.strip_prefix("coerce ")?;
15030    let call = rest.split(" as ").next().unwrap_or(rest).trim();
15031    let (name, tail) = call.split_once('(')?;
15032    let (args, _) = tail.rsplit_once(')')?;
15033    Some((name.trim(), split_expression_args(args)))
15034}
15035
15036fn split_expression_args(args: &str) -> Vec<&str> {
15037    let mut values = Vec::new();
15038    let mut start = 0usize;
15039    let mut depth = 0i32;
15040    let mut in_string = false;
15041    let mut previous = '\0';
15042    for (index, ch) in args.char_indices() {
15043        if ch == '"' && previous != '\\' {
15044            in_string = !in_string;
15045        } else if !in_string {
15046            match ch {
15047                '(' | '[' | '{' => depth += 1,
15048                ')' | ']' | '}' => depth -= 1,
15049                ',' if depth == 0 => {
15050                    let value = args[start..index].trim();
15051                    if !value.is_empty() {
15052                        values.push(value);
15053                    }
15054                    start = index + ch.len_utf8();
15055                }
15056                _ => {}
15057            }
15058        }
15059        previous = ch;
15060    }
15061    let value = args[start..].trim();
15062    if !value.is_empty() {
15063        values.push(value);
15064    }
15065    values
15066}
15067
15068fn effect_payload_statements(body: &str) -> Vec<String> {
15069    collect_body_statements(body, effect_payload_statement_balance)
15070}
15071
15072fn workflow_invoke_statements(body: &str) -> Vec<String> {
15073    collect_body_statements(body, workflow_invoke_statement_balance)
15074}
15075
15076#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15077enum StatementBalance {
15078    None,
15079    Parens,
15080    Braces,
15081}
15082
15083fn collect_body_statements(
15084    body: &str,
15085    statement_balance: fn(&str) -> Option<StatementBalance>,
15086) -> Vec<String> {
15087    let lines = body.lines().collect::<Vec<_>>();
15088    let mut statements = Vec::new();
15089    let mut index = 0usize;
15090    let mut record_depth = 0i32;
15091    let mut multiline_string = false;
15092    while index < lines.len() {
15093        let trimmed = lines[index].trim();
15094        if trimmed.is_empty() {
15095            index += 1;
15096            continue;
15097        }
15098        if multiline_string {
15099            if trimmed.contains("\"\"\"") {
15100                multiline_string = false;
15101            }
15102            index += 1;
15103            continue;
15104        }
15105        if record_depth > 0 {
15106            record_depth += brace_delta(trimmed);
15107            index += 1;
15108            continue;
15109        }
15110        if parse_record_start(trimmed).is_some() {
15111            record_depth = brace_delta(trimmed).max(1);
15112            index += 1;
15113            continue;
15114        }
15115        if trimmed.contains("\"\"\"") {
15116            multiline_string = trimmed.matches("\"\"\"").count() % 2 == 1;
15117            index += 1;
15118            continue;
15119        }
15120        if let Some(balance) = statement_balance(trimmed) {
15121            match balance {
15122                StatementBalance::None => statements.push(trimmed.to_owned()),
15123                StatementBalance::Parens => {
15124                    let (statement, next_index) =
15125                        statement_until_balanced(&lines, index, trimmed, paren_delta);
15126                    statements.push(statement);
15127                    index = next_index + 1;
15128                    continue;
15129                }
15130                StatementBalance::Braces => {
15131                    let (statement, next_index) =
15132                        statement_until_balanced(&lines, index, trimmed, brace_delta);
15133                    statements.push(statement);
15134                    index = next_index + 1;
15135                    continue;
15136                }
15137            }
15138        }
15139        index += 1;
15140    }
15141    statements
15142}
15143
15144fn effect_payload_statement_balance(trimmed: &str) -> Option<StatementBalance> {
15145    if trimmed.starts_with("coerce ") {
15146        Some(StatementBalance::Parens)
15147    } else if trimmed.starts_with("claim ") {
15148        Some(StatementBalance::None)
15149    } else {
15150        None
15151    }
15152}
15153
15154fn workflow_invoke_statement_balance(trimmed: &str) -> Option<StatementBalance> {
15155    trimmed
15156        .starts_with("invoke ")
15157        .then_some(StatementBalance::Braces)
15158}
15159
15160fn invoke_statement_parts(statement: &str) -> Option<(&str, &str)> {
15161    let rest = statement.trim().strip_prefix("invoke ")?;
15162    let target = rest
15163        .split_whitespace()
15164        .next()
15165        .unwrap_or("")
15166        .trim_end_matches('{');
15167    if target.is_empty() {
15168        return None;
15169    }
15170    let open = statement.find('{')?;
15171    let mut depth = 0i32;
15172    let mut close = None;
15173    for (offset, ch) in statement[open..].char_indices() {
15174        match ch {
15175            '{' => depth += 1,
15176            '}' => {
15177                depth -= 1;
15178                if depth == 0 {
15179                    close = Some(open + offset);
15180                    break;
15181                }
15182            }
15183            _ => {}
15184        }
15185    }
15186    let close = close?;
15187    (close > open).then_some((target, statement[open + 1..close].trim()))
15188}
15189
15190fn statement_until_balanced(
15191    lines: &[&str],
15192    index: usize,
15193    trimmed: &str,
15194    delta: fn(&str) -> i32,
15195) -> (String, usize) {
15196    let mut statement = trimmed.to_owned();
15197    let mut depth = delta(trimmed);
15198    let mut cursor = index;
15199    while depth > 0 && cursor + 1 < lines.len() {
15200        cursor += 1;
15201        let next = lines[cursor].trim();
15202        statement.push(' ');
15203        statement.push_str(next);
15204        depth += delta(next);
15205    }
15206    (statement, cursor)
15207}
15208
15209fn paren_delta(line: &str) -> i32 {
15210    line.chars().fold(0, |depth, ch| match ch {
15211        '(' => depth + 1,
15212        ')' => depth - 1,
15213        _ => depth,
15214    })
15215}
15216
15217/// The hygienic class name synthesized for an inline `decide -> { … } as
15218/// <binding>`. Dots are illegal in user class names (like the `flow.<name>.seg*`
15219/// rule convention), so `decide.<rule>.<binding>` can never collide with a
15220/// declared schema. The lowering pass, the type checker, and the runtime fixture
15221/// all derive the same name, so the anonymous result shape flows exactly like a
15222/// named `coerce -> Schema`: `after <binding> succeeds as r` resolves `r`'s
15223/// fields for `case` dispatch and field access.
15224pub fn inline_decide_schema_name(rule: &str, binding: &str) -> String {
15225    format!("decide.{rule}.{binding}")
15226}
15227
15228/// A single-identifier `decide` field type is either a primitive keyword
15229/// (`bool`, `string`, …) or a reference to a declared class/enum. The `decide`
15230/// grammar only admits single identifiers, so no compound parsing is needed.
15231fn decide_field_type_syntax(ty: &str, span: SourceSpan) -> TypeSyntax {
15232    if is_primitive_type(ty) {
15233        TypeSyntax::Primitive {
15234            name: ty.to_owned(),
15235            span,
15236        }
15237    } else {
15238        TypeSyntax::Ref {
15239            name: Ident {
15240                name: ty.to_owned(),
15241                span,
15242            },
15243        }
15244    }
15245}
15246
15247/// Collects every inline `decide … as <binding>` in a rule body — recursing
15248/// through nested after/case/branch/handler blocks — yielding
15249/// `(binding, result_fields, span)` for synthesis and type registration.
15250#[allow(clippy::type_complexity)]
15251fn collect_decide_effects<'a>(
15252    statements: &'a [body::BodyStmt],
15253    out: &mut Vec<(&'a str, &'a [(String, String)], SourceSpan)>,
15254) {
15255    for statement in statements {
15256        match statement {
15257            body::BodyStmt::Effect(effect) => {
15258                if let body::BodyEffectKind::Decide { result_fields } = &effect.kind {
15259                    if let Some(binding) = &effect.binding {
15260                        out.push((binding.as_str(), result_fields.as_slice(), effect.span));
15261                    }
15262                }
15263            }
15264            body::BodyStmt::After(after) => collect_decide_effects(&after.body, out),
15265            body::BodyStmt::Case(case) => {
15266                for branch in &case.branches {
15267                    collect_decide_effects(&branch.body, out);
15268                }
15269            }
15270            _ => {}
15271        }
15272    }
15273}
15274
15275/// Registers each inline `decide … as <binding>` result as `Ref(decide.<rule>.<binding>)`
15276/// so the after-binding type flow resolves the anonymous shape's fields, exactly
15277/// like a named `coerce -> Schema`. The synthesized class is injected into both
15278/// the semantic schema index and the IR by [`collect_inline_decide_schemas`].
15279fn collect_decide_payload_types(
15280    statements: &[body::BodyStmt],
15281    rule_name: &str,
15282    payloads: &mut BTreeMap<String, IrType>,
15283) {
15284    let mut decides = Vec::new();
15285    collect_decide_effects(statements, &mut decides);
15286    for (binding, _fields, _span) in decides {
15287        payloads.insert(
15288            binding.to_owned(),
15289            IrType::Ref(inline_decide_schema_name(rule_name, binding)),
15290        );
15291    }
15292}
15293
15294fn collect_prompt_payload_types(
15295    statements: &[body::BodyStmt],
15296    payloads: &mut BTreeMap<String, IrType>,
15297) {
15298    for statement in statements {
15299        match statement {
15300            body::BodyStmt::Effect(effect) => {
15301                if matches!(&effect.kind, body::BodyEffectKind::Prompt { .. }) {
15302                    if let Some(binding) = &effect.binding {
15303                        payloads
15304                            .insert(binding.clone(), IrType::Primitive(IrPrimitiveType::String));
15305                    }
15306                }
15307            }
15308            body::BodyStmt::After(after) => collect_prompt_payload_types(&after.body, payloads),
15309            body::BodyStmt::Case(case) => {
15310                for branch in &case.branches {
15311                    collect_prompt_payload_types(&branch.body, payloads);
15312                }
15313            }
15314            _ => {}
15315        }
15316    }
15317}
15318
15319/// Synthesizes a hygienic `decide.<rule>.<binding>` class for every inline
15320/// `decide -> { … } as <binding>`, injecting it into both the semantic schema
15321/// index (so field access / `case` type-check) and the IR (so the runtime
15322/// fixture can generate the anonymous shape). Mirrors the generated
15323/// `<Enum>.<Variant>` class synthesis for data-carrying sum-type variants.
15324fn collect_inline_decide_schemas(
15325    items: &[Item],
15326    semantic: &mut SemanticContext,
15327    ir: &mut IrProgram,
15328) {
15329    for item in items {
15330        let Item::Rule(rule) = item else {
15331            continue;
15332        };
15333        let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15334        let mut decides = Vec::new();
15335        collect_decide_effects(&body_ast.statements, &mut decides);
15336        for (binding, fields, span) in decides {
15337            let name = inline_decide_schema_name(&rule.name.name, binding);
15338            // Build the field shape once as `TypeSyntax` (the schema-index form),
15339            // then lower it for the IR so both representations stay in lockstep.
15340            let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
15341            let mut ir_fields = Vec::new();
15342            for (field_name, field_ty) in fields {
15343                let ty = decide_field_type_syntax(field_ty, span);
15344                ir_fields.push(IrClassField {
15345                    name: field_name.clone(),
15346                    ty: lower_type(ty.clone()),
15347                    is_key: false,
15348                    presence_condition: None,
15349                    span,
15350                });
15351                syntax_fields.insert(field_name.clone(), ty);
15352            }
15353            semantic.schemas.classes.insert(name.clone(), syntax_fields);
15354            ir.schemas.push(IrSchema::Class(IrClass {
15355                name,
15356                fields: ir_fields,
15357                span,
15358            }));
15359        }
15360    }
15361}
15362
15363/// The hygienic synthetic class name for a `redact … as <binding>` projection:
15364/// `redact.<rule>.<binding>`, holding only the kept fields of the source schema.
15365pub fn redact_schema_name(rule: &str, binding: &str) -> String {
15366    format!("redact.{rule}.{binding}")
15367}
15368
15369/// Collects every `redact <source> keep [..] as <binding>` in a rule body —
15370/// recursing through nested after/case/branch/handler blocks — for projected-type
15371/// synthesis, type registration, and IFC value-flow.
15372#[allow(clippy::type_complexity)]
15373fn collect_redact_effects<'a>(
15374    statements: &'a [body::BodyStmt],
15375    out: &mut Vec<(&'a str, &'a [String], &'a str, SourceSpan)>,
15376) {
15377    for statement in statements {
15378        match statement {
15379            body::BodyStmt::Redact {
15380                source,
15381                keep,
15382                binding,
15383                span,
15384            } => out.push((source.as_str(), keep.as_slice(), binding.as_str(), *span)),
15385            body::BodyStmt::After(after) => collect_redact_effects(&after.body, out),
15386            body::BodyStmt::Case(case) => {
15387                for branch in &case.branches {
15388                    collect_redact_effects(&branch.body, out);
15389                }
15390            }
15391            _ => {}
15392        }
15393    }
15394}
15395
15396/// Resolves binding -> schema name for a rule's redact SOURCES: `when Class as x`
15397/// matches, plus coerce/decide/exec result bindings. Used only to find the schema
15398/// a `redact` projects from, so the synthetic projected class copies the kept
15399/// fields' types. (`after`-alias sources are a documented follow-up; an
15400/// unresolved source surfaces as an empty projection + a `validate_redactions`
15401/// diagnostic.) Diagnostics from the reused collector are discarded — the real
15402/// pass re-emits them.
15403fn rule_binding_schemas(rule: &RuleDecl, semantic: &SemanticContext) -> BTreeMap<String, String> {
15404    let mut schemas = binding_types_for_rule(rule);
15405    let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15406    let mut payloads = collect_effect_payload_types(rule, semantic, &mut Vec::new());
15407    collect_exec_payload_types(&body_ast.statements, semantic, &mut payloads);
15408    collect_decide_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
15409    collect_redact_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
15410    // `after <binding> <predicate> as <alias>` aliases the effect's completed
15411    // payload schema, so a `coerce … as c` then `after c succeeds as cust` then
15412    // `redact cust …` resolves (the primary read-then-redact flow). Only
15413    // payload-carrying predicates are mapped here; terminal predicates
15414    // (`times out`/`fails`) bind synthetic terminal schemas not usefully redacted.
15415    for line in rule.body.text.lines() {
15416        let Some(rest) = line.trim().strip_prefix("after ") else {
15417            continue;
15418        };
15419        let mut words = rest.split_whitespace();
15420        let Some(binding) = words.next() else {
15421            continue;
15422        };
15423        let Some(predicate) = words.next() else {
15424            continue;
15425        };
15426        if predicate == "times" && words.next() != Some("out") {
15427            continue;
15428        }
15429        let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
15430            continue;
15431        };
15432        let alias = alias.trim_end_matches('{').trim();
15433        if alias.is_empty() {
15434            continue;
15435        }
15436        if let Some(IrType::Ref(schema)) = payloads.get(binding) {
15437            schemas.insert(alias.to_owned(), schema.clone());
15438        }
15439    }
15440    for (binding, ty) in payloads {
15441        if let IrType::Ref(schema) = ty {
15442            schemas.insert(binding, schema);
15443        }
15444    }
15445    schemas
15446}
15447
15448/// Synthesizes a hygienic `redact.<rule>.<binding>` class for every
15449/// `redact <source> keep [..] as <binding>`, holding ONLY the kept fields of the
15450/// source schema (with their source types). This is what makes a redaction sound:
15451/// the projected binding cannot expose a dropped field (accessing one is a
15452/// type error, since it is absent from the synthetic class), so the lowered IFC
15453/// label the checker assigns the projection is honoured by the type system too.
15454/// Mirrors [`collect_inline_decide_schemas`]; run before the rule loop so
15455/// `analyze_rule` sees the class. A redact chained off an earlier redact's output
15456/// resolves via the local map built as the pass proceeds.
15457fn collect_redact_schemas(items: &[Item], semantic: &mut SemanticContext, ir: &mut IrProgram) {
15458    for item in items {
15459        let Item::Rule(rule) = item else {
15460            continue;
15461        };
15462        let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15463        let mut redacts = Vec::new();
15464        collect_redact_effects(&body_ast.statements, &mut redacts);
15465        if redacts.is_empty() {
15466            continue;
15467        }
15468        let binding_schemas = rule_binding_schemas(rule, semantic);
15469        let mut local: BTreeMap<String, String> = BTreeMap::new();
15470        for (source, keep, binding, span) in redacts {
15471            let name = redact_schema_name(&rule.name.name, binding);
15472            let source_schema = binding_schemas
15473                .get(source)
15474                .cloned()
15475                .or_else(|| local.get(source).cloned());
15476            // Clone the kept fields' types out of the source schema first, so the
15477            // immutable borrow ends before we insert the new class.
15478            let projected: Vec<(String, TypeSyntax)> = source_schema
15479                .as_ref()
15480                .and_then(|schema| semantic.schemas.classes.get(schema))
15481                .map(|src_fields| {
15482                    keep.iter()
15483                        .filter_map(|field| {
15484                            src_fields.get(field).map(|ty| (field.clone(), ty.clone()))
15485                        })
15486                        .collect()
15487                })
15488                .unwrap_or_default();
15489            let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
15490            let mut ir_fields = Vec::new();
15491            for (field_name, ty) in &projected {
15492                syntax_fields.insert(field_name.clone(), ty.clone());
15493                ir_fields.push(IrClassField {
15494                    name: field_name.clone(),
15495                    ty: lower_type(ty.clone()),
15496                    is_key: false,
15497                    presence_condition: None,
15498                    span,
15499                });
15500            }
15501            semantic.schemas.classes.insert(name.clone(), syntax_fields);
15502            ir.schemas.push(IrSchema::Class(IrClass {
15503                name: name.clone(),
15504                fields: ir_fields,
15505                span,
15506            }));
15507            local.insert(binding.to_owned(), name);
15508        }
15509    }
15510}
15511
15512/// Registers each `redact … as <binding>` result as `Ref(redact.<rule>.<binding>)`
15513/// so field access / `case` through the projection resolves against the kept-only
15514/// synthetic class (a dropped field is an unknown-field error). Mirrors
15515/// [`collect_decide_payload_types`].
15516fn collect_redact_payload_types(
15517    statements: &[body::BodyStmt],
15518    rule_name: &str,
15519    payloads: &mut BTreeMap<String, IrType>,
15520) {
15521    let mut redacts = Vec::new();
15522    collect_redact_effects(statements, &mut redacts);
15523    for (_source, _keep, binding, _span) in redacts {
15524        payloads.insert(
15525            binding.to_owned(),
15526            IrType::Ref(redact_schema_name(rule_name, binding)),
15527        );
15528    }
15529}
15530
15531/// Validates each `redact <source> keep [..] as <out>`: the source must resolve to
15532/// a known schema, and every kept field must exist on it. Fail-closed — an
15533/// unresolvable source or unknown kept field is a hard error, so a redaction can
15534/// never silently project nothing (which would carry no data and mask a mistake).
15535fn validate_redactions(
15536    rule: &RuleDecl,
15537    statements: &[body::BodyStmt],
15538    semantic: &SemanticContext,
15539    binding_schemas: &BTreeMap<String, String>,
15540    diagnostics: &mut Vec<Diagnostic>,
15541) {
15542    let mut redacts = Vec::new();
15543    collect_redact_effects(statements, &mut redacts);
15544    let mut local: BTreeMap<String, String> = BTreeMap::new();
15545    for (source, keep, binding, span) in redacts {
15546        let source_schema = binding_schemas
15547            .get(source)
15548            .cloned()
15549            .or_else(|| local.get(source).cloned());
15550        local.insert(
15551            binding.to_owned(),
15552            redact_schema_name(&rule.name.name, binding),
15553        );
15554        let Some(schema) = source_schema else {
15555            diagnostics.push(Diagnostic {
15556                related: Vec::new(),
15557                span,
15558                message: format!(
15559                    "rule `{}` redacts `{source}`, which has no known schema",
15560                    rule.name.name
15561                ),
15562                suggestion: Some(
15563                    "redact a binding with a known record type — a matched `when Class as x`, or a \
15564                     coerce/decide/exec result"
15565                        .to_owned(),
15566                ),
15567            });
15568            continue;
15569        };
15570        let Some(src_fields) = semantic.schemas.classes.get(&schema) else {
15571            continue;
15572        };
15573        for field in keep {
15574            if !src_fields.contains_key(field) {
15575                diagnostics.push(Diagnostic {
15576                    related: Vec::new(),
15577                    span,
15578                    message: format!(
15579                        "rule `{}` redacts `{source}` keeping unknown field `{field}` of `{schema}`",
15580                        rule.name.name
15581                    ),
15582                    suggestion: Some(format!("keep a field declared on `{schema}`")),
15583                });
15584            }
15585        }
15586    }
15587}
15588
15589/// Registers the typed result of the single `exec "..." -> Schema as binding`
15590/// form so `after <binding> succeeds as r` resolves `r`'s fields — the same
15591/// after-binding type flow a named `coerce -> Schema` already gets. The
15592/// streaming `-> each Schema` form records one fact per element (not a single
15593/// bound value), so it is skipped here.
15594fn collect_exec_payload_types(
15595    statements: &[body::BodyStmt],
15596    semantic: &SemanticContext,
15597    payloads: &mut BTreeMap<String, IrType>,
15598) {
15599    for statement in statements {
15600        match statement {
15601            body::BodyStmt::Effect(effect) => {
15602                if let body::BodyEffectKind::Exec {
15603                    parse_target: Some(parse),
15604                    ..
15605                } = &effect.kind
15606                {
15607                    if !parse.each {
15608                        if let Some(binding) = &effect.binding {
15609                            if semantic.schemas.class_exists(&parse.schema) {
15610                                payloads.insert(binding.clone(), IrType::Ref(parse.schema.clone()));
15611                            }
15612                        }
15613                    }
15614                }
15615            }
15616            body::BodyStmt::After(after) => {
15617                collect_exec_payload_types(&after.body, semantic, payloads)
15618            }
15619            body::BodyStmt::Case(case) => {
15620                for branch in &case.branches {
15621                    collect_exec_payload_types(&branch.body, semantic, payloads);
15622                }
15623            }
15624            _ => {}
15625        }
15626    }
15627}
15628
15629/// Collects the schemas an `exec ... -> each` stream records as facts.
15630fn push_ingest_fact_writes(statements: &[body::BodyStmt], fact_writes: &mut Vec<String>) {
15631    for statement in statements {
15632        match statement {
15633            body::BodyStmt::Effect(effect) => {
15634                match &effect.kind {
15635                    body::BodyEffectKind::Exec {
15636                        parse_target: Some(parse),
15637                        ..
15638                    } if parse.each => {
15639                        fact_writes.push(format!("schema:{}", parse.schema));
15640                    }
15641                    // `import <fmt> <Schema>` admits one `<Schema>` fact per row
15642                    // (spec/std-library/files.md), so a `when <Schema>` rule has a
15643                    // producer for liveness/effect-graph analysis.
15644                    body::BodyEffectKind::FileImport { schema, .. } => {
15645                        fact_writes.push(format!("schema:{schema}"));
15646                    }
15647                    _ => {}
15648                }
15649            }
15650            body::BodyStmt::After(after) => push_ingest_fact_writes(&after.body, fact_writes),
15651            body::BodyStmt::Case(case) => {
15652                for branch in &case.branches {
15653                    push_ingest_fact_writes(&branch.body, fact_writes);
15654                }
15655            }
15656            _ => {}
15657        }
15658    }
15659}
15660
15661/// Body-effect operand checks that need schema knowledge:
15662/// - `timer until <operand>`: a non-literal operand must be a dotted path
15663///   resolving to a `time`-typed field (spec/scheduled-time.md). Literals were
15664///   format-validated by the body parser, so anything that still looks like an
15665///   instant here is a valid literal and passes.
15666/// - `exec ... -> Schema` / `-> each Schema`: the parse target must name a
15667///   declared class (spec/json-ingestion.md).
15668///
15669/// The coordination safety model (spec/coordination.md): at most one held
15670/// lease per progression (hard default), exhaustive outcome handling, and
15671/// the linear must-release discipline (instance terminals auto-release, so
15672/// a path that ends in `complete`/`fail` is safe without an explicit
15673/// `release`).
15674fn validate_coordination_discipline(
15675    rule: &RuleDecl,
15676    statements: &[body::BodyStmt],
15677    diagnostics: &mut Vec<Diagnostic>,
15678) {
15679    let mut acquires = Vec::new();
15680    let mut consumes = Vec::new();
15681    let mut claims = Vec::new();
15682    collect_coordination_effects(statements, &mut acquires, &mut consumes, &mut claims);
15683
15684    // A `renew <binding>` names ONE OF TWO legitimate referents (T3, mirroring
15685    // the `release` disambiguation below):
15686    //   (1) an `acquire ... as <binding>` LEASE binding acquired in this rule —
15687    //       lowers to `lease.renew` (std.coord); resolves the acquire's recorded
15688    //       resource/key at runtime;
15689    //   (2) a `claim <issue> as <binding>` CLAIM binding claimed in this rule —
15690    //       lowers to `tracker.renew` (std.tracker); resolves the claimed issue
15691    //       id from the claim's output fact.
15692    // A `renew <binding>` naming NEITHER renews nothing, so catch the typo at
15693    // `whip check`. (Scoped to `renew`, which is new.)
15694    let claim_bindings = collect_claim_bindings(statements);
15695    let renewable: BTreeSet<&str> = acquires
15696        .iter()
15697        .map(|(b, _, _)| b.as_str())
15698        .chain(claim_bindings.iter().map(String::as_str))
15699        .collect();
15700    for_each_body(statements, &mut |stmt| {
15701        if let body::BodyStmt::Effect(effect) = stmt {
15702            if let body::BodyEffectKind::LeaseRenew {
15703                acquire_binding, ..
15704            } = &effect.kind
15705            {
15706                if !renewable.contains(acquire_binding.as_str()) {
15707                    diagnostics.push(Diagnostic {
15708                        related: Vec::new(),
15709                        span: effect.span,
15710                        message: format!(
15711                            "rule `{}` renews unbound coordination binding `{}`",
15712                            rule.name.name, acquire_binding
15713                        ),
15714                        suggestion: Some(format!(
15715                            "`renew {acquire_binding}` must name a lease acquired here (`acquire ... as {acquire_binding}`) or an issue claimed here (`claim ... as {acquire_binding}`)"
15716                        )),
15717                    });
15718                }
15719            }
15720        }
15721    });
15722
15723    // A `release <x>` names ONE OF THREE legitimate referents (spec/coordination.md,
15724    // verified against the corpus + the rule-body matrix test):
15725    //   (1) an `acquire ... as <x>` LEASE binding acquired in this rule;
15726    //   (2) a `claim <x> as ...` — the *item* being claimed (the `TrackerClaim.item`,
15727    //       NOT the claim's `as` binding);
15728    //   (3) a `when <queue> has ready <x> as <x>` WORK-ITEM binding, released
15729    //       without a same-rule claim.
15730    // A naive `acquire ∪ claim-binding` model false-positives forms (2) and (3);
15731    // admit exactly these three and flag a `release <x>` that matches none as a
15732    // genuinely-unbound release. Scoped per-rule like the `renew` check above.
15733    let work_items: Vec<String> = rule
15734        .whens
15735        .iter()
15736        .filter_map(|when| when_has_ready_binding(&when.text))
15737        .collect();
15738    let releasable: BTreeSet<&str> = acquires
15739        .iter()
15740        .map(|(b, _, _)| b.as_str())
15741        .chain(claims.iter().map(|(item, _)| item.as_str()))
15742        .chain(work_items.iter().map(String::as_str))
15743        .collect();
15744    for_each_body(statements, &mut |stmt| {
15745        if let body::BodyStmt::Effect(effect) = stmt {
15746            if let body::BodyEffectKind::TrackerRelease { item } = &effect.kind {
15747                if !releasable.contains(item.as_str()) {
15748                    diagnostics.push(Diagnostic {
15749                        related: Vec::new(),
15750                        span: effect.span,
15751                        message: format!(
15752                            "rule `{}` releases unbound coordination item `{}`",
15753                            rule.name.name, item
15754                        ),
15755                        suggestion: Some(format!(
15756                            "`release {item}` must name a lease acquired here (`acquire ... as {item}`), an item claimed here (`claim {item} as ...`), or a work item bound by a `when <queue> has ready ... as {item}` reaction"
15757                        )),
15758                    });
15759                }
15760            }
15761        }
15762    });
15763
15764    if acquires.len() > 1 {
15765        diagnostics.push(Diagnostic { related: Vec::new(),
15766            span: acquires[1].2,
15767            message: format!(
15768                "rule `{}` acquires more than one lease in a single progression",
15769                rule.name.name
15770            ),
15771            suggestion: Some(
15772                "the hard default is at most one held lease per progression (it breaks hold-and-wait); restructure into separate rules"
15773                    .to_owned(),
15774            ),
15775        });
15776    }
15777    for (binding, until_ttl, span) in &acquires {
15778        if *until_ttl {
15779            continue;
15780        }
15781        let mut predicates = BTreeSet::new();
15782        collect_after_predicates(statements, binding, &mut predicates);
15783        for required in ["held", "contended"] {
15784            if !predicates.contains(required) {
15785                diagnostics.push(Diagnostic { related: Vec::new(),
15786                    span: *span,
15787                    message: format!(
15788                        "rule `{}` does not handle the `{required}` outcome of lease `{binding}`",
15789                        rule.name.name
15790                    ),
15791                    suggestion: Some(format!(
15792                        "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
15793                    )),
15794                });
15795            }
15796        }
15797        if let Some(held_body) = find_after_body(statements, binding, body::AfterPredicate::Held) {
15798            if !releases_or_terminates(held_body, binding) {
15799                diagnostics.push(Diagnostic { related: Vec::new(),
15800                    span: *span,
15801                    message: format!(
15802                        "rule `{}` can hold lease `{binding}` forever: the `held` branch neither releases it nor reaches a workflow terminal",
15803                        rule.name.name
15804                    ),
15805                    suggestion: Some(format!(
15806                        "add `release {binding}` on every non-terminal path, or use `acquire ... until ttl` for fire-and-forget"
15807                    )),
15808                });
15809            }
15810        }
15811    }
15812    for (binding, span) in &consumes {
15813        let mut predicates = BTreeSet::new();
15814        collect_after_predicates(statements, binding, &mut predicates);
15815        for required in ["ok", "over"] {
15816            if !predicates.contains(required) {
15817                diagnostics.push(Diagnostic { related: Vec::new(),
15818                    span: *span,
15819                    message: format!(
15820                        "rule `{}` does not handle the `{required}` outcome of counter consume `{binding}`",
15821                        rule.name.name
15822                    ),
15823                    suggestion: Some(format!(
15824                        "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
15825                    )),
15826                });
15827            }
15828        }
15829    }
15830}
15831
15832fn collect_coordination_effects(
15833    statements: &[body::BodyStmt],
15834    acquires: &mut Vec<(String, bool, SourceSpan)>,
15835    consumes: &mut Vec<(String, SourceSpan)>,
15836    claims: &mut Vec<(String, SourceSpan)>,
15837) {
15838    for_each_body(statements, &mut |stmt| {
15839        if let body::BodyStmt::Effect(effect) = stmt {
15840            match &effect.kind {
15841                body::BodyEffectKind::LeaseAcquire { until_ttl, .. } => {
15842                    if let Some(binding) = &effect.binding {
15843                        acquires.push((binding.clone(), *until_ttl, effect.span));
15844                    }
15845                }
15846                body::BodyEffectKind::CounterConsume { .. } => {
15847                    if let Some(binding) = &effect.binding {
15848                        consumes.push((binding.clone(), effect.span));
15849                    }
15850                }
15851                // A `claim <item> as <lease>` makes `<item>` releasable: the
15852                // releasable referent is the *item* being claimed (the
15853                // `TrackerClaim.item`), not the claim's `as` binding.
15854                body::BodyEffectKind::TrackerClaim { item, .. } => {
15855                    claims.push((item.clone(), effect.span));
15856                }
15857                _ => {}
15858            }
15859        }
15860    });
15861}
15862
15863/// The work-item binding of a `when <queue> has ready <item> as <binding>`
15864/// reaction: the `as <binding>` names a claimable/releasable work item pulled
15865/// off the queue (spec/coordination.md). Returns `None` for every other `when`
15866/// pattern (plain fact binds are not releasable). Guards (`where ...`) are
15867/// stripped first so the pattern words line up.
15868fn when_has_ready_binding(when: &str) -> Option<String> {
15869    let (pattern, _) = split_when_guard(when);
15870    let mut words = pattern.split_whitespace();
15871    let _queue = words.next()?;
15872    if words.next() == Some("has") && words.next() == Some("ready") {
15873        return binding_after_as(pattern);
15874    }
15875    None
15876}
15877
15878fn collect_after_predicates(
15879    statements: &[body::BodyStmt],
15880    binding: &str,
15881    predicates: &mut BTreeSet<&'static str>,
15882) {
15883    for_each_body(statements, &mut |stmt| {
15884        if let body::BodyStmt::After(after) = stmt {
15885            if after.binding == binding {
15886                predicates.insert(after.predicate.as_str());
15887            }
15888        }
15889    });
15890}
15891
15892fn find_after_body<'a>(
15893    statements: &'a [body::BodyStmt],
15894    binding: &str,
15895    predicate: body::AfterPredicate,
15896) -> Option<&'a [body::BodyStmt]> {
15897    for statement in statements {
15898        match statement {
15899            body::BodyStmt::After(after) => {
15900                if after.binding == binding && after.predicate == predicate {
15901                    return Some(&after.body);
15902                }
15903                if let Some(found) = find_after_body(&after.body, binding, predicate) {
15904                    return Some(found);
15905                }
15906            }
15907            body::BodyStmt::Case(case) => {
15908                for branch in &case.branches {
15909                    if let Some(found) = find_after_body(&branch.body, binding, predicate) {
15910                        return Some(found);
15911                    }
15912                }
15913            }
15914            _ => {}
15915        }
15916    }
15917    None
15918}
15919
15920/// Linear must-release, prototype form: a statement list is safe if some
15921/// statement guarantees release — an explicit `release <binding>`, a
15922/// workflow terminal (instance-terminal auto-release), a nested after-block
15923/// that is safe, or a branching construct ALL of whose branches are safe.
15924fn releases_or_terminates(statements: &[body::BodyStmt], binding: &str) -> bool {
15925    statements.iter().any(|statement| match statement {
15926        body::BodyStmt::Effect(effect) => matches!(
15927            &effect.kind,
15928            body::BodyEffectKind::TrackerRelease { item } if item == binding
15929        ),
15930        body::BodyStmt::Terminal(_) => true,
15931        body::BodyStmt::After(after) => releases_or_terminates(&after.body, binding),
15932        body::BodyStmt::Case(case) => {
15933            !case.branches.is_empty()
15934                && case
15935                    .branches
15936                    .iter()
15937                    .all(|branch| releases_or_terminates(&branch.body, binding))
15938        }
15939        _ => false,
15940    })
15941}
15942
15943fn for_each_body(statements: &[body::BodyStmt], visit: &mut impl FnMut(&body::BodyStmt)) {
15944    for statement in statements {
15945        visit(statement);
15946        match statement {
15947            body::BodyStmt::After(after) => for_each_body(&after.body, visit),
15948            body::BodyStmt::Case(case) => {
15949                for branch in &case.branches {
15950                    for_each_body(&branch.body, visit);
15951                }
15952            }
15953            _ => {}
15954        }
15955    }
15956}
15957
15958/// Family B: the `(root, field)` pairs a `case <root>.<disc> { "<lit>" => ... }` arm
15959/// makes readable — the fields conditioned on `<disc> is "<lit>"`. Empty unless the
15960/// scrutinee is a single-level `<root>.<disc>` path bound to a schema and the arm
15961/// pattern is the matching string literal.
15962fn family_b_arm_allowed(
15963    scrutinee: &str,
15964    pattern: &str,
15965    binding_types: &BTreeMap<String, String>,
15966    semantic: &SemanticContext,
15967) -> BTreeSet<(String, String)> {
15968    let mut allowed = BTreeSet::new();
15969    let Some((root, disc)) = scrutinee.split_once('.') else {
15970        return allowed;
15971    };
15972    if disc.contains('.') {
15973        return allowed;
15974    }
15975    let trimmed = pattern.trim();
15976    if trimmed == "_" || trimmed == "default" {
15977        return allowed;
15978    }
15979    let literal = trimmed.trim_matches('"');
15980    if literal.is_empty() {
15981        return allowed;
15982    }
15983    let Some(schema) = binding_types.get(root) else {
15984        return allowed;
15985    };
15986    if let Some(conditions) = semantic.schemas.presence.get(schema) {
15987        for (field, (cond_disc, cond_literal)) in conditions {
15988            if cond_disc == disc && cond_literal == literal {
15989                allowed.insert((root.to_owned(), field.clone()));
15990            }
15991        }
15992    }
15993    allowed
15994}
15995
15996/// Reject reads of a Family B presence-conditioned field in `text` that are not
15997/// permitted by `allowed` (the conditioned fields this scope's `case` arm makes
15998/// present). `text` is any source fragment that may contain dotted field paths.
15999fn check_conditioned_reads_in_text(
16000    rule: &RuleDecl,
16001    text: &str,
16002    span: SourceSpan,
16003    semantic: &SemanticContext,
16004    binding_types: &BTreeMap<String, String>,
16005    allowed: &BTreeSet<(String, String)>,
16006    diagnostics: &mut Vec<Diagnostic>,
16007) {
16008    for (root, path) in dotted_paths(text) {
16009        let Some(first) = path.first() else {
16010            continue;
16011        };
16012        let Some(schema) = binding_types.get(&root) else {
16013            continue;
16014        };
16015        if let Some((disc, _literal)) = semantic.schemas.field_presence(schema, first) {
16016            if !allowed.contains(&(root.clone(), first.clone())) {
16017                diagnostics.push(Diagnostic {
16018                    related: Vec::new(),
16019                    span,
16020                    message: format!(
16021                        "rule `{}` reads conditional field `{root}.{first}` outside a matching `case {root}.{disc}` arm",
16022                        rule.name.name
16023                    ),
16024                    suggestion: Some(format!(
16025                        "read `{root}.{first}` inside `case {root}.{disc} {{ \"...\" => ... }}` — it is present only for a specific `{disc}`"
16026                    )),
16027                });
16028            }
16029        }
16030    }
16031}
16032
16033fn check_conditioned_reads_in_fields(
16034    rule: &RuleDecl,
16035    fields: &[body::FieldAssign],
16036    semantic: &SemanticContext,
16037    binding_types: &BTreeMap<String, String>,
16038    allowed: &BTreeSet<(String, String)>,
16039    diagnostics: &mut Vec<Diagnostic>,
16040) {
16041    for field in fields {
16042        match &field.value {
16043            body::FieldValue::Expr { source, .. } => check_conditioned_reads_in_text(
16044                rule,
16045                source,
16046                field.span,
16047                semantic,
16048                binding_types,
16049                allowed,
16050                diagnostics,
16051            ),
16052            body::FieldValue::Nested { fields, .. } => check_conditioned_reads_in_fields(
16053                rule,
16054                fields,
16055                semantic,
16056                binding_types,
16057                allowed,
16058                diagnostics,
16059            ),
16060            body::FieldValue::Shorthand => {}
16061        }
16062    }
16063}
16064
16065/// Family B read-narrowing (discriminated-families-design.md §5.6/§5.7): walk the
16066/// rule body and reject a read of a presence-conditioned field that is not inside a
16067/// matching `case <root>.<disc>` arm. Each `case` arm extends `allowed` with the
16068/// fields its discriminant=literal makes present. (v1 covers record/terminal/done
16069/// values, branch conditions, and case guards; effect prompt/argument positions are
16070/// a documented follow-up.)
16071fn validate_conditioned_field_reads(
16072    rule: &RuleDecl,
16073    statements: &[body::BodyStmt],
16074    semantic: &SemanticContext,
16075    binding_types: &BTreeMap<String, String>,
16076    allowed: &BTreeSet<(String, String)>,
16077    diagnostics: &mut Vec<Diagnostic>,
16078) {
16079    for statement in statements {
16080        match statement {
16081            body::BodyStmt::Record(record) => check_conditioned_reads_in_fields(
16082                rule,
16083                &record.fields,
16084                semantic,
16085                binding_types,
16086                allowed,
16087                diagnostics,
16088            ),
16089            body::BodyStmt::Terminal(terminal) => {
16090                check_conditioned_reads_in_fields(
16091                    rule,
16092                    &terminal.fields,
16093                    semantic,
16094                    binding_types,
16095                    allowed,
16096                    diagnostics,
16097                );
16098                // A bare scalar payload value is also an egress read.
16099                if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
16100                    check_conditioned_reads_in_text(
16101                        rule,
16102                        source,
16103                        terminal.span,
16104                        semantic,
16105                        binding_types,
16106                        allowed,
16107                        diagnostics,
16108                    );
16109                }
16110            }
16111            body::BodyStmt::Done {
16112                replacement: Some(record),
16113                ..
16114            } => check_conditioned_reads_in_fields(
16115                rule,
16116                &record.fields,
16117                semantic,
16118                binding_types,
16119                allowed,
16120                diagnostics,
16121            ),
16122            body::BodyStmt::Milestone { fields, .. } => check_conditioned_reads_in_fields(
16123                rule,
16124                fields,
16125                semantic,
16126                binding_types,
16127                allowed,
16128                diagnostics,
16129            ),
16130            body::BodyStmt::Done { .. }
16131            | body::BodyStmt::Cancel { .. }
16132            | body::BodyStmt::Redact { .. }
16133            | body::BodyStmt::Effect(_) => {}
16134            body::BodyStmt::After(after) => validate_conditioned_field_reads(
16135                rule,
16136                &after.body,
16137                semantic,
16138                binding_types,
16139                allowed,
16140                diagnostics,
16141            ),
16142            body::BodyStmt::Region(region) => {
16143                validate_conditioned_field_reads(
16144                    rule,
16145                    &region.body,
16146                    semantic,
16147                    binding_types,
16148                    allowed,
16149                    diagnostics,
16150                );
16151                validate_conditioned_field_reads(
16152                    rule,
16153                    &region.lapse_body,
16154                    semantic,
16155                    binding_types,
16156                    allowed,
16157                    diagnostics,
16158                );
16159            }
16160            body::BodyStmt::Case(case) => {
16161                for arm in &case.branches {
16162                    let mut arm_allowed = allowed.clone();
16163                    arm_allowed.extend(family_b_arm_allowed(
16164                        &case.scrutinee,
16165                        &arm.pattern,
16166                        binding_types,
16167                        semantic,
16168                    ));
16169                    if let Some(guard) = &arm.guard {
16170                        check_conditioned_reads_in_text(
16171                            rule,
16172                            guard,
16173                            arm.span,
16174                            semantic,
16175                            binding_types,
16176                            &arm_allowed,
16177                            diagnostics,
16178                        );
16179                    }
16180                    validate_conditioned_field_reads(
16181                        rule,
16182                        &arm.body,
16183                        semantic,
16184                        binding_types,
16185                        &arm_allowed,
16186                        diagnostics,
16187                    );
16188                }
16189            }
16190        }
16191    }
16192}
16193
16194fn validate_body_effect_operands(
16195    rule: &RuleDecl,
16196    statements: &[body::BodyStmt],
16197    semantic: &SemanticContext,
16198    binding_types: &BTreeMap<String, String>,
16199    diagnostics: &mut Vec<Diagnostic>,
16200) {
16201    for statement in statements {
16202        match statement {
16203            body::BodyStmt::Effect(effect) => {
16204                match &effect.kind {
16205                    body::BodyEffectKind::LeaseAcquire { resource, .. }
16206                        if !semantic.leases.contains(resource) =>
16207                    {
16208                        diagnostics.push(Diagnostic { related: Vec::new(),
16209                            span: effect.span,
16210                            message: format!(
16211                                "rule `{}` acquires undeclared lease `{resource}`",
16212                                rule.name.name
16213                            ),
16214                            suggestion: Some(format!(
16215                                "declare `lease {resource} {{ key <Type>  slots <N>  ttl <duration> }}`"
16216                            )),
16217                        });
16218                    }
16219                    body::BodyEffectKind::LedgerAppend { ledger, schema, .. } => {
16220                        if !semantic.ledgers.contains(ledger) {
16221                            diagnostics.push(Diagnostic { related: Vec::new(),
16222                                span: effect.span,
16223                                message: format!(
16224                                    "rule `{}` appends to undeclared ledger `{ledger}`",
16225                                    rule.name.name
16226                                ),
16227                                suggestion: Some(format!(
16228                                    "declare `ledger {ledger} {{ entry <Type>  partition by <field>  retain <duration> }}`"
16229                                )),
16230                            });
16231                        }
16232                        if !semantic.schemas.class_exists(schema) {
16233                            diagnostics.push(Diagnostic {
16234                                related: Vec::new(),
16235                                span: effect.span,
16236                                message: format!(
16237                                    "rule `{}` appends unknown entry class `{schema}`",
16238                                    rule.name.name
16239                                ),
16240                                suggestion: Some(format!("declare `class {schema}` first")),
16241                            });
16242                        }
16243                    }
16244                    body::BodyEffectKind::CounterConsume { counter, .. }
16245                        if !semantic.counters.contains(counter) =>
16246                    {
16247                        diagnostics.push(Diagnostic { related: Vec::new(),
16248                            span: effect.span,
16249                            message: format!(
16250                                "rule `{}` consumes undeclared counter `{counter}`",
16251                                rule.name.name
16252                            ),
16253                            suggestion: Some(format!(
16254                                "declare `counter {counter} {{ key <Type>  cap <N>  reset <period> }}`"
16255                            )),
16256                        });
16257                    }
16258                    _ => {}
16259                }
16260                // `exec <name> with <binding>` requires a typed record binding
16261                // (spec/std-script.md "Static checks" item 4): the binding is
16262                // serialized to the script's stdin as a typed record, so an
16263                // unknown or untyped binding cannot cross.
16264                if let body::BodyEffectKind::Exec {
16265                    target:
16266                        body::ExecTarget::Capability {
16267                            name,
16268                            stdin_binding,
16269                        },
16270                    ..
16271                } = &effect.kind
16272                {
16273                    match binding_types.get(stdin_binding) {
16274                        None => {
16275                            diagnostics.push(Diagnostic {
16276                                related: Vec::new(),
16277                                span: effect.span,
16278                                message: format!(
16279                                    "rule `{}` uses unknown binding `{stdin_binding}` in `exec {name} with {stdin_binding}` — `with` requires a typed record binding",
16280                                    rule.name.name
16281                                ),
16282                                suggestion: Some(format!(
16283                                    "bind a typed record first (e.g. `when <Class> as {stdin_binding}` or `coerce ... -> <Class> as {stdin_binding}`) and pass that binding to `with`"
16284                                )),
16285                            });
16286                        }
16287                        // A dotted binding without an indexed payload class is an
16288                        // untyped runtime fact (`when fact <name> as x`) — no
16289                        // static record shape crosses to stdin. Non-dotted
16290                        // unknown classes are already reported at their binding
16291                        // site (`matches unknown class` / unknown parse schema).
16292                        Some(schema)
16293                            if schema.contains('.') && !semantic.schemas.class_exists(schema) =>
16294                        {
16295                            diagnostics.push(Diagnostic {
16296                                related: Vec::new(),
16297                                span: effect.span,
16298                                message: format!(
16299                                    "rule `{}` passes untyped fact binding `{stdin_binding}` to `exec {name} with` — `with` requires a typed record binding",
16300                                    rule.name.name
16301                                ),
16302                                suggestion: Some(format!(
16303                                    "declare `signal {schema} {{ ... }}` for a typed reaction, or bind a declared class and pass that to `with`"
16304                                )),
16305                            });
16306                        }
16307                        Some(_) => {}
16308                    }
16309                }
16310                if let body::BodyEffectKind::Exec {
16311                    parse_target: Some(parse),
16312                    ..
16313                } = &effect.kind
16314                {
16315                    if !semantic.schemas.class_exists(&parse.schema) {
16316                        let suggestion =
16317                            match closest_name(&parse.schema, semantic.schemas.classes.keys()) {
16318                                Some(candidate) => format!(
16319                                    "did you mean `{candidate}`? otherwise declare `class {}`",
16320                                    parse.schema
16321                                ),
16322                                None => format!(
16323                                    "declare `class {}` before parsing into it",
16324                                    parse.schema
16325                                ),
16326                            };
16327                        diagnostics.push(Diagnostic {
16328                            related: Vec::new(),
16329                            span: effect.span,
16330                            message: format!(
16331                                "rule `{}` parses exec output into unknown schema `{}`",
16332                                rule.name.name, parse.schema
16333                            ),
16334                            suggestion: Some(suggestion),
16335                        });
16336                    }
16337                }
16338                let body::BodyEffectKind::Timer {
16339                    until: Some(until), ..
16340                } = &effect.kind
16341                else {
16342                    continue;
16343                };
16344                if body::is_iso8601_instant(until) {
16345                    continue;
16346                }
16347                let mut segments = until.split('.');
16348                let root = segments.next().unwrap_or_default();
16349                let path = segments.map(str::to_owned).collect::<Vec<_>>();
16350                let Some(schema) = binding_types.get(root) else {
16351                    diagnostics.push(Diagnostic { related: Vec::new(),
16352                        span: effect.span,
16353                        message: format!(
16354                            "rule `{}` uses unknown binding `{root}` in `timer until {until}`",
16355                            rule.name.name
16356                        ),
16357                        suggestion: Some(
16358                            "bind a fact in `when` and reference a `time` field on it, or use an ISO-8601 literal"
16359                                .to_owned(),
16360                        ),
16361                    });
16362                    continue;
16363                };
16364                // Dotted runtime fact bindings are untyped; their fields
16365                // cannot be statically checked.
16366                if schema.contains('.') {
16367                    continue;
16368                }
16369                let resolved = if path.is_empty() {
16370                    Err(format!(
16371                        "`{root}` is a `{schema}` record, not a `time` value"
16372                    ))
16373                } else {
16374                    semantic.schemas.resolve_field_path(schema, &path)
16375                };
16376                match resolved {
16377                    Ok(TypeSyntax::Primitive { ref name, .. }) if name == "time" => {}
16378                    Ok(_) => {
16379                        diagnostics.push(Diagnostic { related: Vec::new(),
16380                            span: effect.span,
16381                            message: format!(
16382                                "rule `{}` uses non-time operand `{until}` in `timer until`",
16383                                rule.name.name
16384                            ),
16385                            suggestion: Some(format!(
16386                                "declare the field as `time` on `{schema}` or use an ISO-8601 literal"
16387                            )),
16388                        });
16389                    }
16390                    Err(message) => {
16391                        diagnostics.push(Diagnostic { related: Vec::new(),
16392                            span: effect.span,
16393                            message: format!(
16394                                "rule `{}` has invalid `timer until` operand `{until}`: {message}",
16395                                rule.name.name
16396                            ),
16397                            suggestion: Some(
16398                                "reference a `time`-typed field on a bound fact, or use an ISO-8601 literal"
16399                                    .to_owned(),
16400                            ),
16401                        });
16402                    }
16403                }
16404            }
16405            body::BodyStmt::After(after) => {
16406                validate_body_effect_operands(
16407                    rule,
16408                    &after.body,
16409                    semantic,
16410                    binding_types,
16411                    diagnostics,
16412                );
16413            }
16414            body::BodyStmt::Case(case) => {
16415                for branch in &case.branches {
16416                    validate_body_effect_operands(
16417                        rule,
16418                        &branch.body,
16419                        semantic,
16420                        binding_types,
16421                        diagnostics,
16422                    );
16423                }
16424            }
16425            _ => {}
16426        }
16427    }
16428}
16429
16430fn validate_known_field_paths(
16431    rule: &RuleDecl,
16432    line: &str,
16433    semantic: &SemanticContext,
16434    binding_types: &BTreeMap<String, String>,
16435    diagnostics: &mut Vec<Diagnostic>,
16436) {
16437    validate_known_field_paths_at_span(
16438        rule,
16439        line,
16440        rule.body.span,
16441        semantic,
16442        binding_types,
16443        diagnostics,
16444    );
16445}
16446
16447fn validate_known_field_paths_at_span(
16448    rule: &RuleDecl,
16449    line: &str,
16450    span: SourceSpan,
16451    semantic: &SemanticContext,
16452    binding_types: &BTreeMap<String, String>,
16453    diagnostics: &mut Vec<Diagnostic>,
16454) {
16455    for (root, path) in dotted_paths(line) {
16456        let Some(schema) = binding_types.get(&root) else {
16457            continue;
16458        };
16459        if !semantic.schemas.class_exists(schema) {
16460            continue;
16461        }
16462        if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
16463            diagnostics.push(Diagnostic {
16464                related: Vec::new(),
16465                span,
16466                message: format!(
16467                    "rule `{}` has invalid field path `{root}.{}`: {message}",
16468                    rule.name.name,
16469                    path.join(".")
16470                ),
16471                suggestion: Some(
16472                    "use a field declared on the bound schema or add it to the class declaration"
16473                        .to_owned(),
16474                ),
16475            });
16476        }
16477    }
16478}
16479
16480fn dotted_paths(line: &str) -> Vec<(String, Vec<String>)> {
16481    let bytes = line.as_bytes();
16482    let mut paths = Vec::new();
16483    let mut index = 0;
16484
16485    while index < bytes.len() {
16486        if !is_ident_start(bytes[index]) {
16487            index += 1;
16488            continue;
16489        }
16490
16491        let root_start = index;
16492        index += 1;
16493        while index < bytes.len() && is_ident_continue(bytes[index]) {
16494            index += 1;
16495        }
16496        let root = &line[root_start..index];
16497        let mut fields = Vec::new();
16498
16499        while bytes.get(index) == Some(&b'.')
16500            && bytes
16501                .get(index + 1)
16502                .is_some_and(|byte| is_ident_start(*byte))
16503        {
16504            index += 1;
16505            let field_start = index;
16506            index += 1;
16507            while index < bytes.len() && is_ident_continue(bytes[index]) {
16508                index += 1;
16509            }
16510            fields.push(line[field_start..index].to_owned());
16511        }
16512
16513        if !fields.is_empty() {
16514            paths.push((root.to_owned(), fields));
16515        }
16516    }
16517
16518    paths
16519}
16520
16521fn interpolation_roots(line: &str) -> Vec<String> {
16522    let mut roots = Vec::new();
16523    let mut rest = line;
16524
16525    while let Some(open) = rest.find("{{") {
16526        let after_open = &rest[open + 2..];
16527        let Some(close) = after_open.find("}}") else {
16528            break;
16529        };
16530        let expr = after_open[..close].trim();
16531        if let Some(root) = expr
16532            .split(|ch: char| !ch.is_alphanumeric() && ch != '_')
16533            .find(|part| !part.is_empty())
16534        {
16535            roots.push(root.to_owned());
16536        }
16537        rest = &after_open[close + 2..];
16538    }
16539
16540    roots
16541}
16542
16543// `claim` stays bindable: `claim item as claim` is an established idiom and
16544// the trailing binding position is unambiguous.
16545const RESERVED_BINDING_KEYWORDS: &[&str] = &[
16546    "after", "call", "case", "coerce", "complete", "consume", "done", "emit", "fail", "invoke",
16547    "record", "tell", "when", "where",
16548];
16549
16550fn validate_binding_name(
16551    rule: &RuleDecl,
16552    binding: &str,
16553    span: SourceSpan,
16554    diagnostics: &mut Vec<Diagnostic>,
16555) {
16556    if RESERVED_BINDING_KEYWORDS.contains(&binding) {
16557        diagnostics.push(Diagnostic {
16558            related: Vec::new(),
16559            span,
16560            message: format!(
16561                "rule `{}` binds reserved keyword `{binding}`",
16562                rule.name.name
16563            ),
16564            suggestion: Some(format!(
16565                "`{binding}` is a rule body keyword; choose another binding name"
16566            )),
16567        });
16568    }
16569}
16570
16571fn closest_name<'a>(target: &str, candidates: impl Iterator<Item = &'a String>) -> Option<String> {
16572    let target_lower = target.to_lowercase();
16573    candidates
16574        .map(|candidate| {
16575            let distance = edit_distance(&target_lower, &candidate.to_lowercase());
16576            (distance, candidate)
16577        })
16578        .filter(|(distance, candidate)| {
16579            *distance <= 2 && *distance < target.len().min(candidate.len())
16580        })
16581        .min_by_key(|(distance, candidate)| (*distance, candidate.as_str().to_owned()))
16582        .map(|(_, candidate)| candidate.clone())
16583}
16584
16585fn edit_distance(a: &str, b: &str) -> usize {
16586    let a: Vec<char> = a.chars().collect();
16587    let b: Vec<char> = b.chars().collect();
16588    let mut previous: Vec<usize> = (0..=b.len()).collect();
16589    let mut current = vec![0usize; b.len() + 1];
16590    for (i, a_char) in a.iter().enumerate() {
16591        current[0] = i + 1;
16592        for (j, b_char) in b.iter().enumerate() {
16593            let substitution = previous[j] + usize::from(a_char != b_char);
16594            current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
16595        }
16596        std::mem::swap(&mut previous, &mut current);
16597    }
16598    previous[b.len()]
16599}
16600
16601fn fact_read_from_when(when: &str) -> String {
16602    let (pattern, _) = split_when_guard(when);
16603    let first = pattern.split_whitespace().next().unwrap_or("<empty>");
16604    if first.chars().next().is_some_and(char::is_uppercase) {
16605        format!("schema:{first}")
16606    } else {
16607        format!("pattern:{pattern}")
16608    }
16609}
16610
16611fn parse_record_start(line: &str) -> Option<(String, Option<String>)> {
16612    let rest = line.strip_prefix("record ").or_else(|| {
16613        line.strip_prefix("done ")
16614            .and_then(|rest| rest.split_once("->"))
16615            .map(|(_, record)| record.trim())
16616            .and_then(|record| record.strip_prefix("record "))
16617    })?;
16618    let before_brace = rest.split('{').next().unwrap_or(rest).trim();
16619    let mut parts = before_brace.split_whitespace();
16620    let schema = parts.next()?.to_owned();
16621    let from_binding = match (parts.next(), parts.next(), parts.next()) {
16622        (None, None, None) => None,
16623        (Some("from"), Some(binding), None) => Some(binding.to_owned()),
16624        _ => return None,
16625    };
16626    Some((schema, from_binding))
16627}
16628
16629fn validate_record_field(
16630    rule: &RuleDecl,
16631    line: &str,
16632    record_schema: &str,
16633    semantic: &SemanticContext,
16634    binding_types: &BTreeMap<String, String>,
16635    known_roots: &BTreeSet<String>,
16636    diagnostics: &mut Vec<Diagnostic>,
16637) {
16638    let Some((field, expr)) = record_field_assignment(line) else {
16639        diagnostics.push(Diagnostic {
16640            related: Vec::new(),
16641            span: rule.body.span,
16642            message: format!(
16643                "rule `{}` has malformed field assignment in `record {record_schema}`",
16644                rule.name.name
16645            ),
16646            suggestion: Some("write record fields as `field value`".to_owned()),
16647        });
16648        return;
16649    };
16650
16651    let Some(fields) = semantic.schemas.classes.get(record_schema) else {
16652        return;
16653    };
16654    let Some(field_ty) = fields.get(field) else {
16655        diagnostics.push(Diagnostic {
16656            related: Vec::new(),
16657            span: rule.body.span,
16658            message: format!("class `{record_schema}` has no field `{field}`"),
16659            suggestion: Some(format!(
16660                "add `{field}` to `class {record_schema}` or record an existing field"
16661            )),
16662        });
16663        return;
16664    };
16665
16666    if let Some((root, path)) = expression_path(expr) {
16667        if let Some(schema) = binding_types.get(&root) {
16668            if !semantic.schemas.class_exists(schema) {
16669                return;
16670            }
16671            if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
16672                diagnostics.push(Diagnostic { related: Vec::new(),
16673                    span: rule.body.span,
16674                    message: format!(
16675                        "rule `{}` has invalid field path `{root}.{}`: {message}",
16676                        rule.name.name,
16677                        path.join(".")
16678                    ),
16679                    suggestion: Some(
16680                        "use a field declared on the bound schema or add it to the class declaration"
16681                            .to_owned(),
16682                    ),
16683                });
16684            }
16685        } else if let Some(root) = dangling_value_root(expr, known_roots) {
16686            // A field access whose root is neither a bound name nor a special
16687            // root is a dangling reference (the binding does not exist).
16688            diagnostics.push(Diagnostic { related: Vec::new(),
16689                span: rule.body.span,
16690                message: format!(
16691                    "rule `{}` has unknown binding `{root}` in `record {record_schema}` field `{field}`",
16692                    rule.name.name
16693                ),
16694                suggestion: Some(
16695                    "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16696                        .to_owned(),
16697                ),
16698            });
16699        }
16700    }
16701
16702    validate_literal_assignment(
16703        rule,
16704        record_schema,
16705        field,
16706        field_ty,
16707        expr,
16708        semantic,
16709        diagnostics,
16710    );
16711    validate_expected_assignment(
16712        rule,
16713        record_schema,
16714        field,
16715        field_ty,
16716        expr,
16717        semantic,
16718        binding_types,
16719        diagnostics,
16720    );
16721}
16722
16723fn record_field_assignment(line: &str) -> Option<(&str, &str)> {
16724    let field_end = line.find(char::is_whitespace)?;
16725    let field = &line[..field_end];
16726    let expr = line[field_end..].trim();
16727    (!field.is_empty() && !expr.is_empty()).then_some((field, expr))
16728}
16729
16730/// Roots valid in value positions without being author bindings: the
16731/// external-event payload and the coerce prompt context. An explicit allowlist
16732/// so genuine typos are still caught.
16733const SPECIAL_VALUE_ROOTS: &[&str] = &["external", "ctx"];
16734
16735/// Collects every binding NAME a rule body introduces, from the parsed AST so it
16736/// is robust to multi-line prompts and nesting (the line-based effect collectors
16737/// only track `coerce`/`claim`, so `tell`/`exec`/etc. bindings are invisible to
16738/// `binding_types`). Used to reject dangling roots in value positions without
16739/// false-flagging valid effect results, `after` aliases, or case bindings.
16740fn collect_all_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
16741    for statement in statements {
16742        match statement {
16743            body::BodyStmt::Effect(effect) => {
16744                if let Some(binding) = &effect.binding {
16745                    out.insert(binding.clone());
16746                }
16747            }
16748            body::BodyStmt::Region(region) => {
16749                if let Some(view) = &region.lapse_binding {
16750                    out.insert(view.clone());
16751                }
16752                collect_all_binding_names(&region.body, out);
16753                collect_all_binding_names(&region.lapse_body, out);
16754            }
16755            body::BodyStmt::After(after) => {
16756                if let Some(alias) = &after.alias {
16757                    out.insert(alias.clone());
16758                }
16759                collect_all_binding_names(&after.body, out);
16760            }
16761            body::BodyStmt::Case(case) => {
16762                for branch in &case.branches {
16763                    if let Some(binding) = &branch.binding {
16764                        out.insert(binding.clone());
16765                    }
16766                    collect_all_binding_names(&branch.body, out);
16767                }
16768            }
16769            // `redact … as <out>` introduces the projected binding `out`.
16770            body::BodyStmt::Redact { binding, .. } => {
16771                out.insert(binding.clone());
16772            }
16773            body::BodyStmt::Record(_)
16774            | body::BodyStmt::Done { .. }
16775            | body::BodyStmt::Terminal(_)
16776            | body::BodyStmt::Milestone { .. }
16777            | body::BodyStmt::Cancel { .. } => {}
16778        }
16779    }
16780}
16781
16782/// A `source`'s `emit <signal>` must name a declared `signal` — the ingestion
16783/// mirror of the rule-side "reacts to undeclared signal" check on `when <signal>`.
16784/// Only dotted names are typed signal declarations (a bare name is a class/fact,
16785/// consistent with the reaction check). Without this a source silently admits a
16786/// signal fact no rule can react to (rules may only react to declared signals),
16787/// so ingested data is dropped with no diagnostic — this lifts the guarantee to
16788/// static `whip check`, symmetric with the clock/file/http source runtime that
16789/// admits `emit_signal`.
16790fn validate_source_emit_signal_declared(
16791    source: &SourceDecl,
16792    declared_signals: &BTreeSet<String>,
16793    diagnostics: &mut Vec<Diagnostic>,
16794) {
16795    let signal = &source.emit.signal;
16796    if signal.contains('.') && !declared_signals.contains(signal) {
16797        let suggestion = match closest_name(signal, declared_signals.iter()) {
16798            Some(candidate) => {
16799                format!("did you mean `{candidate}`? otherwise declare `signal {signal} {{ ... }}`")
16800            }
16801            None => format!("declare `signal {signal} {{ ... }}` so rules can react to it"),
16802        };
16803        diagnostics.push(Diagnostic {
16804            related: Vec::new(),
16805            span: source.emit.signal_span,
16806            message: format!(
16807                "source `{}` emits undeclared signal `{}`",
16808                source.name.name, signal
16809            ),
16810            suggestion: Some(suggestion),
16811        });
16812    }
16813
16814    // The emit fields map the source's observation record onto the signal
16815    // payload by name. Each `<field> <observe>.<obsfield>` must read a real
16816    // observation field for this source's kind, else it silently maps null at
16817    // runtime. The observation schemas below MUST mirror the records built in
16818    // the CLI source resolvers (`resolve_due_{clock,file,http}_sources`): add a
16819    // field there → add it here. Unknown providers have no known schema, so
16820    // their emit fields are not checked (avoids false positives).
16821    let observation_fields: Option<&[&str]> = match source.provider.name.as_str() {
16822        "clock" => Some(&[
16823            "scheduled_at",
16824            "observed_at",
16825            "occurrence_id",
16826            "missed_count",
16827            "schedule_name",
16828        ]),
16829        // `file` observes lines in `path` mode and (path, content-hash)
16830        // occurrences in `watch` mode (spec/std-ingress.md I2a; content
16831        // READING stays std.files).
16832        "file" if source.watch.is_some() => Some(&["path", "content_hash", "watch"]),
16833        "file" => Some(&["line", "line_index", "path"]),
16834        "http" => Some(&["item", "item_index", "url"]),
16835        _ => None,
16836    };
16837    // `dedup` reads the same observation record the emit mapping reads: an
16838    // unknown field would make the admission key silently null at runtime.
16839    if let (Some(fields), Some(SourceValue::Path { segments, .. })) =
16840        (observation_fields, &source.dedup)
16841    {
16842        if let [field] = segments.as_slice() {
16843            if !fields.contains(&field.name.as_str()) {
16844                diagnostics.push(Diagnostic {
16845                    related: Vec::new(),
16846                    span: field.span,
16847                    message: format!(
16848                        "source `{}` `dedup` reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16849                        source.name.name,
16850                        source.observe_binding.name,
16851                        field.name,
16852                        source.provider.name,
16853                        field.name
16854                    ),
16855                    suggestion: Some(format!(
16856                        "available observation fields: {}",
16857                        fields.join(", ")
16858                    )),
16859                });
16860            }
16861        }
16862    }
16863    if let Some(fields) = observation_fields {
16864        let observe = &source.observe_binding.name;
16865        for emit_field in &source.emit.fields {
16866            let SourceValue::Path {
16867                binding,
16868                segments,
16869                span,
16870            } = &emit_field.value
16871            else {
16872                continue;
16873            };
16874            if &binding.name != observe {
16875                diagnostics.push(Diagnostic {
16876                    related: Vec::new(),
16877                    span: *span,
16878                    message: format!(
16879                        "source `{}` emit reads unknown binding `{}`",
16880                        source.name.name, binding.name
16881                    ),
16882                    suggestion: Some(format!(
16883                        "the source's observation binding is `{observe}` (declared by `observe as {observe}`)"
16884                    )),
16885                });
16886                continue;
16887            }
16888            if let Some(obs_field) = segments.first() {
16889                if !fields.contains(&obs_field.name.as_str()) {
16890                    diagnostics.push(Diagnostic {
16891                        related: Vec::new(),
16892                        span: obs_field.span,
16893                        message: format!(
16894                            "source `{}` emit reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16895                            source.name.name, observe, obs_field.name, source.provider.name, obs_field.name
16896                        ),
16897                        suggestion: Some(format!(
16898                            "available observation fields: {}",
16899                            fields.join(", ")
16900                        )),
16901                    });
16902                }
16903            }
16904        }
16905    }
16906}
16907
16908/// `emit signal <name> to <target>` requires `<name>` to be a declared `signal`
16909/// — the declaration is the typed payload contract at the emit site, symmetric
16910/// with the reaction-side "reacts to undeclared signal" check on `when <signal>`
16911/// (spec/event-ingress.md, "Directed injection"). Without this, an emit of an
16912/// undeclared signal only fails at runtime when the effect input is built
16913/// (`whipplescript_kernel::rule_lowering`, "emit signal of undeclared signal");
16914/// this lifts the same guarantee to static `whip check`. Recurses into
16915/// `after`/`case`/`branch`/`handler` bodies so a nested emit is covered too.
16916fn validate_emit_signal_declarations(
16917    rule: &RuleDecl,
16918    statements: &[body::BodyStmt],
16919    declared_signals: &BTreeSet<String>,
16920    diagnostics: &mut Vec<Diagnostic>,
16921) {
16922    for statement in statements {
16923        match statement {
16924            body::BodyStmt::Effect(effect) => {
16925                if let body::BodyEffectKind::Notify { event, .. } = &effect.kind {
16926                    if !declared_signals.contains(event) {
16927                        diagnostics.push(Diagnostic {
16928                            related: Vec::new(),
16929                            span: effect.span,
16930                            message: format!(
16931                                "rule `{}` emits undeclared signal `{event}`",
16932                                rule.name.name
16933                            ),
16934                            suggestion: Some(format!(
16935                                "declare `signal {event} {{ ... }}` so the emitted payload is typed and admissible, \
16936                                 or check the signal name"
16937                            )),
16938                        });
16939                    }
16940                }
16941            }
16942            body::BodyStmt::After(after) => {
16943                validate_emit_signal_declarations(rule, &after.body, declared_signals, diagnostics)
16944            }
16945            body::BodyStmt::Case(case) => {
16946                for branch in &case.branches {
16947                    validate_emit_signal_declarations(
16948                        rule,
16949                        &branch.body,
16950                        declared_signals,
16951                        diagnostics,
16952                    );
16953                }
16954            }
16955            _ => {}
16956        }
16957    }
16958}
16959
16960/// Flags dangling roots in the field payloads of body-AST effects that the
16961/// line-based validators don't reach: `emit`/`notify` (`Notify`), `file item
16962/// into` (`TrackerFile`), and ledger `append` (`LedgerAppend`). Uses the parsed
16963/// AST and the same root check as the record/coerce/tell/invoke validators.
16964fn validate_effect_field_roots(
16965    rule: &RuleDecl,
16966    statements: &[body::BodyStmt],
16967    known_roots: &BTreeSet<String>,
16968    diagnostics: &mut Vec<Diagnostic>,
16969) {
16970    for statement in statements {
16971        match statement {
16972            body::BodyStmt::Effect(effect) => match &effect.kind {
16973                body::BodyEffectKind::Notify {
16974                    target_expr,
16975                    event,
16976                    from,
16977                    fields,
16978                } => {
16979                    if let Some(from) = from {
16980                        check_operand_root(
16981                            rule,
16982                            &format!("emit `{event}` from"),
16983                            from,
16984                            known_roots,
16985                            diagnostics,
16986                        );
16987                    }
16988                    check_operand_root(
16989                        rule,
16990                        &format!("emit `{event}` target"),
16991                        target_expr,
16992                        known_roots,
16993                        diagnostics,
16994                    );
16995                    check_field_value_roots(
16996                        rule,
16997                        &format!("emit `{event}`"),
16998                        fields,
16999                        known_roots,
17000                        diagnostics,
17001                    );
17002                }
17003                body::BodyEffectKind::TrackerFile { queue, fields } => {
17004                    check_field_value_roots(
17005                        rule,
17006                        &format!("file into `{queue}`"),
17007                        fields,
17008                        known_roots,
17009                        diagnostics,
17010                    );
17011                }
17012                body::BodyEffectKind::TrackerFinish { item, fields } => {
17013                    check_operand_root(rule, "finish item", item, known_roots, diagnostics);
17014                    check_field_value_roots(rule, "finish", fields, known_roots, diagnostics);
17015                }
17016                body::BodyEffectKind::LedgerAppend { ledger, fields, .. } => {
17017                    check_field_value_roots(
17018                        rule,
17019                        &format!("append to `{ledger}`"),
17020                        fields,
17021                        known_roots,
17022                        diagnostics,
17023                    );
17024                }
17025                body::BodyEffectKind::LeaseAcquire {
17026                    resource, key_expr, ..
17027                } => {
17028                    check_operand_root(
17029                        rule,
17030                        &format!("acquire `{resource}` key"),
17031                        key_expr,
17032                        known_roots,
17033                        diagnostics,
17034                    );
17035                }
17036                body::BodyEffectKind::CounterConsume {
17037                    counter,
17038                    key_expr,
17039                    amount_expr,
17040                } => {
17041                    check_operand_root(
17042                        rule,
17043                        &format!("consume `{counter}` key"),
17044                        key_expr,
17045                        known_roots,
17046                        diagnostics,
17047                    );
17048                    check_operand_root(
17049                        rule,
17050                        &format!("consume `{counter}` amount"),
17051                        amount_expr,
17052                        known_roots,
17053                        diagnostics,
17054                    );
17055                }
17056                _ => {}
17057            },
17058            body::BodyStmt::After(after) => {
17059                validate_effect_field_roots(rule, &after.body, known_roots, diagnostics)
17060            }
17061            body::BodyStmt::Case(case) => {
17062                for branch in &case.branches {
17063                    validate_effect_field_roots(rule, &branch.body, known_roots, diagnostics);
17064                }
17065            }
17066            _ => {}
17067        }
17068    }
17069}
17070
17071/// The single source of truth for value-position root validation: returns the
17072/// dangling root of a single-path value expression — a `root.field…` access whose
17073/// root is neither a known binding nor a recognized special root — or `None`.
17074/// Bare atoms (agents, enum variants, literals) have no path and are ignored; the
17075/// `"`-guard skips values whose "path" was mis-extracted from inside a string
17076/// literal. Used by every value-position validator (record/terminal/coerce/tell/
17077/// invoke/effect payloads/operands).
17078fn dangling_value_root(value: &str, known_roots: &BTreeSet<String>) -> Option<String> {
17079    let (root, path) = expression_path(value)?;
17080    if !path.is_empty()
17081        && !value.contains('"')
17082        && !known_roots.contains(&root)
17083        && !SPECIAL_VALUE_ROOTS.contains(&root.as_str())
17084    {
17085        Some(root)
17086    } else {
17087        None
17088    }
17089}
17090
17091/// Flags a dangling root in a single effect-operand expression (e.g. an
17092/// `emit ... to <target>` target, a lease/counter `for <key>` key). Same check
17093/// as the field/record validators.
17094fn check_operand_root(
17095    rule: &RuleDecl,
17096    context: &str,
17097    operand: &str,
17098    known_roots: &BTreeSet<String>,
17099    diagnostics: &mut Vec<Diagnostic>,
17100) {
17101    if let Some(root) = dangling_value_root(operand, known_roots) {
17102        diagnostics.push(Diagnostic { related: Vec::new(),
17103            span: rule.body.span,
17104            message: format!(
17105                "rule `{}` has unknown binding `{root}` in {context} `{operand}`",
17106                rule.name.name
17107            ),
17108            suggestion: Some(
17109                "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
17110                    .to_owned(),
17111            ),
17112        });
17113    }
17114}
17115
17116fn check_field_value_roots(
17117    rule: &RuleDecl,
17118    context: &str,
17119    fields: &[body::FieldAssign],
17120    known_roots: &BTreeSet<String>,
17121    diagnostics: &mut Vec<Diagnostic>,
17122) {
17123    for field in fields {
17124        match &field.value {
17125            body::FieldValue::Expr { source, .. } => {
17126                if let Some(root) = dangling_value_root(source, known_roots) {
17127                    diagnostics.push(Diagnostic { related: Vec::new(),
17128                        span: rule.body.span,
17129                        message: format!(
17130                            "rule `{}` has unknown binding `{root}` in {context} field `{}`",
17131                            rule.name.name, field.name
17132                        ),
17133                        suggestion: Some(
17134                            "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
17135                                .to_owned(),
17136                        ),
17137                    });
17138                }
17139            }
17140            body::FieldValue::Nested { fields, .. } => {
17141                check_field_value_roots(rule, context, fields, known_roots, diagnostics)
17142            }
17143            body::FieldValue::Shorthand => {}
17144        }
17145    }
17146}
17147
17148/// The complete set of value-position binding roots for a rule: `when` bindings
17149/// plus every binding the body introduces, collected from the parsed AST.
17150fn known_roots_for_rule(rule: &RuleDecl) -> BTreeSet<String> {
17151    let mut roots: BTreeSet<String> = binding_types_for_rule(rule).into_keys().collect();
17152    let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
17153    collect_all_binding_names(&body_ast.statements, &mut roots);
17154    roots
17155}
17156
17157fn validate_record_blocks(
17158    rule: &RuleDecl,
17159    semantic: &SemanticContext,
17160    binding_types: &BTreeMap<String, String>,
17161    known_roots: &BTreeSet<String>,
17162    diagnostics: &mut Vec<Diagnostic>,
17163) {
17164    for (schema, from_binding, body) in record_blocks(&rule.body.text) {
17165        for assignment in collect_field_assignments(&body) {
17166            let (field, value) = match assignment {
17167                RecordFieldAssignment::Value { field, value } => (field, value),
17168                RecordFieldAssignment::Shorthand { field } => {
17169                    let value = from_binding
17170                        .as_ref()
17171                        .map(|binding| format!("{binding}.{field}"))
17172                        .unwrap_or_else(|| field.clone());
17173                    (field, value)
17174                }
17175            };
17176            let line = format!("{field} {value}");
17177            validate_record_field(
17178                rule,
17179                &line,
17180                &schema,
17181                semantic,
17182                binding_types,
17183                known_roots,
17184                diagnostics,
17185            );
17186        }
17187    }
17188}
17189
17190fn record_blocks(body: &str) -> Vec<(String, Option<String>, String)> {
17191    let mut blocks = Vec::new();
17192    let lines = body.lines().collect::<Vec<_>>();
17193    let mut index = 0usize;
17194    while index < lines.len() {
17195        let trimmed = lines[index].trim();
17196        let Some((schema, from_binding)) = parse_record_start(trimmed) else {
17197            index += 1;
17198            continue;
17199        };
17200        // Single-line record `record X { f y }`: opens and closes on one line
17201        // (brace_delta 0), so the multi-line loop below never collects its fields,
17202        // leaving them unvalidated. Extract the inner content directly.
17203        if brace_delta(trimmed) == 0 && trimmed.contains('{') {
17204            if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
17205                if close > open {
17206                    blocks.push((
17207                        schema,
17208                        from_binding,
17209                        trimmed[open + 1..close].trim().to_owned(),
17210                    ));
17211                }
17212            }
17213            index += 1;
17214            continue;
17215        }
17216        let mut depth = brace_delta(trimmed);
17217        let mut record_lines = Vec::new();
17218        index += 1;
17219        while index < lines.len() && depth > 0 {
17220            let line = lines[index];
17221            let before = depth;
17222            depth += brace_delta(line);
17223            if !(before == 1 && depth == 0 && line.trim() == "}") {
17224                record_lines.push(line.to_owned());
17225            }
17226            index += 1;
17227        }
17228        blocks.push((schema, from_binding, record_lines.join("\n")));
17229    }
17230    blocks
17231}
17232
17233fn workflow_terminal_blocks(body: &str) -> Vec<(String, String, String)> {
17234    let mut blocks = Vec::new();
17235    let lines = body.lines().collect::<Vec<_>>();
17236    let mut index = 0usize;
17237    while index < lines.len() {
17238        let trimmed = lines[index].trim();
17239        let terminal = trimmed
17240            .strip_prefix("complete ")
17241            .map(|rest| ("complete", rest))
17242            .or_else(|| trimmed.strip_prefix("fail ").map(|rest| ("fail", rest)));
17243        let Some((action, rest)) = terminal else {
17244            index += 1;
17245            continue;
17246        };
17247        let Some(name) = rest.split('{').next().and_then(|header| {
17248            let mut parts = header.split_whitespace();
17249            match (parts.next(), parts.next()) {
17250                (Some(name), None) => Some(name.to_owned()),
17251                _ => None,
17252            }
17253        }) else {
17254            index += 1;
17255            continue;
17256        };
17257        let mut depth = brace_delta(trimmed);
17258        let mut terminal_lines = Vec::new();
17259        if depth == 0 && trimmed.contains('{') {
17260            // Single-line block: `complete <name> { <fields> }` opens and closes
17261            // on this line, so its inner content never reaches the multi-line loop
17262            // below. Capture the content between the braces as the block body.
17263            if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
17264                if close > open {
17265                    let inner = trimmed[open + 1..close].trim();
17266                    if !inner.is_empty() {
17267                        terminal_lines.push(inner.to_owned());
17268                    }
17269                }
17270            }
17271            index += 1;
17272        } else {
17273            index += 1;
17274            while index < lines.len() && depth > 0 {
17275                let line = lines[index];
17276                let before = depth;
17277                depth += brace_delta(line);
17278                if !(before == 1 && depth == 0 && line.trim() == "}") {
17279                    terminal_lines.push(line.to_owned());
17280                }
17281                index += 1;
17282            }
17283        }
17284        blocks.push((action.to_owned(), name, terminal_lines.join("\n")));
17285    }
17286    blocks
17287}
17288
17289#[derive(Clone, Debug, Eq, PartialEq)]
17290enum RecordFieldAssignment {
17291    Value { field: String, value: String },
17292    Shorthand { field: String },
17293}
17294
17295fn collect_field_assignments(body: &str) -> Vec<RecordFieldAssignment> {
17296    // Token-level splitting (R5): structure comes from tokens, never line
17297    // breaks, so a single-line multi-field payload
17298    // (`complete result { first "a" second "b" }`) collects every field —
17299    // the same splitter the kernel and table rows already use.
17300    body::split_field_assignments(body)
17301        .into_iter()
17302        .map(|assignment| match assignment.value {
17303            Some(value) => RecordFieldAssignment::Value {
17304                field: assignment.name,
17305                value,
17306            },
17307            None => RecordFieldAssignment::Shorthand {
17308                field: assignment.name,
17309            },
17310        })
17311        .collect()
17312}
17313
17314fn expression_path(expr: &str) -> Option<(String, Vec<String>)> {
17315    let mut paths = dotted_paths(expr);
17316    if paths.len() != 1 {
17317        return None;
17318    }
17319    Some(paths.remove(0))
17320}
17321
17322fn validate_literal_assignment(
17323    rule: &RuleDecl,
17324    record_schema: &str,
17325    field: &str,
17326    field_ty: &TypeSyntax,
17327    expr: &str,
17328    semantic: &SemanticContext,
17329    diagnostics: &mut Vec<Diagnostic>,
17330) {
17331    let Some(literal) = parse_literal_expr(expr) else {
17332        return;
17333    };
17334
17335    match field_ty {
17336        TypeSyntax::Primitive { name, .. } => {
17337            validate_primitive_literal(rule, record_schema, field, name, &literal, diagnostics)
17338        }
17339        TypeSyntax::LiteralString { value, .. } => {
17340            if literal != LiteralExpr::String(value.as_str()) {
17341                diagnostics.push(Diagnostic {
17342                    related: Vec::new(),
17343                    span: rule.body.span,
17344                    message: format!(
17345                        "field `{record_schema}.{field}` expects literal string `{value}`"
17346                    ),
17347                    suggestion: Some(format!("record `{field} {value:?}`")),
17348                });
17349            }
17350        }
17351        TypeSyntax::Ref { name } => {
17352            validate_enum_literal(
17353                rule,
17354                record_schema,
17355                field,
17356                &name.name,
17357                &literal,
17358                semantic,
17359                diagnostics,
17360            );
17361        }
17362        TypeSyntax::Union { variants, .. } => {
17363            validate_union_literal(rule, record_schema, field, variants, &literal, diagnostics);
17364        }
17365        TypeSyntax::AgentRef { agents, .. } => {
17366            validate_agent_ref_literal(rule, record_schema, field, agents, &literal, diagnostics);
17367        }
17368        TypeSyntax::Optional { inner, .. } => {
17369            if literal != LiteralExpr::Null {
17370                validate_literal_assignment(
17371                    rule,
17372                    record_schema,
17373                    field,
17374                    inner,
17375                    expr,
17376                    semantic,
17377                    diagnostics,
17378                );
17379            }
17380        }
17381        TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17382    }
17383}
17384
17385#[allow(clippy::too_many_arguments)]
17386fn validate_expected_assignment(
17387    rule: &RuleDecl,
17388    record_schema: &str,
17389    field: &str,
17390    field_ty: &TypeSyntax,
17391    expr: &str,
17392    semantic: &SemanticContext,
17393    binding_types: &BTreeMap<String, String>,
17394    diagnostics: &mut Vec<Diagnostic>,
17395) {
17396    if !(expr.trim_start().starts_with('{') || expr.trim_start().starts_with('[')) {
17397        return;
17398    }
17399    validate_expr_source_against_type(
17400        rule,
17401        record_schema,
17402        field,
17403        field_ty,
17404        expr,
17405        semantic,
17406        &ExprScope::from_bindings(binding_types),
17407        diagnostics,
17408    );
17409}
17410
17411#[allow(clippy::too_many_arguments)]
17412fn validate_expr_source_against_type(
17413    rule: &RuleDecl,
17414    record_schema: &str,
17415    field: &str,
17416    expected_ty: &TypeSyntax,
17417    expr: &str,
17418    semantic: &SemanticContext,
17419    scope: &ExprScope,
17420    diagnostics: &mut Vec<Diagnostic>,
17421) {
17422    match expected_ty {
17423        TypeSyntax::Map { inner, .. } => {
17424            let parsed = match parse_expression(expr) {
17425                Ok(Expr::Object(fields)) => fields,
17426                Ok(_) => {
17427                    diagnostics.push(Diagnostic {
17428                        related: Vec::new(),
17429                        span: rule.body.span,
17430                        message: format!("field `{record_schema}.{field}` expects a map literal"),
17431                        suggestion: Some(format!("record `{field} {{ key value }}`")),
17432                    });
17433                    return;
17434                }
17435                Err(message) => {
17436                    diagnostics.push(Diagnostic {
17437                        related: Vec::new(),
17438                        span: rule.body.span,
17439                        message: format!(
17440                            "field `{record_schema}.{field}` expects a map literal: {message}"
17441                        ),
17442                        suggestion: Some(format!("record `{field} {{ key value }}`")),
17443                    });
17444                    return;
17445                }
17446            };
17447            for map_field in &parsed {
17448                validate_expr_against_type(
17449                    rule,
17450                    record_schema,
17451                    field,
17452                    inner,
17453                    &map_field.value,
17454                    semantic,
17455                    scope,
17456                    diagnostics,
17457                );
17458            }
17459        }
17460        TypeSyntax::Array { inner, .. } => match parse_expression(expr) {
17461            Ok(Expr::Array(items)) => {
17462                for item in items {
17463                    validate_expr_against_type(
17464                        rule,
17465                        record_schema,
17466                        field,
17467                        inner,
17468                        &item,
17469                        semantic,
17470                        scope,
17471                        diagnostics,
17472                    );
17473                }
17474            }
17475            Ok(expr) => validate_inferred_assignment_type(
17476                rule,
17477                record_schema,
17478                field,
17479                expected_ty,
17480                &expr,
17481                semantic,
17482                scope,
17483                diagnostics,
17484            ),
17485            Err(message) => {
17486                push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
17487            }
17488        },
17489        TypeSyntax::Optional { inner, .. } => {
17490            if expr.trim() != "null" {
17491                validate_expr_source_against_type(
17492                    rule,
17493                    record_schema,
17494                    field,
17495                    inner,
17496                    expr,
17497                    semantic,
17498                    scope,
17499                    diagnostics,
17500                );
17501            }
17502        }
17503        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
17504            let parsed = match parse_expression(expr) {
17505                Ok(Expr::Object(fields)) => fields,
17506                Ok(expr) => {
17507                    validate_inferred_assignment_type(
17508                        rule,
17509                        record_schema,
17510                        field,
17511                        expected_ty,
17512                        &expr,
17513                        semantic,
17514                        scope,
17515                        diagnostics,
17516                    );
17517                    return;
17518                }
17519                Err(message) => {
17520                    push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics);
17521                    return;
17522                }
17523            };
17524            validate_object_literal_fields(
17525                rule,
17526                record_schema,
17527                field,
17528                &name.name,
17529                &parsed,
17530                semantic,
17531                scope,
17532                diagnostics,
17533            );
17534        }
17535        _ => match parse_expression(expr) {
17536            Ok(expr) => validate_inferred_assignment_type(
17537                rule,
17538                record_schema,
17539                field,
17540                expected_ty,
17541                &expr,
17542                semantic,
17543                scope,
17544                diagnostics,
17545            ),
17546            Err(message) => {
17547                push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
17548            }
17549        },
17550    }
17551}
17552
17553#[allow(clippy::too_many_arguments)]
17554fn validate_expr_against_type(
17555    rule: &RuleDecl,
17556    record_schema: &str,
17557    field: &str,
17558    expected_ty: &TypeSyntax,
17559    expr: &Expr,
17560    semantic: &SemanticContext,
17561    scope: &ExprScope,
17562    diagnostics: &mut Vec<Diagnostic>,
17563) {
17564    match expr {
17565        Expr::Array(items) if matches!(expected_ty, TypeSyntax::Array { .. }) => {
17566            if let TypeSyntax::Array { inner, .. } = expected_ty {
17567                for item in items {
17568                    validate_expr_against_type(
17569                        rule,
17570                        record_schema,
17571                        field,
17572                        inner,
17573                        item,
17574                        semantic,
17575                        scope,
17576                        diagnostics,
17577                    );
17578                }
17579            }
17580        }
17581        Expr::Object(fields) => match expected_ty {
17582            TypeSyntax::Map { inner, .. } => {
17583                for field in fields {
17584                    validate_expr_against_type(
17585                        rule,
17586                        record_schema,
17587                        field.key.as_str(),
17588                        inner,
17589                        &field.value,
17590                        semantic,
17591                        scope,
17592                        diagnostics,
17593                    );
17594                }
17595            }
17596            TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
17597                validate_object_literal_fields(
17598                    rule,
17599                    record_schema,
17600                    field,
17601                    &name.name,
17602                    fields,
17603                    semantic,
17604                    scope,
17605                    diagnostics,
17606                );
17607            }
17608            _ => validate_inferred_assignment_type(
17609                rule,
17610                record_schema,
17611                field,
17612                expected_ty,
17613                expr,
17614                semantic,
17615                scope,
17616                diagnostics,
17617            ),
17618        },
17619        _ => validate_inferred_assignment_type(
17620            rule,
17621            record_schema,
17622            field,
17623            expected_ty,
17624            expr,
17625            semantic,
17626            scope,
17627            diagnostics,
17628        ),
17629    }
17630}
17631
17632fn push_invalid_assignment_expr(
17633    rule: &RuleDecl,
17634    record_schema: &str,
17635    field: &str,
17636    message: String,
17637    diagnostics: &mut Vec<Diagnostic>,
17638) {
17639    diagnostics.push(Diagnostic {
17640        related: Vec::new(),
17641        span: rule.body.span,
17642        message: format!(
17643            "rule `{}` has invalid expression for field `{record_schema}.{field}`: {message}",
17644            rule.name.name
17645        ),
17646        suggestion: Some(
17647            "use array literals or expected-schema object literals for collection fields"
17648                .to_owned(),
17649        ),
17650    });
17651}
17652
17653#[allow(clippy::too_many_arguments)]
17654fn validate_object_literal_fields(
17655    rule: &RuleDecl,
17656    record_schema: &str,
17657    field: &str,
17658    object_schema: &str,
17659    object_fields: &[ExprObjectField],
17660    semantic: &SemanticContext,
17661    scope: &ExprScope,
17662    diagnostics: &mut Vec<Diagnostic>,
17663) {
17664    let Some(schema_fields) = semantic.schemas.classes.get(object_schema) else {
17665        return;
17666    };
17667    let mut seen = BTreeSet::new();
17668    for object_field in object_fields {
17669        if !seen.insert(object_field.key.clone()) {
17670            diagnostics.push(Diagnostic {
17671                related: Vec::new(),
17672                span: rule.body.span,
17673                message: format!(
17674                    "field `{record_schema}.{field}` repeats object field `{}`",
17675                    object_field.key
17676                ),
17677                suggestion: Some("remove the duplicate object field".to_owned()),
17678            });
17679            continue;
17680        }
17681        let Some(field_ty) = schema_fields.get(&object_field.key) else {
17682            diagnostics.push(Diagnostic {
17683                related: Vec::new(),
17684                span: rule.body.span,
17685                message: format!(
17686                    "class `{object_schema}` has no field `{}`",
17687                    object_field.key
17688                ),
17689                suggestion: Some(format!(
17690                    "add `{}` to `class {object_schema}` or use an existing field",
17691                    object_field.key
17692                )),
17693            });
17694            continue;
17695        };
17696        validate_expr_against_type(
17697            rule,
17698            object_schema,
17699            &object_field.key,
17700            field_ty,
17701            &object_field.value,
17702            semantic,
17703            scope,
17704            diagnostics,
17705        );
17706    }
17707    for (required, ty) in schema_fields {
17708        if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
17709            continue;
17710        }
17711        diagnostics.push(Diagnostic { related: Vec::new(),
17712            span: rule.body.span,
17713            message: format!(
17714                "field `{record_schema}.{field}` is missing required object field `{object_schema}.{required}`"
17715            ),
17716            suggestion: Some(format!("add `{required}` to the `{field}` object literal")),
17717        });
17718    }
17719}
17720
17721#[allow(clippy::too_many_arguments)]
17722fn validate_inferred_assignment_type(
17723    rule: &RuleDecl,
17724    record_schema: &str,
17725    field: &str,
17726    expected_ty: &TypeSyntax,
17727    expr: &Expr,
17728    semantic: &SemanticContext,
17729    scope: &ExprScope,
17730    diagnostics: &mut Vec<Diagnostic>,
17731) {
17732    let literal = expr_literal_as_literal_expr(expr);
17733    if let Some(literal) = literal {
17734        validate_literal_against_type(
17735            rule,
17736            record_schema,
17737            field,
17738            expected_ty,
17739            &literal,
17740            semantic,
17741            diagnostics,
17742        );
17743        return;
17744    }
17745
17746    let context = ExprValidationContext::rule(rule);
17747    let mut local_diagnostics = Vec::new();
17748    let actual_ty = infer_expr_type(expr, semantic, scope, &context, &mut local_diagnostics);
17749    diagnostics.extend(local_diagnostics);
17750    let expected_expr_ty = expr_type_from_type_syntax(expected_ty, semantic);
17751    if !types_comparable(&actual_ty, &expected_expr_ty) {
17752        diagnostics.push(Diagnostic {
17753            related: Vec::new(),
17754            span: rule.body.span,
17755            message: format!(
17756                "field `{record_schema}.{field}` receives incompatible expression type"
17757            ),
17758            suggestion: Some(format!(
17759                "record a value compatible with `{}`",
17760                expected_ty.to_source()
17761            )),
17762        });
17763    }
17764}
17765
17766fn validate_literal_against_type(
17767    rule: &RuleDecl,
17768    record_schema: &str,
17769    field: &str,
17770    field_ty: &TypeSyntax,
17771    literal: &LiteralExpr<'_>,
17772    semantic: &SemanticContext,
17773    diagnostics: &mut Vec<Diagnostic>,
17774) {
17775    match field_ty {
17776        TypeSyntax::Primitive { name, .. } => {
17777            validate_primitive_literal(rule, record_schema, field, name, literal, diagnostics)
17778        }
17779        TypeSyntax::LiteralString { value, .. } => {
17780            if literal != &LiteralExpr::String(value.as_str()) {
17781                diagnostics.push(Diagnostic {
17782                    related: Vec::new(),
17783                    span: rule.body.span,
17784                    message: format!(
17785                        "field `{record_schema}.{field}` expects literal string `{value}`"
17786                    ),
17787                    suggestion: Some(format!("record `{field} {value:?}`")),
17788                });
17789            }
17790        }
17791        TypeSyntax::Ref { name } => {
17792            validate_enum_literal(
17793                rule,
17794                record_schema,
17795                field,
17796                &name.name,
17797                literal,
17798                semantic,
17799                diagnostics,
17800            );
17801        }
17802        TypeSyntax::Union { variants, .. } => {
17803            validate_union_literal(rule, record_schema, field, variants, literal, diagnostics);
17804        }
17805        TypeSyntax::AgentRef { agents, .. } => {
17806            validate_agent_ref_literal(rule, record_schema, field, agents, literal, diagnostics);
17807        }
17808        TypeSyntax::Optional { inner, .. } => {
17809            if literal != &LiteralExpr::Null {
17810                validate_literal_against_type(
17811                    rule,
17812                    record_schema,
17813                    field,
17814                    inner,
17815                    literal,
17816                    semantic,
17817                    diagnostics,
17818                );
17819            }
17820        }
17821        TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17822    }
17823}
17824
17825fn expr_literal_as_literal_expr(expr: &Expr) -> Option<LiteralExpr<'_>> {
17826    match expr {
17827        Expr::Literal(ExprLiteral::String(value)) => Some(LiteralExpr::String(value)),
17828        Expr::Literal(ExprLiteral::Number(value)) => Some(LiteralExpr::Number(value)),
17829        Expr::Literal(ExprLiteral::Bool(_)) => Some(LiteralExpr::Bool),
17830        Expr::Literal(ExprLiteral::Null) => Some(LiteralExpr::Null),
17831        Expr::Literal(ExprLiteral::Ident(value)) => Some(LiteralExpr::Ident(value)),
17832        _ => None,
17833    }
17834}
17835
17836fn validate_agent_ref_literal(
17837    rule: &RuleDecl,
17838    record_schema: &str,
17839    field: &str,
17840    agents: &[Ident],
17841    literal: &LiteralExpr<'_>,
17842    diagnostics: &mut Vec<Diagnostic>,
17843) {
17844    let allowed = agents
17845        .iter()
17846        .map(|agent| agent.name.as_str())
17847        .collect::<Vec<_>>();
17848    if let LiteralExpr::String(value) = literal {
17849        diagnostics.push(Diagnostic {
17850            related: Vec::new(),
17851            span: rule.body.span,
17852            message: format!(
17853                "field `{record_schema}.{field}` expects an AgentRef value, not string `{value}`"
17854            ),
17855            suggestion: Some(format!(
17856                "use an unquoted declared agent name: {}",
17857                allowed.join(", ")
17858            )),
17859        });
17860        return;
17861    }
17862    let LiteralExpr::Ident(value) = literal else {
17863        diagnostics.push(Diagnostic {
17864            related: Vec::new(),
17865            span: rule.body.span,
17866            message: format!("field `{record_schema}.{field}` expects an AgentRef value"),
17867            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17868        });
17869        return;
17870    };
17871    if !allowed.contains(value) {
17872        diagnostics.push(Diagnostic {
17873            related: Vec::new(),
17874            span: rule.body.span,
17875            message: format!("field `{record_schema}.{field}` cannot reference agent `{value}`"),
17876            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17877        });
17878    }
17879}
17880
17881fn parse_literal_expr(expr: &str) -> Option<LiteralExpr<'_>> {
17882    let expr = expr.trim().trim_end_matches(',');
17883    if let Some(value) = expr
17884        .strip_prefix('"')
17885        .and_then(|rest| rest.strip_suffix('"'))
17886    {
17887        return Some(LiteralExpr::String(value));
17888    }
17889    if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
17890        && expr.chars().any(|ch| ch.is_ascii_digit())
17891    {
17892        return Some(LiteralExpr::Number(expr));
17893    }
17894    match expr {
17895        "true" => Some(LiteralExpr::Bool),
17896        "false" => Some(LiteralExpr::Bool),
17897        "null" => Some(LiteralExpr::Null),
17898        value if value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') => {
17899            Some(LiteralExpr::Ident(value))
17900        }
17901        _ => None,
17902    }
17903}
17904
17905struct ExprParser<'a> {
17906    source: &'a str,
17907    tokens: Vec<ExprToken>,
17908    pos: usize,
17909    depth: usize,
17910}
17911
17912/// Recursion-depth ceiling for the guard-expression grammar. Every level of
17913/// `(`/`[`/`{` nesting and every prefix `!`/`not` descends through
17914/// `parse_unary`, so bounding it there stops a deeply-nested expression in a
17915/// workflow file from overflowing the stack and aborting the process — a
17916/// normal `Err` diagnostic is returned instead. Far above any real expression.
17917const MAX_EXPR_DEPTH: usize = 256;
17918
17919#[derive(Clone, Debug, Eq, PartialEq)]
17920struct ExprToken {
17921    kind: ExprTokenKind,
17922}
17923
17924#[derive(Clone, Debug, Eq, PartialEq)]
17925enum ExprTokenKind {
17926    Ident(String),
17927    String(String),
17928    Number(String),
17929    Symbol(char),
17930    Op(&'static str),
17931}
17932
17933impl<'a> ExprParser<'a> {
17934    fn new(source: &'a str) -> Self {
17935        Self {
17936            source,
17937            tokens: lex_expr(source),
17938            pos: 0,
17939            depth: 0,
17940        }
17941    }
17942
17943    fn parse(mut self) -> Result<Expr, String> {
17944        let expr = self.parse_or()?;
17945        if self.peek().is_some() {
17946            return Err(format!(
17947                "unexpected token in expression `{}`",
17948                self.source.trim()
17949            ));
17950        }
17951        Ok(expr)
17952    }
17953
17954    fn parse_or(&mut self) -> Result<Expr, String> {
17955        let mut expr = self.parse_and()?;
17956        while self.consume_op("||") || self.consume_ident("or") {
17957            let right = self.parse_and()?;
17958            expr = Expr::Binary {
17959                op: BinaryOp::Or,
17960                left: Box::new(expr),
17961                right: Box::new(right),
17962            };
17963        }
17964        Ok(expr)
17965    }
17966
17967    fn parse_and(&mut self) -> Result<Expr, String> {
17968        let mut expr = self.parse_comparison()?;
17969        while self.consume_op("&&") || self.consume_ident("and") {
17970            let right = self.parse_comparison()?;
17971            expr = Expr::Binary {
17972                op: BinaryOp::And,
17973                left: Box::new(expr),
17974                right: Box::new(right),
17975            };
17976        }
17977        Ok(expr)
17978    }
17979
17980    fn parse_comparison(&mut self) -> Result<Expr, String> {
17981        let mut expr = self.parse_additive()?;
17982        loop {
17983            let op = if self.consume_op("==") {
17984                Some(BinaryOp::Eq)
17985            } else if self.consume_op("!=") {
17986                Some(BinaryOp::Ne)
17987            } else if self.consume_op("<=") {
17988                Some(BinaryOp::Le)
17989            } else if self.consume_op(">=") {
17990                Some(BinaryOp::Ge)
17991            } else if self.consume_symbol('<') {
17992                Some(BinaryOp::Lt)
17993            } else if self.consume_symbol('>') {
17994                Some(BinaryOp::Gt)
17995            } else if self.consume_ident("not") {
17996                if !self.consume_ident("in") {
17997                    return Err("expected `in` after `not`".to_owned());
17998                }
17999                Some(BinaryOp::NotIn)
18000            } else if self.consume_ident("in") {
18001                Some(BinaryOp::In)
18002            } else {
18003                None
18004            };
18005            let Some(op) = op else {
18006                return Ok(expr);
18007            };
18008            let right = self.parse_additive()?;
18009            expr = Expr::Binary {
18010                op,
18011                left: Box::new(expr),
18012                right: Box::new(right),
18013            };
18014        }
18015    }
18016
18017    fn parse_additive(&mut self) -> Result<Expr, String> {
18018        let mut expr = self.parse_multiplicative()?;
18019        loop {
18020            let op = if self.consume_symbol('+') {
18021                Some(BinaryOp::Add)
18022            } else if self.consume_symbol('-') {
18023                Some(BinaryOp::Sub)
18024            } else {
18025                None
18026            };
18027            let Some(op) = op else {
18028                return Ok(expr);
18029            };
18030            let right = self.parse_multiplicative()?;
18031            expr = Expr::Binary {
18032                op,
18033                left: Box::new(expr),
18034                right: Box::new(right),
18035            };
18036        }
18037    }
18038
18039    fn parse_multiplicative(&mut self) -> Result<Expr, String> {
18040        let mut expr = self.parse_unary()?;
18041        loop {
18042            let op = if self.consume_symbol('*') {
18043                Some(BinaryOp::Mul)
18044            } else if self.consume_symbol('/') {
18045                Some(BinaryOp::Div)
18046            } else {
18047                None
18048            };
18049            let Some(op) = op else {
18050                return Ok(expr);
18051            };
18052            let right = self.parse_unary()?;
18053            expr = Expr::Binary {
18054                op,
18055                left: Box::new(expr),
18056                right: Box::new(right),
18057            };
18058        }
18059    }
18060
18061    fn parse_unary(&mut self) -> Result<Expr, String> {
18062        // Depth guard (every nesting level descends through here): return a
18063        // diagnostic rather than recurse the native stack to a crash.
18064        self.depth += 1;
18065        if self.depth > MAX_EXPR_DEPTH {
18066            self.depth -= 1;
18067            return Err(format!(
18068                "expression in `{}` is nested too deeply (limit {MAX_EXPR_DEPTH})",
18069                self.source.trim()
18070            ));
18071        }
18072        let result = self.parse_unary_inner();
18073        self.depth -= 1;
18074        result
18075    }
18076
18077    fn parse_unary_inner(&mut self) -> Result<Expr, String> {
18078        if self.consume_symbol('!') {
18079            return Ok(Expr::Unary {
18080                op: UnaryOp::Not,
18081                expr: Box::new(self.parse_unary()?),
18082            });
18083        }
18084        // Prefix `not` binds looser than comparisons so `not x in y`
18085        // reads as `not (x in y)`; binary `not in` is handled by
18086        // parse_comparison before this prefix form is reached.
18087        if self.consume_ident("not") {
18088            return Ok(Expr::Unary {
18089                op: UnaryOp::Not,
18090                expr: Box::new(self.parse_comparison()?),
18091            });
18092        }
18093        self.parse_postfix()
18094    }
18095
18096    fn parse_postfix(&mut self) -> Result<Expr, String> {
18097        let mut expr = self.parse_primary()?;
18098        loop {
18099            if self.consume_symbol('[') {
18100                let key = self.parse_or()?;
18101                self.expect_symbol(']')?;
18102                expr = Expr::Index {
18103                    target: Box::new(expr),
18104                    key: Box::new(key),
18105                };
18106                continue;
18107            }
18108            return Ok(expr);
18109        }
18110    }
18111
18112    fn parse_primary(&mut self) -> Result<Expr, String> {
18113        if self.consume_symbol('(') {
18114            let expr = self.parse_or()?;
18115            self.expect_symbol(')')?;
18116            return Ok(expr);
18117        }
18118        if self.consume_symbol('[') {
18119            let mut items = Vec::new();
18120            if self.consume_symbol(']') {
18121                return Ok(Expr::Array(items));
18122            }
18123            loop {
18124                items.push(self.parse_or()?);
18125                if self.consume_symbol(']') {
18126                    break;
18127                }
18128                self.expect_symbol(',')?;
18129            }
18130            return Ok(Expr::Array(items));
18131        }
18132        if self.consume_symbol('{') {
18133            let mut fields = Vec::new();
18134            if self.consume_symbol('}') {
18135                return Ok(Expr::Object(fields));
18136            }
18137            loop {
18138                let key = match self.advance().map(|token| token.kind.clone()) {
18139                    Some(ExprTokenKind::Ident(value) | ExprTokenKind::String(value)) => value,
18140                    _ => return Err("expected object field name".to_owned()),
18141                };
18142                let value = self.parse_or()?;
18143                fields.push(ExprObjectField { key, value });
18144                if self.consume_symbol('}') {
18145                    break;
18146                }
18147                let _ = self.consume_symbol(',');
18148            }
18149            return Ok(Expr::Object(fields));
18150        }
18151        match self.advance().map(|token| token.kind.clone()) {
18152            Some(ExprTokenKind::String(value)) => Ok(Expr::Literal(ExprLiteral::String(value))),
18153            Some(ExprTokenKind::Number(value)) => Ok(Expr::Literal(ExprLiteral::Number(value))),
18154            Some(ExprTokenKind::Ident(value)) if value == "true" => {
18155                Ok(Expr::Literal(ExprLiteral::Bool(true)))
18156            }
18157            Some(ExprTokenKind::Ident(value)) if value == "false" => {
18158                Ok(Expr::Literal(ExprLiteral::Bool(false)))
18159            }
18160            Some(ExprTokenKind::Ident(value)) if value == "null" => {
18161                Ok(Expr::Literal(ExprLiteral::Null))
18162            }
18163            Some(ExprTokenKind::Ident(value)) if value == "exists" && !self.at_symbol('(') => {
18164                let arg = match self.parse_postfix()? {
18165                    Expr::Literal(ExprLiteral::Ident(path)) => Expr::Path(vec![path]),
18166                    expr => expr,
18167                };
18168                Ok(Expr::Call {
18169                    name: value,
18170                    args: vec![arg],
18171                })
18172            }
18173            Some(ExprTokenKind::Ident(value))
18174                if matches!(value.as_str(), "count" | "exists" | "empty")
18175                    && self.at_symbol('(') =>
18176            {
18177                self.expect_symbol('(')?;
18178                if let Some(query) = self.try_parse_query()? {
18179                    self.expect_symbol(')')?;
18180                    Ok(Expr::Call {
18181                        name: value,
18182                        args: vec![query],
18183                    })
18184                } else {
18185                    let mut args = Vec::new();
18186                    if self.consume_symbol(')') {
18187                        return Ok(Expr::Call { name: value, args });
18188                    }
18189                    loop {
18190                        args.push(self.parse_or()?);
18191                        if self.consume_symbol(')') {
18192                            break;
18193                        }
18194                        self.expect_symbol(',')?;
18195                    }
18196                    Ok(Expr::Call { name: value, args })
18197                }
18198            }
18199            Some(ExprTokenKind::Ident(value)) => {
18200                let mut path = vec![value];
18201                while self.consume_symbol('.') {
18202                    let Some(ExprTokenKind::Ident(field)) =
18203                        self.advance().map(|token| token.kind.clone())
18204                    else {
18205                        return Err("expected field name after `.`".to_owned());
18206                    };
18207                    path.push(field);
18208                }
18209                if path.len() == 1 {
18210                    Ok(Expr::Literal(ExprLiteral::Ident(path.remove(0))))
18211                } else {
18212                    Ok(Expr::Path(path))
18213                }
18214            }
18215            _ => Err(format!("expected expression in `{}`", self.source.trim())),
18216        }
18217    }
18218
18219    fn try_parse_query(&mut self) -> Result<Option<Expr>, String> {
18220        let checkpoint = self.pos;
18221        let kind = if self.consume_ident("effect") {
18222            QueryKind::Effect
18223        } else if matches!(
18224            self.peek().map(|token| &token.kind),
18225            Some(ExprTokenKind::Ident(value)) if value.chars().next().is_some_and(char::is_uppercase)
18226        ) {
18227            QueryKind::Fact
18228        } else {
18229            return Ok(None);
18230        };
18231        let mut head = Vec::new();
18232        while let Some(token) = self.peek() {
18233            if self.at_symbol(')') || self.at_ident("where") {
18234                break;
18235            }
18236            head.push(self.token_text(token));
18237            self.pos += 1;
18238        }
18239        if head.is_empty() {
18240            self.pos = checkpoint;
18241            return Ok(None);
18242        }
18243        let guard = if self.consume_ident("where") {
18244            Some(Box::new(self.parse_or()?))
18245        } else {
18246            None
18247        };
18248        Ok(Some(Expr::Query {
18249            kind,
18250            head: join_query_head(&head),
18251            guard,
18252        }))
18253    }
18254
18255    fn token_text(&self, token: &ExprToken) -> String {
18256        match &token.kind {
18257            ExprTokenKind::Ident(value) | ExprTokenKind::Number(value) => value.clone(),
18258            ExprTokenKind::String(value) => format!("{value:?}"),
18259            ExprTokenKind::Symbol(value) => value.to_string(),
18260            ExprTokenKind::Op(value) => value.to_string(),
18261        }
18262    }
18263
18264    fn peek(&self) -> Option<&ExprToken> {
18265        self.tokens.get(self.pos)
18266    }
18267
18268    fn advance(&mut self) -> Option<&ExprToken> {
18269        let token = self.tokens.get(self.pos)?;
18270        self.pos += 1;
18271        Some(token)
18272    }
18273
18274    fn at_symbol(&self, symbol: char) -> bool {
18275        matches!(
18276            self.peek().map(|token| &token.kind),
18277            Some(ExprTokenKind::Symbol(value)) if *value == symbol
18278        )
18279    }
18280
18281    fn consume_symbol(&mut self, symbol: char) -> bool {
18282        if self.at_symbol(symbol) {
18283            self.pos += 1;
18284            true
18285        } else {
18286            false
18287        }
18288    }
18289
18290    fn expect_symbol(&mut self, symbol: char) -> Result<(), String> {
18291        if self.consume_symbol(symbol) {
18292            Ok(())
18293        } else {
18294            Err(format!("expected `{symbol}`"))
18295        }
18296    }
18297
18298    fn at_ident(&self, ident: &str) -> bool {
18299        matches!(
18300            self.peek().map(|token| &token.kind),
18301            Some(ExprTokenKind::Ident(value)) if value == ident
18302        )
18303    }
18304
18305    fn consume_ident(&mut self, ident: &str) -> bool {
18306        if self.at_ident(ident) {
18307            self.pos += 1;
18308            true
18309        } else {
18310            false
18311        }
18312    }
18313
18314    fn consume_op(&mut self, op: &'static str) -> bool {
18315        if matches!(
18316            self.peek().map(|token| &token.kind),
18317            Some(ExprTokenKind::Op(value)) if *value == op
18318        ) {
18319            self.pos += 1;
18320            true
18321        } else {
18322            false
18323        }
18324    }
18325}
18326
18327fn join_query_head(tokens: &[String]) -> String {
18328    let mut head = String::new();
18329    for token in tokens {
18330        if token == "." {
18331            head.push('.');
18332        } else if head.ends_with('.') || head.is_empty() {
18333            head.push_str(token);
18334        } else {
18335            head.push(' ');
18336            head.push_str(token);
18337        }
18338    }
18339    head
18340}
18341
18342fn lex_expr(source: &str) -> Vec<ExprToken> {
18343    let bytes = source.as_bytes();
18344    let mut tokens = Vec::new();
18345    let mut index = 0usize;
18346    while index < bytes.len() {
18347        let byte = bytes[index];
18348        if byte.is_ascii_whitespace() {
18349            index += 1;
18350            continue;
18351        }
18352        if is_ident_start(byte) {
18353            let start = index;
18354            index += 1;
18355            while index < bytes.len() && is_ident_continue(bytes[index]) {
18356                index += 1;
18357            }
18358            tokens.push(ExprToken {
18359                kind: ExprTokenKind::Ident(source[start..index].to_owned()),
18360            });
18361            continue;
18362        }
18363        if byte.is_ascii_digit() {
18364            let start = index;
18365            index += 1;
18366            while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
18367                index += 1;
18368            }
18369            tokens.push(ExprToken {
18370                kind: ExprTokenKind::Number(source[start..index].to_owned()),
18371            });
18372            continue;
18373        }
18374        if byte == b'"' {
18375            let start = index + 1;
18376            index += 1;
18377            while index < bytes.len() && bytes[index] != b'"' {
18378                index += 1;
18379            }
18380            let value = source[start..index.min(bytes.len())].to_owned();
18381            index = (index + 1).min(bytes.len());
18382            tokens.push(ExprToken {
18383                kind: ExprTokenKind::String(value),
18384            });
18385            continue;
18386        }
18387        let rest = &source[index..];
18388        if rest.starts_with("&&") {
18389            tokens.push(ExprToken {
18390                kind: ExprTokenKind::Op("&&"),
18391            });
18392            index += 2;
18393        } else if rest.starts_with("||") {
18394            tokens.push(ExprToken {
18395                kind: ExprTokenKind::Op("||"),
18396            });
18397            index += 2;
18398        } else if rest.starts_with("==") {
18399            tokens.push(ExprToken {
18400                kind: ExprTokenKind::Op("=="),
18401            });
18402            index += 2;
18403        } else if rest.starts_with("!=") {
18404            tokens.push(ExprToken {
18405                kind: ExprTokenKind::Op("!="),
18406            });
18407            index += 2;
18408        } else if rest.starts_with("<=") {
18409            tokens.push(ExprToken {
18410                kind: ExprTokenKind::Op("<="),
18411            });
18412            index += 2;
18413        } else if rest.starts_with(">=") {
18414            tokens.push(ExprToken {
18415                kind: ExprTokenKind::Op(">="),
18416            });
18417            index += 2;
18418        } else {
18419            tokens.push(ExprToken {
18420                kind: ExprTokenKind::Symbol(byte as char),
18421            });
18422            index += 1;
18423        }
18424    }
18425    tokens
18426}
18427
18428fn validate_primitive_literal(
18429    rule: &RuleDecl,
18430    record_schema: &str,
18431    field: &str,
18432    primitive: &str,
18433    literal: &LiteralExpr<'_>,
18434    diagnostics: &mut Vec<Diagnostic>,
18435) {
18436    let valid = matches!(
18437        (primitive, literal),
18438        ("string", LiteralExpr::String(_))
18439            | ("string", LiteralExpr::Ident(_))
18440            | ("int", LiteralExpr::Number(_))
18441            | ("float", LiteralExpr::Number(_))
18442            | ("bool", LiteralExpr::Bool)
18443            | ("null", LiteralExpr::Null)
18444            | ("duration", LiteralExpr::String(_))
18445            | ("time", LiteralExpr::String(_))
18446    );
18447    if !valid {
18448        diagnostics.push(Diagnostic {
18449            related: Vec::new(),
18450            span: rule.body.span,
18451            message: format!("field `{record_schema}.{field}` expects `{primitive}`"),
18452            suggestion: Some(format!("record a value compatible with `{primitive}`")),
18453        });
18454        return;
18455    }
18456    match (primitive, literal) {
18457        ("duration", LiteralExpr::String(value)) if parse_duration_seconds(value).is_none() => {
18458            diagnostics.push(Diagnostic {
18459                related: Vec::new(),
18460                span: rule.body.span,
18461                message: format!("field `{record_schema}.{field}` has invalid duration literal"),
18462                suggestion: Some("use an ISO-8601 duration such as `\"PT30M\"`".to_owned()),
18463            });
18464        }
18465        ("time", LiteralExpr::String(value)) if parse_time_epoch_seconds(value).is_none() => {
18466            diagnostics.push(Diagnostic {
18467                related: Vec::new(),
18468                span: rule.body.span,
18469                message: format!("field `{record_schema}.{field}` has invalid time literal"),
18470                suggestion: Some(
18471                    "use an RFC3339 timestamp such as `\"2026-05-29T10:00:00Z\"`".to_owned(),
18472                ),
18473            });
18474        }
18475        _ => {}
18476    }
18477}
18478
18479fn validate_enum_literal(
18480    rule: &RuleDecl,
18481    record_schema: &str,
18482    field: &str,
18483    schema: &str,
18484    literal: &LiteralExpr<'_>,
18485    semantic: &SemanticContext,
18486    diagnostics: &mut Vec<Diagnostic>,
18487) {
18488    let Some(variants) = semantic.schemas.enums.get(schema) else {
18489        return;
18490    };
18491    let LiteralExpr::Ident(variant) = literal else {
18492        diagnostics.push(Diagnostic {
18493            related: Vec::new(),
18494            span: rule.body.span,
18495            message: format!("field `{record_schema}.{field}` expects enum `{schema}`"),
18496            suggestion: Some(format!(
18497                "use one of: {}",
18498                variants.iter().cloned().collect::<Vec<_>>().join(", ")
18499            )),
18500        });
18501        return;
18502    };
18503    if !variants.contains(*variant) {
18504        diagnostics.push(Diagnostic {
18505            related: Vec::new(),
18506            span: rule.body.span,
18507            message: format!("enum `{schema}` has no variant `{variant}`"),
18508            suggestion: Some(format!(
18509                "use one of: {}",
18510                variants.iter().cloned().collect::<Vec<_>>().join(", ")
18511            )),
18512        });
18513    }
18514}
18515
18516fn validate_union_literal(
18517    rule: &RuleDecl,
18518    record_schema: &str,
18519    field: &str,
18520    variants: &[TypeSyntax],
18521    literal: &LiteralExpr<'_>,
18522    diagnostics: &mut Vec<Diagnostic>,
18523) {
18524    let allowed = variants
18525        .iter()
18526        .filter_map(|variant| match variant {
18527            TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
18528            _ => None,
18529        })
18530        .collect::<Vec<_>>();
18531    if allowed.is_empty() {
18532        return;
18533    }
18534    let LiteralExpr::String(value) = literal else {
18535        diagnostics.push(Diagnostic {
18536            related: Vec::new(),
18537            span: rule.body.span,
18538            message: format!("field `{record_schema}.{field}` expects one of its literal variants"),
18539            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
18540        });
18541        return;
18542    };
18543    if !allowed.contains(value) {
18544        diagnostics.push(Diagnostic {
18545            related: Vec::new(),
18546            span: rule.body.span,
18547            message: format!("field `{record_schema}.{field}` cannot be `{value}`"),
18548            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
18549        });
18550    }
18551}
18552
18553fn parse_effect_line(line: &str) -> Option<(IrEffectKind, Option<String>)> {
18554    let kind = if line.starts_with("tell ") {
18555        IrEffectKind::AgentTell
18556    } else if line.starts_with("coerce ") || line.starts_with("prompt ") {
18557        IrEffectKind::SchemaCoerce
18558    } else if line.starts_with("claim ") {
18559        IrEffectKind::TrackerClaim
18560    } else if line.starts_with("call ")
18561        || line.starts_with("recall ")
18562        || line.starts_with("learn ")
18563        || line.starts_with("curate ")
18564    {
18565        IrEffectKind::CapabilityCall
18566    } else if line.starts_with("emit ") {
18567        IrEffectKind::EventEmit
18568    } else if line.starts_with("invoke ") {
18569        IrEffectKind::WorkflowInvoke
18570    } else if line.starts_with("read ") {
18571        IrEffectKind::FileRead
18572    } else if line.starts_with("write ") {
18573        IrEffectKind::FileWrite
18574    } else if line.starts_with("import ") {
18575        IrEffectKind::FileImport
18576    } else if line.starts_with("export ") {
18577        IrEffectKind::FileExport
18578    } else if line.starts_with("acquire ") {
18579        IrEffectKind::LeaseAcquire
18580    } else if line.starts_with("renew ") {
18581        IrEffectKind::LeaseRenew
18582    } else if line.starts_with("append ") {
18583        IrEffectKind::LedgerAppend
18584    } else if line.starts_with("consume ") && line.contains(" for ") {
18585        // The counter verb (`consume <counter> for <key> …`); the bare
18586        // `consume <binding>` alias was removed, and `done` never reaches
18587        // this fn as an effect line.
18588        IrEffectKind::CounterConsume
18589    } else {
18590        return None;
18591    };
18592
18593    Some((kind, binding_after_as(line)))
18594}
18595
18596fn parse_consume_line(line: &str) -> Option<String> {
18597    // `done <binding>` consumes the matched fact. The bare `consume` alias for
18598    // `done` was removed; `consume <counter> for ...` is the distinct counter
18599    // verb (multi-word, so it never satisfies the identifier check below).
18600    let binding = line
18601        .trim()
18602        .trim_end_matches(';')
18603        .strip_prefix("done ")?
18604        .split("->")
18605        .next()
18606        .unwrap_or_default()
18607        .trim();
18608    let mut chars = binding.chars();
18609    let first = chars.next()?;
18610    if !(first.is_ascii_alphabetic() || first == '_') {
18611        return None;
18612    }
18613    chars
18614        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18615        .then(|| binding.to_owned())
18616}
18617
18618fn binding_after_multiline_string_end(line: &str) -> Option<String> {
18619    line.strip_prefix("\"\"\"")
18620        .and_then(|rest| rest.trim().strip_prefix("as "))
18621        .and_then(|rest| rest.split_whitespace().next())
18622        .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18623        .filter(|binding| !binding.is_empty())
18624        .map(str::to_owned)
18625}
18626
18627fn validate_rule_prompt_content_type_annotation(
18628    rule: &RuleDecl,
18629    line: &str,
18630    diagnostics: &mut Vec<Diagnostic>,
18631) {
18632    if !(line.starts_with("tell ") || line.starts_with("coerce ")) {
18633        return;
18634    }
18635    let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18636        return;
18637    };
18638    diagnostics.push(Diagnostic {
18639        related: Vec::new(),
18640        span: rule.body.span,
18641        message: format!(
18642            "rule `{}` has malformed multiline prompt content type `{annotation}`",
18643            rule.name.name
18644        ),
18645        suggestion: Some(
18646            "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18647                .to_owned(),
18648        ),
18649    });
18650}
18651
18652fn validate_coerce_prompt_content_type_annotations(
18653    coerce: &CoerceDecl,
18654    diagnostics: &mut Vec<Diagnostic>,
18655) {
18656    for line in coerce.body.text.lines().map(str::trim) {
18657        if !line.starts_with("prompt ") {
18658            continue;
18659        }
18660        let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18661            continue;
18662        };
18663        diagnostics.push(Diagnostic { related: Vec::new(),
18664            span: coerce.body.span,
18665            message: format!(
18666                "coerce `{}` has malformed multiline prompt content type `{annotation}`",
18667                coerce.name.name
18668            ),
18669            suggestion: Some(
18670                "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18671                    .to_owned(),
18672            ),
18673        });
18674    }
18675}
18676
18677fn malformed_prompt_content_type_annotation(line: &str) -> Option<String> {
18678    let (_, tail) = line.split_once("\"\"\"")?;
18679    let candidate = tail.trim();
18680    if candidate.is_empty() || candidate.contains("\"\"\"") {
18681        return None;
18682    }
18683    let mut parts = candidate.split_whitespace();
18684    let first = parts.next()?;
18685    let has_extra_text = parts.next().is_some();
18686    let first_is_supported = is_supported_prompt_content_type(first);
18687    let first_is_annotation_shaped = first_is_supported || first.contains('/');
18688    if has_extra_text && first_is_annotation_shaped {
18689        return Some(candidate.to_owned());
18690    }
18691    if first.contains('/') && !first_is_supported {
18692        return Some(first.to_owned());
18693    }
18694    None
18695}
18696
18697fn is_supported_prompt_content_type(candidate: &str) -> bool {
18698    if !is_prompt_content_type_token(candidate) {
18699        return false;
18700    }
18701    let normalized = candidate.to_ascii_lowercase();
18702    normalized.contains('/')
18703        || matches!(
18704            normalized.as_str(),
18705            "markdown" | "json" | "text" | "plain" | "html" | "xml" | "yaml" | "yml"
18706        )
18707}
18708
18709fn is_prompt_content_type_token(candidate: &str) -> bool {
18710    let mut chars = candidate.chars();
18711    let Some(first) = chars.next() else {
18712        return false;
18713    };
18714    first.is_ascii_alphanumeric()
18715        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '+' | '-' | '_'))
18716}
18717
18718fn binding_after_as(line: &str) -> Option<String> {
18719    let mut tokens = line.split_whitespace();
18720    while let Some(token) = tokens.next() {
18721        if token == "as" {
18722            return tokens
18723                .next()
18724                .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18725                .filter(|binding| !binding.is_empty())
18726                .map(str::to_owned);
18727        }
18728    }
18729    None
18730}
18731
18732fn parse_after_line(line: &str) -> Option<(String, DependencyPredicate)> {
18733    let rest = line.strip_prefix("after ")?;
18734    if rest.contains("=>") {
18735        return None;
18736    }
18737    let before_body = rest.split('{').next().unwrap_or(rest).trim();
18738    let mut parts = before_body.split_whitespace();
18739    let binding = parts.next()?.to_owned();
18740    let predicate = match parts.next()? {
18741        "succeeds" => DependencyPredicate::Succeeds,
18742        "fails" => DependencyPredicate::Fails,
18743        // `times out` / `cancelled` react only to that specific non-success
18744        // terminal status (spec/expression-kernel.md), mirroring succeeds/fails.
18745        "cancelled" => DependencyPredicate::Cancelled,
18746        "times" => {
18747            if parts.next()? != "out" {
18748                return None;
18749            }
18750            DependencyPredicate::TimedOut
18751        }
18752        // Coordination outcomes (spec/coordination.md) are completion-valued;
18753        // the arm dispatch happens on the outcome variant at lowering.
18754        "completes" | "held" | "contended" | "ok" | "over" => DependencyPredicate::Completes,
18755        // `after p reaches "<name>" [as m]` (Family C): consume the quoted
18756        // milestone name (which may contain whitespace — token-splitting used
18757        // to reject multi-word names); the IR predicate is completion-shaped
18758        // (runtime gating keys on the milestone-specific `reached` fact).
18759        "reaches" => {
18760            let rest = before_body.trim().strip_prefix(&binding)?.trim_start();
18761            let after_kw = rest.strip_prefix("reaches")?.trim_start();
18762            let quoted = after_kw.strip_prefix('"')?;
18763            let close = quoted.find('"')?;
18764            let tail = &quoted[close + 1..];
18765            let mut tail_parts = tail.split_whitespace();
18766            match (tail_parts.next(), tail_parts.next(), tail_parts.next()) {
18767                (None, None, None) => {}
18768                (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18769                _ => return None,
18770            }
18771            return Some((binding, DependencyPredicate::Completes));
18772        }
18773        _ => return None,
18774    };
18775    match (parts.next(), parts.next(), parts.next()) {
18776        (None, None, None) => {}
18777        (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18778        _ => return None,
18779    }
18780    Some((binding, predicate))
18781}
18782
18783pub(crate) fn is_identifier(value: &str) -> bool {
18784    let mut chars = value.chars();
18785    let Some(first) = chars.next() else {
18786        return false;
18787    };
18788    (first.is_ascii_alphabetic() || first == '_')
18789        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18790}
18791
18792fn lower_type(ty: TypeSyntax) -> IrType {
18793    match ty {
18794        TypeSyntax::Primitive { name, .. } => IrType::Primitive(lower_primitive_type(&name)),
18795        TypeSyntax::LiteralString { value, .. } => IrType::LiteralString(value),
18796        TypeSyntax::Ref { name } => IrType::Ref(name.name),
18797        TypeSyntax::AgentRef { agents, .. } => {
18798            IrType::AgentRef(agents.into_iter().map(|agent| agent.name).collect())
18799        }
18800        TypeSyntax::Optional { inner, .. } => IrType::Optional(Box::new(lower_type(*inner))),
18801        TypeSyntax::Array { inner, .. } => IrType::Array(Box::new(lower_type(*inner))),
18802        TypeSyntax::Map { inner, .. } => IrType::Map(Box::new(lower_type(*inner))),
18803        TypeSyntax::Union { variants, .. } => {
18804            IrType::Union(variants.into_iter().map(lower_type).collect())
18805        }
18806    }
18807}
18808
18809fn lower_primitive_type(name: &str) -> IrPrimitiveType {
18810    match name {
18811        "string" => IrPrimitiveType::String,
18812        "int" => IrPrimitiveType::Int,
18813        "float" => IrPrimitiveType::Float,
18814        "bool" => IrPrimitiveType::Bool,
18815        "null" => IrPrimitiveType::Null,
18816        "duration" => IrPrimitiveType::Duration,
18817        "time" => IrPrimitiveType::Time,
18818        "image" => IrPrimitiveType::Image,
18819        "audio" => IrPrimitiveType::Audio,
18820        "pdf" => IrPrimitiveType::Pdf,
18821        "video" => IrPrimitiveType::Video,
18822        _ => IrPrimitiveType::String,
18823    }
18824}
18825
18826fn push_line(snapshot: &mut String, line: impl AsRef<str>) {
18827    snapshot.push_str(line.as_ref());
18828    snapshot.push('\n');
18829}
18830
18831fn stable_hash(value: &str) -> String {
18832    // SHA-256 truncated to 128 bits (the FNV-collision hardening swap):
18833    // source_hash/ir_hash are program-version identity — colliding them
18834    // aliases two program revisions. Report-schema digest patterns and the
18835    // Python validator mirrors must stay in lockstep with this width.
18836    use sha2::Digest;
18837    let digest = sha2::Sha256::digest(value.as_bytes());
18838    let mut hex = String::with_capacity(32);
18839    for byte in &digest[..16] {
18840        hex.push_str(&format!("{byte:02x}"));
18841    }
18842    hex
18843}
18844
18845pub fn parse_duration_seconds(value: &str) -> Option<f64> {
18846    let value = value.strip_prefix('P')?;
18847    let mut rest = value;
18848    let mut seconds = 0.0;
18849    let mut consumed = false;
18850    let mut in_time = false;
18851
18852    while !rest.is_empty() {
18853        if let Some(next) = rest.strip_prefix('T') {
18854            if in_time {
18855                return None;
18856            }
18857            in_time = true;
18858            rest = next;
18859            continue;
18860        }
18861
18862        let number_len = rest
18863            .char_indices()
18864            .take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.')
18865            .map(|(index, ch)| index + ch.len_utf8())
18866            .last()?;
18867        let number = rest[..number_len].parse::<f64>().ok()?;
18868        if !number.is_finite() {
18869            return None;
18870        }
18871        let unit = rest[number_len..].chars().next()?;
18872        rest = &rest[number_len + unit.len_utf8()..];
18873        let multiplier = match (in_time, unit) {
18874            (false, 'D') => 86_400.0,
18875            (true, 'H') => 3_600.0,
18876            (true, 'M') => 60.0,
18877            (true, 'S') => 1.0,
18878            _ => return None,
18879        };
18880        seconds += number * multiplier;
18881        consumed = true;
18882    }
18883
18884    consumed.then_some(seconds)
18885}
18886
18887pub fn parse_time_epoch_seconds(value: &str) -> Option<f64> {
18888    if value.len() < 20 {
18889        return None;
18890    }
18891    let year = parse_fixed_i32(value, 0, 4)?;
18892    require_byte(value, 4, b'-')?;
18893    let month = parse_fixed_u32(value, 5, 2)?;
18894    require_byte(value, 7, b'-')?;
18895    let day = parse_fixed_u32(value, 8, 2)?;
18896    require_byte(value, 10, b'T')?;
18897    let hour = parse_fixed_u32(value, 11, 2)?;
18898    require_byte(value, 13, b':')?;
18899    let minute = parse_fixed_u32(value, 14, 2)?;
18900    require_byte(value, 16, b':')?;
18901    let second = parse_fixed_u32(value, 17, 2)?;
18902    let mut offset_start = 19;
18903    let mut fractional_second = 0.0;
18904    if value.as_bytes().get(offset_start).copied() == Some(b'.') {
18905        let fraction_start = offset_start + 1;
18906        let fraction_len = value[fraction_start..]
18907            .char_indices()
18908            .take_while(|(_, ch)| ch.is_ascii_digit())
18909            .map(|(index, ch)| index + ch.len_utf8())
18910            .last()?;
18911        let fraction = &value[fraction_start..fraction_start + fraction_len];
18912        let scale = 10_f64.powi(i32::try_from(fraction.len()).ok()?);
18913        fractional_second = fraction.parse::<f64>().ok()? / scale;
18914        offset_start = fraction_start + fraction_len;
18915    }
18916    if !(1..=12).contains(&month)
18917        || !(1..=days_in_month(year, month)).contains(&day)
18918        || hour > 23
18919        || minute > 59
18920        || second > 60
18921    {
18922        return None;
18923    }
18924
18925    let offset_seconds = match value.as_bytes().get(offset_start).copied()? {
18926        b'Z' if value.len() == offset_start + 1 => 0,
18927        b'+' | b'-' if value.len() == offset_start + 6 => {
18928            let sign = if value.as_bytes()[offset_start] == b'+' {
18929                1
18930            } else {
18931                -1
18932            };
18933            let offset_hour = parse_fixed_i32(value, offset_start + 1, 2)?;
18934            require_byte(value, offset_start + 3, b':')?;
18935            let offset_minute = parse_fixed_i32(value, offset_start + 4, 2)?;
18936            if offset_hour > 23 || offset_minute > 59 {
18937                return None;
18938            }
18939            sign * (offset_hour * 3_600 + offset_minute * 60)
18940        }
18941        _ => return None,
18942    };
18943
18944    let days = days_from_civil(year, month, day);
18945    let local_seconds = days * 86_400 + i64::from(hour * 3_600 + minute * 60 + second.min(59));
18946    Some((local_seconds - i64::from(offset_seconds)) as f64 + fractional_second)
18947}
18948
18949fn parse_fixed_i32(value: &str, start: usize, len: usize) -> Option<i32> {
18950    value.get(start..start + len)?.parse::<i32>().ok()
18951}
18952
18953fn parse_fixed_u32(value: &str, start: usize, len: usize) -> Option<u32> {
18954    value.get(start..start + len)?.parse::<u32>().ok()
18955}
18956
18957fn require_byte(value: &str, index: usize, expected: u8) -> Option<()> {
18958    (value.as_bytes().get(index).copied()? == expected).then_some(())
18959}
18960
18961fn days_in_month(year: i32, month: u32) -> u32 {
18962    match month {
18963        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
18964        4 | 6 | 9 | 11 => 30,
18965        2 if is_leap_year(year) => 29,
18966        2 => 28,
18967        _ => 0,
18968    }
18969}
18970
18971fn is_leap_year(year: i32) -> bool {
18972    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
18973}
18974
18975fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
18976    let year = year - i32::from(month <= 2);
18977    let era = if year >= 0 { year } else { year - 399 } / 400;
18978    let year_of_era = year - era * 400;
18979    let month = month as i32;
18980    let day = day as i32;
18981    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
18982    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
18983    i64::from(era * 146_097 + day_of_era - 719_468)
18984}
18985
18986fn format_syntax(program: Program) -> String {
18987    let mut formatted = String::new();
18988    if let Some(workflow) = program.workflow {
18989        format_tags(&program.workflow_tags, &mut formatted);
18990        format_description(program.workflow_description.as_ref(), &mut formatted);
18991        push_line(&mut formatted, format!("workflow {}", workflow.name));
18992        formatted.push('\n');
18993    }
18994
18995    let mut top_level_items = Vec::new();
18996    top_level_items.extend(program.patterns.into_iter().map(Item::Pattern));
18997    top_level_items.extend(program.items);
18998    format_items(top_level_items, &mut formatted);
18999
19000    if !formatted.is_empty() && !program.workflows.is_empty() {
19001        formatted.push('\n');
19002    }
19003    let workflow_count = program.workflows.len();
19004    for (index, workflow) in program.workflows.into_iter().enumerate() {
19005        format_workflow(workflow, &mut formatted);
19006        if index + 1 < workflow_count {
19007            formatted.push('\n');
19008        }
19009    }
19010
19011    formatted
19012}
19013
19014fn format_items(items: Vec<Item>, formatted: &mut String) {
19015    let item_count = items.len();
19016    for (index, item) in items.into_iter().enumerate() {
19017        format_item(item, formatted);
19018        if index + 1 < item_count {
19019            formatted.push('\n');
19020        }
19021    }
19022}
19023
19024fn format_item(item: Item, formatted: &mut String) {
19025    match item {
19026        Item::Include(include) => {
19027            push_line(formatted, format!("include {:?}", include.path.value));
19028        }
19029        Item::Use(use_decl) => {
19030            push_line(formatted, format!("use {}", use_decl.name.value));
19031        }
19032        Item::Tracker(queue) => {
19033            push_line(formatted, format!("tracker {} {{", queue.name.name));
19034            push_line(formatted, format!("  provider {}", queue.provider.name));
19035            push_line(formatted, "}");
19036        }
19037        Item::Mark(mark) => {
19038            push_line(
19039                formatted,
19040                format!("mark {:?} after {}", mark.name.value, mark.site),
19041            );
19042        }
19043        Item::Gauge(gauge) => {
19044            let mut header = format!("gauge {}", gauge.name.name);
19045            if let Some(site) = &gauge.site {
19046                header.push_str(&format!(" on {site}"));
19047            }
19048            header.push_str(" {");
19049            push_line(formatted, header);
19050            let judge = match &gauge.judge {
19051                GaugeJudge::Coerce(target, args) if args.is_empty() => {
19052                    format!("coerce {}", target.name)
19053                }
19054                GaugeJudge::Coerce(target, args) => {
19055                    format!("coerce {}({})", target.name, args.join(", "))
19056                }
19057                GaugeJudge::Prompt(template) => format!("prompt {:?}", template.value),
19058                GaugeJudge::Exec(command) => format!("exec {:?}", command.value),
19059                GaugeJudge::Labels(source) => format!("labels {:?}", source.value),
19060            };
19061            push_line(formatted, format!("  judge via {judge}"));
19062            if let Some(bar) = &gauge.expect {
19063                let subject = match &bar.subject {
19064                    GaugeBarSubject::Chance { field } => format!("P({})", field.name),
19065                    GaugeBarSubject::Stat { stat } => stat.name.clone(),
19066                };
19067                let direction = if bar.at_least { "at least" } else { "at most" };
19068                push_line(
19069                    formatted,
19070                    format!("  expect {subject} {direction} {}", bar.threshold),
19071                );
19072            }
19073            if !gauge.inputs.is_empty() {
19074                let names = gauge
19075                    .inputs
19076                    .iter()
19077                    .map(|input| input.name.as_str())
19078                    .collect::<Vec<_>>()
19079                    .join(", ");
19080                push_line(formatted, format!("  inputs {names}"));
19081            }
19082            push_line(formatted, "}");
19083        }
19084        Item::Campaign(campaign) => {
19085            push_line(formatted, format!("campaign {} {{", campaign.name.name));
19086            if !campaign.ascend.is_empty() {
19087                let names = campaign
19088                    .ascend
19089                    .iter()
19090                    .map(|gauge| gauge.name.as_str())
19091                    .collect::<Vec<_>>()
19092                    .join(", ");
19093                push_line(formatted, format!("  ascend {names}"));
19094            }
19095            for reach in &campaign.reach {
19096                let direction = if reach.at_least {
19097                    "at least"
19098                } else {
19099                    "at most"
19100                };
19101                let unit = reach.unit.as_deref().unwrap_or("");
19102                push_line(
19103                    formatted,
19104                    format!(
19105                        "  reach {} {direction} {}{unit}",
19106                        reach.gauge.name, reach.threshold
19107                    ),
19108                );
19109            }
19110            for guard in &campaign.guard {
19111                push_line(
19112                    formatted,
19113                    format!(
19114                        "  guard {} within {} percent",
19115                        guard.gauge.name, guard.band_percent
19116                    ),
19117                );
19118            }
19119            if !campaign.sacrifice.is_empty() {
19120                let names = campaign
19121                    .sacrifice
19122                    .iter()
19123                    .map(|gauge| gauge.name.as_str())
19124                    .collect::<Vec<_>>()
19125                    .join(", ");
19126                push_line(formatted, format!("  sacrifice {names}"));
19127            }
19128            if campaign.proposer_redacted {
19129                push_line(formatted, "  proposer redacted");
19130            }
19131            push_line(formatted, "}");
19132        }
19133        Item::Channel(channel) => {
19134            push_line(formatted, format!("channel {} {{", channel.name.name));
19135            push_line(formatted, format!("  provider {}", channel.provider.name));
19136            if let Some(workspace) = &channel.workspace {
19137                push_line(formatted, format!("  workspace {}", workspace.name));
19138            }
19139            if let Some(destination) = &channel.destination {
19140                push_line(formatted, format!("  destination {:?}", destination.value));
19141            }
19142            push_line(formatted, "}");
19143        }
19144        Item::FileStore(file_store) => {
19145            push_line(formatted, format!("file store {} {{", file_store.name.name));
19146            push_line(formatted, format!("  root {:?}", file_store.root));
19147            let format_globs = |formatted: &mut String, direction: &str, globs: &[String]| {
19148                if !globs.is_empty() {
19149                    let rendered = globs
19150                        .iter()
19151                        .map(|glob| format!("{glob:?}"))
19152                        .collect::<Vec<_>>()
19153                        .join(", ");
19154                    push_line(formatted, format!("  allow {direction} [{rendered}]"));
19155                }
19156            };
19157            format_globs(formatted, "read", &file_store.read_globs);
19158            format_globs(formatted, "write", &file_store.write_globs);
19159            if let Some(provider) = &file_store.provider {
19160                push_line(formatted, format!("  provider {}", provider.name));
19161            }
19162            push_line(formatted, "}");
19163        }
19164        Item::MemoryPool(pool) => {
19165            push_line(formatted, format!("memory pool {} {{", pool.name.name));
19166            if let Some(limit) = pool.context_limit {
19167                push_line(formatted, format!("  context limit {limit}"));
19168            }
19169            push_line(formatted, "}");
19170        }
19171        Item::Action(action) => {
19172            let params = action
19173                .params
19174                .iter()
19175                .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
19176                .collect::<Vec<_>>()
19177                .join(", ");
19178            push_line(
19179                formatted,
19180                format!("action {}({params}) {{", action.name.name),
19181            );
19182            for line in action.body.text.lines() {
19183                if line.trim().is_empty() {
19184                    push_line(formatted, "");
19185                } else {
19186                    push_line(formatted, line.trim_end());
19187                }
19188            }
19189            push_line(formatted, "}");
19190        }
19191        Item::Pattern(pattern) => format_pattern(pattern, formatted),
19192        Item::Apply(apply) => format_apply(apply, formatted),
19193        Item::WorkflowContract(contract) => {
19194            push_line(
19195                formatted,
19196                format!(
19197                    "{} {} {}",
19198                    contract.kind.as_str(),
19199                    contract.name.name,
19200                    contract.ty.to_source()
19201                ),
19202            );
19203        }
19204        Item::Harness(harness) => format_harness(harness, formatted),
19205        Item::Agent(agent) => format_agent(agent, formatted),
19206        Item::Enum(enum_decl) => format_enum(enum_decl, formatted),
19207        Item::Event(event) => format_event(event, formatted),
19208        Item::Source(source) => format_source(*source, formatted),
19209        Item::Test(test) => format_test(test, formatted),
19210        Item::Lease(lease) => {
19211            push_line(formatted, format!("lease {} {{", lease.name.name));
19212            if lease.shared {
19213                push_line(formatted, "  shared");
19214            }
19215            push_line(formatted, format!("  key {}", lease.key_type.name));
19216            push_line(formatted, format!("  slots {}", lease.slots));
19217            push_line(formatted, format!("  ttl {}s", lease.ttl_seconds));
19218            push_line(formatted, "}");
19219        }
19220        Item::Ledger(ledger) => {
19221            push_line(formatted, format!("ledger {} {{", ledger.name.name));
19222            if ledger.shared {
19223                push_line(formatted, "  shared");
19224            }
19225            push_line(formatted, format!("  entry {}", ledger.entry_schema.name));
19226            push_line(
19227                formatted,
19228                format!("  partition by {}", ledger.partition_field.name),
19229            );
19230            push_line(formatted, format!("  retain {}s", ledger.retain_seconds));
19231            push_line(formatted, "}");
19232        }
19233        Item::Counter(counter) => {
19234            push_line(formatted, format!("counter {} {{", counter.name.name));
19235            if counter.shared {
19236                push_line(formatted, "  shared");
19237            }
19238            push_line(formatted, format!("  key {}", counter.key_type.name));
19239            push_line(formatted, format!("  cap {}", counter.cap));
19240            push_line(formatted, format!("  reset {}", counter.reset));
19241            push_line(formatted, "}");
19242        }
19243        Item::Class(class_decl) => format_class(class_decl, formatted),
19244        Item::Table(table) => format_table(table, formatted),
19245        Item::Coerce(coerce) => format_coerce(coerce, formatted),
19246        Item::Assert(assertion) => {
19247            format_tags(&assertion.tags, formatted);
19248            format_description(assertion.description.as_ref(), formatted);
19249            push_line(formatted, format!("assert {}", assertion.expr));
19250        }
19251        Item::Rule(rule) => format_rule(rule, formatted),
19252    }
19253}
19254
19255fn format_tags(tags: &[TagDecl], formatted: &mut String) {
19256    for tag in tags {
19257        push_line(formatted, format!("@{}", tag.name));
19258    }
19259}
19260
19261fn format_description(description: Option<&StringLiteral>, formatted: &mut String) {
19262    if let Some(description) = description {
19263        push_line(formatted, format!("description {:?}", description.value));
19264    }
19265}
19266
19267fn format_pattern(pattern: PatternDecl, formatted: &mut String) {
19268    let params = if pattern.type_params.is_empty() {
19269        String::new()
19270    } else {
19271        format!(
19272            "<{}>",
19273            pattern
19274                .type_params
19275                .iter()
19276                .map(|param| param.name.as_str())
19277                .collect::<Vec<_>>()
19278                .join(", ")
19279        )
19280    };
19281    push_line(
19282        formatted,
19283        format!("pattern {}{} {{", pattern.name.name, params),
19284    );
19285    let mut inner = String::new();
19286    format_items(pattern.items, &mut inner);
19287    for line in inner.lines() {
19288        if line.is_empty() {
19289            formatted.push('\n');
19290        } else {
19291            push_line(formatted, format!("  {line}"));
19292        }
19293    }
19294    push_line(formatted, "}");
19295}
19296
19297fn format_apply(apply: ApplyDecl, formatted: &mut String) {
19298    let args = if apply.type_args.is_empty() {
19299        String::new()
19300    } else {
19301        format!(
19302            "<{}>",
19303            apply
19304                .type_args
19305                .iter()
19306                .map(TypeSyntax::to_source)
19307                .collect::<Vec<_>>()
19308                .join(", ")
19309        )
19310    };
19311    push_line(
19312        formatted,
19313        format!(
19314            "apply {}{} as {} {{",
19315            apply.pattern.name, args, apply.alias.name
19316        ),
19317    );
19318    format_block_body(&apply.body.text, formatted);
19319    push_line(formatted, "}");
19320}
19321
19322fn format_workflow(workflow: WorkflowDecl, formatted: &mut String) {
19323    format_tags(&workflow.tags, formatted);
19324    format_description(workflow.description.as_ref(), formatted);
19325    push_line(formatted, format!("workflow {} {{", workflow.name.name));
19326    let mut inner = String::new();
19327    format_items(workflow.items, &mut inner);
19328    for line in inner.lines() {
19329        if line.is_empty() {
19330            formatted.push('\n');
19331        } else {
19332            push_line(formatted, format!("  {line}"));
19333        }
19334    }
19335    push_line(formatted, "}");
19336}
19337
19338fn format_harness(harness: HarnessDecl, formatted: &mut String) {
19339    push_line(
19340        formatted,
19341        format!("harness {}: {}", harness.name.name, harness.kind.name),
19342    );
19343}
19344
19345fn format_agent(agent: AgentDecl, formatted: &mut String) {
19346    let harness = agent
19347        .harness
19348        .as_ref()
19349        .map(|harness| format!(" using {}", harness.name))
19350        .or_else(|| {
19351            agent
19352                .delegated_to
19353                .as_ref()
19354                .map(|delegate| format!(" delegated to {}", delegate.name))
19355        })
19356        .unwrap_or_default();
19357    push_line(
19358        formatted,
19359        format!("agent {}{} {{", agent.name.name, harness),
19360    );
19361    for field in agent.fields {
19362        match field {
19363            AgentField::Provider(provider) => {
19364                push_line(formatted, format!("  provider {}", provider.name));
19365            }
19366            AgentField::Profile(profile) => {
19367                push_line(formatted, format!("  profile {:?}", profile.value));
19368            }
19369            AgentField::Capacity(capacity, _) => {
19370                push_line(formatted, format!("  capacity {capacity}"));
19371            }
19372            AgentField::Skills(skills, _) => {
19373                let skills = skills
19374                    .into_iter()
19375                    .map(|skill| format!("{:?}", skill.value))
19376                    .collect::<Vec<_>>()
19377                    .join(", ");
19378                push_line(formatted, format!("  skills [{skills}]"));
19379            }
19380            AgentField::Capabilities(capabilities, _) => {
19381                let capabilities = capabilities
19382                    .into_iter()
19383                    .map(|capability| format!("{:?}", capability.value))
19384                    .collect::<Vec<_>>()
19385                    .join(", ");
19386                push_line(formatted, format!("  capabilities [{capabilities}]"));
19387            }
19388            AgentField::Requires(classes, _) => {
19389                let classes = classes
19390                    .into_iter()
19391                    .map(|class| class.name)
19392                    .collect::<Vec<_>>()
19393                    .join(", ");
19394                push_line(formatted, format!("  requires [{classes}]"));
19395            }
19396            AgentField::Tools(tools, _) => {
19397                let tools = tools
19398                    .into_iter()
19399                    .map(|tool| tool.name)
19400                    .collect::<Vec<_>>()
19401                    .join(", ");
19402                push_line(formatted, format!("  tools [{tools}]"));
19403            }
19404            AgentField::Compaction(strategy) => {
19405                push_line(formatted, format!("  compaction {}", strategy.name));
19406            }
19407            AgentField::Thread(mode) => {
19408                push_line(formatted, format!("  thread {}", mode.name));
19409            }
19410            AgentField::Settings(sources) => {
19411                push_line(formatted, format!("  settings {}", sources.name));
19412            }
19413            AgentField::Unknown { name, .. } => {
19414                push_line(formatted, format!("  {}", name.name));
19415            }
19416        }
19417    }
19418    push_line(formatted, "}");
19419}
19420
19421fn format_enum(enum_decl: EnumDecl, formatted: &mut String) {
19422    push_line(formatted, format!("enum {} {{", enum_decl.name.name));
19423    for variant in enum_decl.variants {
19424        if variant.fields.is_empty() {
19425            push_line(formatted, format!("  {}", variant.name.name));
19426            continue;
19427        }
19428        push_line(formatted, format!("  {} {{", variant.name.name));
19429        for field in variant.fields {
19430            push_line(
19431                formatted,
19432                format!("    {} {}", field.name.name, field.ty.to_source()),
19433            );
19434        }
19435        push_line(formatted, "  }");
19436    }
19437    push_line(formatted, "}");
19438}
19439
19440fn format_time_of_day(time: TimeOfDay) -> String {
19441    format!("{:02}:{:02}", time.hour, time.minute)
19442}
19443
19444fn format_weekday(day: Weekday) -> &'static str {
19445    match day {
19446        Weekday::Monday => "monday",
19447        Weekday::Tuesday => "tuesday",
19448        Weekday::Wednesday => "wednesday",
19449        Weekday::Thursday => "thursday",
19450        Weekday::Friday => "friday",
19451        Weekday::Saturday => "saturday",
19452        Weekday::Sunday => "sunday",
19453    }
19454}
19455
19456fn format_recurrence(recurrence: &Recurrence) -> String {
19457    match recurrence {
19458        Recurrence::At { time, .. } => format!("at {}", format_time_of_day(*time)),
19459        Recurrence::EveryDuration { source, .. } => format!("every {source}"),
19460        Recurrence::EveryCalendar { pattern, time, .. } => {
19461            let pattern = match pattern {
19462                CalendarPattern::Day => "day".to_owned(),
19463                CalendarPattern::Weekday => "weekday".to_owned(),
19464                CalendarPattern::Weekly(day) => format_weekday(*day).to_owned(),
19465            };
19466            format!("every {pattern} at {}", format_time_of_day(*time))
19467        }
19468    }
19469}
19470
19471fn format_source_value(value: &SourceValue) -> String {
19472    match value {
19473        SourceValue::Path {
19474            binding, segments, ..
19475        } => {
19476            let mut text = binding.name.clone();
19477            for segment in segments {
19478                text.push('.');
19479                text.push_str(&segment.name);
19480            }
19481            text
19482        }
19483        SourceValue::String(literal) => format!("{:?}", literal.value),
19484        SourceValue::Number(number, _) => number.clone(),
19485    }
19486}
19487
19488fn format_test_fields(fields: &[TestField], formatted: &mut String) {
19489    for field in fields {
19490        push_line(
19491            formatted,
19492            format!("    {} {}", field.name.name, field.value),
19493        );
19494    }
19495}
19496
19497fn format_test(test: TestDecl, formatted: &mut String) {
19498    push_line(formatted, format!("test {:?} {{", test.name.value));
19499    if let Some(workflow) = &test.workflow {
19500        push_line(formatted, format!("  workflow {}", workflow.name));
19501    }
19502    for clause in &test.clauses {
19503        match clause {
19504            TestClause::Given(given) => match given {
19505                GivenClause::Input { fields, .. } => {
19506                    push_line(formatted, "  given input {");
19507                    format_test_fields(fields, formatted);
19508                    push_line(formatted, "  }");
19509                }
19510                GivenClause::Fact { ty, fields, .. } => {
19511                    push_line(formatted, format!("  given fact {} {{", ty.name));
19512                    format_test_fields(fields, formatted);
19513                    push_line(formatted, "  }");
19514                }
19515                GivenClause::Signal { name, fields, .. } => {
19516                    push_line(formatted, format!("  given signal {name} {{"));
19517                    format_test_fields(fields, formatted);
19518                    push_line(formatted, "  }");
19519                }
19520                GivenClause::Clock { at, .. } => {
19521                    push_line(formatted, format!("  given clock at {:?}", at.value));
19522                }
19523                GivenClause::Tracker {
19524                    tracker, fields, ..
19525                } => {
19526                    push_line(formatted, format!("  given tracker {tracker} issue {{"));
19527                    format_test_fields(fields, formatted);
19528                    push_line(formatted, "  }");
19529                }
19530                GivenClause::File {
19531                    store,
19532                    path,
19533                    content,
19534                    ..
19535                } => {
19536                    push_line(
19537                        formatted,
19538                        format!(
19539                            "  given file {store} at {:?} {:?}",
19540                            path.value, content.value
19541                        ),
19542                    );
19543                }
19544            },
19545            TestClause::Stub(stub) => {
19546                let surface = stub.surface.join(" ");
19547                match &stub.payload {
19548                    Some(StubPayload::Message(message)) => push_line(
19549                        formatted,
19550                        format!("  stub {surface} {} {:?}", stub.outcome, message.value),
19551                    ),
19552                    Some(StubPayload::Record(fields)) => {
19553                        push_line(formatted, format!("  stub {surface} {} {{", stub.outcome));
19554                        format_test_fields(fields, formatted);
19555                        push_line(formatted, "  }");
19556                    }
19557                    None => push_line(formatted, format!("  stub {surface} {}", stub.outcome)),
19558                }
19559            }
19560            TestClause::Run(run) => {
19561                let text = match &run.kind {
19562                    RunKind::UntilIdle => "run until idle".to_owned(),
19563                    RunKind::UntilWorkflowCompleted => "run until workflow completed".to_owned(),
19564                    RunKind::UntilWorkflowFailed => "run until workflow failed".to_owned(),
19565                    RunKind::ForSteps(steps) => format!("run for {steps} steps"),
19566                };
19567                push_line(formatted, format!("  {text}"));
19568            }
19569            TestClause::Expect(expect) => {
19570                push_line(
19571                    formatted,
19572                    format!("  {}", format_expect_target(&expect.target)),
19573                );
19574            }
19575        }
19576    }
19577    push_line(formatted, "}");
19578}
19579
19580fn format_expect_target(target: &ExpectTarget) -> String {
19581    match target {
19582        ExpectTarget::WorkflowCompleted => "expect workflow completed".to_owned(),
19583        ExpectTarget::WorkflowFailed { failure: None } => "expect workflow failed".to_owned(),
19584        ExpectTarget::WorkflowFailed {
19585            failure: Some(failure),
19586        } => format!("expect workflow failed with {}", failure.name),
19587        ExpectTarget::Rule { name, status } => {
19588            let status = match status {
19589                RuleStatus::Fired => "fired".to_owned(),
19590                RuleStatus::FiredTimes(count) => format!("fired {count} times"),
19591                RuleStatus::DidNotFire => "did not fire".to_owned(),
19592            };
19593            format!("expect rule {} {status}", name.name)
19594        }
19595        ExpectTarget::Effect { name, status } => {
19596            let status = match status {
19597                EffectStatus::Requested => "requested",
19598                EffectStatus::Completed => "completed",
19599                EffectStatus::Failed => "failed",
19600            };
19601            format!("expect effect {name} {status}")
19602        }
19603        ExpectTarget::Diagnostic { code } => format!("expect diagnostic {code}"),
19604        ExpectTarget::NoEffect { name } => format!("expect no {name}"),
19605        ExpectTarget::Projection(query) => format!("expect {}", format_proj_query(query)),
19606    }
19607}
19608
19609fn format_proj_query(query: &ProjQuery) -> String {
19610    match &query.kind {
19611        ProjQueryKind::Exists => format!("{} exists", query.noun),
19612        ProjQueryKind::Count { predicate, count } => {
19613            format!("{} count where {predicate} is {count}", query.noun)
19614        }
19615        ProjQueryKind::Where { predicate } => {
19616            format!("{} where {predicate}", query.noun)
19617        }
19618    }
19619}
19620
19621fn format_source(source: SourceDecl, formatted: &mut String) {
19622    push_line(
19623        formatted,
19624        format!("source {} as {} {{", source.provider.name, source.name.name),
19625    );
19626    if let Some(clock) = &source.clock {
19627        push_line(
19628            formatted,
19629            format!("  {}", format_recurrence(&clock.recurrence)),
19630        );
19631        if let Some(timezone) = &clock.timezone {
19632            push_line(formatted, format!("  timezone {:?}", timezone.value));
19633        }
19634        match clock.missed {
19635            Some(MissedPolicy::Skip) => push_line(formatted, "  missed skip"),
19636            Some(MissedPolicy::Coalesce) => push_line(formatted, "  missed coalesce"),
19637            Some(MissedPolicy::CatchUp { limit }) => {
19638                push_line(formatted, format!("  missed catch_up limit {limit}"))
19639            }
19640            None => {}
19641        }
19642    }
19643    if let Some(path) = &source.path {
19644        push_line(formatted, format!("  path {:?}", path.value));
19645    }
19646    if let Some(watch) = &source.watch {
19647        push_line(formatted, format!("  watch {:?}", watch.value));
19648    }
19649    if let Some(url) = &source.url {
19650        push_line(formatted, format!("  url {:?}", url.value));
19651    }
19652    if let Some(dedup) = &source.dedup {
19653        push_line(formatted, format!("  dedup {}", format_source_value(dedup)));
19654    }
19655    push_line(
19656        formatted,
19657        format!("  observe as {}", source.observe_binding.name),
19658    );
19659    let from = source
19660        .emit
19661        .from
19662        .as_ref()
19663        .map(|ident| format!(" from {}", ident.name))
19664        .unwrap_or_default();
19665    if source.emit.fields.is_empty() && source.emit.from.is_some() {
19666        push_line(formatted, format!("  emit {}{from}", source.emit.signal));
19667    } else {
19668        push_line(formatted, format!("  emit {}{from} {{", source.emit.signal));
19669        for field in &source.emit.fields {
19670            push_line(
19671                formatted,
19672                format!(
19673                    "    {} {}",
19674                    field.name.name,
19675                    format_source_value(&field.value)
19676                ),
19677            );
19678        }
19679        push_line(formatted, "  }");
19680    }
19681    push_line(formatted, "}");
19682}
19683
19684fn format_event(event: EventDecl, formatted: &mut String) {
19685    push_line(formatted, format!("signal {} {{", event.name));
19686    for field in event.fields {
19687        push_line(
19688            formatted,
19689            format!("  {} {}", field.name.name, field.ty.to_source()),
19690        );
19691    }
19692    push_line(formatted, "}");
19693}
19694
19695fn format_class(class_decl: ClassDecl, formatted: &mut String) {
19696    push_line(formatted, format!("class {} {{", class_decl.name.name));
19697    for field in class_decl.fields {
19698        let key = if field.is_key { " @key" } else { "" };
19699        push_line(
19700            formatted,
19701            format!("  {} {}{key}", field.name.name, field.ty.to_source()),
19702        );
19703    }
19704    push_line(formatted, "}");
19705}
19706
19707fn format_table(table: TableDecl, formatted: &mut String) {
19708    format_tags(&table.tags, formatted);
19709    format_description(table.description.as_ref(), formatted);
19710    push_line(
19711        formatted,
19712        format!("table {} as {} [", table.name.name, table.schema.name),
19713    );
19714    for row in table.rows {
19715        push_line(formatted, "  {");
19716        for line in row.body.text.lines() {
19717            if line.trim().is_empty() {
19718                formatted.push('\n');
19719            } else {
19720                // `trim()` (not `trim_end()`): normalize the field to a fixed
19721                // 4-space indent rather than prepending to the row's existing
19722                // indent, which compounded every pass. Row bodies are flat field
19723                // lists, so a fixed indent is the canonical form.
19724                push_line(formatted, format!("    {}", line.trim()));
19725            }
19726        }
19727        push_line(formatted, "  }");
19728    }
19729    push_line(formatted, "]");
19730}
19731
19732fn format_coerce(coerce: CoerceDecl, formatted: &mut String) {
19733    let params = coerce
19734        .params
19735        .into_iter()
19736        .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
19737        .collect::<Vec<_>>()
19738        .join(", ");
19739    push_line(
19740        formatted,
19741        format!(
19742            "coerce {}({}) -> {} {{",
19743            coerce.name.name,
19744            params,
19745            coerce.output.to_source()
19746        ),
19747    );
19748    format_block_body(&coerce.body.text, formatted);
19749    push_line(formatted, "}");
19750}
19751
19752fn format_rule(rule: RuleDecl, formatted: &mut String) {
19753    format_tags(&rule.tags, formatted);
19754    format_description(rule.description.as_ref(), formatted);
19755    push_line(formatted, format!("rule {}", rule.name.name));
19756    for when in rule.whens {
19757        push_line(formatted, format!("  when {}", when.text));
19758    }
19759    push_line(formatted, "=> {");
19760    format_block_body(&rule.body.text, formatted);
19761    push_line(formatted, "}");
19762}
19763
19764/// Flat-prepend body emitter used where the output feeds IR construction (e.g.
19765/// a table's synthetic rule body, whose `body_hash` is part of program identity).
19766/// Kept byte-for-byte stable so the lowered IR / snapshots do not move; the
19767/// idempotent re-indenter for human formatting is [`format_block_body`].
19768fn push_block_body(body: &str, formatted: &mut String) {
19769    if body.is_empty() {
19770        return;
19771    }
19772    for line in body.lines() {
19773        if line.trim().is_empty() {
19774            formatted.push('\n');
19775        } else {
19776            push_line(formatted, format!("  {}", line.trim_end()));
19777        }
19778    }
19779}
19780
19781/// Re-indent a rule/apply body to a canonical form derived from brace nesting,
19782/// so `whip fmt` is idempotent. Two concerns make this non-trivial:
19783///   - **Bracket nesting:** code lines are indented by their `{`/`[`/`(` depth
19784///     (string-aware via `scan_braces`), not by a flat prepend that compounds on
19785///     nested `record`/`complete` blocks.
19786///   - **Multi-line `"""..."""` strings:** the content is dedented to its common
19787///     indent and re-indented to the block depth (preserving relative structure).
19788///     This matches the single-pass canonical form AND is stable across passes,
19789///     where the old flat prepend grew the string content every time.
19790fn format_block_body(body: &str, formatted: &mut String) {
19791    if body.trim().is_empty() {
19792        return;
19793    }
19794    let lines: Vec<&str> = body.lines().collect();
19795    let mut index = 0;
19796    let mut depth: i32 = 1;
19797    while index < lines.len() {
19798        let trimmed = lines[index].trim();
19799        if trimmed.is_empty() {
19800            formatted.push('\n');
19801            index += 1;
19802            continue;
19803        }
19804        let opens_with_closer = trimmed
19805            .chars()
19806            .next()
19807            .is_some_and(|ch| matches!(ch, '}' | ']' | ')'));
19808        let line_depth = if opens_with_closer {
19809            (depth - 1).max(0)
19810        } else {
19811            depth
19812        };
19813        let prefix = "  ".repeat(line_depth as usize);
19814        let (delta, opens_triple) = scan_braces(trimmed);
19815        push_line(formatted, format!("{prefix}{trimmed}"));
19816        if opens_triple {
19817            // Collect the string content up to the closing `"""`.
19818            let mut end = index + 1;
19819            while end < lines.len() && lines[end].matches("\"\"\"").count().is_multiple_of(2) {
19820                end += 1;
19821            }
19822            let content = &lines[index + 1..end];
19823            let common = content
19824                .iter()
19825                .filter(|line| !line.trim().is_empty())
19826                .map(|line| line.len() - line.trim_start().len())
19827                .min()
19828                .unwrap_or(0);
19829            for line in content {
19830                if line.trim().is_empty() {
19831                    formatted.push('\n');
19832                } else {
19833                    push_line(formatted, format!("{prefix}{}", &line[common..]));
19834                }
19835            }
19836            if end < lines.len() {
19837                // The closing-delimiter line, re-indented to the block depth.
19838                push_line(formatted, format!("{prefix}{}", lines[end].trim()));
19839            }
19840            index = end + 1;
19841        } else {
19842            index += 1;
19843        }
19844        depth = (depth + delta).max(0);
19845    }
19846}
19847
19848/// Net bracket-depth change for one line, ignoring brackets inside strings.
19849/// Returns `(delta, opens_unclosed_triple)`: a `true` second element means the
19850/// line starts a `"""..."""` that does not close on the same line, so following
19851/// lines are string content. ASCII markers only — UTF-8 string bytes can't
19852/// false-match.
19853fn scan_braces(line: &str) -> (i32, bool) {
19854    let bytes = line.as_bytes();
19855    let mut index = 0;
19856    let mut delta = 0i32;
19857    let mut in_string = false;
19858    while index < bytes.len() {
19859        if in_string {
19860            match bytes[index] {
19861                b'\\' => index += 1,
19862                b'"' => in_string = false,
19863                _ => {}
19864            }
19865            index += 1;
19866            continue;
19867        }
19868        if line[index..].starts_with("\"\"\"") {
19869            match line[index + 3..].find("\"\"\"") {
19870                Some(offset) => index += 3 + offset + 3,
19871                None => return (delta, true),
19872            }
19873            continue;
19874        }
19875        match bytes[index] {
19876            b'"' => in_string = true,
19877            b'{' | b'[' | b'(' => delta += 1,
19878            b'}' | b']' | b')' => delta -= 1,
19879            _ => {}
19880        }
19881        index += 1;
19882    }
19883    (delta, false)
19884}
19885
19886impl TypeSyntax {
19887    fn to_source(&self) -> String {
19888        match self {
19889            Self::Primitive { name, .. } => name.clone(),
19890            Self::LiteralString { value, .. } => format!("{value:?}"),
19891            Self::Ref { name } => name.name.clone(),
19892            Self::AgentRef { agents, .. } => {
19893                let agents = agents
19894                    .iter()
19895                    .map(|agent| agent.name.as_str())
19896                    .collect::<Vec<_>>()
19897                    .join(" | ");
19898                format!("AgentRef<{agents}>")
19899            }
19900            Self::Optional { inner, .. } => format!("{}?", inner.to_source()),
19901            Self::Array { inner, .. } => format!("{}[]", inner.to_source()),
19902            Self::Map { inner, .. } => format!("map<{}>", inner.to_source()),
19903            Self::Union { variants, .. } => variants
19904                .iter()
19905                .map(Self::to_source)
19906                .collect::<Vec<_>>()
19907                .join(" | "),
19908        }
19909    }
19910}
19911
19912/// Stage marker retained for the CLI scaffold.
19913pub fn parser_stage() -> &'static str {
19914    whipplescript_core::IMPLEMENTATION_STAGE
19915}
19916
19917#[derive(Clone, Debug, Eq, PartialEq)]
19918struct Lexed {
19919    tokens: Vec<Token>,
19920    diagnostics: Vec<Diagnostic>,
19921    comments: Vec<Comment>,
19922}
19923
19924#[derive(Clone, Debug, Eq, PartialEq)]
19925struct Token {
19926    kind: TokenKind,
19927    span: SourceSpan,
19928}
19929
19930#[derive(Clone, Debug, Eq, PartialEq)]
19931enum TokenKind {
19932    Ident(String),
19933    String(String),
19934    Number(String),
19935    Arrow,
19936    ThinArrow,
19937    Symbol(char),
19938}
19939
19940impl TokenKind {
19941    fn label(&self) -> String {
19942        match self {
19943            Self::Ident(value) => format!("identifier `{value}`"),
19944            Self::String(_) => "string literal".to_owned(),
19945            Self::Number(_) => "number literal".to_owned(),
19946            Self::Arrow => "`=>`".to_owned(),
19947            Self::ThinArrow => "`->`".to_owned(),
19948            Self::Symbol(value) => format!("`{value}`"),
19949        }
19950    }
19951}
19952
19953fn lex(source: &str) -> Lexed {
19954    let bytes = source.as_bytes();
19955    let mut tokens = Vec::new();
19956    let mut diagnostics = Vec::new();
19957    let mut comments = Vec::new();
19958    let mut index = 0;
19959
19960    while index < bytes.len() {
19961        let byte = bytes[index];
19962        if byte.is_ascii_whitespace() {
19963            index += 1;
19964            continue;
19965        }
19966
19967        if byte == b'#' {
19968            let end = skip_line(bytes, index + 1);
19969            comments.push(Comment {
19970                marker: CommentMarker::Hash,
19971                text: source[index + 1..end].trim().to_owned(),
19972                span: SourceSpan { start: index, end },
19973            });
19974            index = end;
19975            continue;
19976        }
19977
19978        if byte == b'/' && bytes.get(index + 1) == Some(&b'/') {
19979            let end = skip_line(bytes, index + 2);
19980            comments.push(Comment {
19981                marker: CommentMarker::Slash,
19982                text: source[index + 2..end].trim().to_owned(),
19983                span: SourceSpan { start: index, end },
19984            });
19985            index = end;
19986            continue;
19987        }
19988
19989        if is_ident_start(byte) {
19990            let start = index;
19991            index += 1;
19992            while index < bytes.len() && is_ident_continue(bytes[index]) {
19993                index += 1;
19994            }
19995            tokens.push(Token {
19996                kind: TokenKind::Ident(source[start..index].to_owned()),
19997                span: SourceSpan { start, end: index },
19998            });
19999            continue;
20000        }
20001
20002        if byte.is_ascii_digit() {
20003            let start = index;
20004            index += 1;
20005            while index < bytes.len() && bytes[index].is_ascii_digit() {
20006                index += 1;
20007            }
20008            tokens.push(Token {
20009                kind: TokenKind::Number(source[start..index].to_owned()),
20010                span: SourceSpan { start, end: index },
20011            });
20012            continue;
20013        }
20014
20015        if byte == b'"' {
20016            let (token, next, diagnostic) = lex_string(source, index);
20017            tokens.push(token);
20018            if let Some(diagnostic) = diagnostic {
20019                diagnostics.push(diagnostic);
20020            }
20021            index = next;
20022            continue;
20023        }
20024
20025        if byte == b'=' && bytes.get(index + 1) == Some(&b'>') {
20026            tokens.push(Token {
20027                kind: TokenKind::Arrow,
20028                span: SourceSpan {
20029                    start: index,
20030                    end: index + 2,
20031                },
20032            });
20033            index += 2;
20034            continue;
20035        }
20036
20037        if byte == b'=' && bytes.get(index + 1) == Some(&b'=') {
20038            index += 2;
20039            continue;
20040        }
20041
20042        if byte == b'!' && bytes.get(index + 1) == Some(&b'=') {
20043            index += 2;
20044            continue;
20045        }
20046
20047        if matches!(byte, b'<' | b'>') && bytes.get(index + 1) == Some(&b'=') {
20048            index += 2;
20049            continue;
20050        }
20051
20052        if matches!(byte, b'&' | b'|') && bytes.get(index + 1) == Some(&byte) {
20053            index += 2;
20054            continue;
20055        }
20056
20057        if byte == b'-' && bytes.get(index + 1) == Some(&b'>') {
20058            tokens.push(Token {
20059                kind: TokenKind::ThinArrow,
20060                span: SourceSpan {
20061                    start: index,
20062                    end: index + 2,
20063                },
20064            });
20065            index += 2;
20066            continue;
20067        }
20068
20069        // Arithmetic operators appear inside guard and field-value
20070        // expressions, which are re-parsed from raw source slices; the
20071        // file-level lexer only needs to step over them.
20072        if matches!(byte, b'*' | b'/' | b'-') {
20073            index += 1;
20074            continue;
20075        }
20076
20077        if b"{}[]()<>,?|.+!:@".contains(&byte) {
20078            tokens.push(Token {
20079                kind: TokenKind::Symbol(byte as char),
20080                span: SourceSpan {
20081                    start: index,
20082                    end: index + 1,
20083                },
20084            });
20085            index += 1;
20086            continue;
20087        }
20088
20089        diagnostics.push(Diagnostic {
20090            related: Vec::new(),
20091            span: SourceSpan {
20092                start: index,
20093                end: index + 1,
20094            },
20095            message: format!("unexpected character `{}`", byte as char),
20096            suggestion: None,
20097        });
20098        index += 1;
20099    }
20100
20101    Lexed {
20102        tokens,
20103        diagnostics,
20104        comments,
20105    }
20106}
20107
20108/// Extract the comments from a source program, in source order. Comments are not
20109/// part of the token stream or AST; this is the entry point tooling (`whip fmt`,
20110/// the LSP) uses to preserve them.
20111pub fn lex_comments(source: &str) -> Vec<Comment> {
20112    lex(source).comments
20113}
20114
20115/// Byte-span regions of string literals and comments in `source`. A tool that
20116/// edits identifier occurrences (e.g. `whip lsp` rename) consults these to avoid
20117/// touching text inside a prompt string or a comment — only code identifiers are
20118/// real references.
20119pub fn string_and_comment_spans(source: &str) -> Vec<SourceSpan> {
20120    let lexed = lex(source);
20121    let mut spans: Vec<SourceSpan> = lexed
20122        .tokens
20123        .iter()
20124        .filter(|token| matches!(token.kind, TokenKind::String(_)))
20125        .map(|token| token.span)
20126        .collect();
20127    spans.extend(lexed.comments.iter().map(|comment| comment.span));
20128    spans
20129}
20130
20131fn skip_line(bytes: &[u8], mut index: usize) -> usize {
20132    while index < bytes.len() && bytes[index] != b'\n' {
20133        index += 1;
20134    }
20135    index
20136}
20137
20138fn is_ident_start(byte: u8) -> bool {
20139    byte.is_ascii_alphabetic() || byte == b'_'
20140}
20141
20142fn is_ident_continue(byte: u8) -> bool {
20143    is_ident_start(byte) || byte.is_ascii_digit() || byte == b'-'
20144}
20145
20146fn lex_string(source: &str, start: usize) -> (Token, usize, Option<Diagnostic>) {
20147    let bytes = source.as_bytes();
20148    let triple = bytes.get(start..start + 3) == Some(b"\"\"\"");
20149    let content_start = if triple { start + 3 } else { start + 1 };
20150    let mut index = content_start;
20151
20152    while index < bytes.len() {
20153        if triple && bytes.get(index..index + 3) == Some(b"\"\"\"") {
20154            let end = index + 3;
20155            return (
20156                Token {
20157                    kind: TokenKind::String(source[content_start..index].to_owned()),
20158                    span: SourceSpan { start, end },
20159                },
20160                end,
20161                None,
20162            );
20163        }
20164
20165        if !triple && bytes[index] == b'"' {
20166            let end = index + 1;
20167            return (
20168                Token {
20169                    kind: TokenKind::String(source[content_start..index].to_owned()),
20170                    span: SourceSpan { start, end },
20171                },
20172                end,
20173                None,
20174            );
20175        }
20176
20177        if !triple && bytes[index] == b'\\' && index + 1 < bytes.len() {
20178            index += 2;
20179        } else {
20180            index += 1;
20181        }
20182    }
20183
20184    (
20185        Token {
20186            kind: TokenKind::String(source[content_start..].to_owned()),
20187            span: SourceSpan {
20188                start,
20189                end: source.len(),
20190            },
20191        },
20192        source.len(),
20193        Some(Diagnostic {
20194            related: Vec::new(),
20195            span: SourceSpan {
20196                start,
20197                end: source.len(),
20198            },
20199            message: "unterminated string literal".to_owned(),
20200            suggestion: Some("close the string literal".to_owned()),
20201        }),
20202    )
20203}
20204
20205/// The value kind of a `declaration_block` clause (Shape 1, DR-0011 amended
20206/// 2026-07-08 with `Duration`/`Glob`/`Schema`/`Scalar`/`Flag`). The order-free
20207/// analog of `body::SlotKind` for top-level declarations. A `Flag` clause is a
20208/// bare presence clause carrying no value.
20209#[derive(Clone, Copy, Debug)]
20210#[allow(dead_code)]
20211enum ClauseKind {
20212    Identifier,
20213    Expression,
20214    Duration,
20215    Glob,
20216    Schema,
20217    Scalar,
20218    Flag,
20219}
20220
20221/// The typed AST node a migrated declaration lowers to — the one hand-written
20222/// seam of the otherwise data-driven Shape 1 pipeline. `effect_operation`
20223/// lowers to a uniform node, but the seven decls each build a distinct typed
20224/// node, so a future dispatch slice switches on this. Unused in D2.0 beyond
20225/// being carried in the spec.
20226#[derive(Clone, Copy, Debug)]
20227#[allow(dead_code)]
20228enum DeclAstKind {
20229    Tracker,
20230    Channel,
20231    Counter,
20232    Lease,
20233    Ledger,
20234    MemoryPool,
20235    FileStore,
20236}
20237
20238/// One order-free clause of a `declaration_block` construct: a named value
20239/// (`words` = the build-time-split clause-name tokens; single-word names are a
20240/// one-element slice), an optional `connective` consumed before the value
20241/// (`Some("by")` for ledger `partition by`; shares Shape 2's vocabulary plus
20242/// `by`), a value `kind`, whether it is a `[ ... ]` `list`, and the
20243/// `unknown_hint` shown when a sibling clause name is not recognized. The
20244/// order-free analog of `body::EffectSlotSpec`.
20245///
20246/// `required`/`missing_summary` are NOT carried here: required-ness is a
20247/// validation concern, not a parse concern. For the std decls it is enforced
20248/// by the typed-node builder (`item_from_decl_ast`, the hand-written seam,
20249/// which also owns the bespoke domain guidance); the manifest still declares
20250/// `required`/`missing_summary` as the third-party contract validated by the
20251/// CLI manifest validator.
20252#[derive(Clone, Copy, Debug)]
20253struct ClauseSpec {
20254    name: &'static str,
20255    words: &'static [&'static str],
20256    connective: Option<&'static str>,
20257    kind: ClauseKind,
20258    list: bool,
20259    unknown_hint: &'static str,
20260}
20261
20262/// The full grammar of one `declaration_block` construct (Shape 1). `keyword`
20263/// is the full head phrase (`"memory pool"`/`"file store"`); `keyword_words`
20264/// is its whitespace-split tokens (head-word dispatch reads `keyword_words[0]`).
20265/// `ast_kind` is the hand-written builder seam.
20266#[derive(Clone, Copy, Debug)]
20267#[allow(dead_code)]
20268struct DeclarationBlockSpec {
20269    keyword: &'static str,
20270    keyword_words: &'static [&'static str],
20271    ast_kind: DeclAstKind,
20272    clauses: &'static [ClauseSpec],
20273}
20274
20275// The table itself is generated at build time from the grammar-only manifests
20276// (std/grammars/*.json) by build.rs, mirroring `EFFECT_OPERATION_GRAMMAR`: each
20277// declaration_block construct's DR-0011 `grammar` object transcribes into one
20278// `DeclarationBlockSpec` row, so the manifests are the single source of parse
20279// grammar and the table can never drift from them. D2.0 builds the table and
20280// unit-tests it; nothing dispatches through it yet.
20281include!(concat!(env!("OUT_DIR"), "/declaration_block_grammar.rs"));
20282
20283/// The parsed value of one matched `declaration_block` clause. A `Scalar` clause
20284/// is literal-polymorphic: `cap`/`slots`/`context limit` carry a `Number`, while
20285/// file-store `root` and channel `destination` carry `Str` — the grammar marks
20286/// both `scalar` and the per-decl builder casts to the field's width/shape.
20287/// `Missing` records a clause whose name matched but whose value failed to parse,
20288/// so the first-word span is still captured (file-store `root_span` is set on the
20289/// clause keyword regardless of the value's fate).
20290#[derive(Clone, Debug)]
20291enum ClauseValue {
20292    Ident(Ident),
20293    Duration(u64),
20294    Number(u32),
20295    Str(StringLiteral),
20296    Globs(Vec<String>),
20297    Flag,
20298    Missing,
20299}
20300
20301/// The order-free accumulator a generic `parse_declaration_block` fills as it
20302/// reads a decl's brace block, keyed by the spec clause `name`; the per-decl
20303/// typed-node builder reads it by name. Each record also carries the FIRST
20304/// clause-name-word token span (file-store/memory-pool serialize these into the
20305/// AST for `whip fmt`). Last write wins, matching the hand parsers' field
20306/// overwrite. The Shape-1 analog of the ordered field vector Shape 2 builds.
20307struct ClauseBag {
20308    records: Vec<(&'static str, SourceSpan, ClauseValue)>,
20309}
20310
20311impl ClauseBag {
20312    fn new() -> Self {
20313        ClauseBag {
20314            records: Vec::new(),
20315        }
20316    }
20317
20318    fn record(&mut self, name: &'static str, first_word_span: SourceSpan, value: ClauseValue) {
20319        self.records.push((name, first_word_span, value));
20320    }
20321
20322    fn get(&self, name: &str) -> Option<&(&'static str, SourceSpan, ClauseValue)> {
20323        self.records
20324            .iter()
20325            .rev()
20326            .find(|(clause, _, _)| *clause == name)
20327    }
20328
20329    fn ident(&self, name: &str) -> Option<Ident> {
20330        match self.get(name) {
20331            Some((_, _, ClauseValue::Ident(ident))) => Some(ident.clone()),
20332            _ => None,
20333        }
20334    }
20335
20336    fn duration(&self, name: &str) -> Option<u64> {
20337        match self.get(name) {
20338            Some((_, _, ClauseValue::Duration(seconds))) => Some(*seconds),
20339            _ => None,
20340        }
20341    }
20342
20343    fn number(&self, name: &str) -> Option<u32> {
20344        match self.get(name) {
20345            Some((_, _, ClauseValue::Number(value))) => Some(*value),
20346            _ => None,
20347        }
20348    }
20349
20350    fn text(&self, name: &str) -> Option<String> {
20351        self.text_literal(name).map(|literal| literal.value)
20352    }
20353
20354    fn text_literal(&self, name: &str) -> Option<StringLiteral> {
20355        match self.get(name) {
20356            Some((_, _, ClauseValue::Str(literal))) => Some(literal.clone()),
20357            _ => None,
20358        }
20359    }
20360
20361    fn globs(&self, name: &str) -> Vec<String> {
20362        match self.get(name) {
20363            Some((_, _, ClauseValue::Globs(values))) => values.clone(),
20364            _ => Vec::new(),
20365        }
20366    }
20367
20368    fn flag(&self, name: &str) -> bool {
20369        matches!(self.get(name), Some((_, _, ClauseValue::Flag)))
20370    }
20371
20372    fn span(&self, name: &str) -> Option<SourceSpan> {
20373        self.get(name).map(|(_, span, _)| *span)
20374    }
20375}
20376
20377struct Parser<'a> {
20378    source: &'a str,
20379    tokens: Vec<Token>,
20380    pos: usize,
20381    diagnostics: Vec<Diagnostic>,
20382    /// S7 inline contract payloads: classes synthesized from `output result {
20383    /// … }`-style blocks, appended to the item list after the parse loop.
20384    pending_contract_classes: Vec<ClassDecl>,
20385}
20386
20387struct ParsedWorkflow {
20388    decl: WorkflowDecl,
20389    explicit_body: bool,
20390}
20391
20392impl Parser<'_> {
20393    fn parse_program(&mut self) -> Program {
20394        let mut workflow = None;
20395        let mut workflow_tags = Vec::new();
20396        let mut workflow_description = None;
20397        let mut explicit_workflow_body = false;
20398        let mut workflows = Vec::new();
20399        let mut patterns = Vec::new();
20400        let mut items = Vec::new();
20401        let mut pending_tags = Vec::new();
20402        let mut pending_description = None;
20403
20404        while !self.is_at_end() {
20405            if self.at_symbol('@') {
20406                if let Some(tag) = self.parse_tag() {
20407                    pending_tags.push(tag);
20408                }
20409            } else if self.at_ident("description") {
20410                self.parse_pending_description(&mut pending_description);
20411            } else if self.at_ident("workflow") {
20412                if let Some(parsed_workflow) = self.parse_workflow(
20413                    std::mem::take(&mut pending_tags),
20414                    pending_description.take(),
20415                ) {
20416                    if parsed_workflow.explicit_body {
20417                        workflows.push(parsed_workflow.decl);
20418                    } else {
20419                        if workflow.is_some() {
20420                            self.diagnostics.push(Diagnostic { related: Vec::new(),
20421                                span: parsed_workflow.decl.name.span,
20422                                message: "multiple implicit workflow headers are not supported"
20423                                    .to_owned(),
20424                                suggestion: Some(
20425                                    "use explicit `workflow Name { ... }` declarations with `--root`"
20426                                        .to_owned(),
20427                                ),
20428                            });
20429                        }
20430                        workflow_tags = parsed_workflow.decl.tags;
20431                        workflow_description = parsed_workflow.decl.description;
20432                        // A header-form workflow carries no block, so its only
20433                        // items are the compact-signature contracts (if any);
20434                        // those are top-level for a single-workflow program.
20435                        items.extend(parsed_workflow.decl.items);
20436                        workflow = Some(parsed_workflow.decl.name);
20437                        explicit_workflow_body = false;
20438                    }
20439                }
20440            } else if self.at_ident("pattern") {
20441                self.reject_pending_tags(&mut pending_tags, "pattern");
20442                self.reject_pending_description(&mut pending_description, "pattern");
20443                if let Some(pattern) = self.parse_pattern() {
20444                    patterns.push(pattern);
20445                }
20446            } else if let Some(item) =
20447                self.parse_declaration_item(&mut pending_tags, &mut pending_description)
20448            {
20449                items.push(item);
20450            } else if self.reject_gherkin_misuse() {
20451                continue;
20452            } else {
20453                if self.is_at_end() {
20454                    break;
20455                }
20456                self.unexpected("top-level declaration");
20457                if !self.is_at_end() {
20458                    self.advance();
20459                }
20460            }
20461        }
20462
20463        // S7: classes synthesized from inline contract payloads join the item
20464        // list like ordinary declarations.
20465        items.extend(
20466            std::mem::take(&mut self.pending_contract_classes)
20467                .into_iter()
20468                .map(Item::Class),
20469        );
20470
20471        Program {
20472            workflow,
20473            workflow_tags,
20474            workflow_description,
20475            explicit_workflow_body,
20476            workflows,
20477            patterns,
20478            items,
20479        }
20480    }
20481
20482    fn parse_workflow(
20483        &mut self,
20484        tags: Vec<TagDecl>,
20485        description: Option<StringLiteral>,
20486    ) -> Option<ParsedWorkflow> {
20487        let start = self.expect_keyword("workflow")?.span.start;
20488        let name = self.expect_ident("workflow name")?;
20489        let mut explicit_body = false;
20490        let mut items = Vec::new();
20491        let mut end = name.span.end;
20492        // Optional compact contract signature: `Name(in: T, ...) -> Out [! Fail]`.
20493        // Desugars to the same `input`/`output`/`failure` contract decls as the
20494        // keyword form, with the output named `result` and the failure `error`
20495        // (the conventional names). Both forms are legal; `whip fmt` re-emits the
20496        // keyword lines (one canonical stored shape).
20497        if self.at_symbol('(') {
20498            if let Some((contracts, signature_end)) = self.parse_compact_contract_signature() {
20499                end = signature_end;
20500                items.extend(contracts.into_iter().map(Item::WorkflowContract));
20501            }
20502        }
20503        if self.at_symbol('{') {
20504            explicit_body = true;
20505            self.expect_symbol('{')?;
20506            let mut pending_tags = Vec::new();
20507            let mut pending_description = None;
20508            while !self.is_at_end() && !self.at_symbol('}') {
20509                if self.at_symbol('@') {
20510                    if let Some(tag) = self.parse_tag() {
20511                        pending_tags.push(tag);
20512                    }
20513                    continue;
20514                }
20515                if self.at_ident("description") {
20516                    self.parse_pending_description(&mut pending_description);
20517                    continue;
20518                }
20519                if self.at_ident("workflow") || self.at_ident("pattern") {
20520                    self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
20521                    self.reject_pending_description(
20522                        &mut pending_description,
20523                        "workflow body declaration",
20524                    );
20525                    self.unexpected("workflow body declaration");
20526                    self.advance();
20527                    continue;
20528                }
20529                if let Some(item) =
20530                    self.parse_declaration_item(&mut pending_tags, &mut pending_description)
20531                {
20532                    items.push(item);
20533                } else if self.reject_gherkin_misuse() {
20534                    continue;
20535                } else {
20536                    if self.is_at_end() {
20537                        break;
20538                    }
20539                    self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
20540                    self.reject_pending_description(
20541                        &mut pending_description,
20542                        "workflow body declaration",
20543                    );
20544                    self.unexpected("workflow body declaration");
20545                    if !self.is_at_end() {
20546                        self.advance();
20547                    }
20548                }
20549            }
20550            if let Some(close) = self.expect_symbol('}') {
20551                end = close.span.end;
20552            }
20553        }
20554        // S7: classes synthesized from this workflow's inline contract payloads
20555        // stay in ITS scope (braced-workflow schemas are workflow-scoped), so
20556        // two workflows can both write `output result { … }` without their
20557        // `output.result` classes colliding.
20558        items.extend(
20559            std::mem::take(&mut self.pending_contract_classes)
20560                .into_iter()
20561                .map(Item::Class),
20562        );
20563        Some(ParsedWorkflow {
20564            decl: WorkflowDecl {
20565                name,
20566                tags,
20567                description,
20568                items,
20569                span: SourceSpan { start, end },
20570            },
20571            explicit_body,
20572        })
20573    }
20574
20575    /// Parses a compact contract signature `(name: Type, ...) -> Output [! Failure]`
20576    /// into the same contract decls the keyword form produces. The output binding
20577    /// is named `result` and the failure `error` — the conventional names used by
20578    /// `complete result` / `fail error`. Returns the contracts and the signature's
20579    /// end offset (so the workflow span covers it).
20580    fn parse_compact_contract_signature(&mut self) -> Option<(Vec<WorkflowContractDecl>, usize)> {
20581        self.expect_symbol('(')?;
20582        let mut contracts = Vec::new();
20583        while !self.is_at_end() && !self.at_symbol(')') {
20584            let name = self.expect_ident("workflow input name")?;
20585            self.expect_symbol(':')?;
20586            let ty = self.parse_type()?;
20587            let span = name.span.join(ty.span());
20588            contracts.push(WorkflowContractDecl {
20589                kind: WorkflowContractKind::Input,
20590                name,
20591                ty,
20592                span,
20593            });
20594            if self.at_symbol(',') {
20595                self.advance();
20596            } else if !self.at_symbol(')') {
20597                self.unexpected("`,` or `)`");
20598                while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
20599                    self.advance();
20600                }
20601            }
20602        }
20603        self.expect_symbol(')')?;
20604        self.expect_thin_arrow()?;
20605        let output_ty = self.parse_type()?;
20606        let output_span = output_ty.span();
20607        let mut end = output_span.end;
20608        contracts.push(WorkflowContractDecl {
20609            kind: WorkflowContractKind::Output,
20610            name: Ident {
20611                name: "result".to_owned(),
20612                span: output_span,
20613            },
20614            ty: output_ty,
20615            span: output_span,
20616        });
20617        if self.at_symbol('!') {
20618            self.advance();
20619            let failure_ty = self.parse_type()?;
20620            let failure_span = failure_ty.span();
20621            end = failure_span.end;
20622            contracts.push(WorkflowContractDecl {
20623                kind: WorkflowContractKind::Failure,
20624                name: Ident {
20625                    name: "error".to_owned(),
20626                    span: failure_span,
20627                },
20628                ty: failure_ty,
20629                span: failure_span,
20630            });
20631        }
20632        Some((contracts, end))
20633    }
20634
20635    fn parse_tag(&mut self) -> Option<TagDecl> {
20636        let at = self.expect_symbol('@')?;
20637        let name_start = at.span.end;
20638        let mut name_end = name_start;
20639        for (offset, ch) in self.source[name_start..].char_indices() {
20640            if ch.is_whitespace() {
20641                break;
20642            }
20643            name_end = name_start + offset + ch.len_utf8();
20644        }
20645        let name = self.source[name_start..name_end].to_owned();
20646        while !self.is_at_end() && self.peek().is_some_and(|token| token.span.start < name_end) {
20647            self.advance();
20648        }
20649        let span = SourceSpan {
20650            start: at.span.start,
20651            end: name_end,
20652        };
20653        if name.is_empty() {
20654            self.diagnostics.push(Diagnostic {
20655                related: Vec::new(),
20656                span,
20657                message: "tag is missing a name".to_owned(),
20658                suggestion: Some("write a tag such as `@fixture`".to_owned()),
20659            });
20660            return None;
20661        }
20662        if !name
20663            .chars()
20664            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
20665        {
20666            self.diagnostics.push(Diagnostic {
20667                related: Vec::new(),
20668                span,
20669                message: format!("tag `@{name}` contains unsupported characters"),
20670                suggestion: Some(
20671                    "use letters, digits, `_`, `-`, `.`, or `:` in tag names".to_owned(),
20672                ),
20673            });
20674            return None;
20675        }
20676        Some(TagDecl { name, span })
20677    }
20678
20679    fn reject_pending_tags(&mut self, pending_tags: &mut Vec<TagDecl>, target: &str) {
20680        for tag in pending_tags.drain(..) {
20681            self.diagnostics.push(Diagnostic {
20682                related: Vec::new(),
20683                span: tag.span,
20684                message: format!("tag `@{}` cannot be attached to {target}", tag.name),
20685                suggestion: Some(
20686                    "place tags on workflows, matrices, assertions, or rules".to_owned(),
20687                ),
20688            });
20689        }
20690    }
20691
20692    fn parse_pending_description(&mut self, pending_description: &mut Option<StringLiteral>) {
20693        let Some(description) = self.parse_description() else {
20694            return;
20695        };
20696        if let Some(previous) = pending_description.replace(description) {
20697            self.diagnostics.push(Diagnostic { related: Vec::new(),
20698                span: previous.span,
20699                message: "description is not attached to a declaration".to_owned(),
20700                suggestion: Some(
20701                    "place only one `description \"...\"` immediately before the target declaration"
20702                        .to_owned(),
20703                ),
20704            });
20705        }
20706    }
20707
20708    fn parse_description(&mut self) -> Option<StringLiteral> {
20709        let description = self.expect_keyword("description")?;
20710        let Some(value) = self.expect_string("description string") else {
20711            return Some(StringLiteral {
20712                value: String::new(),
20713                span: description.span,
20714            });
20715        };
20716        Some(value)
20717    }
20718
20719    fn reject_pending_description(
20720        &mut self,
20721        pending_description: &mut Option<StringLiteral>,
20722        target: &str,
20723    ) {
20724        if let Some(description) = pending_description.take() {
20725            self.diagnostics.push(Diagnostic {
20726                related: Vec::new(),
20727                span: description.span,
20728                message: format!("description cannot be attached to {target}"),
20729                suggestion: Some(
20730                    "place descriptions on workflows, matrices, assertions, or rules".to_owned(),
20731                ),
20732            });
20733        }
20734    }
20735
20736    fn reject_gherkin_misuse(&mut self) -> bool {
20737        let Some(token) = self.peek() else {
20738            return false;
20739        };
20740        let TokenKind::Ident(keyword) = &token.kind else {
20741            return false;
20742        };
20743        if !is_gherkin_keyword(keyword) {
20744            return false;
20745        }
20746        let span = token.span;
20747        self.diagnostics.push(Diagnostic { related: Vec::new(),
20748            span,
20749            message: format!(
20750                "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
20751            ),
20752            suggestion: Some(
20753                "use `workflow`, `table`, `rule ... when ... => { ... }`, and `assert` instead of free-text Given/When/Then steps"
20754                    .to_owned(),
20755            ),
20756        });
20757        self.advance_to_line_end(span.start);
20758        true
20759    }
20760
20761    fn advance_to_line_end(&mut self, line_start: usize) {
20762        let line_end = self.source[line_start..]
20763            .find('\n')
20764            .map(|offset| line_start + offset)
20765            .unwrap_or(self.source.len());
20766        while self.peek().is_some_and(|token| token.span.start < line_end) {
20767            self.advance();
20768        }
20769    }
20770
20771    fn parse_declaration_item(
20772        &mut self,
20773        pending_tags: &mut Vec<TagDecl>,
20774        pending_description: &mut Option<StringLiteral>,
20775    ) -> Option<Item> {
20776        // Data-driven `declaration_block` dispatch (Shape 1), the first check —
20777        // the analog of `body.rs`'s `effect_operation_spec` hook. Head-word peek;
20778        // the five real exceptions (harness/agent/signal/source/coerce) and all
20779        // core decls are absent from the grammar table, so table membership is
20780        // the partition. Every declaration-family construct parses through
20781        // `parse_declaration_block` + its typed-node builder — no hand parsers.
20782        if let Some(spec) = self.declaration_block_spec_at() {
20783            self.reject_pending_tags(pending_tags, spec.keyword);
20784            self.reject_pending_description(pending_description, spec.keyword);
20785            return self.parse_declaration_block(spec);
20786        }
20787        if self.at_ident("include") {
20788            self.reject_pending_tags(pending_tags, "include");
20789            self.reject_pending_description(pending_description, "include");
20790            self.parse_include().map(Item::Include)
20791        } else if self.at_ident("use") {
20792            self.reject_pending_tags(pending_tags, "use");
20793            self.reject_pending_description(pending_description, "use");
20794            self.parse_use().map(Item::Use)
20795        } else if self.at_ident("pattern") {
20796            self.reject_pending_tags(pending_tags, "pattern");
20797            self.reject_pending_description(pending_description, "pattern");
20798            self.parse_pattern().map(Item::Pattern)
20799        } else if self.at_ident("apply") {
20800            self.reject_pending_tags(pending_tags, "apply");
20801            self.reject_pending_description(pending_description, "apply");
20802            self.parse_apply().map(Item::Apply)
20803        } else if self.at_ident("input") || self.at_ident("output") || self.at_ident("failure") {
20804            self.reject_pending_tags(pending_tags, "workflow contract");
20805            self.reject_pending_description(pending_description, "workflow contract");
20806            self.parse_workflow_contract().map(Item::WorkflowContract)
20807        } else if self.at_ident("flow") {
20808            // R2 (language-refinement campaign): the `flow` declaration was
20809            // REMOVED — sequential pipelines are written as a rule chaining
20810            // steps with `then <binding> <- <effect>`.
20811            let span = self
20812                .peek()
20813                .map(|token| token.span)
20814                .unwrap_or(SourceSpan { start: 0, end: 0 });
20815            self.diagnostics.push(Diagnostic {
20816                related: Vec::new(),
20817                span,
20818                message: "the `flow` declaration was removed".to_owned(),
20819                suggestion: Some(
20820                    "write a `rule` and chain sequential steps with `then <binding> <- <effect>`"
20821                        .to_owned(),
20822                ),
20823            });
20824            // Recovery: swallow the whole flow declaration (headers + balanced
20825            // body) so the body's statements don't each re-error as bogus
20826            // top-level declarations.
20827            let mut depth = 0usize;
20828            while !self.is_at_end() {
20829                let token = self.advance();
20830                match &token.kind {
20831                    TokenKind::Symbol('{') => depth += 1,
20832                    TokenKind::Symbol('}') => {
20833                        depth = depth.saturating_sub(1);
20834                        if depth == 0 {
20835                            break;
20836                        }
20837                    }
20838                    _ => {}
20839                }
20840            }
20841            None
20842        } else if self.at_ident("action") {
20843            self.reject_pending_tags(pending_tags, "action");
20844            self.reject_pending_description(pending_description, "action");
20845            self.parse_action().map(Item::Action)
20846        } else if self.at_ident("harness") {
20847            self.reject_pending_tags(pending_tags, "harness");
20848            self.reject_pending_description(pending_description, "harness");
20849            self.parse_harness().map(Item::Harness)
20850        } else if self.at_ident("agent") {
20851            self.reject_pending_tags(pending_tags, "agent");
20852            self.reject_pending_description(pending_description, "agent");
20853            self.parse_agent().map(Item::Agent)
20854        } else if self.at_ident("enum") {
20855            self.reject_pending_tags(pending_tags, "enum");
20856            self.reject_pending_description(pending_description, "enum");
20857            self.parse_enum().map(Item::Enum)
20858        } else if self.at_ident("signal") {
20859            self.reject_pending_tags(pending_tags, "signal");
20860            self.reject_pending_description(pending_description, "signal");
20861            self.parse_event().map(Item::Event)
20862        } else if self.at_ident("gauge") {
20863            self.reject_pending_tags(pending_tags, "gauge");
20864            self.reject_pending_description(pending_description, "gauge");
20865            self.parse_gauge().map(Item::Gauge)
20866        } else if self.at_ident("campaign") {
20867            self.reject_pending_tags(pending_tags, "campaign");
20868            self.reject_pending_description(pending_description, "campaign");
20869            self.parse_campaign().map(Item::Campaign)
20870        } else if self.at_ident("mark") {
20871            self.reject_pending_tags(pending_tags, "mark");
20872            self.reject_pending_description(pending_description, "mark");
20873            self.parse_mark().map(Item::Mark)
20874        } else if self.at_ident("source") {
20875            self.reject_pending_tags(pending_tags, "source");
20876            self.reject_pending_description(pending_description, "source");
20877            self.parse_source()
20878                .map(|source| Item::Source(Box::new(source)))
20879        } else if self.at_ident("test") {
20880            self.reject_pending_tags(pending_tags, "test");
20881            self.reject_pending_description(pending_description, "test");
20882            self.parse_test().map(Item::Test)
20883        } else if self.at_ident("class") {
20884            self.reject_pending_tags(pending_tags, "class");
20885            self.reject_pending_description(pending_description, "class");
20886            self.parse_class().map(Item::Class)
20887        } else if self.at_ident("table") {
20888            self.parse_table(std::mem::take(pending_tags), pending_description.take())
20889                .map(Item::Table)
20890        } else if self.at_ident("coerce") {
20891            self.reject_pending_tags(pending_tags, "coerce");
20892            self.reject_pending_description(pending_description, "coerce");
20893            self.parse_coerce().map(Item::Coerce)
20894        } else if self.at_ident("assert") {
20895            self.parse_assert(std::mem::take(pending_tags), pending_description.take())
20896                .map(Item::Assert)
20897        } else if self.at_ident("rule") {
20898            self.parse_rule(std::mem::take(pending_tags), pending_description.take())
20899                .map(Item::Rule)
20900        } else {
20901            None
20902        }
20903    }
20904
20905    /// Peek (without consuming) the `declaration_block` grammar whose keyword
20906    /// head word matches the current token. Head-word dispatch only
20907    /// (`keyword_words[0]`, NOT a 2-token peek — a 2-token peek mis-routes a
20908    /// malformed `file <x>` to the wrong diagnostic; tail validation belongs in
20909    /// the per-decl parser). The exact analog of `body::effect_operation_spec`.
20910    fn declaration_block_spec_at(&self) -> Option<&'static DeclarationBlockSpec> {
20911        let head = match self.peek().map(|token| &token.kind) {
20912            Some(TokenKind::Ident(value)) => value.as_str(),
20913            _ => return None,
20914        };
20915        DECLARATION_BLOCK_GRAMMAR
20916            .iter()
20917            .find(|spec| spec.keyword_words.first() == Some(&head))
20918    }
20919
20920    /// The single generic top-level `declaration_block` parser (Shape 1) — the
20921    /// analog of `body::parse_effect_operation`. The head word is already matched
20922    /// by `declaration_block_spec_at`; this consumes the keyword (validating any
20923    /// tail word, e.g. `store` after `file`), the name, and an order-free brace
20924    /// block of clauses into a `ClauseBag`, then hands off to the per-decl typed
20925    /// node builder (`item_from_decl_ast`). Unknown clauses emit the spec's
20926    /// `unknown_hint` and resynchronize (file-store precedent).
20927    fn parse_declaration_block(&mut self, spec: &'static DeclarationBlockSpec) -> Option<Item> {
20928        let head = *spec.keyword_words.first()?;
20929        let start = self.expect_keyword(head)?.span.start;
20930        for tail in &spec.keyword_words[1..] {
20931            if !self.consume_ident(tail) {
20932                self.expected(format!("`{tail}` after `{head}`"));
20933                return None;
20934            }
20935        }
20936        let name = self.expect_ident(&format!("{} name", spec.keyword))?;
20937        // Surface-defaults batch (R4 S1/S2): the block is optional. A bare
20938        // declaration (`tracker backlog`) parses with an empty clause bag;
20939        // constructs whose clauses are all optional/defaulted accept it, and
20940        // ones with required clauses report their own missing-field
20941        // diagnostics (better than "expected `{`").
20942        if !self.at_symbol('{') {
20943            let span = SourceSpan {
20944                start,
20945                end: name.span.end,
20946            };
20947            let bag = ClauseBag::new();
20948            return self.item_from_decl_ast(spec.ast_kind, &bag, name, span);
20949        }
20950        self.expect_symbol('{')?;
20951        let field_label = format!("{} field", spec.keyword);
20952        let mut bag = ClauseBag::new();
20953        while !self.is_at_end() && !self.at_symbol('}') {
20954            let Some(field) = self.expect_ident(&field_label) else {
20955                self.synchronize_to_block_item();
20956                continue;
20957            };
20958            // Greedy multi-word clause-name match: `field` is the first word;
20959            // extend with follow words while some clause name is a longer prefix
20960            // match (file-store `allow read`/`allow write`, memory `context limit`).
20961            let first_word_span = field.span;
20962            let mut words = vec![field.name];
20963            let matched = loop {
20964                let depth = words.len();
20965                if let Some(clause) = spec.clauses.iter().find(|clause| {
20966                    clause.words.len() == depth
20967                        && clause
20968                            .words
20969                            .iter()
20970                            .zip(&words)
20971                            .all(|(a, b)| *a == b.as_str())
20972                }) {
20973                    break Some(clause);
20974                }
20975                let extendable = spec.clauses.iter().any(|clause| {
20976                    clause.words.len() > depth
20977                        && clause
20978                            .words
20979                            .iter()
20980                            .zip(&words)
20981                            .all(|(a, b)| *a == b.as_str())
20982                });
20983                if !extendable {
20984                    break None;
20985                }
20986                let Some(next) = self.expect_ident("clause name") else {
20987                    break None;
20988                };
20989                words.push(next.name);
20990            };
20991            let Some(clause) = matched else {
20992                let hint = spec.clauses.first().map(|clause| clause.unknown_hint);
20993                self.diagnostics.push(Diagnostic {
20994                    related: Vec::new(),
20995                    span: first_word_span,
20996                    message: format!("unknown {} field `{}`", spec.keyword, words.join(" ")),
20997                    suggestion: hint.map(str::to_owned),
20998                });
20999                self.synchronize_to_block_item();
21000                continue;
21001            };
21002            // Clause connective (ledger `partition by`): mandatory (M2) — a
21003            // missing connective is a parse error, like a Shape 2 slot connective.
21004            if let Some(connective) = clause.connective {
21005                if !self.consume_ident(connective) {
21006                    self.diagnostics.push(Diagnostic {
21007                        related: Vec::new(),
21008                        span: first_word_span,
21009                        message: format!("expected `{connective}` after `{}`", clause.name),
21010                        suggestion: Some(format!("write `{} {connective} <field>`", clause.name)),
21011                    });
21012                    self.synchronize_to_block_item();
21013                    continue;
21014                }
21015            }
21016            let value = self.parse_clause_value(clause);
21017            bag.record(clause.name, first_word_span, value);
21018        }
21019        let close = self.expect_symbol('}')?;
21020        let span = SourceSpan {
21021            start,
21022            end: close.span.end,
21023        };
21024        self.item_from_decl_ast(spec.ast_kind, &bag, name, span)
21025    }
21026
21027    /// Parse one clause's value by its `ClauseKind`, recording `Missing` on a
21028    /// failed parse so the clause's first-word span is still captured. A `Scalar`
21029    /// is literal-polymorphic (number or string); the builder casts.
21030    fn parse_clause_value(&mut self, clause: &ClauseSpec) -> ClauseValue {
21031        match clause.kind {
21032            ClauseKind::Identifier | ClauseKind::Schema => self
21033                .expect_ident(&format!("{} value", clause.name))
21034                .map_or(ClauseValue::Missing, ClauseValue::Ident),
21035            ClauseKind::Duration => self
21036                .parse_decl_duration_seconds(&format!("{} duration", clause.name))
21037                .map_or(ClauseValue::Missing, ClauseValue::Duration),
21038            ClauseKind::Scalar => match self.peek().map(|token| &token.kind) {
21039                Some(TokenKind::String(_)) => self
21040                    .expect_string(&format!("{} value", clause.name))
21041                    .map_or(ClauseValue::Missing, ClauseValue::Str),
21042                _ => self
21043                    .expect_u32(&format!("{} value", clause.name))
21044                    .map_or(ClauseValue::Missing, |(value, _)| {
21045                        ClauseValue::Number(value)
21046                    }),
21047            },
21048            ClauseKind::Glob if clause.list => {
21049                // Route glob lists through the shared list parser UNCHANGED — it
21050                // keeps its "skill string" element label (avoids file-store
21051                // negative-fixture churn).
21052                let globs = self
21053                    .parse_string_list()
21054                    .map(|(literals, _)| literals.into_iter().map(|l| l.value).collect())
21055                    .unwrap_or_default();
21056                ClauseValue::Globs(globs)
21057            }
21058            ClauseKind::Glob => self
21059                .expect_string(&format!("{} value", clause.name))
21060                .map_or(ClauseValue::Missing, ClauseValue::Str),
21061            ClauseKind::Flag => ClauseValue::Flag,
21062            // No migrated decl carries an expression clause; recorded inert.
21063            ClauseKind::Expression => ClauseValue::Missing,
21064        }
21065    }
21066
21067    /// The one hand-written seam of the data-driven pipeline: build the distinct
21068    /// typed AST node each `declaration_block` lowers to from the order-free
21069    /// `ClauseBag`, reproducing each hand parser's required-field check, bespoke
21070    /// missing-field diagnostic, and field-width casts exactly (so success `.ir`
21071    /// and coord IR fields are byte-identical).
21072    fn item_from_decl_ast(
21073        &mut self,
21074        ast_kind: DeclAstKind,
21075        bag: &ClauseBag,
21076        name: Ident,
21077        span: SourceSpan,
21078    ) -> Option<Item> {
21079        match ast_kind {
21080            DeclAstKind::Tracker => {
21081                // S1: `tracker <name>` bare — provider defaults to `builtin`,
21082                // today's only provider.
21083                let provider = bag.ident("provider").unwrap_or_else(|| Ident {
21084                    name: "builtin".to_owned(),
21085                    span,
21086                });
21087                Some(Item::Tracker(TrackerDecl {
21088                    name,
21089                    provider,
21090                    span,
21091                }))
21092            }
21093            DeclAstKind::Channel => {
21094                // S2: `channel <name>` bare — provider defaults to `local`.
21095                let provider = bag.ident("provider").unwrap_or_else(|| Ident {
21096                    name: "local".to_owned(),
21097                    span,
21098                });
21099                Some(Item::Channel(ChannelDecl {
21100                    name,
21101                    provider,
21102                    workspace: bag.ident("workspace"),
21103                    destination: bag.text_literal("destination"),
21104                    span,
21105                }))
21106            }
21107            DeclAstKind::Counter => {
21108                let key_type = bag.ident("key");
21109                let cap = bag.number("cap").map(i64::from);
21110                // `reset`: identifier + membership check; an invalid period keeps
21111                // the value (matching the hand parser) but emits the enum diagnostic.
21112                let reset = bag.ident("reset").map(|period| {
21113                    if !matches!(
21114                        period.name.as_str(),
21115                        "hourly" | "daily" | "weekly" | "monthly"
21116                    ) {
21117                        self.diagnostics.push(Diagnostic {
21118                            related: Vec::new(),
21119                            span: period.span,
21120                            message: format!("unknown reset period `{}`", period.name),
21121                            suggestion: Some(
21122                                "use `hourly`, `daily`, `weekly`, or `monthly`".to_owned(),
21123                            ),
21124                        });
21125                    }
21126                    period.name
21127                });
21128                let shared = bag.flag("shared");
21129                let (Some(key_type), Some(cap), Some(reset)) = (key_type, cap, reset) else {
21130                    self.diagnostics.push(Diagnostic {
21131                        related: Vec::new(),
21132                        span,
21133                        message: format!(
21134                            "counter `{}` must declare `key`, `cap`, and `reset`",
21135                            name.name
21136                        ),
21137                        suggestion: Some(
21138                            "every counter is bounded: declare all three fields".to_owned(),
21139                        ),
21140                    });
21141                    return None;
21142                };
21143                Some(Item::Counter(CounterDecl {
21144                    name,
21145                    key_type,
21146                    cap,
21147                    reset,
21148                    timezone: bag.text("timezone"),
21149                    shared,
21150                    span,
21151                }))
21152            }
21153            DeclAstKind::Lease => {
21154                let key_type = bag.ident("key");
21155                let slots = bag.number("slots").unwrap_or(1);
21156                let ttl_seconds = bag.duration("ttl");
21157                let shared = bag.flag("shared");
21158                let (Some(key_type), Some(ttl_seconds)) = (key_type, ttl_seconds) else {
21159                    self.diagnostics.push(Diagnostic {
21160                        related: Vec::new(),
21161                        span,
21162                        message: format!(
21163                            "lease `{}` must declare a `key` type and a `ttl` backstop",
21164                            name.name
21165                        ),
21166                        suggestion: Some(
21167                            "every lease is bounded: declare `key <Type>` and `ttl <duration>`"
21168                                .to_owned(),
21169                        ),
21170                    });
21171                    return None;
21172                };
21173                Some(Item::Lease(LeaseDecl {
21174                    name,
21175                    key_type,
21176                    slots,
21177                    ttl_seconds,
21178                    shared,
21179                    span,
21180                }))
21181            }
21182            DeclAstKind::Ledger => {
21183                let entry_schema = bag.ident("entry");
21184                let partition_field = bag.ident("partition");
21185                let retain_seconds = bag.duration("retain");
21186                let shared = bag.flag("shared");
21187                let (Some(entry_schema), Some(partition_field), Some(retain_seconds)) =
21188                    (entry_schema, partition_field, retain_seconds)
21189                else {
21190                    self.diagnostics.push(Diagnostic {
21191                        related: Vec::new(),
21192                        span,
21193                        message: format!(
21194                            "ledger `{}` must declare `entry`, `partition by`, and `retain`",
21195                            name.name
21196                        ),
21197                        suggestion: Some(
21198                            "every ledger is bounded and partitioned: declare all three fields"
21199                                .to_owned(),
21200                        ),
21201                    });
21202                    return None;
21203                };
21204                Some(Item::Ledger(LedgerDecl {
21205                    name,
21206                    entry_schema,
21207                    partition_field,
21208                    retain_seconds,
21209                    shared,
21210                    span,
21211                }))
21212            }
21213            DeclAstKind::FileStore => {
21214                let root_span = bag.span("root");
21215                let read_span = bag.span("allow read");
21216                let write_span = bag.span("allow write");
21217                let provider_span = bag.span("provider");
21218                let read_globs = bag.globs("allow read");
21219                let write_globs = bag.globs("allow write");
21220                let provider = bag.ident("provider");
21221                let Some(root) = bag.text("root") else {
21222                    self.diagnostics.push(Diagnostic {
21223                        related: Vec::new(),
21224                        span,
21225                        message: format!("file store `{}` is missing a root", name.name),
21226                        suggestion: Some(
21227                            "add `root \"<dir>\"` inside the file store block".to_owned(),
21228                        ),
21229                    });
21230                    return None;
21231                };
21232                Some(Item::FileStore(FileStoreDecl {
21233                    name,
21234                    root,
21235                    read_globs,
21236                    write_globs,
21237                    provider,
21238                    root_span,
21239                    read_span,
21240                    write_span,
21241                    provider_span,
21242                    span,
21243                }))
21244            }
21245            DeclAstKind::MemoryPool => {
21246                let context_limit = bag.number("context limit").map(u64::from);
21247                // The span serializes only alongside a value (hand parser sets it
21248                // inside the successful-parse branch).
21249                let context_limit_span = context_limit.and(bag.span("context limit"));
21250                Some(Item::MemoryPool(MemoryPoolDecl {
21251                    name,
21252                    context_limit,
21253                    context_limit_span,
21254                    span,
21255                }))
21256            }
21257        }
21258    }
21259
21260    fn parse_pattern(&mut self) -> Option<PatternDecl> {
21261        let start = self.expect_keyword("pattern")?.span.start;
21262        let name = self.expect_ident("pattern name")?;
21263        let type_params = self.parse_type_param_list().unwrap_or_default();
21264        let open = self.expect_symbol('{')?;
21265        let mut items = Vec::new();
21266        let mut pending_tags = Vec::new();
21267        let mut pending_description = None;
21268        while !self.is_at_end() && !self.at_symbol('}') {
21269            if self.at_symbol('@') {
21270                if let Some(tag) = self.parse_tag() {
21271                    pending_tags.push(tag);
21272                }
21273                continue;
21274            }
21275            if self.at_ident("description") {
21276                self.parse_pending_description(&mut pending_description);
21277                continue;
21278            }
21279            if self.at_ident("workflow") || self.at_ident("pattern") {
21280                self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
21281                self.reject_pending_description(
21282                    &mut pending_description,
21283                    "pattern body declaration",
21284                );
21285                self.unexpected("pattern body declaration");
21286                self.advance();
21287                continue;
21288            }
21289            if let Some(item) =
21290                self.parse_declaration_item(&mut pending_tags, &mut pending_description)
21291            {
21292                items.push(item);
21293            } else if self.reject_gherkin_misuse() {
21294                continue;
21295            } else {
21296                if self.is_at_end() {
21297                    break;
21298                }
21299                self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
21300                self.reject_pending_description(
21301                    &mut pending_description,
21302                    "pattern body declaration",
21303                );
21304                self.unexpected("pattern body declaration");
21305                self.advance();
21306            }
21307        }
21308        let end = self
21309            .expect_symbol('}')
21310            .map(|token| token.span.end)
21311            .unwrap_or(open.span.end);
21312        Some(PatternDecl {
21313            name,
21314            type_params,
21315            items,
21316            span: SourceSpan { start, end },
21317        })
21318    }
21319
21320    fn parse_type_param_list(&mut self) -> Option<Vec<Ident>> {
21321        if !self.at_symbol('<') {
21322            return Some(Vec::new());
21323        }
21324        self.expect_symbol('<')?;
21325        let mut params = Vec::new();
21326        while !self.is_at_end() && !self.at_symbol('>') {
21327            params.push(self.expect_ident("type parameter")?);
21328            if self.at_symbol(',') {
21329                self.advance();
21330            } else if !self.at_symbol('>') {
21331                self.unexpected("`,` or `>`");
21332                while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
21333                    self.advance();
21334                }
21335            }
21336        }
21337        self.expect_symbol('>')?;
21338        Some(params)
21339    }
21340
21341    fn parse_type_arg_list(&mut self) -> Option<Vec<TypeSyntax>> {
21342        if !self.at_symbol('<') {
21343            return Some(Vec::new());
21344        }
21345        self.expect_symbol('<')?;
21346        let mut args = Vec::new();
21347        while !self.is_at_end() && !self.at_symbol('>') {
21348            args.push(self.parse_type()?);
21349            if self.at_symbol(',') {
21350                self.advance();
21351            } else if !self.at_symbol('>') {
21352                self.unexpected("`,` or `>`");
21353                while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
21354                    self.advance();
21355                }
21356            }
21357        }
21358        self.expect_symbol('>')?;
21359        Some(args)
21360    }
21361
21362    fn parse_apply(&mut self) -> Option<ApplyDecl> {
21363        let start = self.expect_keyword("apply")?.span.start;
21364        let pattern = self.expect_ident("pattern name")?;
21365        let type_args = self.parse_type_arg_list().unwrap_or_default();
21366        self.expect_keyword("as")?;
21367        let alias = self.expect_ident("pattern application alias")?;
21368        let body = self.parse_block_source()?;
21369        let span = SourceSpan {
21370            start,
21371            end: body.span.end,
21372        };
21373        Some(ApplyDecl {
21374            pattern,
21375            type_args,
21376            alias,
21377            body,
21378            span,
21379        })
21380    }
21381
21382    fn parse_include(&mut self) -> Option<IncludeDecl> {
21383        self.expect_keyword("include")?;
21384        Some(IncludeDecl {
21385            path: self.expect_string("include path")?,
21386        })
21387    }
21388
21389    fn parse_workflow_contract(&mut self) -> Option<WorkflowContractDecl> {
21390        let keyword = self.advance().clone();
21391        let kind = match &keyword.kind {
21392            TokenKind::Ident(value) if value == "input" => WorkflowContractKind::Input,
21393            TokenKind::Ident(value) if value == "output" => WorkflowContractKind::Output,
21394            TokenKind::Ident(value) if value == "failure" => WorkflowContractKind::Failure,
21395            _ => return None,
21396        };
21397        let name = self.expect_ident("workflow contract name")?;
21398        // S7 (surface-defaults batch): an inline payload block synthesizes a
21399        // hygienic anonymous class (the `decide` precedent) — `output result {
21400        // message string }` declares the class `output.result` implicitly. The
21401        // dotted name cannot collide with a user class (identifiers cannot
21402        // contain `.`).
21403        if self.at_symbol('{') {
21404            self.advance();
21405            let mut fields = Vec::new();
21406            while !self.is_at_end() && !self.at_symbol('}') {
21407                let Some(field_name) = self.expect_ident("contract payload field name") else {
21408                    self.synchronize_to_block_item();
21409                    continue;
21410                };
21411                let Some(field_ty) = self.parse_type() else {
21412                    self.synchronize_to_block_item();
21413                    continue;
21414                };
21415                let field_span = field_name.span.join(field_ty.span());
21416                fields.push(ClassField {
21417                    name: field_name,
21418                    ty: field_ty,
21419                    is_key: false,
21420                    presence_condition: None,
21421                    span: field_span,
21422                });
21423            }
21424            let close_span = self.peek().map(|token| token.span);
21425            self.expect_symbol('}')?;
21426            let contract_keyword = match kind {
21427                WorkflowContractKind::Input => "input",
21428                WorkflowContractKind::Output => "output",
21429                WorkflowContractKind::Failure => "failure",
21430            };
21431            let class_name = format!("{contract_keyword}.{}", name.name);
21432            let end = close_span.map(|span| span.end).unwrap_or(name.span.end);
21433            let span = SourceSpan {
21434                start: keyword.span.start,
21435                end,
21436            };
21437            self.pending_contract_classes.push(ClassDecl {
21438                name: Ident {
21439                    name: class_name.clone(),
21440                    span,
21441                },
21442                fields,
21443                span,
21444            });
21445            return Some(WorkflowContractDecl {
21446                kind,
21447                name,
21448                ty: TypeSyntax::Ref {
21449                    name: Ident {
21450                        name: class_name,
21451                        span,
21452                    },
21453                },
21454                span,
21455            });
21456        }
21457        let ty = self.parse_type()?;
21458        let span = keyword.span.join(ty.span());
21459        Some(WorkflowContractDecl {
21460            kind,
21461            name,
21462            ty,
21463            span,
21464        })
21465    }
21466
21467    fn parse_use(&mut self) -> Option<UseDecl> {
21468        self.expect_keyword("use")?;
21469        if self.at_ident("plugin") || self.at_ident("skill") {
21470            let removed_kind = self.advance().clone();
21471            let removed_label = match &removed_kind.kind {
21472                TokenKind::Ident(value) => value.as_str(),
21473                _ => "",
21474            };
21475            self.diagnostics.push(Diagnostic { related: Vec::new(),
21476                span: removed_kind.span,
21477                message: format!("`use {removed_label}` is no longer supported"),
21478                suggestion: Some(
21479                    "write `use std.memory` for package libraries; attach skills with `agent { skills [...] }`"
21480                        .to_owned(),
21481                ),
21482            });
21483        }
21484        Some(UseDecl {
21485            name: self.expect_use_name("package library name")?,
21486        })
21487    }
21488
21489    /// Parses `<n><unit>` durations at declaration level (`ttl 10m`,
21490    /// `retain 90d`) — the lexer splits them into a number and a unit ident.
21491    fn parse_decl_duration_seconds(&mut self, label: &str) -> Option<u64> {
21492        let (value, span) = self.expect_u32(label)?;
21493        let unit = self.expect_ident(label)?;
21494        match body::parse_short_duration_seconds(&format!("{value}{}", unit.name)) {
21495            Some(seconds) if seconds > 0 => Some(seconds),
21496            _ => {
21497                self.diagnostics.push(Diagnostic {
21498                    related: Vec::new(),
21499                    span: span.join(unit.span),
21500                    message: format!("invalid duration `{value}{}`", unit.name),
21501                    suggestion: Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
21502                });
21503                None
21504            }
21505        }
21506    }
21507
21508    fn parse_harness(&mut self) -> Option<HarnessDecl> {
21509        let start = self.expect_keyword("harness")?.span.start;
21510        let name = self.expect_ident("harness name")?;
21511        self.expect_symbol(':')?;
21512        let kind = self.expect_ident("harness kind")?;
21513        let span = SourceSpan {
21514            start,
21515            end: kind.span.end,
21516        };
21517        Some(HarnessDecl { name, kind, span })
21518    }
21519
21520    fn parse_agent(&mut self) -> Option<AgentDecl> {
21521        let start = self.expect_keyword("agent")?.span.start;
21522        let name = self.expect_ident("agent name")?;
21523        let harness = if self.at_ident("using") {
21524            self.advance();
21525            Some(self.expect_ident("harness name")?)
21526        } else {
21527            None
21528        };
21529        let delegated_to = if harness.is_none() && self.at_ident("delegated") {
21530            self.advance();
21531            self.expect_keyword("to")?;
21532            Some(self.expect_ident("delegate provider")?)
21533        } else {
21534            None
21535        };
21536        // A bare declaration (`agent researcher`) is valid: every field has a
21537        // default (managed provider, least-authority profile, capacity 1).
21538        if !self.at_symbol('{') {
21539            let end = delegated_to
21540                .as_ref()
21541                .map(|ident| ident.span.end)
21542                .or_else(|| harness.as_ref().map(|ident| ident.span.end))
21543                .unwrap_or(name.span.end);
21544            return Some(AgentDecl {
21545                name,
21546                harness,
21547                delegated_to,
21548                fields: Vec::new(),
21549                span: SourceSpan { start, end },
21550            });
21551        }
21552        let open = self.expect_symbol('{')?;
21553        let mut fields = Vec::new();
21554
21555        while !self.is_at_end() && !self.at_symbol('}') {
21556            let Some(field_name) = self.expect_ident("agent field") else {
21557                self.synchronize_to_block_item();
21558                continue;
21559            };
21560
21561            match field_name.name.as_str() {
21562                "provider" => {
21563                    if let Some(provider) = self.expect_ident("provider name") {
21564                        fields.push(AgentField::Provider(provider));
21565                    } else {
21566                        self.synchronize_to_block_item();
21567                    }
21568                }
21569                "profile" => {
21570                    if let Some(value) = self.expect_string("profile string") {
21571                        fields.push(AgentField::Profile(value));
21572                    } else {
21573                        self.synchronize_to_block_item();
21574                    }
21575                }
21576                "capacity" => {
21577                    if let Some((value, span)) = self.expect_u32("capacity value") {
21578                        fields.push(AgentField::Capacity(value, span));
21579                    } else {
21580                        self.synchronize_to_block_item();
21581                    }
21582                }
21583                "skills" => {
21584                    if let Some((skills, span)) = self.parse_string_list() {
21585                        fields.push(AgentField::Skills(skills, span));
21586                    } else {
21587                        self.synchronize_to_block_item();
21588                    }
21589                }
21590                "capabilities" => {
21591                    if let Some((capabilities, span)) = self.parse_string_list() {
21592                        fields.push(AgentField::Capabilities(capabilities, span));
21593                    } else {
21594                        self.synchronize_to_block_item();
21595                    }
21596                }
21597                "requires" => {
21598                    if let Some((classes, span)) = self.parse_feature_class_list() {
21599                        fields.push(AgentField::Requires(classes, span));
21600                    } else {
21601                        self.synchronize_to_block_item();
21602                    }
21603                }
21604                "tools" => {
21605                    if let Some((tools, span)) = self.parse_ident_list() {
21606                        fields.push(AgentField::Tools(tools, span));
21607                    } else {
21608                        self.synchronize_to_block_item();
21609                    }
21610                }
21611                "compaction" => {
21612                    if let Some(strategy) = self.expect_ident("compaction strategy") {
21613                        fields.push(AgentField::Compaction(strategy));
21614                    } else {
21615                        self.synchronize_to_block_item();
21616                    }
21617                }
21618                "thread" => {
21619                    if let Some(mode) = self.expect_ident("thread mode") {
21620                        fields.push(AgentField::Thread(mode));
21621                    } else {
21622                        self.synchronize_to_block_item();
21623                    }
21624                }
21625                "settings" => {
21626                    if let Some(sources) = self.expect_ident("settings source") {
21627                        fields.push(AgentField::Settings(sources));
21628                    } else {
21629                        self.synchronize_to_block_item();
21630                    }
21631                }
21632                _ => {
21633                    let span = field_name.span;
21634                    fields.push(AgentField::Unknown {
21635                        name: field_name,
21636                        span,
21637                    });
21638                    self.synchronize_to_block_item();
21639                }
21640            }
21641        }
21642
21643        let end = self
21644            .expect_symbol('}')
21645            .map(|token| token.span.end)
21646            .unwrap_or(open.span.end);
21647
21648        Some(AgentDecl {
21649            name,
21650            harness,
21651            delegated_to,
21652            fields,
21653            span: SourceSpan { start, end },
21654        })
21655    }
21656
21657    fn parse_enum(&mut self) -> Option<EnumDecl> {
21658        let start = self.expect_keyword("enum")?.span.start;
21659        let name = self.expect_ident("enum name")?;
21660        let open = self.expect_symbol('{')?;
21661        let mut variants = Vec::new();
21662
21663        let mut previous_variant_end: Option<usize> = None;
21664        while !self.is_at_end() && !self.at_symbol('}') {
21665            let Some(variant) = self.expect_ident("enum variant") else {
21666                self.synchronize_to_block_item();
21667                continue;
21668            };
21669            // One variant per line: two variants sharing a line is almost
21670            // always pasted prose or a forgotten `#` — and the stray words
21671            // would otherwise become variants that pollute the domain (and
21672            // reach coerce output schemas).
21673            if let Some(previous_end) = previous_variant_end {
21674                let line_of = |offset: usize| {
21675                    self.source[..offset.min(self.source.len())]
21676                        .bytes()
21677                        .filter(|byte| *byte == b'\n')
21678                        .count()
21679                };
21680                if line_of(previous_end) == line_of(variant.span.start) {
21681                    self.diagnostics.push(Diagnostic {
21682                        related: Vec::new(),
21683                        span: variant.span,
21684                        message: format!(
21685                            "enum `{}` declares variant `{}` on the same line as the previous variant",
21686                            name.name, variant.name
21687                        ),
21688                        suggestion: Some("write one enum variant per line".to_owned()),
21689                    });
21690                }
21691            }
21692            // A brace body makes this a data-carrying variant; the body
21693            // reuses the class field grammar (sum types, spec/sum-types.md).
21694            let mut fields = Vec::new();
21695            let mut end = variant.span.end;
21696            if self.at_symbol('{') {
21697                self.expect_symbol('{');
21698                while !self.is_at_end() && !self.at_symbol('}') {
21699                    let Some(field_name) = self.expect_ident("variant field name") else {
21700                        self.synchronize_to_block_item();
21701                        continue;
21702                    };
21703                    let Some(ty) = self.parse_type() else {
21704                        self.synchronize_to_block_item();
21705                        continue;
21706                    };
21707                    fields.push(ClassField {
21708                        span: field_name.span.join(ty.span()),
21709                        name: field_name,
21710                        ty,
21711                        is_key: false,
21712                        presence_condition: None,
21713                    });
21714                }
21715                if let Some(close) = self.expect_symbol('}') {
21716                    end = close.span.end;
21717                }
21718            }
21719            let span = SourceSpan {
21720                start: variant.span.start,
21721                end,
21722            };
21723            previous_variant_end = Some(end);
21724            variants.push(EnumVariantDecl {
21725                name: variant,
21726                fields,
21727                span,
21728            });
21729        }
21730
21731        let end = self
21732            .expect_symbol('}')
21733            .map(|token| token.span.end)
21734            .unwrap_or(open.span.end);
21735
21736        Some(EnumDecl {
21737            name,
21738            variants,
21739            span: SourceSpan { start, end },
21740        })
21741    }
21742
21743    fn parse_event(&mut self) -> Option<EventDecl> {
21744        let start = self.expect_keyword("signal")?.span.start;
21745        // Dotted lowercase name (`deploy.finished`), matching the `when fact`
21746        // convention and distinct from PascalCase classes.
21747        let first = self.expect_ident("signal name")?;
21748        let mut name = first.name.clone();
21749        let mut name_span = first.span;
21750        while self.at_symbol('.') {
21751            self.expect_symbol('.');
21752            let segment = self.expect_ident("signal name segment")?;
21753            name.push('.');
21754            name.push_str(&segment.name);
21755            name_span = name_span.join(segment.span);
21756        }
21757        if !name.contains('.')
21758            || name
21759                .split('.')
21760                .any(|segment| segment.chars().next().is_some_and(char::is_uppercase))
21761        {
21762            self.diagnostics.push(Diagnostic {
21763                related: Vec::new(),
21764                span: name_span,
21765                message: format!("signal name `{name}` must be dotted lowercase"),
21766                suggestion: Some(
21767                    "use a dotted lowercase name such as `deploy.finished`".to_owned(),
21768                ),
21769            });
21770        }
21771        let open = self.expect_symbol('{')?;
21772        let mut fields = Vec::new();
21773        while !self.is_at_end() && !self.at_symbol('}') {
21774            let Some(field_name) = self.expect_ident("signal field name") else {
21775                self.synchronize_to_block_item();
21776                continue;
21777            };
21778            let Some(ty) = self.parse_type() else {
21779                self.synchronize_to_block_item();
21780                continue;
21781            };
21782            let presence_condition = self.parse_field_presence_condition();
21783            fields.push(ClassField {
21784                span: field_name.span.join(ty.span()),
21785                name: field_name,
21786                ty,
21787                is_key: false,
21788                presence_condition,
21789            });
21790        }
21791        let end = self
21792            .expect_symbol('}')
21793            .map(|token| token.span.end)
21794            .unwrap_or(open.span.end);
21795        Some(EventDecl {
21796            name,
21797            name_span,
21798            fields,
21799            span: SourceSpan { start, end },
21800        })
21801    }
21802
21803    /// A dotted name (`summarize.extract`, `std.spend`) as one string + span.
21804    fn parse_dotted_name_spanned(&mut self, label: &str) -> Option<(String, SourceSpan)> {
21805        let first = self.expect_ident(label)?;
21806        let mut name = first.name.clone();
21807        let mut span = first.span;
21808        while self.at_symbol('.') {
21809            self.expect_symbol('.');
21810            let segment = self.expect_ident(label)?;
21811            name.push('.');
21812            name.push_str(&segment.name);
21813            span = span.join(segment.span);
21814        }
21815        Some((name, span))
21816    }
21817
21818    fn parse_gauge_ref(&mut self, label: &str) -> Option<GaugeRef> {
21819        let (name, span) = self.parse_dotted_name_spanned(label)?;
21820        Some(GaugeRef { name, span })
21821    }
21822
21823    /// A comma-separated gauge-reference list (`ascend a, std.spend`).
21824    fn parse_gauge_ref_list(&mut self, label: &str, into: &mut Vec<GaugeRef>) -> Option<()> {
21825        into.push(self.parse_gauge_ref(label)?);
21826        while self.at_symbol(',') {
21827            self.expect_symbol(',');
21828            into.push(self.parse_gauge_ref(label)?);
21829        }
21830        Some(())
21831    }
21832
21833    /// A numeric literal as exact source text: `800` or `0.9`. The
21834    /// declaration lexer emits digits-only Number tokens, so a fraction is
21835    /// Number `.` Number.
21836    fn parse_decl_number_text(&mut self, label: &str) -> Option<(String, SourceSpan)> {
21837        let token = self.peek()?;
21838        let TokenKind::Number(whole) = token.kind.clone() else {
21839            self.expected(label);
21840            return None;
21841        };
21842        let mut span = token.span;
21843        let mut text = whole;
21844        self.advance();
21845        if self.at_symbol('.') {
21846            self.expect_symbol('.');
21847            let token = self.peek()?;
21848            let TokenKind::Number(fraction) = token.kind.clone() else {
21849                self.expected(format!("{label} fraction digits"));
21850                return None;
21851            };
21852            text.push('.');
21853            text.push_str(&fraction);
21854            span = span.join(token.span);
21855            self.advance();
21856        }
21857        Some((text, span))
21858    }
21859
21860    /// Bar direction: `at least` (true) / `at most` (false). The declaration
21861    /// tokenizer steps over `>=`/`<=` silently (the `is` precedent), so a
21862    /// user writing an operator gets a targeted diagnostic instead of a
21863    /// direction-less bar: the raw source gap before the next token is
21864    /// inspected for the dropped operator.
21865    fn parse_bar_direction(&mut self, label: &str) -> Option<bool> {
21866        if self.consume_ident("at") {
21867            if self.consume_ident("least") {
21868                return Some(true);
21869            }
21870            if self.consume_ident("most") {
21871                return Some(false);
21872            }
21873            self.expected(format!("`least` or `most` after `at` in {label}"));
21874            return None;
21875        }
21876        let gap_start = self.last_span_end();
21877        let gap_end = self
21878            .peek()
21879            .map(|token| token.span.start)
21880            .unwrap_or(gap_start);
21881        let gap = &self.source[gap_start..gap_end.max(gap_start)];
21882        let suggestion = if gap.contains(">=") {
21883            Some("write `at least` (the declaration grammar uses words, not `>=`)".to_owned())
21884        } else if gap.contains("<=") {
21885            Some("write `at most` (the declaration grammar uses words, not `<=`)".to_owned())
21886        } else {
21887            Some("write `at least <n>` or `at most <n>`".to_owned())
21888        };
21889        self.diagnostics.push(Diagnostic {
21890            related: Vec::new(),
21891            span: SourceSpan {
21892                start: gap_start,
21893                end: gap_end.max(gap_start),
21894            },
21895            message: format!("expected `at least` or `at most` in {label}"),
21896            suggestion,
21897        });
21898        None
21899    }
21900
21901    /// `mark "<name>" after <site>`.
21902    fn parse_mark(&mut self) -> Option<MarkDecl> {
21903        let start = self.expect_keyword("mark")?.span.start;
21904        let name = self.expect_string("mark name")?;
21905        if !self.consume_ident("after") {
21906            self.expected("`after` and a committing site in the mark declaration");
21907            return None;
21908        }
21909        let (site, site_span) = self.parse_dotted_name_spanned("mark site")?;
21910        Some(MarkDecl {
21911            name,
21912            site,
21913            site_span,
21914            span: SourceSpan {
21915                start,
21916                end: site_span.end,
21917            },
21918        })
21919    }
21920
21921    /// `gauge <name> [on <site>] { judge via <form> [expect <bar>]
21922    /// [inputs <gauges>] }`.
21923    fn parse_gauge(&mut self) -> Option<GaugeDecl> {
21924        let start = self.expect_keyword("gauge")?.span.start;
21925        let name = self.expect_ident("gauge name")?;
21926        let (site, site_span) = if self.consume_ident("on") {
21927            let (site, span) = self.parse_dotted_name_spanned("gauge site")?;
21928            (Some(site), Some(span))
21929        } else {
21930            (None, None)
21931        };
21932        let open = self.expect_symbol('{')?;
21933        let mut judge: Option<GaugeJudge> = None;
21934        let mut expect: Option<GaugeBar> = None;
21935        let mut inputs: Vec<GaugeRef> = Vec::new();
21936        while !self.is_at_end() && !self.at_symbol('}') {
21937            if self.at_ident("judge") {
21938                let keyword = self.advance().clone();
21939                if !self.consume_ident("via") {
21940                    self.expected("`via` after `judge`");
21941                    self.synchronize_to_block_item();
21942                    continue;
21943                }
21944                let form = if self.consume_ident("coerce") {
21945                    self.expect_ident("coerce judge name").and_then(|name| {
21946                        let mut args = Vec::new();
21947                        if self.at_symbol('(') {
21948                            self.expect_symbol('(');
21949                            loop {
21950                                let (path, _) = self.parse_dotted_name_spanned("judge argument")?;
21951                                args.push(path);
21952                                if !self.at_symbol(',') {
21953                                    break;
21954                                }
21955                                self.expect_symbol(',')?;
21956                            }
21957                            self.expect_symbol(')')?;
21958                        }
21959                        Some(GaugeJudge::Coerce(name, args))
21960                    })
21961                } else if self.consume_ident("prompt") {
21962                    self.expect_string("prompt judge template")
21963                        .map(GaugeJudge::Prompt)
21964                } else if self.consume_ident("exec") {
21965                    self.expect_string("exec judge command")
21966                        .map(GaugeJudge::Exec)
21967                } else if self.consume_ident("labels") {
21968                    self.expect_string("labels source").map(GaugeJudge::Labels)
21969                } else {
21970                    self.diagnostics.push(Diagnostic {
21971                        related: Vec::new(),
21972                        span: keyword.span,
21973                        message: "unknown judge form".to_owned(),
21974                        suggestion: Some(
21975                            "judge forms are `coerce <Name>`, `prompt \"<template>\"`, \
21976                             `exec \"<command>\"`, and `labels \"<source>\"`"
21977                                .to_owned(),
21978                        ),
21979                    });
21980                    None
21981                };
21982                let Some(form) = form else {
21983                    self.synchronize_to_block_item();
21984                    continue;
21985                };
21986                if judge.is_some() {
21987                    self.diagnostics.push(Diagnostic {
21988                        related: Vec::new(),
21989                        span: keyword.span,
21990                        message: "gauge declares more than one judge".to_owned(),
21991                        suggestion: Some("a gauge has exactly one judge".to_owned()),
21992                    });
21993                } else {
21994                    judge = Some(form);
21995                }
21996            } else if self.at_ident("expect") {
21997                let keyword = self.advance().clone();
21998                let subject_ident = match self.expect_ident("bar subject") {
21999                    Some(ident) => ident,
22000                    None => {
22001                        self.synchronize_to_block_item();
22002                        continue;
22003                    }
22004                };
22005                let subject = if subject_ident.name == "P" && self.at_symbol('(') {
22006                    self.expect_symbol('(');
22007                    let Some(field) = self.expect_ident("chance bar field") else {
22008                        self.synchronize_to_block_item();
22009                        continue;
22010                    };
22011                    if self.expect_symbol(')').is_none() {
22012                        self.synchronize_to_block_item();
22013                        continue;
22014                    }
22015                    GaugeBarSubject::Chance { field }
22016                } else {
22017                    let stat = &subject_ident.name;
22018                    let is_quantile = stat.len() > 1
22019                        && stat.starts_with('p')
22020                        && stat[1..].chars().all(|ch| ch.is_ascii_digit());
22021                    if stat != "mean" && !is_quantile {
22022                        self.diagnostics.push(Diagnostic {
22023                            related: Vec::new(),
22024                            span: subject_ident.span,
22025                            message: format!("unknown bar statistic `{stat}`"),
22026                            suggestion: Some(
22027                                "bars are chance-shaped (`P(<field>)`) or stat-shaped \
22028                                 (`mean`, `p10`, `p90`, ...)"
22029                                    .to_owned(),
22030                            ),
22031                        });
22032                    }
22033                    GaugeBarSubject::Stat {
22034                        stat: subject_ident,
22035                    }
22036                };
22037                let Some(at_least) = self.parse_bar_direction("the gauge bar") else {
22038                    self.synchronize_to_block_item();
22039                    continue;
22040                };
22041                let Some((threshold, threshold_span)) =
22042                    self.parse_decl_number_text("bar threshold")
22043                else {
22044                    self.synchronize_to_block_item();
22045                    continue;
22046                };
22047                if expect.is_some() {
22048                    self.diagnostics.push(Diagnostic {
22049                        related: Vec::new(),
22050                        span: keyword.span,
22051                        message: "gauge declares more than one bar".to_owned(),
22052                        suggestion: Some("a gauge has at most one `expect` bar".to_owned()),
22053                    });
22054                } else {
22055                    expect = Some(GaugeBar {
22056                        subject,
22057                        at_least,
22058                        threshold,
22059                        span: keyword.span.join(threshold_span),
22060                    });
22061                }
22062            } else if self.at_ident("inputs") {
22063                self.advance();
22064                if self
22065                    .parse_gauge_ref_list("input gauge name", &mut inputs)
22066                    .is_none()
22067                {
22068                    self.synchronize_to_block_item();
22069                }
22070            } else {
22071                let span = self.peek().map(|token| token.span).unwrap_or(open.span);
22072                self.diagnostics.push(Diagnostic {
22073                    related: Vec::new(),
22074                    span,
22075                    message: "unknown gauge clause".to_owned(),
22076                    suggestion: Some(
22077                        "gauge clauses are `judge via`, `expect`, and `inputs`".to_owned(),
22078                    ),
22079                });
22080                self.synchronize_to_block_item();
22081            }
22082        }
22083        let end = self
22084            .expect_symbol('}')
22085            .map(|token| token.span.end)
22086            .unwrap_or(open.span.end);
22087        let Some(judge) = judge else {
22088            self.diagnostics.push(Diagnostic {
22089                related: Vec::new(),
22090                span: name.span,
22091                message: format!("gauge `{}` declares no judge", name.name),
22092                suggestion: Some(
22093                    "add `judge via coerce <Name>`, `judge via prompt \"<template>\"`, \
22094                     `judge via exec \"<command>\"`, or `judge via labels \"<source>\"`"
22095                        .to_owned(),
22096                ),
22097            });
22098            return None;
22099        };
22100        Some(GaugeDecl {
22101            name,
22102            site,
22103            site_span,
22104            judge,
22105            expect,
22106            inputs,
22107            span: SourceSpan { start, end },
22108        })
22109    }
22110
22111    /// `campaign <name> { ascend … [reach …] [guard …] [sacrifice …] }`.
22112    fn parse_campaign(&mut self) -> Option<CampaignDecl> {
22113        let start = self.expect_keyword("campaign")?.span.start;
22114        let name = self.expect_ident("campaign name")?;
22115        let open = self.expect_symbol('{')?;
22116        let mut ascend: Vec<GaugeRef> = Vec::new();
22117        let mut reach: Vec<CampaignReach> = Vec::new();
22118        let mut guard: Vec<CampaignGuard> = Vec::new();
22119        let mut sacrifice: Vec<GaugeRef> = Vec::new();
22120        let mut proposer_redacted = false;
22121        while !self.is_at_end() && !self.at_symbol('}') {
22122            if self.at_ident("ascend") {
22123                self.advance();
22124                if self
22125                    .parse_gauge_ref_list("ascend gauge name", &mut ascend)
22126                    .is_none()
22127                {
22128                    self.synchronize_to_block_item();
22129                }
22130            } else if self.at_ident("reach") {
22131                let keyword = self.advance().clone();
22132                let Some(gauge) = self.parse_gauge_ref("reach gauge name") else {
22133                    self.synchronize_to_block_item();
22134                    continue;
22135                };
22136                let Some(at_least) = self.parse_bar_direction("the reach target") else {
22137                    self.synchronize_to_block_item();
22138                    continue;
22139                };
22140                let Some((threshold, threshold_span)) =
22141                    self.parse_decl_number_text("reach threshold")
22142                else {
22143                    self.synchronize_to_block_item();
22144                    continue;
22145                };
22146                // A trailing duration unit (`800ms`) lexes as a separate
22147                // ident; a clause keyword never collides with the unit set.
22148                let unit = if self
22149                    .peek()
22150                    .map(|token| {
22151                        matches!(&token.kind, TokenKind::Ident(name)
22152                            if matches!(name.as_str(), "ms" | "s" | "m" | "h" | "d"))
22153                    })
22154                    .unwrap_or(false)
22155                {
22156                    self.expect_ident("unit").map(|ident| ident.name)
22157                } else {
22158                    None
22159                };
22160                reach.push(CampaignReach {
22161                    gauge,
22162                    at_least,
22163                    threshold,
22164                    unit,
22165                    span: keyword.span.join(threshold_span),
22166                });
22167            } else if self.at_ident("guard") {
22168                let keyword = self.advance().clone();
22169                let Some(gauge) = self.parse_gauge_ref("guard gauge name") else {
22170                    self.synchronize_to_block_item();
22171                    continue;
22172                };
22173                if !self.consume_ident("within") {
22174                    self.expected("`within` after the guarded gauge");
22175                    self.synchronize_to_block_item();
22176                    continue;
22177                }
22178                let Some((band_percent, band_span)) = self.parse_decl_number_text("guard band")
22179                else {
22180                    self.synchronize_to_block_item();
22181                    continue;
22182                };
22183                if !self.consume_ident("percent") {
22184                    self.expected("`percent` after the guard band");
22185                    self.synchronize_to_block_item();
22186                    continue;
22187                }
22188                guard.push(CampaignGuard {
22189                    gauge,
22190                    band_percent,
22191                    span: keyword.span.join(band_span),
22192                });
22193            } else if self.at_ident("sacrifice") {
22194                self.advance();
22195                if self
22196                    .parse_gauge_ref_list("sacrifice gauge name", &mut sacrifice)
22197                    .is_none()
22198                {
22199                    self.synchronize_to_block_item();
22200                }
22201            } else if self.at_ident("proposer") {
22202                self.advance();
22203                if self.consume_ident("redacted") {
22204                    proposer_redacted = true;
22205                } else {
22206                    self.expected("`redacted` after `proposer`");
22207                    self.synchronize_to_block_item();
22208                }
22209            } else {
22210                let span = self.peek().map(|token| token.span).unwrap_or(open.span);
22211                self.diagnostics.push(Diagnostic {
22212                    related: Vec::new(),
22213                    span,
22214                    message: "unknown campaign clause".to_owned(),
22215                    suggestion: Some(
22216                        "campaign clauses are `ascend`, `reach`, `guard`, `sacrifice`, \
22217                         and `proposer redacted`"
22218                            .to_owned(),
22219                    ),
22220                });
22221                self.synchronize_to_block_item();
22222            }
22223        }
22224        let end = self
22225            .expect_symbol('}')
22226            .map(|token| token.span.end)
22227            .unwrap_or(open.span.end);
22228        if ascend.is_empty() && reach.is_empty() {
22229            self.diagnostics.push(Diagnostic {
22230                related: Vec::new(),
22231                span: name.span,
22232                message: format!("campaign `{}` names nothing to improve", name.name),
22233                suggestion: Some("add an `ascend` or `reach` clause".to_owned()),
22234            });
22235        }
22236        Some(CampaignDecl {
22237            name,
22238            ascend,
22239            reach,
22240            guard,
22241            sacrifice,
22242            proposer_redacted,
22243            span: SourceSpan { start, end },
22244        })
22245    }
22246
22247    fn last_span_end(&self) -> usize {
22248        self.pos
22249            .checked_sub(1)
22250            .and_then(|index| self.tokens.get(index))
22251            .map(|token| token.span.end)
22252            .unwrap_or(0)
22253    }
22254
22255    fn parse_dotted_name(&mut self, label: &str) -> Option<String> {
22256        let first = self.expect_ident(label)?;
22257        let mut name = first.name.clone();
22258        while self.at_symbol('.') {
22259            self.advance();
22260            let segment = self.expect_ident(label)?;
22261            name.push('.');
22262            name.push_str(&segment.name);
22263        }
22264        Some(name)
22265    }
22266
22267    /// Capture the source text of an expression from the current token to end of
22268    /// line (the `assert`/guard idiom), advancing past the consumed tokens.
22269    fn capture_expr_to_line_end(&mut self) -> (String, SourceSpan) {
22270        let start = self
22271            .peek()
22272            .map(|token| token.span.start)
22273            .unwrap_or(self.source.len());
22274        let line_end = self.source[start..]
22275            .find('\n')
22276            .map(|offset| start + offset)
22277            .unwrap_or(self.source.len());
22278        let mut end = start;
22279        while !self.is_at_end() {
22280            let Some(token) = self.peek() else { break };
22281            if token.span.start >= line_end {
22282                break;
22283            }
22284            let token_end = token.span.end.min(line_end);
22285            self.advance();
22286            end = token_end;
22287        }
22288        let span = SourceSpan { start, end };
22289        trimmed_source_text(self.source_text(span), span)
22290    }
22291
22292    /// Capture source text up to (but not including) a terminator identifier or a
22293    /// closing brace — used for a predicate bounded by `is`.
22294    fn capture_expr_until_ident(&mut self, terminator: &str) -> (String, SourceSpan) {
22295        let start = self
22296            .peek()
22297            .map(|token| token.span.start)
22298            .unwrap_or(self.source.len());
22299        let mut end = start;
22300        while !self.is_at_end() && !self.at_ident(terminator) && !self.at_symbol('}') {
22301            let Some(token) = self.peek() else { break };
22302            let token_end = token.span.end;
22303            self.advance();
22304            end = token_end;
22305        }
22306        let span = SourceSpan { start, end };
22307        trimmed_source_text(self.source_text(span), span)
22308    }
22309
22310    fn parse_test(&mut self) -> Option<TestDecl> {
22311        let start = self.expect_keyword("test")?.span.start;
22312        let name = self.expect_string("test name")?;
22313        let open = self.expect_symbol('{')?;
22314        let mut workflow = None;
22315        let mut clauses = Vec::new();
22316        while !self.is_at_end() && !self.at_symbol('}') {
22317            if self.at_ident("workflow") {
22318                self.advance();
22319                match self.expect_ident("workflow name") {
22320                    Some(name) => {
22321                        if workflow.is_some() {
22322                            self.diagnostics.push(Diagnostic {
22323                                related: Vec::new(),
22324                                span: name.span,
22325                                message: "a test scenario binds at most one `workflow`".to_owned(),
22326                                suggestion: Some(
22327                                    "remove the extra `workflow <Name>` header".to_owned(),
22328                                ),
22329                            });
22330                        }
22331                        workflow = Some(name);
22332                    }
22333                    None => self.synchronize_to_block_item(),
22334                }
22335            } else if self.at_ident("given") {
22336                match self.parse_given() {
22337                    Some(clause) => clauses.push(TestClause::Given(clause)),
22338                    None => self.synchronize_to_block_item(),
22339                }
22340            } else if self.at_ident("stub") {
22341                match self.parse_stub() {
22342                    Some(clause) => clauses.push(TestClause::Stub(clause)),
22343                    None => self.synchronize_to_block_item(),
22344                }
22345            } else if self.at_ident("run") {
22346                match self.parse_run() {
22347                    Some(clause) => clauses.push(TestClause::Run(clause)),
22348                    None => self.synchronize_to_block_item(),
22349                }
22350            } else if self.at_ident("expect") {
22351                match self.parse_expect() {
22352                    Some(clause) => clauses.push(TestClause::Expect(clause)),
22353                    None => self.synchronize_to_block_item(),
22354                }
22355            } else {
22356                self.unexpected("a test clause (`workflow`, `given`, `stub`, `run`, or `expect`)");
22357                self.synchronize_to_block_item();
22358            }
22359        }
22360        let end = self
22361            .expect_symbol('}')
22362            .map(|token| token.span.end)
22363            .unwrap_or(open.span.end);
22364        Some(TestDecl {
22365            name,
22366            workflow,
22367            clauses,
22368            span: SourceSpan { start, end },
22369        })
22370    }
22371
22372    fn parse_test_record(&mut self) -> Option<(Vec<TestField>, usize)> {
22373        let open = self.expect_symbol('{')?;
22374        let mut fields = Vec::new();
22375        while !self.is_at_end() && !self.at_symbol('}') {
22376            let Some(name) = self.expect_ident("test field name") else {
22377                self.synchronize_to_block_item();
22378                continue;
22379            };
22380            let (value, value_span) = self.capture_expr_to_line_end();
22381            fields.push(TestField {
22382                span: name.span.join(value_span),
22383                name,
22384                value,
22385            });
22386        }
22387        let end = self
22388            .expect_symbol('}')
22389            .map(|token| token.span.end)
22390            .unwrap_or(open.span.end);
22391        Some((fields, end))
22392    }
22393
22394    fn parse_given(&mut self) -> Option<GivenClause> {
22395        let start = self.expect_keyword("given")?.span.start;
22396        if self.consume_ident("input") {
22397            let (fields, end) = self.parse_test_record()?;
22398            Some(GivenClause::Input {
22399                fields,
22400                span: SourceSpan { start, end },
22401            })
22402        } else if self.consume_ident("fact") {
22403            let ty = self.expect_ident("fact type")?;
22404            let (fields, end) = self.parse_test_record()?;
22405            Some(GivenClause::Fact {
22406                ty,
22407                fields,
22408                span: SourceSpan { start, end },
22409            })
22410        } else if self.consume_ident("signal") {
22411            let name = self.parse_dotted_name("signal name")?;
22412            let (fields, end) = self.parse_test_record()?;
22413            Some(GivenClause::Signal {
22414                name,
22415                fields,
22416                span: SourceSpan { start, end },
22417            })
22418        } else if self.consume_ident("clock") {
22419            if !self.consume_ident("at") {
22420                self.expected("`at <timestamp>` after `given clock`");
22421            }
22422            let at = self.expect_string("clock timestamp")?;
22423            let end = at.span.end;
22424            Some(GivenClause::Clock {
22425                at,
22426                span: SourceSpan { start, end },
22427            })
22428        } else if self.consume_ident("tracker") {
22429            let tracker = self.parse_dotted_name("tracker name")?;
22430            if !self.consume_ident("issue") {
22431                self.expected("`issue { … }` after `given tracker <name>`");
22432            }
22433            let (fields, end) = self.parse_test_record()?;
22434            Some(GivenClause::Tracker {
22435                tracker,
22436                fields,
22437                span: SourceSpan { start, end },
22438            })
22439        } else if self.consume_ident("file") {
22440            let store = self.parse_dotted_name("file store name")?;
22441            if !self.consume_ident("at") {
22442                self.expected("`at <path> \"<content>\"` after `given file <store>`");
22443            }
22444            let path = self.expect_string("file path")?;
22445            let content = self.expect_string("file content")?;
22446            let end = content.span.end;
22447            Some(GivenClause::File {
22448                store,
22449                path,
22450                content,
22451                span: SourceSpan { start, end },
22452            })
22453        } else {
22454            self.unexpected(
22455                "`input`, `fact`, `signal`, `clock`, `tracker`, or `file` after `given`",
22456            );
22457            None
22458        }
22459    }
22460
22461    fn parse_stub(&mut self) -> Option<StubClause> {
22462        let start = self.expect_keyword("stub")?.span.start;
22463        // Surface path: dotted-name segments up to the outcome, all on the `stub`
22464        // line. The trailing segment (before a `{`, string, or end-of-line) is the
22465        // outcome; the rest is the surface. v0 keeps this lexical: at least one
22466        // surface segment + one outcome.
22467        let line_end = self.source[start..]
22468            .find('\n')
22469            .map(|offset| start + offset)
22470            .unwrap_or(self.source.len());
22471        let mut segments = Vec::new();
22472        while matches!(
22473            self.peek().map(|token| &token.kind),
22474            Some(TokenKind::Ident(_))
22475        ) && self.peek().is_some_and(|token| token.span.start < line_end)
22476        {
22477            match self.parse_dotted_name("stub surface") {
22478                Some(segment) => segments.push(segment),
22479                None => break,
22480            }
22481        }
22482        if segments.len() < 2 {
22483            self.diagnostics.push(Diagnostic {
22484                related: Vec::new(),
22485                span: SourceSpan {
22486                    start,
22487                    end: self.last_span_end(),
22488                },
22489                message: "stub needs a surface and an outcome (e.g. `stub agent triager succeeds`)"
22490                    .to_owned(),
22491                suggestion: Some("write `stub <surface...> <outcome> [payload]`".to_owned()),
22492            });
22493            return None;
22494        }
22495        let outcome = segments.pop().expect("outcome present");
22496        let surface = segments;
22497        let payload = if self.at_symbol('{') {
22498            let (fields, _) = self.parse_test_record()?;
22499            Some(StubPayload::Record(fields))
22500        } else if matches!(
22501            self.peek().map(|token| &token.kind),
22502            Some(TokenKind::String(_))
22503        ) {
22504            Some(StubPayload::Message(self.expect_string("stub message")?))
22505        } else {
22506            None
22507        };
22508        let end = self.last_span_end();
22509        Some(StubClause {
22510            surface,
22511            outcome,
22512            payload,
22513            span: SourceSpan { start, end },
22514        })
22515    }
22516
22517    fn parse_run(&mut self) -> Option<RunClause> {
22518        let start = self.expect_keyword("run")?.span.start;
22519        let kind = if self.consume_ident("until") {
22520            if self.consume_ident("idle") {
22521                RunKind::UntilIdle
22522            } else if self.consume_ident("workflow") {
22523                if self.consume_ident("completed") {
22524                    RunKind::UntilWorkflowCompleted
22525                } else if self.consume_ident("failed") {
22526                    RunKind::UntilWorkflowFailed
22527                } else {
22528                    self.expected("`completed` or `failed` after `workflow`");
22529                    return None;
22530                }
22531            } else {
22532                self.expected("`idle` or `workflow completed|failed` after `until`");
22533                return None;
22534            }
22535        } else if self.consume_ident("for") {
22536            let (steps, _) = self.expect_u32("step count")?;
22537            if !self.consume_ident("steps") {
22538                self.expected("`steps` after the step count");
22539            }
22540            RunKind::ForSteps(steps)
22541        } else {
22542            self.expected("`until ...` or `for <N> steps` after `run`");
22543            return None;
22544        };
22545        let end = self.last_span_end();
22546        Some(RunClause {
22547            kind,
22548            span: SourceSpan { start, end },
22549        })
22550    }
22551
22552    fn parse_expect(&mut self) -> Option<ExpectClause> {
22553        let start = self.expect_keyword("expect")?.span.start;
22554        let target = if self.consume_ident("workflow") {
22555            if self.consume_ident("completed") {
22556                ExpectTarget::WorkflowCompleted
22557            } else if self.consume_ident("failed") {
22558                let failure = if self.consume_ident("with") {
22559                    self.expect_ident("failure type")
22560                } else {
22561                    None
22562                };
22563                ExpectTarget::WorkflowFailed { failure }
22564            } else {
22565                self.expected("`completed` or `failed` after `workflow`");
22566                return None;
22567            }
22568        } else if self.consume_ident("rule") {
22569            let name = self.expect_ident("rule name")?;
22570            let status = if self.consume_ident("fired") {
22571                if matches!(
22572                    self.peek().map(|token| &token.kind),
22573                    Some(TokenKind::Number(_))
22574                ) {
22575                    let (count, _) = self.expect_u32("fired count")?;
22576                    if !self.consume_ident("times") {
22577                        self.expected("`times` after the fired count");
22578                    }
22579                    RuleStatus::FiredTimes(count)
22580                } else {
22581                    RuleStatus::Fired
22582                }
22583            } else if self.consume_ident("did") {
22584                if !self.consume_ident("not") {
22585                    self.expected("`not` in `did not fire`");
22586                }
22587                if !self.consume_ident("fire") {
22588                    self.expected("`fire` in `did not fire`");
22589                }
22590                RuleStatus::DidNotFire
22591            } else {
22592                self.expected("`fired`, `fired <N> times`, or `did not fire`");
22593                return None;
22594            };
22595            ExpectTarget::Rule { name, status }
22596        } else if self.consume_ident("effect") {
22597            let name = self.parse_dotted_name("effect name")?;
22598            let status = if self.consume_ident("requested") {
22599                EffectStatus::Requested
22600            } else if self.consume_ident("completed") {
22601                EffectStatus::Completed
22602            } else if self.consume_ident("failed") {
22603                EffectStatus::Failed
22604            } else {
22605                self.expected("`requested`, `completed`, or `failed` after the effect name");
22606                return None;
22607            };
22608            ExpectTarget::Effect { name, status }
22609        } else if self.consume_ident("diagnostic") {
22610            let code = self.parse_dotted_name("diagnostic code")?;
22611            ExpectTarget::Diagnostic { code }
22612        } else if self.consume_ident("no") {
22613            let name = self.parse_dotted_name("forbidden effect name")?;
22614            ExpectTarget::NoEffect { name }
22615        } else {
22616            let noun = self.parse_dotted_name("projection noun")?;
22617            let kind = self.parse_proj_query_kind()?;
22618            let end = self.last_span_end();
22619            ExpectTarget::Projection(ProjQuery {
22620                noun,
22621                kind,
22622                span: SourceSpan { start, end },
22623            })
22624        };
22625        let end = self.last_span_end();
22626        Some(ExpectClause {
22627            target,
22628            span: SourceSpan { start, end },
22629        })
22630    }
22631
22632    fn parse_proj_query_kind(&mut self) -> Option<ProjQueryKind> {
22633        if self.consume_ident("exists") {
22634            return Some(ProjQueryKind::Exists);
22635        }
22636        if self.consume_ident("count") {
22637            if !self.consume_ident("where") {
22638                self.expected("`where <predicate> is <N>` after `count`");
22639                return None;
22640            }
22641            let (predicate, _) = self.capture_expr_until_ident("is");
22642            if !self.consume_ident("is") {
22643                self.expected("`is <N>` after the count predicate");
22644                return None;
22645            }
22646            let (count, _) = self.expect_u32("count value")?;
22647            return Some(ProjQueryKind::Count { predicate, count });
22648        }
22649        if self.consume_ident("where") {
22650            let (predicate, _) = self.capture_expr_to_line_end();
22651            return Some(ProjQueryKind::Where { predicate });
22652        }
22653        self.expected("`exists`, `count where ... is <N>`, or `where ...`");
22654        None
22655    }
22656
22657    fn parse_source(&mut self) -> Option<SourceDecl> {
22658        let start = self.expect_keyword("source")?.span.start;
22659        let provider = self.expect_ident("source provider")?;
22660        let is_clock = provider.name == "clock";
22661        if !self.consume_ident("as") {
22662            self.expected("`as <name>` after the source provider");
22663            return None;
22664        }
22665        let name = self.expect_ident("source name")?;
22666        let open = self.expect_symbol('{')?;
22667
22668        let mut recurrence: Option<Recurrence> = None;
22669        let mut timezone: Option<StringLiteral> = None;
22670        let mut missed: Option<MissedPolicy> = None;
22671        let mut path: Option<StringLiteral> = None;
22672        let mut watch: Option<StringLiteral> = None;
22673        let mut url: Option<StringLiteral> = None;
22674        let mut dedup: Option<SourceValue> = None;
22675        let mut observe_binding: Option<Ident> = None;
22676        let mut emit: Option<SourceEmit> = None;
22677
22678        while !self.is_at_end() && !self.at_symbol('}') {
22679            if self.at_ident("every") || self.at_ident("at") {
22680                if let Some(parsed) = self.parse_recurrence() {
22681                    recurrence = Some(parsed);
22682                } else {
22683                    self.synchronize_to_block_item();
22684                }
22685            } else if self.at_ident("timezone") {
22686                self.advance();
22687                timezone = self.expect_string("timezone string");
22688            } else if self.at_ident("path") {
22689                self.advance();
22690                path = self.expect_string("path string");
22691            } else if self.at_ident("watch") {
22692                self.advance();
22693                watch = self.expect_string("watch glob string");
22694            } else if self.at_ident("url") {
22695                self.advance();
22696                url = self.expect_string("url string");
22697            } else if self.at_ident("dedup") {
22698                self.advance();
22699                dedup = self.parse_source_value();
22700            } else if self.at_ident("missed") {
22701                missed = self.parse_missed_policy();
22702            } else if self.at_ident("observe") {
22703                self.advance();
22704                if !self.consume_ident("as") {
22705                    self.expected("`as <binding>` after `observe`");
22706                }
22707                observe_binding = self.expect_ident("observe binding");
22708            } else if self.at_ident("emit") {
22709                emit = self.parse_source_emit();
22710            } else {
22711                self.unexpected(
22712                    "a source clause (`every`/`at`, `timezone`, `path`, `watch`, `url`, `dedup`, `missed`, `observe`, `emit`)",
22713                );
22714                self.synchronize_to_block_item();
22715            }
22716        }
22717        let end = self
22718            .expect_symbol('}')
22719            .map(|token| token.span.end)
22720            .unwrap_or(open.span.end);
22721        let span = SourceSpan { start, end };
22722
22723        let observe_binding = match observe_binding {
22724            Some(binding) => binding,
22725            None => {
22726                self.diagnostics.push(Diagnostic {
22727                    related: Vec::new(),
22728                    span,
22729                    message: format!("source `{}` must declare `observe as <binding>`", name.name),
22730                    suggestion: Some("add `observe as tick`".to_owned()),
22731                });
22732                return None;
22733            }
22734        };
22735        let emit = match emit {
22736            Some(emit) => emit,
22737            None => {
22738                self.diagnostics.push(Diagnostic {
22739                    related: Vec::new(),
22740                    span,
22741                    message: format!(
22742                        "source `{}` must declare `emit <signal> {{ ... }}`",
22743                        name.name
22744                    ),
22745                    suggestion: Some("add `emit triage.tick { ... }`".to_owned()),
22746                });
22747                return None;
22748            }
22749        };
22750
22751        let clock = if is_clock {
22752            let recurrence = match recurrence {
22753                Some(recurrence) => recurrence,
22754                None => {
22755                    self.diagnostics.push(Diagnostic {
22756                        related: Vec::new(),
22757                        span,
22758                        message: format!("clock source `{}` must declare a recurrence", name.name),
22759                        suggestion: Some(
22760                            "add `every weekday at 09:00`, `every 5m`, or `at 09:00`".to_owned(),
22761                        ),
22762                    });
22763                    return None;
22764                }
22765            };
22766            Some(ClockPolicy {
22767                recurrence,
22768                timezone,
22769                missed,
22770                span,
22771            })
22772        } else {
22773            if recurrence.is_some() || timezone.is_some() || missed.is_some() {
22774                self.diagnostics.push(Diagnostic {
22775                    related: Vec::new(),
22776                    span,
22777                    message: format!(
22778                        "source `{}` uses clock-only clauses but its provider is `{}`, not `clock`",
22779                        name.name, provider.name
22780                    ),
22781                    suggestion: Some(
22782                        "use `source clock as ...` for recurrence, timezone, or missed clauses"
22783                            .to_owned(),
22784                    ),
22785                });
22786            }
22787            None
22788        };
22789
22790        Some(SourceDecl {
22791            name,
22792            provider,
22793            clock,
22794            path,
22795            watch,
22796            url,
22797            dedup,
22798            observe_binding,
22799            emit,
22800            span,
22801        })
22802    }
22803
22804    fn parse_recurrence(&mut self) -> Option<Recurrence> {
22805        if self.at_ident("at") {
22806            let at = self.expect_keyword("at")?;
22807            let time = self.parse_time_of_day()?;
22808            return Some(Recurrence::At {
22809                span: at.span.join(time.span),
22810                time,
22811            });
22812        }
22813        let every = self.expect_keyword("every")?;
22814        if matches!(
22815            self.peek().map(|token| &token.kind),
22816            Some(TokenKind::Number(_))
22817        ) {
22818            let (value, _) = self.expect_u32("recurrence interval")?;
22819            let unit = self.expect_ident("duration unit (`s`, `m`, `h`, or `d`)")?;
22820            let seconds = match unit.name.as_str() {
22821                "s" => value as u64,
22822                "m" => value as u64 * 60,
22823                "h" => value as u64 * 3_600,
22824                "d" => value as u64 * 86_400,
22825                other => {
22826                    self.diagnostics.push(Diagnostic {
22827                        related: Vec::new(),
22828                        span: unit.span,
22829                        message: format!("unknown duration unit `{other}`"),
22830                        suggestion: Some("use `s`, `m`, `h`, or `d`".to_owned()),
22831                    });
22832                    return None;
22833                }
22834            };
22835            return Some(Recurrence::EveryDuration {
22836                seconds,
22837                source: format!("{value}{}", unit.name),
22838                span: every.span.join(unit.span),
22839            });
22840        }
22841        let pattern_ident =
22842            self.expect_ident("calendar pattern (`day`, `weekday`, or a weekday)")?;
22843        let pattern = match pattern_ident.name.as_str() {
22844            "day" => CalendarPattern::Day,
22845            "weekday" => CalendarPattern::Weekday,
22846            "monday" => CalendarPattern::Weekly(Weekday::Monday),
22847            "tuesday" => CalendarPattern::Weekly(Weekday::Tuesday),
22848            "wednesday" => CalendarPattern::Weekly(Weekday::Wednesday),
22849            "thursday" => CalendarPattern::Weekly(Weekday::Thursday),
22850            "friday" => CalendarPattern::Weekly(Weekday::Friday),
22851            "saturday" => CalendarPattern::Weekly(Weekday::Saturday),
22852            "sunday" => CalendarPattern::Weekly(Weekday::Sunday),
22853            other => {
22854                self.diagnostics.push(Diagnostic {
22855                    related: Vec::new(),
22856                    span: pattern_ident.span,
22857                    message: format!("unknown calendar pattern `{other}`"),
22858                    suggestion: Some(
22859                        "use `day`, `weekday`, or a weekday such as `monday`".to_owned(),
22860                    ),
22861                });
22862                return None;
22863            }
22864        };
22865        if !self.consume_ident("at") {
22866            self.expected("`at <hh:mm>` after the calendar pattern");
22867            return None;
22868        }
22869        let time = self.parse_time_of_day()?;
22870        Some(Recurrence::EveryCalendar {
22871            pattern,
22872            span: every.span.join(time.span),
22873            time,
22874        })
22875    }
22876
22877    fn parse_time_of_day(&mut self) -> Option<TimeOfDay> {
22878        let (hour, hour_span) = self.expect_u32("hour")?;
22879        self.expect_symbol(':')?;
22880        let (minute, minute_span) = self.expect_u32("minute")?;
22881        if hour > 23 || minute > 59 {
22882            self.diagnostics.push(Diagnostic {
22883                related: Vec::new(),
22884                span: hour_span.join(minute_span),
22885                message: format!("invalid time of day `{hour:02}:{minute:02}`"),
22886                suggestion: Some("use a 24-hour `hh:mm` such as `09:00`".to_owned()),
22887            });
22888            return None;
22889        }
22890        Some(TimeOfDay {
22891            hour: hour as u8,
22892            minute: minute as u8,
22893            span: hour_span.join(minute_span),
22894        })
22895    }
22896
22897    fn parse_missed_policy(&mut self) -> Option<MissedPolicy> {
22898        self.expect_keyword("missed")?;
22899        if self.consume_ident("skip") {
22900            return Some(MissedPolicy::Skip);
22901        }
22902        if self.consume_ident("coalesce") {
22903            return Some(MissedPolicy::Coalesce);
22904        }
22905        if self.consume_ident("catch_up") {
22906            if !self.consume_ident("limit") {
22907                self.expected("`limit <N>` after `catch_up`");
22908                return None;
22909            }
22910            let (limit, _) = self.expect_u32("catch_up limit")?;
22911            return Some(MissedPolicy::CatchUp { limit });
22912        }
22913        self.expected("`skip`, `coalesce`, or `catch_up limit <N>`");
22914        None
22915    }
22916
22917    fn parse_source_emit(&mut self) -> Option<SourceEmit> {
22918        let emit = self.expect_keyword("emit")?;
22919        let first = self.expect_ident("emit signal name")?;
22920        let mut signal = first.name.clone();
22921        let mut signal_span = first.span;
22922        while self.at_symbol('.') {
22923            self.advance();
22924            let segment = self.expect_ident("signal name segment")?;
22925            signal.push('.');
22926            signal.push_str(&segment.name);
22927            signal_span = signal_span.join(segment.span);
22928        }
22929        let from = if self.consume_ident("from") {
22930            Some(self.expect_ident("binding name after `from`")?)
22931        } else {
22932            None
22933        };
22934        if from.is_some() && !self.at_symbol('{') {
22935            let end = from.as_ref().map(|ident| ident.span.end).unwrap_or(0);
22936            return Some(SourceEmit {
22937                signal,
22938                signal_span,
22939                from,
22940                fields: Vec::new(),
22941                span: SourceSpan {
22942                    start: emit.span.start,
22943                    end,
22944                },
22945            });
22946        }
22947        let open = self.expect_symbol('{')?;
22948        let mut fields = Vec::new();
22949        while !self.is_at_end() && !self.at_symbol('}') {
22950            let Some(field_name) = self.expect_ident("emit field name") else {
22951                self.synchronize_to_block_item();
22952                continue;
22953            };
22954            let Some(value) = self.parse_source_value() else {
22955                self.synchronize_to_block_item();
22956                continue;
22957            };
22958            let value_span = match &value {
22959                SourceValue::Path { span, .. } => *span,
22960                SourceValue::String(literal) => literal.span,
22961                SourceValue::Number(_, span) => *span,
22962            };
22963            fields.push(SourceEmitField {
22964                span: field_name.span.join(value_span),
22965                name: field_name,
22966                value,
22967            });
22968        }
22969        let end = self
22970            .expect_symbol('}')
22971            .map(|token| token.span.end)
22972            .unwrap_or(open.span.end);
22973        Some(SourceEmit {
22974            signal,
22975            signal_span,
22976            from,
22977            fields,
22978            span: SourceSpan {
22979                start: emit.span.start,
22980                end,
22981            },
22982        })
22983    }
22984
22985    fn parse_source_value(&mut self) -> Option<SourceValue> {
22986        match self.peek().map(|token| &token.kind) {
22987            Some(TokenKind::String(_)) => self.expect_string("value").map(SourceValue::String),
22988            Some(TokenKind::Number(_)) => {
22989                let token = self.advance().clone();
22990                if let TokenKind::Number(value) = token.kind {
22991                    Some(SourceValue::Number(value, token.span))
22992                } else {
22993                    None
22994                }
22995            }
22996            Some(TokenKind::Ident(_)) => {
22997                let binding = self.expect_ident("value path")?;
22998                let mut segments = Vec::new();
22999                let mut span = binding.span;
23000                while self.at_symbol('.') {
23001                    self.advance();
23002                    let segment = self.expect_ident("path segment")?;
23003                    span = span.join(segment.span);
23004                    segments.push(segment);
23005                }
23006                Some(SourceValue::Path {
23007                    binding,
23008                    segments,
23009                    span,
23010                })
23011            }
23012            _ => {
23013                self.expected("a value (observation path, string, or number)");
23014                None
23015            }
23016        }
23017    }
23018
23019    fn parse_class(&mut self) -> Option<ClassDecl> {
23020        let start = self.expect_keyword("class")?.span.start;
23021        let name = self.expect_ident("class name")?;
23022        let open = self.expect_symbol('{')?;
23023        let mut fields = Vec::new();
23024
23025        while !self.is_at_end() && !self.at_symbol('}') {
23026            let Some(field_name) = self.expect_ident("class field name") else {
23027                self.synchronize_to_block_item();
23028                continue;
23029            };
23030            let Some(ty) = self.parse_type() else {
23031                self.synchronize_to_block_item();
23032                continue;
23033            };
23034            // `@key`: mark this field as the class's natural key (import per-row
23035            // idempotency, spec/std-library/files.md).
23036            let mut is_key = false;
23037            if self.at_symbol('@') {
23038                if let Some(tag) = self.parse_tag() {
23039                    if tag.name == "key" {
23040                        is_key = true;
23041                    } else {
23042                        self.diagnostics.push(Diagnostic {
23043                            related: Vec::new(),
23044                            span: tag.span,
23045                            message: format!("unknown field tag `@{}`", tag.name),
23046                            suggestion: Some(
23047                                "the only field tag is `@key` (the class natural key)".to_owned(),
23048                            ),
23049                        });
23050                    }
23051                }
23052            }
23053            let presence_condition = self.parse_field_presence_condition();
23054            let span = field_name.span.join(ty.span());
23055            fields.push(ClassField {
23056                span,
23057                name: field_name,
23058                ty,
23059                is_key,
23060                presence_condition,
23061            });
23062        }
23063
23064        let end = self
23065            .expect_symbol('}')
23066            .map(|token| token.span.end)
23067            .unwrap_or(open.span.end);
23068
23069        Some(ClassDecl {
23070            name,
23071            fields,
23072            span: SourceSpan { start, end },
23073        })
23074    }
23075
23076    fn parse_table(
23077        &mut self,
23078        tags: Vec<TagDecl>,
23079        description: Option<StringLiteral>,
23080    ) -> Option<TableDecl> {
23081        let start = self.expect_keyword("table")?.span.start;
23082        let name = self.expect_ident("table name")?;
23083        self.expect_keyword("as")?;
23084        let schema = self.expect_ident("table row class")?;
23085        let open = self.expect_symbol('[')?;
23086        let mut rows = Vec::new();
23087
23088        while !self.is_at_end() && !self.at_symbol(']') {
23089            if self.at_symbol(',') {
23090                self.advance();
23091                continue;
23092            }
23093            if !self.at_symbol('{') {
23094                self.unexpected("table row `{ ... }`");
23095                self.synchronize_to_table_row();
23096                continue;
23097            }
23098            if let Some(row) = self.parse_table_row() {
23099                rows.push(row);
23100            }
23101            if self.at_symbol(',') {
23102                self.advance();
23103            }
23104        }
23105
23106        let end = self
23107            .expect_symbol(']')
23108            .map(|token| token.span.end)
23109            .unwrap_or(open.span.end);
23110        Some(TableDecl {
23111            name,
23112            tags,
23113            description,
23114            schema,
23115            rows,
23116            span: SourceSpan { start, end },
23117        })
23118    }
23119
23120    fn parse_table_row(&mut self) -> Option<TableRow> {
23121        let open = self.expect_symbol('{')?;
23122        let body_start = open.span.end;
23123        let mut depth = 1usize;
23124        let mut body_end = body_start;
23125        let mut close_end = open.span.end;
23126
23127        while !self.is_at_end() {
23128            let token = self.advance().clone();
23129            match token.kind {
23130                TokenKind::Symbol('{') => {
23131                    depth += 1;
23132                    body_end = token.span.end;
23133                }
23134                TokenKind::Symbol('}') => {
23135                    depth -= 1;
23136                    if depth == 0 {
23137                        body_end = token.span.start;
23138                        close_end = token.span.end;
23139                        break;
23140                    }
23141                    body_end = token.span.end;
23142                }
23143                _ => body_end = token.span.end,
23144            }
23145        }
23146
23147        if depth != 0 {
23148            self.diagnostics.push(Diagnostic {
23149                related: Vec::new(),
23150                span: SourceSpan {
23151                    start: open.span.start,
23152                    end: body_end,
23153                },
23154                message: "unterminated table row".to_owned(),
23155                suggestion: Some("close the table row with `}`".to_owned()),
23156            });
23157            return None;
23158        }
23159
23160        let body_span = SourceSpan {
23161            start: body_start,
23162            end: body_end,
23163        };
23164        let (text, span) = trimmed_source_text(self.source_text(body_span), body_span);
23165        Some(TableRow {
23166            body: BlockSource { text, span },
23167            span: SourceSpan {
23168                start: open.span.start,
23169                end: close_end,
23170            },
23171        })
23172    }
23173
23174    fn parse_coerce(&mut self) -> Option<CoerceDecl> {
23175        let start = self.expect_keyword("coerce")?.span.start;
23176        let name = self.expect_ident("coerce name")?;
23177        let params = self.parse_param_list()?;
23178        self.expect_thin_arrow()?;
23179        let output = self.parse_type()?;
23180        // Block-less prompt-only form: `coerce f(a) -> T """…"""` — sugar for
23181        // a block whose sole clause is the prompt. Desugared here to the same
23182        // body text (`prompt <raw string>`), so lowering, prompt extraction,
23183        // and fingerprints are byte-for-byte the block form's.
23184        if !self.at_symbol('{') {
23185            if let Some(TokenKind::String(_)) = self.peek().map(|token| &token.kind) {
23186                let token = self.advance().clone();
23187                let raw = self
23188                    .source_text(SourceSpan {
23189                        start: token.span.start,
23190                        end: token.span.end,
23191                    })
23192                    .to_owned();
23193                let body = BlockSource {
23194                    text: format!("prompt {raw}"),
23195                    span: token.span,
23196                };
23197                let span = SourceSpan {
23198                    start,
23199                    end: body.span.end,
23200                };
23201                return Some(CoerceDecl {
23202                    name,
23203                    params,
23204                    output,
23205                    body,
23206                    span,
23207                });
23208            }
23209        }
23210        let body = self.parse_block_source()?;
23211        let span = SourceSpan {
23212            start,
23213            end: body.span.end,
23214        };
23215        Some(CoerceDecl {
23216            name,
23217            params,
23218            output,
23219            body,
23220            span,
23221        })
23222    }
23223
23224    fn parse_param_list(&mut self) -> Option<Vec<ParamDecl>> {
23225        self.expect_symbol('(')?;
23226        let mut params = Vec::new();
23227
23228        while !self.is_at_end() && !self.at_symbol(')') {
23229            let name = self.expect_ident("parameter name")?;
23230            let ty = self.parse_type()?;
23231            params.push(ParamDecl {
23232                span: name.span.join(ty.span()),
23233                name,
23234                ty,
23235            });
23236
23237            if self.at_symbol(',') {
23238                self.advance();
23239            } else if !self.at_symbol(')') {
23240                self.unexpected("`,` or `)`");
23241                while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
23242                    self.advance();
23243                }
23244            }
23245        }
23246
23247        self.expect_symbol(')')?;
23248        Some(params)
23249    }
23250
23251    /// `action <name>(<param: type>, …) { <effect chain> }` (DR-0023). The body
23252    /// is captured as a block source; expansion at call sites is a later slice.
23253    fn parse_action(&mut self) -> Option<ActionDecl> {
23254        let start = self.expect_keyword("action")?.span.start;
23255        let name = self.expect_ident("action name")?;
23256        self.expect_symbol('(')?;
23257        let mut params = Vec::new();
23258        while !self.is_at_end() && !self.at_symbol(')') {
23259            let param_name = self.expect_ident("action parameter name")?;
23260            let ty = self.parse_type()?;
23261            let span = param_name.span.join(ty.span());
23262            params.push(ActionParam {
23263                name: param_name,
23264                ty,
23265                span,
23266            });
23267            if self.at_symbol(',') {
23268                self.advance();
23269            }
23270        }
23271        self.expect_symbol(')')?;
23272        let body = self.parse_block_source()?;
23273        let span = SourceSpan {
23274            start,
23275            end: body.span.end,
23276        };
23277        Some(ActionDecl {
23278            name,
23279            params,
23280            body,
23281            span,
23282        })
23283    }
23284
23285    fn parse_rule(
23286        &mut self,
23287        tags: Vec<TagDecl>,
23288        description: Option<StringLiteral>,
23289    ) -> Option<RuleDecl> {
23290        let start = self.expect_keyword("rule")?.span.start;
23291        let name = self.expect_ident("rule name")?;
23292        let mut whens = Vec::new();
23293
23294        while !self.is_at_end() && !self.at_arrow() {
23295            if self.at_ident("when") {
23296                whens.extend(self.parse_when_clauses()?);
23297            } else if self.at_ident("with") {
23298                let span = self
23299                    .peek()
23300                    .map(|token| token.span)
23301                    .unwrap_or(SourceSpan { start, end: start });
23302                self.diagnostics.push(Diagnostic {
23303                    related: Vec::new(),
23304                    span,
23305                    message: "`with` is not a rule readiness clause".to_owned(),
23306                    suggestion: Some("use `when` for rule conditions".to_owned()),
23307                });
23308                self.advance();
23309            } else {
23310                self.unexpected("`when` clause or `=>`");
23311                self.advance();
23312            }
23313        }
23314
23315        self.expect_arrow()?;
23316        let body = self.parse_block_source()?;
23317        let span = SourceSpan {
23318            start,
23319            end: body.span.end,
23320        };
23321        Some(RuleDecl {
23322            name,
23323            tags,
23324            description,
23325            whens,
23326            body,
23327            span,
23328        })
23329    }
23330
23331    fn parse_when_clauses(&mut self) -> Option<Vec<WhenClause>> {
23332        let when = self.expect_keyword("when")?;
23333        if self.at_symbol('{') {
23334            return self.parse_grouped_when_clauses(when.span);
23335        }
23336
23337        Some(vec![self.parse_when_clause_after_keyword(when.span)?])
23338    }
23339
23340    fn parse_assert(
23341        &mut self,
23342        tags: Vec<TagDecl>,
23343        description: Option<StringLiteral>,
23344    ) -> Option<AssertDecl> {
23345        let assert = self.expect_keyword("assert")?;
23346        let expr_start = assert.span.end;
23347        let line_end = self.source[expr_start..]
23348            .find('\n')
23349            .map(|offset| expr_start + offset)
23350            .unwrap_or(self.source.len());
23351        let mut expr_end = line_end;
23352
23353        while !self.is_at_end() && self.peek()?.span.start < line_end {
23354            expr_end = self.peek()?.span.end.min(line_end);
23355            self.advance();
23356        }
23357        expr_end = Self::extend_span_over_skipped_operators(self.source, expr_end, line_end);
23358
23359        let span = SourceSpan {
23360            start: expr_start,
23361            end: expr_end,
23362        };
23363        let (expr, span) = trimmed_source_text(self.source_text(span), span);
23364        Some(AssertDecl {
23365            tags,
23366            description,
23367            expr,
23368            span,
23369        })
23370    }
23371
23372    fn parse_when_clause_after_keyword(&mut self, when: SourceSpan) -> Option<WhenClause> {
23373        self.parse_when_clause_with_stop(when, false)
23374    }
23375
23376    /// Flow headers terminate at the body `{`; rule headers at `=>`.
23377    fn parse_when_clause_with_stop(
23378        &mut self,
23379        when: SourceSpan,
23380        stop_at_brace: bool,
23381    ) -> Option<WhenClause> {
23382        let text_start = when.end;
23383        let mut text_end = text_start;
23384
23385        while !(self.is_at_end()
23386            || self.at_arrow()
23387            || self.at_ident("when")
23388            || self.at_ident("rule")
23389            || stop_at_brace && self.at_symbol('{'))
23390        {
23391            text_end = self.peek()?.span.end;
23392            self.advance();
23393        }
23394        let limit = self
23395            .peek()
23396            .map(|token| token.span.start)
23397            .unwrap_or(self.source.len());
23398        text_end = Self::extend_span_over_skipped_operators(self.source, text_end, limit);
23399
23400        let span = SourceSpan {
23401            start: text_start,
23402            end: text_end,
23403        };
23404        let (text, span) = trimmed_source_text(self.source_text(span), span);
23405        Some(WhenClause { text, span })
23406    }
23407
23408    /// The file-level lexer steps over expression operators (`==`, `!=`,
23409    /// `<=`, `>=`, `&&`, `||`, `*`, `/`, `-`) without emitting tokens —
23410    /// expressions are re-parsed from raw source slices. A raw capture that
23411    /// walks TOKEN spans therefore stops short when the clause ends in a
23412    /// dangling operator, silently truncating `assert a ==` to `assert a`
23413    /// (which then mis-diagnoses as a non-boolean expression instead of a
23414    /// syntax error). Extend `end` across same-line trailing operator bytes
23415    /// so the expression parser sees the dangling operator. `=>`/`->` are
23416    /// real tokens and `//` starts a comment; neither is consumed here.
23417    fn extend_span_over_skipped_operators(source: &str, mut end: usize, limit: usize) -> usize {
23418        let bytes = source.as_bytes();
23419        loop {
23420            let mut cursor = end;
23421            while cursor < limit && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' {
23422                cursor += 1;
23423            }
23424            let width = match (bytes.get(cursor), bytes.get(cursor + 1)) {
23425                _ if cursor >= limit => break,
23426                (Some(b'='), Some(b'='))
23427                | (Some(b'!'), Some(b'='))
23428                | (Some(b'<'), Some(b'='))
23429                | (Some(b'>'), Some(b'='))
23430                | (Some(b'&'), Some(b'&'))
23431                | (Some(b'|'), Some(b'|')) => 2,
23432                (Some(b'/'), Some(b'/')) => break,
23433                (Some(b'-'), Some(b'>')) => break,
23434                (Some(b'*' | b'/' | b'-'), _) => 1,
23435                _ => break,
23436            };
23437            if cursor + width > limit {
23438                break;
23439            }
23440            end = cursor + width;
23441        }
23442        end
23443    }
23444
23445    fn parse_grouped_when_clauses(&mut self, when: SourceSpan) -> Option<Vec<WhenClause>> {
23446        let open = self.expect_symbol('{')?;
23447        let body_start = open.span.end;
23448        let mut depth = 1usize;
23449        let mut body_end = body_start;
23450        let mut close_end = open.span.end;
23451
23452        while !self.is_at_end() {
23453            let token = self.advance().clone();
23454            match token.kind {
23455                TokenKind::Symbol('{') => {
23456                    depth += 1;
23457                    body_end = token.span.end;
23458                }
23459                TokenKind::Symbol('}') => {
23460                    depth -= 1;
23461                    if depth == 0 {
23462                        body_end = token.span.start;
23463                        close_end = token.span.end;
23464                        break;
23465                    }
23466                    body_end = token.span.end;
23467                }
23468                _ => body_end = token.span.end,
23469            }
23470        }
23471
23472        if depth != 0 {
23473            self.diagnostics.push(Diagnostic {
23474                related: Vec::new(),
23475                span: SourceSpan {
23476                    start: when.start,
23477                    end: body_end,
23478                },
23479                message: "unterminated grouped `when` block".to_owned(),
23480                suggestion: Some("close the grouped readiness block with `}`".to_owned()),
23481            });
23482            return Some(Vec::new());
23483        }
23484
23485        let body_span = SourceSpan {
23486            start: body_start,
23487            end: body_end,
23488        };
23489        let mut clauses = Vec::new();
23490        let mut offset = 0usize;
23491        for line in self.source_text(body_span).split_inclusive('\n') {
23492            let line_without_newline = line.trim_end_matches('\n');
23493            let line_start = body_span.start + offset;
23494            offset += line.len();
23495            let leading = line_without_newline.len() - line_without_newline.trim_start().len();
23496            let trailing = line_without_newline.len() - line_without_newline.trim_end().len();
23497            let trimmed_start = line_start + leading;
23498            let trimmed_end = line_start + line_without_newline.len().saturating_sub(trailing);
23499            if trimmed_start >= trimmed_end {
23500                continue;
23501            }
23502            clauses.push(WhenClause {
23503                text: self.source[trimmed_start..trimmed_end].to_owned(),
23504                span: SourceSpan {
23505                    start: trimmed_start,
23506                    end: trimmed_end,
23507                },
23508            });
23509        }
23510
23511        if clauses.is_empty() {
23512            self.diagnostics.push(Diagnostic {
23513                related: Vec::new(),
23514                span: SourceSpan {
23515                    start: when.start,
23516                    end: close_end,
23517                },
23518                message: "grouped `when` block has no readiness clauses".to_owned(),
23519                suggestion: Some(
23520                    "add one condition per line, such as `started` or `Class as binding`"
23521                        .to_owned(),
23522                ),
23523            });
23524        }
23525
23526        Some(clauses)
23527    }
23528
23529    fn parse_block_source(&mut self) -> Option<BlockSource> {
23530        let open = self.expect_symbol('{')?;
23531        let body_start = open.span.end;
23532        let mut depth = 1usize;
23533        let mut body_end = body_start;
23534
23535        while !self.is_at_end() {
23536            let token = self.advance().clone();
23537            match token.kind {
23538                TokenKind::Symbol('{') => {
23539                    depth += 1;
23540                    body_end = token.span.end;
23541                }
23542                TokenKind::Symbol('}') => {
23543                    depth -= 1;
23544                    if depth == 0 {
23545                        body_end = token.span.start;
23546                        return Some(BlockSource {
23547                            text: self
23548                                .source_text(SourceSpan {
23549                                    start: body_start,
23550                                    end: body_end,
23551                                })
23552                                .trim()
23553                                .to_owned(),
23554                            span: SourceSpan {
23555                                start: open.span.start,
23556                                end: token.span.end,
23557                            },
23558                        });
23559                    }
23560                    body_end = token.span.end;
23561                }
23562                _ => {
23563                    body_end = token.span.end;
23564                }
23565            }
23566        }
23567
23568        self.diagnostics.push(Diagnostic {
23569            related: Vec::new(),
23570            span: SourceSpan {
23571                start: open.span.start,
23572                end: body_end,
23573            },
23574            message: "unterminated block".to_owned(),
23575            suggestion: Some("add a closing `}`".to_owned()),
23576        });
23577        Some(BlockSource {
23578            text: self
23579                .source_text(SourceSpan {
23580                    start: body_start,
23581                    end: body_end,
23582                })
23583                .trim()
23584                .to_owned(),
23585            span: SourceSpan {
23586                start: open.span.start,
23587                end: body_end,
23588            },
23589        })
23590    }
23591
23592    fn parse_type(&mut self) -> Option<TypeSyntax> {
23593        let first = self.parse_type_atom()?;
23594        let first = self.parse_type_suffixes(first);
23595
23596        if !self.at_symbol('|') {
23597            return Some(first);
23598        }
23599
23600        let start = first.span().start;
23601        let mut end = first.span().end;
23602        let mut variants = vec![first];
23603
23604        while self.at_symbol('|') {
23605            self.advance();
23606            let variant = self.parse_type_atom()?;
23607            let variant = self.parse_type_suffixes(variant);
23608            end = variant.span().end;
23609            variants.push(variant);
23610        }
23611
23612        Some(TypeSyntax::Union {
23613            variants,
23614            span: SourceSpan { start, end },
23615        })
23616    }
23617
23618    fn parse_type_atom(&mut self) -> Option<TypeSyntax> {
23619        Some(if self.at_ident("AgentRef") {
23620            let agent_ref = self.advance().clone();
23621            self.expect_symbol('<')?;
23622            let mut agents = Vec::new();
23623            while !self.is_at_end() && !self.at_symbol('>') {
23624                if self.at_symbol('|') {
23625                    self.advance();
23626                    continue;
23627                }
23628                let Some(agent) = self.expect_ident("agent reference") else {
23629                    break;
23630                };
23631                agents.push(agent);
23632            }
23633            let close = self.expect_symbol('>')?;
23634            TypeSyntax::AgentRef {
23635                agents,
23636                span: agent_ref.span.join(close.span),
23637            }
23638        } else if self.at_ident("map") {
23639            let map = self.advance().clone();
23640            self.expect_symbol('<')?;
23641            let inner = self.parse_type()?;
23642            let close = self.expect_symbol('>')?;
23643            TypeSyntax::Map {
23644                span: map.span.join(close.span),
23645                inner: Box::new(inner),
23646            }
23647        } else if matches!(
23648            self.peek().map(|token| &token.kind),
23649            Some(TokenKind::String(_))
23650        ) {
23651            let literal = self.expect_string("literal type")?;
23652            TypeSyntax::LiteralString {
23653                value: literal.value,
23654                span: literal.span,
23655            }
23656        } else {
23657            let ident = self.expect_ident("type name")?;
23658            if is_primitive_type(&ident.name) {
23659                TypeSyntax::Primitive {
23660                    name: ident.name,
23661                    span: ident.span,
23662                }
23663            } else {
23664                TypeSyntax::Ref { name: ident }
23665            }
23666        })
23667    }
23668
23669    fn parse_type_suffixes(&mut self, mut ty: TypeSyntax) -> TypeSyntax {
23670        loop {
23671            if self.at_symbol('?') {
23672                let question = self.advance().clone();
23673                ty = TypeSyntax::Optional {
23674                    span: ty.span().join(question.span),
23675                    inner: Box::new(ty),
23676                };
23677            } else if self.at_symbol('[') {
23678                self.advance();
23679                let Some(close) = self.expect_symbol(']') else {
23680                    return ty;
23681                };
23682                ty = TypeSyntax::Array {
23683                    span: ty.span().join(close.span),
23684                    inner: Box::new(ty),
23685                };
23686            } else {
23687                return ty;
23688            }
23689        }
23690    }
23691
23692    fn parse_string_list(&mut self) -> Option<(Vec<StringLiteral>, SourceSpan)> {
23693        let open = self.expect_symbol('[')?;
23694        let mut values = Vec::new();
23695
23696        while !self.is_at_end() && !self.at_symbol(']') {
23697            values.push(self.expect_string("skill string")?);
23698            if self.at_symbol(',') {
23699                self.advance();
23700            } else if !self.at_symbol(']') {
23701                self.unexpected("`,` or `]`");
23702                self.synchronize_to_block_item();
23703                break;
23704            }
23705        }
23706
23707        let close = self.expect_symbol(']')?;
23708        Some((values, open.span.join(close.span)))
23709    }
23710
23711    /// Parse a bracketed list of identifiers, e.g. `[WordCount, OpenPr]`. Used for
23712    /// the agent `tools` grant, whose entries reference declared workflows by name.
23713    fn parse_ident_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
23714        let open = self.expect_symbol('[')?;
23715        let mut values = Vec::new();
23716
23717        while !self.is_at_end() && !self.at_symbol(']') {
23718            values.push(self.expect_ident("tool workflow name")?);
23719            if self.at_symbol(',') {
23720                self.advance();
23721            } else if !self.at_symbol(']') {
23722                self.unexpected("`,` or `]`");
23723                self.synchronize_to_block_item();
23724                break;
23725            }
23726        }
23727
23728        let close = self.expect_symbol(']')?;
23729        Some((values, open.span.join(close.span)))
23730    }
23731
23732    /// Parse a bracketed list of dotted feature-class names, e.g.
23733    /// `[session.resume, turn.cancel]` (agent `requires`, DR-0015 taxonomy).
23734    /// Each entry is one or more identifiers joined by `.`; taxonomy
23735    /// membership is validated at lowering.
23736    fn parse_feature_class_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
23737        let open = self.expect_symbol('[')?;
23738        let mut values = Vec::new();
23739
23740        while !self.is_at_end() && !self.at_symbol(']') {
23741            let head = self.expect_ident("feature class")?;
23742            let mut name = head.name.clone();
23743            let mut span = head.span;
23744            while self.at_symbol('.') {
23745                self.advance();
23746                let part = self.expect_ident("feature class segment")?;
23747                name.push('.');
23748                name.push_str(&part.name);
23749                span = span.join(part.span);
23750            }
23751            values.push(Ident { name, span });
23752            if self.at_symbol(',') {
23753                self.advance();
23754            } else if !self.at_symbol(']') {
23755                self.unexpected("`,` or `]`");
23756                self.synchronize_to_block_item();
23757                break;
23758            }
23759        }
23760
23761        let close = self.expect_symbol(']')?;
23762        Some((values, open.span.join(close.span)))
23763    }
23764
23765    fn expect_keyword(&mut self, keyword: &str) -> Option<Token> {
23766        if self.at_ident(keyword) {
23767            Some(self.advance().clone())
23768        } else {
23769            self.expected(format!("`{keyword}`"));
23770            None
23771        }
23772    }
23773
23774    fn expect_ident(&mut self, label: &str) -> Option<Ident> {
23775        let token = self.peek()?;
23776        if let TokenKind::Ident(name) = &token.kind {
23777            let ident = Ident {
23778                name: name.clone(),
23779                span: token.span,
23780            };
23781            self.advance();
23782            Some(ident)
23783        } else {
23784            self.expected(label);
23785            None
23786        }
23787    }
23788
23789    /// Family B: an optional `when <discriminant> is "<literal>"` suffix on a
23790    /// schema/signal field — the field is present only when the literal-union
23791    /// discriminant field equals the literal. `is` is used instead of `==` to stay
23792    /// within the declaration tokenizer; the meaning is equality
23793    /// (spec/decision-records/discriminated-families-design.md §5.7).
23794    fn parse_field_presence_condition(&mut self) -> Option<(String, String)> {
23795        if !self.at_ident("when") {
23796            return None;
23797        }
23798        self.advance(); // `when`
23799        let disc = self.expect_ident("discriminant field name after `when`")?;
23800        if self.at_ident("is") {
23801            self.advance();
23802        } else {
23803            self.expected("`is` after the discriminant field");
23804            return None;
23805        }
23806        let literal = self.expect_string("discriminant literal value")?;
23807        Some((disc.name, literal.value))
23808    }
23809
23810    fn expect_string(&mut self, label: &str) -> Option<StringLiteral> {
23811        let token = self.peek()?;
23812        if let TokenKind::String(value) = &token.kind {
23813            let literal = StringLiteral {
23814                value: value.clone(),
23815                span: token.span,
23816            };
23817            self.advance();
23818            Some(literal)
23819        } else {
23820            self.expected(label);
23821            None
23822        }
23823    }
23824
23825    fn expect_use_name(&mut self, label: &str) -> Option<StringLiteral> {
23826        let token = self.peek()?;
23827        match &token.kind {
23828            // A package name may be a dotted path (`std.messaging`, `std.coord`)
23829            // or a bare ident (`memory`); a string literal is also accepted.
23830            TokenKind::Ident(value) => {
23831                let mut name = value.clone();
23832                let mut span = token.span;
23833                self.advance();
23834                while self.at_symbol('.') {
23835                    self.expect_symbol('.');
23836                    let Some(segment) = self.expect_ident("package name segment") else {
23837                        break;
23838                    };
23839                    name.push('.');
23840                    name.push_str(&segment.name);
23841                    span = span.join(segment.span);
23842                }
23843                Some(StringLiteral { value: name, span })
23844            }
23845            TokenKind::String(value) => {
23846                let literal = StringLiteral {
23847                    value: value.clone(),
23848                    span: token.span,
23849                };
23850                self.advance();
23851                Some(literal)
23852            }
23853            _ => {
23854                self.expected(label);
23855                None
23856            }
23857        }
23858    }
23859
23860    fn expect_u32(&mut self, label: &str) -> Option<(u32, SourceSpan)> {
23861        let token = self.peek()?;
23862        if let TokenKind::Number(value) = &token.kind {
23863            let span = token.span;
23864            let parsed = value.parse::<u32>();
23865            self.advance();
23866            match parsed {
23867                Ok(value) => Some((value, span)),
23868                Err(_) => {
23869                    self.diagnostics.push(Diagnostic {
23870                        related: Vec::new(),
23871                        span,
23872                        message: format!("{label} must fit in u32"),
23873                        suggestion: Some("use a non-negative integer such as `1`".to_owned()),
23874                    });
23875                    None
23876                }
23877            }
23878        } else {
23879            self.expected(label);
23880            None
23881        }
23882    }
23883
23884    fn expect_symbol(&mut self, symbol: char) -> Option<Token> {
23885        if self.at_symbol(symbol) {
23886            Some(self.advance().clone())
23887        } else {
23888            self.expected(format!("`{symbol}`"));
23889            None
23890        }
23891    }
23892
23893    fn expect_arrow(&mut self) -> Option<Token> {
23894        if self.at_arrow() {
23895            Some(self.advance().clone())
23896        } else {
23897            self.expected("`=>`");
23898            None
23899        }
23900    }
23901
23902    fn expect_thin_arrow(&mut self) -> Option<Token> {
23903        if self.at_thin_arrow() {
23904            Some(self.advance().clone())
23905        } else {
23906            self.expected("`->`");
23907            None
23908        }
23909    }
23910
23911    fn at_ident(&self, expected: &str) -> bool {
23912        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Ident(value)) if value == expected)
23913    }
23914
23915    fn consume_ident(&mut self, expected: &str) -> bool {
23916        if self.at_ident(expected) {
23917            self.advance();
23918            true
23919        } else {
23920            false
23921        }
23922    }
23923
23924    fn at_symbol(&self, expected: char) -> bool {
23925        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Symbol(value)) if *value == expected)
23926    }
23927
23928    fn at_arrow(&self) -> bool {
23929        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Arrow))
23930    }
23931
23932    fn at_thin_arrow(&self) -> bool {
23933        matches!(
23934            self.peek().map(|token| &token.kind),
23935            Some(TokenKind::ThinArrow)
23936        )
23937    }
23938
23939    fn peek(&self) -> Option<&Token> {
23940        self.tokens.get(self.pos)
23941    }
23942
23943    fn advance(&mut self) -> &Token {
23944        let index = self.pos;
23945        self.pos += 1;
23946        &self.tokens[index]
23947    }
23948
23949    fn is_at_end(&self) -> bool {
23950        self.pos >= self.tokens.len()
23951    }
23952
23953    fn expected(&mut self, expected: impl fmt::Display) {
23954        let expected = expected.to_string();
23955        let (span, found) = match self.peek() {
23956            Some(token) => (token.span, token.kind.label()),
23957            None => (
23958                SourceSpan {
23959                    start: self.source.len(),
23960                    end: self.source.len(),
23961                },
23962                "end of file".to_owned(),
23963            ),
23964        };
23965        self.diagnostics.push(Diagnostic {
23966            related: Vec::new(),
23967            span,
23968            message: format!("expected {expected}, found {found}"),
23969            suggestion: suggestion_for_expected(&expected),
23970        });
23971    }
23972
23973    fn unexpected(&mut self, expected: impl fmt::Display) {
23974        let Some(token) = self.peek() else {
23975            self.expected(expected);
23976            return;
23977        };
23978        let expected = expected.to_string();
23979        self.diagnostics.push(Diagnostic {
23980            related: Vec::new(),
23981            span: token.span,
23982            message: format!("expected {expected}, found {}", token.kind.label()),
23983            suggestion: suggestion_for_expected(&expected),
23984        });
23985    }
23986
23987    fn synchronize_to_block_item(&mut self) {
23988        while !self.is_at_end() {
23989            if self.at_symbol('}')
23990                || self.at_ident("profile")
23991                || self.at_ident("provider")
23992                || self.at_ident("capacity")
23993                || self.at_ident("skills")
23994                || self.at_ident("capabilities")
23995                || self.at_ident("tools")
23996                || self.at_ident("compaction")
23997                || self.at_ident("settings")
23998            {
23999                return;
24000            }
24001            self.advance();
24002        }
24003    }
24004
24005    fn synchronize_to_table_row(&mut self) {
24006        while !self.is_at_end() {
24007            if self.at_symbol('{') || self.at_symbol(']') {
24008                return;
24009            }
24010            self.advance();
24011        }
24012    }
24013
24014    fn source_text(&self, span: SourceSpan) -> &str {
24015        &self.source[span.start..span.end]
24016    }
24017}
24018
24019fn trimmed_source_text(source: &str, span: SourceSpan) -> (String, SourceSpan) {
24020    let leading = source.len() - source.trim_start().len();
24021    let trailing = source.len() - source.trim_end().len();
24022    let end = source.len().saturating_sub(trailing);
24023    if leading > end {
24024        return (
24025            String::new(),
24026            SourceSpan {
24027                start: span.end,
24028                end: span.end,
24029            },
24030        );
24031    }
24032    (
24033        source[leading..end].to_owned(),
24034        SourceSpan {
24035            start: span.start + leading,
24036            end: span.start + end,
24037        },
24038    )
24039}
24040
24041fn is_primitive_type(name: &str) -> bool {
24042    matches!(
24043        name,
24044        "string"
24045            | "int"
24046            | "float"
24047            | "bool"
24048            | "null"
24049            | "duration"
24050            | "time"
24051            | "image"
24052            | "audio"
24053            | "pdf"
24054            | "video"
24055    )
24056}
24057
24058fn is_gherkin_keyword(keyword: &str) -> bool {
24059    matches!(
24060        keyword,
24061        "Feature"
24062            | "Rule"
24063            | "Background"
24064            | "Scenario"
24065            | "ScenarioOutline"
24066            | "Scenario-Outline"
24067            | "Examples"
24068            | "Given"
24069            | "When"
24070            | "Then"
24071            | "And"
24072            | "But"
24073    )
24074}
24075
24076fn suggestion_for_expected(expected: &str) -> Option<String> {
24077    match expected {
24078        "`{`" => Some("add a `{ ... }` block".to_owned()),
24079        "`=>`" => Some("add `=> { ... }` after the rule conditions".to_owned()),
24080        "`->`" => Some("add `-> OutputType` before the coerce prompt block".to_owned()),
24081        "profile string" => Some("write `profile \"profile-name\"`".to_owned()),
24082        "capacity value" => Some("write `capacity 1`".to_owned()),
24083        "package library name" => Some("write a package library name, such as `memory`".to_owned()),
24084        "type name" => Some("write a primitive type or schema name".to_owned()),
24085        _ => None,
24086    }
24087}
24088
24089#[cfg(test)]
24090mod tests {
24091    use super::*;
24092
24093    #[test]
24094    fn parser_scaffold_links_to_core() {
24095        assert_eq!(parser_stage(), "release");
24096    }
24097
24098    #[test]
24099    fn declaration_block_grammar_table_is_complete() {
24100        // Drift canary for the build.rs-generated DECLARATION_BLOCK_GRAMMAR
24101        // table (D2.0 scaffolding). The table exists and is validated here; no
24102        // dispatch consumes it yet.
24103        let keywords: Vec<&str> = DECLARATION_BLOCK_GRAMMAR
24104            .iter()
24105            .map(|spec| spec.keyword)
24106            .collect();
24107        assert_eq!(
24108            keywords.len(),
24109            7,
24110            "expected exactly 7 declaration_block specs"
24111        );
24112        for expected in [
24113            "tracker",
24114            "channel",
24115            "counter",
24116            "lease",
24117            "ledger",
24118            "file store",
24119            "memory pool",
24120        ] {
24121            assert!(
24122                keywords.contains(&expected),
24123                "missing declaration_block keyword `{expected}`; got {keywords:?}"
24124            );
24125        }
24126
24127        let find = |keyword: &str| -> &DeclarationBlockSpec {
24128            DECLARATION_BLOCK_GRAMMAR
24129                .iter()
24130                .find(|spec| spec.keyword == keyword)
24131                .unwrap_or_else(|| panic!("no spec for `{keyword}`"))
24132        };
24133        let clause = |keyword: &str, name: &str| -> &ClauseSpec {
24134            find(keyword)
24135                .clauses
24136                .iter()
24137                .find(|clause| clause.name == name)
24138                .unwrap_or_else(|| panic!("no clause `{name}` on `{keyword}`"))
24139        };
24140
24141        // Multi-word keywords split into two words; single-word into one.
24142        assert_eq!(find("memory pool").keyword_words, &["memory", "pool"]);
24143        assert_eq!(find("file store").keyword_words, &["file", "store"]);
24144        assert_eq!(find("tracker").keyword_words, &["tracker"]);
24145
24146        // ledger `partition by` is the M2 connective clause.
24147        assert_eq!(clause("ledger", "partition").connective, Some("by"));
24148
24149        // lease `shared` is a flag (no value).
24150        assert!(matches!(clause("lease", "shared").kind, ClauseKind::Flag));
24151        assert!(!clause("lease", "shared").list);
24152        assert_eq!(clause("lease", "shared").connective, None);
24153
24154        // file-store `allow read`/`allow write` are multi-word glob lists.
24155        for (name, words) in [
24156            ("allow read", ["allow", "read"]),
24157            ("allow write", ["allow", "write"]),
24158        ] {
24159            let allow = clause("file store", name);
24160            assert!(allow.list, "`{name}` must be list:true");
24161            assert_eq!(allow.words, words);
24162            assert!(matches!(allow.kind, ClauseKind::Glob));
24163        }
24164
24165        // Exercise the head-word peek so the scaffolding method is live.
24166        let mut parser = Parser {
24167            source: "memory pool p { }",
24168            tokens: lex("memory pool p { }").tokens,
24169            pos: 0,
24170            diagnostics: Vec::new(),
24171            pending_contract_classes: Vec::new(),
24172        };
24173        let spec = parser
24174            .declaration_block_spec_at()
24175            .expect("head word `memory` must resolve to the memory pool spec");
24176        assert_eq!(spec.keyword, "memory pool");
24177        // It only peeks — position is unchanged.
24178        assert_eq!(parser.pos, 0);
24179        parser.diagnostics.clear();
24180    }
24181
24182    const SEND_PROGRAM: &str = r##"
24183@service
24184workflow Notify
24185
24186class Trigger { id string }
24187
24188agent worker { provider fixture  profile "r"  capacity 1 }
24189
24190channel alerts { provider fixture  destination "#ops" }
24191
24192table seed as Trigger [ { id "t" } ]
24193
24194rule notify
24195  when Trigger as t
24196=> {
24197  send via alerts {
24198    text "hello"
24199  } as sent
24200}
24201"##;
24202
24203    #[test]
24204    fn send_lowers_to_messaging_capability_call_without_builtin_registration() {
24205        // `send via <channel>` lowers to a `messaging.send` capability.call
24206        // construct use. The construct/contract registration itself is NOT a
24207        // parser builtin any more: it comes from the embedded `std.messaging`
24208        // manifest, merged by the CLI when the program imports the package
24209        // (`use std.messaging`). The parser still registers the `std.messaging`
24210        // standard library from the `channel` declaration (the ambient decl tier).
24211        let compiled = compile_program(SEND_PROGRAM);
24212        assert_eq!(
24213            compiled.diagnostics,
24214            Vec::new(),
24215            "{:?}",
24216            compiled.diagnostics
24217        );
24218        let ir = compiled.ir.expect("lowered IR");
24219        let uses = ir.construct_uses();
24220        assert_eq!(uses.len(), 1);
24221        assert_eq!(uses[0].keyword, "send");
24222        assert_eq!(uses[0].target_capability, "messaging.send");
24223        let registry = ir.contract_registry();
24224        assert!(
24225            registry.constructs.is_empty(),
24226            "the parser registers no builtin constructs: {:?}",
24227            registry.constructs
24228        );
24229        assert!(
24230            !registry
24231                .effect_contracts
24232                .iter()
24233                .any(|c| c.id == "messaging.send"),
24234            "the messaging.send contract comes from the embedded manifest, not the parser"
24235        );
24236        assert!(
24237            registry
24238                .libraries
24239                .iter()
24240                .any(|lib| lib.id == "std.messaging" && lib.standard),
24241            "the channel declaration still registers the std.messaging standard library"
24242        );
24243    }
24244
24245    #[test]
24246    fn send_to_unknown_channel_is_rejected() {
24247        let source = SEND_PROGRAM.replace("send via alerts", "send via ghost");
24248        let compiled = compile_program(&source);
24249        let violations: Vec<&Diagnostic> = compiled
24250            .diagnostics
24251            .iter()
24252            .filter(|d| d.message.contains("unknown channel"))
24253            .collect();
24254        assert_eq!(violations.len(), 1, "{:?}", compiled.diagnostics);
24255        assert!(violations[0].message.contains("ghost"));
24256    }
24257
24258    #[test]
24259    fn derives_contract_registry_from_imports_and_effects() {
24260        let source = r#"
24261workflow RegistrySlice
24262
24263use memory
24264
24265class Task {
24266  title string
24267}
24268
24269class Review {
24270  accepted bool
24271}
24272
24273coerce reviewTask(title string) -> Review {
24274  prompt """
24275  Review {{ title }}
24276  """
24277}
24278
24279agent worker {
24280  provider fixture
24281  profile "repo-writer"
24282  capacity 1
24283}
24284
24285rule start
24286  when Task as task
24287=> {
24288  tell worker as turn """
24289  Work on {{ task.title }}
24290  """
24291
24292  after turn succeeds {
24293    coerce reviewTask(task.title) as review
24294  }
24295}
24296"#;
24297
24298        let compiled = compile_program(source);
24299        assert_eq!(compiled.diagnostics, Vec::new());
24300        let ir = compiled.ir.expect("program compiles");
24301        let registry = ir.contract_registry();
24302        assert_eq!(registry.validate(), Vec::new());
24303
24304        assert!(registry
24305            .libraries
24306            .iter()
24307            .any(|library| library.id == "memory" && !library.standard));
24308        assert!(registry
24309            .libraries
24310            .iter()
24311            .any(|library| library.id == "std.agent" && library.standard));
24312        assert!(registry
24313            .libraries
24314            .iter()
24315            .any(|library| library.id == "std.coercion" && library.standard));
24316
24317        let coerce = registry
24318            .effect_contracts
24319            .iter()
24320            .find(|contract| contract.id == "schema.coerce")
24321            .expect("coerce contract");
24322        assert_eq!(coerce.library_id, "std.coercion");
24323        assert_eq!(coerce.validation, TypedOutputValidation::RuntimeBoundary);
24324        assert!(coerce.source_forms.contains(&"coerce".to_owned()));
24325        assert!(coerce.source_forms.contains(&"prompt".to_owned()));
24326        assert!(coerce
24327            .required_capabilities
24328            .contains(&"schema.coerce".to_owned()));
24329        assert_eq!(coerce.provider_kinds, vec!["schema_coercer".to_owned()]);
24330
24331        let agent = registry
24332            .effect_contracts
24333            .iter()
24334            .find(|contract| contract.id == "agent.tell")
24335            .expect("agent contract");
24336        assert_eq!(agent.library_id, "std.agent");
24337        assert_eq!(agent.output_schema.as_deref(), Some("AgentTurn"));
24338    }
24339
24340    #[test]
24341    fn capability_calls_require_the_target_capability() {
24342        let source = r#"
24343workflow PackageCall
24344
24345use memory
24346
24347class Task {
24348  title string
24349}
24350
24351rule start
24352  when Task as task
24353=> {
24354  call memory.query for task as context
24355}
24356"#;
24357
24358        let compiled = compile_program(source);
24359        assert_eq!(compiled.diagnostics, Vec::new());
24360        let ir = compiled.ir.expect("program compiles");
24361        let effect = ir.rules[0]
24362            .metadata
24363            .effects
24364            .iter()
24365            .find(|effect| effect.kind == IrEffectKind::CapabilityCall)
24366            .expect("capability call effect");
24367        assert_eq!(
24368            effect.required_capabilities,
24369            vec!["memory.query".to_owned()]
24370        );
24371
24372        let registry = ir.contract_registry();
24373        let contract = registry
24374            .effect_contracts
24375            .iter()
24376            .find(|contract| contract.id == "capability.call")
24377            .expect("capability call contract");
24378        assert!(contract
24379            .required_capabilities
24380            .contains(&"memory.query".to_owned()));
24381        assert!(!contract
24382            .required_capabilities
24383            .contains(&"capability.call".to_owned()));
24384    }
24385
24386    #[test]
24387    fn package_recall_form_lowers_to_capability_call_marker() {
24388        let source = r#"
24389workflow PackageRecall
24390
24391use memory
24392
24393memory pool project_memory {
24394  context limit 8
24395}
24396
24397class Task {
24398  title string
24399}
24400
24401rule start
24402  when Task as task
24403=> {
24404  recall project_memory for task as context
24405}
24406"#;
24407
24408        let compiled = compile_program(source);
24409        assert_eq!(compiled.diagnostics, Vec::new());
24410        let ir = compiled.ir.expect("program compiles");
24411        let effect = ir.rules[0]
24412            .metadata
24413            .effects
24414            .iter()
24415            .find(|effect| effect.kind == IrEffectKind::CapabilityCall)
24416            .expect("capability call effect");
24417        assert_eq!(effect.binding.as_deref(), Some("context"));
24418        assert_eq!(
24419            effect.required_capabilities,
24420            vec!["memory.query".to_owned()]
24421        );
24422        assert_eq!(
24423            effect.construct_use,
24424            Some(IrConstructUse {
24425                keyword: "recall".to_owned(),
24426                scope: "rule_body".to_owned(),
24427                construct_family: "effect_operation".to_owned(),
24428                lowering_target: "capability_call".to_owned(),
24429                target_capability: "memory.query".to_owned(),
24430            })
24431        );
24432        assert_eq!(ir.construct_uses().len(), 1);
24433        assert!(ir.to_snapshot().contains("construct=recall->memory.query"));
24434    }
24435
24436    fn b1g_body_matrix_program(body: &str) -> String {
24437        r#"
24438workflow B1gMatrix {
24439  use memory
24440
24441  memory pool project_memory {
24442    context limit 8
24443  }
24444
24445  output result Done
24446  failure error Failed
24447
24448  class Ticket {
24449    id string
24450    title string
24451    due_at time
24452    amount int
24453  }
24454
24455  class TicketPublic {
24456    id string
24457    title string
24458  }
24459
24460  class Workspace {
24461    id string
24462  }
24463
24464  class Note {
24465    text string
24466  }
24467
24468  class Done {
24469    ok bool
24470  }
24471
24472  class Failed {
24473    reason string
24474  }
24475
24476  class Review {
24477    summary string
24478    fixed bool
24479  }
24480
24481  class LedgerEntry {
24482    area string
24483    text string
24484  }
24485
24486  class Row {
24487    title string
24488  }
24489
24490  signal deploy.finished {
24491    service string
24492    status string
24493  }
24494
24495  tracker backlog {
24496    provider builtin
24497  }
24498
24499  lease workspace_slot {
24500    key Workspace
24501    slots 1
24502    ttl 30m
24503  }
24504
24505  ledger review_log {
24506    entry LedgerEntry
24507    partition by area
24508    retain 30d
24509  }
24510
24511  counter request_budget {
24512    key Ticket
24513    cap 10
24514    reset daily
24515  }
24516
24517  file store docs {
24518    root "./data"
24519    allow write ["**"]
24520  }
24521
24522  channel ops_room {
24523    provider fixture
24524  }
24525
24526  agent worker {
24527    provider fixture
24528    profile "repo-writer"
24529    capacity 1
24530  }
24531
24532  coerce classify(title string) -> Review {
24533    prompt "classify"
24534  }
24535
24536  rule probe
24537    when Ticket as ticket
24538    when Workspace as workspace
24539    when backlog has ready issue as item
24540    when worker is available
24541  => {
24542__BODY__
24543  }
24544}
24545
24546workflow Child {
24547  input task ChildTask
24548  output result ChildResult
24549
24550  class ChildTask {
24551    title string
24552  }
24553
24554  class ChildResult {
24555    summary string
24556  }
24557
24558  rule finish
24559    when ChildTask as task
24560  => {
24561    complete result {
24562      summary task.title
24563    }
24564  }
24565}
24566"#
24567        .replace("__BODY__", body)
24568    }
24569
24570    #[test]
24571    fn coordination_shared_declarations_lower_to_ir() {
24572        let source = r#"
24573workflow SharedCoord
24574
24575class Key {
24576  id string
24577}
24578
24579class Entry {
24580  area string
24581}
24582
24583lease shared_slot {
24584  shared
24585  key Key
24586  slots 1
24587  ttl 30m
24588}
24589
24590ledger shared_log {
24591  shared
24592  entry Entry
24593  partition by area
24594  retain 30d
24595}
24596
24597counter shared_budget {
24598  shared
24599  key Key
24600  cap 10
24601  reset daily
24602}
24603"#;
24604        let compiled = compile_program(source);
24605        assert!(
24606            compiled.diagnostics.is_empty(),
24607            "unexpected diagnostics: {:?}",
24608            compiled.diagnostics
24609        );
24610        let ir = compiled.ir.expect("valid IR");
24611        assert!(ir
24612            .leases
24613            .iter()
24614            .any(|lease| lease.name == "shared_slot" && lease.shared && lease.ttl_seconds == 1800));
24615        assert!(ir
24616            .ledgers
24617            .iter()
24618            .any(|ledger| ledger.name == "shared_log" && ledger.shared));
24619        assert!(ir
24620            .counters
24621            .iter()
24622            .any(|counter| counter.name == "shared_budget" && counter.shared));
24623    }
24624
24625    #[test]
24626    fn file_store_clause_spans_are_first_word_tokens() {
24627        // HARD INVARIANT: root_span/read_span/write_span are the FIRST
24628        // clause-name-word token span (`root`/`allow`/`allow`), not the value or
24629        // a joined multi-word span. These serialize into FileStoreDecl and feed
24630        // `whip fmt`; the `.ir` golden does not witness them, so assert directly
24631        // (mirrors examples/file-store-demo.whip).
24632        let source = r#"
24633workflow FileSpanProbe
24634file store notes_store {
24635  root "./data"
24636  allow read ["notes/**"]
24637  allow write ["notes/**"]
24638}
24639"#;
24640        let parsed = parse_program(source);
24641        assert_eq!(
24642            parsed.diagnostics,
24643            Vec::new(),
24644            "unexpected diagnostics: {:?}",
24645            parsed.diagnostics
24646        );
24647        let store = parsed
24648            .program
24649            .items
24650            .iter()
24651            .find_map(|item| match item {
24652                Item::FileStore(decl) => Some(decl),
24653                _ => None,
24654            })
24655            .expect("file store decl");
24656        let text_at = |span: SourceSpan| &source[span.start..span.end];
24657        assert_eq!(text_at(store.root_span.expect("root span")), "root");
24658        assert_eq!(text_at(store.read_span.expect("read span")), "allow");
24659        assert_eq!(text_at(store.write_span.expect("write span")), "allow");
24660        // No `provider` clause: the decl records None (the runtime default is
24661        // `local`), and the `.ir` snapshot stays provider-free (F5 zero-churn).
24662        assert_eq!(store.provider, None);
24663        let ir = compile_program(source).ir.expect("valid IR");
24664        assert_eq!(ir.file_stores[0].provider, None);
24665        assert!(!ir.to_snapshot().contains("provider"));
24666    }
24667
24668    #[test]
24669    fn file_store_provider_clause_parses_and_unknown_is_rejected() {
24670        // spec/std-files.md "Surface" (slice F5): the optional block-internal
24671        // `provider <ident>` clause parses (aligning with the channel
24672        // declaration's provider clause), serializes into the snapshot, and
24673        // an identifier outside the v1 provider list is a check error.
24674        let source = r#"
24675workflow FileProviderProbe
24676file store notes_store {
24677  root "./data"
24678  allow read ["notes/**"]
24679  provider local
24680}
24681"#;
24682        let compiled = compile_program(source);
24683        assert_eq!(
24684            compiled.diagnostics,
24685            Vec::new(),
24686            "unexpected diagnostics: {:?}",
24687            compiled.diagnostics
24688        );
24689        let ir = compiled.ir.expect("valid IR");
24690        assert_eq!(ir.file_stores[0].provider.as_deref(), Some("local"));
24691        assert!(ir.to_snapshot().contains("    provider local"));
24692
24693        let unknown = source.replace("provider local", "provider s3");
24694        let compiled = compile_program(&unknown);
24695        assert!(
24696            compiled.diagnostics.iter().any(|diagnostic| {
24697                diagnostic
24698                    .message
24699                    .contains("file store `notes_store` names unknown provider `s3`")
24700            }),
24701            "unknown provider must be a check error: {:?}",
24702            compiled.diagnostics
24703        );
24704    }
24705
24706    #[test]
24707    fn formatter_preserves_file_store_provider_clause() {
24708        let source = r#"
24709workflow FileProviderFmt
24710file store notes_store { root "./data" provider local }
24711"#;
24712        let formatted = format_program(source);
24713        assert_eq!(formatted.diagnostics, Vec::new());
24714        let formatted = formatted.formatted.expect("formats");
24715        assert!(
24716            formatted.contains("file store notes_store {\n  root \"./data\"\n  provider local\n}"),
24717            "{formatted}"
24718        );
24719    }
24720
24721    #[test]
24722    fn formatter_preserves_shared_coordination_declarations() {
24723        let source = r#"
24724workflow SharedCoord
24725class Key { id string }
24726lease shared_slot { shared key Key slots 1 ttl 30m }
24727"#;
24728        let formatted = format_program(source);
24729        assert_eq!(formatted.diagnostics, Vec::new());
24730        let formatted = formatted.formatted.expect("formats");
24731        assert!(formatted.contains("lease shared_slot {\n  shared\n  key Key"));
24732    }
24733
24734    fn b1g_probe_rule(case_name: &str, body: &str) -> IrRule {
24735        let source = b1g_body_matrix_program(body);
24736        let compiled = compile_program_with_root(&source, Some("B1gMatrix"));
24737        assert!(
24738            compiled.diagnostics.is_empty(),
24739            "{case_name} emitted diagnostics: {:?}",
24740            compiled.diagnostics
24741        );
24742        let ir = compiled.ir.expect("valid matrix IR");
24743        ir.rules
24744            .into_iter()
24745            .find(|rule| rule.name == "probe")
24746            .expect("probe rule")
24747    }
24748
24749    fn b1g_effect<'a>(
24750        rule: &'a IrRule,
24751        kind: IrEffectKind,
24752        binding: Option<&str>,
24753        case_name: &str,
24754    ) -> &'a IrEffectNode {
24755        rule.metadata
24756            .effects
24757            .iter()
24758            .find(|effect| effect.kind == kind && effect.binding.as_deref() == binding)
24759            .unwrap_or_else(|| {
24760                panic!(
24761                    "{case_name} did not lower {kind:?} / {binding:?}; effects: {:?}",
24762                    rule.metadata.effects
24763                )
24764            })
24765    }
24766
24767    #[test]
24768    fn accepted_rule_body_matrix_has_no_silent_noops() {
24769        let effect_cases = [
24770            (
24771                "tell",
24772                r#"    tell worker as turn "go""#,
24773                IrEffectKind::AgentTell,
24774                Some("turn"),
24775            ),
24776            (
24777                "coerce",
24778                r#"    coerce classify(ticket.title) as review"#,
24779                IrEffectKind::SchemaCoerce,
24780                Some("review"),
24781            ),
24782            (
24783                "prompt",
24784                r#"    prompt "Summarize {{ ticket.title }}" using fixture as summary"#,
24785                IrEffectKind::SchemaCoerce,
24786                Some("summary"),
24787            ),
24788            (
24789                "decide",
24790                r#"    decide "fixed?" -> { fixed bool } as verdict"#,
24791                IrEffectKind::SchemaCoerce,
24792                Some("verdict"),
24793            ),
24794            (
24795                "call",
24796                r#"    call memory.query for ticket as called"#,
24797                IrEffectKind::CapabilityCall,
24798                Some("called"),
24799            ),
24800            (
24801                "recall",
24802                r#"    recall project_memory for ticket.title as memories"#,
24803                IrEffectKind::CapabilityCall,
24804                Some("memories"),
24805            ),
24806            (
24807                "send",
24808                r#"    send via ops_room { text ticket.title } as sent"#,
24809                IrEffectKind::CapabilityCall,
24810                Some("sent"),
24811            ),
24812            (
24813                "invoke",
24814                r#"    invoke Child { task { title ticket.title } } as child"#,
24815                IrEffectKind::WorkflowInvoke,
24816                Some("child"),
24817            ),
24818            (
24819                "timer_duration",
24820                r#"    timer 5m as wait"#,
24821                IrEffectKind::TimerWait,
24822                Some("wait"),
24823            ),
24824            (
24825                "timer_until",
24826                r#"    timer until ticket.due_at as deadline"#,
24827                IrEffectKind::TimerWait,
24828                Some("deadline"),
24829            ),
24830            (
24831                "exec_raw",
24832                r#"    exec "echo hi" as run"#,
24833                IrEffectKind::ExecCommand,
24834                Some("run"),
24835            ),
24836            (
24837                "queue_file",
24838                r#"    file issue into backlog { title ticket.title body "body" } as filed"#,
24839                IrEffectKind::TrackerFile,
24840                Some("filed"),
24841            ),
24842            (
24843                "queue_claim",
24844                r#"    claim item as lease"#,
24845                IrEffectKind::TrackerClaim,
24846                Some("lease"),
24847            ),
24848            (
24849                "queue_release",
24850                r#"    release item"#,
24851                IrEffectKind::TrackerRelease,
24852                None,
24853            ),
24854            (
24855                "queue_finish",
24856                r#"    finish item { summary ticket.title }"#,
24857                IrEffectKind::TrackerFinish,
24858                None,
24859            ),
24860            (
24861                "lease_acquire",
24862                r#"    acquire workspace_slot for workspace until ttl as slot"#,
24863                IrEffectKind::LeaseAcquire,
24864                Some("slot"),
24865            ),
24866            (
24867                "ledger_append",
24868                r#"    append LedgerEntry { area ticket.id text ticket.title } to review_log as entry"#,
24869                IrEffectKind::LedgerAppend,
24870                Some("entry"),
24871            ),
24872            (
24873                "counter_consume",
24874                r#"    consume request_budget for ticket amount ticket.amount as spend
24875
24876    after spend ok {
24877      record Note { text "ok" }
24878    }
24879
24880    after spend over {
24881      record Note { text "over" }
24882    }"#,
24883                IrEffectKind::CounterConsume,
24884                Some("spend"),
24885            ),
24886            (
24887                "notify",
24888                r#"    emit signal deploy.finished to ticket.id { service ticket.title status "ok" } as signal_sent"#,
24889                IrEffectKind::SignalEmit,
24890                Some("signal_sent"),
24891            ),
24892            (
24893                "file_read",
24894                r#"    read text from docs at "note.md" as file_read"#,
24895                IrEffectKind::FileRead,
24896                Some("file_read"),
24897            ),
24898            (
24899                "file_write",
24900                r#"    write text to docs at "out.md" { body ticket.title mode create } as file_write"#,
24901                IrEffectKind::FileWrite,
24902                Some("file_write"),
24903            ),
24904            (
24905                "file_import",
24906                r#"    import json Row from docs at "rows.json" as imported"#,
24907                IrEffectKind::FileImport,
24908                Some("imported"),
24909            ),
24910            (
24911                "file_export",
24912                r#"    export json Row to docs at "rows.json" { mode create } as exported"#,
24913                IrEffectKind::FileExport,
24914                Some("exported"),
24915            ),
24916        ];
24917
24918        for (case_name, body, kind, binding) in effect_cases {
24919            let rule = b1g_probe_rule(case_name, body);
24920            let effect = b1g_effect(&rule, kind, binding, case_name);
24921            match case_name {
24922                "send" => {
24923                    assert_eq!(effect.resource.as_deref(), Some("ops_room"));
24924                    assert_eq!(
24925                        effect
24926                            .construct_use
24927                            .as_ref()
24928                            .map(|use_| use_.keyword.as_str()),
24929                        Some("send")
24930                    );
24931                }
24932                "notify" => {
24933                    assert_eq!(effect.resource.as_deref(), Some("signal:deploy.finished"));
24934                }
24935                "file_read" | "file_write" | "file_import" | "file_export" => {
24936                    assert_eq!(effect.resource.as_deref(), Some("docs"));
24937                }
24938                _ => {}
24939            }
24940        }
24941
24942        let record = b1g_probe_rule("record", r#"    record Note { text ticket.title }"#);
24943        assert!(record
24944            .metadata
24945            .fact_writes
24946            .contains(&"schema:Note".to_owned()));
24947        assert!(record
24948            .metadata
24949            .egress_payload_reads
24950            .get("fact:Note")
24951            .is_some_and(|roots| roots.contains("ticket")));
24952
24953        let done = b1g_probe_rule("done", r#"    done ticket"#);
24954        assert!(done
24955            .metadata
24956            .fact_consumes
24957            .contains(&"schema:Ticket".to_owned()));
24958
24959        let done_replacement = b1g_probe_rule(
24960            "done_replacement",
24961            r#"    done ticket -> record Note { text ticket.title }"#,
24962        );
24963        assert!(done_replacement
24964            .metadata
24965            .fact_consumes
24966            .contains(&"schema:Ticket".to_owned()));
24967        assert!(done_replacement
24968            .metadata
24969            .fact_writes
24970            .contains(&"schema:Note".to_owned()));
24971
24972        let complete = b1g_probe_rule("complete", r#"    complete result { ok true }"#);
24973        assert!(complete
24974            .metadata
24975            .terminal_completes
24976            .contains(&"result".to_owned()));
24977
24978        let fail = b1g_probe_rule("fail", r#"    fail error { reason "bad" }"#);
24979        assert_eq!(fail.metadata.effects, Vec::new());
24980
24981        let exec_each = b1g_probe_rule("exec_each", r#"    exec "printf '{}'" -> each Row"#);
24982        b1g_effect(&exec_each, IrEffectKind::ExecCommand, None, "exec_each");
24983        assert!(exec_each
24984            .metadata
24985            .fact_writes
24986            .contains(&"schema:Row".to_owned()));
24987
24988        let bounded = b1g_probe_rule(
24989            "bounded_record",
24990            r#"    record TicketPublic from ticket {
24991      id
24992      title
24993    }"#,
24994        );
24995        assert!(
24996            bounded
24997                .metadata
24998                .bounded_egresses
24999                .iter()
25000                .any(|egress| egress.sink == "fact:TicketPublic"
25001                    && egress.keep == vec!["id".to_owned(), "title".to_owned()]),
25002            "{:?}",
25003            bounded.metadata.bounded_egresses
25004        );
25005
25006        let redaction = b1g_probe_rule(
25007            "redaction",
25008            r#"    redact ticket keep [id, title] as safe
25009    record TicketPublic from safe {
25010      id
25011      title
25012    }"#,
25013        );
25014        assert!(redaction
25015            .metadata
25016            .redactions
25017            .iter()
25018            .any(|projection| projection.source == "ticket" && projection.binding == "safe"));
25019        assert!(redaction
25020            .metadata
25021            .fact_writes
25022            .contains(&"schema:TicketPublic".to_owned()));
25023    }
25024
25025    #[test]
25026    fn prompt_lowers_to_coerce_with_string_payload() {
25027        let source = r#"
25028workflow PromptText
25029
25030output result string
25031
25032class Ticket {
25033  title string
25034}
25035
25036rule ask
25037  when Ticket as ticket
25038=> {
25039  prompt "Summarize {{ ticket.title }}" using fixture as answer
25040
25041  after answer succeeds as text {
25042    complete result text
25043  }
25044}
25045"#;
25046
25047        let compiled = compile_program(source);
25048        assert!(
25049            compiled.diagnostics.is_empty(),
25050            "prompt program diagnostics: {:?}",
25051            compiled.diagnostics
25052        );
25053        let ir = compiled.ir.expect("program compiles");
25054        let rule = ir
25055            .rules
25056            .iter()
25057            .find(|rule| rule.name == "ask")
25058            .expect("ask rule");
25059        let effect = rule
25060            .metadata
25061            .effects
25062            .iter()
25063            .find(|effect| effect.binding.as_deref() == Some("answer"))
25064            .expect("prompt effect");
25065        assert_eq!(effect.kind, IrEffectKind::SchemaCoerce);
25066
25067        let coerce = ir
25068            .contract_registry()
25069            .effect_contracts
25070            .into_iter()
25071            .find(|contract| contract.id == "schema.coerce")
25072            .expect("coerce contract");
25073        assert!(coerce.source_forms.contains(&"prompt".to_owned()));
25074    }
25075
25076    #[test]
25077    fn parses_schema_agent_and_rule_slice() {
25078        let source = r#"
25079workflow QueueWorkerSlice
25080
25081use memory
25082
25083tracker backlog {
25084  provider builtin
25085}
25086
25087enum ReviewStatus {
25088  Accept
25089  Revise
25090}
25091
25092class WorkReview {
25093  state "accepted" | "rejected"
25094  status ReviewStatus
25095  followups string[]
25096  maybeReason string?
25097  scores map<int>
25098}
25099
25100coerce reviewWork(issueTitle string, changedFiles string[]) -> WorkReview {
25101  prompt """
25102  Review {{ issueTitle }} with files {{ changedFiles }}
25103  """
25104}
25105
25106agent worker {
25107  provider fixture
25108  profile "repo-writer"
25109  capacity 1
25110  skills ["repo-user"]
25111}
25112
25113rule start_ready_item
25114  when backlog has ready issue as item
25115  when worker is available
25116=> {
25117  claim item as claim
25118
25119  after claim succeeds {
25120    tell worker """
25121    Implement {{ item.title }}
25122    """
25123  }
25124}
25125"#;
25126
25127        let parsed = parse_program(source);
25128        assert_eq!(parsed.diagnostics, Vec::new());
25129        let workflow = parsed
25130            .program
25131            .workflow
25132            .as_ref()
25133            .map(|ident| ident.name.as_str());
25134        assert_eq!(workflow, Some("QueueWorkerSlice"));
25135        assert_eq!(parsed.program.items.len(), 7);
25136
25137        let coerce = parsed.program.items.iter().find_map(|item| match item {
25138            Item::Coerce(coerce) => Some(coerce),
25139            _ => None,
25140        });
25141        let coerce = match coerce {
25142            Some(coerce) => coerce,
25143            None => panic!("expected coerce item"),
25144        };
25145        assert_eq!(coerce.params.len(), 2);
25146
25147        let rule = parsed.program.items.iter().find_map(|item| match item {
25148            Item::Rule(rule) => Some(rule),
25149            _ => None,
25150        });
25151        let rule = match rule {
25152            Some(rule) => rule,
25153            None => panic!("expected rule item"),
25154        };
25155        assert_eq!(rule.whens.len(), 2);
25156        assert_eq!(rule.whens[0].text, "backlog has ready issue as item");
25157        assert!(rule.body.text.contains("after claim succeeds"));
25158    }
25159
25160    #[test]
25161    fn parses_and_lowers_static_table_rows() {
25162        let source = r#"
25163workflow TableSeed
25164
25165agent codex {
25166  provider codex
25167  profile "repo-writer"
25168  capacity 1
25169}
25170
25171class Task {
25172  provider AgentRef<codex>
25173  title string
25174  priority int
25175  status "queued"
25176}
25177
25178table tasks as Task [
25179  {
25180    provider codex
25181    title "Review parser"
25182    priority 1
25183    status "queued"
25184  }
25185
25186  {
25187    provider codex
25188    title "Review runtime"
25189    priority 2
25190    status "queued"
25191  }
25192]
25193"#;
25194
25195        let parsed = parse_program(source);
25196        assert_eq!(parsed.diagnostics, Vec::new());
25197        let table = parsed
25198            .program
25199            .items
25200            .iter()
25201            .find_map(|item| match item {
25202                Item::Table(table) => Some(table),
25203                _ => None,
25204            })
25205            .expect("table item");
25206        assert_eq!(table.rows.len(), 2);
25207        let row_spans = table.rows.iter().map(|row| row.span).collect::<Vec<_>>();
25208
25209        let compiled = compile_program(source);
25210        let ir = compiled
25211            .ir
25212            .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25213        let table_rule = ir
25214            .rules
25215            .iter()
25216            .find(|rule| rule.name == "table_tasks")
25217            .expect("table lowers to generated started rule");
25218        assert_eq!(table_rule.whens[0].pattern, "started");
25219        assert!(table_rule.body.contains("record Task"));
25220        assert_eq!(table_rule.metadata.fact_writes, vec!["schema:Task"]);
25221        assert_eq!(table_rule.metadata.record_sources.len(), 2);
25222        assert_eq!(
25223            table_rule
25224                .metadata
25225                .record_sources
25226                .iter()
25227                .map(|source| (
25228                    source.schema.as_str(),
25229                    source.construct.as_str(),
25230                    source.span
25231                ))
25232                .collect::<Vec<_>>(),
25233            row_spans
25234                .iter()
25235                .map(|span| ("Task", "table_row", *span))
25236                .collect::<Vec<_>>()
25237        );
25238    }
25239
25240    #[test]
25241    fn rejects_old_matrix_declarations() {
25242        let source = r#"
25243workflow MatrixSeed
25244
25245class Task {
25246  title string
25247  status "queued"
25248}
25249
25250matrix tasks as Task [
25251  {
25252    title "Review parser"
25253    status "queued"
25254  }
25255]
25256"#;
25257
25258        let compiled = compile_program(source);
25259        assert!(compiled.ir.is_none());
25260        assert!(compiled.diagnostics.iter().any(|diagnostic| {
25261            diagnostic
25262                .message
25263                .contains("expected top-level declaration, found identifier `matrix`")
25264        }));
25265    }
25266
25267    #[test]
25268    fn rejects_table_rows_that_violate_row_schema() {
25269        let source = r#"
25270workflow BadTable
25271
25272agent codex {
25273  provider codex
25274  profile "repo-writer"
25275  capacity 1
25276}
25277
25278class Task {
25279  provider AgentRef<codex>
25280  status "queued"
25281}
25282
25283table tasks as Task [
25284  {
25285    provider "codex"
25286    status "done"
25287  }
25288]
25289"#;
25290
25291        let compiled = compile_program(source);
25292
25293        assert!(compiled.ir.is_none());
25294        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25295            .message
25296            .contains("expects an AgentRef value, not string `codex`")));
25297        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25298            .message
25299            .contains("expects literal string `queued`")));
25300    }
25301
25302    #[test]
25303    fn parses_formats_and_lowers_source_tags_as_metadata() {
25304        let source = r#"
25305@fixture
25306@release-gate
25307workflow Tagged
25308
25309class Task {
25310  status "queued"
25311}
25312
25313@seed
25314table tasks as Task [
25315  {
25316    status "queued"
25317  }
25318]
25319
25320@acceptance
25321assert count(Task where status == "queued") == 1
25322
25323@dispatch
25324rule consume_task
25325  when Task as task
25326=> {
25327  done task
25328}
25329"#;
25330
25331        let parsed = parse_program(source);
25332        assert_eq!(parsed.diagnostics, Vec::new());
25333        assert_eq!(
25334            parsed
25335                .program
25336                .workflow_tags
25337                .iter()
25338                .map(|tag| tag.name.as_str())
25339                .collect::<Vec<_>>(),
25340            vec!["fixture", "release-gate"]
25341        );
25342
25343        let formatted = format_program(source).formatted.expect("formats");
25344        assert!(formatted.contains("@fixture\n@release-gate\nworkflow Tagged"));
25345        assert!(formatted.contains("@seed\ntable tasks as Task"));
25346        assert!(formatted.contains("@acceptance\nassert count"));
25347        assert!(formatted.contains("@dispatch\nrule consume_task"));
25348
25349        let compiled = compile_program(source);
25350        let ir = compiled
25351            .ir
25352            .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25353        let tags = ir
25354            .source_tags
25355            .iter()
25356            .map(|tag| {
25357                (
25358                    tag.name.as_str(),
25359                    tag.target_kind.as_str(),
25360                    tag.target.as_str(),
25361                )
25362            })
25363            .collect::<Vec<_>>();
25364        assert!(tags.contains(&("fixture", "workflow", "Tagged")));
25365        assert!(tags.contains(&("release-gate", "workflow", "Tagged")));
25366        assert!(tags.contains(&("seed", "table", "tasks")));
25367        assert!(tags.contains(&("dispatch", "rule", "consume_task")));
25368        assert!(ir
25369            .source_tags
25370            .iter()
25371            .any(|tag| tag.name == "acceptance" && tag.target_kind == "assertion"));
25372    }
25373
25374    #[test]
25375    fn parses_formats_and_lowers_source_descriptions_as_metadata() {
25376        let source = r#"
25377@fixture
25378description "Fixture-backed acceptance workflow"
25379workflow Described
25380
25381class Task {
25382  status "queued"
25383}
25384
25385description "Static task seed rows"
25386table tasks as Task [
25387  {
25388    status "queued"
25389  }
25390]
25391
25392description "All seed tasks were consumed"
25393assert count(Task where status == "queued") == 0
25394
25395description "Consume one queued task"
25396rule consume_task
25397  when Task as task
25398=> {
25399  done task
25400}
25401"#;
25402
25403        let parsed = parse_program(source);
25404        assert_eq!(parsed.diagnostics, Vec::new());
25405        assert_eq!(
25406            parsed
25407                .program
25408                .workflow_description
25409                .as_ref()
25410                .map(|description| description.value.as_str()),
25411            Some("Fixture-backed acceptance workflow")
25412        );
25413
25414        let formatted = format_program(source).formatted.expect("formats");
25415        assert!(formatted.contains(
25416            "@fixture\ndescription \"Fixture-backed acceptance workflow\"\nworkflow Described"
25417        ));
25418        assert!(formatted.contains("description \"Static task seed rows\"\ntable tasks as Task"));
25419        assert!(formatted.contains("description \"All seed tasks were consumed\"\nassert count"));
25420        assert!(formatted.contains("description \"Consume one queued task\"\nrule consume_task"));
25421
25422        let compiled = compile_program(source);
25423        let ir = compiled
25424            .ir
25425            .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25426        let descriptions = ir
25427            .source_descriptions
25428            .iter()
25429            .map(|description| {
25430                (
25431                    description.value.as_str(),
25432                    description.target_kind.as_str(),
25433                    description.target.as_str(),
25434                )
25435            })
25436            .collect::<Vec<_>>();
25437        assert!(descriptions.contains(&(
25438            "Fixture-backed acceptance workflow",
25439            "workflow",
25440            "Described"
25441        )));
25442        assert!(descriptions.contains(&("Static task seed rows", "table", "tasks")));
25443        assert!(descriptions.contains(&("Consume one queued task", "rule", "consume_task")));
25444        assert!(ir
25445            .source_descriptions
25446            .iter()
25447            .any(
25448                |description| description.value == "All seed tasks were consumed"
25449                    && description.target_kind == "assertion"
25450            ));
25451    }
25452
25453    #[test]
25454    fn rejects_descriptions_on_unsupported_declarations_for_now() {
25455        let source = r#"
25456workflow BadDescriptions
25457
25458description "Task schema"
25459class Task {
25460  status "queued"
25461}
25462"#;
25463
25464        let parsed = parse_program(source);
25465
25466        assert_eq!(parsed.diagnostics.len(), 1);
25467        assert_eq!(
25468            parsed.diagnostics[0].message,
25469            "description cannot be attached to class"
25470        );
25471    }
25472
25473    #[test]
25474    fn rejects_tags_on_unsupported_declarations_for_now() {
25475        let source = r#"
25476workflow BadTags
25477
25478@schema
25479class Task {
25480  status "queued"
25481}
25482"#;
25483
25484        let parsed = parse_program(source);
25485
25486        assert_eq!(parsed.diagnostics.len(), 1);
25487        assert_eq!(
25488            parsed.diagnostics[0].message,
25489            "tag `@schema` cannot be attached to class"
25490        );
25491    }
25492
25493    #[test]
25494    fn use_short_form_imports_package_libraries_and_rejects_removed_kinds() {
25495        let parsed = parse_program("workflow Imports\n\nuse memory\n");
25496        assert_eq!(parsed.diagnostics, Vec::new());
25497        let use_decl = parsed.program.items.iter().find_map(|item| match item {
25498            Item::Use(use_decl) => Some(use_decl),
25499            _ => None,
25500        });
25501        assert_eq!(
25502            use_decl.map(|decl| decl.name.value.as_str()),
25503            Some("memory")
25504        );
25505
25506        let removed_plugin = parse_program("workflow Imports\n\nuse plugin \"memory\"\n");
25507        assert_eq!(removed_plugin.diagnostics.len(), 1);
25508        assert_eq!(
25509            removed_plugin.diagnostics[0].message,
25510            "`use plugin` is no longer supported"
25511        );
25512
25513        let removed_skill = parse_program("workflow Imports\n\nuse skill \"repo-user\"\n");
25514        assert_eq!(removed_skill.diagnostics.len(), 1);
25515        assert_eq!(
25516            removed_skill.diagnostics[0].message,
25517            "`use skill` is no longer supported"
25518        );
25519    }
25520
25521    #[test]
25522    fn parses_include_declarations_and_records_ir_metadata() {
25523        let source = r#"include "library.whip"
25524
25525workflow Imports
25526
25527class Task {
25528  id string
25529}
25530"#;
25531        let parsed = parse_program(source);
25532        assert_eq!(parsed.diagnostics, Vec::new());
25533        let include = parsed.program.items.iter().find_map(|item| match item {
25534            Item::Include(include) => Some(include),
25535            _ => None,
25536        });
25537        assert_eq!(
25538            include.map(|decl| decl.path.value.as_str()),
25539            Some("library.whip")
25540        );
25541
25542        let compiled = compile_program(source);
25543        let ir = compiled.ir.expect("source compiles");
25544        assert_eq!(ir.includes[0].path, "library.whip");
25545        assert!(ir.to_snapshot().contains("includes\n  library.whip\n"));
25546    }
25547
25548    #[test]
25549    fn parses_explicit_workflow_block_and_contracts() {
25550        let source = r#"
25551workflow ReviewPhase {
25552  input phase PhaseReviewRequest
25553  output result PhaseReviewResult
25554  failure error ReviewFailure
25555
25556  class PhaseReviewRequest {
25557    title string
25558  }
25559
25560  class PhaseReviewResult {
25561    accepted bool
25562  }
25563
25564  class ReviewFailure {
25565    reason string
25566  }
25567
25568  rule noop
25569    when started
25570  => {
25571  }
25572}
25573"#;
25574        let compiled = compile_program(source);
25575        assert_eq!(compiled.diagnostics, Vec::new());
25576        let ir = compiled.ir.expect("source compiles");
25577        assert_eq!(ir.workflow, "ReviewPhase");
25578        assert_eq!(ir.workflow_contracts.len(), 3);
25579        let snapshot = ir.to_snapshot();
25580        assert!(snapshot.contains("workflow_contracts\n  input phase ref<PhaseReviewRequest>"));
25581        assert!(snapshot.contains("  output result ref<PhaseReviewResult>"));
25582        assert!(snapshot.contains("  failure error ref<ReviewFailure>"));
25583    }
25584
25585    #[test]
25586    fn revision_fixture_bundles_compile_with_expected_contract_shapes() {
25587        let compatible_v1 =
25588            compile_program(include_str!("../fixtures/revision-compatible-v1.whip"));
25589        let compatible_v2 =
25590            compile_program(include_str!("../fixtures/revision-compatible-v2.whip"));
25591        let incompatible_v2 =
25592            compile_program(include_str!("../fixtures/revision-incompatible-v2.whip"));
25593        for compiled in [&compatible_v1, &compatible_v2, &incompatible_v2] {
25594            assert_eq!(compiled.diagnostics, Vec::new());
25595        }
25596        let compatible_v1 = compatible_v1.ir.expect("compatible v1 compiles");
25597        let compatible_v2 = compatible_v2.ir.expect("compatible v2 compiles");
25598        let incompatible_v2 = incompatible_v2.ir.expect("incompatible v2 compiles");
25599
25600        assert_eq!(compatible_v1.workflow, "RevisionFixture");
25601        assert_eq!(compatible_v2.workflow, "RevisionFixture");
25602        assert_eq!(incompatible_v2.workflow, "RevisionFixture");
25603        assert_eq!(
25604            compatible_v1
25605                .workflow_contracts
25606                .iter()
25607                .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25608                .collect::<Vec<_>>(),
25609            compatible_v2
25610                .workflow_contracts
25611                .iter()
25612                .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25613                .collect::<Vec<_>>()
25614        );
25615        assert_ne!(
25616            compatible_v1
25617                .workflow_contracts
25618                .iter()
25619                .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25620                .collect::<Vec<_>>(),
25621            incompatible_v2
25622                .workflow_contracts
25623                .iter()
25624                .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25625                .collect::<Vec<_>>()
25626        );
25627        assert!(compatible_v2
25628            .schemas
25629            .iter()
25630            .any(|schema| matches!(schema, IrSchema::Class(class) if class.name == "AuditTrail")));
25631    }
25632
25633    #[test]
25634    fn expands_pattern_applications_with_hygienic_names() {
25635        let source = r#"
25636pattern Review<Input> {
25637  class Result {
25638    item Input
25639  }
25640
25641  rule dispatch
25642    when Input as item
25643  => {
25644  }
25645}
25646
25647workflow Root {
25648  class Task {
25649    title string
25650  }
25651
25652  apply Review<Task> as taskReview {
25653  }
25654}
25655"#;
25656        let compiled = compile_program(source);
25657        assert_eq!(compiled.diagnostics, Vec::new());
25658        let ir = compiled.ir.expect("source compiles");
25659        let snapshot = ir.to_snapshot();
25660        assert!(snapshot.contains("pattern_applications\n  Review as taskReview<ref<Task>>"));
25661        assert!(snapshot.contains("    generated class:taskReview_Result"));
25662        assert!(snapshot.contains("    generated rule:taskReview_dispatch"));
25663        assert!(snapshot.contains("class taskReview_Result"));
25664        assert!(snapshot.contains("    item ref<Task>"));
25665        assert!(snapshot.contains("rule taskReview_dispatch"));
25666        assert!(snapshot.contains("    when Task as item"));
25667    }
25668
25669    #[test]
25670    fn pattern_application_records_definition_and_application_spans() {
25671        let source = r#"
25672pattern Review<Input> {
25673  rule dispatch
25674    when Input as item
25675  => {
25676  }
25677}
25678
25679workflow Root {
25680  class Task {
25681    title string
25682  }
25683
25684  apply Review<Task> as taskReview {
25685  }
25686}
25687"#;
25688        let compiled = compile_program(source);
25689        assert_eq!(compiled.diagnostics, Vec::new());
25690        let ir = compiled.ir.expect("source compiles");
25691        let application = ir
25692            .pattern_applications
25693            .first()
25694            .expect("one pattern application");
25695
25696        // The recorded definition span must cover the `pattern Review<...> { ... }`
25697        // declaration, and the application span the `apply ... as ... { ... }` site.
25698        let definition =
25699            &source[application.definition_span.start..application.definition_span.end];
25700        assert!(definition.starts_with("pattern Review"));
25701        assert!(definition.ends_with('}'));
25702        let application_site =
25703            &source[application.application_span.start..application.application_span.end];
25704        assert!(application_site.starts_with("apply Review<Task> as taskReview"));
25705        assert!(application_site.ends_with('}'));
25706
25707        let snapshot = ir.to_snapshot();
25708        assert!(snapshot.contains(&format!(
25709            "    defined-at {}..{}",
25710            application.definition_span.start, application.definition_span.end
25711        )));
25712        assert!(snapshot.contains(&format!(
25713            "    applied-at {}..{}",
25714            application.application_span.start, application.application_span.end
25715        )));
25716    }
25717
25718    #[test]
25719    fn rejects_terminal_statement_in_pattern_body() {
25720        let source = r#"
25721pattern Finisher<Input> {
25722  rule wrap_up
25723    when Input as item
25724  => {
25725    complete result {
25726      done 1
25727    }
25728  }
25729}
25730
25731workflow Root {
25732  output result Summary
25733
25734  class Summary {
25735    done int
25736  }
25737
25738  class Task {
25739    title string
25740  }
25741
25742  apply Finisher<Task> as finish {
25743  }
25744}
25745"#;
25746        let compiled = compile_program(source);
25747        assert!(compiled.ir.is_none());
25748        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25749            .message
25750            .contains("cannot reach a workflow terminal")));
25751    }
25752
25753    #[test]
25754    fn rejects_workflow_contract_in_pattern_body() {
25755        let source = r#"
25756pattern Contracted<Input> {
25757  output result Input
25758
25759  rule dispatch
25760    when Input as item
25761  => {
25762  }
25763}
25764
25765workflow Root {
25766  class Task {
25767    title string
25768  }
25769
25770  apply Contracted<Task> as contracted {
25771  }
25772}
25773"#;
25774        let compiled = compile_program(source);
25775        assert!(compiled.ir.is_none());
25776        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25777            .message
25778            .contains("workflow contracts are not allowed in pattern bodies")));
25779    }
25780
25781    #[test]
25782    fn parses_workflow_invoke_effect_metadata() {
25783        let source = r#"
25784workflow Parent {
25785  class Task {
25786    title string
25787  }
25788
25789  rule dispatch
25790    when Task as task
25791  => {
25792    invoke Child { task task } as child
25793  }
25794}
25795
25796workflow Child {
25797  input task Task
25798
25799  class Task {
25800    title string
25801  }
25802}
25803"#;
25804        let compiled = compile_program_with_root(source, Some("Parent"));
25805        assert_eq!(compiled.diagnostics, Vec::new());
25806        let ir = compiled.ir.expect("source compiles");
25807        let rule = ir
25808            .rules
25809            .iter()
25810            .find(|rule| rule.name == "dispatch")
25811            .expect("dispatch rule lowers");
25812        assert_eq!(rule.metadata.effects.len(), 1);
25813        assert_eq!(rule.metadata.effects[0].kind, IrEffectKind::WorkflowInvoke);
25814        assert_eq!(rule.metadata.effects[0].binding.as_deref(), Some("child"));
25815        assert_eq!(
25816            rule.metadata.effects[0].workflow_target.as_deref(),
25817            Some("Child")
25818        );
25819        assert!(ir
25820            .to_snapshot()
25821            .contains("child kind=workflow.invoke binding=child"));
25822    }
25823
25824    #[test]
25825    fn rejects_unknown_workflow_invocation_target() {
25826        let source = r#"
25827workflow Parent {
25828  class Task {
25829    title string
25830  }
25831
25832  rule dispatch
25833    when Task as task
25834  => {
25835    invoke Missing { task task } as child
25836  }
25837}
25838"#;
25839        let compiled = compile_program(source);
25840        assert!(compiled.ir.is_none());
25841        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25842            .message
25843            .contains("invokes unknown workflow `Missing`")));
25844    }
25845
25846    #[test]
25847    fn validates_workflow_invocation_inputs_against_target_contract() {
25848        let source = r#"
25849workflow Parent {
25850  class Task {
25851    title string
25852  }
25853
25854  rule dispatch
25855    when Task as task
25856  => {
25857    invoke Child { wrong task } as child
25858  }
25859}
25860
25861workflow Child {
25862  input task Task
25863
25864  class Task {
25865    title string
25866  }
25867}
25868"#;
25869        let compiled = compile_program_with_root(source, Some("Parent"));
25870        assert!(compiled.ir.is_none());
25871        let messages = compiled
25872            .diagnostics
25873            .iter()
25874            .map(|diagnostic| diagnostic.message.as_str())
25875            .collect::<Vec<_>>();
25876        assert!(
25877            messages
25878                .iter()
25879                .any(|message| message.contains("workflow `Child` has no input `wrong`")),
25880            "{messages:#?}"
25881        );
25882        assert!(
25883            messages
25884                .iter()
25885                .any(|message| message
25886                    .contains("workflow invocation `Child` is missing input `task`")),
25887            "{messages:#?}"
25888        );
25889    }
25890
25891    #[test]
25892    fn validates_nested_workflow_invocation_input_payloads() {
25893        let source = r#"
25894workflow Parent {
25895  class Task {
25896    title string
25897  }
25898
25899  rule dispatch
25900    when Task as task
25901  => {
25902    invoke Child { task { count "bad" } } as child
25903  }
25904}
25905
25906workflow Child {
25907  input task ChildTask
25908
25909  class ChildTask {
25910    count int
25911  }
25912}
25913"#;
25914        let compiled = compile_program_with_root(source, Some("Parent"));
25915        assert!(compiled.ir.is_none());
25916        assert!(compiled.diagnostics.iter().any(|diagnostic| {
25917            diagnostic
25918                .message
25919                .contains("field `ChildTask.count` expects `int`")
25920        }));
25921    }
25922
25923    #[test]
25924    fn rejects_direct_recursive_workflow_invocation() {
25925        let source = r#"
25926workflow Parent {
25927  input task Task
25928
25929  class Task {
25930    title string
25931  }
25932
25933  rule dispatch
25934    when Task as task
25935  => {
25936    invoke Parent { task task } as next
25937  }
25938}
25939"#;
25940        let compiled = compile_program(source);
25941        assert!(compiled.ir.is_none());
25942        assert!(compiled.diagnostics.iter().any(|diagnostic| {
25943            diagnostic
25944                .message
25945                .contains("recursively invokes workflow `Parent`")
25946        }));
25947    }
25948
25949    #[test]
25950    fn expands_pattern_application_value_arguments() {
25951        let source = r#"
25952pattern Review<Input> {
25953  rule dispatch
25954    when Input as item
25955  => {
25956  }
25957}
25958
25959workflow Root {
25960  class Task {
25961    title string
25962  }
25963
25964  apply Review<Task> as taskReview {
25965    item task
25966  }
25967}
25968"#;
25969        let compiled = compile_program(source);
25970        assert_eq!(compiled.diagnostics, Vec::new());
25971        let snapshot = compiled.ir.expect("source compiles").to_snapshot();
25972        assert!(snapshot.contains("    arg item task"));
25973    }
25974
25975    #[test]
25976    fn rejects_malformed_pattern_application_arguments() {
25977        let source = r#"
25978pattern Review<Input> {
25979  rule dispatch
25980    when Input as item
25981  => {
25982  }
25983}
25984
25985workflow Root {
25986  class Task {
25987    title string
25988  }
25989
25990  apply Review<Task> as taskReview {
25991    item
25992    item task
25993  }
25994}
25995"#;
25996        let compiled = compile_program(source);
25997        assert!(compiled.ir.is_none());
25998        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25999            .message
26000            .contains("argument `item` is missing a value")));
26001    }
26002
26003    #[test]
26004    fn rejects_unknown_workflow_terminal_actions() {
26005        let source = r#"
26006workflow BadTerminal {
26007  output result Result
26008
26009  class Result {
26010    status "ok"
26011  }
26012
26013  rule bad
26014    when started
26015  => {
26016    complete missing {
26017      status "ok"
26018    }
26019  }
26020}
26021"#;
26022        let compiled = compile_program(source);
26023        assert!(compiled.ir.is_none());
26024        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26025            .message
26026            .contains("completes unknown workflow terminal `missing`")));
26027    }
26028
26029    #[test]
26030    fn rejects_duplicate_workflow_inputs() {
26031        let source = r#"
26032workflow DuplicateInput {
26033  input phase PhaseRequest
26034  input phase PhaseRequest
26035
26036  class PhaseRequest {
26037    title string
26038  }
26039
26040  rule noop
26041    when started
26042  => {
26043  }
26044}
26045"#;
26046        let compiled = compile_program(source);
26047        assert!(compiled.ir.is_none());
26048        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26049            .message
26050            .contains("workflow declares input `phase` more than once")));
26051    }
26052
26053    #[test]
26054    fn rejects_with_as_rule_readiness_alias() {
26055        let source = r#"
26056workflow WithIsNotWhen
26057
26058rule bad
26059  with started
26060=> {
26061}
26062"#;
26063        let compiled = compile_program(source);
26064
26065        assert!(compiled.ir.is_none());
26066        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26067            .message
26068            .contains("`with` is not a rule readiness clause")));
26069        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26070            .suggestion
26071            .as_deref()
26072            .is_some_and(|suggestion| suggestion.contains("use `when` for rule conditions"))));
26073    }
26074
26075    #[test]
26076    fn parses_grouped_when_clauses_as_ordinary_readiness_clauses() {
26077        let source = r#"
26078workflow GroupedWhen
26079
26080class Task {
26081  status "queued"
26082}
26083
26084agent worker {
26085  provider fixture
26086  profile "repo-writer"
26087  capacity 1
26088}
26089
26090rule start
26091  when {
26092    Task as task where task.status == "queued"
26093    worker is available
26094  }
26095=> {
26096  tell worker "do it"
26097}
26098"#;
26099        let compiled = compile_program(source);
26100        let ir = compiled.ir.expect("program compiles");
26101        let rule = &ir.rules[0];
26102
26103        assert_eq!(rule.whens.len(), 2);
26104        assert_eq!(rule.whens[0].pattern, "Task as task");
26105        assert_eq!(
26106            rule.whens[0]
26107                .guard
26108                .as_ref()
26109                .map(|guard| guard.expr.to_snapshot()),
26110            Some("task.status == \"queued\"".to_owned())
26111        );
26112        assert_eq!(rule.whens[1].pattern, "worker is available");
26113        assert!(ir
26114            .to_snapshot()
26115            .contains("    when Task as task where task.status == \"queued\""));
26116        assert!(ir.to_snapshot().contains("    when worker is available"));
26117    }
26118
26119    #[test]
26120    fn accepts_harness_declarations_and_agent_bindings() {
26121        let source = r#"
26122workflow HarnessTopology
26123
26124harness coder: codex
26125harness reviewer: claude
26126
26127agent implementer using coder {
26128  profile "repo-writer"
26129  capacity 1
26130}
26131
26132agent critic using reviewer {
26133  profile "repo-reader"
26134  capacity 1
26135}
26136
26137rule start
26138  when started
26139=> {
26140  tell implementer as turn "implement"
26141}
26142"#;
26143
26144        let compiled = compile_program(source);
26145        assert_eq!(compiled.diagnostics, Vec::new());
26146        let ir = compiled.ir.expect("program compiles");
26147        assert_eq!(ir.harnesses.len(), 2);
26148        assert_eq!(ir.harnesses[0].name, "coder");
26149        assert_eq!(ir.harnesses[0].kind, "codex");
26150        assert_eq!(
26151            ir.agents
26152                .iter()
26153                .find(|agent| agent.name == "implementer")
26154                .and_then(|agent| agent.harness.as_deref()),
26155            Some("coder")
26156        );
26157        let snapshot = ir.to_snapshot();
26158        assert!(snapshot.contains("harness coder kind=codex"));
26159        assert!(snapshot.contains("agent implementer harness=coder"));
26160    }
26161
26162    #[test]
26163    fn rejects_agent_binding_to_unknown_harness() {
26164        let source = r#"
26165workflow UnknownHarness
26166
26167agent worker using missing {
26168  profile "repo-writer"
26169  capacity 1
26170}
26171"#;
26172
26173        let compiled = compile_program(source);
26174        assert!(compiled.ir.is_none());
26175        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26176            .message
26177            .contains("agent `worker` uses unknown harness `missing`")));
26178    }
26179
26180    #[test]
26181    fn rejects_duplicate_harness_declarations_and_accepts_kinds_structurally() {
26182        let source = r#"
26183workflow BadHarnesses
26184
26185harness coder: spaceship
26186harness coder: codex
26187
26188agent worker using coder {
26189  profile "repo-writer"
26190  capacity 1
26191}
26192"#;
26193
26194        let compiled = compile_program(source);
26195        assert!(compiled.ir.is_none());
26196        let messages = compiled
26197            .diagnostics
26198            .iter()
26199            .map(|diagnostic| diagnostic.message.as_str())
26200            .collect::<Vec<_>>();
26201        assert!(
26202            messages
26203                .iter()
26204                .any(|message| message.contains("harness `coder` is declared more than once")),
26205            "{messages:#?}"
26206        );
26207        // Kind validity is registry-derived and validated by the CLI
26208        // (spec/std-agent.md "Open provider registry"): the parser no longer
26209        // holds a compiled-in kind set, so `spaceship` draws no parser
26210        // diagnostic here.
26211        assert!(
26212            !messages
26213                .iter()
26214                .any(|message| message.contains("unsupported kind")),
26215            "{messages:#?}"
26216        );
26217    }
26218
26219    #[test]
26220    fn validates_workflow_terminal_payload_fields() {
26221        let source = r#"
26222workflow BadTerminalPayload {
26223  output result Result
26224
26225  class Result {
26226    status "ok"
26227    summary string
26228  }
26229
26230  rule bad
26231    when started
26232  => {
26233    complete result {
26234      status "bad"
26235      extra "ignored"
26236    }
26237  }
26238}
26239"#;
26240        let compiled = compile_program(source);
26241        assert!(compiled.ir.is_none());
26242        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26243            .message
26244            .contains("field `Result.status` expects literal string `ok`")));
26245        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26246            .message
26247            .contains("class `Result` has no field `extra`")));
26248        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26249            .message
26250            .contains("workflow terminal `result` is missing required field `Result.summary`")));
26251    }
26252
26253    #[test]
26254    fn accepts_workflow_terminal_actions_in_header_style_workflows() {
26255        let source = r#"
26256workflow ImplicitTerminal
26257
26258output result Result
26259
26260class Result {
26261  status "ok"
26262}
26263
26264rule finish
26265  when started
26266=> {
26267  complete result {
26268    status "ok"
26269  }
26270}
26271"#;
26272        let compiled = compile_program(source);
26273        assert_eq!(compiled.diagnostics, Vec::new());
26274        let ir = compiled.ir.expect("header-style terminals compile");
26275        assert_eq!(ir.workflow_contracts.len(), 1);
26276    }
26277
26278    #[test]
26279    fn rejects_header_style_terminal_for_undeclared_contract() {
26280        let source = r#"
26281workflow ImplicitTerminal
26282
26283class Result {
26284  status "ok"
26285}
26286
26287rule bad
26288  when started
26289=> {
26290  complete result {
26291    status "ok"
26292  }
26293}
26294"#;
26295        let compiled = compile_program(source);
26296        assert!(compiled.ir.is_none());
26297        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26298            .message
26299            .contains("completes unknown workflow terminal `result`")));
26300    }
26301
26302    #[test]
26303    fn scalar_terminal_contract_accepts_bare_value() {
26304        // A scalar output contract takes a bare scalar terminal payload.
26305        let source = r#"
26306workflow ScalarTerminal {
26307  output result float
26308  failure error string
26309
26310  rule good
26311    when started
26312  => {
26313    complete result 0.9
26314  }
26315}
26316"#;
26317        let compiled = compile_program(source);
26318        assert!(
26319            compiled.diagnostics.is_empty(),
26320            "scalar terminal payload rejected: {:?}",
26321            compiled.diagnostics
26322        );
26323        assert!(compiled.ir.is_some());
26324    }
26325
26326    #[test]
26327    fn scalar_terminal_contract_rejects_a_field_block() {
26328        // A scalar contract with a `{ ... }` block is a shape mismatch.
26329        let source = r#"
26330workflow ScalarTerminal {
26331  output result float
26332
26333  rule bad
26334    when started
26335  => {
26336    complete result {
26337      value 0.9
26338    }
26339  }
26340}
26341"#;
26342        let compiled = compile_program(source);
26343        assert!(compiled.ir.is_none());
26344        assert!(
26345            compiled.diagnostics.iter().any(|d| d
26346                .message
26347                .contains("has a scalar payload contract but is given a field block")),
26348            "{:?}",
26349            compiled.diagnostics
26350        );
26351    }
26352
26353    #[test]
26354    fn class_terminal_contract_rejects_a_bare_scalar() {
26355        // A class contract given a bare scalar value is a shape mismatch.
26356        let source = r#"
26357workflow ClassTerminal {
26358  output result Score
26359  class Score { value number }
26360
26361  rule bad
26362    when started
26363  => {
26364    complete result 0.9
26365  }
26366}
26367"#;
26368        let compiled = compile_program(source);
26369        assert!(compiled.ir.is_none());
26370        assert!(
26371            compiled.diagnostics.iter().any(|d| d
26372                .message
26373                .contains("has a class payload contract `Score` but is given a bare scalar value")),
26374            "{:?}",
26375            compiled.diagnostics
26376        );
26377    }
26378
26379    #[test]
26380    fn scalar_terminal_value_is_typechecked_against_the_contract() {
26381        // A string literal against a `number` scalar contract is a type error.
26382        let source = r#"
26383workflow ScalarTerminal {
26384  output result float
26385
26386  rule bad
26387    when started
26388  => {
26389    complete result "not a number"
26390  }
26391}
26392"#;
26393        let compiled = compile_program(source);
26394        assert!(compiled.ir.is_none());
26395        assert!(
26396            compiled
26397                .diagnostics
26398                .iter()
26399                .any(|d| d.message.contains("result.value") || d.message.contains("number")),
26400            "{:?}",
26401            compiled.diagnostics
26402        );
26403    }
26404
26405    #[test]
26406    fn selects_root_from_multiple_explicit_workflows() {
26407        let source = r#"
26408class Shared {
26409  id string
26410}
26411
26412workflow First {
26413  rule one
26414    when started
26415  => {
26416    record Shared {
26417      id "first"
26418    }
26419  }
26420}
26421
26422workflow Second {
26423  rule two
26424    when started
26425  => {
26426    record Shared {
26427      id "second"
26428    }
26429  }
26430}
26431"#;
26432        let ambiguous = compile_program(source);
26433        assert!(ambiguous.ir.is_none());
26434        assert!(ambiguous.diagnostics.iter().any(|diagnostic| diagnostic
26435            .message
26436            .contains("multiple workflow declarations require an explicit root")));
26437
26438        let compiled = compile_program_with_root(source, Some("Second"));
26439        assert_eq!(compiled.diagnostics, Vec::new());
26440        let ir = compiled.ir.expect("selected root compiles");
26441        assert_eq!(ir.workflow, "Second");
26442        assert_eq!(ir.rules.len(), 1);
26443        assert_eq!(ir.rules[0].name, "two");
26444        assert!(ir.to_snapshot().contains("class Shared"));
26445    }
26446
26447    #[test]
26448    fn reports_recoverable_diagnostics() {
26449        let source = r#"
26450workflow Broken
26451
26452agent worker {
26453  provider fixture
26454  profile 42
26455  capacity nope
26456}
26457
26458rule missing_body
26459  when started
26460=>
26461"#;
26462
26463        let parsed = parse_program(source);
26464        assert!(parsed.diagnostics.len() >= 3);
26465        assert!(parsed
26466            .diagnostics
26467            .iter()
26468            .any(|diagnostic| diagnostic.message.contains("profile string")));
26469        assert!(parsed
26470            .diagnostics
26471            .iter()
26472            .any(|diagnostic| diagnostic.suggestion.as_deref()
26473                == Some("write `profile \"profile-name\"`")));
26474        assert!(parsed
26475            .diagnostics
26476            .iter()
26477            .any(|diagnostic| diagnostic.message.contains("capacity value")));
26478        assert!(parsed
26479            .diagnostics
26480            .iter()
26481            .any(|diagnostic| diagnostic.message.contains("`{`")));
26482    }
26483
26484    #[test]
26485    fn lowers_and_formats_agent_tools_grant() {
26486        // DR-0025: an agent may declare a `tools [...]` grant of workflows it can
26487        // invoke as typed tools. The grant lowers to `IrAgent.tools`, survives a
26488        // format round-trip, and de-duplicates entries with a diagnostic.
26489        let source = r#"
26490workflow GrantHost
26491
26492agent worker {
26493  provider owned
26494  profile "repo-writer"
26495  capacity 1
26496  tools [WordCount, OpenPr]
26497}
26498"#;
26499        let compiled = compile_program(source);
26500        assert_eq!(compiled.diagnostics, Vec::new());
26501        let ir = compiled.ir.expect("valid ir");
26502        let agent = ir
26503            .agents
26504            .iter()
26505            .find(|agent| agent.name == "worker")
26506            .expect("worker agent");
26507        assert_eq!(
26508            agent.tools,
26509            vec!["WordCount".to_owned(), "OpenPr".to_owned()]
26510        );
26511
26512        let formatted = format_program(source).formatted.expect("formats");
26513        assert!(
26514            formatted.contains("tools [WordCount, OpenPr]"),
26515            "formatted: {formatted}"
26516        );
26517
26518        // A duplicate grant entry is rejected.
26519        let dup = compile_program(
26520            "workflow Dup\nagent a {\n  provider owned\n  profile \"p\"\n  tools [X, X]\n}\n",
26521        );
26522        assert!(
26523            dup.diagnostics
26524                .iter()
26525                .any(|d| d.message.contains("grants tool `X` more than once")),
26526            "diagnostics: {:?}",
26527            dup.diagnostics
26528        );
26529    }
26530
26531    #[test]
26532    fn harness_class_classifies_managed_vs_delegated_and_emits_only_delegated() {
26533        // Classifier is total: owned/fixture Managed, the rest Delegated.
26534        assert_eq!(harness_class("owned"), HarnessClass::Managed);
26535        assert_eq!(harness_class("fixture"), HarnessClass::Managed);
26536        assert_eq!(harness_class("claude"), HarnessClass::Delegated);
26537        assert_eq!(harness_class("codex"), HarnessClass::Delegated);
26538        assert_eq!(harness_class("native-fixture"), HarnessClass::Delegated);
26539        assert_eq!(harness_class("command"), HarnessClass::Delegated);
26540
26541        // A `provider owned` agent lowers Managed and emits NO class token (default).
26542        let managed = compile_program(
26543            "workflow W\nagent m {\n  provider owned\n  profile \"p\"\n  capacity 1\n}\n",
26544        );
26545        let managed_ir = managed.ir.expect("ir");
26546        assert_eq!(managed_ir.agents[0].harness_class, HarnessClass::Managed);
26547        assert!(!managed_ir.to_snapshot().contains("class="));
26548
26549        // A `provider claude` agent lowers Delegated and emits `class=delegated`.
26550        let delegated = compile_program(
26551            "workflow W\nagent d {\n  provider claude\n  profile \"repo-writer\"\n  capacity 1\n}\n",
26552        );
26553        let delegated_ir = delegated.ir.expect("ir");
26554        assert_eq!(
26555            delegated_ir.agents[0].harness_class,
26556            HarnessClass::Delegated
26557        );
26558        assert!(delegated_ir.to_snapshot().contains("class=delegated"));
26559
26560        // `using <harness>` resolves the class through the harness's kind.
26561        let via_harness = compile_program(
26562            "workflow W\nharness box: claude\nagent d using box {\n  profile \"repo-writer\"\n  capacity 1\n}\n",
26563        );
26564        let via_ir = via_harness.ir.expect("ir");
26565        assert_eq!(via_ir.agents[0].harness_class, HarnessClass::Delegated);
26566    }
26567
26568    #[test]
26569    fn tell_with_skills_lowers_to_effect_turn_skills_and_ir_snapshot() {
26570        let source = concat!(
26571            "workflow W\n",
26572            "agent coder {\n  provider owned\n  profile \"p\"\n  capacity 1\n}\n",
26573            "class Task {\n  note string\n}\n",
26574            "rule go\n  when Task as t\n=> {\n  tell coder with skills [\"review\", \"lint\"] \"do it\" as turn\n}\n",
26575        );
26576        let compiled = compile_program(source);
26577        assert!(
26578            compiled.diagnostics.is_empty(),
26579            "{:?}",
26580            compiled.diagnostics
26581        );
26582        let ir = compiled.ir.expect("ir");
26583        let effect = ir
26584            .rules
26585            .iter()
26586            .flat_map(|rule| &rule.metadata.effects)
26587            .find(|effect| effect.kind == IrEffectKind::AgentTell)
26588            .expect("tell effect");
26589        assert_eq!(
26590            effect.turn_skills,
26591            vec!["review".to_owned(), "lint".to_owned()]
26592        );
26593        // The .ir snapshot carries the pin (appended only when present).
26594        assert!(
26595            ir.to_snapshot().contains("skills=review,lint"),
26596            "{}",
26597            ir.to_snapshot()
26598        );
26599    }
26600
26601    #[test]
26602    fn agent_compaction_strategy_parses_lowers_formats_and_validates() {
26603        let source = compile_program(
26604            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n  compaction hard_reset\n}\n",
26605        );
26606        assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26607        let ir = source.ir.expect("ir");
26608        let agent = ir.agents.iter().find(|a| a.name == "w").expect("agent");
26609        assert_eq!(agent.compaction.as_deref(), Some("hard_reset"));
26610
26611        // Round-trips through the formatter and the .ir snapshot.
26612        let formatted = format_program(
26613            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n  compaction hard_reset\n}\n",
26614        )
26615        .formatted
26616        .expect("formats");
26617        assert!(formatted.contains("compaction hard_reset"), "{formatted}");
26618        assert!(ir.to_snapshot().contains("compaction=hard_reset"));
26619
26620        // An unknown strategy is a diagnostic (not silently accepted).
26621        let bad = compile_program(
26622            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n  compaction squish\n}\n",
26623        );
26624        assert!(
26625            bad.diagnostics
26626                .iter()
26627                .any(|d| d.message.contains("unknown compaction strategy `squish`")),
26628            "diagnostics: {:?}",
26629            bad.diagnostics
26630        );
26631
26632        // An agent with no compaction field lowers to None and adds no .ir token.
26633        let plain = compile_program(
26634            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n}\n",
26635        );
26636        let plain_ir = plain.ir.expect("ir");
26637        assert_eq!(plain_ir.agents[0].compaction, None);
26638        assert!(!plain_ir.to_snapshot().contains("compaction="));
26639    }
26640
26641    #[test]
26642    fn agent_settings_source_parses_lowers_formats_and_validates() {
26643        // DR-0034 Decision 4: the delegated ambient-config knob.
26644        let source = compile_program(
26645            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings project\n}\n",
26646        );
26647        assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26648        let ir = source.ir.expect("ir");
26649        let agent = ir.agents.iter().find(|a| a.name == "w").expect("agent");
26650        assert_eq!(agent.settings.as_deref(), Some("project"));
26651
26652        // Round-trips through the formatter and the .ir snapshot.
26653        let formatted = format_program(
26654            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings project\n}\n",
26655        )
26656        .formatted
26657        .expect("formats");
26658        assert!(formatted.contains("settings project"), "{formatted}");
26659        assert!(ir.to_snapshot().contains("settings=project"));
26660
26661        // An unknown source is a diagnostic (not silently accepted).
26662        let bad = compile_program(
26663            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings everything\n}\n",
26664        );
26665        assert!(
26666            bad.diagnostics
26667                .iter()
26668                .any(|d| d.message.contains("unknown settings source `everything`")),
26669            "diagnostics: {:?}",
26670            bad.diagnostics
26671        );
26672
26673        // Declaring settings twice is a diagnostic.
26674        let dup = compile_program(
26675            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings project\n  settings user\n}\n",
26676        );
26677        assert!(
26678            dup.diagnostics
26679                .iter()
26680                .any(|d| d.message.contains("declares settings more than once")),
26681            "diagnostics: {:?}",
26682            dup.diagnostics
26683        );
26684
26685        // An agent with no settings field lowers to None and adds no .ir token —
26686        // unset means the provider's own default, not the empty set.
26687        let plain = compile_program(
26688            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n}\n",
26689        );
26690        let plain_ir = plain.ir.expect("ir");
26691        assert_eq!(plain_ir.agents[0].settings, None);
26692        assert!(!plain_ir.to_snapshot().contains("settings="));
26693    }
26694
26695    #[test]
26696    fn agent_thread_mode_parses_lowers_and_partitions() {
26697        // `thread continue` on a Managed agent lowers into the IR.
26698        let source = "workflow ChatDemo\n\noutput result Done\n\nclass Done {\n  ok int\n}\n\n\
26699             agent helper {\n  provider owned\n  profile \"repo-reader\"\n  capacity 1\n  thread continue\n}\n\n\
26700             rule go\n  when started\n=> {\n  tell helper as reply \"\"\"\n  Hi.\n  \"\"\"\n\n\
26701             \x20 after reply succeeds {\n    complete result { ok 1 }\n  }\n}\n";
26702        let compiled = compile_program(source);
26703        let ir = compiled.ir.expect("thread continue compiles");
26704        let agent = ir
26705            .agents
26706            .iter()
26707            .find(|agent| agent.name == "helper")
26708            .expect("agent lowered");
26709        assert_eq!(agent.thread.as_deref(), Some("continue"));
26710
26711        // An unknown mode is a diagnostic.
26712        let bad = source.replace("thread continue", "thread sometimes");
26713        let compiled = compile_program(&bad);
26714        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26715            .message
26716            .contains("unknown thread mode `sometimes`")));
26717
26718        // `thread` on a Delegated agent is a managed-knob partition error.
26719        let delegated = source.replace("provider owned", "provider codex");
26720        let compiled = compile_program(&delegated);
26721        assert!(
26722            compiled.diagnostics.iter().any(|diagnostic| diagnostic
26723                .message
26724                .contains("is delegated; `thread` is a managed-harness knob")),
26725            "{:?}",
26726            compiled
26727                .diagnostics
26728                .iter()
26729                .map(|d| &d.message)
26730                .collect::<Vec<_>>()
26731        );
26732    }
26733
26734    #[test]
26735    fn agent_knobs_partition_by_harness_class() {
26736        // DR-0034 Decision 3: each class admits only its meaningful knobs; the
26737        // other class rejects them with a diagnostic, never a silent no-op.
26738
26739        // `compaction` on a Delegated agent is an error — you cannot tell Claude
26740        // how to compact; it does its own.
26741        let bad = compile_program(
26742            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  compaction summarize\n}\n",
26743        );
26744        assert!(
26745            bad.diagnostics.iter().any(|d| d
26746                .message
26747                .contains("is delegated; `compaction` is a managed-harness knob")),
26748            "diagnostics: {:?}",
26749            bad.diagnostics
26750        );
26751
26752        // `settings` on a Managed agent is an error — WhippleScript already
26753        // assembles the context; there is nothing foreign to configure.
26754        let bad = compile_program(
26755            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n  settings project\n}\n",
26756        );
26757        assert!(
26758            bad.diagnostics.iter().any(|d| d
26759                .message
26760                .contains("is managed; `settings` is a delegated-harness knob")),
26761            "diagnostics: {:?}",
26762            bad.diagnostics
26763        );
26764
26765        // The class resolves through a `using <harness>` binding too.
26766        let bad = compile_program(
26767            "workflow C\nharness box: claude\nagent w using box {\n  profile \"p\"\n  capacity 1\n  compaction summarize\n}\n",
26768        );
26769        assert!(
26770            bad.diagnostics.iter().any(|d| d
26771                .message
26772                .contains("is delegated; `compaction` is a managed-harness knob")),
26773            "diagnostics: {:?}",
26774            bad.diagnostics
26775        );
26776
26777        // The right-class pairings stay legal.
26778        let good = compile_program(
26779            "workflow C\nagent m {\n  provider owned\n  profile \"p\"\n  capacity 1\n  compaction summarize\n}\nagent d {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings project\n}\n",
26780        );
26781        assert!(good.diagnostics.is_empty(), "{:?}", good.diagnostics);
26782
26783        // An agent with no provider binding is Managed by default (D8-5), so a
26784        // managed-only knob is legal on it.
26785        let unbound = compile_program(
26786            "workflow C\nagent w {\n  profile \"p\"\n  capacity 1\n  compaction summarize\n}\n",
26787        );
26788        assert!(
26789            !unbound
26790                .diagnostics
26791                .iter()
26792                .any(|d| d.message.contains("managed-harness knob")),
26793            "diagnostics: {:?}",
26794            unbound.diagnostics
26795        );
26796    }
26797
26798    /// `requires [<feature.class>]` (spec/std-agent.md slice 6): dotted
26799    /// taxonomy classes parse into `IrAgent::requires`, render in the .ir
26800    /// snapshot only when declared, format canonically, and a non-taxonomy
26801    /// class or duplicate is a lowering diagnostic.
26802    #[test]
26803    fn agent_requires_parses_taxonomy_classes() {
26804        let program = "workflow R\n\nagent a {\n  provider owned\n  profile \"repo-reader\"\n  capacity 1\n  requires [session.resume, turn.cancel]\n}\n";
26805        let compiled = compile_program(program);
26806        assert!(
26807            compiled.diagnostics.is_empty(),
26808            "{:?}",
26809            compiled.diagnostics
26810        );
26811        let ir = compiled.ir.expect("ir");
26812        let agent = ir.agents.first().expect("agent");
26813        assert_eq!(
26814            agent.requires,
26815            vec!["session.resume".to_owned(), "turn.cancel".to_owned()]
26816        );
26817        assert!(ir
26818            .to_snapshot()
26819            .contains("requires=[session.resume, turn.cancel]"));
26820
26821        // The formatter preserves the field.
26822        let formatted = format_program(program).formatted.expect("formats");
26823        assert!(
26824            formatted.contains("  requires [session.resume, turn.cancel]"),
26825            "{formatted}"
26826        );
26827
26828        // Agents without `requires` keep an unchanged snapshot (no ripple).
26829        let plain = compile_program(
26830            "workflow R\n\nagent a {\n  provider owned\n  profile \"p\"\n  capacity 1\n}\n",
26831        );
26832        assert!(!plain.ir.expect("ir").to_snapshot().contains("requires="));
26833
26834        // Taxonomy membership bites (DR-0015): a made-up class is rejected.
26835        let unknown = compile_program(
26836            "workflow R\n\nagent a {\n  provider owned\n  profile \"p\"\n  capacity 1\n  requires [warp.drive]\n}\n",
26837        );
26838        assert!(
26839            unknown.diagnostics.iter().any(|d| d
26840                .message
26841                .contains("requires unknown feature class `warp.drive`")),
26842            "{:?}",
26843            unknown.diagnostics
26844        );
26845
26846        // Duplicates are rejected.
26847        let duplicate = compile_program(
26848            "workflow R\n\nagent a {\n  provider owned\n  profile \"p\"\n  capacity 1\n  requires [turn.cancel, turn.cancel]\n}\n",
26849        );
26850        assert!(
26851            duplicate.diagnostics.iter().any(|d| d
26852                .message
26853                .contains("requires feature class `turn.cancel` more than once")),
26854            "{:?}",
26855            duplicate.diagnostics
26856        );
26857    }
26858
26859    #[test]
26860    fn agent_delegated_to_sugar_and_managed_default() {
26861        // `agent d delegated to <provider>` is the Delegated surface spelling
26862        // (DR-0034 Decision 2); it names the provider kind directly.
26863        let program = "workflow C\nagent d delegated to claude {\n  profile \"p\"\n  capacity 1\n  settings project\n}\n";
26864        let source = compile_program(program);
26865        assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26866        let ir = source.ir.expect("ir");
26867        let agent = ir.agents.iter().find(|a| a.name == "d").expect("agent");
26868        assert_eq!(agent.provider.as_deref(), Some("claude"));
26869        assert_eq!(agent.harness_class, HarnessClass::Delegated);
26870        assert!(ir.to_snapshot().contains("class=delegated"));
26871
26872        // The formatter preserves the sugar.
26873        let formatted = format_program(program).formatted.expect("formats");
26874        assert!(
26875            formatted.contains("agent d delegated to claude {"),
26876            "{formatted}"
26877        );
26878
26879        // `delegated to owned` is a contradiction — owned is the managed substrate.
26880        let bad = compile_program(
26881            "workflow C\nagent d delegated to owned {\n  profile \"p\"\n  capacity 1\n}\n",
26882        );
26883        assert!(
26884            bad.diagnostics.iter().any(|d| d
26885                .message
26886                .contains("delegates to `owned`, which is a managed kind")),
26887            "diagnostics: {:?}",
26888            bad.diagnostics
26889        );
26890
26891        // An unknown delegate kind parses structurally: kind existence is
26892        // registry-validated by the CLI (spec/std-agent.md "Open provider
26893        // registry"), and an unrecognized kind classifies Delegated (never
26894        // Managed), so `delegated to mystery` draws no parser diagnostic.
26895        let unknown = compile_program(
26896            "workflow C\nagent d delegated to mystery {\n  profile \"p\"\n  capacity 1\n}\n",
26897        );
26898        assert!(
26899            !unknown
26900                .diagnostics
26901                .iter()
26902                .any(|d| d.message.contains("unsupported provider")),
26903            "diagnostics: {:?}",
26904            unknown.diagnostics
26905        );
26906
26907        // `delegated to` plus a `provider` field is an error, not a silent pick.
26908        let both = compile_program(
26909            "workflow C\nagent d delegated to claude {\n  provider codex\n  profile \"p\"\n  capacity 1\n}\n",
26910        );
26911        assert!(
26912            both.diagnostics.iter().any(|d| d
26913                .message
26914                .contains("declares both `delegated to` and direct provider")),
26915            "diagnostics: {:?}",
26916            both.diagnostics
26917        );
26918
26919        // A bare `agent m { … }` is Managed by default (Decision 6: managed is
26920        // the substrate) — it lowers to the owned provider with no diagnostic.
26921        let plain = compile_program("workflow C\nagent m {\n  profile \"p\"\n  capacity 1\n}\n");
26922        assert!(plain.diagnostics.is_empty(), "{:?}", plain.diagnostics);
26923        let plain_ir = plain.ir.expect("ir");
26924        assert_eq!(plain_ir.agents[0].provider.as_deref(), Some("owned"));
26925        assert_eq!(plain_ir.agents[0].harness_class, HarnessClass::Managed);
26926    }
26927
26928    #[test]
26929    fn accepts_agent_ref_dynamic_tell_targets() {
26930        let source = r#"
26931workflow AgentRefRouting
26932
26933agent codex {
26934  provider codex
26935  profile "repo-writer"
26936  capacity 1
26937  capabilities ["agent.tell"]
26938}
26939
26940agent claude {
26941  provider claude
26942  profile "repo-writer"
26943  capacity 1
26944  capabilities ["agent.tell"]
26945}
26946
26947class LanguageTask {
26948  provider AgentRef<codex | claude>
26949  prompt string
26950}
26951
26952rule run_task
26953  when LanguageTask as task
26954  when task.provider is available
26955=> {
26956  tell task.provider requires ["agent.tell"] as turn "{{ task.prompt }}"
26957}
26958"#;
26959
26960        let compiled = compile_program(source);
26961        assert_eq!(compiled.diagnostics, Vec::new());
26962        let ir = compiled.ir.expect("valid ir");
26963        let rule = ir
26964            .rules
26965            .iter()
26966            .find(|rule| rule.name == "run_task")
26967            .expect("run_task");
26968        assert_eq!(rule.metadata.effects.len(), 1);
26969        assert_eq!(rule.metadata.effects[0].kind, IrEffectKind::AgentTell);
26970    }
26971
26972    #[test]
26973    fn rejects_agent_ref_targets_missing_required_capabilities() {
26974        let source = r#"
26975workflow BadAgentRefCapabilities
26976
26977agent codex {
26978  provider codex
26979  profile "repo-writer"
26980  capacity 1
26981  capabilities ["agent.tell", "repo.write"]
26982}
26983
26984agent claude {
26985  provider claude
26986  profile "repo-reader"
26987  capacity 1
26988  capabilities ["agent.tell"]
26989}
26990
26991class LanguageTask {
26992  provider AgentRef<codex | claude>
26993  prompt string
26994}
26995
26996rule run_task
26997  when LanguageTask as task
26998=> {
26999  tell task.provider requires ["repo.write"] as turn """
27000  {{ task.prompt }}
27001  """
27002}
27003"#;
27004
27005        let compiled = compile_program(source);
27006        assert!(compiled.ir.is_none());
27007        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27008            .message
27009            .contains("agent `claude` requiring undeclared capability `repo.write`")));
27010    }
27011
27012    #[test]
27013    fn rejects_plain_string_dynamic_tell_targets() {
27014        let source = r#"
27015workflow BadAgentRefRouting
27016
27017agent codex {
27018  provider codex
27019  profile "repo-writer"
27020  capacity 1
27021}
27022
27023class LanguageTask {
27024  provider string
27025}
27026
27027rule run_task
27028  when LanguageTask as task
27029=> {
27030  tell task.provider "bad"
27031}
27032"#;
27033
27034        let compiled = compile_program(source);
27035        assert!(compiled.ir.is_none());
27036        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27037            .message
27038            .contains("non-AgentRef dynamic tell target `task.provider`")));
27039    }
27040
27041    #[test]
27042    fn rejects_unknown_agent_ref_domain_values() {
27043        let source = r#"
27044workflow BadAgentRefDomain
27045
27046agent codex {
27047  provider codex
27048  profile "repo-writer"
27049  capacity 1
27050}
27051
27052class LanguageTask {
27053  provider AgentRef<codex | ghost>
27054}
27055
27056rule seed
27057  when started
27058=> {
27059  record LanguageTask {
27060    provider claude
27061  }
27062}
27063"#;
27064
27065        let compiled = compile_program(source);
27066        assert!(compiled.ir.is_none());
27067        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27068            .message
27069            .contains("AgentRef references unknown agent `ghost`")));
27070        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27071            .message
27072            .contains("field `LanguageTask.provider` cannot reference agent `claude`")));
27073    }
27074
27075    #[test]
27076    fn rejects_quoted_agent_ref_record_values() {
27077        let source = r#"
27078workflow BadQuotedAgentRef
27079
27080agent codex {
27081  provider codex
27082  profile "repo-writer"
27083  capacity 1
27084}
27085
27086class LanguageTask {
27087  provider AgentRef<codex>
27088}
27089
27090rule seed
27091  when started
27092=> {
27093  record LanguageTask {
27094    provider "codex"
27095  }
27096}
27097"#;
27098
27099        let compiled = compile_program(source);
27100        assert!(compiled.ir.is_none());
27101        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27102            .message
27103            .contains("expects an AgentRef value, not string `codex`")));
27104    }
27105
27106    #[test]
27107    fn requires_presence_proof_for_optional_field_access() {
27108        let source = r#"
27109workflow OptionalProof
27110
27111class Person {
27112  name string
27113}
27114
27115class Issue {
27116  assignee Person?
27117}
27118
27119rule unsafe_optional
27120  when Issue as issue where issue.assignee.name == "Ada"
27121=> {
27122}
27123"#;
27124
27125        let compiled = compile_program(source);
27126        assert!(compiled.ir.is_none());
27127        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27128            .message
27129            .contains("unsafe optional path `issue.assignee.name`")));
27130    }
27131
27132    #[test]
27133    fn accepts_presence_proof_before_optional_field_access() {
27134        let source = r#"
27135workflow OptionalProof
27136
27137class Person {
27138  name string
27139}
27140
27141class Issue {
27142  assignee Person?
27143}
27144
27145rule safe_optional
27146  when Issue as issue where issue.assignee != null && issue.assignee.name == "Ada"
27147=> {
27148}
27149
27150rule safe_exists
27151  when Issue as issue where exists issue.assignee && issue.assignee.name == "Ada"
27152=> {
27153}
27154
27155rule safe_not_null
27156  when Issue as issue where !(issue.assignee == null) && issue.assignee.name == "Ada"
27157=> {
27158}
27159"#;
27160
27161        let compiled = compile_program(source);
27162        assert_eq!(compiled.diagnostics, Vec::new());
27163        assert!(compiled.ir.is_some());
27164    }
27165
27166    #[test]
27167    fn parses_expression_kernel_surface() {
27168        let cases = [
27169            ("true || false && !ready", "true || (false && !ready)"),
27170            (
27171                "count(task.labels) == 0 || exists(Result where status == \"done\")",
27172                "(count(task.labels) == 0) || exists(Result where status == \"done\")",
27173            ),
27174            (
27175                "task.labels[\"priority\"] == [\"high\", \"urgent\"][0]",
27176                "task.labels[\"priority\"] == [\"high\", \"urgent\"][0]",
27177            ),
27178            (
27179                "exists issue.assignee && issue.assignee.name == \"Ada\"",
27180                "exists(issue.assignee) && (issue.assignee.name == \"Ada\")",
27181            ),
27182            (
27183                "{title task.title, metadata {phase \"kernel\"}}",
27184                "{title task.title, metadata {phase \"kernel\"}}",
27185            ),
27186            (
27187                "count(effect agent.tell where target == \"worker\") >= 1",
27188                "count(effect agent.tell where target == \"worker\") >= 1",
27189            ),
27190        ];
27191
27192        for (source, expected) in cases {
27193            let expr = parse_expression(source).expect(source);
27194            assert_eq!(expr.to_snapshot(), expected);
27195        }
27196
27197        for source in ["task.labels[", "count(Result where)", "[1,,2]"] {
27198            assert!(
27199                parse_expression(source).is_err(),
27200                "{source} unexpectedly parsed"
27201            );
27202        }
27203    }
27204
27205    /// Every expression form in the spec's grammar (expression-kernel.md
27206    /// "Expression Forms") parses and renders to a pinned snapshot, so parser
27207    /// precedence and snapshot parenthesization are locked for each form.
27208    #[test]
27209    fn deeply_nested_expression_errors_instead_of_overflowing_the_stack() {
27210        // Run on a production-sized stack (compile happens on the 8 MB main
27211        // thread, not a 2 MB test thread): a pathologically nested guard
27212        // expression must return a normal Err diagnostic, not abort the
27213        // process with a stack overflow.
27214        std::thread::Builder::new()
27215            .stack_size(8 * 1024 * 1024)
27216            .spawn(|| {
27217                let deep = format!("{}task.done{}", "(".repeat(8000), ")".repeat(8000));
27218                let result = parse_expression(&deep);
27219                assert!(
27220                    result
27221                        .as_ref()
27222                        .err()
27223                        .is_some_and(|message| message.contains("nested too deeply")),
27224                    "expected a depth-limit diagnostic, got {result:?}"
27225                );
27226                // A reasonably nested expression still parses (guard is generous).
27227                let ok = format!("{}task.done{}", "(".repeat(64), ")".repeat(64));
27228                assert!(parse_expression(&ok).is_ok(), "64-deep nesting must parse");
27229            })
27230            .expect("spawn")
27231            .join()
27232            .expect("nested-expression parse must not crash");
27233    }
27234
27235    #[test]
27236    fn parses_every_expression_form_with_pinned_precedence() {
27237        let cases = [
27238            // Literals.
27239            ("\"text\"", "\"text\""),
27240            ("42", "42"),
27241            ("2.5", "2.5"),
27242            ("true && false", "true && false"),
27243            ("task.note == null", "task.note == null"),
27244            // Paths and (map) indexing, including chained indexes.
27245            (
27246                "task.meta[\"a\"][\"b\"] == \"c\"",
27247                "task.meta[\"a\"][\"b\"] == \"c\"",
27248            ),
27249            // Unary and word-form connectives normalize to symbol operators.
27250            ("not task.done", "!task.done"),
27251            ("!!task.done", "!!task.done"),
27252            (
27253                "task.a and task.b or task.c",
27254                "(task.a && task.b) || task.c",
27255            ),
27256            // Prefix `not` binds looser than comparisons.
27257            ("not task.state == \"open\"", "!(task.state == \"open\")"),
27258            // Boolean precedence: `&&` over `||`, unary over both.
27259            (
27260                "task.a || task.b && !task.c",
27261                "task.a || (task.b && !task.c)",
27262            ),
27263            // Arithmetic precedence: `* /` over `+ -`, both over comparisons.
27264            ("1 + 2 * 3 == 7", "(1 + (2 * 3)) == 7"),
27265            ("10 - 4 / 2 >= 8", "(10 - (4 / 2)) >= 8"),
27266            // Comparisons/membership are ONE flat left-associative level:
27267            // `a == b < c` groups as `(a == b) < c` (the spec sketch draws
27268            // ordering tighter than equality; the divergence is only
27269            // observable in chains, which the type checker rejects anyway —
27270            // this pin documents the implemented grouping).
27271            ("task.a == task.b < task.c", "(task.a == task.b) < task.c"),
27272            // Ordering and membership forms.
27273            ("task.n <= 5 && task.n > 0", "(task.n <= 5) && (task.n > 0)"),
27274            ("\"x\" in task.labels", "\"x\" in task.labels"),
27275            ("\"x\" not in task.labels", "\"x\" not in task.labels"),
27276            // Presence proof form vs collection form.
27277            ("exists task.owner", "exists(task.owner)"),
27278            (
27279                "exists(Task where done == false)",
27280                "exists(Task where done == false)",
27281            ),
27282            // count/empty over arrays, queries, and effect queries.
27283            ("count([1, 2]) == 2", "count([1, 2]) == 2"),
27284            (
27285                "empty(Task where done == false)",
27286                "empty(Task where done == false)",
27287            ),
27288            ("empty(task.labels)", "empty(task.labels)"),
27289            ("empty([])", "empty([])"),
27290            (
27291                "count(effect kind agent.tell where target == \"w\") == 0",
27292                "count(effect kind agent.tell where target == \"w\") == 0",
27293            ),
27294            (
27295                "exists(effect kind schema.coerce)",
27296                "exists(effect kind schema.coerce)",
27297            ),
27298            // Array and object literals.
27299            ("[\"a\", \"b\"]", "[\"a\", \"b\"]"),
27300            (
27301                "{title task.title, meta {phase \"kernel\"}}",
27302                "{title task.title, meta {phase \"kernel\"}}",
27303            ),
27304        ];
27305
27306        for (source, expected) in cases {
27307            let expr = parse_expression(source).expect(source);
27308            assert_eq!(expr.to_snapshot(), expected, "for `{source}`");
27309        }
27310    }
27311
27312    /// Invalid expression syntax fails deterministically with an actionable
27313    /// message: dangling operators, unclosed delimiters, malformed queries,
27314    /// and unsupported call syntax (tracker "invalid syntax diagnostics").
27315    #[test]
27316    fn invalid_expression_syntax_produces_deterministic_errors() {
27317        let cases = [
27318            // Dangling operators.
27319            ("task.a ==", "expected expression"),
27320            ("1 +", "expected expression"),
27321            ("task.a && || task.b", "expected expression"),
27322            ("task.a == == 1", "expected expression"),
27323            ("!", "expected expression"),
27324            // Unclosed delimiters.
27325            ("(task.a == 1", "expected `)`"),
27326            ("task.labels[\"k\"", "expected `]`"),
27327            ("[1, 2", "expected `,`"),
27328            ("{a 1", "expected object field name"),
27329            // Trailing garbage after a complete expression.
27330            ("task.a == 1)", "unexpected token"),
27331            ("in task.a", "unexpected token"),
27332            // Malformed queries and paths.
27333            ("count(Task where", "expected expression"),
27334            ("count(Task where )", "expected expression"),
27335            ("task..a", "expected field name after `.`"),
27336            ("task.a not b", "expected `in` after `not`"),
27337        ];
27338
27339        for (source, expected) in cases {
27340            let message = parse_expression(source).expect_err(source);
27341            assert!(
27342                message.contains(expected),
27343                "`{source}` -> `{message}` (expected `{expected}`)"
27344            );
27345        }
27346    }
27347
27348    /// Syntax errors inside real guards and assertions surface as compile
27349    /// diagnostics naming the rule/assertion, not as panics or silent drops.
27350    #[test]
27351    fn guard_and_assertion_syntax_errors_surface_with_context() {
27352        let source = r#"
27353workflow BadExpressionSyntax
27354
27355class Task {
27356  title string
27357}
27358
27359assert count(Task) ==
27360
27361rule dangling_guard
27362  when Task as task where task.title ==
27363=> {
27364}
27365"#;
27366
27367        let compiled = compile_program(source);
27368        assert!(compiled.ir.is_none());
27369        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27370            .message
27371            .contains("invalid assertion expression: expected expression")));
27372        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27373            .message
27374            .contains("rule `dangling_guard` has invalid guard expression: expected expression")));
27375    }
27376
27377    /// `empty` static checks: arity, scalar rejection, and the optional rule —
27378    /// `empty(Optional<T>)` is defined only when `empty(T)` is, so an optional
27379    /// string is accepted while an optional int is rejected.
27380    #[test]
27381    fn validates_empty_call_arity_and_optional_arguments() {
27382        let source = r#"
27383workflow EmptyCallChecks
27384
27385class Task {
27386  title string
27387  note string?
27388  age int?
27389  done bool
27390}
27391
27392assert empty() == true
27393assert empty(["a"], ["b"]) == true
27394assert count(Task where empty(note) && empty(title)) == 0
27395assert count(Task where empty(age)) == 0
27396assert count(Task where empty(done)) == 0
27397"#;
27398
27399        let compiled = compile_program(source);
27400        assert!(compiled.ir.is_none());
27401        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27402            .message
27403            .contains("calls `empty` with 0 arguments, expected 1")));
27404        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27405            .message
27406            .contains("calls `empty` with 2 arguments, expected 1")));
27407        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27408            .message
27409            .contains("calls `empty` with unsupported optional argument type `int?`")));
27410        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27411            .message
27412            .contains("calls `empty` with unsupported argument type `bool`")));
27413        // The accepted forms produce no `empty` diagnostics of their own.
27414        assert!(!compiled
27415            .diagnostics
27416            .iter()
27417            .any(|diagnostic| diagnostic.message.contains("`string?`")));
27418        assert!(!compiled.diagnostics.iter().any(|diagnostic| diagnostic
27419            .message
27420            .contains("unsupported argument type `string`")));
27421    }
27422
27423    /// The formatter never re-renders expression text: guards and assertions
27424    /// keep the author's exact spelling (word-form connectives, redundant
27425    /// parentheses), and formatting is idempotent over them. Rendered
27426    /// parenthesization belongs to the IR snapshot (see
27427    /// `parses_every_expression_form_with_pinned_precedence`), never to `fmt`.
27428    #[test]
27429    fn format_preserves_expression_source_text_verbatim() {
27430        let source = r#"workflow FormatExpressions
27431
27432class Task {
27433  title string
27434  done bool
27435}
27436
27437assert count(Task where (done == false) and not done) == 0
27438
27439rule keep_spelling
27440  when Task as task where not task.done and (task.title == "a" or task.title == "b")
27441=> {
27442}
27443"#;
27444
27445        let formatted = format_program(source);
27446        assert_eq!(formatted.diagnostics, Vec::new());
27447        let once = formatted.formatted.expect("formats");
27448        assert!(once.contains(
27449            "when Task as task where not task.done and (task.title == \"a\" or task.title == \"b\")"
27450        ));
27451        assert!(once.contains("assert count(Task where (done == false) and not done) == 0"));
27452
27453        let twice = format_program(&once).formatted.expect("formats twice");
27454        assert_eq!(once, twice, "formatting is idempotent over expressions");
27455    }
27456
27457    #[test]
27458    fn validates_expected_schema_object_and_map_record_fields() {
27459        let source = r#"
27460workflow ObjectRecordFields
27461
27462class Owner {
27463  name string
27464}
27465
27466class Task {
27467  title string
27468  metadata map<string>
27469  owner Owner?
27470}
27471
27472rule seed
27473  when started
27474=> {
27475    record Task {
27476    title "Implement object literals"
27477    metadata { phase "kernel" }
27478    owner { name "Ada" }
27479  }
27480
27481  record Task {
27482    title "Implement multiline object literals"
27483    metadata {
27484      phase "kernel"
27485      owner "Ada"
27486    }
27487    owner {
27488      name "Ada"
27489    }
27490  }
27491}
27492"#;
27493
27494        let compiled = compile_program(source);
27495        assert_eq!(compiled.diagnostics, Vec::new());
27496        assert!(compiled.ir.is_some());
27497    }
27498
27499    #[test]
27500    fn rejects_invalid_expected_schema_object_and_map_record_fields() {
27501        let source = r#"
27502workflow BadObjectRecordFields
27503
27504class Owner {
27505  name string
27506}
27507
27508class Task {
27509  metadata map<string>
27510  owner Owner
27511}
27512
27513rule seed
27514  when started
27515=> {
27516  record Task {
27517    metadata { phase 1 }
27518    owner { alias "Ada" }
27519  }
27520}
27521
27522rule bad_guard
27523  when Task as task where { phase "kernel" } == task.metadata
27524=> {
27525}
27526"#;
27527
27528        let compiled = compile_program(source);
27529        assert!(compiled.ir.is_none());
27530        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27531            .message
27532            .contains("field `Task.metadata` expects `string`")));
27533        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27534            .message
27535            .contains("class `Owner` has no field `alias`")));
27536        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27537            .message
27538            .contains("missing required object field `Owner.name`")));
27539        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27540            .message
27541            .contains("compares incompatible expression types")));
27542    }
27543
27544    #[test]
27545    fn rejects_invalid_expression_types() {
27546        let source = r#"
27547workflow BadExpressionTypes
27548
27549class Task {
27550  title string
27551  labels map<string>
27552  priority int
27553  ready bool
27554}
27555
27556rule non_bool_guard
27557  when Task as task where task.priority
27558=> {
27559}
27560
27561rule bad_ordering
27562  when Task as task where task.title > "abc"
27563=> {
27564}
27565
27566rule bad_membership
27567  when Task as task where task.title in task.priority
27568=> {
27569}
27570
27571rule bad_equality
27572  when Task as task where task.ready == "yes"
27573=> {
27574}
27575
27576rule bad_array
27577  when Task as task where task.title in ["abc", 1]
27578=> {
27579}
27580
27581rule bad_map_key
27582  when Task as task where task.labels[1] == "urgent"
27583=> {
27584}
27585
27586rule bad_map_membership
27587  when Task as task where 1 in task.labels
27588=> {
27589}
27590"#;
27591
27592        let compiled = compile_program(source);
27593        assert!(compiled.ir.is_none());
27594        assert!(compiled
27595            .diagnostics
27596            .iter()
27597            .any(|diagnostic| diagnostic.message.contains("non-boolean guard expression")));
27598        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27599            .message
27600            .contains("orders non-orderable expression values")));
27601        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27602            .message
27603            .contains("uses membership against a non-array/non-map expression")));
27604        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27605            .message
27606            .contains("compares incompatible expression types")));
27607        assert!(compiled
27608            .diagnostics
27609            .iter()
27610            .any(|diagnostic| diagnostic.message.contains("mixed-type array literal")));
27611        assert!(compiled
27612            .diagnostics
27613            .iter()
27614            .any(|diagnostic| diagnostic.message.contains("non-string key")));
27615        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27616            .message
27617            .contains("map membership with a non-string key")));
27618    }
27619
27620    #[test]
27621    fn validates_duration_and_time_ordering_and_literals() {
27622        let source = r#"
27623workflow DurationTimeExpressions
27624
27625class Window {
27626  elapsed duration
27627  limit duration
27628  opened_at time
27629  due_at time
27630}
27631
27632assert exists(Window where elapsed < limit)
27633assert exists(Window where opened_at <= due_at)
27634
27635rule seed
27636  when started
27637=> {
27638  record Window {
27639    elapsed "PT30.5M"
27640    limit "PT1.25H"
27641    opened_at "2026-05-29T10:00:00.250-04:00"
27642    due_at "2026-05-29T14:00:00.500Z"
27643  }
27644}
27645"#;
27646
27647        let compiled = compile_program(source);
27648        assert_eq!(compiled.diagnostics, Vec::new());
27649        assert!(compiled.ir.is_some());
27650    }
27651
27652    #[test]
27653    fn rejects_invalid_duration_and_time_literals() {
27654        let source = r#"
27655workflow BadDurationTimeExpressions
27656
27657class Window {
27658  elapsed duration
27659  limit duration
27660  opened_at time
27661}
27662
27663rule seed
27664  when started
27665=> {
27666  record Window {
27667    elapsed "thirty minutes"
27668    limit "P1M"
27669    opened_at "morning"
27670  }
27671}
27672"#;
27673
27674        let compiled = compile_program(source);
27675        assert!(compiled.ir.is_none());
27676        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27677            .message
27678            .contains("field `Window.elapsed` has invalid duration literal")));
27679        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27680            .message
27681            .contains("field `Window.limit` has invalid duration literal")));
27682        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27683            .message
27684            .contains("field `Window.opened_at` has invalid time literal")));
27685    }
27686
27687    #[test]
27688    fn validates_assertion_expression_types_and_paths() {
27689        let source = r#"
27690workflow BadAssertions
27691
27692class Task {
27693  provider "codex" | "claude"
27694  priority int
27695}
27696
27697assert count(Task where provider == "bad") == 0
27698assert count(Task)
27699assert missing.root == "value"
27700"#;
27701
27702        let compiled = compile_program(source);
27703        assert!(compiled.ir.is_none());
27704        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27705            .message
27706            .contains("assertion compares finite-domain value to unknown `bad`")));
27707        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27708            .message
27709            .contains("assertion has non-boolean assertion expression")));
27710        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27711            .message
27712            .contains("assertion has unknown expression root `missing`")));
27713    }
27714
27715    #[test]
27716    fn validates_symmetric_finite_domain_literals_and_unknown_guard_roots() {
27717        let source = r#"
27718workflow SymmetricFiniteDomain
27719
27720enum ReviewStatus {
27721  Accept
27722  Revise
27723}
27724
27725class Task {
27726  status ReviewStatus
27727  provider "codex" | "claude"
27728}
27729
27730rule symmetric_literal
27731  when Task as task where "bad" == task.provider
27732=> {
27733}
27734
27735rule enum_variant_literal
27736  when Task as task where Missing == task.status
27737=> {
27738}
27739
27740rule array_membership_literal
27741  when Task as task where task.provider in ["codex", "bad"]
27742=> {
27743}
27744
27745rule implicit_query_head
27746  when Task as task where exists(Task where status == Missing)
27747=> {
27748}
27749
27750rule unknown_root
27751  when Task as task where other.provider == "codex"
27752=> {
27753}
27754"#;
27755
27756        let compiled = compile_program(source);
27757        assert!(compiled.ir.is_none());
27758        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27759            .message
27760            .contains("finite-domain value to unknown `bad`")));
27761        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27762            .message
27763            .contains("finite-domain value to unknown `Missing`")));
27764        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27765            .message
27766            .contains("unknown expression root `other`")));
27767    }
27768
27769    #[test]
27770    fn rejects_unsatisfiable_finite_domain_expression_relations() {
27771        let source = r#"
27772workflow UnsatisfiableFiniteDomains
27773
27774class Task {
27775  provider "codex" | "claude"
27776  route "cache" | "coerce"
27777}
27778
27779rule disjoint_equality
27780  when Task as task where task.provider == task.route
27781=> {
27782}
27783
27784rule empty_membership
27785  when Task as task where task.provider in []
27786=> {
27787}
27788
27789rule excluded_membership
27790  when Task as task where task.provider not in ["codex", "claude"]
27791=> {
27792}
27793"#;
27794
27795        let compiled = compile_program(source);
27796        assert!(compiled.ir.is_none());
27797        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27798            .message
27799            .contains("statically unsatisfiable finite-domain equality")));
27800        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27801            .message
27802            .contains("statically unsatisfiable finite-domain membership")));
27803        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27804            .message
27805            .contains("statically unsatisfiable finite-domain exclusion")));
27806    }
27807
27808    #[test]
27809    fn accepts_map_index_expressions() {
27810        let source = r#"
27811workflow MapIndex
27812
27813class Task {
27814  labels map<string>
27815}
27816
27817rule route
27818  when Task as task where task.labels["priority"] == "high"
27819=> {
27820}
27821"#;
27822
27823        let compiled = compile_program(source);
27824        assert_eq!(compiled.diagnostics, Vec::new());
27825        let ir = compiled.ir.expect("valid ir");
27826        let guard = ir.rules[0].whens[0].guard.as_ref().expect("guard");
27827        assert_eq!(
27828            guard.expr.to_snapshot(),
27829            "task.labels[\"priority\"] == \"high\""
27830        );
27831    }
27832
27833    #[test]
27834    fn lowers_deterministic_ir_snapshot() {
27835        let source = r#"
27836workflow Snapshot
27837
27838
27839class Work {
27840  title string
27841  files string[]
27842  state "open" | "done"
27843}
27844
27845class Result {
27846  title string
27847  files string[]
27848}
27849
27850agent worker {
27851  provider fixture
27852  profile "repo-writer"
27853  capacity 2
27854  skills ["repo-user"]
27855}
27856
27857rule start
27858  when Work as work
27859=>
27860{
27861  tell worker "{{ work.title }}"
27862}
27863
27864rule finish
27865  when Result as result
27866=>
27867{
27868  record Work {
27869    title result.title
27870    files result.files
27871    state "done"
27872  }
27873}
27874"#;
27875
27876        let compiled = compile_program(source);
27877        assert_eq!(compiled.diagnostics, Vec::new());
27878        let ir = match compiled.ir {
27879            Some(ir) => ir,
27880            None => panic!("expected lowered IR"),
27881        };
27882
27883        let expected = "\
27884workflow Snapshot
27885schemas
27886  class Work
27887    title string
27888    files array<string>
27889    state union<literal<\"open\"> | literal<\"done\">>
27890  class Result
27891    title string
27892    files array<string>
27893agents
27894  agent worker harness=<fallback> provider=fixture profile=repo-writer capacity=2 skills=[repo-user] capabilities=[] tools=[]
27895rules
27896  rule start
27897    when Work as work
27898    reads
27899      schema:Work
27900    effects
27901      effect1 kind=agent.tell binding=- key=ef8ed2edd19578a222b6ad56ea1bffa8
27902    body_hash 96e148d2d421ee97960ef8f3edf61db9
27903  rule finish
27904    when Result as result
27905    reads
27906      schema:Result
27907    writes
27908      schema:Work
27909    body_hash 4a5ce925842b5b0bceb64bf33361d523
27910rule_dependencies
27911  finish --schema:Work--> start
27912";
27913
27914        assert_eq!(ir.to_snapshot(), expected);
27915    }
27916
27917    #[test]
27918    fn example_ir_snapshots_are_stable() {
27919        let examples = [
27920            (
27921                include_str!("../../../examples/minimal-noop.whip"),
27922                include_str!("../../../examples/minimal-noop.ir"),
27923            ),
27924            (
27925                include_str!("../../../examples/queue-worker-with-review.whip"),
27926                include_str!("../../../examples/queue-worker-with-review.ir"),
27927            ),
27928            (
27929                include_str!("../../../examples/circuit-breaker.whip"),
27930                include_str!("../../../examples/circuit-breaker.ir"),
27931            ),
27932            (
27933                include_str!("../../../examples/coerce-branch.whip"),
27934                include_str!("../../../examples/coerce-branch.ir"),
27935            ),
27936            (
27937                include_str!("../../../examples/terminal-output-union.whip"),
27938                include_str!("../../../examples/terminal-output-union.ir"),
27939            ),
27940            (
27941                include_str!("../../../examples/triage-chain.whip"),
27942                include_str!("../../../examples/triage-chain.ir"),
27943            ),
27944            (
27945                include_str!("../../../examples/incident-router.whip"),
27946                include_str!("../../../examples/incident-router.ir"),
27947            ),
27948            (
27949                include_str!("../../../examples/expression-kernel.whip"),
27950                include_str!("../../../examples/expression-kernel.ir"),
27951            ),
27952            (
27953                include_str!("../../../examples/multi-agent-bounded-concurrency.whip"),
27954                include_str!("../../../examples/multi-agent-bounded-concurrency.ir"),
27955            ),
27956            // `openclaw-lite` now imports the external `memory` package, so it
27957            // only compiles clean with a `whip.lock`; this parser-level snapshot
27958            // has no package resolution. Its IR stability is covered by the
27959            // lock-aware `dev_openclaw_lite_observes_heartbeat_and_files_work`.
27960            (
27961                include_str!("../../../examples/scheduled-escalation.whip"),
27962                include_str!("../../../examples/scheduled-escalation.ir"),
27963            ),
27964            (
27965                include_str!("../../../examples/event-bridge.whip"),
27966                include_str!("../../../examples/event-bridge.ir"),
27967            ),
27968            (
27969                include_str!("../../../examples/reusable-review-pattern.whip"),
27970                include_str!("../../../examples/reusable-review-pattern.ir"),
27971            ),
27972            (
27973                include_str!("../../../examples/reusable-action-chain.whip"),
27974                include_str!("../../../examples/reusable-action-chain.ir"),
27975            ),
27976            (
27977                include_str!("../../../examples/exec-json-ingest.whip"),
27978                include_str!("../../../examples/exec-json-ingest.ir"),
27979            ),
27980            (
27981                include_str!("../../../examples/deterministic-validation.whip"),
27982                include_str!("../../../examples/deterministic-validation.ir"),
27983            ),
27984            (
27985                include_str!("../../../examples/autoresearch-lite.whip"),
27986                include_str!("../../../examples/autoresearch-lite.ir"),
27987            ),
27988            (
27989                include_str!("../../../examples/gastown-lite.whip"),
27990                include_str!("../../../examples/gastown-lite.ir"),
27991            ),
27992            (
27993                include_str!("../../../examples/ralph.whip"),
27994                include_str!("../../../examples/ralph.ir"),
27995            ),
27996        ];
27997
27998        for (source, expected) in examples {
27999            let compiled = compile_program(source);
28000            assert_eq!(compiled.diagnostics, Vec::new());
28001            let ir = match compiled.ir {
28002                Some(ir) => ir,
28003                None => panic!("expected lowered IR"),
28004            };
28005            assert_eq!(ir.to_snapshot(), expected);
28006        }
28007    }
28008
28009    #[test]
28010    fn revision_examples_compile() {
28011        let examples = [
28012            (
28013                include_str!("../../../examples/revision-ticket-v1.whip"),
28014                Some("RevisionTicket"),
28015            ),
28016            (
28017                include_str!("../../../examples/revision-ticket-v2.whip"),
28018                Some("RevisionTicket"),
28019            ),
28020            (
28021                include_str!("../../../examples/revision-repair-planner.whip"),
28022                Some("RevisionRepairPlanner"),
28023            ),
28024            (
28025                include_str!("../../../examples/revision-running-cancel.whip"),
28026                Some("RevisionRunningCancel"),
28027            ),
28028            (
28029                include_str!("../../../examples/revision-parent-child.whip"),
28030                Some("ParentRevisionExample"),
28031            ),
28032            (
28033                include_str!("../../../examples/revision-validation-approval.whip"),
28034                Some("RevisionValidation"),
28035            ),
28036        ];
28037
28038        for (source, root) in examples {
28039            let compiled = compile_program_with_root(source, root);
28040            assert_eq!(compiled.diagnostics, Vec::new());
28041            assert!(compiled.ir.is_some());
28042        }
28043    }
28044
28045    #[test]
28046    fn rejects_unknown_schema_references() {
28047        let source = include_str!("../../../examples/invalid/unknown-schema.whip");
28048        let compiled = compile_program(source);
28049
28050        assert!(compiled.ir.is_none());
28051        assert_eq!(compiled.diagnostics.len(), 2);
28052        assert!(compiled
28053            .diagnostics
28054            .iter()
28055            .any(|diagnostic| diagnostic.message == "unknown schema reference `MissingStatus`"));
28056        assert!(compiled
28057            .diagnostics
28058            .iter()
28059            .any(|diagnostic| diagnostic.message == "unknown schema reference `MissingOutput`"));
28060    }
28061
28062    #[test]
28063    fn emit_of_undeclared_signal_is_flagged_statically() {
28064        // Q1: `emit signal <name>` must name a declared signal, caught at
28065        // `whip check` time (previously only at runtime effect-input building).
28066        let source = "\
28067workflow Emitter
28068signal trigger.x { peer string }
28069signal known.sig { note string }
28070rule relay
28071  when trigger.x as t
28072=> {
28073  emit signal known.sig to t.peer { note \"ok\" } as a
28074  emit signal unknown.sig to t.peer { note \"bad\" } as b
28075}
28076";
28077        let compiled = compile_program(source);
28078        let messages: Vec<&str> = compiled
28079            .diagnostics
28080            .iter()
28081            .map(|d| d.message.as_str())
28082            .collect();
28083        assert!(
28084            messages.contains(&"rule `relay` emits undeclared signal `unknown.sig`"),
28085            "expected the undeclared-emit diagnostic, got {messages:?}"
28086        );
28087        // The declared emit (`known.sig`) is not flagged.
28088        assert!(
28089            !messages
28090                .iter()
28091                .any(|m| m.contains("emits undeclared signal `known.sig`")),
28092            "a declared signal must not be flagged: {messages:?}"
28093        );
28094    }
28095
28096    #[test]
28097    fn source_emit_of_undeclared_signal_is_flagged_statically() {
28098        // The ingestion mirror of the reaction-side check: a `source` whose
28099        // `emit <signal>` names an undeclared signal is caught at `whip check`
28100        // time, so it cannot silently admit a fact no rule can react to.
28101        let source = "\
28102workflow SourceEmit
28103signal ingress.known { text string }
28104source file as feed {
28105  path \"/tmp/x.txt\"
28106  observe as obs
28107  emit ingress.unknown { text obs.line }
28108}
28109output result Done
28110class Done { ok string }
28111rule react
28112  when ingress.known as k
28113=> {
28114  complete result { ok \"ok\" }
28115}
28116";
28117        let compiled = compile_program(source);
28118        let messages: Vec<&str> = compiled
28119            .diagnostics
28120            .iter()
28121            .map(|d| d.message.as_str())
28122            .collect();
28123        assert!(
28124            messages.contains(&"source `feed` emits undeclared signal `ingress.unknown`"),
28125            "expected the undeclared source-emit diagnostic, got {messages:?}"
28126        );
28127        // A source emitting a declared signal must not be flagged.
28128        let ok_source = source.replace("ingress.unknown", "ingress.known");
28129        let ok_compiled = compile_program(&ok_source);
28130        assert!(
28131            !ok_compiled
28132                .diagnostics
28133                .iter()
28134                .any(|d| d.message.contains("emits undeclared signal")),
28135            "a declared signal must not be flagged: {:?}",
28136            ok_compiled
28137                .diagnostics
28138                .iter()
28139                .map(|d| d.message.as_str())
28140                .collect::<Vec<_>>()
28141        );
28142    }
28143
28144    #[test]
28145    fn source_emit_of_unknown_observation_field_is_flagged_statically() {
28146        // A source emit maps the observation record by name; reading a field the
28147        // source kind's observation doesn't have (here a `file` source has only
28148        // line/line_index/path) is caught at `whip check`, not silently mapped
28149        // null at runtime.
28150        let source = "\
28151workflow BadObs
28152signal ingress.fed { text string }
28153source file as feed {
28154  path \"/tmp/x.txt\"
28155  observe as obs
28156  emit ingress.fed { text obs.nosuchfield }
28157}
28158output result Done
28159class Done { ok string }
28160rule react
28161  when ingress.fed as f
28162=> { complete result { ok \"ok\" } }
28163";
28164        let messages: Vec<String> = compile_program(source)
28165            .diagnostics
28166            .iter()
28167            .map(|d| d.message.clone())
28168            .collect();
28169        assert!(
28170            messages.iter().any(|m| m.contains(
28171                "emit reads `obs.nosuchfield`, but a `file` source's observation has no field"
28172            )),
28173            "expected the unknown-observation-field diagnostic, got {messages:?}"
28174        );
28175        // A valid observation field (`line`) must not be flagged.
28176        let ok = source.replace("obs.nosuchfield", "obs.line");
28177        assert!(
28178            !compile_program(&ok)
28179                .diagnostics
28180                .iter()
28181                .any(|d| d.message.contains("observation has no field")),
28182            "a valid observation field must not be flagged"
28183        );
28184    }
28185
28186    #[test]
28187    fn renew_of_unacquired_lease_is_flagged_statically() {
28188        // `renew <binding>` must name a lease acquired in the same rule; a typo
28189        // (here `nonexistent`, no matching acquire) is caught at `whip check`
28190        // rather than renewing nothing at runtime.
28191        let source = "\
28192workflow RenewTypo
28193class Ticket { id string }
28194class Done { ok string }
28195lease slot { shared key Ticket slots 1 ttl 60s }
28196output result Done
28197table seed as Ticket [ { id \"t\" } ]
28198rule grab
28199  when Ticket as t
28200=> {
28201  acquire slot for t.id until ttl as held
28202  after held held {
28203    renew nonexistent until 300s as r
28204    complete result { ok \"ok\" }
28205  }
28206}
28207";
28208        let messages: Vec<String> = compile_program(source)
28209            .diagnostics
28210            .iter()
28211            .map(|d| d.message.clone())
28212            .collect();
28213        assert!(
28214            messages
28215                .iter()
28216                .any(|m| m.contains("renews unbound coordination binding `nonexistent`")),
28217            "expected the unbound-renew diagnostic, got {messages:?}"
28218        );
28219        // Renewing the actually-acquired lease (`held`) must not be flagged.
28220        let ok = source.replace("renew nonexistent", "renew held");
28221        assert!(
28222            !compile_program(&ok)
28223                .diagnostics
28224                .iter()
28225                .any(|d| d.message.contains("renews unbound coordination binding")),
28226            "renewing an acquired lease must not be flagged"
28227        );
28228    }
28229
28230    /// T3: a `renew <binding>` naming a same-rule `claim ... as <binding>` CLAIM
28231    /// binding is accepted at `whip check` (it lowers to `tracker.renew`), and
28232    /// the parser emits the `tracker.renew` effect kind for it.
28233    #[test]
28234    fn renew_of_a_claim_binding_is_accepted_and_lowers_to_tracker_renew() {
28235        let source = "\
28236workflow RenewClaim
28237class Done { ok string }
28238tracker backlog { provider builtin }
28239output result Done
28240rule work
28241  when backlog has ready issue as issue
28242=> {
28243  claim issue ttl 1h as active
28244
28245  after active succeeds {
28246    renew active as renewed
28247  }
28248
28249  after renewed succeeds {
28250    complete result { ok \"ok\" }
28251  }
28252}
28253";
28254        let compiled = compile_program(source);
28255        assert!(
28256            !compiled
28257                .diagnostics
28258                .iter()
28259                .any(|d| d.message.contains("renews unbound coordination binding")),
28260            "renewing a claim binding must not be flagged: {:?}",
28261            compiled.diagnostics
28262        );
28263        let ir = compiled.ir.expect("compiles");
28264        let work = ir
28265            .rules
28266            .iter()
28267            .find(|rule| rule.name == "work")
28268            .expect("work rule");
28269        assert!(
28270            work.metadata
28271                .effects
28272                .iter()
28273                .any(|effect| effect.kind == IrEffectKind::TrackerRenew),
28274            "a renew of a claim binding lowers to TrackerRenew: {:?}",
28275            work.metadata.effects
28276        );
28277        // And the coord `LeaseRenew` kind was NOT emitted for it.
28278        assert!(
28279            !work
28280                .metadata
28281                .effects
28282                .iter()
28283                .any(|effect| effect.kind == IrEffectKind::LeaseRenew),
28284            "no lease.renew for a claim-binding renew: {:?}",
28285            work.metadata.effects
28286        );
28287    }
28288
28289    #[test]
28290    fn release_of_each_bound_coordination_form_is_accepted() {
28291        // A `release <x>` legitimately names ONE OF THREE referents
28292        // (spec/coordination.md): (1) an `acquire ... as <x>` lease binding,
28293        // (2) a `claim <x> as ...` item, or (3) a `when <queue> has ready ... as
28294        // <x>` work-item binding. All three must pass `whip check`; the naive
28295        // acquire∪claim-binding model false-positives (2) and (3).
28296        let source = "\
28297workflow ReleaseForms
28298class Ticket { id string }
28299class Done { ok string }
28300lease slot { key Ticket slots 1 ttl 60s }
28301tracker backlog { provider builtin }
28302agent worker { provider fixture profile \"repo-writer\" capacity 1 }
28303output result Done
28304rule work
28305  when backlog has ready issue as issue
28306  when worker is available
28307=> {
28308  acquire slot for issue.id until ttl as held
28309  claim issue as active_claim
28310  after active_claim succeeds {
28311    release held
28312    release issue
28313    complete result { ok \"done\" }
28314  }
28315  after active_claim fails {
28316    release held
28317    complete result { ok \"gave-up\" }
28318  }
28319}
28320";
28321        let messages: Vec<String> = compile_program(source)
28322            .diagnostics
28323            .iter()
28324            .map(|d| d.message.clone())
28325            .collect();
28326        assert!(
28327            !messages
28328                .iter()
28329                .any(|m| m.contains("releases unbound coordination item")),
28330            "no bound release form must be flagged, got {messages:?}"
28331        );
28332    }
28333
28334    #[test]
28335    fn release_of_unbound_coordination_item_is_flagged_statically() {
28336        // `release <x>` where `<x>` matches none of the three legitimate
28337        // referents (no acquire binding, no claim item, no when-has-ready
28338        // work-item binding) is a genuinely-unbound release: caught at
28339        // `whip check` rather than releasing nothing at runtime.
28340        let source = "\
28341workflow ReleaseTypo
28342class Done { ok string }
28343tracker backlog { provider builtin }
28344agent worker { provider fixture profile \"repo-writer\" capacity 1 }
28345output result Done
28346rule work
28347  when backlog has ready issue as issue
28348  when worker is available
28349=> {
28350  claim issue as active_claim
28351  after active_claim succeeds {
28352    release nonexistent
28353    complete result { ok \"done\" }
28354  }
28355  after active_claim fails {
28356    complete result { ok \"gave-up\" }
28357  }
28358}
28359";
28360        let messages: Vec<String> = compile_program(source)
28361            .diagnostics
28362            .iter()
28363            .map(|d| d.message.clone())
28364            .collect();
28365        assert!(
28366            messages
28367                .iter()
28368                .any(|m| m.contains("releases unbound coordination item `nonexistent`")),
28369            "expected the unbound-release diagnostic, got {messages:?}"
28370        );
28371        // Releasing the actually-bound work item (`issue`) must not be flagged.
28372        let ok = source.replace("release nonexistent", "release issue");
28373        assert!(
28374            !compile_program(&ok)
28375                .diagnostics
28376                .iter()
28377                .any(|d| d.message.contains("releases unbound coordination item")),
28378            "releasing a bound work item must not be flagged"
28379        );
28380    }
28381
28382    #[test]
28383    fn http_source_url_must_have_an_http_scheme() {
28384        // The runtime GETs the url, so a scheme-less url is caught at check time.
28385        let source = "\
28386workflow BadUrl
28387signal ingress.fed { text string }
28388source http as feed {
28389  url \"not-a-real-url\"
28390  observe as obs
28391  emit ingress.fed { text obs.item }
28392}
28393output result Done
28394class Done { ok string }
28395rule react
28396  when ingress.fed as f
28397=> { complete result { ok \"ok\" } }
28398";
28399        let messages: Vec<String> = compile_program(source)
28400            .diagnostics
28401            .iter()
28402            .map(|d| d.message.clone())
28403            .collect();
28404        assert!(
28405            messages
28406                .iter()
28407                .any(|m| m.contains("is not an absolute http(s) URL")),
28408            "expected the http url-scheme diagnostic, got {messages:?}"
28409        );
28410        // A well-formed https url must not be flagged.
28411        let ok = source.replace("not-a-real-url", "https://example.com/feed.json");
28412        assert!(
28413            !compile_program(&ok)
28414                .diagnostics
28415                .iter()
28416                .any(|d| d.message.contains("absolute http(s) URL")),
28417            "a well-formed url must not be flagged"
28418        );
28419    }
28420
28421    /// I2a `watch` clause (spec/std-ingress.md): a `file` source in occurrence
28422    /// mode parses, lowers `watch` into the IR, formats idempotently, and the
28423    /// watch-mode observation schema (`path`/`content_hash`/`watch`) governs
28424    /// the emit-field check.
28425    #[test]
28426    fn file_watch_source_parses_lowers_and_formats() {
28427        let source = "\
28428workflow WatchSource
28429signal drop.arrived { path string digest string }
28430source file as drops {
28431  watch \"./drops/*.json\"
28432  observe as obs
28433  emit drop.arrived {
28434    path obs.path
28435    digest obs.content_hash
28436  }
28437}
28438output result Done
28439class Done { ok string }
28440rule react
28441  when drop.arrived as f
28442=> { complete result { ok \"ok\" } }
28443";
28444        let compiled = compile_program(source);
28445        let ir = compiled.ir.expect("watch source compiles");
28446        let decl = ir.sources.first().expect("source lowered");
28447        assert!(decl.is_file);
28448        assert_eq!(decl.watch.as_deref(), Some("./drops/*.json"));
28449        assert_eq!(decl.path, None);
28450        let formatted = format_program(source).formatted.expect("formats");
28451        assert!(
28452            formatted.contains("watch \"./drops/*.json\""),
28453            "{formatted}"
28454        );
28455        assert_eq!(
28456            format_program(&formatted).formatted.expect("reformats"),
28457            formatted,
28458            "fmt must be idempotent over the watch clause"
28459        );
28460        // The watch-mode observation record has no `line`: reading it is the
28461        // unknown-observation-field diagnostic.
28462        let bad = source.replace("digest obs.content_hash", "digest obs.line");
28463        let messages: Vec<String> = compile_program(&bad)
28464            .diagnostics
28465            .iter()
28466            .map(|d| d.message.clone())
28467            .collect();
28468        assert!(
28469            messages
28470                .iter()
28471                .any(|m| m.contains("observation has no field `line`")),
28472            "watch-mode emit must validate against the occurrence schema, got {messages:?}"
28473        );
28474    }
28475
28476    /// I2a closed clause sets (spec/std-ingress.md "Static checks" #3): `watch`
28477    /// is file-only; `path`/`watch` are exclusive; a file source must declare
28478    /// one of them.
28479    #[test]
28480    fn file_source_clause_set_is_closed() {
28481        let watch_on_clock = "\
28482workflow BadWatch
28483signal tick.fired { at time }
28484source clock as ticker {
28485  every 5m
28486  missed skip
28487  watch \"./drops/*.json\"
28488  observe as tick
28489  emit tick.fired { at tick.scheduled_at }
28490}
28491output result Done
28492class Done { ok string }
28493rule react
28494  when tick.fired as f
28495=> { complete result { ok \"ok\" } }
28496";
28497        let messages: Vec<String> = compile_program(watch_on_clock)
28498            .diagnostics
28499            .iter()
28500            .map(|d| d.message.clone())
28501            .collect();
28502        assert!(
28503            messages
28504                .iter()
28505                .any(|m| m.contains("`watch` clause but its provider is `clock`")),
28506            "watch outside `file` must be rejected, got {messages:?}"
28507        );
28508
28509        let both_modes = "\
28510workflow BothModes
28511signal ingress.fed { text string }
28512source file as feed {
28513  path \"./inbox.txt\"
28514  watch \"./drops/*.txt\"
28515  observe as obs
28516  emit ingress.fed { text obs.path }
28517}
28518output result Done
28519class Done { ok string }
28520rule react
28521  when ingress.fed as f
28522=> { complete result { ok \"ok\" } }
28523";
28524        let messages: Vec<String> = compile_program(both_modes)
28525            .diagnostics
28526            .iter()
28527            .map(|d| d.message.clone())
28528            .collect();
28529        assert!(
28530            messages
28531                .iter()
28532                .any(|m| m.contains("declares both `path` and `watch`")),
28533            "path+watch must be rejected as exclusive modes, got {messages:?}"
28534        );
28535
28536        let neither = "\
28537workflow Neither
28538signal ingress.fed { text string }
28539source file as feed {
28540  observe as obs
28541  emit ingress.fed { text obs.line }
28542}
28543output result Done
28544class Done { ok string }
28545rule react
28546  when ingress.fed as f
28547=> { complete result { ok \"ok\" } }
28548";
28549        let messages: Vec<String> = compile_program(neither)
28550            .diagnostics
28551            .iter()
28552            .map(|d| d.message.clone())
28553            .collect();
28554        assert!(
28555            messages
28556                .iter()
28557                .any(|m| m.contains("requires a `path` or `watch` clause")),
28558            "a file source with neither mode must be rejected, got {messages:?}"
28559        );
28560    }
28561
28562    /// I2a `dedup` clause (spec/std-ingress.md): parses and lowers to
28563    /// `dedup_field` for http/file(line) sources; rejected where no provider
28564    /// delivery identity applies (clock, watch mode) and when the path does
28565    /// not name a known observation field off the observe binding.
28566    #[test]
28567    fn dedup_clause_parses_lowers_and_validates() {
28568        let source = "\
28569workflow DedupSource
28570signal ingress.ingested { text string }
28571source http as feed {
28572  url \"https://example.com/feed.json\"
28573  dedup obs.item
28574  observe as obs
28575  emit ingress.ingested { text obs.item }
28576}
28577output result Done
28578class Done { ok string }
28579rule react
28580  when ingress.ingested as f
28581=> { complete result { ok \"ok\" } }
28582";
28583        let compiled = compile_program(source);
28584        let ir = compiled.ir.expect("dedup source compiles");
28585        let decl = ir.sources.first().expect("source lowered");
28586        assert!(decl.is_http);
28587        assert_eq!(decl.dedup_field.as_deref(), Some("item"));
28588        let formatted = format_program(source).formatted.expect("formats");
28589        assert!(formatted.contains("dedup obs.item"), "{formatted}");
28590        assert_eq!(
28591            format_program(&formatted).formatted.expect("reformats"),
28592            formatted,
28593            "fmt must be idempotent over the dedup clause"
28594        );
28595
28596        // Unknown observation field: the admission key would be silently null.
28597        let bad_field = source.replace("dedup obs.item", "dedup obs.delivery_id");
28598        let messages: Vec<String> = compile_program(&bad_field)
28599            .diagnostics
28600            .iter()
28601            .map(|d| d.message.clone())
28602            .collect();
28603        assert!(
28604            messages
28605                .iter()
28606                .any(|m| m.contains("observation has no field `delivery_id`")),
28607            "an unknown dedup field must be rejected, got {messages:?}"
28608        );
28609
28610        // Wrong binding root: dedup reads the observation, nothing else.
28611        let bad_root = source.replace("dedup obs.item", "dedup other.item");
28612        let messages: Vec<String> = compile_program(&bad_root)
28613            .diagnostics
28614            .iter()
28615            .map(|d| d.message.clone())
28616            .collect();
28617        assert!(
28618            messages
28619                .iter()
28620                .any(|m| m.contains("`dedup` must name one observation field")),
28621            "a dedup path off a foreign binding must be rejected, got {messages:?}"
28622        );
28623
28624        // No provider delivery identity on a clock source.
28625        let on_clock = "\
28626workflow DedupClock
28627signal tick.fired { at time }
28628source clock as ticker {
28629  every 5m
28630  missed skip
28631  dedup tick.occurrence_id
28632  observe as tick
28633  emit tick.fired { at tick.scheduled_at }
28634}
28635output result Done
28636class Done { ok string }
28637rule react
28638  when tick.fired as f
28639=> { complete result { ok \"ok\" } }
28640";
28641        let messages: Vec<String> = compile_program(on_clock)
28642            .diagnostics
28643            .iter()
28644            .map(|d| d.message.clone())
28645            .collect();
28646        assert!(
28647            messages
28648                .iter()
28649                .any(|m| m.contains("declares a `dedup` clause but its provider is `clock`")),
28650            "dedup on a clock source must be rejected, got {messages:?}"
28651        );
28652    }
28653
28654    #[test]
28655    fn enum_variants_are_one_per_line() {
28656        // Pasted prose / a forgotten `#` would otherwise become variants that
28657        // pollute the domain (and reach coerce output schemas).
28658        let garbage = compile_program(
28659            "workflow T\noutput result D\nclass D { a string }\nenum E {\n  A\n  utterly unknown line\n  B\n}\nrule r\n  when started\n=> {\n  complete result { a \"x\" }\n}\n",
28660        );
28661        assert!(garbage.diagnostics.iter().any(|d| d
28662            .message
28663            .contains("on the same line as the previous variant")));
28664        // Payload-carrying variants (sum types) are untouched.
28665        let payload = compile_program(
28666            "workflow T\noutput result D\nclass D { a string }\nenum E {\n  Approved {\n    score float\n  }\n  Rejected {\n    reason string\n  }\n}\nrule r\n  when started\n=> {\n  complete result { a \"x\" }\n}\n",
28667        );
28668        assert!(
28669            !payload
28670                .diagnostics
28671                .iter()
28672                .any(|d| d.message.contains("same line")),
28673            "{:?}",
28674            payload.diagnostics
28675        );
28676    }
28677
28678    #[test]
28679    fn unknown_std_package_import_is_a_check_error() {
28680        // std resolution is a built-in registry: a typo'd name can never
28681        // resolve, so it must not silently import nothing.
28682        let typo = compile_program(
28683            "use std.coercon\nworkflow T\noutput result D\nclass D { a string }\nrule r\n  when started\n=> {\n  complete result { a \"x\" }\n}\n",
28684        );
28685        assert!(typo
28686            .diagnostics
28687            .iter()
28688            .any(|d| d.message.contains("unknown standard package `std.coercon`")));
28689        // Every real std package name passes.
28690        for id in STD_PACKAGE_IDS {
28691            let ok = compile_program(&format!(
28692                "use {id}\nworkflow T\noutput result D\nclass D {{ a string }}\nrule r\n  when started\n=> {{\n  complete result {{ a \"x\" }}\n}}\n"
28693            ));
28694            assert!(
28695                !ok.diagnostics
28696                    .iter()
28697                    .any(|d| d.message.contains("unknown standard package")),
28698                "{id}: {:?}",
28699                ok.diagnostics
28700            );
28701        }
28702        // Non-std package names stay lock-resolved, not registry-checked.
28703        let nonstd = compile_program(
28704            "use notes\nworkflow T\noutput result D\nclass D { a string }\nrule r\n  when started\n=> {\n  complete result { a \"x\" }\n}\n",
28705        );
28706        assert!(!nonstd
28707            .diagnostics
28708            .iter()
28709            .any(|d| d.message.contains("unknown standard package")));
28710    }
28711
28712    #[test]
28713    fn blockless_coerce_desugars_to_the_prompt_clause() {
28714        let block = compile_program(
28715            "use std.coercion\nworkflow T\noutput result D\nclass D { a string }\nclass V { a string }\ncoerce f(x string) -> V {\n  prompt \"\"\"markdown\n  Judge {{ x }}. {{ ctx.output_format }}\n  \"\"\"\n}\nrule r\n  when started\n=> {\n  coerce f(\"t\") as v\n  after v succeeds as o {\n    complete result { a o.a }\n  }\n}\n",
28716        );
28717        let blockless = compile_program(
28718            "use std.coercion\nworkflow T\noutput result D\nclass D { a string }\nclass V { a string }\ncoerce f(x string) -> V \"\"\"markdown\nJudge {{ x }}. {{ ctx.output_format }}\n\"\"\"\nrule r\n  when started\n=> {\n  coerce f(\"t\") as v\n  after v succeeds as o {\n    complete result { a o.a }\n  }\n}\n",
28719        );
28720        let block_ir = block.ir.expect("block form compiles");
28721        let blockless_ir = blockless.ir.expect("blockless form compiles");
28722        // Same declaration, same clause: the sugar prefixes `prompt ` onto the
28723        // raw string, so the body is the block form's sole clause.
28724        assert!(blockless_ir.coerces[0].body.starts_with("prompt \"\"\""));
28725        assert_eq!(block_ir.coerces[0].name, blockless_ir.coerces[0].name);
28726    }
28727
28728    #[test]
28729    fn coerce_body_is_a_validated_clause_list() {
28730        let source = |body: &str| {
28731            format!(
28732                "use std.coercion\nworkflow T\noutput result D\nclass D {{ a string }}\nclass V {{ a string }}\ncoerce f(x string) -> V {{\n{body}\n}}\nrule r\n  when started\n=> {{\n  coerce f(\"t\") as v\n  after v succeeds as o {{\n    complete result {{ a o.a }}\n  }}\n}}\n"
28733            )
28734        };
28735        // A typo'd `promt` — previously a SILENT no-prompt coercion — errors.
28736        let typo = compile_program(&source("  promt \"Judge {{ x }}\""));
28737        assert!(typo
28738            .diagnostics
28739            .iter()
28740            .any(|d| d.message.contains("unknown coerce field `promt`")));
28741        // Unknown fields error.
28742        let junk = compile_program(&source("  prompt \"Judge {{ x }}\"\n  mystery field"));
28743        assert!(junk
28744            .diagnostics
28745            .iter()
28746            .any(|d| d.message.contains("unknown coerce field `mystery`")));
28747        // The legal clauses pass: prompt (multi-line, annotated) + provider,
28748        // with comments and blank lines interleaved.
28749        let legal = compile_program(&source(
28750            "  # choose the fixture\n  provider fixture\n\n  prompt \"\"\"markdown\n  Judge {{ x }}.\n  {{ ctx.output_format }}\n  \"\"\"",
28751        ));
28752        assert!(
28753            !legal
28754                .diagnostics
28755                .iter()
28756                .any(|d| d.message.contains("unknown coerce field")),
28757            "{:?}",
28758            legal.diagnostics
28759        );
28760        // A malformed provider clause errors.
28761        let malformed = compile_program(&source("  provider one two\n  prompt \"Judge {{ x }}\""));
28762        assert!(malformed
28763            .diagnostics
28764            .iter()
28765            .any(|d| d.message.contains("malformed `provider` clause")));
28766    }
28767
28768    #[test]
28769    fn rejects_invalid_agent_declarations() {
28770        let source = include_str!("../../../examples/invalid/bad-agent.whip");
28771        let compiled = compile_program(source);
28772
28773        assert!(compiled.ir.is_none());
28774        assert_eq!(compiled.diagnostics.len(), 3);
28775        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
28776            .message
28777            .contains("capacity must be greater than zero")));
28778        assert!(compiled
28779            .diagnostics
28780            .iter()
28781            .any(|diagnostic| diagnostic.message.contains("more than once")));
28782        assert!(compiled
28783            .diagnostics
28784            .iter()
28785            .any(|diagnostic| diagnostic.message.contains("unknown agent field")));
28786        // A missing profile is no longer a diagnostic: it defaults to the
28787        // least-authority `no-repo` (agent-declaration defaults, 2026-07-21).
28788        assert!(!compiled
28789            .diagnostics
28790            .iter()
28791            .any(|diagnostic| diagnostic.message.contains("missing a profile")));
28792    }
28793
28794    #[test]
28795    fn rejects_invalid_effect_dependencies() {
28796        let source = include_str!("../../../examples/invalid/bad-effect-graph.whip");
28797        let compiled = compile_program(source);
28798
28799        assert!(compiled.ir.is_none());
28800        assert!(compiled
28801            .diagnostics
28802            .iter()
28803            .any(|diagnostic| diagnostic.message.contains("unknown effect binding")));
28804        assert!(compiled
28805            .diagnostics
28806            .iter()
28807            .any(|diagnostic| diagnostic.message.contains("unsupported `after`")));
28808    }
28809
28810    #[test]
28811    fn accepts_equality_guards_in_when_clauses() {
28812        let source = r#"
28813workflow GuardGuess
28814
28815class WorkItem {
28816  state "ready" | "blocked"
28817}
28818
28819rule branch
28820  when WorkItem as item where item.state == "ready"
28821=> {
28822}
28823"#;
28824        let compiled = compile_program(source);
28825        assert_eq!(compiled.diagnostics, Vec::new());
28826        let ir = compiled.ir.expect("valid ir");
28827        let when = ir
28828            .rules
28829            .iter()
28830            .flat_map(|rule| &rule.whens)
28831            .find(|when| when.source == "WorkItem as item where item.state == \"ready\"")
28832            .expect("guarded when");
28833        assert_eq!(when.pattern, "WorkItem as item");
28834        assert_eq!(
28835            when.guard.as_ref().map(|guard| guard.expr.to_snapshot()),
28836            Some("item.state == \"ready\"".to_owned())
28837        );
28838    }
28839
28840    #[test]
28841    fn lowers_assertions_to_parsed_expression_ir() {
28842        let source = r#"
28843workflow AssertionGuess
28844
28845class Result {
28846  status "done"
28847}
28848
28849assert count(Result where status == "done") == 1
28850"#;
28851        let compiled = compile_program(source);
28852        assert_eq!(compiled.diagnostics, Vec::new());
28853        let ir = compiled.ir.expect("valid ir");
28854        let assertion = ir.assertions.first().expect("assertion");
28855        assert_eq!(
28856            assertion.expr.source,
28857            "count(Result where status == \"done\") == 1"
28858        );
28859        assert_eq!(
28860            assertion.expr.expr.to_snapshot(),
28861            "count(Result where status == \"done\") == 1"
28862        );
28863        assert_eq!(
28864            assertion
28865                .projection_reads
28866                .iter()
28867                .map(IrProjectionRead::to_snapshot)
28868                .collect::<Vec<_>>(),
28869            vec!["fact:Result where status == \"done\""]
28870        );
28871    }
28872
28873    #[test]
28874    fn lowers_guard_projection_reads_to_rule_metadata() {
28875        let source = r#"
28876workflow GuardProjection
28877
28878class Task {
28879  status "ready"
28880}
28881
28882class Result {
28883  status "done"
28884}
28885
28886rule gated
28887  when Task as task where exists(Result where status == "done")
28888=> {
28889}
28890"#;
28891        let compiled = compile_program(source);
28892        assert_eq!(compiled.diagnostics, Vec::new());
28893        let ir = compiled.ir.expect("valid ir");
28894        let rule = ir.rules.first().expect("rule");
28895        assert_eq!(
28896            rule.metadata
28897                .projection_reads
28898                .iter()
28899                .map(IrProjectionRead::to_snapshot)
28900                .collect::<Vec<_>>(),
28901            vec!["fact:Result where status == \"done\""]
28902        );
28903    }
28904
28905    fn read_codec_program(format: &str) -> String {
28906        format!(
28907            r#"
28908workflow ReadBody
28909
28910output result Result
28911
28912class Result {{
28913  status string
28914}}
28915
28916file store project_files {{
28917  root "./data"
28918}}
28919
28920rule pick
28921  when started
28922=> {{
28923  read {format} from project_files at "note.md" as fileResult
28924  after fileResult succeeds as result {{
28925    complete result {{
28926      status "ok"
28927    }}
28928  }}
28929}}
28930"#
28931        )
28932    }
28933
28934    #[test]
28935    fn read_accepts_text_and_markdown_body_codecs() {
28936        for format in ["text", "markdown"] {
28937            let compiled = compile_program(&read_codec_program(format));
28938            assert_eq!(
28939                compiled.diagnostics,
28940                Vec::new(),
28941                "`read {format}` compiles clean"
28942            );
28943            assert!(compiled.ir.is_some(), "`read {format}` produces IR");
28944        }
28945    }
28946
28947    #[test]
28948    fn read_rejects_structured_and_binary_codecs() {
28949        // Structured codecs are the `import` surface; `bytes` is a deferred read
28950        // codec. `read` decodes only body formats in v0.
28951        for format in ["json", "jsonl", "csv", "bytes"] {
28952            let compiled = compile_program(&read_codec_program(format));
28953            assert!(
28954                compiled
28955                    .diagnostics
28956                    .iter()
28957                    .any(|diagnostic| diagnostic.message.contains("not supported")),
28958                "`read {format}` is rejected with a diagnostic; got {:?}",
28959                compiled.diagnostics
28960            );
28961        }
28962    }
28963
28964    fn write_program(format: &str, mode_clause: &str) -> String {
28965        format!(
28966            r#"
28967workflow WriteBody
28968
28969output result Result
28970
28971class Result {{
28972  status string
28973}}
28974
28975file store out_files {{
28976  root "./data"
28977  allow write ["**"]
28978}}
28979
28980rule pick
28981  when started
28982=> {{
28983  write {format} to out_files at "report.md" {{
28984    body "hello"
28985    {mode_clause}
28986  }} as written
28987  after written succeeds as result {{
28988    complete result {{
28989      status "ok"
28990    }}
28991  }}
28992}}
28993"#
28994        )
28995    }
28996
28997    #[test]
28998    fn write_accepts_text_and_markdown_with_explicit_mode() {
28999        for format in ["text", "markdown"] {
29000            let compiled = compile_program(&write_program(format, "mode create"));
29001            assert_eq!(
29002                compiled.diagnostics,
29003                Vec::new(),
29004                "`write {format}` with an explicit mode compiles clean"
29005            );
29006            assert!(compiled.ir.is_some(), "`write {format}` produces IR");
29007        }
29008    }
29009
29010    #[test]
29011    fn write_rejects_structured_codecs() {
29012        for format in ["json", "csv", "bytes"] {
29013            let compiled = compile_program(&write_program(format, "mode create"));
29014            assert!(
29015                compiled
29016                    .diagnostics
29017                    .iter()
29018                    .any(|diagnostic| diagnostic.message.contains("not supported")),
29019                "`write {format}` is rejected; got {:?}",
29020                compiled.diagnostics
29021            );
29022        }
29023    }
29024
29025    #[test]
29026    fn write_requires_an_explicit_mode() {
29027        // "No silent overwrite": omitting the mode is a check error.
29028        let compiled = compile_program(&write_program("text", ""));
29029        assert!(
29030            compiled
29031                .diagnostics
29032                .iter()
29033                .any(|diagnostic| diagnostic.message.contains("explicit `mode`")),
29034            "`write` without a mode is rejected; got {:?}",
29035            compiled.diagnostics
29036        );
29037    }
29038
29039    #[test]
29040    fn write_rejects_unknown_mode() {
29041        let compiled = compile_program(&write_program("text", "mode clobber"));
29042        assert!(
29043            compiled
29044                .diagnostics
29045                .iter()
29046                .any(|diagnostic| diagnostic.message.contains("unknown write mode")),
29047            "an unknown write mode is rejected; got {:?}",
29048            compiled.diagnostics
29049        );
29050    }
29051
29052    fn import_program(format: &str) -> String {
29053        format!(
29054            r#"
29055workflow ImportRows
29056
29057output result Result
29058
29059class Result {{
29060  status string
29061}}
29062
29063class IssueRow {{
29064  title string
29065  priority string
29066}}
29067
29068file store data_files {{
29069  root "./data"
29070}}
29071
29072rule pick
29073  when started
29074=> {{
29075  import {format} IssueRow from data_files at "issues.in" as imported
29076  after imported succeeds as r {{
29077    complete result {{
29078      status "ok"
29079    }}
29080  }}
29081}}
29082"#
29083        )
29084    }
29085
29086    #[test]
29087    fn import_accepts_structured_codecs_and_lowers_to_file_import() {
29088        for format in ["jsonl", "json", "csv"] {
29089            let compiled = compile_program(&import_program(format));
29090            assert_eq!(
29091                compiled.diagnostics,
29092                Vec::new(),
29093                "`import {format}` compiles clean"
29094            );
29095            let ir = compiled.ir.expect("import produces IR");
29096            let rule = ir.rules.first().expect("rule");
29097            assert!(
29098                rule.metadata
29099                    .effects
29100                    .iter()
29101                    .any(|effect| effect.kind == IrEffectKind::FileImport),
29102                "`import {format}` lowers to a file.import effect"
29103            );
29104        }
29105    }
29106
29107    #[test]
29108    fn import_rejects_unsupported_codecs() {
29109        // `import` decodes structured row codecs only; body/binary formats are
29110        // not import surfaces.
29111        for format in ["xml", "text", "markdown", "bytes"] {
29112            let compiled = compile_program(&import_program(format));
29113            assert!(
29114                compiled
29115                    .diagnostics
29116                    .iter()
29117                    .any(|diagnostic| diagnostic.message.contains("not supported")),
29118                "`import {format}` is rejected; got {:?}",
29119                compiled.diagnostics
29120            );
29121        }
29122    }
29123
29124    #[test]
29125    fn class_field_key_annotation_lowers_and_rejects_duplicates() {
29126        let single = compile_program(
29127            r#"
29128workflow Keyed
29129
29130class Row {
29131  id string @key
29132  title string
29133}
29134"#,
29135        );
29136        assert_eq!(
29137            single.diagnostics,
29138            Vec::new(),
29139            "single `@key` compiles clean"
29140        );
29141        let ir = single.ir.expect("ir");
29142        let class = ir
29143            .schemas
29144            .iter()
29145            .find_map(|schema| match schema {
29146                IrSchema::Class(class) if class.name == "Row" => Some(class),
29147                _ => None,
29148            })
29149            .expect("Row class");
29150        let key_fields = class
29151            .fields
29152            .iter()
29153            .filter(|field| field.is_key)
29154            .map(|field| field.name.as_str())
29155            .collect::<Vec<_>>();
29156        assert_eq!(key_fields, vec!["id"], "the `@key` field is recorded");
29157
29158        let dual = compile_program(
29159            r#"
29160workflow Keyed
29161
29162class Row {
29163  a string @key
29164  b string @key
29165}
29166"#,
29167        );
29168        assert!(
29169            dual.diagnostics
29170                .iter()
29171                .any(|diagnostic| diagnostic.message.contains("more than one `@key`")),
29172            "two `@key` fields are rejected; got {:?}",
29173            dual.diagnostics
29174        );
29175    }
29176
29177    #[test]
29178    fn single_line_terminal_block_validates_its_fields() {
29179        // Regression: `complete <name> { <field> }` on one line was reported as
29180        // missing the field, because the terminal-block extractor only captured
29181        // subsequent lines (a single-line block has brace-delta 0). Both the
29182        // single-line and multi-line forms must validate identically.
29183        for body in [
29184            "  complete result { status \"ok\" }",
29185            "  complete result {\n    status \"ok\"\n  }",
29186        ] {
29187            let source = format!(
29188                r#"
29189workflow S
29190
29191output result Result
29192
29193class Result {{
29194  status string
29195}}
29196
29197rule go
29198  when started
29199=> {{
29200{body}
29201}}
29202"#
29203            );
29204            let compiled = compile_program(&source);
29205            assert!(
29206                !compiled
29207                    .diagnostics
29208                    .iter()
29209                    .any(|diagnostic| diagnostic.message.contains("missing required field")),
29210                "terminal block validates its field; got {:?}",
29211                compiled.diagnostics
29212            );
29213        }
29214    }
29215
29216    #[test]
29217    fn action_declaration_parses_and_is_inert_until_expansion() {
29218        // DR-0023 slice 1: an `action` template parses (typed params + a block
29219        // body) and lowers away cleanly (inert until call-site expansion in
29220        // slice 2), so a program declaring an unused action compiles with no
29221        // diagnostics.
29222        let compiled = compile_program(
29223            r#"
29224workflow A
29225
29226output result Result
29227
29228class Result {
29229  status string
29230}
29231
29232class Task {
29233  name string
29234}
29235
29236action do_it(task Task, label string) {
29237  record Result {
29238    status label
29239  }
29240}
29241
29242rule go
29243  when started
29244=> {
29245  complete result {
29246    status "ok"
29247  }
29248}
29249"#,
29250        );
29251        assert_eq!(
29252            compiled.diagnostics,
29253            Vec::new(),
29254            "an unused action declaration compiles clean"
29255        );
29256        let ir = compiled.ir.expect("program with an action lowers");
29257        // The action is a template consumed before lowering — it is not a runtime
29258        // construct, so it leaves no rule/schema behind beyond the workflow's own.
29259        assert!(
29260            ir.rules.iter().any(|rule| rule.name == "go"),
29261            "the ordinary rule still lowers alongside the action template"
29262        );
29263    }
29264
29265    #[test]
29266    fn accepts_typed_case_branches_in_rule_bodies() {
29267        let source = r#"
29268workflow CaseGuess
29269
29270enum ReviewStatus {
29271  Accept
29272  Revise
29273  Blocked
29274}
29275
29276class Review {
29277  status ReviewStatus
29278  assignee string?
29279}
29280
29281class Routed {
29282  status ReviewStatus
29283}
29284
29285rule route
29286  when Review as review
29287=> {
29288  case review.status {
29289    Accept => {
29290      record Routed {
29291        status Accept
29292      }
29293    }
29294    Revise => {
29295      record Routed {
29296        status Revise
29297      }
29298    }
29299    Blocked => {
29300      record Routed {
29301        status Blocked
29302      }
29303    }
29304  }
29305
29306  case review.assignee {
29307    Some owner => {
29308      record Routed {
29309        status Accept
29310      }
29311    }
29312    None => {
29313      record Routed {
29314        status Blocked
29315      }
29316    }
29317  }
29318}
29319"#;
29320        let compiled = compile_program(source);
29321        assert_eq!(compiled.diagnostics, Vec::new());
29322        assert!(compiled.ir.is_some());
29323    }
29324
29325    #[test]
29326    fn accepts_terminal_output_case_branches_inside_completes_after() {
29327        let source = r#"
29328workflow TerminalCaseGuess
29329
29330class WorkItem {
29331  title string
29332}
29333
29334class MessageClassification {
29335  summary string
29336}
29337
29338class Routed {
29339  branch string
29340  detail string
29341}
29342
29343coerce classifyMessage(title string) -> MessageClassification {
29344  prompt "Classify"
29345}
29346
29347rule classify
29348  when WorkItem as item
29349=> {
29350  coerce classifyMessage(item.title) as classification
29351
29352  after classification completes {
29353    case classification {
29354      Completed as result => {
29355        record Routed {
29356          branch "completed"
29357          detail result.summary
29358        }
29359      }
29360      Failed as failure => {
29361        record Routed {
29362          branch "failed"
29363          detail failure.reason
29364        }
29365      }
29366      TimedOut as timeout => {
29367        record Routed {
29368          branch "timed_out"
29369          detail timeout.summary
29370        }
29371      }
29372      Cancelled as cancel => {
29373        record Routed {
29374          branch "cancelled"
29375          detail cancel.summary
29376        }
29377      }
29378    }
29379  }
29380}
29381"#;
29382        let compiled = compile_program(source);
29383        assert_eq!(compiled.diagnostics, Vec::new());
29384        assert!(compiled.ir.is_some());
29385    }
29386
29387    #[test]
29388    fn accepts_terminal_output_case_as_binding_form() {
29389        // `Completed as result` is accepted alongside the space form `Completed result`
29390        // and binds/narrows identically (Stage 1b surface unification).
29391        let source = r#"
29392workflow T
29393
29394class WorkItem { title string }
29395class MessageClassification { summary string }
29396class Routed {
29397  branch string
29398  detail string
29399}
29400
29401coerce classifyMessage(title string) -> MessageClassification {
29402  prompt "Classify"
29403}
29404
29405rule classify
29406  when WorkItem as item
29407=> {
29408  coerce classifyMessage(item.title) as classification
29409
29410  after classification completes {
29411    case classification {
29412      Completed as result => {
29413        record Routed { branch "completed" detail result.summary }
29414      }
29415      Failed as failure => {
29416        record Routed { branch "failed" detail failure.reason }
29417      }
29418      TimedOut as timeout => {
29419        record Routed { branch "timed_out" detail timeout.summary }
29420      }
29421      Cancelled as cancel => {
29422        record Routed { branch "cancelled" detail cancel.summary }
29423      }
29424    }
29425  }
29426}
29427"#;
29428        let compiled = compile_program(source);
29429        assert_eq!(compiled.diagnostics, Vec::new());
29430        assert!(compiled.ir.is_some());
29431    }
29432
29433    #[test]
29434    fn accepts_after_times_out_branch_and_types_payload_alias() {
29435        let source = r#"
29436workflow TimedOutBranch
29437
29438class WorkItem {
29439  title string
29440}
29441
29442class MessageClassification {
29443  summary string
29444}
29445
29446class Routed {
29447  branch string
29448  detail string
29449}
29450
29451coerce classifyMessage(title string) -> MessageClassification {
29452  prompt "Classify"
29453}
29454
29455rule classify
29456  when WorkItem as item
29457=> {
29458  coerce classifyMessage(item.title) as classification
29459
29460  after classification times out as t {
29461    record Routed {
29462      branch "timed_out"
29463      detail t.summary
29464    }
29465  }
29466}
29467"#;
29468        let compiled = compile_program(source);
29469        assert_eq!(compiled.diagnostics, Vec::new());
29470        assert!(compiled.ir.is_some());
29471    }
29472
29473    #[test]
29474    fn accepts_after_cancelled_branch_and_types_payload_alias() {
29475        let source = r#"
29476workflow CancelledBranch
29477
29478class WorkItem {
29479  title string
29480}
29481
29482class MessageClassification {
29483  summary string
29484}
29485
29486class Routed {
29487  branch string
29488  detail string
29489}
29490
29491coerce classifyMessage(title string) -> MessageClassification {
29492  prompt "Classify"
29493}
29494
29495rule classify
29496  when WorkItem as item
29497=> {
29498  coerce classifyMessage(item.title) as classification
29499
29500  after classification cancelled as c {
29501    record Routed {
29502      branch "cancelled"
29503      detail c.summary
29504    }
29505  }
29506}
29507"#;
29508        let compiled = compile_program(source);
29509        assert_eq!(compiled.diagnostics, Vec::new());
29510        assert!(compiled.ir.is_some());
29511    }
29512
29513    #[test]
29514    fn rejects_invalid_after_predicate_during_compilation() {
29515        let source = r#"
29516workflow BadPredicate
29517
29518class WorkItem {
29519  title string
29520}
29521
29522class MessageClassification {
29523  summary string
29524}
29525
29526class Routed {
29527  branch string
29528}
29529
29530coerce classifyMessage(title string) -> MessageClassification {
29531  prompt "Classify"
29532}
29533
29534rule classify
29535  when WorkItem as item
29536=> {
29537  coerce classifyMessage(item.title) as classification
29538
29539  after classification explodes {
29540    record Routed {
29541      branch "boom"
29542    }
29543  }
29544}
29545"#;
29546        let compiled = compile_program(source);
29547        assert!(compiled.diagnostics.iter().any(|d| d
29548            .message
29549            .contains("unsupported `after` predicate `explodes`")));
29550    }
29551
29552    #[test]
29553    fn lowers_terminal_output_case_branches_to_typed_ir() {
29554        let source = include_str!("../../../examples/terminal-output-union.whip");
29555        let compiled = compile_program(source);
29556        assert_eq!(compiled.diagnostics, Vec::new());
29557        let ir = compiled.ir.expect("expected lowered IR");
29558        let rule = ir
29559            .rules
29560            .iter()
29561            .find(|rule| rule.name == "classify_work")
29562            .expect("rule");
29563
29564        let terminal_output = rule
29565            .metadata
29566            .terminal_outputs
29567            .iter()
29568            .find(|output| output.binding == "classification")
29569            .expect("terminal output");
29570        assert_eq!(terminal_output.alternatives.len(), 4);
29571        assert_eq!(
29572            terminal_output.alternatives[0].payload_type,
29573            IrType::Ref("Classification".to_owned())
29574        );
29575        assert_eq!(
29576            rule.metadata
29577                .terminal_branches
29578                .iter()
29579                .map(|branch| {
29580                    (
29581                        branch.tag.as_deref().unwrap_or("_"),
29582                        branch.binding.as_deref().unwrap_or("-"),
29583                    )
29584                })
29585                .collect::<Vec<_>>(),
29586            vec![
29587                ("Completed", "result"),
29588                ("Failed", "failure"),
29589                ("TimedOut", "timeout"),
29590                ("Cancelled", "cancel"),
29591            ]
29592        );
29593    }
29594
29595    #[test]
29596    fn rejects_terminal_payload_fields_outside_refined_tag_schema() {
29597        let source = r#"
29598workflow BadTerminalPayload
29599
29600class WorkItem {
29601  title string
29602}
29603
29604class Classification {
29605  summary string
29606}
29607
29608class TerminalRoute {
29609  detail string
29610}
29611
29612coerce classify(title string) -> Classification {
29613  prompt "Classify"
29614}
29615
29616rule classify_work
29617  when WorkItem as item
29618=> {
29619  coerce classify(item.title) as classification
29620
29621  after classification completes {
29622    case classification {
29623      Completed as result => {
29624        record TerminalRoute {
29625          detail result.reason
29626        }
29627      }
29628      Failed as failure => {
29629        record TerminalRoute {
29630          detail failure.reason
29631        }
29632      }
29633      TimedOut as timeout => {
29634        record TerminalRoute {
29635          detail timeout.summary
29636        }
29637      }
29638      Cancelled as cancel => {
29639        record TerminalRoute {
29640          detail cancel.summary
29641        }
29642      }
29643    }
29644  }
29645}
29646"#;
29647        let compiled = compile_program(source);
29648        assert!(compiled.ir.is_none());
29649        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29650            .message
29651            .contains("invalid field path `result.reason`")));
29652    }
29653
29654    #[test]
29655    fn rejects_invalid_terminal_output_case_branches() {
29656        let source = r#"
29657workflow BadTerminalCaseGuess
29658
29659class WorkItem {
29660  title string
29661}
29662
29663class MessageClassification {
29664  summary string
29665}
29666
29667coerce classifyMessage(title string) -> MessageClassification {
29668  prompt "Classify"
29669}
29670
29671rule classify
29672  when WorkItem as item
29673=> {
29674  coerce classifyMessage(item.title) as classification
29675
29676  after classification completes {
29677    case classification {
29678      Success as result => {
29679      }
29680      Completed as result => {
29681      }
29682    }
29683  }
29684}
29685"#;
29686        let compiled = compile_program(source);
29687        assert!(compiled.ir.is_none());
29688        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29689            .message
29690            .contains("terminal-output case pattern cannot be `Success`")));
29691        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29692            .message
29693            .contains("non-exhaustive terminal-output case; missing Failed, TimedOut, Cancelled")));
29694    }
29695
29696    /// Source for terminal-output case tests: a coerce whose `Completed` payload
29697    /// is `MessageClassification`, matched in an `after ... completes` case. The
29698    /// `{cases}` placeholder is filled per test.
29699    fn terminal_case_program(cases: &str) -> String {
29700        format!(
29701            r#"
29702workflow TerminalCaseMatrix
29703
29704class WorkItem {{
29705  title string
29706}}
29707
29708class MessageClassification {{
29709  summary string
29710}}
29711
29712class Routed {{
29713  branch string
29714}}
29715
29716coerce classifyMessage(title string) -> MessageClassification {{
29717  prompt "Classify"
29718}}
29719
29720rule classify
29721  when WorkItem as item
29722=> {{
29723  coerce classifyMessage(item.title) as classification
29724
29725  after classification completes {{
29726    case classification {{
29727{cases}
29728    }}
29729  }}
29730}}
29731"#
29732        )
29733    }
29734
29735    #[test]
29736    fn accepts_guarded_terminal_case_branch_referencing_refined_payload() {
29737        // Regression: a `where` guard on a tagged terminal branch must be able to
29738        // read the tag-refined payload binding (`result.summary`). It was wrongly
29739        // rejected as an unknown root because `validate_case_blocks` could not
29740        // bind the terminal payload into the guard scope.
29741        let source = terminal_case_program(
29742            "      Completed as result where result.summary == \"ok\" => { record Routed { branch \"ok\" } }\n      _ => { record Routed { branch \"other\" } }",
29743        );
29744        let compiled = compile_program(&source);
29745        assert_eq!(
29746            compiled.diagnostics,
29747            Vec::new(),
29748            "{:?}",
29749            compiled.diagnostics
29750        );
29751        assert!(compiled.ir.is_some());
29752    }
29753
29754    #[test]
29755    fn rejects_terminal_case_guard_referencing_unknown_payload_field() {
29756        let source = terminal_case_program(
29757            "      Completed as result where result.nonexistent == \"ok\" => { record Routed { branch \"ok\" } }\n      _ => { record Routed { branch \"other\" } }",
29758        );
29759        let compiled = compile_program(&source);
29760        assert!(compiled.ir.is_none());
29761        assert!(
29762            compiled.diagnostics.iter().any(|d| d
29763                .message
29764                .contains("schema `MessageClassification` has no field `nonexistent`")),
29765            "{:?}",
29766            compiled.diagnostics
29767        );
29768    }
29769
29770    #[test]
29771    fn rejects_non_boolean_terminal_case_guard() {
29772        let source = terminal_case_program(
29773            "      Completed as result where result.summary => { record Routed { branch \"ok\" } }\n      _ => { record Routed { branch \"other\" } }",
29774        );
29775        let compiled = compile_program(&source);
29776        assert!(compiled.ir.is_none());
29777        assert!(
29778            compiled
29779                .diagnostics
29780                .iter()
29781                .any(|d| d.message.contains("non-boolean case guard expression")),
29782            "{:?}",
29783            compiled.diagnostics
29784        );
29785    }
29786
29787    #[test]
29788    fn rejects_duplicate_terminal_output_case_tag() {
29789        let source = terminal_case_program(
29790            "      Completed as result => { record Routed { branch \"a\" } }\n      Completed as other => { record Routed { branch \"b\" } }\n      Failed as failure => { record Routed { branch \"f\" } }\n      TimedOut as timeout => { record Routed { branch \"t\" } }\n      Cancelled as cancel => { record Routed { branch \"c\" } }",
29791        );
29792        let compiled = compile_program(&source);
29793        assert!(compiled.ir.is_none());
29794        assert!(
29795            compiled.diagnostics.iter().any(|d| d
29796                .message
29797                .contains("duplicate unguarded terminal-output case pattern `Completed`")),
29798            "{:?}",
29799            compiled.diagnostics
29800        );
29801    }
29802
29803    #[test]
29804    fn rejects_terminal_output_case_branch_without_payload_binding() {
29805        let source = terminal_case_program(
29806            "      Completed => { record Routed { branch \"a\" } }\n      Failed as failure => { record Routed { branch \"f\" } }\n      TimedOut as timeout => { record Routed { branch \"t\" } }\n      Cancelled as cancel => { record Routed { branch \"c\" } }",
29807        );
29808        let compiled = compile_program(&source);
29809        assert!(compiled.ir.is_none());
29810        assert!(
29811            compiled.diagnostics.iter().any(|d| d
29812                .message
29813                .contains("malformed terminal-output case pattern `Completed`")),
29814            "{:?}",
29815            compiled.diagnostics
29816        );
29817    }
29818
29819    #[test]
29820    fn rejects_invalid_case_branch_patterns() {
29821        let source = r#"
29822workflow BadCaseGuess
29823
29824enum ReviewStatus {
29825  Accept
29826  Revise
29827}
29828
29829class Review {
29830  status ReviewStatus
29831  assignee string
29832}
29833
29834rule route
29835  when Review as review
29836=> {
29837  case review.status {
29838    Missing => {
29839    }
29840  }
29841
29842  case review.assignee {
29843    Some owner => {
29844    }
29845  }
29846}
29847"#;
29848        let compiled = compile_program(source);
29849        assert!(compiled.ir.is_none());
29850        let missing = compiled
29851            .diagnostics
29852            .iter()
29853            .find(|diagnostic| {
29854                diagnostic
29855                    .message
29856                    .contains("enum `ReviewStatus` has no variant `Missing`")
29857            })
29858            .expect("missing variant diagnostic");
29859        assert!(source[missing.span.start..missing.span.end].contains("Mis"));
29860        let some = compiled
29861            .diagnostics
29862            .iter()
29863            .find(|diagnostic| {
29864                diagnostic
29865                    .message
29866                    .contains("uses `Some` for a non-optional case")
29867            })
29868            .expect("some diagnostic");
29869        assert!(source[some.span.start..some.span.end].contains("Some"));
29870    }
29871
29872    #[test]
29873    fn diagnoses_non_exhaustive_and_duplicate_case_branches() {
29874        let source = r#"
29875workflow CaseCoverageGuess
29876
29877enum ReviewStatus {
29878  Accept
29879  Revise
29880  Blocked
29881}
29882
29883class Review {
29884  status ReviewStatus
29885  provider "codex" | "claude"
29886  owner string?
29887}
29888
29889rule route
29890  when Review as review
29891=> {
29892  case review.status {
29893    Accept => {
29894    }
29895    Accept => {
29896    }
29897    Revise => {
29898    }
29899  }
29900
29901  case review.provider {
29902    "codex" => {
29903    }
29904  }
29905
29906  case review.owner {
29907    Some owner => {
29908    }
29909  }
29910}
29911"#;
29912        let compiled = compile_program(source);
29913        assert!(compiled.ir.is_none());
29914        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29915            .message
29916            .contains("duplicate unguarded case pattern `Accept`")));
29917        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29918            .message
29919            .contains("non-exhaustive case; missing Blocked")));
29920        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29921            .message
29922            .contains("non-exhaustive case; missing claude")));
29923        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29924            .message
29925            .contains("non-exhaustive case; missing None")));
29926    }
29927
29928    #[test]
29929    fn accepts_fallback_and_guarded_duplicate_case_branches() {
29930        let source = r#"
29931workflow CaseFallbackGuess
29932
29933enum ReviewStatus {
29934  Accept
29935  Revise
29936  Blocked
29937}
29938
29939class Review {
29940  status ReviewStatus
29941  owner string?
29942}
29943
29944rule route
29945  when Review as review
29946=> {
29947  case review.status {
29948    Accept where review.owner != null => {
29949    }
29950    Accept where review.owner == null => {
29951    }
29952    _ => {
29953    }
29954  }
29955}
29956"#;
29957        let compiled = compile_program(source);
29958        assert_eq!(compiled.diagnostics, Vec::new());
29959        assert!(compiled.ir.is_some());
29960    }
29961
29962    #[test]
29963    fn rejects_unreachable_case_branch_after_wildcard() {
29964        // A branch placed after an unguarded `_` can never match (case-family.maude
29965        // inv c, redundant-postwild).
29966        let source = r#"
29967workflow CaseUnreachableGuess
29968
29969enum ReviewStatus {
29970  Accept
29971  Revise
29972  Blocked
29973}
29974
29975class Review {
29976  status ReviewStatus
29977}
29978
29979rule route
29980  when Review as review
29981=> {
29982  case review.status {
29983    Accept => {
29984    }
29985    _ => {
29986    }
29987    Revise => {
29988    }
29989  }
29990}
29991"#;
29992        let compiled = compile_program(source);
29993        assert!(compiled.ir.is_none());
29994        assert!(
29995            compiled.diagnostics.iter().any(|diagnostic| diagnostic
29996                .message
29997                .contains("unreachable case branch after the `_` wildcard")),
29998            "expected unreachable-after-wildcard diagnostic: {:?}",
29999            compiled.diagnostics
30000        );
30001    }
30002
30003    #[test]
30004    fn family_b_presence_condition_validates_discriminant() {
30005        let program = |fields: &str| {
30006            format!(
30007                r#"
30008workflow B
30009input e Event
30010output result Done
30011class Done {{ ok bool }}
30012class Event {{
30013{fields}
30014}}
30015rule r
30016  when Event as e
30017=> {{
30018  complete result {{ ok true }}
30019}}
30020"#
30021            )
30022        };
30023        // Valid: a literal-union discriminant with an in-range `when` literal.
30024        let ok = compile_program(&program(
30025            "  kind \"deploy\" | \"rollback\"\n  region string when kind is \"deploy\"",
30026        ));
30027        assert_eq!(ok.diagnostics, Vec::new());
30028        assert!(ok.ir.is_some());
30029        // Unknown discriminant.
30030        let bad1 = compile_program(&program(
30031            "  kind \"deploy\" | \"rollback\"\n  region string when missing is \"deploy\"",
30032        ));
30033        assert!(bad1
30034            .diagnostics
30035            .iter()
30036            .any(|d| d.message.contains("unknown discriminant `missing`")));
30037        // Literal not in the discriminant union.
30038        let bad2 = compile_program(&program(
30039            "  kind \"deploy\" | \"rollback\"\n  region string when kind is \"ship\"",
30040        ));
30041        assert!(bad2
30042            .diagnostics
30043            .iter()
30044            .any(|d| d.message.contains("not a value of `kind`")));
30045        // Discriminant is not a string-literal union.
30046        let bad3 = compile_program(&program(
30047            "  kind string\n  region string when kind is \"deploy\"",
30048        ));
30049        assert!(bad3
30050            .diagnostics
30051            .iter()
30052            .any(|d| d.message.contains("not a string-literal discriminant")));
30053    }
30054
30055    #[test]
30056    fn case_arm_effect_records_its_selector() {
30057        // An effect inside a `case <scrutinee> { <pattern> => … }` arm records the
30058        // selector `(scrutinee, pattern)` so the IFC checker can apply
30059        // NMIF-on-the-selector to a crossing (DR §7.4).
30060        let source = r#"
30061workflow S
30062
30063input item WorkItem
30064output result R
30065class WorkItem { kind "a" | "b" }
30066class R { ok bool }
30067class V { ok bool }
30068
30069coerce f(t string) -> V { prompt "x" }
30070
30071rule r
30072  when WorkItem as item
30073=> {
30074  case item.kind {
30075    "a" => {
30076      coerce f("hi") as v
30077      after v succeeds {
30078        complete result { ok v.ok }
30079      }
30080    }
30081    "b" => {
30082      complete result { ok false }
30083    }
30084  }
30085}
30086"#;
30087        let ir = compile_program(source).ir.expect("compiles");
30088        let rule = ir.rules.iter().find(|r| r.name == "r").expect("rule r");
30089        let coerce = rule
30090            .metadata
30091            .effects
30092            .iter()
30093            .find(|e| e.binding.as_deref() == Some("v"))
30094            .expect("coerce effect v");
30095        let (scrutinee, pattern) = coerce
30096            .selected_by
30097            .as_ref()
30098            .expect("coerce in a case arm records its selector");
30099        assert_eq!(scrutinee, "item.kind");
30100        assert_eq!(pattern, "\"a\"");
30101        // An effect outside any case (the `complete` is in an arm, but there are no
30102        // top-level effects here) — sanity: a fresh top-level coerce has no selector.
30103        // (Covered by every other example whose effects are top-level: selected_by None.)
30104    }
30105
30106    #[test]
30107    fn family_b_read_narrowing_restricts_conditioned_reads() {
30108        let program = |body: &str| {
30109            format!(
30110                r#"
30111workflow B
30112input e Event
30113output result Done
30114class Done {{ region string }}
30115class Event {{
30116  kind "deploy" | "rollback"
30117  region string when kind is "deploy"
30118}}
30119rule r
30120  when Event as e
30121=> {{
30122{body}
30123}}
30124"#
30125            )
30126        };
30127        // Outside any case arm: a conditioned read is rejected.
30128        let outside = compile_program(&program("  complete result { region e.region }"));
30129        assert!(
30130            outside
30131                .diagnostics
30132                .iter()
30133                .any(|d| d.message.contains("conditional field `e.region`")),
30134            "{:?}",
30135            outside.diagnostics
30136        );
30137        // Inside the matching `deploy` arm: allowed.
30138        let matching = compile_program(&program(
30139            "  case e.kind {\n    \"deploy\" => { complete result { region e.region } }\n    \"rollback\" => { complete result { region \"none\" } }\n  }",
30140        ));
30141        assert_eq!(matching.diagnostics, Vec::new());
30142        assert!(matching.ir.is_some());
30143        // Inside the wrong (`rollback`) arm: rejected (region is a deploy-only field).
30144        let wrong = compile_program(&program(
30145            "  case e.kind {\n    \"deploy\" => { complete result { region \"x\" } }\n    \"rollback\" => { complete result { region e.region } }\n  }",
30146        ));
30147        assert!(
30148            wrong
30149                .diagnostics
30150                .iter()
30151                .any(|d| d.message.contains("conditional field `e.region`")),
30152            "{:?}",
30153            wrong.diagnostics
30154        );
30155    }
30156
30157    #[test]
30158    fn rejects_conflicting_reused_effect_binding() {
30159        // Reusing an effect binding for two effects with DIFFERENT result types makes
30160        // `after <binding> …` ambiguous (§5.5). Same-type reuse is harmless and allowed.
30161        let source = r#"
30162workflow D
30163
30164output result R
30165class R { x string }
30166class WorkItem { title string }
30167class A { a string }
30168class B { b string }
30169
30170coerce fa(t string) -> A { prompt "x" }
30171coerce fb(t string) -> B { prompt "x" }
30172
30173rule r
30174  when WorkItem as item
30175=> {
30176  coerce fa(item.title) as v
30177  coerce fb(item.title) as v
30178  complete result { x "done" }
30179}
30180"#;
30181        let compiled = compile_program(source);
30182        assert!(
30183            compiled
30184                .diagnostics
30185                .iter()
30186                .any(|d| d.message.contains("reuses effect binding `v`")),
30187            "{:?}",
30188            compiled.diagnostics
30189        );
30190    }
30191
30192    #[test]
30193    fn rejects_transitive_workflow_invocation_cycle() {
30194        // A invokes B invokes A — a runtime invoke cycle with no compile-time
30195        // convergence proof (RESOLVED 2026-07-01). Rejected before root selection.
30196        let source = r#"
30197workflow A {
30198  input task TA
30199  output result RA
30200  class TA { id string }
30201  class RA { id string }
30202  rule go
30203    when TA as t
30204  => {
30205    invoke B { task { id t.id } } as b
30206    after b succeeds as r { complete result { id r.id } }
30207  }
30208}
30209
30210workflow B {
30211  input task TB
30212  output result RB
30213  class TB { id string }
30214  class RB { id string }
30215  rule go
30216    when TB as t
30217  => {
30218    invoke A { task { id t.id } } as a
30219    after a succeeds as r { complete result { id r.id } }
30220  }
30221}
30222"#;
30223        let compiled = compile_program_with_root(source, Some("A"));
30224        assert!(compiled.ir.is_none());
30225        assert!(
30226            compiled.diagnostics.iter().any(|d| d
30227                .message
30228                .contains("graph.unbounded_workflow_invocation_recursion")
30229                && d.message.contains("A -> B -> A")),
30230            "{:?}",
30231            compiled.diagnostics
30232        );
30233    }
30234
30235    #[test]
30236    fn accepts_acyclic_workflow_invocation_chain() {
30237        // A invokes B invokes C — a finite chain, never flagged (bite: the cycle
30238        // detector must not over-reject non-recursive nesting).
30239        let source = r#"
30240workflow A {
30241  input task TA
30242  output result RA
30243  class TA { id string }
30244  class RA { id string }
30245  rule go
30246    when TA as t
30247  => {
30248    invoke B { task { id t.id } } as b
30249    after b succeeds as r { complete result { id r.id } }
30250  }
30251}
30252
30253workflow B {
30254  input task TB
30255  output result RB
30256  class TB { id string }
30257  class RB { id string }
30258  rule go
30259    when TB as t
30260  => {
30261    invoke C { task { id t.id } } as c
30262    after c succeeds as r { complete result { id r.id } }
30263  }
30264}
30265
30266workflow C {
30267  input task TC
30268  output result RC
30269  class TC { id string }
30270  class RC { id string }
30271  rule go
30272    when TC as t
30273  => {
30274    complete result { id t.id }
30275  }
30276}
30277"#;
30278        let compiled = compile_program_with_root(source, Some("A"));
30279        assert!(
30280            !compiled.diagnostics.iter().any(|d| d
30281                .message
30282                .contains("graph.unbounded_workflow_invocation_recursion")),
30283            "acyclic chain wrongly flagged: {:?}",
30284            compiled.diagnostics
30285        );
30286    }
30287
30288    #[test]
30289    fn rejects_invoking_private_sibling_workflow() {
30290        let source = r#"
30291class Job { id string }
30292class Report { id string }
30293
30294@private
30295workflow Child {
30296  input task Job
30297  output result Report
30298  rule work
30299    when Job as t
30300  => {
30301    complete result { id t.id }
30302  }
30303}
30304
30305workflow Parent {
30306  input task Job
30307  output result Report
30308  rule go
30309    when Job as t
30310  => {
30311    invoke Child { task t } as child
30312    after child succeeds as r { complete result { id r.id } }
30313  }
30314}
30315"#;
30316        let compiled = compile_program_with_root(source, Some("Parent"));
30317        assert!(compiled.ir.is_none());
30318        assert!(
30319            compiled
30320                .diagnostics
30321                .iter()
30322                .any(|d| d.message.contains("private workflow `Child`")),
30323            "{:?}",
30324            compiled.diagnostics
30325        );
30326    }
30327
30328    #[test]
30329    fn accepts_private_workflow_as_selected_root() {
30330        let source = r#"
30331class Job { id string }
30332class Report { id string }
30333
30334@private
30335workflow Child {
30336  input task Job
30337  output result Report
30338  rule work
30339    when Job as t
30340  => {
30341    complete result { id t.id }
30342  }
30343}
30344"#;
30345        let compiled = compile_program_with_root(source, Some("Child"));
30346        let ir = compiled
30347            .ir
30348            .unwrap_or_else(|| panic!("private root compiles: {:?}", compiled.diagnostics));
30349        assert!(ir.source_tags.iter().any(|tag| {
30350            tag.name == "private" && tag.target_kind == "workflow" && tag.target == "Child"
30351        }));
30352    }
30353
30354    #[test]
30355    fn typed_invoke_result_checks_field_access_against_child_output() {
30356        // The child's output is a shared top-level class `Report`. The parent's
30357        // `after child succeeds as r` binds r to Report, so `r.missing` (not a
30358        // field of Report) is rejected — invoke results are no longer opaque.
30359        let source = r#"
30360class Report { id string }
30361class Job { id string }
30362
30363workflow Parent {
30364  input task Job
30365  output result Report
30366  rule go
30367    when Job as t
30368  => {
30369    invoke Child { task { id t.id } } as child
30370    after child succeeds as r {
30371      complete result { id r.missing }
30372    }
30373  }
30374}
30375
30376workflow Child {
30377  input task Job
30378  output result Report
30379  rule work
30380    when Job as t
30381  => {
30382    complete result { id t.id }
30383  }
30384}
30385"#;
30386        let compiled = compile_program_with_root(source, Some("Parent"));
30387        assert!(
30388            compiled.ir.is_none(),
30389            "unknown field on invoke result must not compile"
30390        );
30391        assert!(
30392            compiled
30393                .diagnostics
30394                .iter()
30395                .any(|d| d.message.contains("r.missing") || d.message.contains("missing")),
30396            "typed invoke result did not reject r.missing: {:?}",
30397            compiled.diagnostics
30398        );
30399    }
30400
30401    #[test]
30402    fn typed_invoke_result_accepts_a_valid_child_output_field() {
30403        // Bite: a field that IS on the child's output contract resolves and the
30404        // program compiles.
30405        let source = r#"
30406class Report { id string }
30407class Job { id string }
30408
30409workflow Parent {
30410  input task Job
30411  output result Report
30412  rule go
30413    when Job as t
30414  => {
30415    invoke Child { task { id t.id } } as child
30416    after child succeeds as r {
30417      complete result { id r.id }
30418    }
30419  }
30420}
30421
30422workflow Child {
30423  input task Job
30424  output result Report
30425  rule work
30426    when Job as t
30427  => {
30428    complete result { id t.id }
30429  }
30430}
30431"#;
30432        let compiled = compile_program_with_root(source, Some("Parent"));
30433        assert!(
30434            compiled.diagnostics.is_empty(),
30435            "valid invoke-result field access wrongly rejected: {:?}",
30436            compiled.diagnostics
30437        );
30438        assert!(compiled.ir.is_some());
30439    }
30440
30441    #[test]
30442    fn typed_invoke_failure_checks_field_access_against_child_failure() {
30443        // The child's failure is a shared top-level class `ChildError`. The
30444        // parent's `after child fails as f` binds f to ChildError, so
30445        // `f.nonexistent` (not a field of ChildError) is rejected — the failure
30446        // binding is the child's declared failure shape, not the opaque base.
30447        let source = r#"
30448class Report { id string }
30449class Job { id string }
30450class ChildError { reason string }
30451class ParentError { detail string }
30452
30453workflow Parent {
30454  input task Job
30455  output result Report
30456  failure err ParentError
30457  rule go
30458    when Job as t
30459  => {
30460    invoke Child { task { id t.id } } as child
30461    after child succeeds as r {
30462      complete result { id r.id }
30463    }
30464    after child fails as f {
30465      fail err { detail f.nonexistent }
30466    }
30467  }
30468}
30469
30470workflow Child {
30471  input task Job
30472  output result Report
30473  failure err ChildError
30474  rule work
30475    when Job as t
30476  => {
30477    fail err { reason t.id }
30478  }
30479}
30480"#;
30481        let compiled = compile_program_with_root(source, Some("Parent"));
30482        assert!(
30483            compiled.ir.is_none(),
30484            "unknown field on invoke failure must not compile"
30485        );
30486        assert!(
30487            compiled
30488                .diagnostics
30489                .iter()
30490                .any(|d| d.message.contains("f.nonexistent") || d.message.contains("nonexistent")),
30491            "typed invoke failure did not reject f.nonexistent: {:?}",
30492            compiled.diagnostics
30493        );
30494    }
30495
30496    #[test]
30497    fn typed_invoke_failure_accepts_a_valid_child_failure_field() {
30498        // Bite: a field that IS on the child's shared failure contract resolves
30499        // and the program compiles — the `fails` binding is no longer the opaque
30500        // `TerminalFailed` base when the child declares a shared failure class.
30501        let source = r#"
30502class Report { id string }
30503class Job { id string }
30504class ChildError { reason string }
30505class ParentError { detail string }
30506
30507workflow Parent {
30508  input task Job
30509  output result Report
30510  failure err ParentError
30511  rule go
30512    when Job as t
30513  => {
30514    invoke Child { task { id t.id } } as child
30515    after child succeeds as r {
30516      complete result { id r.id }
30517    }
30518    after child fails as f {
30519      fail err { detail f.reason }
30520    }
30521  }
30522}
30523
30524workflow Child {
30525  input task Job
30526  output result Report
30527  failure err ChildError
30528  rule work
30529    when Job as t
30530  => {
30531    fail err { reason t.id }
30532  }
30533}
30534"#;
30535        let compiled = compile_program_with_root(source, Some("Parent"));
30536        assert!(
30537            compiled.diagnostics.is_empty(),
30538            "valid invoke-failure field access wrongly rejected: {:?}",
30539            compiled.diagnostics
30540        );
30541        assert!(compiled.ir.is_some());
30542    }
30543
30544    #[test]
30545    fn whole_program_validation_catches_a_broken_sibling_under_any_root() {
30546        // Two workflows; `Good` is well-formed, `Broken` references an undeclared
30547        // schema in its own scope. Compiling with `--root Good` must still catch
30548        // `Broken`'s error — the pre-pass validates EVERY workflow, not just the
30549        // selected root (RESOLVED 2026-07-01). Before this, a broken sibling was
30550        // silently discarded by root selection and never validated.
30551        let source = r#"
30552workflow Good {
30553  input task TG
30554  output result RG
30555  class TG { id string }
30556  class RG { id string }
30557  rule go
30558    when TG as t
30559  => {
30560    complete result { id t.id }
30561  }
30562}
30563
30564workflow Broken {
30565  input task TB
30566  output result RB
30567  class TB { id string }
30568  class RB { id string }
30569  rule go
30570    when Nonexistent as t
30571  => {
30572    complete result { id t.id }
30573  }
30574}
30575"#;
30576        let compiled = compile_program_with_root(source, Some("Good"));
30577        assert!(
30578            compiled.ir.is_none(),
30579            "a program with a broken sibling must not compile"
30580        );
30581        assert!(
30582            compiled
30583                .diagnostics
30584                .iter()
30585                .any(|d| d.message.contains("Nonexistent")),
30586            "the broken sibling's error was not surfaced: {:?}",
30587            compiled.diagnostics
30588        );
30589    }
30590
30591    #[test]
30592    fn cross_workflow_reference_to_sibling_local_is_annotated() {
30593        // `Consumer` references class `Secret`, which is declared *inside* sibling
30594        // workflow `Owner` (private to it). The reference is an error (isolation),
30595        // and it carries a related note pointing at Owner's declaration so the
30596        // author knows the name exists but is out of scope.
30597        let source = r#"
30598workflow Owner {
30599  input task TO
30600  output result RO
30601  class TO { id string }
30602  class RO { id string }
30603  class Secret { id string }
30604  rule go
30605    when TO as t
30606  => {
30607    complete result { id t.id }
30608  }
30609}
30610
30611workflow Consumer {
30612  input task TC
30613  output result RC
30614  class TC { id string }
30615  class RC { id string }
30616  rule go
30617    when Secret as s
30618  => {
30619    complete result { id s.id }
30620  }
30621}
30622"#;
30623        let compiled = compile_program_with_root(source, Some("Consumer"));
30624        assert!(
30625            compiled.ir.is_none(),
30626            "sibling-local reference must not compile"
30627        );
30628        let leak = compiled
30629            .diagnostics
30630            .iter()
30631            .find(|d| d.message.contains("`Secret`"))
30632            .expect("an unknown-name diagnostic for Secret");
30633        assert!(
30634            leak.related
30635                .iter()
30636                .any(|r| r.message.contains("workflow `Owner`")
30637                    && r.message.contains("private to that workflow")),
30638            "missing sibling-local leak note: {:?}",
30639            leak.related
30640        );
30641    }
30642
30643    #[test]
30644    fn shared_top_level_name_is_not_annotated_as_a_leak() {
30645        // Bite: a class declared at the TOP LEVEL is global — both workflows may
30646        // reference it, and no leak note is attached. (Also confirms the program
30647        // compiles: the shared global resolves in each workflow.)
30648        let source = r#"
30649class Shared { id string }
30650
30651workflow Alpha {
30652  input task Shared
30653  output result RA
30654  class RA { id string }
30655  rule go
30656    when Shared as s
30657  => {
30658    complete result { id s.id }
30659  }
30660}
30661
30662workflow Beta {
30663  input task Shared
30664  output result RB
30665  class RB { id string }
30666  rule go
30667    when Shared as s
30668  => {
30669    complete result { id s.id }
30670  }
30671}
30672"#;
30673        let compiled = compile_program_with_root(source, Some("Alpha"));
30674        assert!(
30675            compiled.diagnostics.is_empty(),
30676            "shared top-level global wrongly rejected: {:?}",
30677            compiled.diagnostics
30678        );
30679        assert!(compiled.ir.is_some());
30680    }
30681
30682    #[test]
30683    fn whole_program_validation_accepts_all_well_formed_workflows() {
30684        // Bite: when every workflow is well-formed, the pre-pass adds no spurious
30685        // diagnostics and the selected root still compiles to IR.
30686        let source = r#"
30687workflow Alpha {
30688  input task TA
30689  output result RA
30690  class TA { id string }
30691  class RA { id string }
30692  rule go
30693    when TA as t
30694  => {
30695    complete result { id t.id }
30696  }
30697}
30698
30699workflow Beta {
30700  input task TB
30701  output result RB
30702  class TB { id string }
30703  class RB { id string }
30704  rule go
30705    when TB as t
30706  => {
30707    complete result { id t.id }
30708  }
30709}
30710"#;
30711        let compiled = compile_program_with_root(source, Some("Alpha"));
30712        assert!(
30713            compiled.diagnostics.is_empty(),
30714            "well-formed multi-workflow program emitted diagnostics: {:?}",
30715            compiled.diagnostics
30716        );
30717        assert!(compiled.ir.is_some(), "selected root failed to compile");
30718    }
30719
30720    #[test]
30721    fn compact_workflow_signature_desugars_to_keyword_contracts() {
30722        // `Name(in: T) -> Out ! Fail` must produce exactly the contracts the
30723        // keyword form produces, with output named `result` and failure `error`.
30724        let compact = r#"
30725workflow Triage(ticket: Ticket) -> Resolution ! TriageFailed
30726
30727class Ticket { id string }
30728class Resolution { id string }
30729class TriageFailed { reason string }
30730
30731rule go
30732  when Ticket as t
30733=> {
30734  complete result { id t.id }
30735}
30736"#;
30737        let keyword = r#"
30738workflow Triage
30739
30740input ticket Ticket
30741output result Resolution
30742failure error TriageFailed
30743
30744class Ticket { id string }
30745class Resolution { id string }
30746class TriageFailed { reason string }
30747
30748rule go
30749  when Ticket as t
30750=> {
30751  complete result { id t.id }
30752}
30753"#;
30754        let compact_ir = compile_program_with_root(compact, None);
30755        let keyword_ir = compile_program_with_root(keyword, None);
30756        assert!(
30757            compact_ir.diagnostics.is_empty(),
30758            "compact form did not compile: {:?}",
30759            compact_ir.diagnostics
30760        );
30761        assert!(
30762            keyword_ir.diagnostics.is_empty(),
30763            "keyword form did not compile: {:?}",
30764            keyword_ir.diagnostics
30765        );
30766        // Compare the semantic triple (kind, name, type) — spans naturally differ
30767        // between the two source layouts.
30768        let project = |ir: &IrProgram| {
30769            ir.workflow_contracts
30770                .iter()
30771                .map(|c| {
30772                    (
30773                        format!("{:?}", c.kind),
30774                        c.name.clone(),
30775                        format!("{:?}", c.ty),
30776                    )
30777                })
30778                .collect::<Vec<_>>()
30779        };
30780        assert_eq!(
30781            project(&compact_ir.ir.expect("compact ir")),
30782            project(&keyword_ir.ir.expect("keyword ir")),
30783            "compact signature did not desugar to the same contracts"
30784        );
30785    }
30786
30787    #[test]
30788    fn compact_signature_supports_multiple_inputs_and_optional_failure() {
30789        // Multiple comma-separated inputs; failure clause omitted.
30790        let source = r#"
30791workflow Merge(left: LeftIn, right: RightIn) -> Merged
30792
30793class LeftIn { id string }
30794class RightIn { id string }
30795class Merged { id string }
30796
30797rule go
30798  when {
30799    LeftIn as l
30800    RightIn as r
30801  }
30802=> {
30803  complete result { id l.id }
30804}
30805"#;
30806        let compiled = compile_program_with_root(source, None);
30807        assert!(
30808            compiled.diagnostics.is_empty(),
30809            "multi-input compact form did not compile: {:?}",
30810            compiled.diagnostics
30811        );
30812        let ir = compiled.ir.expect("ir");
30813        let inputs = ir
30814            .workflow_contracts
30815            .iter()
30816            .filter(|c| matches!(c.kind, IrWorkflowContractKind::Input))
30817            .count();
30818        let failures = ir
30819            .workflow_contracts
30820            .iter()
30821            .filter(|c| matches!(c.kind, IrWorkflowContractKind::Failure))
30822            .count();
30823        assert_eq!(inputs, 2, "expected two inputs");
30824        assert_eq!(
30825            failures, 0,
30826            "omitted failure clause must add no failure contract"
30827        );
30828    }
30829
30830    #[test]
30831    fn rejects_headerless_program_with_no_workflow() {
30832        // The implicit compatibility root is removed (RESOLVED 2026-07-01): a
30833        // source with no explicit `workflow` (only shared types/patterns) is a
30834        // library fragment, not a runnable program, and is rejected.
30835        let source = r#"
30836class SharedTicket {
30837  id string
30838}
30839
30840pattern TagReviewed<Input> {
30841  rule tag
30842    when Input as item
30843  => {
30844    record SharedTicket { id item.id }
30845  }
30846}
30847"#;
30848        let compiled = compile_program_with_root(source, None);
30849        assert!(compiled.ir.is_none());
30850        assert!(
30851            compiled
30852                .diagnostics
30853                .iter()
30854                .any(|d| d.message.contains("program declares no `workflow`")),
30855            "{:?}",
30856            compiled.diagnostics
30857        );
30858    }
30859
30860    #[test]
30861    fn accepts_single_workflow_header_program() {
30862        // Bite: the headerless reject must not fire on a program that declares a
30863        // workflow via the header form (the common single-workflow shape).
30864        let source = r#"
30865workflow OnlyOne
30866
30867input item Job
30868output result Done
30869
30870class Job { id string }
30871class Done { id string }
30872
30873rule go
30874  when Job as j
30875=> {
30876  complete result { id j.id }
30877}
30878"#;
30879        let compiled = compile_program_with_root(source, None);
30880        assert!(
30881            !compiled
30882                .diagnostics
30883                .iter()
30884                .any(|d| d.message.contains("program declares no `workflow`")),
30885            "header-form program wrongly rejected as headerless: {:?}",
30886            compiled.diagnostics
30887        );
30888    }
30889
30890    #[test]
30891    fn rejects_recording_observer_only_terminal_schema() {
30892        // §5.4: the terminal family is `origin = observer` — the kernel projects
30893        // it, user rules may only eliminate it. A rule that `record`s a terminal
30894        // schema forges an outcome the kernel never produced, so it is rejected.
30895        for schema in ["TerminalFailed", "TerminalTimedOut", "TerminalCancelled"] {
30896            let source = format!(
30897                r#"
30898workflow Forge
30899
30900input item Job
30901output result Done
30902
30903class Job {{ id string }}
30904class Done {{ id string }}
30905
30906rule sneak
30907  when Job as q
30908=> {{
30909  record {schema} {{ reason "x" summary "y" }}
30910  complete result {{ id q.id }}
30911}}
30912"#
30913            );
30914            let compiled = compile_program(&source);
30915            assert!(
30916                compiled
30917                    .diagnostics
30918                    .iter()
30919                    .any(|d| d.message.contains(&format!(
30920                        "cannot record kernel-owned terminal schema `{schema}`"
30921                    ))),
30922                "expected rejection for {schema}, got {:?}",
30923                compiled.diagnostics
30924            );
30925        }
30926    }
30927
30928    #[test]
30929    fn allows_recording_user_writable_builtin_schema() {
30930        // Regression guard for the fix above: `WorkItem` is a builtin schema ref
30931        // but user-writable (work-tracking state), so recording it must NOT be
30932        // rejected as observer-only.
30933        let source = r#"
30934workflow WriteWork
30935
30936input item Job
30937output result Done
30938
30939class Job { id string }
30940class Done { id string }
30941
30942rule track
30943  when Job as q
30944=> {
30945  record WorkItem { title "t" status "reviewed" }
30946  complete result { id q.id }
30947}
30948"#;
30949        let compiled = compile_program(source);
30950        assert!(
30951            !compiled.diagnostics.iter().any(|d| d
30952                .message
30953                .contains("cannot record kernel-owned terminal schema")),
30954            "WorkItem must remain user-writable, got {:?}",
30955            compiled.diagnostics
30956        );
30957    }
30958
30959    #[test]
30960    fn exhaustive_bool_case_compiles() {
30961        // `case` over a `bool` field is valid when both `true` and `false` are
30962        // covered (the finite two-value domain).
30963        let source = r#"
30964workflow BoolCaseOk
30965
30966output result Done
30967
30968class Done {
30969  note string
30970}
30971
30972class Flag {
30973  ready bool
30974}
30975
30976rule route
30977  when Flag as f
30978=> {
30979  case f.ready {
30980    true => {
30981      complete result {
30982        note "t"
30983      }
30984    }
30985    false => {
30986      complete result {
30987        note "f"
30988      }
30989    }
30990  }
30991}
30992"#;
30993        let compiled = compile_program(source);
30994        assert_eq!(
30995            compiled.diagnostics,
30996            Vec::new(),
30997            "{:?}",
30998            compiled.diagnostics
30999        );
31000        assert!(compiled.ir.is_some());
31001    }
31002
31003    #[test]
31004    fn bool_case_rejects_non_exhaustive_and_non_bool_patterns() {
31005        let source = r#"
31006workflow BoolCaseBad
31007
31008class Flag {
31009  ready bool
31010}
31011
31012rule route
31013  when Flag as f
31014=> {
31015  case f.ready {
31016    true => {
31017    }
31018  }
31019
31020  case f.ready {
31021    maybe => {
31022    }
31023    false => {
31024    }
31025  }
31026}
31027"#;
31028        let compiled = compile_program(source);
31029        assert!(compiled.ir.is_none());
31030        assert!(
31031            compiled
31032                .diagnostics
31033                .iter()
31034                .any(|d| d.message.contains("non-exhaustive case; missing false")),
31035            "expected non-exhaustive diagnostic: {:?}",
31036            compiled.diagnostics
31037        );
31038        assert!(
31039            compiled.diagnostics.iter().any(|d| d
31040                .message
31041                .contains("case pattern `maybe` that is not a `bool` value")),
31042            "expected non-bool pattern diagnostic: {:?}",
31043            compiled.diagnostics
31044        );
31045    }
31046
31047    #[test]
31048    fn exec_schema_result_resolves_typed_fields_for_case() {
31049        // `exec "..." -> Schema as v` registers its result type, so an
31050        // `after v succeeds as r` branch can `case` / field-access `r`'s fields —
31051        // the same after-binding type flow a named `coerce -> Schema` already
31052        // gets. Before the fix this produced "case scrutinee `r.kind` ... not a
31053        // typed path".
31054        let source = r#"
31055@service
31056workflow ExecTyped
31057
31058class Pick { kind "a" | "b" }
31059class R { choice string }
31060
31061output result R
31062
31063signal go.now {
31064  x string
31065}
31066
31067rule j
31068  when go.now as g
31069=> {
31070  exec "echo hi" -> Pick as v
31071
31072  after v succeeds as r {
31073    case r.kind {
31074      "a" => {
31075        complete result {
31076          choice "a"
31077        }
31078      }
31079      "b" => {
31080        complete result {
31081          choice "b"
31082        }
31083      }
31084    }
31085  }
31086}
31087"#;
31088        let compiled = compile_program(source);
31089        assert!(
31090            !compiled
31091                .diagnostics
31092                .iter()
31093                .any(|d| d.message.contains("not a typed path")),
31094            "exec -> Schema result fields should resolve: {:?}",
31095            compiled.diagnostics
31096        );
31097        assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31098    }
31099
31100    #[test]
31101    fn exec_with_requires_typed_record_binding() {
31102        // spec/std-script.md "Static checks" item 4: `exec <name> with <binding>`
31103        // serializes the binding to the script's stdin as a typed record, so the
31104        // binding must be a typed record binding — unknown or untyped-fact
31105        // bindings are check errors.
31106        let source = |with_line: &str, when_line: &str| {
31107            format!(
31108                r#"
31109@service
31110workflow ExecWith
31111
31112class Request {{ text string }}
31113class Report {{ message string }}
31114
31115output result Report
31116
31117rule go
31118  when {when_line}
31119=> {{
31120  exec echo_report with {with_line} -> Report as report
31121
31122  after report succeeds as out {{
31123    complete result {{
31124      message out.message
31125    }}
31126  }}
31127}}
31128"#
31129            )
31130        };
31131
31132        // Positive: a class-typed `when` binding is a typed record binding.
31133        let compiled = compile_program(&source("request", "Request as request"));
31134        assert!(
31135            !compiled
31136                .diagnostics
31137                .iter()
31138                .any(|d| d.message.contains("typed record binding")),
31139            "typed record binding must pass: {:?}",
31140            compiled.diagnostics
31141        );
31142        assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31143
31144        // Negative: an unknown binding.
31145        let compiled = compile_program(&source("missing", "Request as request"));
31146        assert!(
31147            compiled.diagnostics.iter().any(|d| d
31148                .message
31149                .contains("uses unknown binding `missing` in `exec echo_report with missing`")),
31150            "unknown binding must be rejected: {:?}",
31151            compiled.diagnostics
31152        );
31153
31154        // Negative: an untyped runtime fact binding carries no record shape.
31155        let compiled = compile_program(&source("g", "fact foo.bar as g"));
31156        assert!(
31157            compiled.diagnostics.iter().any(|d| d
31158                .message
31159                .contains("passes untyped fact binding `g` to `exec echo_report with`")),
31160            "untyped fact binding must be rejected: {:?}",
31161            compiled.diagnostics
31162        );
31163
31164        // Positive: a typed result binding (`exec -> Schema as r`) is a typed
31165        // record binding for a downstream `with`.
31166        let chained = r#"
31167@service
31168workflow ExecWithChained
31169
31170class Request { text string }
31171class Report { message string }
31172
31173output result Report
31174
31175rule go
31176  when Request as request
31177=> {
31178  exec fetch_request with request -> Request as fetched
31179
31180  after fetched succeeds as staged {
31181    exec echo_report with staged -> Report as report
31182
31183    after report succeeds as out {
31184      complete result {
31185        message out.message
31186      }
31187    }
31188  }
31189}
31190"#;
31191        let compiled = compile_program(chained);
31192        assert!(
31193            !compiled
31194                .diagnostics
31195                .iter()
31196                .any(|d| d.message.contains("typed record binding")),
31197            "typed exec-result binding must pass: {:?}",
31198            compiled.diagnostics
31199        );
31200        assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31201    }
31202
31203    #[test]
31204    fn redact_projection_keeps_only_kept_fields() {
31205        // `redact c keep [id, status] as safe` synthesizes a projected class
31206        // holding only the kept fields, so `safe.id` resolves but `safe.ssn`
31207        // (dropped) is an unknown field — the type-system half of redaction
31208        // soundness (a dropped field cannot be reached through the projection).
31209        let kept = r#"
31210@service
31211workflow RedactKept
31212
31213class Customer { id string  ssn string  status string }
31214class Result { tag string }
31215output result Result
31216
31217signal go.now { x string }
31218
31219coerce read_customer(x string) -> Customer { prompt "x" }
31220
31221rule r
31222  when go.now as g
31223=> {
31224  coerce read_customer(g.x) as c
31225  after c succeeds as cust {
31226    redact cust keep [id, status] as safe
31227    complete result {
31228      tag safe.id
31229    }
31230  }
31231}
31232"#;
31233        let compiled = compile_program(kept);
31234        assert!(
31235            !compiled
31236                .diagnostics
31237                .iter()
31238                .any(|d| d.message.contains("unknown field") || d.message.contains("not a typed")),
31239            "kept field `safe.id` should resolve: {:?}",
31240            compiled.diagnostics
31241        );
31242
31243        assert!(
31244            compiled.ir.is_some(),
31245            "kept program should compile: {compiled:?}"
31246        );
31247
31248        let dropped = kept.replace("tag safe.id", "tag safe.ssn");
31249        let compiled = compile_program(&dropped);
31250        assert!(
31251            compiled
31252                .diagnostics
31253                .iter()
31254                .any(|d| d.message.contains("safe.ssn") || d.message.contains("`ssn`")),
31255            "dropped field `safe.ssn` should be rejected: {:?}",
31256            compiled.diagnostics
31257        );
31258    }
31259
31260    #[test]
31261    fn redact_unknown_kept_field_is_rejected() {
31262        let source = r#"
31263@service
31264workflow RedactBadKeep
31265
31266class Customer { id string  status string }
31267class Result { tag string }
31268output result Result
31269
31270signal go.now { x string }
31271
31272coerce read_customer(x string) -> Customer { prompt "x" }
31273
31274rule r
31275  when go.now as g
31276=> {
31277  coerce read_customer(g.x) as c
31278  after c succeeds as cust {
31279    redact cust keep [id, nonexistent] as safe
31280    complete result {
31281      tag safe.id
31282    }
31283  }
31284}
31285"#;
31286        let compiled = compile_program(source);
31287        assert!(
31288            compiled
31289                .diagnostics
31290                .iter()
31291                .any(|d| d.message.contains("keeping unknown field `nonexistent`")),
31292            "expected unknown-kept-field rejection: {:?}",
31293            compiled.diagnostics
31294        );
31295    }
31296
31297    #[test]
31298    fn inline_decide_result_resolves_typed_fields_for_case() {
31299        // `decide -> { fixed bool } as v` synthesizes a hygienic anonymous result
31300        // class, so `after v succeeds as r` can `case` / field-access `r`'s
31301        // fields — the same after-binding type flow a named `coerce -> Schema`
31302        // gets. Before this, an inline decide result had no type, so `r.fixed`
31303        // was "not a typed path" and `case r.fixed { true/false }` could not bind.
31304        let source = r#"
31305@service
31306workflow InlineDecideTyped
31307
31308class R { choice string }
31309output result R
31310
31311signal go.now {
31312  x string
31313}
31314
31315rule j
31316  when go.now as g
31317=> {
31318  decide "is it fixed?" -> { fixed bool } as v
31319
31320  after v succeeds as r {
31321    case r.fixed {
31322      true => {
31323        complete result {
31324          choice "a"
31325        }
31326      }
31327      false => {
31328        complete result {
31329          choice "b"
31330        }
31331      }
31332    }
31333  }
31334}
31335"#;
31336        let compiled = compile_program(source);
31337        assert!(
31338            !compiled
31339                .diagnostics
31340                .iter()
31341                .any(|d| d.message.contains("not a typed path")),
31342            "inline decide result fields should resolve: {:?}",
31343            compiled.diagnostics
31344        );
31345        let ir = compiled.ir.expect("compiles");
31346        // The synthesized hygienic class is visible in the IR (so the runtime
31347        // fixture can generate the anonymous shape).
31348        assert!(
31349            ir.schemas.iter().any(|schema| matches!(
31350                schema,
31351                IrSchema::Class(class) if class.name == "decide.j.v"
31352            )),
31353            "expected synthesized inline-decide class `decide.j.v` in IR schemas"
31354        );
31355    }
31356
31357    #[test]
31358    fn rejects_malformed_multiline_prompt_content_type_on_rule_prompt() {
31359        let source = r#"
31360workflow PromptAnnotationGuess
31361
31362agent worker {
31363  provider fixture
31364  profile "repo-writer"
31365  capacity 1
31366}
31367
31368rule ask
31369  when started
31370=> {
31371  tell worker as turn """markdown extra
31372  do work
31373  """
31374}
31375"#;
31376        let compiled = compile_program(source);
31377
31378        assert!(compiled.ir.is_none());
31379        assert!(compiled.diagnostics.iter().any(|diagnostic| {
31380            diagnostic
31381                .message
31382                .contains("malformed multiline prompt content type `markdown extra`")
31383                && diagnostic.suggestion.as_deref().is_some_and(|suggestion| {
31384                    suggestion.contains("put prompt text on the next line")
31385                })
31386        }));
31387    }
31388
31389    #[test]
31390    fn rejects_malformed_multiline_prompt_content_type_on_coerce_prompt() {
31391        let source = r#"
31392workflow CoerceAnnotationGuess
31393
31394class Review {
31395  status "ok"
31396}
31397
31398coerce review() -> Review {
31399  prompt """text/markdown extra
31400  classify the review
31401  """
31402}
31403
31404rule run
31405  when started
31406=> {
31407  coerce review() as result
31408}
31409"#;
31410        let compiled = compile_program(source);
31411
31412        assert!(compiled.ir.is_none());
31413        assert!(compiled
31414            .diagnostics
31415            .iter()
31416            .any(|diagnostic| diagnostic.message.contains(
31417                "coerce `review` has malformed multiline prompt content type `text/markdown extra`"
31418            )));
31419    }
31420
31421    #[test]
31422    fn rejects_pasted_top_level_gherkin_with_targeted_diagnostic() {
31423        let source = r#"
31424Feature: provider language routing
31425
31426Scenario: fixture provider reviews every language task
31427  Given a queued language task
31428  When the provider turn completes
31429  Then the language result is reviewed
31430"#;
31431        let compiled = compile_program(source);
31432
31433        assert!(compiled.ir.is_none());
31434        assert!(compiled.diagnostics.iter().any(|diagnostic| {
31435            diagnostic
31436                .message
31437                .contains("Gherkin keyword `Feature` is not WhippleScript workflow syntax")
31438                && diagnostic.suggestion.as_deref().is_some_and(|suggestion| {
31439                    suggestion.contains("use `workflow`, `table`, `rule")
31440                        && suggestion.contains("instead of free-text Given/When/Then steps")
31441                })
31442        }));
31443        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31444            .message
31445            .contains("Gherkin keyword `Given` is not WhippleScript workflow syntax")));
31446    }
31447
31448    #[test]
31449    fn rejects_pasted_gherkin_inside_workflow_body_with_targeted_diagnostic() {
31450        let source = r#"
31451workflow PastedGherkin {
31452  Scenario: fixture provider reviews every language task
31453  Given a queued language task
31454  When the provider turn completes
31455  Then the language result is reviewed
31456}
31457"#;
31458        let compiled = compile_program(source);
31459
31460        assert!(compiled.ir.is_none());
31461        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31462            .message
31463            .contains("Gherkin keyword `Scenario` is not WhippleScript workflow syntax")));
31464        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31465            .message
31466            .contains("Gherkin keyword `Then` is not WhippleScript workflow syntax")));
31467    }
31468
31469    #[test]
31470    fn rejects_pasted_gherkin_background_outline_examples_and_continuations() {
31471        let source = r#"
31472Feature: provider language routing
31473
31474Rule: provider execution remains explicit
31475
31476Background:
31477  Given a seeded provider table
31478  And all provider profiles are available
31479
31480Scenario Outline: provider reviews language task
31481  When <provider> completes <language>
31482  But the review is missing
31483  Then the fixture fails
31484
31485Examples:
31486  | provider | language |
31487  | codex    | French   |
31488"#;
31489        let compiled = compile_program(source);
31490
31491        assert!(compiled.ir.is_none());
31492        for keyword in ["Rule", "Background", "And", "Scenario", "But", "Examples"] {
31493            assert!(
31494                compiled
31495                    .diagnostics
31496                    .iter()
31497                    .any(|diagnostic| diagnostic.message.contains(&format!(
31498                        "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
31499                    ))),
31500                "missing diagnostic for {keyword}: {:?}",
31501                compiled
31502                    .diagnostics
31503                    .iter()
31504                    .map(|diagnostic| diagnostic.message.as_str())
31505                    .collect::<Vec<_>>()
31506            );
31507        }
31508    }
31509
31510    #[test]
31511    fn explains_multiline_string_binding_position() {
31512        let source = r#"
31513workflow BindingGuess
31514
31515agent worker {
31516  provider fixture
31517  profile "repo-writer"
31518  capacity 1
31519}
31520
31521rule branch
31522  when started
31523=> {
31524  tell worker """
31525  do work
31526  """ as turn
31527
31528  after turn succeeds {
31529    tell worker "review" as review
31530  }
31531}
31532"#;
31533        let compiled = compile_program(source);
31534
31535        assert!(compiled.ir.is_none());
31536        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31537            .message
31538            .contains("places effect binding `turn` after a multiline string delimiter")
31539            && diagnostic.suggestion.as_deref().is_some_and(
31540                |suggestion| suggestion.contains("move `as turn` onto the effect line")
31541            )));
31542    }
31543
31544    #[test]
31545    fn invalid_fixtures_have_actionable_diagnostics() {
31546        let fixtures = [
31547            (
31548                "bad-agent",
31549                include_str!("../../../examples/invalid/bad-agent.whip"),
31550            ),
31551            (
31552                "bad-record",
31553                include_str!("../../../examples/invalid/bad-record.whip"),
31554            ),
31555            (
31556                "bad-terminal-payload",
31557                include_str!("../../../examples/invalid/bad-terminal-payload.whip"),
31558            ),
31559            (
31560                "recursive-workflow-invocation",
31561                include_str!("../../../examples/invalid/recursive-workflow-invocation.whip"),
31562            ),
31563            (
31564                "bad-effect-graph",
31565                include_str!("../../../examples/invalid/bad-effect-graph.whip"),
31566            ),
31567            (
31568                "bad-effect-payload",
31569                include_str!("../../../examples/invalid/bad-effect-payload.whip"),
31570            ),
31571            (
31572                "bad-expression-functions",
31573                include_str!("../../../examples/invalid/bad-expression-functions.whip"),
31574            ),
31575            (
31576                "bad-finite-domain",
31577                include_str!("../../../examples/invalid/bad-finite-domain.whip"),
31578            ),
31579            (
31580                "broken",
31581                include_str!("../../../examples/invalid/broken.whip"),
31582            ),
31583            (
31584                "effect-output-scope",
31585                include_str!("../../../examples/invalid/effect-output-scope.whip"),
31586            ),
31587            (
31588                "effectful-self-loop",
31589                include_str!("../../../examples/invalid/effectful-self-loop.whip"),
31590            ),
31591            (
31592                "recursive-pattern",
31593                include_str!("../../../examples/invalid/recursive-pattern.whip"),
31594            ),
31595            (
31596                "evidence-fact-match",
31597                include_str!("../../../examples/invalid/evidence-fact-match.whip"),
31598            ),
31599            (
31600                "unknown-schema",
31601                include_str!("../../../examples/invalid/unknown-schema.whip"),
31602            ),
31603            (
31604                "headerless-library",
31605                include_str!("../../../examples/invalid/headerless-library.whip"),
31606            ),
31607        ];
31608
31609        for (name, source) in fixtures {
31610            let compiled = compile_program(source);
31611            assert!(compiled.ir.is_none(), "{name} unexpectedly compiled");
31612            assert!(
31613                !compiled.diagnostics.is_empty(),
31614                "{name} did not emit diagnostics"
31615            );
31616            assert!(
31617                compiled
31618                    .diagnostics
31619                    .iter()
31620                    .all(|diagnostic| diagnostic.suggestion.is_some()),
31621                "{name} emitted a diagnostic without a suggestion: {:?}",
31622                compiled.diagnostics
31623            );
31624        }
31625    }
31626
31627    #[test]
31628    fn rejects_dangling_root_in_record_value() {
31629        // A record value referencing a binding that does not exist (a typo or an
31630        // unbound name) is a dangling reference that previously compiled silently.
31631        let source = r#"
31632@service
31633workflow DanglingRoot
31634
31635class Ticket { id string }
31636class Note { text string }
31637
31638table seed as Ticket [ { id "1" } ]
31639
31640rule r
31641  when Ticket as ticket
31642=> {
31643  record Note {
31644    text tikcet.id
31645  }
31646}
31647"#;
31648        let compiled = compile_program(source);
31649        assert!(compiled.ir.is_none());
31650        assert!(
31651            compiled
31652                .diagnostics
31653                .iter()
31654                .any(|d| d.message.contains("unknown binding `tikcet`")),
31655            "{:?}",
31656            compiled.diagnostics
31657        );
31658    }
31659
31660    #[test]
31661    fn rejects_dangling_root_in_single_line_record() {
31662        // Single-line records (`record X { f y }`) were skipped by the line-based
31663        // extractor (brace_delta 0) and so were never field-validated; they are
31664        // now covered.
31665        let source = r#"
31666@service
31667workflow DanglingSingleLine
31668
31669class Ticket { id string }
31670class Note { text string }
31671
31672table seed as Ticket [ { id "1" } ]
31673
31674rule r
31675  when Ticket as ticket
31676=> {
31677  record Note { text tikcet.id }
31678}
31679"#;
31680        let compiled = compile_program(source);
31681        assert!(compiled.ir.is_none());
31682        assert!(
31683            compiled
31684                .diagnostics
31685                .iter()
31686                .any(|d| d.message.contains("unknown binding `tikcet`")),
31687            "{:?}",
31688            compiled.diagnostics
31689        );
31690    }
31691
31692    #[test]
31693    fn rejects_dangling_root_in_coerce_argument() {
31694        // Coerce arguments are another value position where a typo'd/unbound root
31695        // was previously accepted leniently by the type-checker.
31696        let source = r#"
31697@service
31698workflow DanglingCoerceArg
31699
31700class Ticket { id string  title string }
31701class Review { summary string }
31702
31703coerce classify(title string) -> Review { prompt "c" }
31704
31705agent reviewer { provider fixture  profile "r"  capacity 1 }
31706
31707table seed as Ticket [ { id "1"  title "t" } ]
31708
31709rule r
31710  when Ticket as ticket
31711  when reviewer is available
31712=> {
31713  coerce classify(tikcet.title) as rev
31714}
31715"#;
31716        let compiled = compile_program(source);
31717        assert!(compiled.ir.is_none());
31718        assert!(
31719            compiled
31720                .diagnostics
31721                .iter()
31722                .any(|d| d.message.contains("unknown binding `tikcet`")
31723                    && d.message.contains("coerce `classify`")),
31724            "{:?}",
31725            compiled.diagnostics
31726        );
31727    }
31728
31729    #[test]
31730    fn rejects_dangling_root_in_counter_consume_operand() {
31731        // Non-field effect operands (lease/counter `for <key>`, `emit ... to
31732        // <target>`) are also checked via `check_operand_root`.
31733        let source = r#"
31734@service
31735workflow CounterOperandDangling
31736
31737class CallFailed { service string }
31738class Service { id string }
31739
31740counter failure_budget { key Service cap 3 reset daily }
31741
31742table seed as CallFailed [ { service "x" } ]
31743
31744rule strike
31745  when CallFailed as f
31746=> {
31747  consume failure_budget for fff.service amount 1 as strike
31748}
31749"#;
31750        let compiled = compile_program(source);
31751        assert!(compiled.ir.is_none());
31752        assert!(
31753            compiled
31754                .diagnostics
31755                .iter()
31756                .any(|d| d.message.contains("unknown binding `fff`")
31757                    && d.message.contains("consume")),
31758            "{:?}",
31759            compiled.diagnostics
31760        );
31761    }
31762
31763    #[test]
31764    fn rejects_dangling_root_in_queue_file_payload() {
31765        // Body-AST effect payloads (`file issue into`, `emit`, ledger `append`)
31766        // are validated via `validate_effect_field_roots`, not the line-based
31767        // validators; their field values were previously unchecked for roots.
31768        let source = r#"
31769@service
31770workflow QueueFieldDangling
31771
31772class Ticket { id string }
31773
31774tracker backlog { provider builtin }
31775
31776table seed as Ticket [ { id "1" } ]
31777
31778rule r
31779  when Ticket as ticket
31780=> {
31781  file issue into backlog {
31782    title tikcet.id
31783    body "x"
31784  }
31785}
31786"#;
31787        let compiled = compile_program(source);
31788        assert!(compiled.ir.is_none());
31789        assert!(
31790            compiled
31791                .diagnostics
31792                .iter()
31793                .any(|d| d.message.contains("unknown binding `tikcet`")
31794                    && d.message.contains("file into")),
31795            "{:?}",
31796            compiled.diagnostics
31797        );
31798    }
31799
31800    #[test]
31801    fn rejects_dangling_root_in_invoke_input() {
31802        // Invoke payload inputs are another value position the type-checker
31803        // accepted leniently for unknown roots.
31804        let source = r#"
31805workflow Parent {
31806  input task Task
31807  output result Out
31808
31809  class Task { id string }
31810  class Out { x string }
31811
31812  rule r
31813    when Task as task
31814  => {
31815    invoke Child { item tikcet.id } as c
31816    after c succeeds as cr {
31817      done task
31818      complete result { x cr.summary }
31819    }
31820  }
31821}
31822
31823workflow Child {
31824  input item string
31825  output result ChildOut
31826  class ChildOut { y string }
31827  rule c
31828    when item as i
31829  => {
31830    complete result { y "done" }
31831  }
31832}
31833"#;
31834        let compiled = compile_program_with_root(source, Some("Parent"));
31835        assert!(compiled.ir.is_none());
31836        assert!(
31837            compiled
31838                .diagnostics
31839                .iter()
31840                .any(|d| d.message.contains("unknown binding `tikcet`")
31841                    && d.message.contains("invoke Child")),
31842            "{:?}",
31843            compiled.diagnostics
31844        );
31845    }
31846
31847    #[test]
31848    fn rejects_dangling_root_in_tell_target() {
31849        // A dynamic tell target with a typo'd/unbound root was silently accepted
31850        // (the type lookup returned None and bailed).
31851        let source = r#"
31852@service
31853workflow DanglingTellTarget
31854
31855class Ticket { id string  provider AgentRef<reviewer> }
31856
31857agent reviewer { provider fixture  profile "r"  capacity 1 }
31858
31859table seed as Ticket [ { id "1"  provider reviewer } ]
31860
31861rule r
31862  when Ticket as ticket
31863=> {
31864  tell tikcet.provider as turn "go"
31865}
31866"#;
31867        let compiled = compile_program(source);
31868        assert!(compiled.ir.is_none());
31869        assert!(
31870            compiled
31871                .diagnostics
31872                .iter()
31873                .any(|d| d.message.contains("unknown binding `tikcet`")
31874                    && d.message.contains("tell target")),
31875            "{:?}",
31876            compiled.diagnostics
31877        );
31878    }
31879
31880    #[test]
31881    fn accepts_effect_binding_root_in_record_value() {
31882        // The AST-collected root set must include `tell`/`after` results (which
31883        // the typed `binding_types` map omits), so reading an effect-result field
31884        // in a record value compiles. This is the case a naive binding_types-only
31885        // check wrongly rejected.
31886        let source = r#"
31887@service
31888workflow EffectRoot
31889
31890class Ticket { id string }
31891class Note { text string }
31892
31893agent reviewer { provider fixture  profile "r"  capacity 1 }
31894
31895table seed as Ticket [ { id "1" } ]
31896
31897rule r
31898  when Ticket as ticket
31899  when reviewer is available
31900=> {
31901  tell reviewer as turn "review"
31902  after turn succeeds {
31903    record Note {
31904      text turn.summary
31905    }
31906  }
31907}
31908"#;
31909        let compiled = compile_program(source);
31910        assert_eq!(
31911            compiled.diagnostics,
31912            Vec::new(),
31913            "{:?}",
31914            compiled.diagnostics
31915        );
31916        assert!(compiled.ir.is_some());
31917    }
31918
31919    #[test]
31920    fn rejects_invalid_record_fields_paths_and_literals() {
31921        let source = include_str!("../../../examples/invalid/bad-record.whip");
31922        let compiled = compile_program(source);
31923
31924        assert!(compiled.ir.is_none());
31925        assert_eq!(compiled.diagnostics.len(), 5);
31926        assert!(compiled
31927            .diagnostics
31928            .iter()
31929            .any(|diagnostic| diagnostic.message.contains("request.missing")));
31930        assert!(compiled
31931            .diagnostics
31932            .iter()
31933            .any(|diagnostic| diagnostic.message.contains("no variant `Maybe`")));
31934        assert!(compiled
31935            .diagnostics
31936            .iter()
31937            .any(|diagnostic| diagnostic.message.contains("expects `float`")));
31938        assert!(compiled
31939            .diagnostics
31940            .iter()
31941            .any(|diagnostic| diagnostic.message.contains("cannot be `scripted`")));
31942        assert!(compiled
31943            .diagnostics
31944            .iter()
31945            .any(|diagnostic| diagnostic.message.contains("no field `extra`")));
31946    }
31947
31948    #[test]
31949    fn rejects_effect_output_outside_after_scope() {
31950        let source = include_str!("../../../examples/invalid/effect-output-scope.whip");
31951        let compiled = compile_program(source);
31952
31953        assert!(compiled.ir.is_none());
31954        assert_eq!(compiled.diagnostics.len(), 1);
31955        assert!(compiled.diagnostics[0]
31956            .message
31957            .contains("outside a matching `after claim ...` block"));
31958    }
31959
31960    /// DR-0043 Decision 5: a `during`/`until` region compiles; the canonical
31961    /// IR body is the condition-HOLDS splice (no region syntax left), and the
31962    /// metadata carries the removed/lapsed variants and the region effects
31963    /// with their level-1 scopes.
31964    #[test]
31965    fn region_compiles_and_ir_carries_variants() {
31966        let source = r#"
31967workflow Deploy
31968
31969output result Done
31970failure error Halted
31971
31972class Incident {
31973  sev string
31974}
31975
31976class Done {
31977  note string
31978}
31979
31980class Halted {
31981  reason string
31982}
31983
31984rule ship
31985  when started
31986=> {
31987  until exists(Incident where sev == "sev1") {
31988    then plan <- timer 1s
31989    then approved <- timer 1s
31990    complete result {
31991      note "shipped"
31992    }
31993  } on lapse as got {
31994    fail error {
31995      reason "halted"
31996    }
31997  }
31998}
31999"#;
32000        let compiled = compile_program(source);
32001        assert!(
32002            compiled.diagnostics.is_empty(),
32003            "region must compile: {:?}",
32004            compiled.diagnostics
32005        );
32006        let ir = compiled.ir.expect("ir");
32007        let rule = &ir.rules[0];
32008        assert!(
32009            !rule.body.contains("until exists") && !rule.body.contains("on lapse"),
32010            "canonical body is the HOLDS splice: {}",
32011            rule.body
32012        );
32013        let region = rule.metadata.region.as_ref().expect("region metadata");
32014        assert!(region.until);
32015        assert_eq!(region.condition, "exists(Incident where sev == \"sev1\")");
32016        assert_eq!(region.lapse_binding.as_deref(), Some("got"));
32017        assert!(
32018            region.body_lapsed.contains("fail error"),
32019            "lapsed variant carries the arm: {}",
32020            region.body_lapsed
32021        );
32022        assert!(
32023            !region.body_removed.contains("timer") && !region.body_removed.contains("fail error"),
32024            "removed variant drops region AND arm: {}",
32025            region.body_removed
32026        );
32027        let bindings: Vec<&str> = region
32028            .effects
32029            .iter()
32030            .map(|effect| effect.binding.as_str())
32031            .collect();
32032        assert!(
32033            bindings.contains(&"__then_plan") && bindings.contains(&"__then_approved"),
32034            "region effects recorded: {bindings:?}"
32035        );
32036    }
32037
32038    /// v1 limit: one region per rule; the second draws a spanned error.
32039    #[test]
32040    fn two_regions_in_one_rule_rejected() {
32041        let source = r#"
32042workflow Two
32043
32044output result Done
32045
32046class Done {
32047  note string
32048}
32049
32050class Flag {
32051  on string
32052}
32053
32054rule go
32055  when started
32056=> {
32057  during empty(Flag) {
32058    timer 1s as a
32059
32060    after a completes {
32061      record Flag {
32062        on "x"
32063      }
32064    }
32065  } on lapse {
32066    complete result {
32067      note "one"
32068    }
32069  }
32070
32071  during empty(Flag) {
32072    timer 1s as b
32073
32074    after b completes {
32075      complete result {
32076        note "two"
32077      }
32078    }
32079  } on lapse {
32080    complete result {
32081      note "three"
32082    }
32083  }
32084}
32085"#;
32086        let compiled = compile_program(source);
32087        assert!(
32088            compiled
32089                .diagnostics
32090                .iter()
32091                .any(|d| d.message.contains("more than one `during`/`until` region")),
32092            "second region rejected: {:?}",
32093            compiled.diagnostics
32094        );
32095    }
32096
32097    /// The lapse arm may not reference bindings the region introduces — they
32098    /// may not exist when the arm runs; the progress view is the window.
32099    #[test]
32100    fn lapse_arm_referencing_region_binding_rejected() {
32101        let source = r#"
32102workflow Scope
32103
32104output result Done
32105failure error Halted
32106
32107class Incident {
32108  sev string
32109}
32110
32111class Done {
32112  note string
32113}
32114
32115class Halted {
32116  reason string
32117}
32118
32119rule go
32120  when started
32121=> {
32122  until exists(Incident where sev == "sev1") {
32123    then plan <- timer 1s
32124    complete result {
32125      note plan.status
32126    }
32127  } on lapse {
32128    fail error {
32129      reason plan.status
32130    }
32131  }
32132}
32133"#;
32134        let compiled = compile_program(source);
32135        assert!(
32136            compiled
32137                .diagnostics
32138                .iter()
32139                .any(|d| d.message.contains("references `plan`, a binding the")),
32140            "arm scope violation rejected: {:?}",
32141            compiled.diagnostics
32142        );
32143    }
32144
32145    /// Full-line `#` comments are legal in rule bodies (ruling 2026-07-21):
32146    /// at statement positions, inside `after` arms, between `then` lines
32147    /// (blanked BEFORE then-expansion, so a comment containing braces cannot
32148    /// corrupt the wrap-depth accounting), while a `#` inside a `"""` prompt
32149    /// stays content -- the markdown heading survives to the effect prompt.
32150    #[test]
32151    fn full_line_comments_in_rule_bodies_compile_and_prompts_keep_hashes() {
32152        let source = r#"
32153use std.script
32154
32155workflow Commented
32156
32157output result Done
32158
32159class Done {
32160  note string
32161}
32162
32163agent helper
32164
32165rule go
32166  when started
32167=> {
32168  # request the probe command
32169  exec "true" as probe
32170
32171  after probe succeeds {
32172    # a comment with braces { and quotes " should be inert
32173    then turn <- tell helper """markdown
32174    # This heading is prompt CONTENT, not a comment.
32175    Summarize.
32176    """
32177    # comment between then chain and terminal
32178    complete result {
32179      note turn.summary
32180    }
32181  }
32182
32183  after probe fails {
32184    # losing is fine
32185  }
32186}
32187"#;
32188        let compiled = compile_program(source);
32189        assert!(
32190            compiled.diagnostics.is_empty(),
32191            "comments must not produce diagnostics: {:?}",
32192            compiled.diagnostics
32193        );
32194        let ir = compiled.ir.expect("compiles");
32195        let rule = &ir.rules[0];
32196        assert!(
32197            !rule.body.contains("# request"),
32198            "compile-path body text is comment-blanked: {}",
32199            rule.body
32200        );
32201        assert!(
32202            rule.body.contains("# This heading is prompt CONTENT"),
32203            "prompt interiors are untouched by blanking: {}",
32204            rule.body
32205        );
32206    }
32207
32208    /// A `#` after code on the same line is still an error -- trailing
32209    /// comments are top-level-only.
32210    #[test]
32211    fn trailing_comment_in_rule_body_still_rejected() {
32212        let source = r#"
32213workflow Trailing
32214
32215output result Done
32216
32217class Done {
32218  note string
32219}
32220
32221rule go
32222  when started
32223=> {
32224  complete result {
32225    note "x"
32226  } # not allowed here
32227}
32228"#;
32229        let compiled = compile_program(source);
32230        assert!(
32231            compiled
32232                .diagnostics
32233                .iter()
32234                .any(|d| d.message.contains("unexpected character `#`")),
32235            "trailing comment must still be rejected: {:?}",
32236            compiled.diagnostics
32237        );
32238    }
32239
32240    #[test]
32241    fn rejects_effectful_self_trigger_loop() {
32242        let source = include_str!("../../../examples/invalid/effectful-self-loop.whip");
32243        let compiled = compile_program(source);
32244
32245        assert!(compiled.ir.is_none());
32246        assert_eq!(compiled.diagnostics.len(), 1);
32247        assert!(compiled.diagnostics[0]
32248            .message
32249            .contains("preserves trigger fact `schema:WorkItem`"));
32250    }
32251
32252    #[test]
32253    fn rejects_non_file_operation_on_a_file_store_grant() {
32254        // A grant on a declared file store may only use file operations; a non-file op
32255        // (e.g. `recall`) is rejected. A non-file-store resource is left alone.
32256        let program = |op: &str, resource: &str, store: &str| {
32257            format!(
32258                r#"
32259@service
32260workflow FileGrant
32261
32262output result R
32263class R {{ ok bool }}
32264class Ticket {{ id string  status "open" }}
32265
32266agent coder {{ provider fixture  profile "repo-writer"  capacity 1 }}
32267
32268file store {store} {{ root "./data"  allow read ["docs/**"] }}
32269
32270table seed as Ticket [ {{ id "T1"  status "open" }} ]
32271
32272rule work
32273  when Ticket as ticket where ticket.status == "open"
32274  when coder is available
32275=> {{
32276  tell coder as turn
32277    with access to {resource} {{
32278      {op}
32279    }}
32280  "go"
32281
32282  after turn succeeds as outcome {{
32283    complete result {{ ok true }}
32284  }}
32285}}
32286"#
32287            )
32288        };
32289
32290        // `recall` on a declared file store is not a file operation.
32291        let bad = compile_program(&program(
32292            "recall for ticket",
32293            "project_files",
32294            "project_files",
32295        ));
32296        assert!(
32297            bad.diagnostics
32298                .iter()
32299                .any(|d| d.message.contains("not a file operation")),
32300            "{:?}",
32301            bad.diagnostics
32302        );
32303        // The same `recall` on a non-file-store resource is left alone (could be a
32304        // package resource) — no false positive.
32305        let ok = compile_program(&program(
32306            "recall for ticket",
32307            "project_memory",
32308            "project_files",
32309        ));
32310        assert!(
32311            !ok.diagnostics
32312                .iter()
32313                .any(|d| d.message.contains("not a file operation")),
32314            "{:?}",
32315            ok.diagnostics
32316        );
32317    }
32318
32319    #[test]
32320    fn parses_memory_pool_declaration_and_snapshots_it() {
32321        // MEM-1: a `memory pool` declaration lowers to a metadata-only pool with
32322        // its optional `context limit`, and the .ir snapshot renders it.
32323        let source = r#"
32324workflow PoolDecl
32325
32326memory pool project_memory {
32327  context limit 8
32328}
32329"#;
32330        let compiled = compile_program(source);
32331        let ir = compiled.ir.expect("compiles");
32332        assert_eq!(ir.memory_pools.len(), 1);
32333        assert_eq!(ir.memory_pools[0].name, "project_memory");
32334        assert_eq!(ir.memory_pools[0].context_limit, Some(8));
32335        let snapshot = ir.to_snapshot();
32336        assert!(snapshot.contains("memory_pools"), "{snapshot}");
32337        assert!(
32338            snapshot.contains("memory pool project_memory"),
32339            "{snapshot}"
32340        );
32341        assert!(snapshot.contains("context limit 8"), "{snapshot}");
32342
32343        // `context limit` is optional — an absent clause lowers to `None` and
32344        // omits the snapshot line (no ripple), mirroring file-store globs.
32345        let bare = compile_program("workflow Bare\n\nmemory pool p {\n}\n")
32346            .ir
32347            .expect("bare pool compiles");
32348        assert_eq!(bare.memory_pools[0].context_limit, None);
32349        assert!(!bare.to_snapshot().contains("context limit"));
32350    }
32351
32352    #[test]
32353    fn rejects_unknown_and_provider_memory_pool_clauses() {
32354        // Unknown clauses are rejected (file-store precedent). Since the
32355        // declaration-family migration (M2), `provider` is no longer a bespoke
32356        // "pools have no provider" clause but a generic unknown field — v1 pools
32357        // are provider-less, so it is simply not in the grammar.
32358        let unknown = compile_program("workflow U\n\nmemory pool p {\n  retention 5\n}\n");
32359        assert!(
32360            unknown
32361                .diagnostics
32362                .iter()
32363                .any(|d| d.message.contains("unknown memory pool field `retention`")),
32364            "{:?}",
32365            unknown.diagnostics
32366        );
32367        let provider = compile_program("workflow P\n\nmemory pool p {\n  provider local\n}\n");
32368        assert!(
32369            provider
32370                .diagnostics
32371                .iter()
32372                .any(|d| d.message.contains("unknown memory pool field `provider`")),
32373            "{:?}",
32374            provider.diagnostics
32375        );
32376    }
32377
32378    #[test]
32379    fn rejects_non_memory_operation_on_a_memory_pool_grant() {
32380        // MEM-1 static check 2 (mirrors the file-store grant precedent): a grant on
32381        // a declared memory pool may only use memory operations; a non-memory op
32382        // (e.g. a file `read`) is rejected. A grant on a non-pool resource is left
32383        // alone (zero-false-positive) — this closes the memory-grant-validation
32384        // deferral, now that `ir.memory_pools` gives a declared-pool list.
32385        let program = |op: &str, resource: &str, pool: &str| {
32386            format!(
32387                r#"
32388@service
32389workflow MemoryGrant
32390
32391output result R
32392class R {{ ok bool }}
32393class Ticket {{ id string  status "open" }}
32394
32395agent coder {{ provider fixture  profile "repo-writer"  capacity 1 }}
32396
32397memory pool {pool} {{ context limit 8 }}
32398
32399table seed as Ticket [ {{ id "T1"  status "open" }} ]
32400
32401rule work
32402  when Ticket as ticket where ticket.status == "open"
32403  when coder is available
32404=> {{
32405  tell coder as turn
32406    with access to {resource} {{
32407      {op}
32408    }}
32409  "go"
32410
32411  after turn succeeds as outcome {{
32412    complete result {{ ok true }}
32413  }}
32414}}
32415"#
32416            )
32417        };
32418
32419        // A file `read` on a declared memory pool is not a memory operation.
32420        let bad = compile_program(&program(
32421            r#"read ["docs/**"]"#,
32422            "project_memory",
32423            "project_memory",
32424        ));
32425        assert!(
32426            bad.diagnostics
32427                .iter()
32428                .any(|d| d.message.contains("not a memory operation")),
32429            "{:?}",
32430            bad.diagnostics
32431        );
32432
32433        // A memory operation (`recall`/`learn`) on the declared pool is accepted.
32434        let ok_recall = compile_program(&program(
32435            "recall for ticket\n      learn for ticket",
32436            "project_memory",
32437            "project_memory",
32438        ));
32439        assert!(
32440            !ok_recall
32441                .diagnostics
32442                .iter()
32443                .any(|d| d.message.contains("not a memory operation")),
32444            "{:?}",
32445            ok_recall.diagnostics
32446        );
32447
32448        // The same file `read` on a non-pool resource is left alone (no false
32449        // positive) — it could be a file store or a package-provided resource.
32450        let ok_other = compile_program(&program(
32451            r#"read ["docs/**"]"#,
32452            "project_files",
32453            "project_memory",
32454        ));
32455        assert!(
32456            !ok_other
32457                .diagnostics
32458                .iter()
32459                .any(|d| d.message.contains("not a memory operation")),
32460            "{:?}",
32461            ok_other.diagnostics
32462        );
32463    }
32464
32465    #[test]
32466    fn rejects_malformed_turn_access_grants() {
32467        // An empty grant block and a duplicate resource on one `tell` are both
32468        // structurally malformed.
32469        let program = |grant_block: &str| {
32470            format!(
32471                r#"
32472@service
32473workflow GrantCheck
32474
32475output result R
32476class R {{ ok bool }}
32477class Ticket {{ id string  status "open" }}
32478
32479agent coder {{ provider fixture  profile "repo-writer"  capacity 1 }}
32480
32481table seed as Ticket [ {{ id "T1"  status "open" }} ]
32482
32483rule work
32484  when Ticket as ticket where ticket.status == "open"
32485  when coder is available
32486=> {{
32487  tell coder as turn
32488{grant_block}
32489  "Work it."
32490
32491  after turn succeeds as outcome {{
32492    complete result {{ ok true }}
32493  }}
32494}}
32495"#
32496            )
32497        };
32498
32499        let empty = compile_program(&program("    with access to project_memory {\n    }\n"));
32500        assert!(
32501            empty
32502                .diagnostics
32503                .iter()
32504                .any(|d| d.message.contains("grants no operations")),
32505            "{:?}",
32506            empty.diagnostics
32507        );
32508
32509        let duplicate = compile_program(&program(
32510            "    with access to project_memory {\n      recall for ticket\n    }\n    with access to project_memory {\n      learn for ticket\n    }\n",
32511        ));
32512        assert!(
32513            duplicate
32514                .diagnostics
32515                .iter()
32516                .any(|d| d.message.contains("more than once")),
32517            "{:?}",
32518            duplicate.diagnostics
32519        );
32520    }
32521
32522    /// MEM-5 static check 4: a memory grant on a tell whose harness is a
32523    /// native adapter warns (the grant is inert there); the same grant on
32524    /// an owned harness stays quiet.
32525    #[test]
32526    fn warns_inert_memory_grant_on_a_native_adapter_tell() {
32527        let program = |harness_kind: &str| {
32528            format!(
32529                r#"
32530@service
32531workflow InertGrant
32532
32533output result R
32534class R {{ ok bool }}
32535class Ticket {{ id string  status "open" }}
32536
32537memory pool project_memory {{
32538  context limit 4
32539}}
32540
32541harness h: {harness_kind}
32542agent coder using h {{ profile "repo-writer"  capacity 1 }}
32543
32544table seed as Ticket [ {{ id "T1"  status "open" }} ]
32545
32546rule work
32547  when Ticket as ticket where ticket.status == "open"
32548  when coder is available
32549=> {{
32550  tell coder as turn
32551    with access to project_memory {{
32552      recall for ticket
32553    }}
32554  "Work it."
32555
32556  after turn succeeds as outcome {{
32557    complete result {{ ok true }}
32558  }}
32559}}
32560"#
32561            )
32562        };
32563        let native = compile_program(&program("codex"));
32564        assert!(
32565            native.diagnostics.is_empty(),
32566            "the grant itself is legal: {:?}",
32567            native.diagnostics
32568        );
32569        assert!(
32570            native
32571                .warnings
32572                .iter()
32573                .any(|warning| warning.message.contains("inert")),
32574            "a codex-harness tell warns: {:?}",
32575            native.warnings
32576        );
32577        let owned = compile_program(&program("owned"));
32578        assert!(
32579            owned
32580                .warnings
32581                .iter()
32582                .all(|warning| !warning.message.contains("inert")),
32583            "an owned-harness tell does not warn: {:?}",
32584            owned.warnings
32585        );
32586    }
32587
32588    #[test]
32589    fn counter_timezone_clause_parses_and_default_utc_warns() {
32590        // std.coord slice 3: `timezone "<IANA zone>"` anchors the counter's
32591        // reset-period boundary; omitting it is legal but draws the
32592        // default-UTC warning.
32593        let program = |timezone_clause: &str| {
32594            format!(
32595                r#"
32596@service
32597workflow CounterTz
32598
32599class CallFailed {{ service string }}
32600class Service {{ id string }}
32601output result CallFailed
32602failure trouble CallFailed
32603
32604counter failure_budget {{ key Service cap 3 reset daily {timezone_clause} }}
32605
32606rule strike
32607  when CallFailed as f
32608=> {{
32609  consume failure_budget for f.service amount 1 as strike
32610  after strike ok {{
32611    complete result {{ service f.service }}
32612  }}
32613  after strike over {{
32614    fail trouble {{ service f.service }}
32615  }}
32616}}
32617"#
32618            )
32619        };
32620        let anchored = compile_program(&program(r#"timezone "America/New_York""#));
32621        assert!(
32622            anchored.diagnostics.is_empty(),
32623            "timezone clause parses: {:?}",
32624            anchored.diagnostics
32625        );
32626        let ir = anchored.ir.expect("anchored program compiles");
32627        assert_eq!(ir.counters[0].timezone.as_deref(), Some("America/New_York"));
32628        assert!(
32629            anchored
32630                .warnings
32631                .iter()
32632                .all(|warning| !warning.message.contains("timezone")),
32633            "an anchored counter does not warn: {:?}",
32634            anchored.warnings
32635        );
32636
32637        let unanchored = compile_program(&program(""));
32638        assert!(
32639            unanchored.diagnostics.is_empty(),
32640            "omitting timezone stays legal: {:?}",
32641            unanchored.diagnostics
32642        );
32643        let ir = unanchored.ir.expect("unanchored program compiles");
32644        assert_eq!(ir.counters[0].timezone, None);
32645        assert!(
32646            unanchored
32647                .warnings
32648                .iter()
32649                .any(|warning| warning.message.contains("anchors to UTC")),
32650            "an unanchored counter draws the default-UTC warning: {:?}",
32651            unanchored.warnings
32652        );
32653    }
32654
32655    #[test]
32656    fn then_sugar_desugars_to_nested_after_and_composes_in_after_blocks() {
32657        // R2: `then <binding> <- <effect>` is pure parser sugar for
32658        // `after <handle> succeeds as <binding> { … }` with a synthetic
32659        // `__then_*` handle; it composes inside after blocks; and the reserved
32660        // namespace is rejected in author text.
32661        let source = r#"
32662use std.script
32663
32664workflow ThenSugar
32665
32666output result Done
32667
32668class Done {
32669  note string
32670}
32671
32672class Trigger {
32673  id string
32674}
32675
32676table seed as Trigger [
32677  { id "t" }
32678]
32679
32680rule pipeline
32681  when Trigger as t
32682=> {
32683  exec "true" as pre
32684
32685  after pre succeeds {
32686    then a <- exec "one"
32687    then b <- exec "two"
32688    complete result {
32689      note b.stdout
32690    }
32691  }
32692}
32693"#;
32694        let compiled = compile_program(source);
32695        assert_eq!(compiled.diagnostics, Vec::new());
32696        let ir = compiled.ir.expect("compiles");
32697        let body = &ir
32698            .rules
32699            .iter()
32700            .find(|rule| rule.name == "pipeline")
32701            .expect("rule")
32702            .body;
32703        assert!(
32704            body.contains("exec \"one\" as __then_a"),
32705            "the chained effect binds the synthetic handle:\n{body}"
32706        );
32707        assert!(
32708            body.contains("after __then_a succeeds as a {"),
32709            "the continuation nests under the success predicate:\n{body}"
32710        );
32711        assert!(
32712            body.contains("after __then_b succeeds as b {"),
32713            "chained thens nest:\n{body}"
32714        );
32715        assert!(!body.contains("then a <-"), "no sugar survives:\n{body}");
32716
32717        let reserved = compile_program(
32718            r#"
32719use std.script
32720
32721workflow Reserved
32722
32723output result Done
32724
32725class Done {
32726  note string
32727}
32728
32729rule r
32730  when started
32731=> {
32732  exec "true" as __then_x
32733
32734  after __then_x succeeds {
32735    complete result { note "no" }
32736  }
32737}
32738"#,
32739        );
32740        assert!(
32741            reserved
32742                .diagnostics
32743                .iter()
32744                .any(|d| d.message.contains("reserved `__then_` binding namespace")),
32745            "{:?}",
32746            reserved.diagnostics
32747        );
32748    }
32749
32750    #[test]
32751    fn warns_on_unhandled_effect_failure_and_stays_quiet_when_observed() {
32752        // Auto-fail R1a: an effect handled only via `after … succeeds` draws the
32753        // prominent unhandled-failure warning; a `fails` or `completes` observer
32754        // silences it.
32755        let program = |handler: &str| {
32756            format!(
32757                r#"
32758use std.script
32759
32760workflow AutoFailWarn
32761
32762output result Done
32763failure error Broken
32764
32765class Done {{ note string }}
32766class Broken {{ reason string }}
32767class Trigger {{ id string }}
32768
32769table seed as Trigger [
32770  {{ id "t" }}
32771]
32772
32773rule r
32774  when Trigger as t
32775=> {{
32776  exec "true" as x
32777
32778  after x succeeds {{
32779    complete result {{ note "ok" }}
32780  }}
32781{handler}}}
32782"#
32783            )
32784        };
32785        let unhandled = compile_program(&program(""));
32786        assert!(
32787            unhandled.diagnostics.is_empty(),
32788            "{:?}",
32789            unhandled.diagnostics
32790        );
32791        assert!(
32792            unhandled
32793                .warnings
32794                .iter()
32795                .any(|warning| warning.message.contains("`x`'s failure is unhandled")),
32796            "succeeds-only handling draws the R1a warning: {:?}",
32797            unhandled.warnings
32798        );
32799
32800        for observer in [
32801            "\n  after x fails {\n    fail error { reason \"broken\" }\n  }\n",
32802            "\n  after x completes {\n    complete result { note \"any\" }\n  }\n",
32803            "\n  after x times out {\n    fail error { reason \"slow\" }\n  }\n",
32804        ] {
32805            let observed = compile_program(&program(observer));
32806            assert!(
32807                observed.diagnostics.is_empty(),
32808                "{:?}",
32809                observed.diagnostics
32810            );
32811            assert!(
32812                observed
32813                    .warnings
32814                    .iter()
32815                    .all(|warning| !warning.message.contains("failure is unhandled")),
32816                "an observer silences the warning ({observer:?}): {:?}",
32817                observed.warnings
32818            );
32819        }
32820    }
32821
32822    #[test]
32823    fn unhandled_failure_warning_exempts_services_timers_and_coordination() {
32824        // Auto-fail R1a exemptions: `@service` workflows record diagnostics
32825        // instead of auto-failing (so the warning's framing would be wrong),
32826        // timers cannot fail, and coordination outcome observers
32827        // (`held`/`contended`/`ok`/`over`) count as observers at check time.
32828        let service = compile_program(
32829            r#"
32830use std.script
32831
32832@service
32833workflow ServiceQuiet
32834
32835class Trigger { id string }
32836class Seen { note string }
32837
32838table seed as Trigger [
32839  { id "t" }
32840]
32841
32842rule r
32843  when Trigger as t
32844=> {
32845  exec "true" as x
32846
32847  after x succeeds {
32848    record Seen { note "ok" }
32849  }
32850}
32851"#,
32852        );
32853        assert!(service.diagnostics.is_empty(), "{:?}", service.diagnostics);
32854        assert!(
32855            service
32856                .warnings
32857                .iter()
32858                .all(|warning| !warning.message.contains("failure is unhandled")),
32859            "@service is exempt: {:?}",
32860            service.warnings
32861        );
32862
32863        let timer = compile_program(
32864            r#"
32865workflow TimerQuiet
32866
32867output result Done
32868
32869class Done { note string }
32870class Trigger { id string }
32871
32872table seed as Trigger [
32873  { id "t" }
32874]
32875
32876rule r
32877  when Trigger as t
32878=> {
32879  timer 5m as pause
32880
32881  after pause completes {
32882    complete result { note "ok" }
32883  }
32884}
32885"#,
32886        );
32887        assert!(timer.diagnostics.is_empty(), "{:?}", timer.diagnostics);
32888        assert!(
32889            timer
32890                .warnings
32891                .iter()
32892                .all(|warning| !warning.message.contains("failure is unhandled")),
32893            "timers are exempt: {:?}",
32894            timer.warnings
32895        );
32896
32897        let coordination = compile_program(
32898            r#"
32899workflow CoordQuiet
32900
32901output result Done
32902failure error Broken
32903
32904class Done { note string }
32905class Broken { reason string }
32906class Trigger { id string }
32907
32908lease build_slot { key Trigger ttl 10m }
32909
32910table seed as Trigger [
32911  { id "t" }
32912]
32913
32914rule r
32915  when Trigger as t
32916=> {
32917  acquire build_slot for t.id as slot
32918
32919  after slot held {
32920    complete result { note "ok" }
32921  }
32922
32923  after slot contended {
32924    fail error { reason "busy" }
32925  }
32926}
32927"#,
32928        );
32929        assert!(
32930            coordination.diagnostics.is_empty(),
32931            "{:?}",
32932            coordination.diagnostics
32933        );
32934        assert!(
32935            coordination
32936                .warnings
32937                .iter()
32938                .all(|warning| !warning.message.contains("failure is unhandled")),
32939            "coordination outcome observers count at check time: {:?}",
32940            coordination.warnings
32941        );
32942    }
32943
32944    #[test]
32945    fn lowers_turn_access_grants_onto_the_agent_tell_effect() {
32946        // `with access to <resource> { … }` on a tell lowers to `access_grants` on the
32947        // agent.tell IR effect (Proposal A authority-narrowing metadata).
32948        let source = r#"
32949@service
32950workflow GrantDemo
32951
32952output result R
32953class R { ok bool }
32954class Ticket { id string  status "open" }
32955
32956agent coder { provider fixture  profile "repo-writer"  capacity 1 }
32957
32958table seed as Ticket [ { id "T1"  status "open" } ]
32959
32960rule work
32961  when Ticket as ticket where ticket.status == "open"
32962  when coder is available
32963=> {
32964  tell coder as turn
32965    with access to project_memory {
32966      recall for ticket
32967      learn for ticket
32968    }
32969    with access to project_files {
32970      read ["docs/**"]
32971    }
32972  "Work it."
32973
32974  after turn succeeds as outcome {
32975    complete result { ok true }
32976  }
32977}
32978"#;
32979        let compiled = compile_program(source);
32980        let ir = compiled.ir.expect("compiles");
32981        let tell = ir
32982            .rules
32983            .iter()
32984            .flat_map(|rule| rule.metadata.effects.iter())
32985            .find(|effect| effect.kind == IrEffectKind::AgentTell)
32986            .expect("agent.tell effect");
32987        assert_eq!(tell.access_grants.len(), 2);
32988        let memory = &tell.access_grants[0];
32989        assert_eq!(memory.resource, "project_memory");
32990        assert_eq!(memory.operations.len(), 2);
32991        assert_eq!(memory.operations[0].operation, "recall");
32992        assert_eq!(memory.operations[0].target.as_deref(), Some("ticket"));
32993        let files = &tell.access_grants[1];
32994        assert_eq!(files.resource, "project_files");
32995        assert_eq!(files.operations[0].operation, "read");
32996        assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
32997    }
32998
32999    #[test]
33000    fn lowers_start_access_grants_onto_the_workflow_invoke_effect() {
33001        // `with access to <resource> { … }` on an invoke lowers to the same
33002        // `access_grants` metadata, ready for the start-grant attenuation seam.
33003        let source = r#"
33004workflow Parent {
33005  class Task { id string }
33006
33007  rule dispatch
33008    when Task as task
33009  => {
33010    invoke Child { task task }
33011      with access to project_files {
33012        read ["docs/**"]
33013      }
33014      as child
33015  }
33016}
33017
33018workflow Child {
33019  input task Task
33020  class Task { id string }
33021}
33022"#;
33023        let compiled = compile_program_with_root(source, Some("Parent"));
33024        let ir = compiled.ir.unwrap_or_else(|| {
33025            panic!(
33026                "source should compile, diagnostics: {:?}",
33027                compiled
33028                    .diagnostics
33029                    .iter()
33030                    .map(|d| &d.message)
33031                    .collect::<Vec<_>>()
33032            )
33033        });
33034        let invoke = ir
33035            .rules
33036            .iter()
33037            .flat_map(|rule| rule.metadata.effects.iter())
33038            .find(|effect| effect.kind == IrEffectKind::WorkflowInvoke)
33039            .expect("workflow.invoke effect");
33040        assert_eq!(invoke.binding.as_deref(), Some("child"));
33041        assert_eq!(invoke.access_grants.len(), 1);
33042        let files = &invoke.access_grants[0];
33043        assert_eq!(files.resource, "project_files");
33044        assert_eq!(files.operations[0].operation, "read");
33045        assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
33046    }
33047
33048    #[test]
33049    fn lowers_resource_less_start_access_grant_shorthand_onto_the_workflow_invoke_effect() {
33050        // `with access to { <resource> { ... } ... }` is syntax sugar for multiple
33051        // resource-specific start grants.
33052        let source = r#"
33053workflow Parent {
33054  class Task { id string }
33055
33056  rule dispatch
33057    when Task as task
33058  => {
33059    invoke Child { task task }
33060      with access to {
33061        project_memory {
33062          recall for task
33063        }
33064        project_files {
33065          read ["docs/**"]
33066        }
33067      }
33068      as child
33069  }
33070}
33071
33072workflow Child {
33073  input task Task
33074  class Task { id string }
33075}
33076"#;
33077        let compiled = compile_program_with_root(source, Some("Parent"));
33078        let ir = compiled.ir.unwrap_or_else(|| {
33079            panic!(
33080                "source should compile, diagnostics: {:?}",
33081                compiled
33082                    .diagnostics
33083                    .iter()
33084                    .map(|d| &d.message)
33085                    .collect::<Vec<_>>()
33086            )
33087        });
33088        let invoke = ir
33089            .rules
33090            .iter()
33091            .flat_map(|rule| rule.metadata.effects.iter())
33092            .find(|effect| effect.kind == IrEffectKind::WorkflowInvoke)
33093            .expect("workflow.invoke effect");
33094        assert_eq!(invoke.binding.as_deref(), Some("child"));
33095        assert_eq!(invoke.access_grants.len(), 2);
33096        let memory = &invoke.access_grants[0];
33097        assert_eq!(memory.resource, "project_memory");
33098        assert_eq!(memory.operations[0].operation, "recall");
33099        assert_eq!(memory.operations[0].target.as_deref(), Some("task"));
33100        let files = &invoke.access_grants[1];
33101        assert_eq!(files.resource, "project_files");
33102        assert_eq!(files.operations[0].operation, "read");
33103        assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
33104    }
33105
33106    #[test]
33107    fn rejects_rule_matching_evidence_only_turn_fact() {
33108        // In-turn observations (streamed/tool_requested/artifact_captured) are
33109        // evidence, never rule-matchable (spec/agent-harness.md); a `when` on them is
33110        // an error. The lifecycle facts (completed/failed/…) stay matchable.
33111        for evidence in [
33112            "agent.turn.streamed",
33113            "agent.turn.tool_requested",
33114            "agent.turn.artifact_captured",
33115        ] {
33116            let source = format!(
33117                "workflow EvidenceMatch\n\noutput result R\nclass R {{ ok bool }}\n\nrule react\n  when fact {evidence} as ev\n=> {{\n  complete result {{ ok true }}\n}}\n"
33118            );
33119            let compiled = compile_program(&source);
33120            assert!(compiled.ir.is_none(), "{evidence} should be rejected");
33121            assert!(
33122                compiled
33123                    .diagnostics
33124                    .iter()
33125                    .any(|d| d.message.contains("evidence-only fact")
33126                        && d.message.contains(evidence)),
33127                "{evidence}: {:?}",
33128                compiled.diagnostics
33129            );
33130        }
33131        // A matchable lifecycle fact does NOT get the evidence error (it may fail a
33132        // different check, e.g. needing a producer, but not this one).
33133        let matchable = "workflow M\n\noutput result R\nclass R {{ ok bool }}\n\nrule react\n  when fact agent.turn.completed as ev\n=> {{\n  complete result {{ ok true }}\n}}\n".replace("{{", "{").replace("}}", "}");
33134        let compiled = compile_program(&matchable);
33135        assert!(
33136            !compiled
33137                .diagnostics
33138                .iter()
33139                .any(|d| d.message.contains("evidence-only fact")),
33140            "completed must not be flagged as evidence-only: {:?}",
33141            compiled.diagnostics
33142        );
33143    }
33144
33145    #[test]
33146    fn rejects_self_recursive_pattern_application() {
33147        // A pattern whose body applies itself is unbounded recursion: it can never
33148        // elaborate into a finite first-order program (graph.unbounded_pattern_recursion).
33149        let source = include_str!("../../../examples/invalid/recursive-pattern.whip");
33150        let compiled = compile_program(source);
33151
33152        assert!(compiled.ir.is_none());
33153        // Exactly the precise recursion diagnostic — the generic "nested apply not
33154        // supported yet" message must be suppressed for the recursive case.
33155        assert_eq!(compiled.diagnostics.len(), 1, "{:?}", compiled.diagnostics);
33156        let diagnostic = &compiled.diagnostics[0];
33157        assert!(
33158            diagnostic
33159                .message
33160                .contains("graph.unbounded_pattern_recursion"),
33161            "{}",
33162            diagnostic.message
33163        );
33164        assert!(
33165            diagnostic.message.contains("expansion cycle Loop -> Loop"),
33166            "the diagnostic names the cycle: {}",
33167            diagnostic.message
33168        );
33169    }
33170
33171    #[test]
33172    fn rejects_mutually_recursive_pattern_application() {
33173        // A cycle that spans two patterns is rejected once, naming the full cycle.
33174        let source = r#"
33175workflow MutualRecursion
33176
33177class Item {
33178  id string
33179}
33180
33181pattern Ping<T> {
33182  apply Pong<T> as a {
33183  }
33184}
33185
33186pattern Pong<T> {
33187  apply Ping<T> as b {
33188  }
33189}
33190
33191apply Ping<Item> as top {
33192}
33193"#;
33194        let compiled = compile_program(source);
33195
33196        assert!(compiled.ir.is_none());
33197        let recursion: Vec<&Diagnostic> = compiled
33198            .diagnostics
33199            .iter()
33200            .filter(|d| d.message.contains("graph.unbounded_pattern_recursion"))
33201            .collect();
33202        // One cycle, reported once (members covered are not re-reported).
33203        assert_eq!(recursion.len(), 1, "{:?}", compiled.diagnostics);
33204        assert!(
33205            recursion[0].message.contains("Ping -> Pong -> Ping"),
33206            "names the full cycle: {}",
33207            recursion[0].message
33208        );
33209    }
33210
33211    #[test]
33212    fn allows_non_recursive_nested_apply_without_recursion_error() {
33213        // A nested apply that does NOT form a cycle is a separate v0 limitation
33214        // (the generic "not supported yet" message), NOT a recursion error.
33215        let source = r#"
33216workflow NonRecursive
33217
33218class Item {
33219  id string
33220}
33221
33222pattern Inner<T> {
33223}
33224
33225pattern Outer<T> {
33226  apply Inner<T> as x {
33227  }
33228}
33229
33230apply Outer<Item> as top {
33231}
33232"#;
33233        let compiled = compile_program(source);
33234
33235        assert!(
33236            !compiled
33237                .diagnostics
33238                .iter()
33239                .any(|d| d.message.contains("graph.unbounded_pattern_recursion")),
33240            "non-recursive nesting must not be flagged as recursion: {:?}",
33241            compiled.diagnostics
33242        );
33243    }
33244
33245    #[test]
33246    fn rejects_unknown_or_wrong_arity_coerce_calls() {
33247        let source = r#"
33248workflow BadCoerce
33249
33250class Review {
33251  reason string
33252}
33253
33254coerce review(summary string) -> Review {
33255  prompt "review"
33256}
33257
33258rule bad
33259  when started
33260=> {
33261  coerce missing("x") as one
33262  coerce review("x", "y") as two
33263}
33264"#;
33265        let compiled = compile_program(source);
33266
33267        assert!(compiled.ir.is_none());
33268        assert_eq!(compiled.diagnostics.len(), 2);
33269        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33270            .message
33271            .contains("unknown coerce function `missing`")));
33272        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33273            .message
33274            .contains("with 2 argument(s), expected 1")));
33275    }
33276
33277    #[test]
33278    fn rejects_bad_effect_payload_argument_types() {
33279        let source = r#"
33280workflow BadEffectPayloads
33281
33282class Owner {
33283  name string
33284}
33285
33286class Payload {
33287  title string
33288  owner Owner
33289  metadata map<string>
33290  tags string[]
33291}
33292
33293class Task {
33294  title string
33295  owner string
33296}
33297
33298class Review {
33299  accepted bool
33300}
33301
33302coerce reviewPayload(payload Payload, metadata map<string>, score int) -> Review {
33303  prompt "review"
33304}
33305
33306rule bad_coerce
33307  when Task as task where { owner "Ada" } == task.owner
33308=> {
33309  coerce reviewPayload(
33310    {
33311      title task.title
33312      owner { handle task.owner }
33313      metadata { phase 3 }
33314      tags ["object", 7]
33315      extra "bad"
33316    },
33317    { phase task.owner, count 3 },
33318    "high"
33319  ) as review
33320}
33321"#;
33322        let compiled = compile_program(source);
33323
33324        assert!(compiled.ir.is_none());
33325        let messages = compiled
33326            .diagnostics
33327            .iter()
33328            .map(|diagnostic| diagnostic.message.as_str())
33329            .collect::<Vec<_>>();
33330        assert!(messages
33331            .iter()
33332            .any(|message| message.contains("object literal without an expected object")));
33333        assert!(messages
33334            .iter()
33335            .any(|message| message.contains("class `Owner` has no field `handle`")));
33336        assert!(messages
33337            .iter()
33338            .any(|message| message.contains("missing required object field `Owner.name`")));
33339        assert!(messages
33340            .iter()
33341            .any(|message| message.contains("class `Payload` has no field `extra`")));
33342        assert!(messages
33343            .iter()
33344            .any(|message| message
33345                .contains("field `coerce `reviewPayload`.metadata` expects `string`")));
33346        assert!(messages.iter().any(|message| {
33347            message.contains("field `coerce `reviewPayload`.score` expects `int`")
33348        }));
33349    }
33350
33351    #[test]
33352    fn lowers_fact_consumption_metadata() {
33353        let source = r#"
33354workflow ConsumeTask
33355
33356class Task {
33357  status "queued"
33358}
33359
33360rule finish
33361  when Task as task
33362=> {
33363  done task
33364}
33365"#;
33366        let compiled = compile_program(source);
33367        let ir = compiled.ir.expect("program compiles");
33368
33369        assert_eq!(ir.rules[0].metadata.fact_consumes, vec!["schema:Task"]);
33370        assert!(ir.to_snapshot().contains("consumes\n      schema:Task"));
33371    }
33372
33373    #[test]
33374    fn rejects_unknown_fact_consumption_binding() {
33375        let source = r#"
33376workflow BadConsume
33377
33378class Task {
33379  status "queued"
33380}
33381
33382rule finish
33383  when Task as task
33384=> {
33385  done missing
33386}
33387"#;
33388        let compiled = compile_program(source);
33389
33390        assert!(compiled.ir.is_none());
33391        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33392            .message
33393            .contains("consumes unknown fact binding `missing`")));
33394    }
33395
33396    #[test]
33397    fn rejects_then_sequencing() {
33398        let source = r#"
33399workflow NoThen
33400
33401class Task {
33402  topic string
33403  status "queued"
33404}
33405
33406class Result {
33407  topic string
33408  turn AgentTurn
33409  status "done"
33410}
33411
33412agent codex {
33413  provider codex
33414  profile "repo-writer"
33415  capacity 1
33416}
33417
33418assert count(Task where status == "queued") == 0
33419assert count(Result where status == "done") == 1
33420
33421rule finish
33422  when Task as task where task.status == "queued"
33423  when codex is available
33424=> {
33425  tell codex as turn "write"
33426  then done task -> record Result from task {
33427    topic
33428    turn turn
33429    status "done"
33430  }
33431}
33432"#;
33433        let compiled = compile_program(source);
33434        assert!(compiled.ir.is_none());
33435        assert!(compiled
33436            .diagnostics
33437            .iter()
33438            .any(|diagnostic| diagnostic.message.contains("unsupported `then` sequencing")));
33439    }
33440
33441    #[test]
33442    fn rejects_after_arrow_sequencing() {
33443        let source = r#"
33444workflow NoAfterArrow
33445
33446agent codex {
33447  provider codex
33448  profile "repo-writer"
33449  capacity 1
33450}
33451
33452rule finish
33453  when started
33454  when codex is available
33455=> {
33456  tell codex as turn "write"
33457
33458  after turn succeeds => {
33459    record Done {
33460      status "done"
33461    }
33462  }
33463}
33464"#;
33465        let compiled = compile_program(source);
33466        assert!(compiled.ir.is_none());
33467        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33468            .message
33469            .contains("unsupported `after ... =>` sequencing")));
33470    }
33471
33472    #[test]
33473    fn formats_top_level_syntax_scaffold() {
33474        let source = r#"workflow Messy
33475class Status {
33476kind "open"|"done"
33477}
33478rule start
33479when started
33480=> {tell worker "hi"}
33481"#;
33482
33483        let formatted = format_program(source);
33484        assert_eq!(formatted.diagnostics, Vec::new());
33485        let expected = concat!(
33486            "workflow Messy\n",
33487            "\n",
33488            "class Status {\n",
33489            "  kind \"open\" | \"done\"\n",
33490            "}\n",
33491            "\n",
33492            "rule start\n",
33493            "  when started\n",
33494            "=> {\n",
33495            "  tell worker \"hi\"\n",
33496            "}\n",
33497        );
33498
33499        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33500    }
33501
33502    #[test]
33503    fn formats_content_typed_multiline_prompts() {
33504        let source = r#"workflow PromptFormat
33505class Review {
33506status "ok"
33507}
33508coerce review() -> Review {
33509prompt """markdown
33510classify
33511"""
33512}
33513agent worker {
33514  provider fixture
33515profile "repo-writer"
33516capacity 1
33517}
33518rule start
33519when started
33520=> {tell worker as turn """markdown
33521write
33522"""
33523tell worker """application/json
33524{"question":"approve?"}
33525"""}
33526"#;
33527
33528        let formatted = format_program(source);
33529        assert_eq!(formatted.diagnostics, Vec::new());
33530        let expected = concat!(
33531            "workflow PromptFormat\n",
33532            "\n",
33533            "class Review {\n",
33534            "  status \"ok\"\n",
33535            "}\n",
33536            "\n",
33537            "coerce review() -> Review {\n",
33538            "  prompt \"\"\"markdown\n",
33539            "  classify\n",
33540            "  \"\"\"\n",
33541            "}\n",
33542            "\n",
33543            "agent worker {\n",
33544            "  provider fixture\n",
33545            "  profile \"repo-writer\"\n",
33546            "  capacity 1\n",
33547            "}\n",
33548            "\n",
33549            "rule start\n",
33550            "  when started\n",
33551            "=> {\n",
33552            "  tell worker as turn \"\"\"markdown\n",
33553            "  write\n",
33554            "  \"\"\"\n",
33555            "  tell worker \"\"\"application/json\n",
33556            "  {\"question\":\"approve?\"}\n",
33557            "  \"\"\"\n",
33558            "}\n",
33559        );
33560
33561        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33562    }
33563
33564    #[test]
33565    fn formats_harness_declarations_and_agent_bindings() {
33566        let source = r#"workflow HarnessFormat
33567harness coder: codex
33568agent implementer using coder {
33569profile "repo-writer"
33570capacity 1
33571}
33572"#;
33573
33574        let formatted = format_program(source);
33575        assert_eq!(formatted.diagnostics, Vec::new());
33576        let expected = concat!(
33577            "workflow HarnessFormat\n",
33578            "\n",
33579            "harness coder: codex\n",
33580            "\n",
33581            "agent implementer using coder {\n",
33582            "  profile \"repo-writer\"\n",
33583            "  capacity 1\n",
33584            "}\n",
33585        );
33586
33587        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33588    }
33589
33590    #[test]
33591    fn formats_explicit_workflow_blocks() {
33592        let source = r#"class Shared {
33593id string
33594}
33595workflow One {
33596input item Shared
33597rule start
33598when Shared as item
33599=> {complete result {id item.id}}
33600}
33601"#;
33602
33603        let formatted = format_program(source);
33604        assert_eq!(formatted.diagnostics, Vec::new());
33605        let expected = concat!(
33606            "class Shared {\n",
33607            "  id string\n",
33608            "}\n",
33609            "\n",
33610            "workflow One {\n",
33611            "  input item Shared\n",
33612            "\n",
33613            "  rule start\n",
33614            "    when Shared as item\n",
33615            "  => {\n",
33616            "    complete result {id item.id}\n",
33617            "  }\n",
33618            "}\n",
33619        );
33620
33621        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33622    }
33623
33624    #[test]
33625    fn formats_invoke_start_access_grants() {
33626        let source = r#"workflow Parent {
33627file store project_files { root "./data" allow read ["docs/**"] allow write ["reports/**"] }
33628class Task { id string }
33629rule dispatch
33630when Task as task
33631=> {
33632invoke Child {
33633task task
33634}
33635with access to project_files {
33636read ["docs/**"]
33637write ["reports/**"]
33638}
33639as child
33640}
33641}
33642
33643workflow Child {
33644input task Task
33645class Task { id string }
33646}
33647"#;
33648
33649        let formatted = format_program(source);
33650        assert_eq!(formatted.diagnostics, Vec::new());
33651        let expected = concat!(
33652            "workflow Parent {\n",
33653            "  file store project_files {\n",
33654            "    root \"./data\"\n",
33655            "    allow read [\"docs/**\"]\n",
33656            "    allow write [\"reports/**\"]\n",
33657            "  }\n",
33658            "\n",
33659            "  class Task {\n",
33660            "    id string\n",
33661            "  }\n",
33662            "\n",
33663            "  rule dispatch\n",
33664            "    when Task as task\n",
33665            "  => {\n",
33666            "    invoke Child {\n",
33667            "      task task\n",
33668            "    }\n",
33669            "    with access to project_files {\n",
33670            "      read [\"docs/**\"]\n",
33671            "      write [\"reports/**\"]\n",
33672            "    }\n",
33673            "    as child\n",
33674            "  }\n",
33675            "}\n",
33676            "\n",
33677            "workflow Child {\n",
33678            "  input task Task\n",
33679            "\n",
33680            "  class Task {\n",
33681            "    id string\n",
33682            "  }\n",
33683            "}\n",
33684        );
33685
33686        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33687    }
33688
33689    #[test]
33690    fn formats_patterns_and_apply_syntax() {
33691        let source = r#"pattern Review<Input>{
33692rule dispatch
33693when Input as item
33694=> {}
33695}
33696workflow Root {
33697apply Review<Task> as taskReview {}
33698}
33699"#;
33700
33701        let formatted = format_program(source);
33702        assert_eq!(formatted.diagnostics, Vec::new());
33703        let expected = concat!(
33704            "pattern Review<Input> {\n",
33705            "  rule dispatch\n",
33706            "    when Input as item\n",
33707            "  => {\n",
33708            "  }\n",
33709            "}\n",
33710            "\n",
33711            "workflow Root {\n",
33712            "  apply Review<Task> as taskReview {\n",
33713            "  }\n",
33714            "}\n",
33715        );
33716
33717        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33718    }
33719
33720    #[test]
33721    fn lexer_captures_comments_without_affecting_tokens() {
33722        let source =
33723            "# top comment\nworkflow Demo\n\nclass Task {\n  title string  // trailing\n}\n";
33724        let comments = lex_comments(source);
33725        assert_eq!(comments.len(), 2);
33726        assert_eq!(comments[0].marker, CommentMarker::Hash);
33727        assert_eq!(comments[0].text, "top comment");
33728        assert_eq!(comments[1].marker, CommentMarker::Slash);
33729        assert_eq!(comments[1].text, "trailing");
33730        // Spans point back at the original source slice (marker through line end).
33731        let first = &comments[0];
33732        assert_eq!(&source[first.span.start..first.span.end], "# top comment");
33733        // Comments stay out of the parse: the program still compiles cleanly.
33734        let compiled = compile_program(source);
33735        assert_eq!(compiled.diagnostics, Vec::new());
33736    }
33737
33738    #[test]
33739    fn test_block_parses_given_run_and_expect_clauses() {
33740        let source = r#"
33741@service
33742workflow Demo
33743
33744test "ci triage" {
33745  given signal github.workflow_failed {
33746    run_id "run_123"
33747  }
33748  stub agent triager succeeds
33749  run until idle
33750  expect issue count where external_id == "run_123" is 1
33751  expect rule triage_failed_run fired
33752}
33753"#;
33754        let compiled = compile_program(source);
33755        assert_eq!(compiled.diagnostics, Vec::new());
33756        let ir = compiled.ir.expect("program compiles");
33757        assert_eq!(ir.tests.len(), 1);
33758        let test = &ir.tests[0];
33759        assert_eq!(test.name, "ci triage");
33760        assert_eq!(test.clauses.len(), 5);
33761
33762        match &test.clauses[0] {
33763            TestClause::Given(GivenClause::Signal { name, fields, .. }) => {
33764                assert_eq!(name, "github.workflow_failed");
33765                assert_eq!(fields.len(), 1);
33766                assert_eq!(fields[0].name.name, "run_id");
33767                assert_eq!(fields[0].value, "\"run_123\"");
33768            }
33769            other => panic!("expected given signal, got {other:?}"),
33770        }
33771        match &test.clauses[1] {
33772            TestClause::Stub(stub) => {
33773                assert_eq!(stub.surface, vec!["agent".to_owned(), "triager".to_owned()]);
33774                assert_eq!(stub.outcome, "succeeds");
33775            }
33776            other => panic!("expected stub, got {other:?}"),
33777        }
33778        assert!(matches!(
33779            &test.clauses[2],
33780            TestClause::Run(RunClause {
33781                kind: RunKind::UntilIdle,
33782                ..
33783            })
33784        ));
33785        match &test.clauses[3] {
33786            TestClause::Expect(ExpectClause {
33787                target: ExpectTarget::Projection(query),
33788                ..
33789            }) => {
33790                assert_eq!(query.noun, "issue");
33791                match &query.kind {
33792                    ProjQueryKind::Count { predicate, count } => {
33793                        assert_eq!(predicate, "external_id == \"run_123\"");
33794                        assert_eq!(*count, 1);
33795                    }
33796                    other => panic!("expected count query, got {other:?}"),
33797                }
33798            }
33799            other => panic!("expected expect projection, got {other:?}"),
33800        }
33801        match &test.clauses[4] {
33802            TestClause::Expect(ExpectClause {
33803                target: ExpectTarget::Rule { name, status },
33804                ..
33805            }) => {
33806                assert_eq!(name.name, "triage_failed_run");
33807                assert_eq!(*status, RuleStatus::Fired);
33808            }
33809            other => panic!("expected expect rule, got {other:?}"),
33810        }
33811    }
33812
33813    #[test]
33814    fn test_block_rejects_a_malformed_predicate() {
33815        let source = r#"
33816@service
33817workflow Demo
33818
33819test "bad predicate" {
33820  run until idle
33821  expect issue count where == == is 1
33822}
33823"#;
33824        let compiled = compile_program(source);
33825        assert!(
33826            compiled
33827                .diagnostics
33828                .iter()
33829                .any(|diagnostic| diagnostic.message.contains("predicate on `issue`")),
33830            "{:?}",
33831            compiled.diagnostics
33832        );
33833    }
33834
33835    #[test]
33836    fn source_clock_block_lowers_to_clock_source() {
33837        let source = r#"
33838workflow ClockSource
33839
33840signal triage.tick {
33841  scheduled_at time
33842  observed_at time
33843  occurrence_id string
33844  missed_count int
33845}
33846
33847source clock as daily_triage {
33848  every weekday at 09:00
33849  timezone "America/New_York"
33850  missed coalesce
33851
33852  observe as tick
33853  emit triage.tick {
33854    scheduled_at tick.scheduled_at
33855    observed_at tick.observed_at
33856    occurrence_id tick.occurrence_id
33857    missed_count tick.missed_count
33858  }
33859}
33860"#;
33861        let compiled = compile_program(source);
33862        assert_eq!(compiled.diagnostics, Vec::new());
33863        let ir = compiled.ir.expect("program compiles");
33864        assert_eq!(ir.sources.len(), 1);
33865        let decl = &ir.sources[0];
33866        assert_eq!(decl.name, "daily_triage");
33867        assert_eq!(decl.provider, "clock");
33868        assert!(decl.is_clock);
33869        assert_eq!(decl.observe_binding, "tick");
33870        assert_eq!(decl.emit_signal, "triage.tick");
33871        assert_eq!(decl.emit_fields.len(), 4);
33872        assert_eq!(decl.timezone.as_deref(), Some("America/New_York"));
33873        assert_eq!(decl.missed, Some(MissedPolicy::Coalesce));
33874        match &decl.recurrence {
33875            Some(Recurrence::EveryCalendar { pattern, time, .. }) => {
33876                assert_eq!(*pattern, CalendarPattern::Weekday);
33877                assert_eq!(time.hour, 9);
33878                assert_eq!(time.minute, 0);
33879            }
33880            other => panic!("expected calendar recurrence, got {other:?}"),
33881        }
33882        // T1 (spec/std-time.md): declaring a clock source registers the
33883        // std.time standard library, exactly as a channel declaration
33884        // registers std.messaging.
33885        let registry = ir.contract_registry();
33886        assert!(
33887            registry
33888                .libraries
33889                .iter()
33890                .any(|library| library.id == "std.time" && library.standard),
33891            "clock source registers std.time: {:?}",
33892            registry.libraries
33893        );
33894    }
33895
33896    #[test]
33897    fn gauge_and_campaign_declarations_parse_and_lower() {
33898        let source = r##"
33899@service
33900workflow Improve
33901
33902output result R
33903class R { v string }
33904signal go.now { x string }
33905
33906coerce DueDateJudge(v string) -> R {
33907  prompt """markdown
33908  Judge {{ v }}.
33909
33910  {{ ctx.output_format }}
33911  """
33912}
33913
33914gauge extract_quality on j.result {
33915  judge via coerce DueDateJudge
33916  expect P(due_date_correct) at least 0.9
33917}
33918
33919gauge tail_latency {
33920  judge via exec "./latency_check.py"
33921  expect p90 at most 800
33922}
33923
33924gauge fulfillment_cost {
33925  judge via exec "./cost_model.py"
33926  inputs extract_quality, std.spend
33927}
33928
33929campaign release_tuning {
33930  ascend extract_quality
33931  reach std.latency at most 800ms
33932  guard tail_latency within 2 percent
33933  sacrifice fulfillment_cost
33934  proposer redacted
33935}
33936
33937rule j
33938  when go.now as g
33939=> {
33940  complete result {
33941    v "ok"
33942  }
33943}
33944"##;
33945        let compiled = compile_program(source);
33946        assert_eq!(compiled.diagnostics, Vec::new());
33947        let ir = compiled.ir.expect("program compiles");
33948        assert_eq!(ir.gauges.len(), 3);
33949        let extract = &ir.gauges[0];
33950        assert_eq!(extract.name, "extract_quality");
33951        assert_eq!(extract.site.as_deref(), Some("j.result"));
33952        assert_eq!(extract.judge_kind, "coerce");
33953        assert_eq!(extract.judge_target, "DueDateJudge");
33954        let bar = extract.expect.as_ref().expect("bar declared");
33955        assert_eq!(
33956            (
33957                bar.form.as_str(),
33958                bar.subject.as_str(),
33959                bar.op.as_str(),
33960                bar.threshold.as_str()
33961            ),
33962            ("chance", "due_date_correct", ">=", "0.9")
33963        );
33964        let tail = &ir.gauges[1];
33965        let tail_bar = tail.expect.as_ref().expect("stat bar declared");
33966        assert_eq!(
33967            (
33968                tail_bar.form.as_str(),
33969                tail_bar.subject.as_str(),
33970                tail_bar.op.as_str()
33971            ),
33972            ("stat", "p90", "<=")
33973        );
33974        let derived = &ir.gauges[2];
33975        assert_eq!(derived.judge_kind, "exec");
33976        assert_eq!(derived.inputs, vec!["extract_quality", "std.spend"]);
33977        assert_eq!(ir.campaigns.len(), 1);
33978        let campaign = &ir.campaigns[0];
33979        assert_eq!(campaign.ascend, vec!["extract_quality"]);
33980        assert_eq!(campaign.reach.len(), 1);
33981        assert_eq!(campaign.reach[0].gauge, "std.latency");
33982        assert_eq!(campaign.reach[0].op, "<=");
33983        assert_eq!(campaign.reach[0].threshold, "800");
33984        assert_eq!(campaign.reach[0].unit.as_deref(), Some("ms"));
33985        assert_eq!(campaign.guard[0].gauge, "tail_latency");
33986        assert_eq!(campaign.guard[0].band_percent, "2");
33987        assert_eq!(campaign.sacrifice, vec!["fulfillment_cost"]);
33988        assert!(campaign.proposer_redacted);
33989        let snapshot = ir.to_snapshot();
33990        assert!(snapshot.contains("gauge extract_quality judge=coerce:DueDateJudge site=j.result expect=chance:due_date_correct>=0.9"));
33991        assert!(snapshot.contains(
33992            "campaign release_tuning ascend=extract_quality reach=std.latency<=800ms guard=tail_latency:within:2% sacrifice=fulfillment_cost proposer=redacted"
33993        ));
33994    }
33995
33996    #[test]
33997    fn mark_declaration_parses_lowers_and_validates() {
33998        let source = r##"
33999@service
34000workflow Improve
34001
34002output result R
34003class R { v string }
34004signal go.now { x string }
34005
34006mark "triaged" after j
34007
34008rule j
34009  when go.now as g
34010=> {
34011  complete result {
34012    v "ok"
34013  }
34014}
34015"##;
34016        let compiled = compile_program(source);
34017        assert_eq!(compiled.diagnostics, Vec::new());
34018        let ir = compiled.ir.expect("program compiles");
34019        assert_eq!(ir.marks.len(), 1);
34020        assert_eq!(ir.marks[0].name, "triaged");
34021        assert_eq!(ir.marks[0].site, "j");
34022        assert!(ir.to_snapshot().contains("mark \"triaged\" after j"));
34023        // An unknown site is a diagnostic.
34024        let unknown = compile_program(&source.replace(
34025            "mark \"triaged\" after j",
34026            "mark \"nowhere\" after missing_rule",
34027        ));
34028        assert!(unknown.diagnostics.iter().any(|d| d
34029            .message
34030            .contains("mark `nowhere` rides unknown site `missing_rule`")));
34031        // Duplicate names are rejected.
34032        let dup = compile_program(&source.replace(
34033            "mark \"triaged\" after j",
34034            "mark \"triaged\" after j\nmark \"triaged\" after j",
34035        ));
34036        assert!(dup.diagnostics.iter().any(|d| d
34037            .message
34038            .contains("mark `triaged` is declared more than once")));
34039        // Formatting is idempotent.
34040        let formatted = format_program(source).formatted.expect("formats");
34041        assert!(formatted.contains("mark \"triaged\" after j"));
34042        assert_eq!(
34043            format_program(&formatted).formatted.expect("reformats"),
34044            formatted
34045        );
34046    }
34047
34048    #[test]
34049    fn coerce_judge_explicit_arguments_parse_lower_and_validate() {
34050        let program = |judge_line: &str| {
34051            format!(
34052                r##"
34053@service
34054workflow Improve
34055
34056output result R
34057class R {{ v string }}
34058class Ticket {{ title string }}
34059signal go.now {{ x string }}
34060
34061coerce Assess(title string, priority string) -> R {{
34062  prompt """markdown
34063  Judge {{{{ title }}}} at {{{{ priority }}}}.
34064
34065  {{{{ ctx.output_format }}}}
34066  """
34067}}
34068
34069gauge quality {{
34070  {judge_line}
34071}}
34072
34073rule j
34074  when go.now as g
34075=> {{
34076  complete result {{
34077    v "ok"
34078  }}
34079}}
34080"##
34081            )
34082        };
34083        // Explicit paths parse, lower in order, and roundtrip through fmt.
34084        let source =
34085            program("judge via coerce Assess(input.ticket.title, facts.Assessment.priority)");
34086        let compiled = compile_program(&source);
34087        assert_eq!(compiled.diagnostics, Vec::new());
34088        let ir = compiled.ir.expect("compiles");
34089        assert_eq!(
34090            ir.gauges[0].judge_args,
34091            vec!["input.ticket.title", "facts.Assessment.priority"]
34092        );
34093        let formatted = format_program(&source).formatted.expect("formats");
34094        assert!(
34095            formatted
34096                .contains("judge via coerce Assess(input.ticket.title, facts.Assessment.priority)"),
34097            "fmt keeps the binding: {formatted}"
34098        );
34099        // Arity is checked: a drifted signature is a check error, never a
34100        // silently rebound judge.
34101        let compiled = compile_program(&program("judge via coerce Assess(input.ticket.title)"));
34102        assert!(
34103            compiled
34104                .diagnostics
34105                .iter()
34106                .any(|diagnostic| diagnostic.message.contains("passes 1 argument")),
34107            "{:?}",
34108            compiled.diagnostics
34109        );
34110        // Paths must be record paths.
34111        let compiled = compile_program(&program(
34112            "judge via coerce Assess(whatever.title, facts.Assessment.priority)",
34113        ));
34114        assert!(
34115            compiled
34116                .diagnostics
34117                .iter()
34118                .any(|diagnostic| diagnostic.message.contains("not a record path")),
34119            "{:?}",
34120            compiled.diagnostics
34121        );
34122        // The reserved `(record)` form needs a single-parameter coerce.
34123        let compiled = compile_program(&program("judge via coerce Assess(record)"));
34124        assert!(
34125            compiled
34126                .diagnostics
34127                .iter()
34128                .any(|diagnostic| diagnostic.message.contains("single-parameter")),
34129            "{:?}",
34130            compiled.diagnostics
34131        );
34132        // Bare (no arguments) still parses — declared, honestly unscoreable.
34133        let compiled = compile_program(&program("judge via coerce Assess"));
34134        assert_eq!(compiled.diagnostics, Vec::new());
34135        assert!(compiled.ir.expect("compiles").gauges[0]
34136            .judge_args
34137            .is_empty());
34138    }
34139
34140    #[test]
34141    fn gauge_and_campaign_cross_reference_validation() {
34142        let source = r##"
34143@service
34144workflow Improve
34145
34146output result R
34147class R { v string }
34148signal go.now { x string }
34149
34150gauge broken_judge {
34151  judge via coerce MissingJudge
34152}
34153
34154gauge broken_inputs {
34155  judge via prompt "score this"
34156  inputs nowhere
34157}
34158
34159campaign confused {
34160  ascend broken_judge
34161  sacrifice broken_judge
34162}
34163
34164campaign unknown_ref {
34165  ascend nowhere_else
34166}
34167
34168rule j
34169  when go.now as g
34170=> {
34171  complete result {
34172    v "ok"
34173  }
34174}
34175"##;
34176        let compiled = compile_program(source);
34177        let messages: Vec<String> = compiled
34178            .diagnostics
34179            .iter()
34180            .map(|diagnostic| diagnostic.message.clone())
34181            .collect();
34182        assert!(messages
34183            .iter()
34184            .any(|m| m.contains("judges via undeclared coerce `MissingJudge`")));
34185        assert!(messages
34186            .iter()
34187            .any(|m| m.contains("derived gauge `broken_inputs` must judge via exec")));
34188        assert!(messages
34189            .iter()
34190            .any(|m| m.contains("unknown gauge `nowhere`")));
34191        assert!(messages
34192            .iter()
34193            .any(|m| m.contains("unknown gauge `nowhere_else`")));
34194        assert!(messages
34195            .iter()
34196            .any(|m| m.contains("names gauge `broken_judge` as both ascend and sacrifice")));
34197    }
34198
34199    #[test]
34200    fn campaign_naming_nothing_is_rejected_at_parse() {
34201        let source = r##"
34202@service
34203workflow Improve
34204
34205output result R
34206class R { v string }
34207signal go.now { x string }
34208
34209campaign nothing_named {
34210  guard std.spend within 5 percent
34211}
34212
34213rule j
34214  when go.now as g
34215=> {
34216  complete result {
34217    v "ok"
34218  }
34219}
34220"##;
34221        let compiled = compile_program(source);
34222        assert!(compiled.diagnostics.iter().any(|d| d
34223            .message
34224            .contains("campaign `nothing_named` names nothing to improve")));
34225    }
34226
34227    #[test]
34228    fn gauge_bar_operator_gets_word_form_diagnostic() {
34229        let source = r##"
34230@service
34231workflow Improve
34232
34233output result R
34234class R { v string }
34235signal go.now { x string }
34236
34237gauge extract_quality {
34238  judge via exec "./judge.py"
34239  expect P(ok) >= 0.9
34240}
34241
34242rule j
34243  when go.now as g
34244=> {
34245  complete result {
34246    v "ok"
34247  }
34248}
34249"##;
34250        let compiled = compile_program(source);
34251        assert!(compiled.diagnostics.iter().any(|diagnostic| {
34252            diagnostic
34253                .suggestion
34254                .as_deref()
34255                .is_some_and(|s| s.contains("write `at least`"))
34256        }));
34257    }
34258
34259    #[test]
34260    fn formats_gauge_and_campaign_declarations() {
34261        let source = "workflow Improve\n\n\ngauge extract_quality on j.result {\n  judge via exec \"./judge.py\"\n  expect P(ok) at least 0.9\n}\n\ncampaign release_tuning {\n  ascend extract_quality\n  reach std.latency at most 800ms\n  guard std.tokens within 2 percent\n  sacrifice std.spend\n  proposer redacted\n}\n";
34262        let formatted = format_program(source);
34263        assert_eq!(formatted.diagnostics, Vec::new());
34264        let once = formatted.formatted.expect("formats");
34265        assert!(once.contains("gauge extract_quality on j.result {"));
34266        assert!(once.contains("  judge via exec \"./judge.py\""));
34267        assert!(once.contains("  expect P(ok) at least 0.9"));
34268        assert!(once.contains("campaign release_tuning {"));
34269        assert!(once.contains("  reach std.latency at most 800ms"));
34270        assert!(once.contains("  guard std.tokens within 2 percent"));
34271        assert!(once.contains("  proposer redacted"));
34272        let twice = format_program(&once).formatted.expect("reformats");
34273        assert_eq!(once, twice, "gauge/campaign formatting is idempotent");
34274    }
34275
34276    #[test]
34277    fn channel_declaration_parses_and_lowers() {
34278        let source = r##"
34279@service
34280workflow ChannelDecl
34281
34282use std.messaging
34283
34284channel release_room {
34285  provider fixture
34286  workspace ops
34287  destination "#release"
34288}
34289
34290output result R
34291class R { v string }
34292signal go.now { x string }
34293
34294rule j
34295  when go.now as g
34296=> {
34297  complete result {
34298    v "ok"
34299  }
34300}
34301"##;
34302        let compiled = compile_program(source);
34303        assert_eq!(compiled.diagnostics, Vec::new());
34304        let ir = compiled.ir.expect("program compiles");
34305        assert_eq!(ir.channels.len(), 1);
34306        let channel = &ir.channels[0];
34307        assert_eq!(channel.name, "release_room");
34308        assert_eq!(channel.provider, "fixture");
34309        assert_eq!(channel.workspace.as_deref(), Some("ops"));
34310        assert_eq!(channel.destination.as_deref(), Some("#release"));
34311        // The channel construct auto-registers std.messaging in the contract
34312        // registry (like leases -> std.coord), and `use std.messaging` parses as
34313        // a dotted package name.
34314        let registry = ir.contract_registry();
34315        assert!(registry
34316            .libraries
34317            .iter()
34318            .any(|library| library.id == "std.messaging"));
34319        // The generic `Message` envelope is a built-in referenceable schema.
34320        assert!(SchemaIndex::with_builtins().class_exists("Message"));
34321        // The `send` construct's typed receipt (the messaging.send contract's
34322        // output schema) is a built-in class so `send … as r` binds a typed r.
34323        assert!(SchemaIndex::with_builtins().class_exists("MessageSendReceipt"));
34324    }
34325
34326    #[test]
34327    fn single_line_multi_field_terminal_payload_collects_every_field() {
34328        // R5 papercut: `complete result { first "a" second "b" }` on ONE line
34329        // must satisfy the required-field check for both fields (token-level
34330        // splitting, not line-based).
34331        let source = r#"
34332workflow OneLine
34333
34334output result Done
34335
34336class Done {
34337  first string
34338  second string
34339}
34340
34341rule r
34342  when started
34343=> {
34344  complete result { first "a" second "b" }
34345}
34346"#;
34347        let compiled = compile_program(source);
34348        assert_eq!(compiled.diagnostics, Vec::new());
34349        assert!(compiled.ir.is_some());
34350    }
34351
34352    #[test]
34353    fn file_store_is_read_only_by_default() {
34354        // S4: a store with no `allow write` policy rejects writes at check
34355        // time; reads need no clause; `allow write` permits and bounds writes.
34356        let program = |allow: &str| {
34357            format!(
34358                r#"
34359use std.files
34360
34361workflow Posture
34362
34363output result Done
34364
34365class Done {{
34366  note string
34367}}
34368
34369file store docs {{
34370  root "./docs"
34371{allow}}}
34372
34373rule r
34374  when started
34375=> {{
34376  write text to docs at "out.txt" {{
34377    body "x"
34378    mode create
34379  }} as out
34380
34381  after out completes {{
34382    complete result {{ note "done" }}
34383  }}
34384}}
34385"#
34386            )
34387        };
34388        let denied = compile_program(&program(""));
34389        assert!(
34390            denied
34391                .diagnostics
34392                .iter()
34393                .any(|d| d.message.contains("permits no writes")),
34394            "{:?}",
34395            denied.diagnostics
34396        );
34397        let allowed = compile_program(&program("  allow write [\"**\"]\n"));
34398        assert_eq!(allowed.diagnostics, Vec::new());
34399
34400        // Reads against a bare store stay clean.
34401        let read_only = compile_program(
34402            r#"
34403use std.files
34404
34405workflow ReadOnly
34406
34407output result Done
34408
34409class Done {
34410  note string
34411}
34412
34413file store docs {
34414  root "./docs"
34415}
34416
34417rule r
34418  when started
34419=> {
34420  read text from docs at "in.txt" as doc
34421
34422  after doc completes {
34423    complete result { note "done" }
34424  }
34425}
34426"#,
34427        );
34428        assert_eq!(read_only.diagnostics, Vec::new());
34429    }
34430
34431    #[test]
34432    fn tracker_bare_defaults_provider_to_builtin() {
34433        // S1 (surface-defaults batch): `tracker <name>` bare — no block —
34434        // defaults the provider to `builtin`.
34435        let source = r#"
34436@service
34437workflow TrackerBare
34438
34439tracker backlog
34440
34441class Item { id string }
34442signal go.now { x string }
34443rule j
34444  when go.now as g
34445=> {
34446  file issue into backlog {
34447    title g.x
34448  }
34449}
34450"#;
34451        let compiled = compile_program(source);
34452        let ir = compiled.ir.expect("compiles");
34453        assert_eq!(ir.trackers.len(), 1);
34454        assert_eq!(ir.trackers[0].provider, "builtin");
34455    }
34456
34457    #[test]
34458    fn emit_signal_from_projects_bounded_fields() {
34459        // S6: `emit signal <name> to <target> from <binding>` parses (block
34460        // optional; an override block allows shorthand), mirroring
34461        // `record … from`.
34462        let source = r#"
34463use std.ingress
34464
34465@service
34466workflow EmitFrom
34467
34468signal deploy.finished {
34469  service string
34470  peer string
34471}
34472
34473signal deploy.acknowledged {
34474  service string
34475}
34476
34477rule relay
34478  when deploy.finished as deployed
34479=> {
34480  emit signal deploy.acknowledged to deployed.peer from deployed as sent
34481}
34482"#;
34483        let compiled = compile_program(source);
34484        assert_eq!(compiled.diagnostics, Vec::new());
34485        assert!(compiled.ir.is_some());
34486    }
34487
34488    #[test]
34489    fn inline_contract_payload_synthesizes_anonymous_class() {
34490        // S7: `output result { message string }` synthesizes the hygienic
34491        // `output.result` class (the `decide` precedent); the terminal payload
34492        // validates against it.
34493        let source = r#"
34494workflow Inline
34495
34496output result {
34497  message string
34498}
34499
34500failure error {
34501  reason string
34502}
34503
34504rule r
34505  when started
34506=> {
34507  complete result {
34508    message "hello"
34509  }
34510}
34511"#;
34512        let compiled = compile_program(source);
34513        assert_eq!(compiled.diagnostics, Vec::new());
34514        let ir = compiled.ir.expect("compiles");
34515        assert!(ir.schemas.iter().any(|schema| matches!(
34516            schema,
34517            IrSchema::Class(class) if class.name == "output.result"
34518        )));
34519        assert!(ir.schemas.iter().any(|schema| matches!(
34520            schema,
34521            IrSchema::Class(class) if class.name == "failure.error"
34522        )));
34523
34524        // A bad payload field is still a check error against the synthesized class.
34525        let bad = compile_program(
34526            r#"
34527workflow InlineBad
34528
34529output result {
34530  message string
34531}
34532
34533rule r
34534  when started
34535=> {
34536  complete result {
34537    wrong "hello"
34538  }
34539}
34540"#,
34541        );
34542        assert!(
34543            !bad.diagnostics.is_empty(),
34544            "unknown field on the synthesized class must be rejected"
34545        );
34546    }
34547
34548    #[test]
34549    fn channel_defaults_provider_to_local() {
34550        // S2 (surface-defaults batch): a channel without a `provider` clause —
34551        // block or bare — defaults to `local`.
34552        let source = r#"
34553use std.messaging
34554use std.ingress
34555
34556@service
34557workflow ChannelDefault
34558
34559channel orphan {
34560  workspace ops
34561}
34562
34563channel bare
34564
34565output result R
34566class R { v string }
34567signal go.now { x string }
34568rule j
34569  when go.now as g
34570=> { complete result { v "ok" } }
34571"#;
34572        let compiled = compile_program(source);
34573        assert_eq!(compiled.diagnostics, Vec::new());
34574        let ir = compiled.ir.expect("compiles");
34575        assert!(
34576            ir.channels
34577                .iter()
34578                .all(|channel| channel.provider == "local"),
34579            "{:?}",
34580            ir.channels
34581        );
34582        assert_eq!(ir.channels.len(), 2);
34583    }
34584
34585    #[test]
34586    fn duplicate_channel_is_rejected() {
34587        let source = r#"
34588@service
34589workflow DupChannel
34590
34591channel room {
34592  provider fixture
34593}
34594channel room {
34595  provider discord
34596}
34597
34598output result R
34599class R { v string }
34600signal go.now { x string }
34601rule j
34602  when go.now as g
34603=> { complete result { v "ok" } }
34604"#;
34605        let compiled = compile_program(source);
34606        let dup = compiled
34607            .diagnostics
34608            .iter()
34609            .find(|d| d.message.contains("declared more than once"))
34610            .expect("expected duplicate-channel diagnostic");
34611        // The diagnostic carries related-information pointing at the first
34612        // declaration (spec/error-handling.md "Spans And Labels").
34613        assert_eq!(dup.related.len(), 1, "expected one related-info entry");
34614        assert_eq!(dup.related[0].message, "first declared here");
34615        assert!(dup.related[0].span.start < dup.span.start);
34616    }
34617
34618    #[test]
34619    fn when_message_from_binds_message_and_validates_channel() {
34620        // `when message from <channel> as msg` binds the built-in `Message`
34621        // envelope and the channel must be declared (spec/messaging.md).
34622        let ok = compile_program(
34623            r#"
34624@service
34625workflow Inbound
34626
34627channel release_room {
34628  provider fixture
34629}
34630
34631output result Decision
34632class Decision { note string }
34633
34634rule react
34635  when message from release_room as msg
34636=> {
34637  complete result { note msg.text }
34638}
34639"#,
34640        );
34641        assert!(
34642            ok.diagnostics.is_empty(),
34643            "expected clean compile, got {:?}",
34644            ok.diagnostics
34645        );
34646        // `msg.text` resolving against the Message schema proves the binding typed.
34647
34648        let bad = compile_program(
34649            r#"
34650@service
34651workflow Inbound
34652
34653channel release_room {
34654  provider fixture
34655}
34656
34657output result Decision
34658class Decision { note string }
34659
34660rule react
34661  when message from typo_room as msg
34662=> {
34663  complete result { note msg.text }
34664}
34665"#,
34666        );
34667        assert!(
34668            bad.diagnostics.iter().any(|d| d
34669                .message
34670                .contains("`when message from typo_room` names an unknown channel")),
34671            "expected unknown-channel diagnostic, got {:?}",
34672            bad.diagnostics
34673        );
34674    }
34675
34676    #[test]
34677    fn unknown_channel_provider_is_a_check_error() {
34678        // spec/std-messaging.md "Static checks": a channel `provider`
34679        // identifier must resolve to a v1 provider capability report; a
34680        // free-form name (`slack` shipped in old demos) is a check error.
34681        let compiled = compile_program(
34682            r##"
34683@service
34684workflow UnknownProvider
34685
34686channel ops_room {
34687  provider slack
34688  destination "#ops"
34689}
34690
34691output result R
34692class R { v string }
34693signal go.now { x string }
34694rule j
34695  when go.now as g
34696=> { complete result { v "ok" } }
34697"##,
34698        );
34699        let unknown = compiled
34700            .diagnostics
34701            .iter()
34702            .find(|d| {
34703                d.message
34704                    .contains("channel `ops_room` names unknown messaging provider `slack`")
34705            })
34706            .expect("expected unknown-provider diagnostic");
34707        assert!(
34708            unknown
34709                .suggestion
34710                .as_deref()
34711                .is_some_and(|s| s.contains("fixture") && s.contains("desktop")),
34712            "suggestion lists the v1 providers: {:?}",
34713            unknown.suggestion
34714        );
34715    }
34716
34717    #[test]
34718    fn desktop_channel_is_outbound_only_at_check_time() {
34719        // The v1 acceptance test (spec/std-messaging.md "Static checks"):
34720        // send/receive-capable providers are DISTINGUISHABLE. `send via` a
34721        // desktop channel passes; `when message from` the same channel is a
34722        // check error conditioned on the provider's capability report.
34723        let send_ok = compile_program(
34724            r#"
34725@service
34726workflow DesktopSend
34727
34728use std.messaging
34729
34730channel alerts {
34731  provider desktop
34732}
34733
34734output result R
34735class R { v string }
34736signal go.now { x string }
34737
34738rule j
34739  when go.now as g
34740=> {
34741  send via alerts {
34742    text "ping"
34743  } as sent
34744
34745  after sent succeeds {
34746    complete result { v "ok" }
34747  }
34748}
34749"#,
34750        );
34751        assert!(
34752            send_ok.diagnostics.is_empty(),
34753            "outbound send over desktop passes: {:?}",
34754            send_ok.diagnostics
34755        );
34756
34757        let inbound_bad = compile_program(
34758            r#"
34759@service
34760workflow DesktopInbound
34761
34762channel alerts {
34763  provider desktop
34764}
34765
34766output result R
34767class R { v string }
34768
34769rule react
34770  when message from alerts as msg
34771=> { complete result { v msg.text } }
34772"#,
34773        );
34774        assert!(
34775            inbound_bad.diagnostics.iter().any(|d| d.message.contains(
34776                "`when message from alerts` observes a channel whose provider `desktop` is outbound-only"
34777            )),
34778            "expected outbound-only diagnostic, got {:?}",
34779            inbound_bad.diagnostics
34780        );
34781
34782        // A bidirectional provider (`local`) admits both directions.
34783        let bidirectional = compile_program(
34784            r#"
34785@service
34786workflow LocalInbound
34787
34788channel alerts {
34789  provider local
34790}
34791
34792output result R
34793class R { v string }
34794
34795rule react
34796  when message from alerts as msg
34797=> { complete result { v msg.text } }
34798"#,
34799        );
34800        assert!(
34801            bidirectional.diagnostics.is_empty(),
34802            "bidirectional provider admits inbound observation: {:?}",
34803            bidirectional.diagnostics
34804        );
34805    }
34806
34807    #[test]
34808    fn channel_provider_reports_cover_the_v1_matrix() {
34809        // Report data is load-bearing for the conditioned checks: pin the v1
34810        // provider set, direction axis, and short-name/provider-id resolution.
34811        let shorts: Vec<&str> = CHANNEL_PROVIDER_REPORTS
34812            .iter()
34813            .map(|r| r.short_name)
34814            .collect();
34815        assert_eq!(shorts, ["fixture", "local", "desktop", "stdio"]);
34816        for report in CHANNEL_PROVIDER_REPORTS {
34817            assert!(
34818                matches!(
34819                    report.direction,
34820                    "outbound_only" | "inbound_only" | "bidirectional"
34821                ),
34822                "direction vocabulary: {}",
34823                report.direction
34824            );
34825            assert!(
34826                matches!(report.identity, "anonymous" | "claimed_actor"),
34827                "identity ladder is v1-narrowed (no verified_actor): {}",
34828                report.identity
34829            );
34830            assert_eq!(report.delivery_receipts, &["accepted", "failed"]);
34831            assert_eq!(
34832                channel_provider_report(report.short_name),
34833                Some(report),
34834                "short name resolves"
34835            );
34836            assert_eq!(
34837                channel_provider_report(report.provider_id),
34838                Some(report),
34839                "provider id resolves"
34840            );
34841        }
34842        assert_eq!(channel_provider_report("slack"), None);
34843        assert_eq!(
34844            channel_provider_report("desktop").map(|r| r.direction),
34845            Some("outbound_only")
34846        );
34847    }
34848
34849    #[test]
34850    fn duplicate_schema_diagnostic_points_at_first_declaration() {
34851        let source = r#"
34852@service
34853workflow DupSchema
34854
34855class Thing { v string }
34856class Thing { w string }
34857
34858output result R
34859class R { v string }
34860signal go.now { x string }
34861rule j
34862  when go.now as g
34863=> { complete result { v "ok" } }
34864"#;
34865        let compiled = compile_program(source);
34866        let dup = compiled
34867            .diagnostics
34868            .iter()
34869            .find(|d| {
34870                d.message
34871                    .contains("schema `Thing` is declared more than once")
34872            })
34873            .expect("expected duplicate-schema diagnostic");
34874        assert_eq!(dup.related.len(), 1);
34875        assert_eq!(dup.related[0].message, "first declared here");
34876        assert!(dup.related[0].span.start < dup.span.start);
34877    }
34878
34879    #[test]
34880    fn interval_clock_source_parses_duration() {
34881        let source = r#"
34882workflow Interval
34883
34884signal tick.beat {
34885  at_time time
34886}
34887
34888source clock as heartbeat {
34889  every 5m
34890  missed skip
34891
34892  observe as tick
34893  emit tick.beat {
34894    at_time tick.scheduled_at
34895  }
34896}
34897"#;
34898        let compiled = compile_program(source);
34899        assert_eq!(compiled.diagnostics, Vec::new());
34900        let ir = compiled.ir.expect("program compiles");
34901        match &ir.sources[0].recurrence {
34902            Some(Recurrence::EveryDuration { seconds, .. }) => assert_eq!(*seconds, 300),
34903            other => panic!("expected duration recurrence, got {other:?}"),
34904        }
34905        assert_eq!(ir.sources[0].missed, Some(MissedPolicy::Skip));
34906    }
34907
34908    #[test]
34909    fn fails_binding_types_to_effecterror_base() {
34910        // DR-0032: `after <effect> fails as f` types `f` to the EffectError base
34911        // (TerminalFailed: reason, summary, effect_id, run_id, kind). The base
34912        // fields read cleanly.
34913        let source = r#"
34914workflow W {
34915  input task T
34916  output result R
34917  failure error E
34918  class T { x string }
34919  class R { y string }
34920  class E { reason string detail string }
34921
34922  rule go when T as task => {
34923    exec "true" as e
34924    after e fails as f {
34925      fail error { reason f.reason detail f.kind }
34926    }
34927    after e succeeds {
34928      complete result { y task.x }
34929    }
34930  }
34931}
34932"#;
34933        let compiled = compile_program(source);
34934        assert!(
34935            !compiled
34936                .diagnostics
34937                .iter()
34938                .any(|d| d.message.contains("invalid field path")),
34939            "base fields should type-check: {:?}",
34940            compiled.diagnostics
34941        );
34942    }
34943
34944    #[test]
34945    fn fails_binding_rejects_non_base_field() {
34946        // The teeth of DR-0032 typing: a field outside the binding's per-kind
34947        // failure schema is a check error. An exec binding narrows to
34948        // TerminalFailedExec (exit_code is legal), but stderr is NOT exposed
34949        // (Decision 4 — redaction), and a coerce binding may not read exec's
34950        // extras (extras are reachable only under the matching kind).
34951        let exec_source = r#"
34952workflow W {
34953  input task T
34954  output result R
34955  failure error E
34956  class T { x string }
34957  class R { y string }
34958  class E { reason string }
34959
34960  rule go when T as task => {
34961    exec "true" as e
34962    after e fails as f {
34963      fail error { reason f.stderr }
34964    }
34965    after e succeeds {
34966      complete result { y task.x }
34967    }
34968  }
34969}
34970"#;
34971        let compiled = compile_program(exec_source);
34972        assert!(
34973            compiled
34974                .diagnostics
34975                .iter()
34976                .any(|d| d.message.contains("invalid field path `f.stderr`")),
34977            "{:?}",
34978            compiled.diagnostics
34979        );
34980
34981        let cross_kind = r#"
34982workflow W {
34983  input task T
34984  output result R
34985  failure error E
34986  class T { x string }
34987  class R { y string }
34988  class E { reason string }
34989  class V { note string }
34990
34991  coerce judge(x string) -> V {
34992    prompt "Classify {{ x }}"
34993  }
34994
34995  rule go when T as task => {
34996    coerce judge(task.x) as c
34997    after c fails as f {
34998      fail error { reason f.exit_code }
34999    }
35000    after c succeeds {
35001      complete result { y task.x }
35002    }
35003  }
35004}
35005"#;
35006        let compiled = compile_program(cross_kind);
35007        assert!(
35008            compiled
35009                .diagnostics
35010                .iter()
35011                .any(|d| d.message.contains("invalid field path `f.exit_code`")
35012                    && d.message.contains("TerminalFailedCoerce")),
35013            "a coerce binding must not read exec extras: {:?}",
35014            compiled.diagnostics
35015        );
35016    }
35017
35018    #[test]
35019    fn fails_binding_narrows_to_per_kind_failure_extras() {
35020        // DR-0032 P3 (DQ-2 static narrowing): the v1 per-kind extras are legal
35021        // exactly when the binding's effect kind matches — exec `exit_code`,
35022        // coerce `error_class` (+ optional `http_status`), tell `error_class`.
35023        let source = r#"
35024workflow W {
35025  input task T
35026  output result R
35027  failure error E
35028  class T { x string }
35029  class R { y string }
35030  class E { reason string code int klass string }
35031  class V { note string }
35032
35033  agent worker {
35034    provider fixture
35035    profile "repo-reader"
35036    capacity 1
35037  }
35038
35039  coerce judge(x string) -> V {
35040    prompt "Classify {{ x }}"
35041  }
35042
35043  rule go when T as task => {
35044    exec "true" as e
35045    coerce judge(task.x) as c
35046    tell worker as turn "go"
35047
35048    after e fails as fe {
35049      fail error { reason fe.reason code fe.exit_code klass "x" }
35050    }
35051    after c fails as fc {
35052      fail error { reason fc.reason code 0 klass fc.error_class }
35053    }
35054    after turn fails as ft {
35055      fail error { reason ft.reason code 0 klass ft.error_class }
35056    }
35057    after e succeeds {
35058      complete result { y task.x }
35059    }
35060  }
35061}
35062"#;
35063        let compiled = compile_program(source);
35064        assert!(
35065            !compiled
35066                .diagnostics
35067                .iter()
35068                .any(|d| d.message.contains("invalid field path")),
35069            "per-kind extras must type-check under the matching kind: {:?}",
35070            compiled.diagnostics
35071        );
35072    }
35073
35074    #[test]
35075    fn milestone_reaches_rejects_undeclared_milestone() {
35076        // Family C terminal-only observation invariant: a parent cannot observe a
35077        // milestone the invoked child never declares.
35078        let source = r#"
35079workflow Parent {
35080  input task Task
35081  class Task { title string }
35082  class Saw { note string }
35083
35084  rule dispatch when Task as task => {
35085    invoke Child { task { title task.title } } as child
35086    after child reaches "never_declared" as m {
35087      record Saw { note m.note }
35088    }
35089  }
35090}
35091
35092workflow Child {
35093  input task Task
35094  output result R
35095  class Task { title string }
35096  class R { title string }
35097  class P { note string }
35098
35099  rule go when Task as task => {
35100    emit milestone "actually_declared" of P { note task.title }
35101    complete result { title task.title }
35102  }
35103}
35104"#;
35105        let compiled = compile_program_with_root(source, Some("Parent"));
35106        assert!(
35107            compiled.diagnostics.iter().any(|d| d.message.contains(
35108                "reaches milestone `never_declared` that workflow `Child` does not declare"
35109            )),
35110            "{:?}",
35111            compiled.diagnostics
35112        );
35113    }
35114
35115    #[test]
35116    fn emit_milestone_rejects_unknown_payload_class() {
35117        let source = r#"
35118workflow Child {
35119  input task Task
35120  output result R
35121  class Task { title string }
35122  class R { title string }
35123
35124  rule go when Task as task => {
35125    emit milestone "m1" of Nonexistent { note task.title }
35126    complete result { title task.title }
35127  }
35128}
35129"#;
35130        let compiled = compile_program(source);
35131        assert!(
35132            compiled.diagnostics.iter().any(|d| d
35133                .message
35134                .contains("emits milestone `m1` with unknown payload class `Nonexistent`")),
35135            "{:?}",
35136            compiled.diagnostics
35137        );
35138    }
35139
35140    #[test]
35141    fn milestone_reaches_accepts_declared_milestone() {
35142        // The positive control: a declared milestone is accepted (no
35143        // reject-undeclared / unknown-class diagnostics).
35144        let source = r#"
35145workflow Parent {
35146  input task Task
35147  class Task { title string }
35148  class Saw { note string }
35149
35150  rule dispatch when Task as task => {
35151    invoke Child { task { title task.title } } as child
35152    after child reaches "halfway" as m {
35153      record Saw { note m.note }
35154    }
35155  }
35156}
35157
35158workflow Child {
35159  input task Task
35160  output result R
35161  class Task { title string }
35162  class R { title string }
35163  class P { note string }
35164
35165  rule go when Task as task => {
35166    emit milestone "halfway" of P { note task.title }
35167    complete result { title task.title }
35168  }
35169}
35170"#;
35171        let compiled = compile_program_with_root(source, Some("Parent"));
35172        assert!(
35173            !compiled
35174                .diagnostics
35175                .iter()
35176                .any(|d| d.message.contains("reaches milestone")
35177                    || d.message.contains("unknown payload class")),
35178            "{:?}",
35179            compiled.diagnostics
35180        );
35181    }
35182
35183    #[test]
35184    fn recurring_clock_source_requires_missed() {
35185        let source = r#"
35186workflow NeedsMissed
35187
35188signal triage.tick {
35189  scheduled_at time
35190}
35191
35192source clock as daily {
35193  every weekday at 09:00
35194  timezone "UTC"
35195
35196  observe as tick
35197  emit triage.tick {
35198    scheduled_at tick.scheduled_at
35199  }
35200}
35201"#;
35202        let compiled = compile_program(source);
35203        assert!(
35204            compiled.diagnostics.iter().any(|diagnostic| diagnostic
35205                .message
35206                .contains("must declare a `missed` policy")),
35207            "{:?}",
35208            compiled.diagnostics
35209        );
35210    }
35211
35212    #[test]
35213    fn calendar_clock_source_requires_timezone() {
35214        let source = r#"
35215workflow NeedsTimezone
35216
35217signal triage.tick {
35218  scheduled_at time
35219}
35220
35221source clock as daily {
35222  every weekday at 09:00
35223  missed skip
35224
35225  observe as tick
35226  emit triage.tick {
35227    scheduled_at tick.scheduled_at
35228  }
35229}
35230"#;
35231        let compiled = compile_program(source);
35232        assert!(
35233            compiled
35234                .diagnostics
35235                .iter()
35236                .any(|diagnostic| diagnostic.message.contains("should declare a `timezone`")),
35237            "{:?}",
35238            compiled.diagnostics
35239        );
35240    }
35241
35242    #[test]
35243    fn generic_source_block_lowers_to_signal_source() {
35244        let source = r#"
35245workflow Ingress
35246
35247signal deploy.finished {
35248  service string
35249}
35250
35251source webhook as deploys {
35252  observe as obs
35253  emit deploy.finished {
35254    service obs.service
35255  }
35256}
35257"#;
35258        let compiled = compile_program(source);
35259        assert_eq!(compiled.diagnostics, Vec::new());
35260        let ir = compiled.ir.expect("program compiles");
35261        assert_eq!(ir.sources.len(), 1);
35262        let decl = &ir.sources[0];
35263        assert!(!decl.is_clock);
35264        assert_eq!(decl.provider, "webhook");
35265        assert!(decl.recurrence.is_none());
35266        assert_eq!(decl.emit_signal, "deploy.finished");
35267    }
35268
35269    #[test]
35270    fn complete_field_reads_are_collected_per_field() {
35271        // The per-field flow-signature metadata (DR-0030 X2 v2) keeps each result
35272        // field's referenced roots SEPARATE (where `egress_payload_reads` joins them):
35273        // `id` references only `a`, `note` references only `b`.
35274        let source = r#"
35275@tool
35276workflow Producer {
35277  input request Req
35278  output result R
35279  class Req { id string }
35280  class A { x string }
35281  class B { y string }
35282  class R { id string  note string }
35283
35284  rule combine
35285    when A as a
35286    when B as b
35287  => {
35288    complete result {
35289      id a.x
35290      note b.y
35291    }
35292  }
35293}
35294"#;
35295        let compiled = compile_program(source);
35296        let ir = compiled.ir.expect("program compiles");
35297        let rule = ir
35298            .rules
35299            .iter()
35300            .find(|r| r.name == "combine")
35301            .expect("combine rule");
35302        let per_field = rule
35303            .metadata
35304            .complete_field_reads
35305            .get("result")
35306            .expect("result has per-field reads");
35307        assert_eq!(
35308            per_field.get("id"),
35309            Some(&BTreeSet::from(["a".to_owned()])),
35310            "id references only a: {per_field:?}"
35311        );
35312        assert_eq!(
35313            per_field.get("note"),
35314            Some(&BTreeSet::from(["b".to_owned()])),
35315            "note references only b: {per_field:?}"
35316        );
35317    }
35318
35319    #[test]
35320    fn milestone_field_reads_are_collected_per_field() {
35321        // D3′: milestone payload fields get the same per-field root metadata as
35322        // `complete result`, so a parent can audit each milestone field separately.
35323        let source = r#"
35324workflow Child {
35325  input request Req
35326  output result R
35327  class Req { id string }
35328  class A { x string }
35329  class B { y string }
35330  class R { ok bool }
35331  class Progress { hot string  cold string }
35332
35333  rule report
35334    when A as a
35335    when B as b
35336  => {
35337    emit milestone "halfway" of Progress {
35338      hot a.x
35339      cold b.y
35340    }
35341    complete result { ok true }
35342  }
35343}
35344"#;
35345        let compiled = compile_program(source);
35346        let ir = compiled.ir.expect("program compiles");
35347        let rule = ir
35348            .rules
35349            .iter()
35350            .find(|r| r.name == "report")
35351            .expect("report rule");
35352        let per_field = rule
35353            .metadata
35354            .milestone_field_reads
35355            .get("halfway")
35356            .expect("milestone has per-field reads");
35357        assert_eq!(
35358            per_field.get("hot"),
35359            Some(&BTreeSet::from(["a".to_owned()])),
35360            "hot references only a: {per_field:?}"
35361        );
35362        assert_eq!(
35363            per_field.get("cold"),
35364            Some(&BTreeSet::from(["b".to_owned()])),
35365            "cold references only b: {per_field:?}"
35366        );
35367    }
35368}