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, consulted by the inject check. IFC-only (NOT in the
1573    /// `.ir` snapshot).
1574    pub endorsed_roots: BTreeSet<String>,
1575    /// Per-coerce argument roots: for EVERY `coerce f(args…) as <binding>` in
1576    /// the rule (marked or not), the binding roots its argument expressions
1577    /// reference. The IFC engine resolves these to governed sources for
1578    /// input-side provenance narrowing at marked crossings — including chaining
1579    /// through unmarked coerces (a model call is a total mixing point: its
1580    /// output carries the join of all its inputs). IFC-only (NOT in the `.ir`
1581    /// snapshot).
1582    pub coerce_input_roots: BTreeMap<String, BTreeSet<String>>,
1583    /// `after <effect-binding> succeeds|completes as <alias>` → the effect
1584    /// binding, so the IFC engine can resolve payload and argument roots
1585    /// through the aliases bodies actually reference. IFC-only (NOT in the
1586    /// `.ir` snapshot).
1587    pub after_aliases: BTreeMap<String, String>,
1588    /// Per egress sink, the binding roots of every enclosing `case` scrutinee
1589    /// (DR-0046): a sink inside a `case` arm is INFLUENCED by the scrutinee —
1590    /// branching on model output and recording per-arm constants is the
1591    /// classic implicit channel. Covers record/complete/milestone/send/write
1592    /// uniformly. IFC-only (NOT in the `.ir` snapshot).
1593    pub egress_case_influence: BTreeMap<String, BTreeSet<String>>,
1594    /// Per `complete <binding>` egress, the binding roots each RESULT FIELD
1595    /// references — a two-level map `binding -> field -> {roots}`. Where
1596    /// `egress_payload_reads` joins all of a sink's fields into one set (enough for the
1597    /// fully-redacted recognizer), this keeps them SEPARATE so the IFC engine can
1598    /// compute a PER-FIELD flow signature (DR-0030 X2 v2): the reads reaching each
1599    /// result field, refined at fact granularity. IFC-only (NOT in the `.ir`
1600    /// snapshot). Union across branches; a `Shorthand` field resolves to the
1601    /// terminal's `from` binding.
1602    pub complete_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1603    /// Per `emit milestone "<name>"` egress, the binding roots each MILESTONE FIELD
1604    /// references — same shape and purpose as `complete_field_reads`, but keyed by
1605    /// milestone name. Milestone payloads are child-to-parent egresses, so IFC needs
1606    /// their per-field flow signature too (D3′). IFC-only (NOT in the `.ir`
1607    /// snapshot). Union across branches.
1608    pub milestone_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1609    /// Bounded-type projection egresses (`record <T> from <src>`): each is governed
1610    /// by the kept fields' per-field label join, like an explicit `redact`. IFC-only
1611    /// (NOT in the `.ir` snapshot). DR-0027 auto-redaction, the bounded-type reading.
1612    pub bounded_egresses: Vec<IrBoundedEgress>,
1613    /// Maximum nesting depth of `after` blocks in the rule body (0 = no `after`,
1614    /// 1 = a top-level `after`, 2 = an `after` inside an `after`, …). Surfaced for the
1615    /// `lint.deep_after_nesting` maintainability check.
1616    pub max_after_depth: usize,
1617}
1618
1619/// A bounded-type projection egress (`record <T> from <src>`): the recorded fact
1620/// keeps exactly `T`'s fields, copied from `src`, so the egress carries only the
1621/// kept fields' per-field labels — the "bounded-type" auto-redaction reading
1622/// (DR-0027). The bound is the declared target type `T`; the labels are the
1623/// SOURCE schema's (a target field mislabelled public is still caught against the
1624/// source's label). The IFC engine governs it exactly like an explicit `redact`.
1625#[derive(Clone, Debug, Eq, PartialEq)]
1626pub struct IrBoundedEgress {
1627    /// The engine's sink string (`fact:<T>` for a record).
1628    pub sink: String,
1629    /// The schema of the `from` source binding, whose per-field labels bound the
1630    /// projection.
1631    pub source_schema: String,
1632    /// The kept field names (the target type `T`'s fields).
1633    pub keep: Vec<String>,
1634}
1635
1636/// A `redact <source> keep [..] as <binding>` projection, surfaced for the
1637/// information-flow value-flow engine (DR-0027). `source` is the binding being
1638/// projected, `keep` the kept field names, `binding` the projected output.
1639#[derive(Clone, Debug, Eq, PartialEq)]
1640pub struct IrRedaction {
1641    pub source: String,
1642    pub keep: Vec<String>,
1643    pub binding: String,
1644    /// The schema of the source binding, when resolvable (a matched class, a
1645    /// coerce/decide/exec result, an `after … as` alias, or an earlier redaction's
1646    /// output). The information-flow engine derives the projection's confidentiality
1647    /// from the kept fields of this schema (`<schema>.<field>` labels), so a redacted
1648    /// egress needs only the kept fields' clearance, not the whole record's. `None`
1649    /// when the source type is not statically known (the engine then stays
1650    /// conservative for that redaction).
1651    pub source_schema: Option<String>,
1652}
1653
1654#[derive(Clone, Debug, Eq, PartialEq)]
1655pub struct IrRecordSource {
1656    pub schema: String,
1657    pub construct: String,
1658    pub span: SourceSpan,
1659}
1660
1661#[derive(Clone, Debug, Eq, PartialEq)]
1662pub struct IrProjectionRead {
1663    pub kind: QueryKind,
1664    pub head: String,
1665    pub guard: Option<String>,
1666}
1667
1668impl IrProjectionRead {
1669    fn to_snapshot(&self) -> String {
1670        let prefix = match self.kind {
1671            QueryKind::Fact => format!("fact:{}", self.head),
1672            QueryKind::Effect => format!("effect:{}", self.head),
1673        };
1674        match &self.guard {
1675            Some(guard) => format!("{prefix} where {guard}"),
1676            None => prefix,
1677        }
1678    }
1679}
1680
1681#[derive(Clone, Debug, Eq, PartialEq)]
1682pub struct IrEffectNode {
1683    pub id: String,
1684    pub kind: IrEffectKind,
1685    pub binding: Option<String>,
1686    pub required_capabilities: Vec<String>,
1687    pub construct_use: Option<IrConstructUse>,
1688    pub idempotency_key: String,
1689    pub span: SourceSpan,
1690    /// Creation-anchored deadline from a `timeout <duration>` clause.
1691    pub timeout_seconds: Option<u64>,
1692    /// Turn-access grants (`with access to …`) lowered onto an `agent.tell` effect as
1693    /// authority-narrowing metadata (Proposal A). Empty for non-grant effects.
1694    pub access_grants: Vec<IrAccessGrant>,
1695    /// Turn-scoped skills (`with skills [...]`) pinned onto an `agent.tell` effect as
1696    /// provenance (context-assembly Phase 7). Recorded, not enforced — the owned
1697    /// catalogue stays discover-all. Empty for effects without a skill pin.
1698    pub turn_skills: Vec<String>,
1699    /// The named resource (file store / channel) a direct effect touches, if any —
1700    /// e.g. the store of a `read`/`write`. Surfaced so information-flow analysis can
1701    /// see rule-body data flows, not just turn-access grants. `None` for effects
1702    /// that touch no named resource. Not part of the `.ir` snapshot.
1703    pub resource: Option<String>,
1704    /// The agent a `tell` addresses (its `target`), surfaced so information-flow
1705    /// analysis can model the turn's egress to that agent's provider. `None` for
1706    /// non-`tell` effects. Not part of the `.ir` snapshot.
1707    pub agent: Option<String>,
1708    /// The workflow an `invoke` addresses, surfaced so information-flow analysis can
1709    /// enumerate and govern invoke membrane ports. `None` for non-`invoke` effects.
1710    /// Not part of the `.ir` snapshot.
1711    pub workflow_target: Option<String>,
1712    /// The `endorsed` source marker (DR-0027 I-IFC3): the author declared this effect
1713    /// (a `coerce`) an integrity-raising crossing. Surfaced so the trusted surface is
1714    /// visible at the source crossing point. Not part of the `.ir` snapshot.
1715    pub endorsed: bool,
1716    /// The `declassified` source marker (DR-0027 I-IFC3): the author declared this
1717    /// `coerce` a confidentiality-lowering crossing (its output schema bounds the
1718    /// leak). Surfaced for audit. Not part of the `.ir` snapshot.
1719    pub declassified: bool,
1720    /// The innermost `case <scrutinee> { <pattern> => … }` arm this effect sits in,
1721    /// as `(scrutinee, pattern)` — the discriminated-families *selector*. Lets the
1722    /// IFC checker apply NMIF-on-the-selector: a crossing (`endorsed`/`declassified`)
1723    /// selected by a low-integrity discriminant is rejected (DR §5.6 / §7.4). `None`
1724    /// for effects outside any `case`. Not part of the `.ir` snapshot.
1725    pub selected_by: Option<(String, String)>,
1726    /// The `exec` surface form — raw command string vs manifest capability
1727    /// (spec/std-script.md "Static checks" item 2) — surfaced so check-time
1728    /// gates (hosted-raw demotion, manifest resolution) classify the effect
1729    /// from the AST instead of re-scanning rule-body text. `None` for
1730    /// non-`exec` effects. Not part of the `.ir` snapshot.
1731    pub exec_target: Option<IrExecTarget>,
1732}
1733
1734/// The two `exec` source forms (spec/std-script.md): a raw command string
1735/// (`exec "cmd"`, dev-profile only) or an operator-manifest capability
1736/// (`exec <name> with <record>`).
1737#[derive(Clone, Debug, Eq, PartialEq)]
1738pub enum IrExecTarget {
1739    Raw,
1740    Capability { name: String },
1741}
1742
1743/// A lowered turn-access grant: the granted operations narrow the turn's effective
1744/// authority on `resource` (modeled in `models/maude/turn-access-grant.maude`).
1745#[derive(Clone, Debug, Eq, PartialEq)]
1746pub struct IrAccessGrant {
1747    pub resource: String,
1748    pub operations: Vec<IrAccessGrantOp>,
1749}
1750
1751#[derive(Clone, Debug, Eq, PartialEq)]
1752pub struct IrAccessGrantOp {
1753    pub operation: String,
1754    pub target: Option<String>,
1755    pub globs: Vec<String>,
1756}
1757
1758#[derive(Clone, Debug, Eq, PartialEq)]
1759pub struct IrConstructUse {
1760    pub keyword: String,
1761    pub scope: String,
1762    pub construct_family: String,
1763    pub lowering_target: String,
1764    pub target_capability: String,
1765}
1766
1767#[derive(Clone, Debug, Eq, PartialEq)]
1768pub enum IrEffectKind {
1769    AgentTell,
1770    SchemaCoerce,
1771    CapabilityCall,
1772    EventEmit,
1773    WorkflowInvoke,
1774    TimerWait,
1775    ExecCommand,
1776    TrackerFile,
1777    TrackerClaim,
1778    TrackerRenew,
1779    TrackerRelease,
1780    TrackerFinish,
1781    LeaseAcquire,
1782    LeaseRenew,
1783    LedgerAppend,
1784    CounterConsume,
1785    SignalEmit,
1786    FileRead,
1787    FileWrite,
1788    FileImport,
1789    FileExport,
1790}
1791
1792#[derive(Clone, Debug, Eq, PartialEq)]
1793pub struct IrEffectDependency {
1794    pub upstream: String,
1795    pub predicate: DependencyPredicate,
1796    pub downstream: String,
1797}
1798
1799#[derive(Clone, Debug, Eq, PartialEq)]
1800pub struct IrRuleCaseBranch {
1801    pub scrutinee: String,
1802    pub scrutinee_type: IrType,
1803    pub pattern: IrCasePattern,
1804    pub guard: Option<IrExpression>,
1805    pub body_hash: String,
1806    pub pattern_span: SourceSpan,
1807}
1808
1809#[derive(Clone, Debug, Eq, PartialEq)]
1810pub enum IrCasePattern {
1811    EnumVariant(String),
1812    LiteralString(String),
1813    Agent(String),
1814    OptionalSome { binding: String },
1815    OptionalNone,
1816    Wildcard,
1817}
1818
1819impl IrCasePattern {
1820    fn to_snapshot(&self) -> String {
1821        match self {
1822            IrCasePattern::EnumVariant(value) => format!("enum:{value}"),
1823            IrCasePattern::LiteralString(value) => format!("literal:\"{value}\""),
1824            IrCasePattern::Agent(value) => format!("agent:{value}"),
1825            IrCasePattern::OptionalSome { binding } => format!("some:{binding}"),
1826            IrCasePattern::OptionalNone => "none".to_owned(),
1827            IrCasePattern::Wildcard => "_".to_owned(),
1828        }
1829    }
1830}
1831
1832#[derive(Clone, Debug, Eq, PartialEq)]
1833pub struct IrTerminalOutput {
1834    pub binding: String,
1835    pub alternatives: Vec<IrTerminalAlternative>,
1836    pub span: SourceSpan,
1837}
1838
1839#[derive(Clone, Debug, Eq, PartialEq)]
1840pub struct IrTerminalAlternative {
1841    pub tag: String,
1842    pub payload_type: IrType,
1843    pub source_span: SourceSpan,
1844}
1845
1846#[derive(Clone, Debug, Eq, PartialEq)]
1847pub struct IrTerminalCaseBranch {
1848    pub scrutinee: String,
1849    pub tag: Option<String>,
1850    pub binding: Option<String>,
1851    pub guard: Option<IrExpression>,
1852    pub body_hash: String,
1853    pub pattern_span: SourceSpan,
1854}
1855
1856#[derive(Clone, Debug, Eq, PartialEq)]
1857pub enum DependencyPredicate {
1858    Succeeds,
1859    Fails,
1860    TimedOut,
1861    Cancelled,
1862    Completes,
1863}
1864
1865#[derive(Clone, Debug)]
1866struct SemanticContext {
1867    workflow: Option<String>,
1868    schemas: SchemaIndex,
1869    agents: BTreeSet<String>,
1870    agent_capabilities: BTreeMap<String, BTreeSet<String>>,
1871    coerce_outputs: BTreeMap<String, TypeSyntax>,
1872    coerce_params: BTreeMap<String, Vec<ParamDecl>>,
1873    workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
1874    /// Declared coordination resources (spec/coordination.md).
1875    leases: BTreeSet<String>,
1876    ledgers: BTreeSet<String>,
1877    counters: BTreeSet<String>,
1878    /// Declared `channel` names (std.messaging); `send via <channel>` must name one.
1879    channels: BTreeSet<String>,
1880    /// Declared channel providers by channel name (std.messaging): the
1881    /// capability-report-conditioned checks (send requires outbound-capable,
1882    /// `when message from` requires inbound-capable) resolve the report
1883    /// through this map.
1884    channel_providers: BTreeMap<String, String>,
1885    /// Declared `memory pool` names (std.memory); `recall`/`learn`/`curate`
1886    /// must name one (MEM-1 check 1).
1887    memory_pools: BTreeSet<String>,
1888}
1889
1890#[derive(Clone, Debug, Default)]
1891struct WorkflowInputSurface {
1892    inputs: BTreeMap<String, TypeSyntax>,
1893    /// The workflow's `output` contract types by name. A parent's
1894    /// `after <invoke-binding> succeeds as r` binds `r` to this contract so
1895    /// `r.<field>` type-checks against the child's declared output (the runtime
1896    /// already carries the child's terminal payload into that binding).
1897    outputs: BTreeMap<String, TypeSyntax>,
1898    /// The workflow's `failure` contract types by name. A parent's
1899    /// `after <invoke-binding> fails as f` binds `f` to this contract (when it is
1900    /// a shared top-level class) so `f.<field>` type-checks against the child's
1901    /// declared failure shape, instead of the generic DR-0032 `TerminalFailed`
1902    /// base. Falls back to the base when the failure class is child-local or the
1903    /// child declares zero/several failures.
1904    failures: BTreeMap<String, TypeSyntax>,
1905    schemas: SchemaIndex,
1906    /// Milestones the workflow may project (Family C): name -> payload class
1907    /// (empty string for a bare, payload-less milestone). Derived by scanning the
1908    /// workflow's rule bodies for `emit milestone "<name>" [of <Class>]`. This is
1909    /// the `declared(S)` set a parent's `after p reaches "<name>"` validates
1910    /// against (reject-undeclared) and the source of the observing binding's type.
1911    milestones: BTreeMap<String, String>,
1912}
1913
1914#[derive(Clone, Debug, Default)]
1915struct SchemaIndex {
1916    classes: BTreeMap<String, BTreeMap<String, TypeSyntax>>,
1917    enums: BTreeMap<String, BTreeSet<String>>,
1918    /// Declared external signals (spec/event-ingress.md); their payload
1919    /// schemas live in `classes` keyed by the dotted signal name.
1920    events: BTreeSet<String>,
1921    /// Family B: per-schema field presence conditions, `schema -> field ->
1922    /// (discriminant field, required literal)`. A conditioned field is readable
1923    /// only inside a matching `case <root>.<disc>` arm.
1924    presence: BTreeMap<String, BTreeMap<String, (String, String)>>,
1925}
1926
1927#[derive(Clone, Debug, Eq, PartialEq)]
1928enum BlockFrame {
1929    After {
1930        binding: String,
1931        predicate: DependencyPredicate,
1932    },
1933}
1934
1935#[derive(Clone, Debug, Eq, PartialEq)]
1936enum LiteralExpr<'a> {
1937    String(&'a str),
1938    Number(&'a str),
1939    Bool,
1940    Null,
1941    Ident(&'a str),
1942}
1943
1944#[derive(Clone, Debug, Eq, PartialEq)]
1945enum ExprType {
1946    Bool,
1947    Int,
1948    Float,
1949    String,
1950    Duration,
1951    Time,
1952    Null,
1953    Object,
1954    Optional(Box<ExprType>),
1955    Array(Box<ExprType>),
1956    Map(Box<ExprType>),
1957    Finite { label: String, values: Vec<String> },
1958    Collection,
1959    Unknown,
1960}
1961
1962#[derive(Clone, Debug, Eq, PartialEq)]
1963pub enum Expr {
1964    Literal(ExprLiteral),
1965    Path(Vec<String>),
1966    Index {
1967        target: Box<Expr>,
1968        key: Box<Expr>,
1969    },
1970    Array(Vec<Expr>),
1971    Object(Vec<ExprObjectField>),
1972    Unary {
1973        op: UnaryOp,
1974        expr: Box<Expr>,
1975    },
1976    Binary {
1977        op: BinaryOp,
1978        left: Box<Expr>,
1979        right: Box<Expr>,
1980    },
1981    Call {
1982        name: String,
1983        args: Vec<Expr>,
1984    },
1985    Query {
1986        kind: QueryKind,
1987        head: String,
1988        guard: Option<Box<Expr>>,
1989    },
1990}
1991
1992#[derive(Clone, Debug, Eq, PartialEq)]
1993pub struct ExprObjectField {
1994    pub key: String,
1995    pub value: Expr,
1996}
1997
1998#[derive(Clone, Debug, Eq, PartialEq)]
1999pub enum ExprLiteral {
2000    String(String),
2001    Number(String),
2002    Bool(bool),
2003    Null,
2004    Ident(String),
2005}
2006
2007#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2008pub enum UnaryOp {
2009    Not,
2010}
2011
2012#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2013pub enum BinaryOp {
2014    Or,
2015    And,
2016    Eq,
2017    Ne,
2018    Lt,
2019    Le,
2020    Gt,
2021    Ge,
2022    In,
2023    NotIn,
2024    Add,
2025    Sub,
2026    Mul,
2027    Div,
2028}
2029
2030#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2031pub enum QueryKind {
2032    Fact,
2033    Effect,
2034}
2035
2036/// Parses a deterministic expression used by guards, assertions, and branch guards.
2037pub fn parse_expression(expr: &str) -> Result<Expr, String> {
2038    ExprParser::new(expr).parse()
2039}
2040
2041impl Expr {
2042    pub fn to_snapshot(&self) -> String {
2043        match self {
2044            Self::Literal(literal) => literal.to_snapshot(),
2045            Self::Path(path) => path.join("."),
2046            Self::Index { target, key } => {
2047                format!(
2048                    "{}[{}]",
2049                    target.to_snapshot_with_parentheses(),
2050                    key.to_snapshot()
2051                )
2052            }
2053            Self::Array(items) => {
2054                let items = items
2055                    .iter()
2056                    .map(Self::to_snapshot)
2057                    .collect::<Vec<_>>()
2058                    .join(", ");
2059                format!("[{items}]")
2060            }
2061            Self::Object(fields) => {
2062                let fields = fields
2063                    .iter()
2064                    .map(|field| format!("{} {}", field.key, field.value.to_snapshot()))
2065                    .collect::<Vec<_>>()
2066                    .join(", ");
2067                format!("{{{fields}}}")
2068            }
2069            Self::Unary { op, expr } => match op {
2070                UnaryOp::Not => format!("!{}", expr.to_snapshot_with_parentheses()),
2071            },
2072            Self::Binary { op, left, right } => format!(
2073                "{} {} {}",
2074                left.to_snapshot_with_parentheses(),
2075                op.to_snapshot(),
2076                right.to_snapshot_with_parentheses()
2077            ),
2078            Self::Call { name, args } => {
2079                let args = args
2080                    .iter()
2081                    .map(Self::to_snapshot)
2082                    .collect::<Vec<_>>()
2083                    .join(", ");
2084                format!("{name}({args})")
2085            }
2086            Self::Query { kind, head, guard } => {
2087                let prefix = match kind {
2088                    QueryKind::Fact => head.clone(),
2089                    QueryKind::Effect => format!("effect {head}"),
2090                };
2091                match guard {
2092                    Some(guard) => format!("{prefix} where {}", guard.to_snapshot()),
2093                    None => prefix,
2094                }
2095            }
2096        }
2097    }
2098
2099    fn to_snapshot_with_parentheses(&self) -> String {
2100        match self {
2101            Self::Binary { .. } => format!("({})", self.to_snapshot()),
2102            _ => self.to_snapshot(),
2103        }
2104    }
2105}
2106
2107impl ExprLiteral {
2108    fn to_snapshot(&self) -> String {
2109        match self {
2110            Self::String(value) => format!("{value:?}"),
2111            Self::Number(value) | Self::Ident(value) => value.clone(),
2112            Self::Bool(value) => value.to_string(),
2113            Self::Null => "null".to_owned(),
2114        }
2115    }
2116}
2117
2118impl BinaryOp {
2119    fn to_snapshot(self) -> &'static str {
2120        match self {
2121            Self::Or => "||",
2122            Self::And => "&&",
2123            Self::Eq => "==",
2124            Self::Ne => "!=",
2125            Self::Lt => "<",
2126            Self::Le => "<=",
2127            Self::Gt => ">",
2128            Self::Ge => ">=",
2129            Self::In => "in",
2130            Self::NotIn => "not in",
2131            Self::Add => "+",
2132            Self::Sub => "-",
2133            Self::Mul => "*",
2134            Self::Div => "/",
2135        }
2136    }
2137}
2138
2139/// Parses a source file into a recoverable AST plus diagnostics.
2140pub fn parse_program(source: &str) -> ParseOutput {
2141    let lexed = lex(source);
2142    let mut parser = Parser {
2143        source,
2144        tokens: lexed.tokens,
2145        pos: 0,
2146        diagnostics: lexed.diagnostics,
2147        pending_contract_classes: Vec::new(),
2148    };
2149
2150    let program = parser.parse_program();
2151    ParseOutput {
2152        program,
2153        diagnostics: parser.diagnostics,
2154    }
2155}
2156
2157/// Parses and lowers a source file into deterministic typed IR.
2158pub fn compile_program(source: &str) -> CompileOutput {
2159    compile_program_with_root(source, None)
2160}
2161
2162/// Parses and lowers a source bundle into deterministic typed IR with an
2163/// optional explicit root workflow selection.
2164pub fn compile_program_with_root(source: &str, root: Option<&str>) -> CompileOutput {
2165    let parsed = parse_program(source);
2166    if !parsed.diagnostics.is_empty() {
2167        return CompileOutput {
2168            ir: None,
2169            diagnostics: parsed.diagnostics,
2170            warnings: Vec::new(),
2171        };
2172    }
2173
2174    // Program-level static check over ALL workflows (before root selection):
2175    // transitive runtime invocation cycles have no compile-time convergence proof
2176    // and are rejected (RESOLVED 2026-07-01). Direct self-invocation is caught
2177    // per-rule during lowering.
2178    let mut invoke_recursion_diagnostics = Vec::new();
2179    detect_workflow_invoke_recursion(&parsed.program, &mut invoke_recursion_diagnostics);
2180    detect_private_workflow_invocations(&parsed.program, &mut invoke_recursion_diagnostics);
2181    if !invoke_recursion_diagnostics.is_empty() {
2182        return CompileOutput {
2183            ir: None,
2184            diagnostics: invoke_recursion_diagnostics,
2185            warnings: Vec::new(),
2186        };
2187    }
2188
2189    let workflow_inputs = collect_workflow_input_surfaces(&parsed.program);
2190    let shared_coordination_usage = collect_shared_coordination_usage(&parsed.program);
2191
2192    // Whole-program validation (RESOLVED 2026-07-01): when a program declares
2193    // more than one explicit `workflow`, validate EVERY workflow — not only the
2194    // selected root — so a broken sibling is caught in a single compile
2195    // regardless of which `--root` is chosen. Each workflow is lowered against
2196    // its own scope (top-level globals + that workflow's local block items),
2197    // which is exactly the scoped program `select_root_workflow` builds for that
2198    // name. Root selection below still produces the single entry IR for
2199    // `dev`/`deploy`; this pass only adds validation coverage and never changes
2200    // the emitted IR (when it finds no errors it returns nothing, so the root is
2201    // lowered once more, cleanly, below). See models/maude/workflow-scoping.maude.
2202    if parsed.program.workflows.len() > 1 {
2203        // Names declared at the top level are global (shared across every
2204        // workflow); names declared inside a `workflow { ... }` block are private
2205        // to it. Map each workflow-local name to its owning workflow(s) so that
2206        // when a workflow references a name that is really a sibling's local, the
2207        // resulting unknown-name error can point the author at where it lives —
2208        // the "names do not leak into sibling workflows" guarantee, surfaced.
2209        let global_names: BTreeSet<String> = parsed
2210            .program
2211            .items
2212            .iter()
2213            .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2214            .collect();
2215        let mut sibling_locals: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
2216        for workflow in &parsed.program.workflows {
2217            for item in &workflow.items {
2218                if let Some((name, span)) = referenced_decl_name(item) {
2219                    sibling_locals
2220                        .entry(name)
2221                        .or_default()
2222                        .push((workflow.name.name.clone(), span));
2223                }
2224            }
2225        }
2226
2227        let mut aggregated = Vec::new();
2228        for workflow in &parsed.program.workflows {
2229            let name = workflow.name.name.clone();
2230            let own_locals: BTreeSet<String> = workflow
2231                .items
2232                .iter()
2233                .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2234                .collect();
2235            let mut diagnostics = match select_root_workflow(parsed.program.clone(), Some(&name)) {
2236                Ok(scoped) => {
2237                    lower_program(
2238                        scoped,
2239                        workflow_inputs.clone(),
2240                        shared_coordination_usage.clone(),
2241                    )
2242                    .diagnostics
2243                }
2244                Err(diagnostics) => diagnostics,
2245            };
2246            for diagnostic in &mut diagnostics {
2247                annotate_cross_workflow_leak(
2248                    diagnostic,
2249                    &name,
2250                    &own_locals,
2251                    &global_names,
2252                    &sibling_locals,
2253                );
2254            }
2255            aggregated.extend(diagnostics);
2256        }
2257        if !aggregated.is_empty() {
2258            return CompileOutput {
2259                ir: None,
2260                diagnostics: aggregated,
2261                warnings: Vec::new(),
2262            };
2263        }
2264    }
2265
2266    match select_root_workflow(parsed.program, root) {
2267        Ok(program) => lower_program(program, workflow_inputs, shared_coordination_usage),
2268        Err(diagnostics) => CompileOutput {
2269            ir: None,
2270            diagnostics,
2271            warnings: Vec::new(),
2272        },
2273    }
2274}
2275
2276/// One top-level declaration for an editor outline (`whip lsp`'s
2277/// `textDocument/documentSymbol`): its name, a coarse kind tag, and source span.
2278#[derive(Clone, Debug, Eq, PartialEq)]
2279pub struct DeclSymbol {
2280    pub name: String,
2281    pub kind: &'static str,
2282    pub span: SourceSpan,
2283}
2284
2285/// Top-level declarations of `source` in source order, for an editor outline. On a
2286/// parse error it returns whatever declarations parsed (best-effort outline).
2287pub fn document_symbols(source: &str) -> Vec<DeclSymbol> {
2288    let program = parse_program(source).program;
2289    let mut symbols = Vec::new();
2290    if let Some(workflow) = &program.workflow {
2291        symbols.push(DeclSymbol {
2292            name: workflow.name.clone(),
2293            kind: "workflow",
2294            span: workflow.span,
2295        });
2296    }
2297    for workflow in &program.workflows {
2298        symbols.push(DeclSymbol {
2299            name: workflow.name.name.clone(),
2300            kind: "workflow",
2301            span: workflow.span,
2302        });
2303    }
2304    for pattern in &program.patterns {
2305        symbols.push(DeclSymbol {
2306            name: pattern.name.name.clone(),
2307            kind: "pattern",
2308            span: pattern.span,
2309        });
2310    }
2311    for item in &program.items {
2312        let symbol = match item {
2313            Item::Class(decl) => ("class", decl.name.name.clone(), decl.span),
2314            Item::Enum(decl) => ("enum", decl.name.name.clone(), decl.span),
2315            Item::Agent(decl) => ("agent", decl.name.name.clone(), decl.span),
2316            Item::Rule(decl) => ("rule", decl.name.name.clone(), decl.span),
2317            Item::Coerce(decl) => ("coerce", decl.name.name.clone(), decl.span),
2318            Item::Action(decl) => ("action", decl.name.name.clone(), decl.span),
2319            Item::Lease(decl) => ("lease", decl.name.name.clone(), decl.span),
2320            Item::Ledger(decl) => ("ledger", decl.name.name.clone(), decl.span),
2321            Item::Counter(decl) => ("counter", decl.name.name.clone(), decl.span),
2322            Item::Tracker(decl) => ("tracker", decl.name.name.clone(), decl.span),
2323            Item::Channel(decl) => ("channel", decl.name.name.clone(), decl.span),
2324            Item::FileStore(decl) => ("file store", decl.name.name.clone(), decl.span),
2325            Item::MemoryPool(decl) => ("memory pool", decl.name.name.clone(), decl.span),
2326            Item::Event(decl) => ("signal", decl.name.clone(), decl.span),
2327            Item::Table(decl) => ("table", decl.name.name.clone(), decl.span),
2328            Item::Gauge(decl) => ("gauge", decl.name.name.clone(), decl.span),
2329            Item::Campaign(decl) => ("campaign", decl.name.name.clone(), decl.span),
2330            Item::Mark(decl) => ("mark", decl.name.value.clone(), decl.span),
2331            _ => continue,
2332        };
2333        symbols.push(DeclSymbol {
2334            name: symbol.1,
2335            kind: symbol.0,
2336            span: symbol.2,
2337        });
2338    }
2339    symbols
2340}
2341
2342/// Formats the syntax tree without lowering or analyzing rule bodies.
2343pub fn format_program(source: &str) -> FormatOutput {
2344    let parsed = parse_program(source);
2345    if !parsed.diagnostics.is_empty() {
2346        return FormatOutput {
2347            formatted: None,
2348            diagnostics: parsed.diagnostics,
2349        };
2350    }
2351
2352    FormatOutput {
2353        formatted: Some(format_syntax(parsed.program)),
2354        diagnostics: Vec::new(),
2355    }
2356}
2357
2358/// Format `source` while preserving comments where they can be placed safely:
2359/// top-level **leading** comments (a `# …` or `// …` line above a declaration, or
2360/// a file-header block) and **trailing** comments on a single-line top-level
2361/// declaration (`workflow Demo  # …`, attached to that element's line); comments
2362/// inside raw-body declarations (`rule`/`apply`/`coerce`/`table`/`flow`, carried by
2363/// the body substring); and comments inside `class`/`agent`/`enum` bodies, including a
2364/// data-carrying `enum` variant's nested field block — both own-line (interleaved
2365/// by source position) and trailing comments on a field/variant line (appended to
2366/// it), and `signal`/`queue`/`file store` bodies the same way — even though those
2367/// bodies rebuild from the AST. Returns `None` when the program does not parse, or
2368/// when a comment has nowhere to attach — e.g. one trailing a declaration's
2369/// opening-brace line, with no field on that line. The caller refuses such files
2370/// rather than dropping comments.
2371pub fn format_program_preserving_comments(source: &str) -> Option<String> {
2372    let parsed = parse_program(source);
2373    if !parsed.diagnostics.is_empty() {
2374        return None;
2375    }
2376    let mut comments = lex_comments(source);
2377    if comments.is_empty() {
2378        return Some(format_syntax(parsed.program));
2379    }
2380    // Both the top-level interleave and the per-body interleave below assume
2381    // ascending source order.
2382    comments.sort_by_key(|comment| comment.span.start);
2383    let program = parsed.program;
2384
2385    // Each top-level element as (source span, formatted chunk), in source order.
2386    let mut elements: Vec<(SourceSpan, String)> = Vec::new();
2387    if let Some(workflow) = program.workflow {
2388        let mut chunk = String::new();
2389        format_tags(&program.workflow_tags, &mut chunk);
2390        format_description(program.workflow_description.as_ref(), &mut chunk);
2391        push_line(&mut chunk, format!("workflow {}", workflow.name));
2392        elements.push((workflow.span, chunk));
2393    }
2394    for pattern in program.patterns {
2395        let span = pattern.span;
2396        let mut chunk = String::new();
2397        format_pattern(pattern, &mut chunk);
2398        elements.push((span, chunk));
2399    }
2400    for item in program.items {
2401        let span = item.span();
2402        let mut chunk = String::new();
2403        // Field-list bodies (`class`/`agent`/`enum`) rebuild from the AST, which
2404        // drops comments. Interleave their own-line body comments here; a body
2405        // comment that cannot be placed safely refuses the whole file (the
2406        // raw-body formatters — rule/coerce/table — already carry their comments).
2407        let placed = match &item {
2408            Item::Class(class_decl) => Some(try_format_class_with_comments(
2409                class_decl, source, &comments, &mut chunk,
2410            )),
2411            Item::Agent(agent) => Some(try_format_agent_with_comments(
2412                agent, source, &comments, &mut chunk,
2413            )),
2414            Item::Enum(enum_decl) => Some(try_format_enum_with_comments(
2415                enum_decl, source, &comments, &mut chunk,
2416            )),
2417            Item::Event(event) => Some(try_format_event_with_comments(
2418                event, source, &comments, &mut chunk,
2419            )),
2420            Item::Tracker(queue) => Some(try_format_tracker_with_comments(
2421                queue, source, &comments, &mut chunk,
2422            )),
2423            Item::FileStore(file_store) => Some(try_format_filestore_with_comments(
2424                file_store, source, &comments, &mut chunk,
2425            )),
2426            _ => None,
2427        };
2428        match placed {
2429            Some(true) => {}
2430            Some(false) => return None,
2431            None => format_item(item, &mut chunk),
2432        }
2433        elements.push((span, chunk));
2434    }
2435    for workflow in program.workflows {
2436        let span = workflow.span;
2437        let mut chunk = String::new();
2438        format_workflow(workflow, &mut chunk);
2439        elements.push((span, chunk));
2440    }
2441    elements.sort_by_key(|(span, _)| span.start);
2442
2443    // Classify top-level comments. A comment INSIDE an element's span is preserved
2444    // by that element's body formatter — a raw `body.text` substring
2445    // (rule/coerce/table) or the per-body interleave above (class/agent/enum) — so
2446    // emitting it here too would duplicate it; skip it. Otherwise an own-line
2447    // comment is `leading` (interleaved between elements by position), and a
2448    // trailing comment (code before it) attaches to the element whose last source
2449    // line it shares — typically a single-line declaration (`workflow Demo  # x`).
2450    // A trailing comment with no such element has nowhere to attach, so the file is
2451    // refused rather than dropping it.
2452    let mut leading: Vec<&Comment> = Vec::new();
2453    let mut element_trailing: Vec<Option<&Comment>> = vec![None; elements.len()];
2454    for comment in &comments {
2455        let in_body = elements
2456            .iter()
2457            .any(|(span, _)| span.start < comment.span.start && comment.span.start < span.end);
2458        if in_body {
2459            continue;
2460        }
2461        let line_start = source[..comment.span.start]
2462            .rfind('\n')
2463            .map(|newline| newline + 1)
2464            .unwrap_or(0);
2465        if source[line_start..comment.span.start].trim().is_empty() {
2466            leading.push(comment);
2467            continue;
2468        }
2469        let comment_line = line_index(source, comment.span.start);
2470        let mut placed = false;
2471        for (index, (span, _)) in elements.iter().enumerate() {
2472            if line_index(source, span.end.saturating_sub(1)) == comment_line {
2473                if element_trailing[index].is_some() {
2474                    return None;
2475                }
2476                element_trailing[index] = Some(comment);
2477                placed = true;
2478                break;
2479            }
2480        }
2481        if !placed {
2482            return None;
2483        }
2484    }
2485
2486    let mut out = String::new();
2487    let mut next_comment = 0;
2488    let element_count = elements.len();
2489    for (index, (span, chunk)) in elements.iter().enumerate() {
2490        while next_comment < leading.len() && leading[next_comment].span.start < span.start {
2491            push_line(&mut out, format_comment(leading[next_comment]));
2492            next_comment += 1;
2493        }
2494        match element_trailing[index] {
2495            Some(comment) => {
2496                out.push_str(chunk.strip_suffix('\n').unwrap_or(chunk));
2497                out.push_str(&format!("  {}\n", format_comment(comment)));
2498            }
2499            None => out.push_str(chunk),
2500        }
2501        if index + 1 < element_count {
2502            out.push('\n');
2503        }
2504    }
2505    if next_comment < leading.len() {
2506        if element_count > 0 {
2507            out.push('\n');
2508        }
2509        while next_comment < leading.len() {
2510            push_line(&mut out, format_comment(leading[next_comment]));
2511            next_comment += 1;
2512        }
2513    }
2514
2515    // Safety net against silent data loss: in-body comments are left to each
2516    // element's body formatter, and some formatters rebuild from the AST (which
2517    // drops comments). The idempotency self-check can't catch a *consistent*
2518    // drop, so verify here that every source comment survives — refuse otherwise.
2519    if lex_comments(&out).len() != comments.len() {
2520        return None;
2521    }
2522    Some(out)
2523}
2524
2525fn format_comment(comment: &Comment) -> String {
2526    let marker = match comment.marker {
2527        CommentMarker::Hash => "#",
2528        CommentMarker::Slash => "//",
2529    };
2530    let text = comment.text.trim();
2531    if text.is_empty() {
2532        marker.to_owned()
2533    } else {
2534        format!("{marker} {text}")
2535    }
2536}
2537
2538/// Zero-based source line of a byte offset.
2539fn line_index(source: &str, offset: usize) -> usize {
2540    source.as_bytes()[..offset]
2541        .iter()
2542        .filter(|&&byte| byte == b'\n')
2543        .count()
2544}
2545
2546/// Classify the comments inside `body` (a field-list declaration's brace region)
2547/// against its `members` (each member's span + already-formatted lines, in source
2548/// order). Returns the own-line comments to interleave between members, plus a
2549/// per-member optional trailing comment (appended to that member's last line).
2550/// Returns `None` when a comment cannot be placed safely — a comment inside a
2551/// *multi-line* member's own body (a deeper level this pass does not place), or a
2552/// trailing comment with no single-line member on its line — so the caller refuses
2553/// the file rather than misplace it. `comments` must be sorted by `span.start`.
2554fn classify_body_comments<'a>(
2555    source: &str,
2556    body: SourceSpan,
2557    members: &[(SourceSpan, Vec<String>)],
2558    comments: &'a [Comment],
2559) -> Option<(Vec<&'a Comment>, Vec<Option<&'a Comment>>)> {
2560    let mut own_line: Vec<&Comment> = Vec::new();
2561    let mut trailing: Vec<Option<&Comment>> = vec![None; members.len()];
2562    for comment in comments {
2563        if comment.span.start <= body.start || comment.span.start >= body.end {
2564            continue;
2565        }
2566        // A comment inside a multi-line member's own braces is a deeper level we do
2567        // not place here (e.g. a data-carrying `enum` variant's nested field).
2568        if members.iter().any(|(span, lines)| {
2569            lines.len() > 1 && span.start < comment.span.start && comment.span.start < span.end
2570        }) {
2571            return None;
2572        }
2573        let line_start = source[..comment.span.start]
2574            .rfind('\n')
2575            .map(|index| index + 1)
2576            .unwrap_or(0);
2577        if source[line_start..comment.span.start].trim().is_empty() {
2578            own_line.push(comment);
2579            continue;
2580        }
2581        // Trailing: attach to a single-line member sharing the comment's line.
2582        let comment_line = line_index(source, comment.span.start);
2583        let mut placed = false;
2584        for (index, (span, lines)) in members.iter().enumerate() {
2585            if lines.len() == 1 && line_index(source, span.start) == comment_line {
2586                if trailing[index].is_some() {
2587                    return None;
2588                }
2589                trailing[index] = Some(comment);
2590                placed = true;
2591                break;
2592            }
2593        }
2594        if !placed {
2595            return None;
2596        }
2597    }
2598    Some((own_line, trailing))
2599}
2600
2601/// Emit each member's lines, interleaving `own_line` comments by source position
2602/// (at `indent`) and appending each member's `trailing` comment to its last line.
2603/// `members` and `own_line` must be in ascending `span.start` order; `trailing`
2604/// is parallel to `members`.
2605fn emit_members_with_comments(
2606    members: &[(SourceSpan, Vec<String>)],
2607    own_line: &[&Comment],
2608    trailing: &[Option<&Comment>],
2609    indent: &str,
2610    formatted: &mut String,
2611) {
2612    let mut next = 0;
2613    for (index, (span, lines)) in members.iter().enumerate() {
2614        while next < own_line.len() && own_line[next].span.start < span.start {
2615            push_line(
2616                formatted,
2617                format!("{indent}{}", format_comment(own_line[next])),
2618            );
2619            next += 1;
2620        }
2621        let last = lines.len().saturating_sub(1);
2622        for (offset, line) in lines.iter().enumerate() {
2623            match trailing[index] {
2624                Some(comment) if offset == last => {
2625                    push_line(formatted, format!("{line}  {}", format_comment(comment)));
2626                }
2627                _ => push_line(formatted, line.clone()),
2628            }
2629        }
2630    }
2631    while next < own_line.len() {
2632        push_line(
2633            formatted,
2634            format!("{indent}{}", format_comment(own_line[next])),
2635        );
2636        next += 1;
2637    }
2638}
2639
2640/// Format a `class` body with its own-line and trailing comments preserved.
2641/// Returns `false` (caller refuses the file) when a body comment cannot be placed
2642/// safely.
2643fn try_format_class_with_comments(
2644    class_decl: &ClassDecl,
2645    source: &str,
2646    comments: &[Comment],
2647    formatted: &mut String,
2648) -> bool {
2649    let members: Vec<(SourceSpan, Vec<String>)> = class_decl
2650        .fields
2651        .iter()
2652        .map(|field| {
2653            let key = if field.is_key { " @key" } else { "" };
2654            (
2655                field.span,
2656                vec![format!(
2657                    "  {} {}{key}",
2658                    field.name.name,
2659                    field.ty.to_source()
2660                )],
2661            )
2662        })
2663        .collect();
2664    let Some((own_line, trailing)) =
2665        classify_body_comments(source, class_decl.span, &members, comments)
2666    else {
2667        return false;
2668    };
2669    push_line(formatted, format!("class {} {{", class_decl.name.name));
2670    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2671    push_line(formatted, "}");
2672    true
2673}
2674
2675/// Format a `queue` body (its single `tracker` member) with own-line and trailing
2676/// comments preserved. Returns `false` (caller refuses the file) when a body
2677/// comment cannot be placed safely.
2678fn try_format_tracker_with_comments(
2679    queue: &TrackerDecl,
2680    source: &str,
2681    comments: &[Comment],
2682    formatted: &mut String,
2683) -> bool {
2684    let members: Vec<(SourceSpan, Vec<String>)> = vec![(
2685        queue.provider.span,
2686        vec![format!("  provider {}", queue.provider.name)],
2687    )];
2688    let Some((own_line, trailing)) = classify_body_comments(source, queue.span, &members, comments)
2689    else {
2690        return false;
2691    };
2692    push_line(formatted, format!("tracker {} {{", queue.name.name));
2693    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2694    push_line(formatted, "}");
2695    true
2696}
2697
2698/// Format a `file store` body (its `root` and optional `allow read`/`allow write`
2699/// clauses) with own-line and trailing comments preserved, interleaved by the
2700/// clause spans captured during parsing. Returns `false` (caller refuses the file)
2701/// when a body comment cannot be placed safely.
2702fn try_format_filestore_with_comments(
2703    file_store: &FileStoreDecl,
2704    source: &str,
2705    comments: &[Comment],
2706    formatted: &mut String,
2707) -> bool {
2708    let render = |globs: &[String]| {
2709        globs
2710            .iter()
2711            .map(|glob| format!("{glob:?}"))
2712            .collect::<Vec<_>>()
2713            .join(", ")
2714    };
2715    let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::new();
2716    if let Some(span) = file_store.root_span {
2717        members.push((span, vec![format!("  root {:?}", file_store.root)]));
2718    }
2719    if !file_store.read_globs.is_empty() {
2720        if let Some(span) = file_store.read_span {
2721            members.push((
2722                span,
2723                vec![format!("  allow read [{}]", render(&file_store.read_globs))],
2724            ));
2725        }
2726    }
2727    if !file_store.write_globs.is_empty() {
2728        if let Some(span) = file_store.write_span {
2729            members.push((
2730                span,
2731                vec![format!(
2732                    "  allow write [{}]",
2733                    render(&file_store.write_globs)
2734                )],
2735            ));
2736        }
2737    }
2738    if let Some(provider) = &file_store.provider {
2739        if let Some(span) = file_store.provider_span {
2740            members.push((span, vec![format!("  provider {}", provider.name)]));
2741        }
2742    }
2743    members.sort_by_key(|(span, _)| span.start);
2744    let Some((own_line, trailing)) =
2745        classify_body_comments(source, file_store.span, &members, comments)
2746    else {
2747        return false;
2748    };
2749    push_line(formatted, format!("file store {} {{", file_store.name.name));
2750    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2751    push_line(formatted, "}");
2752    true
2753}
2754
2755/// Format a `signal` body (a typed payload schema of `ClassField`s, like a class)
2756/// with its own-line and trailing comments preserved. Returns `false` (caller
2757/// refuses the file) when a body comment cannot be placed safely.
2758fn try_format_event_with_comments(
2759    event: &EventDecl,
2760    source: &str,
2761    comments: &[Comment],
2762    formatted: &mut String,
2763) -> bool {
2764    let members: Vec<(SourceSpan, Vec<String>)> = event
2765        .fields
2766        .iter()
2767        .map(|field| {
2768            (
2769                field.span,
2770                vec![format!("  {} {}", field.name.name, field.ty.to_source())],
2771            )
2772        })
2773        .collect();
2774    let Some((own_line, trailing)) = classify_body_comments(source, event.span, &members, comments)
2775    else {
2776        return false;
2777    };
2778    push_line(formatted, format!("signal {} {{", event.name));
2779    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2780    push_line(formatted, "}");
2781    true
2782}
2783
2784fn agent_field_span(field: &AgentField) -> SourceSpan {
2785    match field {
2786        AgentField::Provider(ident) => ident.span,
2787        AgentField::Profile(profile) => profile.span,
2788        AgentField::Capacity(_, span)
2789        | AgentField::Skills(_, span)
2790        | AgentField::Capabilities(_, span)
2791        | AgentField::Requires(_, span)
2792        | AgentField::Tools(_, span) => *span,
2793        AgentField::Compaction(strategy) => strategy.span,
2794        AgentField::Thread(mode) => mode.span,
2795        AgentField::Settings(sources) => sources.span,
2796        AgentField::Unknown { span, .. } => *span,
2797    }
2798}
2799
2800fn agent_field_line(field: &AgentField) -> String {
2801    match field {
2802        AgentField::Provider(provider) => format!("  provider {}", provider.name),
2803        AgentField::Profile(profile) => format!("  profile {:?}", profile.value),
2804        AgentField::Capacity(capacity, _) => format!("  capacity {capacity}"),
2805        AgentField::Skills(skills, _) => {
2806            let skills = skills
2807                .iter()
2808                .map(|skill| format!("{:?}", skill.value))
2809                .collect::<Vec<_>>()
2810                .join(", ");
2811            format!("  skills [{skills}]")
2812        }
2813        AgentField::Capabilities(capabilities, _) => {
2814            let capabilities = capabilities
2815                .iter()
2816                .map(|capability| format!("{:?}", capability.value))
2817                .collect::<Vec<_>>()
2818                .join(", ");
2819            format!("  capabilities [{capabilities}]")
2820        }
2821        AgentField::Requires(classes, _) => {
2822            let classes = classes
2823                .iter()
2824                .map(|class| class.name.as_str())
2825                .collect::<Vec<_>>()
2826                .join(", ");
2827            format!("  requires [{classes}]")
2828        }
2829        AgentField::Tools(tools, _) => {
2830            let tools = tools
2831                .iter()
2832                .map(|tool| tool.name.as_str())
2833                .collect::<Vec<_>>()
2834                .join(", ");
2835            format!("  tools [{tools}]")
2836        }
2837        AgentField::Compaction(strategy) => format!("  compaction {}", strategy.name),
2838        AgentField::Thread(mode) => format!("  thread {}", mode.name),
2839        AgentField::Settings(sources) => format!("  settings {}", sources.name),
2840        AgentField::Unknown { name, .. } => format!("  {}", name.name),
2841    }
2842}
2843
2844/// Format an `agent` body with its own-line and trailing comments preserved.
2845/// Returns `false` (caller refuses the file) when a body comment cannot be placed
2846/// safely.
2847fn try_format_agent_with_comments(
2848    agent: &AgentDecl,
2849    source: &str,
2850    comments: &[Comment],
2851    formatted: &mut String,
2852) -> bool {
2853    let members: Vec<(SourceSpan, Vec<String>)> = agent
2854        .fields
2855        .iter()
2856        .map(|field| (agent_field_span(field), vec![agent_field_line(field)]))
2857        .collect();
2858    let Some((own_line, trailing)) = classify_body_comments(source, agent.span, &members, comments)
2859    else {
2860        return false;
2861    };
2862    let harness = agent
2863        .harness
2864        .as_ref()
2865        .map(|harness| format!(" using {}", harness.name))
2866        .or_else(|| {
2867            agent
2868                .delegated_to
2869                .as_ref()
2870                .map(|delegate| format!(" delegated to {}", delegate.name))
2871        })
2872        .unwrap_or_default();
2873    push_line(
2874        formatted,
2875        format!("agent {}{} {{", agent.name.name, harness),
2876    );
2877    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2878    push_line(formatted, "}");
2879    true
2880}
2881
2882/// Lines for one enum variant, with comments inside a data-carrying variant's
2883/// nested field block preserved (own-line interleaved, trailing appended) — the
2884/// block is a field list in braces, so it reuses the same classify/emit one level
2885/// deeper. Returns `None` when a nested comment cannot be placed safely.
2886fn enum_variant_lines_with_comments(
2887    variant: &EnumVariantDecl,
2888    source: &str,
2889    comments: &[Comment],
2890) -> Option<Vec<String>> {
2891    if variant.fields.is_empty() {
2892        return Some(vec![format!("  {}", variant.name.name)]);
2893    }
2894    let members: Vec<(SourceSpan, Vec<String>)> = variant
2895        .fields
2896        .iter()
2897        .map(|field| {
2898            (
2899                field.span,
2900                vec![format!("    {} {}", field.name.name, field.ty.to_source())],
2901            )
2902        })
2903        .collect();
2904    // `comments` is filtered to this variant's span by classify (via variant.span).
2905    let (own_line, trailing) = classify_body_comments(source, variant.span, &members, comments)?;
2906    let mut block = String::new();
2907    emit_members_with_comments(&members, &own_line, &trailing, "    ", &mut block);
2908    let mut lines = vec![format!("  {} {{", variant.name.name)];
2909    lines.extend(block.lines().map(str::to_owned));
2910    lines.push("  }".to_owned());
2911    Some(lines)
2912}
2913
2914/// Format an `enum` body with its comments preserved at both levels: between
2915/// variants (own-line interleaved, trailing appended to a bare variant's line) and
2916/// inside a data-carrying variant's nested field block. Each brace-body filters
2917/// comments by its own span, so the two levels never double-count. Returns `false`
2918/// (caller refuses the file) when a comment cannot be placed safely.
2919fn try_format_enum_with_comments(
2920    enum_decl: &EnumDecl,
2921    source: &str,
2922    comments: &[Comment],
2923    formatted: &mut String,
2924) -> bool {
2925    let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::with_capacity(enum_decl.variants.len());
2926    for variant in &enum_decl.variants {
2927        let Some(lines) = enum_variant_lines_with_comments(variant, source, comments) else {
2928            return false;
2929        };
2930        members.push((variant.span, lines));
2931    }
2932    // Enum-body-level comments are those NOT inside a data variant's nested block
2933    // (those are placed by `enum_variant_lines_with_comments`); pass only those to
2934    // the body-level classify so the nested ones are not counted twice.
2935    let body_level: Vec<Comment> = comments
2936        .iter()
2937        .filter(|comment| {
2938            !enum_decl.variants.iter().any(|variant| {
2939                !variant.fields.is_empty()
2940                    && variant.span.start < comment.span.start
2941                    && comment.span.start < variant.span.end
2942            })
2943        })
2944        .cloned()
2945        .collect();
2946    let Some((own_line, trailing)) =
2947        classify_body_comments(source, enum_decl.span, &members, &body_level)
2948    else {
2949        return false;
2950    };
2951    push_line(formatted, format!("enum {} {{", enum_decl.name.name));
2952    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2953    push_line(formatted, "}");
2954    true
2955}
2956
2957/// The name a top-level named declaration introduces, paired with its span, when
2958/// it is a kind that another workflow can reference by name (schemas, agents,
2959/// coordination resources, signals). Rules/tests/asserts/apply/contracts/patterns
2960/// introduce no such cross-referenced name here. Mirrors `document_symbols`'
2961/// named-decl set. Used to attach a "declared in workflow B" note when a
2962/// workflow references a name that is really private to a sibling.
2963fn referenced_decl_name(item: &Item) -> Option<(String, SourceSpan)> {
2964    match item {
2965        Item::Class(decl) => Some((decl.name.name.clone(), decl.span)),
2966        Item::Enum(decl) => Some((decl.name.name.clone(), decl.span)),
2967        Item::Agent(decl) => Some((decl.name.name.clone(), decl.span)),
2968        Item::Coerce(decl) => Some((decl.name.name.clone(), decl.span)),
2969        Item::Lease(decl) => Some((decl.name.name.clone(), decl.span)),
2970        Item::Ledger(decl) => Some((decl.name.name.clone(), decl.span)),
2971        Item::Counter(decl) => Some((decl.name.name.clone(), decl.span)),
2972        Item::Tracker(decl) => Some((decl.name.name.clone(), decl.span)),
2973        Item::Channel(decl) => Some((decl.name.name.clone(), decl.span)),
2974        Item::FileStore(decl) => Some((decl.name.name.clone(), decl.span)),
2975        Item::MemoryPool(decl) => Some((decl.name.name.clone(), decl.span)),
2976        Item::Event(decl) => Some((decl.name.clone(), decl.span)),
2977        Item::Table(decl) => Some((decl.name.name.clone(), decl.span)),
2978        Item::Gauge(decl) => Some((decl.name.name.clone(), decl.span)),
2979        Item::Campaign(decl) => Some((decl.name.name.clone(), decl.span)),
2980        Item::Mark(decl) => Some((decl.name.value.clone(), decl.span)),
2981        _ => None,
2982    }
2983}
2984
2985/// If `diagnostic` (produced while validating workflow `current`) reports an
2986/// unknown name that is actually declared *private to a sibling workflow*, attach
2987/// a related note pointing at that sibling's declaration. This turns a bare
2988/// "unknown class `X`" into an actionable "…and `X` lives in workflow `B`; move
2989/// it to the top level to share it." A name that is global or one of `current`'s
2990/// own locals is legitimately in scope and never annotated.
2991fn annotate_cross_workflow_leak(
2992    diagnostic: &mut Diagnostic,
2993    current: &str,
2994    own_locals: &BTreeSet<String>,
2995    global_names: &BTreeSet<String>,
2996    sibling_locals: &BTreeMap<String, Vec<(String, SourceSpan)>>,
2997) {
2998    for (name, owners) in sibling_locals {
2999        if global_names.contains(name) || own_locals.contains(name) {
3000            continue;
3001        }
3002        // Only names actually referenced (as `` `name` ``) in this diagnostic, and
3003        // owned by some workflow other than the one being validated.
3004        if !diagnostic.message.contains(&format!("`{name}`")) {
3005            continue;
3006        }
3007        let Some((owner, span)) = owners.iter().find(|(owner, _)| owner != current) else {
3008            continue;
3009        };
3010        diagnostic.related.push(RelatedInfo {
3011            span: *span,
3012            message: format!(
3013                "`{name}` is declared inside workflow `{owner}`, which makes it \
3014                 private to that workflow; move it to a top-level declaration to \
3015                 share it across workflows"
3016            ),
3017        });
3018        return;
3019    }
3020}
3021
3022fn select_root_workflow(
3023    mut program: Program,
3024    root: Option<&str>,
3025) -> Result<Program, Vec<Diagnostic>> {
3026    // A runnable program requires at least one explicit `workflow`. The implicit
3027    // compatibility root is removed (RESOLVED 2026-07-01): a source that declares
3028    // no `workflow` at all (neither the header form nor a `workflow Name { ... }`
3029    // block) is a library fragment, not a program, and is rejected here rather
3030    // than silently compiled as an anonymous root.
3031    if program.workflow.is_none() && program.workflows.is_empty() {
3032        return Err(vec![Diagnostic {
3033            related: Vec::new(),
3034            span: SourceSpan { start: 0, end: 0 },
3035            message: "program declares no `workflow`".to_owned(),
3036            suggestion: Some(
3037                "add an explicit `workflow Name { ... }` declaration; a runnable \
3038                 program requires at least one workflow (files that only declare \
3039                 shared types or patterns are libraries, meant to be `include`d)"
3040                    .to_owned(),
3041            ),
3042        }]);
3043    }
3044
3045    if program.workflows.is_empty() {
3046        if let Some(root) = root {
3047            match program.workflow.as_ref() {
3048                Some(workflow) if workflow.name == root => {}
3049                Some(workflow) => {
3050                    return Err(vec![Diagnostic {
3051                        related: Vec::new(),
3052                        span: workflow.span,
3053                        message: format!("root workflow `{root}` was not found"),
3054                        suggestion: Some(format!("available workflow: `{}`", workflow.name)),
3055                    }]);
3056                }
3057                None => {
3058                    return Err(vec![Diagnostic {
3059                        related: Vec::new(),
3060                        span: SourceSpan { start: 0, end: 0 },
3061                        message: format!("root workflow `{root}` was not found"),
3062                        suggestion: Some(
3063                            "add an explicit `workflow Name { ... }` declaration".to_owned(),
3064                        ),
3065                    }]);
3066                }
3067            }
3068        }
3069        return Ok(program);
3070    }
3071
3072    let selected_index = match root {
3073        Some(root) => match program
3074            .workflows
3075            .iter()
3076            .position(|workflow| workflow.name.name == root)
3077        {
3078            Some(index) => index,
3079            None => {
3080                let names = program
3081                    .workflows
3082                    .iter()
3083                    .map(|workflow| format!("`{}`", workflow.name.name))
3084                    .collect::<Vec<_>>()
3085                    .join(", ");
3086                return Err(vec![Diagnostic {
3087                    related: Vec::new(),
3088                    span: SourceSpan { start: 0, end: 0 },
3089                    message: format!("root workflow `{root}` was not found"),
3090                    suggestion: Some(format!("available workflows: {names}")),
3091                }]);
3092            }
3093        },
3094        None if program.workflows.len() == 1 => 0,
3095        None => {
3096            let names = program
3097                .workflows
3098                .iter()
3099                .map(|workflow| format!("`{}`", workflow.name.name))
3100                .collect::<Vec<_>>()
3101                .join(", ");
3102            return Err(vec![Diagnostic {
3103                related: Vec::new(),
3104                span: SourceSpan { start: 0, end: 0 },
3105                message: "multiple workflow declarations require an explicit root".to_owned(),
3106                suggestion: Some(format!(
3107                    "pass `--root <name>`; available workflows: {names}"
3108                )),
3109            }]);
3110        }
3111    };
3112
3113    let selected = program.workflows.remove(selected_index);
3114    let mut items = program.items;
3115    let workflow_tags = selected.tags;
3116    let workflow_description = selected.description;
3117    items.extend(selected.items);
3118    Ok(Program {
3119        workflow: Some(selected.name),
3120        workflow_tags,
3121        workflow_description,
3122        explicit_workflow_body: true,
3123        workflows: Vec::new(),
3124        patterns: program.patterns,
3125        items,
3126    })
3127}
3128
3129impl IrProgram {
3130    pub fn construct_uses(&self) -> Vec<&IrConstructUse> {
3131        self.rules
3132            .iter()
3133            .flat_map(|rule| rule.metadata.effects.iter())
3134            .filter_map(|effect| effect.construct_use.as_ref())
3135            .collect()
3136    }
3137
3138    pub fn contract_registry(&self) -> ContractRegistry {
3139        let mut libraries = BTreeMap::<String, LibraryRegistration>::new();
3140        let mut contracts = BTreeMap::<(String, String), EffectContract>::new();
3141
3142        for use_decl in &self.uses {
3143            libraries
3144                .entry(use_decl.name.clone())
3145                .or_insert_with(|| LibraryRegistration {
3146                    id: use_decl.name.clone(),
3147                    version: "unlocked".to_owned(),
3148                    standard: false,
3149                });
3150        }
3151
3152        if !self.harnesses.is_empty() || !self.agents.is_empty() {
3153            register_standard_library(&mut libraries, "std.agent");
3154        }
3155        if !self.trackers.is_empty() {
3156            register_standard_library(&mut libraries, "std.tracker");
3157        }
3158        if !self.events.is_empty() {
3159            register_standard_library(&mut libraries, "std.ingress");
3160        }
3161        if !self.leases.is_empty() || !self.ledgers.is_empty() || !self.counters.is_empty() {
3162            register_standard_library(&mut libraries, "std.coord");
3163        }
3164        if !self.channels.is_empty() {
3165            register_standard_library(&mut libraries, "std.messaging");
3166        }
3167        // A bare `file store` declaration registers the owning library even
3168        // before any rule uses a file effect (spec/std-files.md "Manifest":
3169        // the declaration alone previously registered nothing).
3170        if !self.file_stores.is_empty() {
3171            register_standard_library(&mut libraries, "std.files");
3172        }
3173        if self.sources.iter().any(|source| source.is_clock) {
3174            register_standard_library(&mut libraries, "std.time");
3175        }
3176        if !self.coerces.is_empty() {
3177            register_standard_library(&mut libraries, "std.coercion");
3178            register_effect_contract(
3179                &mut libraries,
3180                &mut contracts,
3181                IrEffectKind::SchemaCoerce,
3182                Vec::new(),
3183            );
3184        }
3185
3186        for rule in &self.rules {
3187            for effect in &rule.metadata.effects {
3188                register_effect_contract(
3189                    &mut libraries,
3190                    &mut contracts,
3191                    effect.kind.clone(),
3192                    effect.required_capabilities.clone(),
3193                );
3194            }
3195        }
3196
3197        // Package-owned construct registrations (e.g. `send`, `recall`) are NOT
3198        // registered here: they come from a package manifest — embedded std
3199        // manifests included — merged in by the CLI when the owning package is
3200        // imported (`use std.messaging`). Modeled in
3201        // `models/maude/std-construct-authorization.maude`.
3202        ContractRegistry {
3203            libraries: libraries.into_values().collect(),
3204            constructs: Vec::new(),
3205            effect_contracts: contracts.into_values().collect(),
3206        }
3207    }
3208
3209    pub fn to_snapshot(&self) -> String {
3210        let mut snapshot = String::new();
3211        push_line(&mut snapshot, format!("workflow {}", self.workflow));
3212
3213        if !self.source_tags.is_empty() {
3214            push_line(&mut snapshot, "source_tags");
3215            for tag in &self.source_tags {
3216                push_line(
3217                    &mut snapshot,
3218                    format!("@{} {} {}", tag.name, tag.target_kind, tag.target),
3219                );
3220            }
3221        }
3222
3223        if !self.source_descriptions.is_empty() {
3224            push_line(&mut snapshot, "source_descriptions");
3225            for description in &self.source_descriptions {
3226                push_line(
3227                    &mut snapshot,
3228                    format!(
3229                        "{:?} {} {}",
3230                        description.value, description.target_kind, description.target
3231                    ),
3232                );
3233            }
3234        }
3235
3236        if !self.shared_coordination_usage.is_empty() {
3237            push_line(&mut snapshot, "shared_coordination_usage");
3238            for usage in &self.shared_coordination_usage {
3239                push_line(
3240                    &mut snapshot,
3241                    format!(
3242                        "{} <- {}",
3243                        usage.resource,
3244                        usage.workflow_principals.join(",")
3245                    ),
3246                );
3247            }
3248        }
3249
3250        if !self.includes.is_empty() {
3251            push_line(&mut snapshot, "includes");
3252            for include in &self.includes {
3253                match &include.source_hash {
3254                    Some(source_hash) => {
3255                        push_line(
3256                            &mut snapshot,
3257                            format!("  {} hash {}", include.path, source_hash),
3258                        );
3259                    }
3260                    None => push_line(&mut snapshot, format!("  {}", include.path)),
3261                }
3262            }
3263        }
3264
3265        if !self.pattern_applications.is_empty() {
3266            push_line(&mut snapshot, "pattern_applications");
3267            for application in &self.pattern_applications {
3268                let type_args = application
3269                    .type_args
3270                    .iter()
3271                    .map(IrType::to_snapshot)
3272                    .collect::<Vec<_>>()
3273                    .join(", ");
3274                push_line(
3275                    &mut snapshot,
3276                    format!(
3277                        "  {} as {}<{}>",
3278                        application.pattern, application.alias, type_args
3279                    ),
3280                );
3281                push_line(
3282                    &mut snapshot,
3283                    format!(
3284                        "    defined-at {}..{}",
3285                        application.definition_span.start, application.definition_span.end
3286                    ),
3287                );
3288                push_line(
3289                    &mut snapshot,
3290                    format!(
3291                        "    applied-at {}..{}",
3292                        application.application_span.start, application.application_span.end
3293                    ),
3294                );
3295                for argument in &application.value_args {
3296                    push_line(
3297                        &mut snapshot,
3298                        format!("    arg {} {}", argument.name, argument.value),
3299                    );
3300                }
3301                for generated in &application.generated {
3302                    push_line(&mut snapshot, format!("    generated {generated}"));
3303                }
3304            }
3305        }
3306
3307        if !self.workflow_contracts.is_empty() {
3308            push_line(&mut snapshot, "workflow_contracts");
3309            for contract in &self.workflow_contracts {
3310                push_line(
3311                    &mut snapshot,
3312                    format!(
3313                        "  {} {} {}",
3314                        contract.kind.as_str(),
3315                        contract.name,
3316                        contract.ty.to_snapshot()
3317                    ),
3318                );
3319            }
3320        }
3321
3322        if !self.uses.is_empty() {
3323            push_line(&mut snapshot, "uses");
3324            for use_decl in &self.uses {
3325                push_line(
3326                    &mut snapshot,
3327                    format!("  {} {}", use_decl.kind.as_str(), use_decl.name),
3328                );
3329            }
3330        }
3331
3332        if !self.schemas.is_empty() {
3333            push_line(&mut snapshot, "schemas");
3334            for schema in &self.schemas {
3335                match schema {
3336                    IrSchema::Enum(enum_decl) => {
3337                        push_line(
3338                            &mut snapshot,
3339                            format!(
3340                                "  enum {} {{ {} }}",
3341                                enum_decl.name,
3342                                enum_decl.variants.join(", ")
3343                            ),
3344                        );
3345                    }
3346                    IrSchema::Class(class_decl) => {
3347                        push_line(&mut snapshot, format!("  class {}", class_decl.name));
3348                        for field in &class_decl.fields {
3349                            // `@key` is serialized only when set, so non-keyed
3350                            // classes keep their prior snapshot (no ripple).
3351                            let key = if field.is_key { " @key" } else { "" };
3352                            push_line(
3353                                &mut snapshot,
3354                                format!("    {} {}{key}", field.name, field.ty.to_snapshot()),
3355                            );
3356                        }
3357                    }
3358                }
3359            }
3360        }
3361
3362        if !self.harnesses.is_empty() {
3363            push_line(&mut snapshot, "harnesses");
3364            for harness in &self.harnesses {
3365                push_line(
3366                    &mut snapshot,
3367                    format!("  harness {} kind={}", harness.name, harness.kind),
3368                );
3369            }
3370        }
3371        if !self.trackers.is_empty() {
3372            push_line(&mut snapshot, "trackers");
3373            for queue in &self.trackers {
3374                push_line(
3375                    &mut snapshot,
3376                    format!("  tracker {} provider={}", queue.name, queue.provider),
3377                );
3378            }
3379        }
3380
3381        if !self.channels.is_empty() {
3382            push_line(&mut snapshot, "channels");
3383            for channel in &self.channels {
3384                let mut line = format!("  channel {} provider={}", channel.name, channel.provider);
3385                if let Some(workspace) = &channel.workspace {
3386                    line.push_str(&format!(" workspace={workspace}"));
3387                }
3388                if let Some(destination) = &channel.destination {
3389                    line.push_str(&format!(" destination={destination:?}"));
3390                }
3391                push_line(&mut snapshot, line);
3392            }
3393        }
3394
3395        if !self.gauges.is_empty() {
3396            push_line(&mut snapshot, "gauges");
3397            for gauge in &self.gauges {
3398                let mut line = format!(
3399                    "  gauge {} judge={}:{}",
3400                    gauge.name, gauge.judge_kind, gauge.judge_target
3401                );
3402                if !gauge.judge_args.is_empty() {
3403                    line.push_str(&format!(" args=({})", gauge.judge_args.join(",")));
3404                }
3405                if let Some(site) = &gauge.site {
3406                    line.push_str(&format!(" site={site}"));
3407                }
3408                if let Some(bar) = &gauge.expect {
3409                    line.push_str(&format!(
3410                        " expect={}:{}{}{}",
3411                        bar.form, bar.subject, bar.op, bar.threshold
3412                    ));
3413                }
3414                if !gauge.inputs.is_empty() {
3415                    line.push_str(&format!(" inputs={}", gauge.inputs.join(",")));
3416                }
3417                push_line(&mut snapshot, line);
3418            }
3419        }
3420
3421        if !self.marks.is_empty() {
3422            push_line(&mut snapshot, "marks");
3423            for mark in &self.marks {
3424                push_line(
3425                    &mut snapshot,
3426                    format!("  mark {:?} after {}", mark.name, mark.site),
3427                );
3428            }
3429        }
3430
3431        if !self.campaigns.is_empty() {
3432            push_line(&mut snapshot, "campaigns");
3433            for campaign in &self.campaigns {
3434                let mut line = format!("  campaign {}", campaign.name);
3435                if !campaign.ascend.is_empty() {
3436                    line.push_str(&format!(" ascend={}", campaign.ascend.join(",")));
3437                }
3438                for reach in &campaign.reach {
3439                    line.push_str(&format!(
3440                        " reach={}{}{}{}",
3441                        reach.gauge,
3442                        reach.op,
3443                        reach.threshold,
3444                        reach.unit.as_deref().unwrap_or("")
3445                    ));
3446                }
3447                for guard in &campaign.guard {
3448                    line.push_str(&format!(
3449                        " guard={}:within:{}%",
3450                        guard.gauge, guard.band_percent
3451                    ));
3452                }
3453                if !campaign.sacrifice.is_empty() {
3454                    line.push_str(&format!(" sacrifice={}", campaign.sacrifice.join(",")));
3455                }
3456                if campaign.proposer_redacted {
3457                    line.push_str(" proposer=redacted");
3458                }
3459                push_line(&mut snapshot, line);
3460            }
3461        }
3462
3463        if !self.file_stores.is_empty() {
3464            push_line(&mut snapshot, "file_stores");
3465            for file_store in &self.file_stores {
3466                push_line(
3467                    &mut snapshot,
3468                    format!(
3469                        "  file store {} root={:?}",
3470                        file_store.name, file_store.root
3471                    ),
3472                );
3473                // Globs are serialized only when present, so stores without an
3474                // `allow` clause keep their prior snapshot (no ripple).
3475                if !file_store.read_globs.is_empty() {
3476                    push_line(
3477                        &mut snapshot,
3478                        format!("    allow read {:?}", file_store.read_globs),
3479                    );
3480                }
3481                if !file_store.write_globs.is_empty() {
3482                    push_line(
3483                        &mut snapshot,
3484                        format!("    allow write {:?}", file_store.write_globs),
3485                    );
3486                }
3487                // The provider likewise serializes only when declared (unset =
3488                // the `local` default), so provider-less stores keep their
3489                // prior `.ir` byte-identically (slice F5 zero-churn gate).
3490                if let Some(provider) = &file_store.provider {
3491                    push_line(&mut snapshot, format!("    provider {provider}"));
3492                }
3493            }
3494        }
3495
3496        if !self.memory_pools.is_empty() {
3497            push_line(&mut snapshot, "memory_pools");
3498            for pool in &self.memory_pools {
3499                push_line(&mut snapshot, format!("  memory pool {}", pool.name));
3500                // The context limit is serialized only when present, so pools
3501                // without it keep a minimal snapshot (no ripple).
3502                if let Some(limit) = pool.context_limit {
3503                    push_line(&mut snapshot, format!("    context limit {limit}"));
3504                }
3505            }
3506        }
3507
3508        if !self.agents.is_empty() {
3509            push_line(&mut snapshot, "agents");
3510            for agent in &self.agents {
3511                let profile = agent.profile.as_deref().unwrap_or("<missing>");
3512                let harness = agent.harness.as_deref().unwrap_or("<fallback>");
3513                let provider = agent.provider.as_deref().unwrap_or("<fallback>");
3514                let capacity = agent
3515                    .capacity
3516                    .map(|capacity| capacity.to_string())
3517                    .unwrap_or_else(|| "<missing>".to_owned());
3518                let skills = if agent.skills.is_empty() {
3519                    "[]".to_owned()
3520                } else {
3521                    format!("[{}]", agent.skills.join(", "))
3522                };
3523                let capabilities = if agent.capabilities.is_empty() {
3524                    "[]".to_owned()
3525                } else {
3526                    format!("[{}]", agent.capabilities.join(", "))
3527                };
3528                let tools = if agent.tools.is_empty() {
3529                    "[]".to_owned()
3530                } else {
3531                    format!("[{}]", agent.tools.join(", "))
3532                };
3533                // Feature requirements append only when declared, so agents
3534                // without `requires` keep an unchanged .ir snapshot (no ripple).
3535                let requires = if agent.requires.is_empty() {
3536                    String::new()
3537                } else {
3538                    format!(" requires=[{}]", agent.requires.join(", "))
3539                };
3540                // Compaction strategy appends only when set, so agents that take the
3541                // harness default keep an unchanged .ir snapshot (no ripple).
3542                let compaction = agent
3543                    .compaction
3544                    .as_deref()
3545                    .map(|strategy| format!(" compaction={strategy}"))
3546                    .unwrap_or_default();
3547                // Settings likewise appends only when set (unset = provider default).
3548                let settings = agent
3549                    .settings
3550                    .as_deref()
3551                    .map(|sources| format!(" settings={sources}"))
3552                    .unwrap_or_default();
3553                // Thread mode likewise appends only when set (unset = fresh).
3554                let thread = agent
3555                    .thread
3556                    .as_deref()
3557                    .map(|mode| format!(" thread={mode}"))
3558                    .unwrap_or_default();
3559                // Harness class (DR-0034): Managed is the default/substrate, so only
3560                // Delegated agents emit a class token — Managed agents' .ir is unchanged.
3561                let class = match agent.harness_class {
3562                    HarnessClass::Delegated => " class=delegated",
3563                    HarnessClass::Managed => "",
3564                };
3565                push_line(
3566                    &mut snapshot,
3567                    format!(
3568                        "  agent {} harness={} provider={} profile={} capacity={} skills={} capabilities={} tools={}{}{}{}{}{}",
3569                        agent.name, harness, provider, profile, capacity, skills, capabilities, tools, requires, compaction, settings, thread, class
3570                    ),
3571                );
3572            }
3573        }
3574
3575        if !self.coerces.is_empty() {
3576            push_line(&mut snapshot, "coerces");
3577            for coerce in &self.coerces {
3578                let params = coerce
3579                    .params
3580                    .iter()
3581                    .map(|param| format!("{} {}", param.name, param.ty.to_snapshot()))
3582                    .collect::<Vec<_>>()
3583                    .join(", ");
3584                push_line(
3585                    &mut snapshot,
3586                    format!(
3587                        "  coerce {}({}) -> {}",
3588                        coerce.name,
3589                        params,
3590                        coerce.output.to_snapshot()
3591                    ),
3592                );
3593            }
3594        }
3595
3596        if !self.assertions.is_empty() {
3597            push_line(&mut snapshot, "assertions");
3598            for assertion in &self.assertions {
3599                push_line(
3600                    &mut snapshot,
3601                    format!("  assert {}", assertion.expr.expr.to_snapshot()),
3602                );
3603                if !assertion.projection_reads.is_empty() {
3604                    push_line(&mut snapshot, "    reads");
3605                    for read in &assertion.projection_reads {
3606                        push_line(&mut snapshot, format!("      {}", read.to_snapshot()));
3607                    }
3608                }
3609            }
3610        }
3611
3612        if !self.rules.is_empty() {
3613            push_line(&mut snapshot, "rules");
3614            for rule in &self.rules {
3615                push_line(&mut snapshot, format!("  rule {}", rule.name));
3616                for when in &rule.whens {
3617                    match &when.guard {
3618                        Some(guard) => push_line(
3619                            &mut snapshot,
3620                            format!(
3621                                "    when {} where {}",
3622                                when.pattern,
3623                                guard.expr.to_snapshot()
3624                            ),
3625                        ),
3626                        None => push_line(&mut snapshot, format!("    when {}", when.pattern)),
3627                    }
3628                }
3629                if !rule.metadata.fact_reads.is_empty() {
3630                    push_line(&mut snapshot, "    reads");
3631                    for read in &rule.metadata.fact_reads {
3632                        push_line(&mut snapshot, format!("      {}", read));
3633                    }
3634                }
3635                if !rule.metadata.projection_reads.is_empty() {
3636                    push_line(&mut snapshot, "    projection_reads");
3637                    for read in &rule.metadata.projection_reads {
3638                        push_line(&mut snapshot, format!("      {}", read.to_snapshot()));
3639                    }
3640                }
3641                if !rule.metadata.fact_writes.is_empty() {
3642                    push_line(&mut snapshot, "    writes");
3643                    for write in &rule.metadata.fact_writes {
3644                        push_line(&mut snapshot, format!("      {}", write));
3645                    }
3646                }
3647                if !rule.metadata.record_sources.is_empty() {
3648                    push_line(&mut snapshot, "    record_sources");
3649                    for source in &rule.metadata.record_sources {
3650                        push_line(
3651                            &mut snapshot,
3652                            format!(
3653                                "      schema:{} construct={} span={}..{}",
3654                                source.schema, source.construct, source.span.start, source.span.end
3655                            ),
3656                        );
3657                    }
3658                }
3659                if !rule.metadata.fact_consumes.is_empty() {
3660                    push_line(&mut snapshot, "    consumes");
3661                    for consumed in &rule.metadata.fact_consumes {
3662                        push_line(&mut snapshot, format!("      {}", consumed));
3663                    }
3664                }
3665                if !rule.metadata.effects.is_empty() {
3666                    push_line(&mut snapshot, "    effects");
3667                    for effect in &rule.metadata.effects {
3668                        let binding = effect.binding.as_deref().unwrap_or("-");
3669                        let construct = effect
3670                            .construct_use
3671                            .as_ref()
3672                            .map(|form| {
3673                                format!(" construct={}->{}", form.keyword, form.target_capability)
3674                            })
3675                            .unwrap_or_default();
3676                        // Turn-access grants are appended only when present, so
3677                        // grant-free effects keep their existing snapshot shape.
3678                        let grants = if effect.access_grants.is_empty() {
3679                            String::new()
3680                        } else {
3681                            let rendered = effect
3682                                .access_grants
3683                                .iter()
3684                                .map(|grant| {
3685                                    let ops = grant
3686                                        .operations
3687                                        .iter()
3688                                        .map(|op| op.operation.as_str())
3689                                        .collect::<Vec<_>>()
3690                                        .join(",");
3691                                    format!("{}[{ops}]", grant.resource)
3692                                })
3693                                .collect::<Vec<_>>()
3694                                .join(";");
3695                            format!(" grants={rendered}")
3696                        };
3697                        // Turn-scoped skill pins (Phase 7) append only when present,
3698                        // so pin-free effects keep their existing snapshot shape.
3699                        let skills = if effect.turn_skills.is_empty() {
3700                            String::new()
3701                        } else {
3702                            format!(" skills={}", effect.turn_skills.join(","))
3703                        };
3704                        push_line(
3705                            &mut snapshot,
3706                            format!(
3707                                "      {} kind={} binding={}{} key={}{}{}",
3708                                effect.id,
3709                                effect.kind.as_str(),
3710                                binding,
3711                                construct,
3712                                effect.idempotency_key,
3713                                grants,
3714                                skills
3715                            ),
3716                        );
3717                    }
3718                }
3719                if !rule.metadata.dependencies.is_empty() {
3720                    push_line(&mut snapshot, "    dependencies");
3721                    for dependency in &rule.metadata.dependencies {
3722                        push_line(
3723                            &mut snapshot,
3724                            format!(
3725                                "      {} --{}--> {}",
3726                                dependency.upstream,
3727                                dependency.predicate.as_str(),
3728                                dependency.downstream
3729                            ),
3730                        );
3731                    }
3732                }
3733                if !rule.metadata.case_branches.is_empty() {
3734                    push_line(&mut snapshot, "    case_branches");
3735                    for branch in &rule.metadata.case_branches {
3736                        let guard = branch
3737                            .guard
3738                            .as_ref()
3739                            .map(|guard| guard.expr.to_snapshot())
3740                            .unwrap_or_else(|| "-".to_owned());
3741                        push_line(
3742                            &mut snapshot,
3743                            format!(
3744                                "      case {} type={} pattern={} guard={} body_hash={} span={}..{}",
3745                                branch.scrutinee,
3746                                branch.scrutinee_type.to_snapshot(),
3747                                branch.pattern.to_snapshot(),
3748                                guard,
3749                                branch.body_hash,
3750                                branch.pattern_span.start,
3751                                branch.pattern_span.end
3752                            ),
3753                        );
3754                    }
3755                }
3756                if !rule.metadata.terminal_outputs.is_empty() {
3757                    push_line(&mut snapshot, "    terminal_outputs");
3758                    for output in &rule.metadata.terminal_outputs {
3759                        push_line(
3760                            &mut snapshot,
3761                            format!(
3762                                "      {} span={}..{}",
3763                                output.binding, output.span.start, output.span.end
3764                            ),
3765                        );
3766                        for alternative in &output.alternatives {
3767                            push_line(
3768                                &mut snapshot,
3769                                format!(
3770                                    "        {} payload={} span={}..{}",
3771                                    alternative.tag,
3772                                    alternative.payload_type.to_snapshot(),
3773                                    alternative.source_span.start,
3774                                    alternative.source_span.end
3775                                ),
3776                            );
3777                        }
3778                    }
3779                }
3780                if !rule.metadata.terminal_branches.is_empty() {
3781                    push_line(&mut snapshot, "    terminal_branches");
3782                    for branch in &rule.metadata.terminal_branches {
3783                        let tag = branch.tag.as_deref().unwrap_or("_");
3784                        let binding = branch.binding.as_deref().unwrap_or("-");
3785                        let guard = branch
3786                            .guard
3787                            .as_ref()
3788                            .map(|guard| guard.expr.to_snapshot())
3789                            .unwrap_or_else(|| "-".to_owned());
3790                        push_line(
3791                            &mut snapshot,
3792                            format!(
3793                                "      case {} {} binding={} guard={} body_hash={} span={}..{}",
3794                                branch.scrutinee,
3795                                tag,
3796                                binding,
3797                                guard,
3798                                branch.body_hash,
3799                                branch.pattern_span.start,
3800                                branch.pattern_span.end
3801                            ),
3802                        );
3803                    }
3804                }
3805                push_line(
3806                    &mut snapshot,
3807                    format!("    body_hash {}", stable_hash(&rule.body)),
3808                );
3809            }
3810        }
3811
3812        if !self.rule_dependencies.is_empty() {
3813            push_line(&mut snapshot, "rule_dependencies");
3814            for dependency in &self.rule_dependencies {
3815                push_line(
3816                    &mut snapshot,
3817                    format!(
3818                        "  {} --{}--> {}",
3819                        dependency.producer, dependency.fact, dependency.consumer
3820                    ),
3821                );
3822            }
3823        }
3824
3825        snapshot
3826    }
3827}
3828
3829fn register_standard_library(libraries: &mut BTreeMap<String, LibraryRegistration>, id: &str) {
3830    libraries
3831        .entry(id.to_owned())
3832        .or_insert_with(|| LibraryRegistration {
3833            id: id.to_owned(),
3834            version: "0.1.0".to_owned(),
3835            standard: true,
3836        });
3837}
3838
3839fn register_effect_contract(
3840    libraries: &mut BTreeMap<String, LibraryRegistration>,
3841    contracts: &mut BTreeMap<(String, String), EffectContract>,
3842    kind: IrEffectKind,
3843    required_capabilities: Vec<String>,
3844) {
3845    let contract = effect_contract_for_kind(kind, required_capabilities);
3846    register_standard_library(libraries, contract.library_id.as_str());
3847    contracts
3848        .entry((contract.id.clone(), contract.version.clone()))
3849        .and_modify(|existing| {
3850            merge_unique(
3851                &mut existing.required_capabilities,
3852                &contract.required_capabilities,
3853            );
3854            merge_unique(&mut existing.provider_kinds, &contract.provider_kinds);
3855            merge_unique(&mut existing.source_forms, &contract.source_forms);
3856            merge_unique(&mut existing.projected_facts, &contract.projected_facts);
3857        })
3858        .or_insert(contract);
3859}
3860
3861fn merge_unique(target: &mut Vec<String>, values: &[String]) {
3862    for value in values {
3863        if !target.contains(value) {
3864            target.push(value.clone());
3865        }
3866    }
3867    target.sort();
3868}
3869
3870fn strings(values: &[&str]) -> Vec<String> {
3871    values.iter().map(|value| (*value).to_owned()).collect()
3872}
3873
3874fn effect_contract_for_kind(
3875    kind: IrEffectKind,
3876    required_capabilities: Vec<String>,
3877) -> EffectContract {
3878    let mut required_capabilities = required_capabilities;
3879    required_capabilities.sort();
3880    required_capabilities.dedup();
3881    let effect_kind = kind.as_str().to_owned();
3882
3883    let (
3884        library_id,
3885        source_forms,
3886        input_schema,
3887        output_schema,
3888        default_capabilities,
3889        provider_kinds,
3890        projected_facts,
3891        validation,
3892    ) = match kind {
3893        IrEffectKind::AgentTell => (
3894            "std.agent",
3895            strings(&["tell"]),
3896            Some("agent.turn.request"),
3897            Some("AgentTurn"),
3898            strings(&["agent.turn"]),
3899            strings(&["agent"]),
3900            strings(&["effect.output"]),
3901            TypedOutputValidation::RuntimeBoundary,
3902        ),
3903        IrEffectKind::SchemaCoerce => (
3904            "std.coercion",
3905            strings(&["coerce", "decide", "prompt"]),
3906            Some("schema.coerce.input"),
3907            Some("typed-provider-output"),
3908            // Capability id == effect kind (spec/std-coercion.md "Static
3909            // checks" 1: the never-enforced `model.invoke` died with the S2
3910            // rename), and the provider kind is the kernel's
3911            // `provider::PROVIDER_SCHEMA_COERCE` ("schema_coercer") string — a
3912            // schema coercer, not a generic model row.
3913            strings(&["schema.coerce"]),
3914            strings(&["schema_coercer"]),
3915            strings(&["effect.output"]),
3916            TypedOutputValidation::RuntimeBoundary,
3917        ),
3918        IrEffectKind::CapabilityCall => (
3919            "std.script",
3920            strings(&["call"]),
3921            Some("capability.call.input"),
3922            Some("capability.call.output"),
3923            Vec::new(),
3924            strings(&["capability"]),
3925            strings(&["effect.output"]),
3926            TypedOutputValidation::RuntimeBoundary,
3927        ),
3928        IrEffectKind::EventEmit => (
3929            "std.ingress",
3930            strings(&["emit"]),
3931            Some("event.emit.input"),
3932            None,
3933            Vec::new(),
3934            Vec::new(),
3935            Vec::new(),
3936            TypedOutputValidation::None,
3937        ),
3938        IrEffectKind::WorkflowInvoke => (
3939            "std.workflow",
3940            strings(&["invoke"]),
3941            Some("workflow.invoke.input"),
3942            Some("workflow.terminal"),
3943            Vec::new(),
3944            Vec::new(),
3945            strings(&["effect.output"]),
3946            TypedOutputValidation::RuntimeBoundary,
3947        ),
3948        IrEffectKind::TimerWait => (
3949            "std.time",
3950            strings(&["timer"]),
3951            Some("timer.wait.input"),
3952            Some("TimerElapsed"),
3953            Vec::new(),
3954            Vec::new(),
3955            strings(&["effect.output"]),
3956            TypedOutputValidation::None,
3957        ),
3958        IrEffectKind::ExecCommand => (
3959            "std.script",
3960            strings(&["exec"]),
3961            Some("exec.command.input"),
3962            Some("exec.command.output"),
3963            strings(&["exec.run"]),
3964            strings(&["script", "command"]),
3965            strings(&["effect.output"]),
3966            TypedOutputValidation::RuntimeBoundary,
3967        ),
3968        IrEffectKind::TrackerFile => (
3969            "std.tracker",
3970            strings(&["file"]),
3971            Some("tracker.file.input"),
3972            None,
3973            strings(&["tracker.file"]),
3974            Vec::new(),
3975            Vec::new(),
3976            TypedOutputValidation::None,
3977        ),
3978        IrEffectKind::TrackerClaim => (
3979            "std.tracker",
3980            strings(&["claim"]),
3981            Some("tracker.claim.input"),
3982            Some("TrackerClaim"),
3983            strings(&["tracker.claim"]),
3984            Vec::new(),
3985            strings(&["effect.output"]),
3986            TypedOutputValidation::None,
3987        ),
3988        // T3: holder-only renew of a claimed issue. No typed output schema (the
3989        // renewed/not_held outcome is a completed/failed terminal, mirroring
3990        // tracker.release), so the manifest contract row folds cleanly against
3991        // this compiled one.
3992        IrEffectKind::TrackerRenew => (
3993            "std.tracker",
3994            strings(&["renew"]),
3995            Some("tracker.renew.input"),
3996            None,
3997            strings(&["tracker.renew"]),
3998            Vec::new(),
3999            Vec::new(),
4000            TypedOutputValidation::None,
4001        ),
4002        IrEffectKind::TrackerRelease => (
4003            "std.tracker",
4004            strings(&["release"]),
4005            Some("tracker.release.input"),
4006            None,
4007            strings(&["tracker.release"]),
4008            Vec::new(),
4009            Vec::new(),
4010            TypedOutputValidation::None,
4011        ),
4012        IrEffectKind::TrackerFinish => (
4013            "std.tracker",
4014            strings(&["finish"]),
4015            Some("tracker.finish.input"),
4016            None,
4017            strings(&["tracker.finish"]),
4018            Vec::new(),
4019            Vec::new(),
4020            TypedOutputValidation::None,
4021        ),
4022        IrEffectKind::LeaseAcquire => (
4023            "std.coord",
4024            strings(&["acquire"]),
4025            Some("lease.acquire.input"),
4026            Some("LeaseAcquireOutcome"),
4027            Vec::new(),
4028            Vec::new(),
4029            strings(&["effect.output"]),
4030            TypedOutputValidation::None,
4031        ),
4032        IrEffectKind::LeaseRenew => (
4033            "std.coord",
4034            strings(&["renew"]),
4035            Some("lease.renew.input"),
4036            Some("LeaseRenewOutcome"),
4037            Vec::new(),
4038            Vec::new(),
4039            strings(&["effect.output"]),
4040            TypedOutputValidation::None,
4041        ),
4042        IrEffectKind::LedgerAppend => (
4043            "std.coord",
4044            strings(&["append"]),
4045            Some("ledger.append.input"),
4046            None,
4047            Vec::new(),
4048            Vec::new(),
4049            Vec::new(),
4050            TypedOutputValidation::None,
4051        ),
4052        IrEffectKind::CounterConsume => (
4053            "std.coord",
4054            strings(&["consume"]),
4055            Some("counter.consume.input"),
4056            Some("CounterConsumeOutcome"),
4057            Vec::new(),
4058            Vec::new(),
4059            strings(&["effect.output"]),
4060            TypedOutputValidation::None,
4061        ),
4062        IrEffectKind::SignalEmit => (
4063            "std.ingress",
4064            strings(&["emit", "signal"]),
4065            Some("signal.emit.input"),
4066            None,
4067            Vec::new(),
4068            Vec::new(),
4069            Vec::new(),
4070            TypedOutputValidation::None,
4071        ),
4072        // std.files capability ids EQUAL effect kinds (spec/std-files.md, M3
4073        // id==kind): each contract requires exactly its own kind string, which
4074        // the store's default-required-capability rule already derives for an
4075        // empty list — declaring it here makes the registry honest about it.
4076        IrEffectKind::FileRead => (
4077            "std.files",
4078            strings(&["read"]),
4079            Some("file.read.input"),
4080            Some("FileReadResult"),
4081            strings(&["file.read"]),
4082            Vec::new(),
4083            strings(&["effect.output"]),
4084            TypedOutputValidation::RuntimeBoundary,
4085        ),
4086        IrEffectKind::FileWrite => (
4087            "std.files",
4088            strings(&["write"]),
4089            Some("file.write.input"),
4090            Some("FileWriteResult"),
4091            strings(&["file.write"]),
4092            Vec::new(),
4093            strings(&["effect.output"]),
4094            TypedOutputValidation::RuntimeBoundary,
4095        ),
4096        IrEffectKind::FileImport => (
4097            "std.files",
4098            strings(&["import"]),
4099            Some("file.import.input"),
4100            Some("FileImportResult"),
4101            strings(&["file.import"]),
4102            Vec::new(),
4103            strings(&["effect.output"]),
4104            TypedOutputValidation::RuntimeBoundary,
4105        ),
4106        IrEffectKind::FileExport => (
4107            "std.files",
4108            strings(&["export"]),
4109            Some("file.export.input"),
4110            Some("FileExportResult"),
4111            strings(&["file.export"]),
4112            Vec::new(),
4113            strings(&["effect.output"]),
4114            TypedOutputValidation::RuntimeBoundary,
4115        ),
4116    };
4117
4118    merge_unique(&mut required_capabilities, &default_capabilities);
4119
4120    EffectContract {
4121        id: effect_kind.clone(),
4122        library_id: library_id.to_owned(),
4123        version: "0.1.0".to_owned(),
4124        effect_kind,
4125        source_forms,
4126        input_schema: input_schema.map(str::to_owned),
4127        output_schema: output_schema.map(str::to_owned),
4128        required_capabilities,
4129        provider_kinds,
4130        projected_facts,
4131        validation,
4132    }
4133}
4134
4135impl IrEffectKind {
4136    /// The single canonical `IrEffectKind` → effect-kind string map. Kernel and
4137    /// CLI delegate here (S0 dedup) so a rename touches exactly one match.
4138    pub fn as_str(&self) -> &'static str {
4139        match self {
4140            Self::AgentTell => "agent.tell",
4141            Self::SchemaCoerce => "schema.coerce",
4142            Self::CapabilityCall => "capability.call",
4143            Self::EventEmit => "event.emit",
4144            Self::WorkflowInvoke => "workflow.invoke",
4145            Self::TimerWait => "timer.wait",
4146            Self::ExecCommand => "exec.command",
4147            Self::TrackerFile => "tracker.file",
4148            Self::TrackerClaim => "tracker.claim",
4149            Self::TrackerRenew => "tracker.renew",
4150            Self::TrackerRelease => "tracker.release",
4151            Self::TrackerFinish => "tracker.finish",
4152            Self::LeaseAcquire => "lease.acquire",
4153            Self::LeaseRenew => "lease.renew",
4154            Self::LedgerAppend => "ledger.append",
4155            Self::CounterConsume => "counter.consume",
4156            Self::SignalEmit => "signal.emit",
4157            Self::FileRead => "file.read",
4158            Self::FileWrite => "file.write",
4159            Self::FileImport => "file.import",
4160            Self::FileExport => "file.export",
4161        }
4162    }
4163}
4164
4165impl DependencyPredicate {
4166    fn as_str(&self) -> &'static str {
4167        match self {
4168            Self::Succeeds => "succeeds",
4169            Self::Fails => "fails",
4170            Self::TimedOut => "timed_out",
4171            Self::Cancelled => "cancelled",
4172            Self::Completes => "completes",
4173        }
4174    }
4175}
4176
4177impl IrUseKind {
4178    fn as_str(&self) -> &'static str {
4179        match self {
4180            Self::Package => "package",
4181        }
4182    }
4183}
4184
4185impl IrType {
4186    /// A human-readable label for this type (e.g. `ref<TicketRequest>`), for use in
4187    /// diagnostics such as workflow-input errors.
4188    pub fn display_label(&self) -> String {
4189        self.to_snapshot()
4190    }
4191
4192    fn to_snapshot(&self) -> String {
4193        match self {
4194            Self::Primitive(primitive) => primitive.as_str().to_owned(),
4195            Self::LiteralString(value) => format!("literal<{value:?}>"),
4196            Self::Ref(name) => format!("ref<{name}>"),
4197            Self::AgentRef(agents) => format!("agentref<{}>", agents.join(" | ")),
4198            Self::Object(fields) => {
4199                let fields = fields
4200                    .iter()
4201                    .map(|field| format!("{} {}", field.name, field.ty.to_snapshot()))
4202                    .collect::<Vec<_>>()
4203                    .join(", ");
4204                format!("object<{{{fields}}}>")
4205            }
4206            Self::Optional(inner) => format!("optional<{}>", inner.to_snapshot()),
4207            Self::Array(inner) => format!("array<{}>", inner.to_snapshot()),
4208            Self::Map(inner) => format!("map<{}>", inner.to_snapshot()),
4209            Self::Union(variants) => {
4210                let variants = variants
4211                    .iter()
4212                    .map(Self::to_snapshot)
4213                    .collect::<Vec<_>>()
4214                    .join(" | ");
4215                format!("union<{variants}>")
4216            }
4217        }
4218    }
4219}
4220
4221impl IrPrimitiveType {
4222    fn as_str(&self) -> &'static str {
4223        match self {
4224            Self::String => "string",
4225            Self::Int => "int",
4226            Self::Float => "float",
4227            Self::Bool => "bool",
4228            Self::Null => "null",
4229            Self::Duration => "duration",
4230            Self::Time => "time",
4231            Self::Image => "image",
4232            Self::Audio => "audio",
4233            Self::Pdf => "pdf",
4234            Self::Video => "video",
4235        }
4236    }
4237}
4238
4239fn lower_program(
4240    program: Program,
4241    workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
4242    shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
4243) -> CompileOutput {
4244    let mut diagnostics = Vec::new();
4245    let mut warnings = Vec::new();
4246    let (program, pattern_applications) = expand_pattern_applications(program, &mut diagnostics);
4247    let pending_regions: BTreeMap<String, IrRegion>;
4248    let program = {
4249        let mut program = program;
4250        // DR-0023: collect `action` templates, then expand their calls inside
4251        // every rule body — including rules generated by flow expansion below.
4252        // Calls are rewritten into ordinary effect-chain text that re-enters the
4253        // normal lowering pipeline; the `action` declarations themselves are
4254        // consumed (never a runtime construct).
4255        let actions: Vec<ActionDecl> = program
4256            .items
4257            .iter()
4258            .filter_map(|item| match item {
4259                Item::Action(action) => Some(action.clone()),
4260                _ => None,
4261            })
4262            .collect();
4263        let mut expanded = Vec::with_capacity(program.items.len());
4264        for item in program.items {
4265            match item {
4266                Item::Action(_) => {}
4267                other => expanded.push(other),
4268            }
4269        }
4270        // Full-line `#` comments inside rule bodies are legal surface, but the
4271        // body text is re-scanned by many line-based passes (action/`then`
4272        // expansion, the kernel's effect scanner). Blank them ONCE here --
4273        // byte-preservingly, so every span still points at the source -- and
4274        // all downstream consumers see comment-free text. Compile path only:
4275        // `whip fmt` re-emits the raw body text and keeps the comments.
4276        for item in &mut expanded {
4277            if let Item::Rule(rule) = item {
4278                if rule.body.text.contains('#') {
4279                    rule.body.text = body::blank_full_line_comments(&rule.body.text);
4280                }
4281            }
4282        }
4283        action_expand::expand_action_calls(&mut expanded, &actions, &mut diagnostics);
4284        // R2: `then <binding> <- <effect>` continuation sugar desugars to the
4285        // nested `after … succeeds as` form here, before any analysis — the
4286        // rest of the pipeline (and the kernel) only ever sees the
4287        // traditional spelling. Runs after action expansion so action-injected
4288        // chains would compose; runs only in the compile path, so `whip fmt`
4289        // (which re-emits rule bodies from source) preserves the sugar.
4290        then_expand::expand_then_statements(&mut expanded, &mut diagnostics);
4291        // DR-0043 Decision 5: regions extract AFTER then-expansion (a `then`
4292        // inside a region folds within the region), rewriting rule bodies to
4293        // the condition-HOLDS variant and stashing the removed/lapsed
4294        // variants + effect scopes for the kernel.
4295        pending_regions = extract_rule_regions(&mut expanded, &mut diagnostics);
4296        program.items = expanded;
4297        program
4298    };
4299    let schema_names = collect_schema_names(&program, &mut diagnostics);
4300    let harness_kinds = collect_harness_kinds(&program, &mut diagnostics);
4301    let agent_names = collect_agent_names(&program, &mut diagnostics);
4302    let workflow_contract_names = collect_workflow_contract_names(&program, &mut diagnostics);
4303    let mut semantic = SemanticContext::from_program(&program, workflow_inputs);
4304    let workflow = match program.workflow {
4305        Some(workflow) => workflow.name,
4306        None => {
4307            diagnostics.push(Diagnostic {
4308                related: Vec::new(),
4309                span: SourceSpan { start: 0, end: 0 },
4310                message: "expected workflow declaration".to_owned(),
4311                suggestion: Some("add `workflow Name` before declarations".to_owned()),
4312            });
4313            "<missing>".to_owned()
4314        }
4315    };
4316
4317    let mut ir = IrProgram {
4318        workflow,
4319        source_tags: Vec::new(),
4320        source_descriptions: Vec::new(),
4321        includes: Vec::new(),
4322        pattern_applications,
4323        workflow_contracts: Vec::new(),
4324        uses: Vec::new(),
4325        harnesses: Vec::new(),
4326        trackers: Vec::new(),
4327        channels: Vec::new(),
4328        gauges: Vec::new(),
4329        marks: Vec::new(),
4330        campaigns: Vec::new(),
4331        file_stores: Vec::new(),
4332        memory_pools: Vec::new(),
4333        events: Vec::new(),
4334        sources: Vec::new(),
4335        tests: Vec::new(),
4336        leases: Vec::new(),
4337        ledgers: Vec::new(),
4338        counters: Vec::new(),
4339        shared_coordination_usage,
4340        schemas: Vec::new(),
4341        agents: Vec::new(),
4342        coerces: Vec::new(),
4343        assertions: Vec::new(),
4344        rules: Vec::new(),
4345        rule_dependencies: Vec::new(),
4346    };
4347    let workflow_tag_target = ir.workflow.clone();
4348    lower_source_tags(
4349        &program.workflow_tags,
4350        "workflow",
4351        &workflow_tag_target,
4352        &mut ir,
4353    );
4354    lower_source_description(
4355        program.workflow_description.as_ref(),
4356        "workflow",
4357        &workflow_tag_target,
4358        &mut ir,
4359    );
4360
4361    // Inline `decide -> { … } as <binding>` synthesizes a hygienic
4362    // `decide.<rule>.<binding>` class so its anonymous result shape flows like a
4363    // named `coerce -> Schema`. Done before the rule loop so `analyze_rule` sees
4364    // the class in the semantic index when type-checking field/`case` access.
4365    collect_inline_decide_schemas(&program.items, &mut semantic, &mut ir);
4366
4367    // `redact <source> keep [..] as <out>` synthesizes a hygienic
4368    // `redact.<rule>.<out>` class holding only the kept fields of the source
4369    // schema, so the projection cannot expose a dropped field. Run after the
4370    // decide synthesis so a redact whose source is a decide result resolves.
4371    collect_redact_schemas(&program.items, &mut semantic, &mut ir);
4372
4373    for item in program.items {
4374        match item {
4375            Item::Include(include) => lower_include(include, &mut ir),
4376            Item::WorkflowContract(contract) => lower_workflow_contract(
4377                contract,
4378                &mut ir,
4379                &schema_names,
4380                &agent_names,
4381                &mut diagnostics,
4382            ),
4383            Item::Use(use_decl) => lower_use(use_decl, &mut ir, &mut diagnostics),
4384            // Actions are consumed before this loop (DR-0023 slice 1 drops them;
4385            // slice 2 expands their calls); reaching one here is unreachable.
4386            Item::Action(action) => {
4387                let _ = action;
4388            }
4389            Item::Pattern(pattern) => diagnostics.push(Diagnostic {
4390                related: Vec::new(),
4391                span: pattern.span,
4392                message: format!(
4393                    "pattern `{}` is not allowed inside this declaration scope",
4394                    pattern.name.name
4395                ),
4396                suggestion: Some("declare patterns at source top level".to_owned()),
4397            }),
4398            Item::Apply(apply) => diagnostics.push(Diagnostic {
4399                related: Vec::new(),
4400                span: apply.span,
4401                message: format!(
4402                    "pattern application `{}` was not expanded",
4403                    apply.alias.name
4404                ),
4405                suggestion: Some(
4406                    "ensure the applied pattern is declared at source top level".to_owned(),
4407                ),
4408            }),
4409            Item::Harness(harness) => lower_harness(harness, &mut ir, &mut diagnostics),
4410            Item::Tracker(queue) => lower_tracker(queue, &mut ir, &mut diagnostics),
4411            Item::Channel(channel) => lower_channel(channel, &mut ir, &mut diagnostics),
4412            Item::Gauge(gauge) => lower_gauge(gauge, &mut ir, &mut diagnostics),
4413            Item::Mark(mark) => lower_mark(mark, &mut ir, &mut diagnostics),
4414            Item::Campaign(campaign) => lower_campaign(campaign, &mut ir, &mut diagnostics),
4415            // The `file store` declaration (capability-scoped store identity)
4416            // lowers to its name + literal root; the runtime file provider reads
4417            // `<root>/<path>` for `read` effects against this store.
4418            Item::FileStore(file_store) => {
4419                // Conditioned check (spec/std-files.md "Static checks"): the
4420                // optional `provider <name>` clause must name a known file
4421                // provider. v1 ships exactly one — `local`, the FileStore
4422                // host-projection seam — and it is also the default when the
4423                // clause is absent. Unknown providers would lower to a store
4424                // no runtime seam backs, so they are rejected at check time.
4425                if let Some(provider) = &file_store.provider {
4426                    if !FILE_STORE_PROVIDERS.contains(&provider.name.as_str()) {
4427                        diagnostics.push(Diagnostic {
4428                            related: Vec::new(),
4429                            span: provider.span,
4430                            message: format!(
4431                                "file store `{}` names unknown provider `{}`",
4432                                file_store.name.name, provider.name
4433                            ),
4434                            suggestion: Some(format!(
4435                                "declare one of the v1 file providers: {}",
4436                                FILE_STORE_PROVIDERS.join(", ")
4437                            )),
4438                        });
4439                        // Fall through: the store still lowers so read/write
4440                        // sites do not cascade an unknown-store error on top.
4441                    }
4442                }
4443                ir.file_stores.push(IrFileStore {
4444                    name: file_store.name.name,
4445                    root: file_store.root,
4446                    read_globs: file_store.read_globs,
4447                    write_globs: file_store.write_globs,
4448                    provider: file_store.provider.map(|provider| provider.name),
4449                });
4450            }
4451            // The `memory pool` declaration (std.memory, MEM-1) lowers to its
4452            // name + optional recall context-limit budget; providers read the
4453            // limit from the `capability.call` effect input.
4454            Item::MemoryPool(pool) => {
4455                ir.memory_pools.push(IrMemoryPool {
4456                    name: pool.name.name,
4457                    context_limit: pool.context_limit,
4458                });
4459            }
4460            Item::Agent(agent) => lower_agent(agent, &mut ir, &harness_kinds, &mut diagnostics),
4461            Item::Enum(enum_decl) => lower_enum(enum_decl, &mut ir, &mut diagnostics),
4462            Item::Event(event) => lower_event(event, &mut ir, &mut diagnostics),
4463            Item::Source(source) => {
4464                validate_source_emit_signal_declared(
4465                    &source,
4466                    &semantic.schemas.events,
4467                    &mut diagnostics,
4468                );
4469                lower_source(*source, &mut ir, &mut diagnostics)
4470            }
4471            Item::Test(test) => lower_test(test, &mut ir, &mut diagnostics),
4472            Item::Lease(lease) => {
4473                if !schema_names.contains(&lease.key_type.name) {
4474                    diagnostics.push(Diagnostic {
4475                        related: Vec::new(),
4476                        span: lease.key_type.span,
4477                        message: format!(
4478                            "lease `{}` keys on undeclared type `{}`",
4479                            lease.name.name, lease.key_type.name
4480                        ),
4481                        suggestion: Some(
4482                            "key a lease on an entity class the workflow already models".to_owned(),
4483                        ),
4484                    });
4485                }
4486                ir.leases.push(IrLease {
4487                    name: lease.name.name,
4488                    key_type: lease.key_type.name,
4489                    slots: lease.slots.max(1),
4490                    ttl_seconds: lease.ttl_seconds,
4491                    shared: lease.shared,
4492                    span: lease.span,
4493                });
4494            }
4495            Item::Ledger(ledger) => {
4496                if !schema_names.contains(&ledger.entry_schema.name) {
4497                    diagnostics.push(Diagnostic {
4498                        related: Vec::new(),
4499                        span: ledger.entry_schema.span,
4500                        message: format!(
4501                            "ledger `{}` records undeclared entry type `{}`",
4502                            ledger.name.name, ledger.entry_schema.name
4503                        ),
4504                        suggestion: Some("declare the entry class before the ledger".to_owned()),
4505                    });
4506                }
4507                ir.ledgers.push(IrLedger {
4508                    name: ledger.name.name,
4509                    entry_schema: ledger.entry_schema.name,
4510                    partition_field: ledger.partition_field.name,
4511                    retain_seconds: ledger.retain_seconds,
4512                    shared: ledger.shared,
4513                    span: ledger.span,
4514                });
4515            }
4516            Item::Counter(counter) => {
4517                if !schema_names.contains(&counter.key_type.name) {
4518                    diagnostics.push(Diagnostic {
4519                        related: Vec::new(),
4520                        span: counter.key_type.span,
4521                        message: format!(
4522                            "counter `{}` keys on undeclared type `{}`",
4523                            counter.name.name, counter.key_type.name
4524                        ),
4525                        suggestion: Some(
4526                            "key a counter on an entity class the workflow already models"
4527                                .to_owned(),
4528                        ),
4529                    });
4530                }
4531                ir.counters.push(IrCounter {
4532                    name: counter.name.name,
4533                    key_type: counter.key_type.name,
4534                    cap: counter.cap,
4535                    reset: counter.reset,
4536                    timezone: counter.timezone,
4537                    shared: counter.shared,
4538                    span: counter.span,
4539                });
4540            }
4541            Item::Class(class_decl) => lower_class(
4542                class_decl,
4543                &mut ir,
4544                &schema_names,
4545                &agent_names,
4546                &mut diagnostics,
4547            ),
4548            Item::Table(table) => {
4549                lower_source_tags(&table.tags, "table", &table.name.name, &mut ir);
4550                lower_source_description(
4551                    table.description.as_ref(),
4552                    "table",
4553                    &table.name.name,
4554                    &mut ir,
4555                );
4556                lower_table(
4557                    table,
4558                    &semantic,
4559                    &workflow_contract_names,
4560                    &mut ir,
4561                    &mut diagnostics,
4562                )
4563            }
4564            Item::Coerce(coerce) => lower_coerce(
4565                coerce,
4566                &mut ir,
4567                &schema_names,
4568                &agent_names,
4569                &mut diagnostics,
4570            ),
4571            Item::Assert(assertion) => {
4572                let assertion_target = stable_hash(&assertion.expr);
4573                lower_source_tags(&assertion.tags, "assertion", &assertion_target, &mut ir);
4574                lower_source_description(
4575                    assertion.description.as_ref(),
4576                    "assertion",
4577                    &assertion_target,
4578                    &mut ir,
4579                );
4580                lower_assert(assertion, &semantic, &mut ir, &mut diagnostics)
4581            }
4582            Item::Rule(rule) => {
4583                lower_source_tags(&rule.tags, "rule", &rule.name.name, &mut ir);
4584                lower_source_description(
4585                    rule.description.as_ref(),
4586                    "rule",
4587                    &rule.name.name,
4588                    &mut ir,
4589                );
4590                lower_rule(
4591                    rule,
4592                    &semantic,
4593                    &workflow_contract_names,
4594                    &mut ir,
4595                    &mut diagnostics,
4596                )
4597            }
4598        }
4599    }
4600
4601    ir.rule_dependencies = build_rule_dependencies(&ir.rules);
4602    validate_turn_access_grant_file_operations(&ir, &mut diagnostics);
4603    validate_turn_access_grant_memory_operations(&ir, &mut diagnostics);
4604    // DR-0043 Decision 5: attach extracted regions onto their lowered rules.
4605    for rule in &mut ir.rules {
4606        if let Some(region) = pending_regions.get(&rule.name) {
4607            rule.metadata.region = Some(region.clone());
4608        }
4609    }
4610    expand_source_emit_from(&mut ir, &mut diagnostics);
4611    validate_file_store_write_policy(&ir, &mut diagnostics);
4612    warn_inert_memory_grant_on_native_adapter(&ir, &mut warnings);
4613    warn_counter_without_timezone(&ir, &mut warnings);
4614    warn_unhandled_effect_failures(&ir, &mut warnings);
4615    validate_improve_declarations(&ir, &mut diagnostics);
4616
4617    CompileOutput {
4618        ir: diagnostics.is_empty().then_some(ir),
4619        diagnostics,
4620        warnings,
4621    }
4622}
4623
4624/// Post-lowering check: a turn-access grant whose resource is a declared `file store`
4625/// may only grant file operations (`read`/`write`/`import`/`export`). Runs after the
4626/// whole program is lowered so every file-store declaration is visible regardless of
4627/// source order. Grants whose resource is NOT a declared file store are left alone —
4628/// they may be package-provided resources whose operation vocabulary lives in the
4629/// capability registry (validated at the construct-graph layer), so this stays
4630/// zero-false-positive.
4631fn validate_turn_access_grant_file_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4632    const FILE_OPERATIONS: [&str; 4] = ["read", "write", "import", "export"];
4633    let file_stores: BTreeSet<&str> = ir
4634        .file_stores
4635        .iter()
4636        .map(|store| store.name.as_str())
4637        .collect();
4638    for rule in &ir.rules {
4639        for effect in &rule.metadata.effects {
4640            for grant in &effect.access_grants {
4641                if !file_stores.contains(grant.resource.as_str()) {
4642                    continue;
4643                }
4644                for op in &grant.operations {
4645                    if !FILE_OPERATIONS.contains(&op.operation.as_str()) {
4646                        diagnostics.push(Diagnostic { related: Vec::new(),
4647                            span: effect.span,
4648                            message: format!(
4649                                "rule `{}` grants `{}` on file store `{}`, which is not a file operation",
4650                                rule.name, op.operation, grant.resource
4651                            ),
4652                            suggestion: Some(
4653                                "file-store grants allow `read`, `write`, `import`, or `export`"
4654                                    .to_owned(),
4655                            ),
4656                        });
4657                    }
4658                }
4659            }
4660        }
4661    }
4662}
4663
4664/// Post-lowering check: a turn-access grant whose resource is a declared `memory
4665/// pool` (std.memory, MEM-1) may only grant memory operations
4666/// (`recall`/`learn`/`curate`). Runs after the whole program is lowered so every
4667/// pool declaration is visible regardless of source order. Grants whose resource
4668/// is NOT a declared memory pool are left alone — they may be file stores or
4669/// package-provided resources whose operation vocabulary lives elsewhere, so this
4670/// stays zero-false-positive. This closes the deliberate memory-grant-validation
4671/// deferral (there was no declared-pool list to key it off before MEM-1).
4672fn validate_turn_access_grant_memory_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4673    const MEMORY_OPERATIONS: [&str; 3] = ["recall", "learn", "curate"];
4674    let memory_pools: BTreeSet<&str> = ir
4675        .memory_pools
4676        .iter()
4677        .map(|pool| pool.name.as_str())
4678        .collect();
4679    for rule in &ir.rules {
4680        for effect in &rule.metadata.effects {
4681            for grant in &effect.access_grants {
4682                if !memory_pools.contains(grant.resource.as_str()) {
4683                    continue;
4684                }
4685                for op in &grant.operations {
4686                    if !MEMORY_OPERATIONS.contains(&op.operation.as_str()) {
4687                        diagnostics.push(Diagnostic {
4688                            related: Vec::new(),
4689                            span: effect.span,
4690                            message: format!(
4691                                "rule `{}` grants `{}` on memory pool `{}`, which is not a memory operation",
4692                                rule.name, op.operation, grant.resource
4693                            ),
4694                            suggestion: Some(
4695                                "memory-pool grants allow `recall`, `learn`, or `curate`".to_owned(),
4696                            ),
4697                        });
4698                    }
4699                }
4700            }
4701        }
4702    }
4703}
4704
4705/// std.coord slice 3: a counter without a declared `timezone` anchors its
4706/// reset-period boundary to UTC — legal, but a daily/weekly/monthly quota
4707/// silently rolling over at an operator-surprising hour is worth a warning.
4708/// S4 (file-store default posture): a store is READ-ONLY by default — a
4709/// `write`/`export` against a store with no `allow write [...]` policy will
4710/// fail closed at runtime, so surface it as a check error here ("catch before
4711/// deployment"). Reads/imports need no clause (mounting the root is the read
4712/// consent); `allow read [...]` narrows them.
4713fn validate_file_store_write_policy(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4714    let read_only: BTreeSet<&str> = ir
4715        .file_stores
4716        .iter()
4717        .filter(|store| store.write_globs.is_empty())
4718        .map(|store| store.name.as_str())
4719        .collect();
4720    if read_only.is_empty() {
4721        return;
4722    }
4723    fn walk(
4724        statements: &[body::BodyStmt],
4725        rule_name: &str,
4726        read_only: &BTreeSet<&str>,
4727        diagnostics: &mut Vec<Diagnostic>,
4728    ) {
4729        for statement in statements {
4730            match statement {
4731                body::BodyStmt::Effect(effect) => {
4732                    let store = match &effect.kind {
4733                        body::BodyEffectKind::FileWrite { store, .. }
4734                        | body::BodyEffectKind::FileExport { store, .. } => Some(store),
4735                        _ => None,
4736                    };
4737                    if let Some(store) = store {
4738                        if read_only.contains(store.as_str()) {
4739                            diagnostics.push(Diagnostic {
4740                                related: Vec::new(),
4741                                span: effect.span,
4742                                message: format!(
4743                                    "rule `{rule_name}` writes to store `{store}`, which permits \
4744                                     no writes — stores are read-only by default"
4745                                ),
4746                                suggestion: Some(format!(
4747                                    "declare `allow write [\"<glob>\", …]` on `file store {store}` \
4748                                     to permit (and bound) writes"
4749                                )),
4750                            });
4751                        }
4752                    }
4753                }
4754                body::BodyStmt::After(after) => {
4755                    walk(&after.body, rule_name, read_only, diagnostics)
4756                }
4757                body::BodyStmt::Case(case) => {
4758                    for branch in &case.branches {
4759                        walk(&branch.body, rule_name, read_only, diagnostics);
4760                    }
4761                }
4762                _ => {}
4763            }
4764        }
4765    }
4766    for rule in &ir.rules {
4767        let (ast, _) = body::parse_rule_body(&rule.body, 0);
4768        walk(&ast.statements, &rule.name, &read_only, diagnostics);
4769    }
4770}
4771
4772/// S6 `emit <signal> from <binding>` (source declarations): expand the
4773/// projection into concrete emit fields once every declaration has lowered
4774/// (the signal may be declared after the source). Each of the signal's
4775/// declared fields not overridden by the block becomes a copy off the `from`
4776/// binding — the `record … from` semantics. The `from` binding must be the
4777/// source's `observe` binding: it is the only binding in scope.
4778fn expand_source_emit_from(ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4779    let events: BTreeMap<String, Vec<String>> = ir
4780        .events
4781        .iter()
4782        .map(|event| {
4783            (
4784                event.name.clone(),
4785                event
4786                    .fields
4787                    .iter()
4788                    .map(|field| field.name.clone())
4789                    .collect(),
4790            )
4791        })
4792        .collect();
4793    for source in &mut ir.sources {
4794        let Some(from) = source.emit_from.clone() else {
4795            continue;
4796        };
4797        if from != source.observe_binding {
4798            diagnostics.push(Diagnostic {
4799                related: Vec::new(),
4800                span: source.span,
4801                message: format!(
4802                    "source `{}` emits `from {from}`, but the only binding in scope is the                      observe binding `{}`",
4803                    source.name, source.observe_binding
4804                ),
4805                suggestion: Some(format!("write `emit {} from {}`", source.emit_signal, source.observe_binding)),
4806            });
4807            continue;
4808        }
4809        let Some(signal_fields) = events.get(&source.emit_signal) else {
4810            // The undeclared-signal diagnostic is reported by the emit checks.
4811            continue;
4812        };
4813        for field in signal_fields {
4814            if source
4815                .emit_fields
4816                .iter()
4817                .any(|existing| &existing.name == field)
4818            {
4819                continue;
4820            }
4821            source.emit_fields.push(IrSourceEmitField {
4822                name: field.clone(),
4823                value: SourceValue::Path {
4824                    binding: Ident {
4825                        name: from.clone(),
4826                        span: source.span,
4827                    },
4828                    segments: vec![Ident {
4829                        name: field.clone(),
4830                        span: source.span,
4831                    }],
4832                    span: source.span,
4833                },
4834                span: source.span,
4835            });
4836        }
4837    }
4838}
4839
4840/// Auto-fail R1a — partiality made visible: an effect whose failure has no
4841/// observing `after` block in its rule will auto-fail the instance at runtime
4842/// (the rule-level net). That is SAFE, but the "handles only `succeeds`" signal
4843/// is load-bearing enough to surface prominently at check time — a warning, not
4844/// a buried lint advisory. `@service` workflows are exempt (they record a
4845/// durable diagnostic and keep running, so the auto-fail framing would be
4846/// wrong), timers are exempt (they cannot fail), and the compile-time observer
4847/// set is deliberately WIDER than the runtime net's: coordination outcome
4848/// predicates (`held`/`contended`/`ok`/`over`) count as observers here so
4849/// ordinary coordination code stays quiet, while the runtime net still catches
4850/// a genuine op failure underneath them.
4851fn warn_unhandled_effect_failures(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4852    let service = ir
4853        .source_tags
4854        .iter()
4855        .any(|tag| tag.target_kind == "workflow" && tag.name == "service");
4856    if service {
4857        return;
4858    }
4859    for rule in &ir.rules {
4860        for effect in &rule.metadata.effects {
4861            let Some(binding) = effect.binding.as_deref() else {
4862                continue;
4863            };
4864            if effect.kind == IrEffectKind::TimerWait {
4865                continue;
4866            }
4867            // A `then`-chained effect (synthetic `__then_*` handle) is an
4868            // explicit opt-in to auto-fail on failure (R2) — never a warning.
4869            if binding.starts_with(then_expand::THEN_BINDING_PREFIX) {
4870                continue;
4871            }
4872            let observed = rule.body.lines().any(|line| {
4873                let Some(rest) = line.trim().strip_prefix("after ") else {
4874                    return false;
4875                };
4876                let mut parts = rest.split_whitespace();
4877                if parts.next() != Some(binding) {
4878                    return false;
4879                }
4880                // `times` only occurs as the two-token predicate `times out`.
4881                matches!(
4882                    parts.next().map(|token| token.trim_end_matches('{')),
4883                    Some("fails" | "times" | "completes" | "held" | "contended" | "ok" | "over")
4884                )
4885            });
4886            if observed {
4887                continue;
4888            }
4889            warnings.push(Diagnostic {
4890                related: Vec::new(),
4891                span: effect.span,
4892                message: format!(
4893                    "effect `{binding}`'s failure is unhandled in rule `{}`; if it fails or \
4894                     times out, the instance will auto-fail with a generic reason",
4895                    rule.name
4896                ),
4897                suggestion: Some(format!(
4898                    "handle it with `after {binding} fails {{ … }}` (typed failure or recovery) \
4899                     or observe every outcome with `after {binding} completes`"
4900                )),
4901            });
4902        }
4903    }
4904}
4905
4906fn warn_counter_without_timezone(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4907    for counter in &ir.counters {
4908        if counter.timezone.is_none() {
4909            warnings.push(Diagnostic {
4910                related: Vec::new(),
4911                span: counter.span,
4912                message: format!(
4913                    "counter `{}` declares no `timezone`; its `{}` reset boundary anchors to UTC",
4914                    counter.name, counter.reset
4915                ),
4916                suggestion: Some(
4917                    "declare `timezone \"<IANA zone>\"` (e.g. `timezone \"America/New_York\"`) to anchor the period locally"
4918                        .to_owned(),
4919                ),
4920            });
4921        }
4922    }
4923}
4924
4925/// MEM-5 static check 4: a memory-pool grant on a `tell` whose agent runs a
4926/// NATIVE adapter (codex/claude/command) is inert — only the owned harness
4927/// exposes the granted memory tools. Warn instead of silently dropping the
4928/// author's intent (the inert-grant honesty the design eliminates).
4929fn warn_inert_memory_grant_on_native_adapter(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4930    let memory_pools: BTreeSet<&str> = ir
4931        .memory_pools
4932        .iter()
4933        .map(|pool| pool.name.as_str())
4934        .collect();
4935    if memory_pools.is_empty() {
4936        return;
4937    }
4938    let harness_kind_of: BTreeMap<&str, &str> = ir
4939        .harnesses
4940        .iter()
4941        .map(|harness| (harness.name.as_str(), harness.kind.as_str()))
4942        .collect();
4943    let agent_harness_kind: BTreeMap<&str, &str> = ir
4944        .agents
4945        .iter()
4946        .filter_map(|agent| {
4947            let harness = agent.harness.as_deref()?;
4948            Some((agent.name.as_str(), *harness_kind_of.get(harness)?))
4949        })
4950        .collect();
4951    for rule in &ir.rules {
4952        for effect in &rule.metadata.effects {
4953            let Some(agent) = effect.agent.as_deref() else {
4954                continue;
4955            };
4956            let Some(kind) = agent_harness_kind.get(agent) else {
4957                continue;
4958            };
4959            if !matches!(*kind, "codex" | "claude" | "command") {
4960                continue;
4961            }
4962            for grant in &effect.access_grants {
4963                if memory_pools.contains(grant.resource.as_str()) {
4964                    warnings.push(Diagnostic {
4965                        related: Vec::new(),
4966                        span: effect.span,
4967                        message: format!(
4968                            "rule `{}` grants memory pool `{}` on a tell to `{agent}`, whose \
4969                             harness kind `{kind}` is a native adapter — memory grants only \
4970                             take effect on the owned harness, so this grant is inert",
4971                            rule.name, grant.resource
4972                        ),
4973                        suggestion: Some(
4974                            "target an owned-harness agent, or drop the memory grant".to_owned(),
4975                        ),
4976                    });
4977                }
4978            }
4979        }
4980    }
4981}
4982
4983fn lower_source_tags(tags: &[TagDecl], target_kind: &str, target: &str, ir: &mut IrProgram) {
4984    for tag in tags {
4985        ir.source_tags.push(IrSourceTag {
4986            name: tag.name.clone(),
4987            target_kind: target_kind.to_owned(),
4988            target: target.to_owned(),
4989            span: tag.span,
4990        });
4991    }
4992}
4993
4994fn lower_source_description(
4995    description: Option<&StringLiteral>,
4996    target_kind: &str,
4997    target: &str,
4998    ir: &mut IrProgram,
4999) {
5000    if let Some(description) = description {
5001        ir.source_descriptions.push(IrSourceDescription {
5002            value: description.value.clone(),
5003            target_kind: target_kind.to_owned(),
5004            target: target.to_owned(),
5005            span: description.span,
5006        });
5007    }
5008}
5009
5010/// Detect recursive pattern application over the pattern-declaration graph and
5011/// emit `graph.unbounded_pattern_recursion` (severity error) for each expansion
5012/// cycle, naming the cycle. Returns the set of patterns that participate in a
5013/// cycle so the caller can suppress the generic "nested apply" message for them.
5014///
5015/// A pattern's body that `apply`s another pattern is an edge; a pattern that can
5016/// reach itself (directly via a self-apply, or transitively) cannot elaborate into
5017/// a finite first-order program, so v0 rejects it (spec/static-analysis.md). The
5018/// reachability closure mirrors `models/maude/pattern-recursion.maude`.
5019fn detect_pattern_recursion(
5020    patterns: &BTreeMap<String, PatternDecl>,
5021    diagnostics: &mut Vec<Diagnostic>,
5022) -> BTreeSet<String> {
5023    // Application edges: pattern name -> the patterns its body applies, with spans.
5024    let mut edges: BTreeMap<&str, Vec<(&str, SourceSpan)>> = BTreeMap::new();
5025    for pattern in patterns.values() {
5026        let mut applied = Vec::new();
5027        for item in &pattern.items {
5028            if let Item::Apply(apply) = item {
5029                applied.push((apply.pattern.name.as_str(), apply.span));
5030            }
5031        }
5032        edges.insert(pattern.name.name.as_str(), applied);
5033    }
5034
5035    // A pattern is recursive iff it can reach itself. Find a shortest cycle path
5036    // back to `start` via breadth-first search, tracking each node's predecessor.
5037    let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
5038        let mut queue: VecDeque<&str> = VecDeque::new();
5039        // predecessor[node] = (came_from, span_of_edge) used to first reach `node`.
5040        let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
5041        for &(target, span) in edges.get(start).into_iter().flatten() {
5042            if target == start {
5043                // Direct self-application.
5044                return Some((vec![start.to_owned(), start.to_owned()], span));
5045            }
5046            if predecessor.insert(target, (start, span)).is_none() {
5047                queue.push_back(target);
5048            }
5049        }
5050        while let Some(node) = queue.pop_front() {
5051            for &(target, span) in edges.get(node).into_iter().flatten() {
5052                if target == start {
5053                    // Reconstruct start -> ... -> node, then close back to start.
5054                    let mut path = vec![node.to_owned()];
5055                    let mut cursor = node;
5056                    while cursor != start {
5057                        let (from, _) = predecessor[cursor];
5058                        path.push(from.to_owned());
5059                        cursor = from;
5060                    }
5061                    path.reverse();
5062                    path.push(start.to_owned());
5063                    // Report at the first apply edge of `start` that enters the cycle.
5064                    let first = &path[1];
5065                    let entry_span = edges
5066                        .get(start)
5067                        .into_iter()
5068                        .flatten()
5069                        .find(|(target, _)| target == first)
5070                        .map(|(_, span)| *span)
5071                        .unwrap_or(span);
5072                    return Some((path, entry_span));
5073                }
5074                if predecessor.insert(target, (node, span)).is_none() {
5075                    queue.push_back(target);
5076                }
5077            }
5078        }
5079        None
5080    };
5081
5082    let mut recursive = BTreeSet::new();
5083    // Iterate patterns in declaration-name order for deterministic diagnostics, and
5084    // report each cycle once by skipping members already covered by a prior cycle.
5085    for name in patterns.keys() {
5086        if recursive.contains(name) {
5087            continue;
5088        }
5089        if let Some((cycle, span)) = find_cycle(name) {
5090            for member in &cycle {
5091                recursive.insert(member.clone());
5092            }
5093            diagnostics.push(Diagnostic { related: Vec::new(),
5094                span,
5095                message: format!(
5096                    "recursive pattern application is not allowed (graph.unbounded_pattern_recursion): expansion cycle {}",
5097                    cycle.join(" -> ")
5098                ),
5099                suggestion: Some(
5100                    "break the cycle: pattern expansion must elaborate into a finite program"
5101                        .to_owned(),
5102                ),
5103            });
5104        }
5105    }
5106    recursive
5107}
5108
5109/// Reject a *transitive* runtime workflow-invocation cycle (A invokes B invokes A,
5110/// or longer). RESOLVED 2026-07-01: the invoke-recursion policy is "as permissive
5111/// as provable convergence at compile time allows"; whipplescript has no
5112/// convergence proof for runtime `invoke` recursion (termination is data-dependent
5113/// and there is no decreasing-measure mechanism yet), so — exactly parallel to
5114/// `detect_pattern_recursion` — any cycle is rejected as
5115/// `graph.unbounded_workflow_invocation_recursion`. Direct self-invocation (a cycle
5116/// of length 1) is already rejected per-rule in `validate_workflow_invocations`, so
5117/// self-edges are excluded here and this catches only length >= 2 cycles. Modeled
5118/// as invoke-graph non-convergence in `models/maude/subworkflow-convergence.maude`.
5119fn detect_workflow_invoke_recursion(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
5120    // Invoke edges: workflow name -> the workflows its rules invoke, with the span
5121    // of the invoking rule body. Built over the raw AST (all workflows), so it is
5122    // independent of root selection. Self-edges are excluded (owned by the direct
5123    // per-rule recursion check).
5124    let mut edges: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
5125    let record_invokes =
5126        |name: &str, items: &[Item], edges: &mut BTreeMap<String, Vec<(String, SourceSpan)>>| {
5127            let entry = edges.entry(name.to_owned()).or_default();
5128            for item in items {
5129                let Item::Rule(rule) = item else {
5130                    continue;
5131                };
5132                for statement in workflow_invoke_statements(&rule.body.text) {
5133                    if let Some((target, _)) = invoke_statement_parts(&statement) {
5134                        if target != name {
5135                            entry.push((target.to_owned(), rule.body.span));
5136                        }
5137                    }
5138                }
5139            }
5140        };
5141    if let Some(root) = &program.workflow {
5142        record_invokes(&root.name, &program.items, &mut edges);
5143    }
5144    for workflow in &program.workflows {
5145        record_invokes(&workflow.name.name, &workflow.items, &mut edges);
5146    }
5147
5148    // A workflow is in a cycle iff it can reach itself over invoke edges. BFS for a
5149    // shortest path back to `start` (mirrors `detect_pattern_recursion`).
5150    let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
5151        let mut queue: VecDeque<&str> = VecDeque::new();
5152        let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
5153        for (target, span) in edges.get(start).into_iter().flatten() {
5154            if predecessor
5155                .insert(target.as_str(), (start, *span))
5156                .is_none()
5157            {
5158                queue.push_back(target.as_str());
5159            }
5160        }
5161        while let Some(node) = queue.pop_front() {
5162            for (target, span) in edges.get(node).into_iter().flatten() {
5163                if target == start {
5164                    let mut path = vec![node.to_owned()];
5165                    let mut cursor = node;
5166                    while cursor != start {
5167                        let (from, _) = predecessor[cursor];
5168                        path.push(from.to_owned());
5169                        cursor = from;
5170                    }
5171                    path.reverse();
5172                    path.push(start.to_owned());
5173                    let first = &path[1];
5174                    let entry_span = edges
5175                        .get(start)
5176                        .into_iter()
5177                        .flatten()
5178                        .find(|(target, _)| target == first)
5179                        .map(|(_, span)| *span)
5180                        .unwrap_or(*span);
5181                    return Some((path, entry_span));
5182                }
5183                if predecessor.insert(target.as_str(), (node, *span)).is_none() {
5184                    queue.push_back(target.as_str());
5185                }
5186            }
5187        }
5188        None
5189    };
5190
5191    let mut flagged: BTreeSet<String> = BTreeSet::new();
5192    for name in edges.keys() {
5193        if flagged.contains(name) {
5194            continue;
5195        }
5196        if let Some((cycle, span)) = find_cycle(name) {
5197            for member in &cycle {
5198                flagged.insert(member.clone());
5199            }
5200            diagnostics.push(Diagnostic {
5201                related: Vec::new(),
5202                span,
5203                message: format!(
5204                    "recursive workflow invocation is not allowed (graph.unbounded_workflow_invocation_recursion): invocation cycle {}",
5205                    cycle.join(" -> ")
5206                ),
5207                suggestion: Some(
5208                    "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"
5209                        .to_owned(),
5210                ),
5211            });
5212        }
5213    }
5214}
5215
5216fn detect_private_workflow_invocations(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
5217    let private_workflows = program
5218        .workflows
5219        .iter()
5220        .filter(|workflow| workflow.tags.iter().any(|tag| tag.name == "private"))
5221        .map(|workflow| workflow.name.name.as_str())
5222        .collect::<BTreeSet<_>>();
5223    if private_workflows.is_empty() {
5224        return;
5225    }
5226
5227    let mut record_private_invokes = |caller: &str, items: &[Item]| {
5228        for item in items {
5229            let Item::Rule(rule) = item else {
5230                continue;
5231            };
5232            for statement in workflow_invoke_statements(&rule.body.text) {
5233                let Some((target, _)) = invoke_statement_parts(&statement) else {
5234                    continue;
5235                };
5236                if caller == target || !private_workflows.contains(target) {
5237                    continue;
5238                }
5239                diagnostics.push(Diagnostic {
5240                    related: Vec::new(),
5241                    span: rule.body.span,
5242                    message: format!(
5243                        "rule `{}` invokes private workflow `{target}`",
5244                        rule.name.name
5245                    ),
5246                    suggestion: Some(
5247                        "remove `@private` from the target workflow or expose a public wrapper workflow"
5248                            .to_owned(),
5249                    ),
5250                });
5251            }
5252        }
5253    };
5254
5255    if let Some(root) = &program.workflow {
5256        record_private_invokes(&root.name, &program.items);
5257    }
5258    for workflow in &program.workflows {
5259        record_private_invokes(&workflow.name.name, &workflow.items);
5260    }
5261}
5262
5263fn expand_pattern_applications(
5264    mut program: Program,
5265    diagnostics: &mut Vec<Diagnostic>,
5266) -> (Program, Vec<IrPatternApplication>) {
5267    let mut patterns = BTreeMap::new();
5268    for pattern in &program.patterns {
5269        if patterns
5270            .insert(pattern.name.name.clone(), pattern.clone())
5271            .is_some()
5272        {
5273            diagnostics.push(Diagnostic {
5274                related: Vec::new(),
5275                span: pattern.name.span,
5276                message: format!("pattern `{}` is declared more than once", pattern.name.name),
5277                suggestion: Some("rename one pattern declaration".to_owned()),
5278            });
5279        }
5280    }
5281
5282    // v0 forbids recursive pattern application (spec/static-analysis.md,
5283    // graph.unbounded_pattern_recursion): an `apply` that reaches, directly or
5284    // transitively, a pattern already on the active expansion stack can never
5285    // elaborate into a finite first-order program. Detect cycles up front so the
5286    // precise diagnostic is emitted and the generic "nested apply not supported
5287    // yet" message is suppressed for the recursive case.
5288    let recursive_patterns = detect_pattern_recursion(&patterns, diagnostics);
5289
5290    let mut expanded_items = Vec::new();
5291    let mut applications = Vec::new();
5292    for item in program.items {
5293        let Item::Apply(apply) = item else {
5294            expanded_items.push(item);
5295            continue;
5296        };
5297        let Some(pattern) = patterns.get(&apply.pattern.name) else {
5298            diagnostics.push(Diagnostic {
5299                related: Vec::new(),
5300                span: apply.pattern.span,
5301                message: format!("pattern `{}` was not found", apply.pattern.name),
5302                suggestion: Some("declare the pattern before applying it".to_owned()),
5303            });
5304            continue;
5305        };
5306        if pattern.type_params.len() != apply.type_args.len() {
5307            diagnostics.push(Diagnostic {
5308                related: Vec::new(),
5309                span: apply.span,
5310                message: format!(
5311                    "pattern `{}` expects {} type arguments but got {}",
5312                    pattern.name.name,
5313                    pattern.type_params.len(),
5314                    apply.type_args.len()
5315                ),
5316                suggestion: Some("match the pattern type parameter list".to_owned()),
5317            });
5318            continue;
5319        }
5320        let type_substitutions = pattern
5321            .type_params
5322            .iter()
5323            .map(|param| param.name.clone())
5324            .zip(apply.type_args.iter().cloned())
5325            .collect::<BTreeMap<_, _>>();
5326        let value_substitutions = parse_pattern_value_arguments(&apply, diagnostics);
5327        let local_names = pattern_local_names(pattern, &apply.alias.name);
5328        let definition_span = pattern.span;
5329        let application_span = apply.span;
5330        let mut generated = Vec::new();
5331        for pattern_item in pattern.items.iter().cloned() {
5332            // Enforce the pattern-body allow-list before expanding: a pattern
5333            // is a compile-time reuse fragment, not a workflow, so forbidden
5334            // constructs are rejected with a clear diagnostic and dropped.
5335            if let Some(diagnostic) = pattern_body_admission(&pattern_item, &recursive_patterns) {
5336                diagnostics.push(diagnostic);
5337                continue;
5338            }
5339            if let Some((generated_name, item)) = expand_pattern_item(
5340                pattern_item,
5341                &apply.alias.name,
5342                &type_substitutions,
5343                &value_substitutions,
5344                &local_names,
5345            ) {
5346                generated.push(generated_name);
5347                expanded_items.push(item);
5348            }
5349        }
5350        applications.push(IrPatternApplication {
5351            pattern: pattern.name.name.clone(),
5352            alias: apply.alias.name,
5353            type_args: apply.type_args.into_iter().map(lower_type).collect(),
5354            value_args: value_substitutions
5355                .into_iter()
5356                .map(|(name, value)| IrPatternArgument { name, value })
5357                .collect(),
5358            generated,
5359            definition_span,
5360            application_span,
5361        });
5362    }
5363    program.items = expanded_items;
5364    (program, applications)
5365}
5366
5367fn pattern_local_names(pattern: &PatternDecl, alias: &str) -> BTreeMap<String, String> {
5368    let mut names = BTreeMap::new();
5369    for item in &pattern.items {
5370        match item {
5371            Item::Harness(harness) => {
5372                names.insert(
5373                    harness.name.name.clone(),
5374                    generated_pattern_name(alias, &harness.name.name),
5375                );
5376            }
5377            Item::Agent(agent) => {
5378                names.insert(
5379                    agent.name.name.clone(),
5380                    generated_pattern_name(alias, &agent.name.name),
5381                );
5382            }
5383            Item::Enum(enum_decl) => {
5384                names.insert(
5385                    enum_decl.name.name.clone(),
5386                    generated_pattern_name(alias, &enum_decl.name.name),
5387                );
5388            }
5389            Item::Class(class_decl) => {
5390                names.insert(
5391                    class_decl.name.name.clone(),
5392                    generated_pattern_name(alias, &class_decl.name.name),
5393                );
5394            }
5395            Item::Coerce(coerce) => {
5396                names.insert(
5397                    coerce.name.name.clone(),
5398                    generated_pattern_name(alias, &coerce.name.name),
5399                );
5400            }
5401            Item::Rule(rule) => {
5402                names.insert(
5403                    rule.name.name.clone(),
5404                    generated_pattern_name(alias, &rule.name.name),
5405                );
5406            }
5407            _ => {}
5408        }
5409    }
5410    names
5411}
5412
5413fn generated_pattern_name(alias: &str, name: &str) -> String {
5414    format!("{alias}_{name}")
5415}
5416
5417fn parse_pattern_value_arguments(
5418    apply: &ApplyDecl,
5419    diagnostics: &mut Vec<Diagnostic>,
5420) -> BTreeMap<String, String> {
5421    let mut args = BTreeMap::new();
5422    for line in apply
5423        .body
5424        .text
5425        .lines()
5426        .map(str::trim)
5427        .filter(|line| !line.is_empty())
5428    {
5429        let mut parts = line.splitn(2, char::is_whitespace);
5430        let Some(name) = parts.next().filter(|name| is_identifier(name)) else {
5431            diagnostics.push(Diagnostic {
5432                related: Vec::new(),
5433                span: apply.body.span,
5434                message: format!(
5435                    "pattern application `{}` has malformed argument `{line}`",
5436                    apply.alias.name
5437                ),
5438                suggestion: Some("write pattern arguments as `name value`".to_owned()),
5439            });
5440            continue;
5441        };
5442        let Some(value) = parts
5443            .next()
5444            .map(str::trim)
5445            .filter(|value| !value.is_empty())
5446        else {
5447            diagnostics.push(Diagnostic {
5448                related: Vec::new(),
5449                span: apply.body.span,
5450                message: format!(
5451                    "pattern application `{}` argument `{name}` is missing a value",
5452                    apply.alias.name
5453                ),
5454                suggestion: Some("write pattern arguments as `name value`".to_owned()),
5455            });
5456            continue;
5457        };
5458        if args.insert(name.to_owned(), value.to_owned()).is_some() {
5459            diagnostics.push(Diagnostic {
5460                related: Vec::new(),
5461                span: apply.body.span,
5462                message: format!(
5463                    "pattern application `{}` passes argument `{name}` more than once",
5464                    apply.alias.name
5465                ),
5466                suggestion: Some("remove the duplicate pattern argument".to_owned()),
5467            });
5468        }
5469    }
5470    args
5471}
5472
5473/// The explicit allow-list gate for a `pattern { ... }` body.
5474///
5475/// A pattern is a compile-time reuse fragment, not a workflow. Its body MAY
5476/// declare the building blocks of a workflow -- rules, effects (`coerce`),
5477/// records (via a rule's `record`), local schemas (`class`/`enum`), tables,
5478/// agents/harnesses, and coordination resources -- but it MUST NOT contain:
5479///   * workflow contracts (`input`/`output`/`failure`) -- workflow-level shape,
5480///   * nested `pattern` declarations,
5481///   * nested `apply` (pattern applications inside pattern bodies), or
5482///   * rules that reach a workflow terminal (`complete`/`fail`): a reusable
5483///     fragment must not hard-code the enclosing workflow's terminal outcome.
5484///
5485/// Returns `Some(diagnostic)` for a forbidden construct; `None` when the item is
5486/// on the allow-list.
5487fn pattern_body_admission(
5488    item: &Item,
5489    recursive_patterns: &BTreeSet<String>,
5490) -> Option<Diagnostic> {
5491    match item {
5492        Item::WorkflowContract(contract) => Some(Diagnostic {
5493            related: Vec::new(),
5494            span: contract.span,
5495            message: "workflow contracts are not allowed in pattern bodies".to_owned(),
5496            suggestion: Some(
5497                "declare workflow inputs, outputs, and failures on the workflow".to_owned(),
5498            ),
5499        }),
5500        Item::Pattern(pattern) => Some(Diagnostic {
5501            related: Vec::new(),
5502            span: pattern.span,
5503            message: "nested pattern declarations are not supported in pattern bodies".to_owned(),
5504            suggestion: Some("declare reusable patterns at source top level".to_owned()),
5505        }),
5506        // A recursive nested apply was already rejected with the precise
5507        // graph.unbounded_pattern_recursion diagnostic by detect_pattern_recursion;
5508        // don't also emit the generic "not supported yet" message for it.
5509        Item::Apply(apply) if !recursive_patterns.contains(&apply.pattern.name) => Some(Diagnostic {
5510            related: Vec::new(),
5511            span: apply.span,
5512            message: "pattern applications inside pattern bodies are not supported yet".to_owned(),
5513            suggestion: Some(
5514                "apply patterns from workflow bodies only in this implementation slice".to_owned(),
5515            ),
5516        }),
5517        // Objective intent is top-level: a gauge binds a judge to this
5518        // program's sites and a campaign partitions this program's gauge
5519        // vector — neither is a reusable template fragment.
5520        Item::Gauge(gauge) => Some(Diagnostic {
5521            related: Vec::new(),
5522            span: gauge.span,
5523            message: "gauge declarations are not allowed in pattern bodies".to_owned(),
5524            suggestion: Some("declare gauges at source top level".to_owned()),
5525        }),
5526        Item::Campaign(campaign) => Some(Diagnostic {
5527            related: Vec::new(),
5528            span: campaign.span,
5529            message: "campaign declarations are not allowed in pattern bodies".to_owned(),
5530            suggestion: Some("declare campaigns at source top level".to_owned()),
5531        }),
5532        Item::Mark(mark) => Some(Diagnostic {
5533            related: Vec::new(),
5534            span: mark.span,
5535            message: "mark declarations are not allowed in pattern bodies".to_owned(),
5536            suggestion: Some("declare marks at source top level".to_owned()),
5537        }),
5538        Item::Rule(rule) => pattern_rule_terminal_span(rule).map(|span| Diagnostic {
5539            related: Vec::new(),
5540            span,
5541            message: format!(
5542                "rule `{}` in a pattern body cannot reach a workflow terminal (`complete`/`fail`)",
5543                rule.name.name
5544            ),
5545            suggestion: Some(
5546                "record a fact in the pattern rule and let a workflow rule decide the terminal outcome"
5547                    .to_owned(),
5548            ),
5549        }),
5550        _ => None,
5551    }
5552}
5553
5554/// Locate the first workflow-terminal statement (`complete`/`fail`) in a
5555/// pattern rule body, returning its source span for diagnostics.
5556fn pattern_rule_terminal_span(rule: &RuleDecl) -> Option<SourceSpan> {
5557    let mut offset = 0usize;
5558    for line in rule.body.text.split_inclusive('\n') {
5559        let trimmed_start = line.trim_start();
5560        let leading = line.len() - trimmed_start.len();
5561        let statement = trimmed_start.trim_end();
5562        if is_pattern_terminal_statement(statement) {
5563            let start = rule.body.span.start + offset + leading;
5564            return Some(SourceSpan {
5565                start,
5566                end: start + statement.len(),
5567            });
5568        }
5569        offset += line.len();
5570    }
5571    None
5572}
5573
5574/// A trimmed body line begins a workflow terminal iff it starts with the
5575/// `complete` or `fail` keyword followed by whitespace, `{`, or end of line.
5576fn is_pattern_terminal_statement(line: &str) -> bool {
5577    for keyword in ["complete", "fail"] {
5578        if let Some(rest) = line.strip_prefix(keyword) {
5579            if rest.is_empty() || rest.starts_with('{') || rest.starts_with(char::is_whitespace) {
5580                return true;
5581            }
5582        }
5583    }
5584    false
5585}
5586
5587fn expand_pattern_item(
5588    item: Item,
5589    alias: &str,
5590    type_substitutions: &BTreeMap<String, TypeSyntax>,
5591    value_substitutions: &BTreeMap<String, String>,
5592    local_names: &BTreeMap<String, String>,
5593) -> Option<(String, Item)> {
5594    match item {
5595        Item::Include(include) => Some((
5596            format!("include:{}", include.path.value),
5597            Item::Include(include),
5598        )),
5599        Item::Use(use_decl) => Some((format!("use:{}", use_decl.name.value), Item::Use(use_decl))),
5600        Item::Tracker(queue) => {
5601            Some((format!("tracker:{}", queue.name.name), Item::Tracker(queue)))
5602        }
5603        Item::Channel(channel) => Some((
5604            format!("channel:{}", channel.name.name),
5605            Item::Channel(channel),
5606        )),
5607        // Gauges, campaigns, and marks are rejected from pattern bodies by
5608        // `pattern_body_admission` (objective intent and cut points are
5609        // top-level); there is deliberately no expansion path for them.
5610        Item::Gauge(_) | Item::Campaign(_) | Item::Mark(_) => None,
5611        Item::FileStore(file_store) => Some((
5612            format!("file-store:{}", file_store.name.name),
5613            Item::FileStore(file_store),
5614        )),
5615        Item::MemoryPool(pool) => Some((
5616            format!("memory-pool:{}", pool.name.name),
5617            Item::MemoryPool(pool),
5618        )),
5619        Item::Event(event) => Some((format!("event:{}", event.name), Item::Event(event))),
5620        Item::Source(source) => {
5621            Some((format!("source:{}", source.name.name), Item::Source(source)))
5622        }
5623        Item::Test(test) => Some((format!("test:{}", test.name.value), Item::Test(test))),
5624        Item::Lease(lease) => Some((format!("lease:{}", lease.name.name), Item::Lease(lease))),
5625        Item::Ledger(ledger) => {
5626            Some((format!("ledger:{}", ledger.name.name), Item::Ledger(ledger)))
5627        }
5628        Item::Counter(counter) => Some((
5629            format!("counter:{}", counter.name.name),
5630            Item::Counter(counter),
5631        )),
5632        Item::Action(action) => {
5633            Some((format!("action:{}", action.name.name), Item::Action(action)))
5634        }
5635        Item::Harness(mut harness) => {
5636            let name = rename_ident(harness.name, alias, local_names);
5637            let generated = format!("harness:{}", name.name);
5638            harness.name = name;
5639            Some((generated, Item::Harness(harness)))
5640        }
5641        // Forbidden constructs are rejected up front by `pattern_body_admission`
5642        // (the explicit allow-list gate), so these arms are unreachable in
5643        // practice; they stay defensive and simply drop the item.
5644        Item::WorkflowContract(_) | Item::Pattern(_) | Item::Apply(_) => None,
5645        Item::Agent(mut agent) => {
5646            let name = rename_ident(agent.name, alias, local_names);
5647            let generated = format!("agent:{}", name.name);
5648            agent.name = name;
5649            if let Some(harness) = agent.harness {
5650                agent.harness = Some(Ident {
5651                    name: local_names
5652                        .get(&harness.name)
5653                        .cloned()
5654                        .unwrap_or(harness.name),
5655                    span: harness.span,
5656                });
5657            }
5658            Some((generated, Item::Agent(agent)))
5659        }
5660        Item::Enum(mut enum_decl) => {
5661            let name = rename_ident(enum_decl.name, alias, local_names);
5662            let generated = format!("enum:{}", name.name);
5663            enum_decl.name = name;
5664            Some((generated, Item::Enum(enum_decl)))
5665        }
5666        Item::Class(mut class_decl) => {
5667            let name = rename_ident(class_decl.name, alias, local_names);
5668            let generated = format!("class:{}", name.name);
5669            class_decl.name = name;
5670            for field in &mut class_decl.fields {
5671                field.ty =
5672                    substitute_pattern_type(field.ty.clone(), type_substitutions, local_names);
5673            }
5674            Some((generated, Item::Class(class_decl)))
5675        }
5676        Item::Table(mut table) => {
5677            let name = rename_ident(table.name, alias, local_names);
5678            let generated = format!("table:{}", name.name);
5679            table.name = name;
5680            for row in &mut table.rows {
5681                row.body.text = substitute_pattern_text(
5682                    &row.body.text,
5683                    type_substitutions,
5684                    value_substitutions,
5685                    local_names,
5686                );
5687            }
5688            Some((generated, Item::Table(table)))
5689        }
5690        Item::Coerce(mut coerce) => {
5691            let name = rename_ident(coerce.name, alias, local_names);
5692            let generated = format!("coerce:{}", name.name);
5693            coerce.name = name;
5694            for param in &mut coerce.params {
5695                param.ty =
5696                    substitute_pattern_type(param.ty.clone(), type_substitutions, local_names);
5697            }
5698            coerce.output =
5699                substitute_pattern_type(coerce.output.clone(), type_substitutions, local_names);
5700            coerce.body.text = substitute_pattern_text(
5701                &coerce.body.text,
5702                type_substitutions,
5703                value_substitutions,
5704                local_names,
5705            );
5706            Some((generated, Item::Coerce(coerce)))
5707        }
5708        Item::Assert(mut assertion) => {
5709            assertion.expr = substitute_pattern_text(
5710                &assertion.expr,
5711                type_substitutions,
5712                value_substitutions,
5713                local_names,
5714            );
5715            Some((format!("assert:{alias}"), Item::Assert(assertion)))
5716        }
5717        Item::Rule(mut rule) => {
5718            let name = rename_ident(rule.name, alias, local_names);
5719            let generated = format!("rule:{}", name.name);
5720            rule.name = name;
5721            for when in &mut rule.whens {
5722                when.text = substitute_pattern_text(
5723                    &when.text,
5724                    type_substitutions,
5725                    value_substitutions,
5726                    local_names,
5727                );
5728            }
5729            rule.body.text = substitute_pattern_text(
5730                &rule.body.text,
5731                type_substitutions,
5732                value_substitutions,
5733                local_names,
5734            );
5735            Some((generated, Item::Rule(rule)))
5736        }
5737    }
5738}
5739
5740fn rename_ident(ident: Ident, alias: &str, local_names: &BTreeMap<String, String>) -> Ident {
5741    Ident {
5742        name: local_names
5743            .get(&ident.name)
5744            .cloned()
5745            .unwrap_or_else(|| generated_pattern_name(alias, &ident.name)),
5746        span: ident.span,
5747    }
5748}
5749
5750fn substitute_pattern_type(
5751    ty: TypeSyntax,
5752    type_substitutions: &BTreeMap<String, TypeSyntax>,
5753    local_names: &BTreeMap<String, String>,
5754) -> TypeSyntax {
5755    match ty {
5756        TypeSyntax::Ref { name } => {
5757            if let Some(replacement) = type_substitutions.get(&name.name) {
5758                return replacement.clone();
5759            }
5760            TypeSyntax::Ref {
5761                name: Ident {
5762                    name: local_names.get(&name.name).cloned().unwrap_or(name.name),
5763                    span: name.span,
5764                },
5765            }
5766        }
5767        TypeSyntax::AgentRef { agents, span } => TypeSyntax::AgentRef {
5768            agents: agents
5769                .into_iter()
5770                .map(|agent| Ident {
5771                    name: local_names.get(&agent.name).cloned().unwrap_or(agent.name),
5772                    span: agent.span,
5773                })
5774                .collect(),
5775            span,
5776        },
5777        TypeSyntax::Optional { inner, span } => TypeSyntax::Optional {
5778            inner: Box::new(substitute_pattern_type(
5779                *inner,
5780                type_substitutions,
5781                local_names,
5782            )),
5783            span,
5784        },
5785        TypeSyntax::Array { inner, span } => TypeSyntax::Array {
5786            inner: Box::new(substitute_pattern_type(
5787                *inner,
5788                type_substitutions,
5789                local_names,
5790            )),
5791            span,
5792        },
5793        TypeSyntax::Map { inner, span } => TypeSyntax::Map {
5794            inner: Box::new(substitute_pattern_type(
5795                *inner,
5796                type_substitutions,
5797                local_names,
5798            )),
5799            span,
5800        },
5801        TypeSyntax::Union { variants, span } => TypeSyntax::Union {
5802            variants: variants
5803                .into_iter()
5804                .map(|variant| substitute_pattern_type(variant, type_substitutions, local_names))
5805                .collect(),
5806            span,
5807        },
5808        other => other,
5809    }
5810}
5811
5812fn substitute_pattern_text(
5813    text: &str,
5814    type_substitutions: &BTreeMap<String, TypeSyntax>,
5815    value_substitutions: &BTreeMap<String, String>,
5816    local_names: &BTreeMap<String, String>,
5817) -> String {
5818    let mut output = text.to_owned();
5819    for (name, replacement) in type_substitutions {
5820        output = replace_identifier(&output, name, &replacement.to_source());
5821    }
5822    for (name, replacement) in local_names {
5823        output = replace_identifier(&output, name, replacement);
5824    }
5825    for (name, replacement) in value_substitutions {
5826        output = replace_identifier(&output, name, replacement);
5827    }
5828    output
5829}
5830
5831fn replace_identifier(source: &str, from: &str, to: &str) -> String {
5832    let mut output = String::new();
5833    let mut index = 0usize;
5834    while let Some(offset) = source[index..].find(from) {
5835        let start = index + offset;
5836        let end = start + from.len();
5837        output.push_str(&source[index..start]);
5838        let before = source[..start].chars().next_back();
5839        let after = source[end..].chars().next();
5840        if before.is_none_or(|ch| !is_identifier_char(ch))
5841            && after.is_none_or(|ch| !is_identifier_char(ch))
5842        {
5843            output.push_str(to);
5844        } else {
5845            output.push_str(&source[start..end]);
5846        }
5847        index = end;
5848    }
5849    output.push_str(&source[index..]);
5850    output
5851}
5852
5853fn is_identifier_char(ch: char) -> bool {
5854    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
5855}
5856
5857fn lower_assert(
5858    assertion: AssertDecl,
5859    semantic: &SemanticContext,
5860    ir: &mut IrProgram,
5861    diagnostics: &mut Vec<Diagnostic>,
5862) {
5863    match parse_expression(&assertion.expr) {
5864        Ok(expr) => {
5865            validate_parsed_expression(
5866                &expr,
5867                semantic,
5868                &ExprScope::default(),
5869                &ExprValidationContext::assertion(assertion.span),
5870                "assertion",
5871                diagnostics,
5872            );
5873            let mut projection_reads = collect_projection_reads(&expr);
5874            sort_projection_reads(&mut projection_reads);
5875            ir.assertions.push(IrAssertion {
5876                expr: IrExpression {
5877                    source: assertion.expr,
5878                    expr,
5879                    span: assertion.span,
5880                },
5881                projection_reads,
5882            });
5883        }
5884        Err(message) => diagnostics.push(Diagnostic {
5885            related: Vec::new(),
5886            span: assertion.span,
5887            message: format!("invalid assertion expression: {message}"),
5888            suggestion: Some(
5889                "use a deterministic expression such as `count(Fact) == 1`".to_owned(),
5890            ),
5891        }),
5892    }
5893}
5894
5895fn lower_expression(source: &str, span: SourceSpan) -> Option<IrExpression> {
5896    parse_expression(source).ok().map(|expr| IrExpression {
5897        source: source.to_owned(),
5898        expr,
5899        span,
5900    })
5901}
5902
5903fn collect_projection_reads(expr: &Expr) -> Vec<IrProjectionRead> {
5904    let mut reads = Vec::new();
5905    collect_projection_reads_into(expr, &mut reads);
5906    reads
5907}
5908
5909fn collect_projection_reads_into(expr: &Expr, reads: &mut Vec<IrProjectionRead>) {
5910    match expr {
5911        Expr::Literal(_) | Expr::Path(_) => {}
5912        Expr::Index { target, key } => {
5913            collect_projection_reads_into(target, reads);
5914            collect_projection_reads_into(key, reads);
5915        }
5916        Expr::Array(items) => {
5917            for item in items {
5918                collect_projection_reads_into(item, reads);
5919            }
5920        }
5921        Expr::Object(fields) => {
5922            for field in fields {
5923                collect_projection_reads_into(&field.value, reads);
5924            }
5925        }
5926        Expr::Unary { expr, .. } => collect_projection_reads_into(expr, reads),
5927        Expr::Binary { left, right, .. } => {
5928            collect_projection_reads_into(left, reads);
5929            collect_projection_reads_into(right, reads);
5930        }
5931        Expr::Call { args, .. } => {
5932            for arg in args {
5933                collect_projection_reads_into(arg, reads);
5934            }
5935        }
5936        Expr::Query { kind, head, guard } => {
5937            reads.push(IrProjectionRead {
5938                kind: *kind,
5939                head: head.clone(),
5940                guard: guard.as_ref().map(|guard| guard.to_snapshot()),
5941            });
5942            if let Some(guard) = guard {
5943                collect_projection_reads_into(guard, reads);
5944            }
5945        }
5946    }
5947}
5948
5949fn sort_projection_reads(reads: &mut Vec<IrProjectionRead>) {
5950    reads.sort_by_key(IrProjectionRead::to_snapshot);
5951    reads.dedup();
5952}
5953
5954fn collect_schema_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
5955    let mut names = BTreeSet::new();
5956    // Track the first declaration span per name so a duplicate can point back to
5957    // it as related information ("first declared here").
5958    let mut first_spans: BTreeMap<String, SourceSpan> = BTreeMap::new();
5959    for item in &program.items {
5960        let name = match item {
5961            Item::Enum(enum_decl) => &enum_decl.name,
5962            Item::Class(class_decl) => &class_decl.name,
5963            _ => continue,
5964        };
5965
5966        if !names.insert(name.name.clone()) {
5967            let mut diagnostic = Diagnostic {
5968                related: Vec::new(),
5969                span: name.span,
5970                message: format!("schema `{}` is declared more than once", name.name),
5971                suggestion: Some("rename one declaration or merge the schemas".to_owned()),
5972            };
5973            if let Some(first) = first_spans.get(&name.name) {
5974                diagnostic = diagnostic.with_related(*first, "first declared here");
5975            }
5976            diagnostics.push(diagnostic);
5977        } else {
5978            first_spans.insert(name.name.clone(), name.span);
5979        }
5980    }
5981
5982    names
5983}
5984
5985fn collect_harness_kinds(
5986    program: &Program,
5987    diagnostics: &mut Vec<Diagnostic>,
5988) -> BTreeMap<String, String> {
5989    let mut kinds: BTreeMap<String, String> = BTreeMap::new();
5990    for item in &program.items {
5991        let Item::Harness(harness) = item else {
5992            continue;
5993        };
5994        if kinds
5995            .insert(harness.name.name.clone(), harness.kind.name.clone())
5996            .is_some()
5997        {
5998            diagnostics.push(Diagnostic {
5999                related: Vec::new(),
6000                span: harness.name.span,
6001                message: format!("harness `{}` is declared more than once", harness.name.name),
6002                suggestion: Some(
6003                    "rename one harness declaration or merge the harness settings".to_owned(),
6004                ),
6005            });
6006        }
6007    }
6008    kinds
6009}
6010
6011fn collect_agent_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
6012    let mut names = BTreeSet::new();
6013    for item in &program.items {
6014        let Item::Agent(agent) = item else {
6015            continue;
6016        };
6017        if !names.insert(agent.name.name.clone()) {
6018            diagnostics.push(Diagnostic {
6019                related: Vec::new(),
6020                span: agent.name.span,
6021                message: format!("agent `{}` is declared more than once", agent.name.name),
6022                suggestion: Some("rename one agent declaration or merge the settings".to_owned()),
6023            });
6024        }
6025    }
6026    names
6027}
6028
6029#[derive(Clone, Debug, Default, Eq, PartialEq)]
6030struct WorkflowContractNames {
6031    inputs: BTreeMap<String, TypeSyntax>,
6032    outputs: BTreeMap<String, TypeSyntax>,
6033    failures: BTreeMap<String, TypeSyntax>,
6034}
6035
6036fn collect_workflow_contract_names(
6037    program: &Program,
6038    diagnostics: &mut Vec<Diagnostic>,
6039) -> WorkflowContractNames {
6040    let mut names = WorkflowContractNames::default();
6041    for item in &program.items {
6042        let Item::WorkflowContract(contract) = item else {
6043            continue;
6044        };
6045        let set = match contract.kind {
6046            WorkflowContractKind::Input => &mut names.inputs,
6047            WorkflowContractKind::Output => &mut names.outputs,
6048            WorkflowContractKind::Failure => &mut names.failures,
6049        };
6050        if set
6051            .insert(contract.name.name.clone(), contract.ty.clone())
6052            .is_some()
6053        {
6054            diagnostics.push(Diagnostic {
6055                related: Vec::new(),
6056                span: contract.name.span,
6057                message: format!(
6058                    "workflow declares {} `{}` more than once",
6059                    contract.kind.as_str(),
6060                    contract.name.name
6061                ),
6062                suggestion: Some("remove the duplicate workflow contract".to_owned()),
6063            });
6064        }
6065    }
6066    names
6067}
6068
6069impl SemanticContext {
6070    fn from_program(
6071        program: &Program,
6072        workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
6073    ) -> Self {
6074        let mut schemas = SchemaIndex::with_builtins();
6075        let mut agents = BTreeSet::new();
6076        let mut agent_capabilities = BTreeMap::new();
6077        let mut coerce_outputs = BTreeMap::new();
6078        let mut coerce_params = BTreeMap::new();
6079        let mut leases = BTreeSet::new();
6080        let mut ledgers = BTreeSet::new();
6081        let mut counters = BTreeSet::new();
6082        let mut channels = BTreeSet::new();
6083        let mut channel_providers = BTreeMap::new();
6084        let mut memory_pools = BTreeSet::new();
6085
6086        for item in &program.items {
6087            schemas.insert_item(item);
6088            match item {
6089                Item::Agent(agent) => {
6090                    agents.insert(agent.name.name.clone());
6091                    let capabilities = agent
6092                        .fields
6093                        .iter()
6094                        .find_map(|field| match field {
6095                            AgentField::Capabilities(capabilities, _) => Some(
6096                                capabilities
6097                                    .iter()
6098                                    .map(|capability| capability.value.clone())
6099                                    .collect::<BTreeSet<_>>(),
6100                            ),
6101                            _ => None,
6102                        })
6103                        .unwrap_or_default();
6104                    agent_capabilities.insert(agent.name.name.clone(), capabilities);
6105                }
6106                Item::Coerce(coerce) => {
6107                    coerce_outputs.insert(coerce.name.name.clone(), coerce.output.clone());
6108                    coerce_params.insert(coerce.name.name.clone(), coerce.params.clone());
6109                }
6110                Item::Lease(lease) => {
6111                    leases.insert(lease.name.name.clone());
6112                }
6113                Item::Ledger(ledger) => {
6114                    ledgers.insert(ledger.name.name.clone());
6115                }
6116                Item::Counter(counter) => {
6117                    counters.insert(counter.name.name.clone());
6118                }
6119                Item::Channel(channel) => {
6120                    channels.insert(channel.name.name.clone());
6121                    channel_providers
6122                        .insert(channel.name.name.clone(), channel.provider.name.clone());
6123                }
6124                Item::MemoryPool(pool) => {
6125                    memory_pools.insert(pool.name.name.clone());
6126                }
6127                _ => {}
6128            }
6129        }
6130
6131        Self {
6132            workflow: program
6133                .workflow
6134                .as_ref()
6135                .map(|workflow| workflow.name.clone()),
6136            schemas,
6137            agents,
6138            agent_capabilities,
6139            coerce_outputs,
6140            coerce_params,
6141            workflow_inputs,
6142            leases,
6143            ledgers,
6144            counters,
6145            channels,
6146            channel_providers,
6147            memory_pools,
6148        }
6149    }
6150}
6151
6152fn collect_workflow_input_surfaces(program: &Program) -> BTreeMap<String, WorkflowInputSurface> {
6153    let mut surfaces = BTreeMap::new();
6154    let top_level_schemas = schema_index_for_items(&program.items);
6155
6156    if let Some(workflow) = &program.workflow {
6157        let inputs = workflow_inputs_for_items(&program.items);
6158        surfaces.insert(
6159            workflow.name.clone(),
6160            WorkflowInputSurface {
6161                inputs,
6162                outputs: workflow_outputs_for_items(&program.items),
6163                failures: workflow_failures_for_items(&program.items),
6164                schemas: top_level_schemas.clone(),
6165                milestones: collect_milestone_declarations(&program.items),
6166            },
6167        );
6168    }
6169
6170    for workflow in &program.workflows {
6171        let mut schemas = top_level_schemas.clone();
6172        schemas.merge(schema_index_for_items(&workflow.items));
6173        surfaces.insert(
6174            workflow.name.name.clone(),
6175            WorkflowInputSurface {
6176                inputs: workflow_inputs_for_items(&workflow.items),
6177                outputs: workflow_outputs_for_items(&workflow.items),
6178                failures: workflow_failures_for_items(&workflow.items),
6179                schemas,
6180                milestones: collect_milestone_declarations(&workflow.items),
6181            },
6182        );
6183    }
6184
6185    surfaces
6186}
6187
6188fn collect_shared_coordination_usage(program: &Program) -> Vec<IrSharedCoordinationUsage> {
6189    let global_shared = shared_coordination_declarations(&program.items);
6190    let mut usage: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6191
6192    let mut record_workflow = |workflow_name: &str, local_items: &[Item]| {
6193        let mut shared = global_shared.clone();
6194        shared.extend(shared_coordination_declarations(local_items));
6195        if shared.is_empty() {
6196            return;
6197        }
6198        let principal = format!("workflow:local/{workflow_name}");
6199        for resource in coordination_resources_used_by_items(&program.items)
6200            .into_iter()
6201            .chain(coordination_resources_used_by_items(local_items))
6202        {
6203            if shared.contains(&resource) {
6204                usage.entry(resource).or_default().insert(principal.clone());
6205            }
6206        }
6207    };
6208
6209    if let Some(workflow) = &program.workflow {
6210        record_workflow(&workflow.name, &[]);
6211    }
6212    for workflow in &program.workflows {
6213        record_workflow(&workflow.name.name, &workflow.items);
6214    }
6215
6216    usage
6217        .into_iter()
6218        .map(|(resource, principals)| IrSharedCoordinationUsage {
6219            resource: format!("resource:{resource}"),
6220            workflow_principals: principals.into_iter().collect(),
6221        })
6222        .collect()
6223}
6224
6225fn shared_coordination_declarations(items: &[Item]) -> BTreeSet<String> {
6226    items
6227        .iter()
6228        .filter_map(|item| match item {
6229            Item::Lease(lease) if lease.shared => Some(lease.name.name.clone()),
6230            Item::Ledger(ledger) if ledger.shared => Some(ledger.name.name.clone()),
6231            Item::Counter(counter) if counter.shared => Some(counter.name.name.clone()),
6232            _ => None,
6233        })
6234        .collect()
6235}
6236
6237fn coordination_resources_used_by_items(items: &[Item]) -> BTreeSet<String> {
6238    let mut resources = BTreeSet::new();
6239    for item in items {
6240        let Item::Rule(rule) = item else {
6241            continue;
6242        };
6243        let (body, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
6244        collect_coordination_resources_from_statements(&body.statements, &mut resources);
6245    }
6246    resources
6247}
6248
6249fn collect_coordination_resources_from_statements(
6250    statements: &[body::BodyStmt],
6251    resources: &mut BTreeSet<String>,
6252) {
6253    for statement in statements {
6254        match statement {
6255            body::BodyStmt::Effect(effect) => match &effect.kind {
6256                body::BodyEffectKind::LeaseAcquire { resource, .. } => {
6257                    resources.insert(resource.clone());
6258                }
6259                body::BodyEffectKind::LedgerAppend { ledger, .. } => {
6260                    resources.insert(ledger.clone());
6261                }
6262                body::BodyEffectKind::CounterConsume { counter, .. } => {
6263                    resources.insert(counter.clone());
6264                }
6265                _ => {}
6266            },
6267            body::BodyStmt::After(after) => {
6268                collect_coordination_resources_from_statements(&after.body, resources);
6269            }
6270            body::BodyStmt::Region(region) => {
6271                collect_coordination_resources_from_statements(&region.body, resources);
6272                collect_coordination_resources_from_statements(&region.lapse_body, resources);
6273            }
6274            body::BodyStmt::Case(case_stmt) => {
6275                for branch in &case_stmt.branches {
6276                    collect_coordination_resources_from_statements(&branch.body, resources);
6277                }
6278            }
6279            body::BodyStmt::Record(_)
6280            | body::BodyStmt::Done { .. }
6281            | body::BodyStmt::Terminal(_)
6282            | body::BodyStmt::Cancel { .. }
6283            | body::BodyStmt::Milestone { .. }
6284            | body::BodyStmt::Redact { .. } => {}
6285        }
6286    }
6287}
6288
6289/// Scans a workflow's rule bodies for `emit milestone "<name>" [of <Class>]`
6290/// projections (Family C) and returns the name -> payload-class map (empty class
6291/// string for a bare milestone). The emit statement IS the declaration — the
6292/// declared milestone set is exactly what the workflow's rules can project, which
6293/// is what a parent's `after p reaches "<name>"` is validated against.
6294fn collect_milestone_declarations(items: &[Item]) -> BTreeMap<String, String> {
6295    let mut milestones = BTreeMap::new();
6296    for item in items {
6297        let Item::Rule(rule) = item else {
6298            continue;
6299        };
6300        for (name, class) in milestone_emissions_in_body(&rule.body.text) {
6301            milestones.entry(name).or_insert(class);
6302        }
6303    }
6304    milestones
6305}
6306
6307/// Validates Family C milestone statements in a rule (spec/decision-records/
6308/// discriminated-families-design.md sections 6.4 / 7.3):
6309///   - child `emit milestone "<name>" of <Class>` — `<Class>` must be a declared
6310///     class (the payload the observing parent narrows into scope);
6311///   - parent `after <p> reaches "<name>"` — `<p>` must be a workflow-invoke
6312///     binding in this rule, and `<name>` must be a milestone that the invoked
6313///     child workflow actually declares (the reject-undeclared / terminal-only
6314///     observation invariant: a parent cannot observe a state the child never
6315///     projects).
6316fn validate_milestone_statements(
6317    rule: &RuleDecl,
6318    semantic: &SemanticContext,
6319    diagnostics: &mut Vec<Diagnostic>,
6320) {
6321    // Child side: every `emit milestone "<name>" of <Class>` payload class must
6322    // exist.
6323    for (name, class) in milestone_emissions_in_body(&rule.body.text) {
6324        if !class.is_empty() && !semantic.schemas.class_exists(&class) {
6325            diagnostics.push(Diagnostic {
6326                related: Vec::new(),
6327                span: rule.body.span,
6328                message: format!(
6329                    "rule `{}` emits milestone `{name}` with unknown payload class `{class}`",
6330                    rule.name.name
6331                ),
6332                suggestion: Some(format!("declare `class {class}` before projecting it")),
6333            });
6334        }
6335    }
6336
6337    // Parent side: every `after <p> reaches "<name>"` must name a milestone the
6338    // invoked child declares.
6339    for (binding, milestone) in milestone_reaches_in_body(&rule.body.text) {
6340        let Some(workflow) = invoke_binding_workflow(rule, &binding) else {
6341            diagnostics.push(Diagnostic {
6342                related: Vec::new(),
6343                span: rule.body.span,
6344                message: format!(
6345                    "rule `{}` has `after {binding} reaches \"{milestone}\"` for `{binding}`, which is not a workflow-invoke binding in this rule",
6346                    rule.name.name
6347                ),
6348                suggestion: Some(
6349                    "`reaches` observes a child workflow milestone; bind the child with `invoke W { ... } as <binding>` first"
6350                        .to_owned(),
6351                ),
6352            });
6353            continue;
6354        };
6355        let declared = semantic
6356            .workflow_inputs
6357            .get(&workflow)
6358            .map(|surface| surface.milestones.contains_key(&milestone))
6359            .unwrap_or(false);
6360        if !declared {
6361            let available = semantic
6362                .workflow_inputs
6363                .get(&workflow)
6364                .map(|surface| {
6365                    surface
6366                        .milestones
6367                        .keys()
6368                        .map(|name| format!("\"{name}\""))
6369                        .collect::<Vec<_>>()
6370                        .join(", ")
6371                })
6372                .unwrap_or_default();
6373            let suggestion = if available.is_empty() {
6374                format!("workflow `{workflow}` declares no milestones; add `emit milestone \"{milestone}\" ...` to it")
6375            } else {
6376                format!("workflow `{workflow}` declares: {available}")
6377            };
6378            diagnostics.push(Diagnostic {
6379                related: Vec::new(),
6380                span: rule.body.span,
6381                message: format!(
6382                    "rule `{}` reaches milestone `{milestone}` that workflow `{workflow}` does not declare",
6383                    rule.name.name
6384                ),
6385                suggestion: Some(suggestion),
6386            });
6387        }
6388    }
6389}
6390
6391/// Parses `after <binding> reaches "<name>"` headers out of a rule body's text,
6392/// returning (binding, milestone-name) pairs. Mirrors `milestone_emissions_in_body`.
6393fn milestone_reaches_in_body(body: &str) -> Vec<(String, String)> {
6394    let mut out = Vec::new();
6395    for raw in body.lines() {
6396        let trimmed = raw.trim();
6397        let Some(rest) = trimmed.strip_prefix("after ") else {
6398            continue;
6399        };
6400        let mut words = rest.split_whitespace();
6401        let Some(binding) = words.next() else {
6402            continue;
6403        };
6404        if words.next() != Some("reaches") {
6405            continue;
6406        }
6407        let Some(quoted) = words.next() else {
6408            continue;
6409        };
6410        if !(quoted.starts_with('"') && quoted.ends_with('"') && quoted.len() >= 2) {
6411            continue;
6412        }
6413        out.push((binding.to_owned(), quoted.trim_matches('"').to_owned()));
6414    }
6415    out
6416}
6417
6418/// Maps an `invoke <Workflow> { ... } as <binding>` binding to the invoked
6419/// workflow name within a single rule, so a sibling `after <binding> reaches`
6420/// can find the child workflow whose milestones it observes.
6421fn invoke_binding_workflow(rule: &RuleDecl, binding: &str) -> Option<String> {
6422    for statement in workflow_invoke_statements(&rule.body.text) {
6423        let (target, _) = invoke_statement_parts(&statement)?;
6424        if let Some(as_binding) = binding_after_as(&statement) {
6425            if as_binding == binding {
6426                return Some(target.to_owned());
6427            }
6428        }
6429    }
6430    None
6431}
6432
6433/// Resolves the payload class of a child milestone for `after <binding> reaches
6434/// "<milestone>"`: follow `binding` to its invoked workflow, then look up the
6435/// milestone in that workflow's declared set. `Some("")` means the milestone is
6436/// declared but payload-less; `None` means undeclared (reject) or unresolvable.
6437fn milestone_payload_class(
6438    rule: &RuleDecl,
6439    binding: &str,
6440    milestone: &str,
6441    semantic: &SemanticContext,
6442) -> Option<String> {
6443    let workflow = invoke_binding_workflow(rule, binding)?;
6444    let surface = semantic.workflow_inputs.get(&workflow)?;
6445    surface.milestones.get(milestone).cloned()
6446}
6447
6448/// Resolves the OUTPUT-contract class of the child workflow a `succeeds`/`completes`
6449/// invoke binding observes, so `after <binding> succeeds as r` can type `r` and
6450/// check `r.<field>`. `None` (leave the binding opaque, unchanged) when: the binding
6451/// is not an invoke; the child declares zero or several outputs (which output the
6452/// child completes is not statically known); the sole output is a scalar (no
6453/// fields); or the output class is not resolvable in THIS workflow's scope. That
6454/// last guard preserves workflow-local scoping — a child's private output class is
6455/// not visible to the parent, so only a shared top-level class is typed here.
6456fn invoke_output_class(
6457    rule: &RuleDecl,
6458    binding: &str,
6459    semantic: &SemanticContext,
6460) -> Option<String> {
6461    let workflow = invoke_binding_workflow(rule, binding)?;
6462    let surface = semantic.workflow_inputs.get(&workflow)?;
6463    if surface.outputs.len() != 1 {
6464        return None;
6465    }
6466    match surface.outputs.values().next()? {
6467        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
6468            Some(name.name.clone())
6469        }
6470        _ => None,
6471    }
6472}
6473
6474/// Resolves the FAILURE-contract class of the child workflow a `fails` invoke
6475/// binding observes, so `after <binding> fails as f` can type `f` to the child's
6476/// declared failure shape (and check `f.<field>`) instead of the generic DR-0032
6477/// `TerminalFailed` base. `None` (fall back to the base) when: the binding is not
6478/// an invoke; the child declares zero or several failures (which failure the child
6479/// raised is not statically known); the sole failure is a scalar (no fields); or
6480/// the failure class is not resolvable in THIS workflow's scope. That last guard
6481/// preserves workflow-local scoping — a child's private failure class is not
6482/// visible to the parent, so only a shared top-level class is typed here; anything
6483/// else keeps the base, which every failure structurally satisfies.
6484fn invoke_failure_class(
6485    rule: &RuleDecl,
6486    binding: &str,
6487    semantic: &SemanticContext,
6488) -> Option<String> {
6489    let workflow = invoke_binding_workflow(rule, binding)?;
6490    let surface = semantic.workflow_inputs.get(&workflow)?;
6491    if surface.failures.len() != 1 {
6492        return None;
6493    }
6494    match surface.failures.values().next()? {
6495        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
6496            Some(name.name.clone())
6497        }
6498        _ => None,
6499    }
6500}
6501
6502/// Parses `emit milestone "<name>" [of <Class>]` headers out of a rule body's
6503/// text, returning (name, class) pairs (class is empty for a bare milestone).
6504/// Text-based to mirror the other body scanners (`workflow_invoke_statements`)
6505/// and stay independent of flow-vs-rule body provenance.
6506fn milestone_emissions_in_body(body: &str) -> Vec<(String, String)> {
6507    let mut out = Vec::new();
6508    for raw in body.lines() {
6509        let trimmed = raw.trim();
6510        let Some(rest) = trimmed.strip_prefix("emit milestone ") else {
6511            continue;
6512        };
6513        // The name is a quoted string literal; take the text between the first
6514        // pair of quotes.
6515        let rest = rest.trim_start();
6516        if !rest.starts_with('"') {
6517            continue;
6518        }
6519        let Some(close) = rest[1..].find('"') else {
6520            continue;
6521        };
6522        let name = rest[1..=close].to_owned();
6523        let after_name = rest[close + 2..].trim_start();
6524        let class = after_name
6525            .strip_prefix("of ")
6526            .map(|tail| {
6527                tail.trim_start()
6528                    .split(|c: char| c.is_whitespace() || c == '{')
6529                    .next()
6530                    .unwrap_or("")
6531                    .to_owned()
6532            })
6533            .unwrap_or_default();
6534        out.push((name, class));
6535    }
6536    out
6537}
6538
6539fn schema_index_for_items(items: &[Item]) -> SchemaIndex {
6540    let mut schemas = SchemaIndex::with_builtins();
6541    for item in items {
6542        schemas.insert_item(item);
6543    }
6544    schemas
6545}
6546
6547fn workflow_inputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6548    items
6549        .iter()
6550        .filter_map(|item| match item {
6551            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Input => {
6552                Some((contract.name.name.clone(), contract.ty.clone()))
6553            }
6554            _ => None,
6555        })
6556        .collect()
6557}
6558
6559fn workflow_outputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6560    items
6561        .iter()
6562        .filter_map(|item| match item {
6563            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Output => {
6564                Some((contract.name.name.clone(), contract.ty.clone()))
6565            }
6566            _ => None,
6567        })
6568        .collect()
6569}
6570
6571fn workflow_failures_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6572    items
6573        .iter()
6574        .filter_map(|item| match item {
6575            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Failure => {
6576                Some((contract.name.name.clone(), contract.ty.clone()))
6577            }
6578            _ => None,
6579        })
6580        .collect()
6581}
6582
6583impl SchemaIndex {
6584    fn with_builtins() -> Self {
6585        let mut index = Self::default();
6586        index.insert_class(
6587            "AgentTurn",
6588            [
6589                ("id", string_ty()),
6590                ("summary", string_ty()),
6591                ("agent", string_ty()),
6592                ("provider", string_ty()),
6593                ("status", string_ty()),
6594                ("run_id", string_ty()),
6595                ("effect_id", string_ty()),
6596            ],
6597        );
6598        index.insert_class(
6599            "WorkItem",
6600            [
6601                ("id", string_ty()),
6602                ("title", string_ty()),
6603                ("body", string_ty()),
6604                ("queue", string_ty()),
6605                ("status", string_ty()),
6606                ("labels", array_ty(string_ty())),
6607            ],
6608        );
6609        index.insert_class(
6610            "Evidence",
6611            [
6612                ("title", string_ty()),
6613                ("path", string_ty()),
6614                ("summary", string_ty()),
6615            ],
6616        );
6617        index.insert_class(
6618            "TerminalFailed",
6619            [
6620                ("reason", string_ty()),
6621                ("summary", string_ty()),
6622                ("effect_id", string_ty()),
6623                ("run_id", string_ty()),
6624                // DR-0032: `kind` names the failing effect — the `EffectError` base
6625                // field that lets a future runtime union dispatch and that
6626                // telemetry reads. Static narrowing does not require it.
6627                ("kind", string_ty()),
6628            ],
6629        );
6630        // DR-0032 P3 (per-kind failure extras, DQ-2 static narrowing): each
6631        // kind's failure schema extends the base with the ruled v1 extras —
6632        // exec `exit_code`; schema.coerce `error_class` + optional
6633        // `http_status`; agent.tell `error_class`. The extras are reachable
6634        // ONLY when the binding's effect kind matches (the `fails`-arm
6635        // narrowing below), so each addition is additive by construction.
6636        index.insert_class(
6637            "TerminalFailedExec",
6638            [
6639                ("reason", string_ty()),
6640                ("summary", string_ty()),
6641                ("effect_id", string_ty()),
6642                ("run_id", string_ty()),
6643                ("kind", string_ty()),
6644                // Absent when the process could not be spawned (the emitters
6645                // only set it for a run that actually started) — the docs
6646                // said so; the type now agrees.
6647                ("exit_code", optional_ty(int_ty())),
6648            ],
6649        );
6650        index.insert_class(
6651            "TerminalFailedCoerce",
6652            [
6653                ("reason", string_ty()),
6654                ("summary", string_ty()),
6655                ("effect_id", string_ty()),
6656                ("run_id", string_ty()),
6657                ("kind", string_ty()),
6658                ("error_class", string_ty()),
6659                ("http_status", optional_ty(int_ty())),
6660            ],
6661        );
6662        index.insert_class(
6663            "TerminalFailedTell",
6664            [
6665                ("reason", string_ty()),
6666                ("summary", string_ty()),
6667                ("effect_id", string_ty()),
6668                ("run_id", string_ty()),
6669                ("kind", string_ty()),
6670                ("error_class", string_ty()),
6671            ],
6672        );
6673        index.insert_class(
6674            "TerminalTimedOut",
6675            [
6676                ("summary", string_ty()),
6677                ("effect_id", string_ty()),
6678                ("run_id", string_ty()),
6679            ],
6680        );
6681        index.insert_class(
6682            "TerminalCancelled",
6683            [
6684                ("summary", string_ty()),
6685                ("effect_id", string_ty()),
6686                ("run_id", string_ty()),
6687            ],
6688        );
6689        // The `after x completes as o` envelope: the runtime delivers the
6690        // terminal UNION {tag, status, summary, effect_id, run_id} (plus the
6691        // dynamically-shaped value/error read via `case o { Completed as v =>
6692        // … }`). Typing the alias as the effect's SUCCESS schema — the old
6693        // behavior — approved field reads that were null at runtime on every
6694        // non-success terminal.
6695        index.insert_class(
6696            "TerminalOutcome",
6697            [
6698                ("tag", string_ty()),
6699                ("status", string_ty()),
6700                ("summary", string_ty()),
6701                ("effect_id", string_ty()),
6702                ("run_id", string_ty()),
6703            ],
6704        );
6705        // The generic inbound messaging envelope (spec/messaging.md): a
6706        // `when message from <channel> as msg` binding sees a `Message`, never a
6707        // domain type. Structured sub-payloads (sender_claims, interaction,
6708        // correlation) are JSON-serialized strings here; provider-specific
6709        // payloads live in bounded evidence / `raw_ref`, not as untyped facts.
6710        index.insert_class(
6711            "Message",
6712            [
6713                ("message_id", string_ty()),
6714                ("channel", string_ty()),
6715                ("provider", string_ty()),
6716                ("received_at", string_ty()),
6717                ("sender", string_ty()),
6718                ("sender_claims", string_ty()),
6719                ("thread_id", string_ty()),
6720                ("text", string_ty()),
6721                ("markdown", string_ty()),
6722                ("attachments", array_ty(string_ty())),
6723                ("interaction", string_ty()),
6724                ("raw_ref", string_ty()),
6725                ("correlation", string_ty()),
6726            ],
6727        );
6728        // The typed receipt a `send via <channel> { ... } as r` binding sees
6729        // (std.messaging; the `messaging.send` contract's output schema —
6730        // spec/std-messaging.md "MessageSendReceipt"). Every provider returns
6731        // the full shape; correlation fields the provider cannot report
6732        // (`provider_message_id`, `thread_id`, `destination`) are empty
6733        // strings, and `accepted_at` is the provider-acknowledged instant.
6734        // Failure is NOT a receipt: it settles `capability.call.failed` with
6735        // the DR-0032 EffectError base and routes to `fails as`. `status` is
6736        // `accepted` in v1 (`delivered` is reserved for providers whose report
6737        // includes it; none exists yet).
6738        index.insert_class(
6739            "MessageSendReceipt",
6740            [
6741                ("message_id", string_ty()),
6742                ("channel", string_ty()),
6743                ("provider", string_ty()),
6744                ("status", string_ty()),
6745                ("provider_message_id", string_ty()),
6746                ("thread_id", string_ty()),
6747                ("destination", string_ty()),
6748                ("accepted_at", string_ty()),
6749            ],
6750        );
6751        index
6752    }
6753
6754    fn insert_class<const N: usize>(&mut self, name: &str, fields: [(&str, TypeSyntax); N]) {
6755        self.classes.insert(
6756            name.to_owned(),
6757            fields
6758                .into_iter()
6759                .map(|(field, ty)| (field.to_owned(), ty))
6760                .collect(),
6761        );
6762    }
6763
6764    fn insert_item(&mut self, item: &Item) {
6765        match item {
6766            Item::Enum(enum_decl) => {
6767                self.enums.insert(
6768                    enum_decl.name.name.clone(),
6769                    enum_decl
6770                        .variants
6771                        .iter()
6772                        .map(|variant| variant.name.name.clone())
6773                        .collect(),
6774                );
6775                // Data-carrying variants are visible as generated
6776                // `<Enum>.<Variant>` classes (spec/sum-types.md), so case
6777                // bindings type-check field access against them.
6778                for variant in &enum_decl.variants {
6779                    if variant.fields.is_empty() {
6780                        continue;
6781                    }
6782                    let mut fields = BTreeMap::new();
6783                    fields.insert(
6784                        "variant".to_owned(),
6785                        TypeSyntax::LiteralString {
6786                            value: variant.name.name.clone(),
6787                            span: variant.name.span,
6788                        },
6789                    );
6790                    for field in &variant.fields {
6791                        fields.insert(field.name.name.clone(), field.ty.clone());
6792                    }
6793                    self.classes.insert(
6794                        format!("{}.{}", enum_decl.name.name, variant.name.name),
6795                        fields,
6796                    );
6797                }
6798            }
6799            Item::Class(class_decl) => {
6800                self.classes.insert(
6801                    class_decl.name.name.clone(),
6802                    class_decl
6803                        .fields
6804                        .iter()
6805                        .map(|field| (field.name.name.clone(), field.ty.clone()))
6806                        .collect(),
6807                );
6808                self.insert_presence(&class_decl.name.name, &class_decl.fields);
6809            }
6810            Item::Event(event) => {
6811                self.events.insert(event.name.clone());
6812                // The payload schema is indexed under the dotted signal name,
6813                // unreachable from user class declarations, so bare `when
6814                // <signal> as x` bindings type-check field access.
6815                self.classes.insert(
6816                    event.name.clone(),
6817                    event
6818                        .fields
6819                        .iter()
6820                        .map(|field| (field.name.name.clone(), field.ty.clone()))
6821                        .collect(),
6822                );
6823                self.insert_presence(&event.name, &event.fields);
6824            }
6825            _ => {}
6826        }
6827    }
6828
6829    /// Record Family B presence conditions for a schema's fields (if any).
6830    fn insert_presence(&mut self, schema: &str, fields: &[ClassField]) {
6831        let conditions: BTreeMap<String, (String, String)> = fields
6832            .iter()
6833            .filter_map(|field| {
6834                field
6835                    .presence_condition
6836                    .clone()
6837                    .map(|condition| (field.name.name.clone(), condition))
6838            })
6839            .collect();
6840        if !conditions.is_empty() {
6841            self.presence.insert(schema.to_owned(), conditions);
6842        }
6843    }
6844
6845    /// The presence condition `(discriminant, literal)` for a schema field, if any.
6846    fn field_presence(&self, schema: &str, field: &str) -> Option<&(String, String)> {
6847        self.presence
6848            .get(schema)
6849            .and_then(|fields| fields.get(field))
6850    }
6851
6852    fn merge(&mut self, other: SchemaIndex) {
6853        self.classes.extend(other.classes);
6854        self.enums.extend(other.enums);
6855        self.presence.extend(other.presence);
6856    }
6857
6858    fn class_exists(&self, name: &str) -> bool {
6859        self.classes.contains_key(name)
6860    }
6861
6862    fn resolve_field_path(&self, root_schema: &str, path: &[String]) -> Result<TypeSyntax, String> {
6863        // Dotted runtime fact names (general `when fact <name>` matches) are
6864        // untyped — unless a declared `event` (or generated `<Enum>.<Variant>`
6865        // class) indexes a payload schema under the dotted name, in which
6866        // case field paths are statically validated against it.
6867        if root_schema.contains('.') && !self.classes.contains_key(root_schema) {
6868            return Ok(TypeSyntax::Ref {
6869                name: Ident {
6870                    name: root_schema.to_owned(),
6871                    span: zero_span(),
6872                },
6873            });
6874        }
6875        let mut schema = root_schema.to_owned();
6876        let mut current = TypeSyntax::Ref {
6877            name: Ident {
6878                name: schema.clone(),
6879                span: zero_span(),
6880            },
6881        };
6882
6883        for field in path {
6884            let Some(fields) = self.classes.get(&schema) else {
6885                return Err(format!("schema `{schema}` has no declared fields"));
6886            };
6887            let Some(field_ty) = fields.get(field) else {
6888                return Err(format!("schema `{schema}` has no field `{field}`"));
6889            };
6890
6891            current = field_ty.clone();
6892            match schema_name_for_path(&current) {
6893                Some(next_schema) => schema = next_schema,
6894                None if field != path.last().expect("path is non-empty") => {
6895                    return Err(format!("field `{field}` is not a schema value"));
6896                }
6897                None => {}
6898            }
6899        }
6900
6901        Ok(current)
6902    }
6903}
6904
6905fn zero_span() -> SourceSpan {
6906    SourceSpan { start: 0, end: 0 }
6907}
6908
6909fn string_ty() -> TypeSyntax {
6910    TypeSyntax::Primitive {
6911        name: "string".to_owned(),
6912        span: zero_span(),
6913    }
6914}
6915
6916fn int_ty() -> TypeSyntax {
6917    TypeSyntax::Primitive {
6918        name: "int".to_owned(),
6919        span: zero_span(),
6920    }
6921}
6922
6923fn optional_ty(inner: TypeSyntax) -> TypeSyntax {
6924    TypeSyntax::Optional {
6925        inner: Box::new(inner),
6926        span: zero_span(),
6927    }
6928}
6929
6930fn array_ty(inner: TypeSyntax) -> TypeSyntax {
6931    TypeSyntax::Array {
6932        inner: Box::new(inner),
6933        span: zero_span(),
6934    }
6935}
6936
6937fn schema_name_for_path(ty: &TypeSyntax) -> Option<String> {
6938    match ty {
6939        TypeSyntax::Ref { name } => Some(name.name.clone()),
6940        TypeSyntax::Optional { inner, .. } => schema_name_for_path(inner),
6941        _ => None,
6942    }
6943}
6944
6945fn lower_include(include: IncludeDecl, ir: &mut IrProgram) {
6946    ir.includes.push(IrInclude {
6947        path: include.path.value,
6948        source_hash: None,
6949    });
6950}
6951
6952fn lower_workflow_contract(
6953    contract: WorkflowContractDecl,
6954    ir: &mut IrProgram,
6955    schema_names: &BTreeSet<String>,
6956    agent_names: &BTreeSet<String>,
6957    diagnostics: &mut Vec<Diagnostic>,
6958) {
6959    validate_type_refs(&contract.ty, schema_names, agent_names, diagnostics);
6960    let kind = match contract.kind {
6961        WorkflowContractKind::Input => IrWorkflowContractKind::Input,
6962        WorkflowContractKind::Output => IrWorkflowContractKind::Output,
6963        WorkflowContractKind::Failure => IrWorkflowContractKind::Failure,
6964    };
6965    ir.workflow_contracts.push(IrWorkflowContract {
6966        kind,
6967        name: contract.name.name,
6968        ty: lower_type(contract.ty),
6969        span: contract.span,
6970    });
6971}
6972
6973/// The complete standard-package universe (the 13 std packages of the
6974/// standard-package campaign). `use std.<name>` outside this list is a check
6975/// error: std resolution is a built-in registry, so an unknown name can never
6976/// resolve later — a typo'd `use std.coercon` would otherwise silently import
6977/// nothing (and downstream missing-import bite is advisory only).
6978pub const STD_PACKAGE_IDS: &[&str] = &[
6979    "std.agent",
6980    "std.coercion",
6981    "std.coord",
6982    "std.files",
6983    "std.human",
6984    "std.ingress",
6985    "std.memory",
6986    "std.messaging",
6987    "std.script",
6988    "std.telemetry",
6989    "std.time",
6990    "std.tracker",
6991    "std.workflow",
6992];
6993
6994fn lower_use(use_decl: UseDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
6995    // A std import is valid when its FAMILY (`std.<name>`) is a known package;
6996    // deeper segments select provider sub-packages (`std.agent.codex`,
6997    // `std.messaging.local`), whose availability the package layer owns.
6998    let std_family_known = |value: &str| {
6999        STD_PACKAGE_IDS.iter().any(|id| {
7000            value == *id
7001                || value
7002                    .strip_prefix(id)
7003                    .is_some_and(|rest| rest.starts_with('.'))
7004        })
7005    };
7006    if use_decl.name.value.starts_with("std.") && !std_family_known(&use_decl.name.value) {
7007        diagnostics.push(Diagnostic {
7008            related: Vec::new(),
7009            span: use_decl.name.span,
7010            message: format!("unknown standard package `{}`", use_decl.name.value),
7011            suggestion: Some(format!(
7012                "standard packages are {}",
7013                STD_PACKAGE_IDS.join(", ")
7014            )),
7015        });
7016    }
7017    let kind = IrUseKind::Package;
7018    ir.uses.push(IrUse {
7019        kind,
7020        name: use_decl.name.value,
7021    });
7022}
7023
7024fn lower_tracker(tracker: TrackerDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7025    if tracker.provider.name != "builtin" {
7026        diagnostics.push(Diagnostic {
7027            related: Vec::new(),
7028            span: tracker.provider.span,
7029            message: format!(
7030                "tracker `{}` uses unavailable provider `{}`",
7031                tracker.name.name, tracker.provider.name
7032            ),
7033            suggestion: Some(
7034                "`builtin` is the available provider; github/linear/jira are deferred bindings"
7035                    .to_owned(),
7036            ),
7037        });
7038    }
7039    ir.trackers.push(IrTracker {
7040        name: tracker.name.name,
7041        provider: tracker.provider.name,
7042        span: tracker.span,
7043    });
7044}
7045
7046fn lower_channel(channel: ChannelDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7047    // Two channels with the same name would make `send via <name>` / `when
7048    // message from <name>` ambiguous (a channel name is a routing identity).
7049    if let Some(existing) = ir
7050        .channels
7051        .iter()
7052        .find(|other| other.name == channel.name.name)
7053    {
7054        diagnostics.push(
7055            Diagnostic {
7056                related: Vec::new(),
7057                span: channel.name.span,
7058                message: format!("channel `{}` is declared more than once", channel.name.name),
7059                suggestion: Some("give each channel a unique name".to_owned()),
7060            }
7061            .with_related(existing.span, "first declared here"),
7062        );
7063        return;
7064    }
7065    // Conditioned check 5 (spec/std-messaging.md "Static checks"): a channel
7066    // `provider` identifier must resolve to a provider capability report —
7067    // short name or full binding provider id. Unknown identifiers would lower
7068    // to a `messaging.send` no binding can route (or an inbound observation no
7069    // provider delivers), so they are rejected at compile time.
7070    if channel_provider_report(&channel.provider.name).is_none() {
7071        let known = CHANNEL_PROVIDER_REPORTS
7072            .iter()
7073            .map(|report| report.short_name)
7074            .collect::<Vec<_>>()
7075            .join(", ");
7076        diagnostics.push(Diagnostic {
7077            related: Vec::new(),
7078            span: channel.provider.span,
7079            message: format!(
7080                "channel `{}` names unknown messaging provider `{}`",
7081                channel.name.name, channel.provider.name
7082            ),
7083            suggestion: Some(format!("declare one of the v1 providers: {known}")),
7084        });
7085        // Fall through: the channel still lowers so `send via`/`when message
7086        // from` sites do not cascade an unknown-channel error on top.
7087    }
7088    ir.channels.push(IrChannel {
7089        name: channel.name.name,
7090        provider: channel.provider.name,
7091        workspace: channel.workspace.map(|workspace| workspace.name),
7092        destination: channel.destination.map(|destination| destination.value),
7093        span: channel.span,
7094    });
7095}
7096
7097/// std.messaging v1 provider capability report (spec/std-messaging.md
7098/// "Capability reports + conditioned checks"). Reports are DATA, never code
7099/// (M8): these compiled constants are mirrored by the embedded std.messaging
7100/// manifest's `bindings[].config.report` rows, and the conditioned static
7101/// checks below admit syntax only when the selected provider's report
7102/// supports it. Report axes are messaging.md "Provider Capability Report"
7103/// narrowed for v1: `delivery_receipts` ⊆ {accepted, failed}; `identity` ⊆
7104/// {anonymous, claimed_actor} (no verified_actor provider exists, so any
7105/// check demanding verified identity fails closed); `content` ⊆
7106/// {text, markdown}.
7107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7108pub struct ChannelProviderReport {
7109    /// The short channel-declaration identifier (`provider <short_name>`).
7110    pub short_name: &'static str,
7111    /// The binding-row provider id the short name resolves to.
7112    pub provider_id: &'static str,
7113    /// `outbound_only` | `inbound_only` | `bidirectional`.
7114    pub direction: &'static str,
7115    /// `anonymous` | `claimed_actor`.
7116    pub identity: &'static str,
7117    /// Interaction families the provider can deliver callbacks for.
7118    pub interactions: &'static [&'static str],
7119    /// Payload content kinds the provider accepts.
7120    pub content: &'static [&'static str],
7121    /// Receipt statuses the provider can report.
7122    pub delivery_receipts: &'static [&'static str],
7123}
7124
7125/// The four v1 std.messaging providers (spec/std-messaging.md "Providers").
7126pub const CHANNEL_PROVIDER_REPORTS: &[ChannelProviderReport] = &[
7127    ChannelProviderReport {
7128        short_name: "fixture",
7129        provider_id: "fixture",
7130        direction: "bidirectional",
7131        identity: "claimed_actor",
7132        interactions: &["buttons", "reactions"],
7133        content: &["text", "markdown"],
7134        delivery_receipts: &["accepted", "failed"],
7135    },
7136    ChannelProviderReport {
7137        short_name: "local",
7138        provider_id: "std.messaging.local",
7139        direction: "bidirectional",
7140        identity: "claimed_actor",
7141        interactions: &["buttons", "reactions"],
7142        content: &["text", "markdown"],
7143        delivery_receipts: &["accepted", "failed"],
7144    },
7145    ChannelProviderReport {
7146        short_name: "desktop",
7147        provider_id: "std.messaging.desktop",
7148        direction: "outbound_only",
7149        identity: "anonymous",
7150        interactions: &[],
7151        content: &["text"],
7152        delivery_receipts: &["accepted", "failed"],
7153    },
7154    ChannelProviderReport {
7155        short_name: "stdio",
7156        provider_id: "std.messaging.stdio",
7157        direction: "bidirectional",
7158        identity: "claimed_actor",
7159        interactions: &["buttons"],
7160        content: &["text", "markdown"],
7161        delivery_receipts: &["accepted", "failed"],
7162    },
7163];
7164
7165/// Resolve a channel's declared `provider <p>` identifier against the v1
7166/// provider reports: the short name (`local`) or the full binding provider id
7167/// (`std.messaging.local`) both resolve. `None` = unknown identifier, a check
7168/// error (spec/std-messaging.md open question 2 resolved: short names resolved
7169/// against contributed provider kinds, unknown = check error).
7170pub fn channel_provider_report(provider: &str) -> Option<&'static ChannelProviderReport> {
7171    CHANNEL_PROVIDER_REPORTS
7172        .iter()
7173        .find(|report| report.short_name == provider || report.provider_id == provider)
7174}
7175
7176/// The built-in resource gauges: deterministic observables already in the
7177/// effect ledger, present without declaration (improve design note §3).
7178/// `std.cache_hit` is the provider prompt-cache hit rate (cache-read tokens /
7179/// total input-side tokens) — present only when the provider reports cache
7180/// usage (spec/inference-cache-note.md G2).
7181pub const BUILTIN_GAUGES: &[&str] = &["std.spend", "std.latency", "std.tokens", "std.cache_hit"];
7182
7183/// The v1 std.files store providers (spec/std-files.md "Providers"): `local`
7184/// is the FileStore host-projection seam (native + DO) and the default when a
7185/// `file store` declares no `provider` clause. Non-filesystem providers
7186/// (S3/GitHub/Drive) are deferred with cause; an unknown identifier is a
7187/// check error at the declaration.
7188pub const FILE_STORE_PROVIDERS: &[&str] = &["local"];
7189
7190fn lower_gauge(gauge: GaugeDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7191    if let Some(existing) = ir.gauges.iter().find(|other| other.name == gauge.name.name) {
7192        diagnostics.push(
7193            Diagnostic {
7194                related: Vec::new(),
7195                span: gauge.name.span,
7196                message: format!("gauge `{}` is declared more than once", gauge.name.name),
7197                suggestion: Some("give each gauge a unique name".to_owned()),
7198            }
7199            .with_related(existing.span, "first declared here"),
7200        );
7201        return;
7202    }
7203    let (judge_kind, judge_target, judge_args) = match &gauge.judge {
7204        GaugeJudge::Coerce(target, args) => ("coerce", target.name.clone(), args.clone()),
7205        GaugeJudge::Prompt(template) => ("prompt", template.value.clone(), Vec::new()),
7206        GaugeJudge::Exec(command) => ("exec", command.value.clone(), Vec::new()),
7207        GaugeJudge::Labels(source) => ("labels", source.value.clone(), Vec::new()),
7208    };
7209    let expect = gauge.expect.as_ref().map(|bar| IrGaugeBar {
7210        form: match &bar.subject {
7211            GaugeBarSubject::Chance { .. } => "chance".to_owned(),
7212            GaugeBarSubject::Stat { .. } => "stat".to_owned(),
7213        },
7214        subject: match &bar.subject {
7215            GaugeBarSubject::Chance { field } => field.name.clone(),
7216            GaugeBarSubject::Stat { stat } => stat.name.clone(),
7217        },
7218        op: if bar.at_least { ">=" } else { "<=" }.to_owned(),
7219        threshold: bar.threshold.clone(),
7220    });
7221    ir.gauges.push(IrGauge {
7222        name: gauge.name.name,
7223        site: gauge.site,
7224        judge_kind: judge_kind.to_owned(),
7225        judge_target,
7226        judge_args,
7227        expect,
7228        inputs: gauge.inputs.into_iter().map(|input| input.name).collect(),
7229        span: gauge.span,
7230    });
7231}
7232
7233fn lower_mark(mark: MarkDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7234    if let Some(existing) = ir.marks.iter().find(|other| other.name == mark.name.value) {
7235        diagnostics.push(
7236            Diagnostic {
7237                related: Vec::new(),
7238                span: mark.name.span,
7239                message: format!("mark `{}` is declared more than once", mark.name.value),
7240                suggestion: Some("give each mark a unique name".to_owned()),
7241            }
7242            .with_related(existing.span, "first declared here"),
7243        );
7244        return;
7245    }
7246    ir.marks.push(IrMark {
7247        name: mark.name.value,
7248        site: mark.site,
7249        span: mark.span,
7250    });
7251}
7252
7253fn lower_campaign(campaign: CampaignDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7254    if let Some(existing) = ir
7255        .campaigns
7256        .iter()
7257        .find(|other| other.name == campaign.name.name)
7258    {
7259        diagnostics.push(
7260            Diagnostic {
7261                related: Vec::new(),
7262                span: campaign.name.span,
7263                message: format!(
7264                    "campaign `{}` is declared more than once",
7265                    campaign.name.name
7266                ),
7267                suggestion: Some("give each campaign a unique name".to_owned()),
7268            }
7269            .with_related(existing.span, "first declared here"),
7270        );
7271        return;
7272    }
7273    ir.campaigns.push(IrCampaign {
7274        name: campaign.name.name,
7275        ascend: campaign
7276            .ascend
7277            .into_iter()
7278            .map(|gauge| gauge.name)
7279            .collect(),
7280        reach: campaign
7281            .reach
7282            .into_iter()
7283            .map(|reach| IrCampaignReach {
7284                gauge: reach.gauge.name,
7285                op: if reach.at_least { ">=" } else { "<=" }.to_owned(),
7286                threshold: reach.threshold,
7287                unit: reach.unit,
7288            })
7289            .collect(),
7290        guard: campaign
7291            .guard
7292            .into_iter()
7293            .map(|guard| IrCampaignGuard {
7294                gauge: guard.gauge.name,
7295                band_percent: guard.band_percent,
7296            })
7297            .collect(),
7298        sacrifice: campaign
7299            .sacrifice
7300            .into_iter()
7301            .map(|gauge| gauge.name)
7302            .collect(),
7303        proposer_redacted: campaign.proposer_redacted,
7304        span: campaign.span,
7305    });
7306}
7307
7308/// Cross-reference validation for the improve surface, run after the item
7309/// loop so declaration order never matters: judge `coerce` targets must
7310/// resolve, derived-gauge inputs and campaign gauge references must name a
7311/// declared gauge or a built-in resource gauge, and a campaign's partition
7312/// must be disjoint (a gauge cannot be both ascended and sacrificed).
7313fn validate_improve_declarations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7314    // A mark rides a committing site: its `after` target must be a rule
7315    // (flow segments have lowered to `flow.<name>.segN` rules by now).
7316    for mark in &ir.marks {
7317        if !ir.rules.iter().any(|rule| rule.name == mark.site) {
7318            diagnostics.push(Diagnostic {
7319                related: Vec::new(),
7320                span: mark.span,
7321                message: format!("mark `{}` rides unknown site `{}`", mark.name, mark.site),
7322                suggestion: Some(format!(
7323                    "declared rules: {}",
7324                    ir.rules
7325                        .iter()
7326                        .map(|rule| rule.name.as_str())
7327                        .collect::<Vec<_>>()
7328                        .join(", ")
7329                )),
7330            });
7331        }
7332    }
7333    let gauge_names: BTreeSet<&str> = ir.gauges.iter().map(|gauge| gauge.name.as_str()).collect();
7334    let resolves = |name: &str| gauge_names.contains(name) || BUILTIN_GAUGES.contains(&name);
7335    let unknown = |name: &str, span: SourceSpan, diagnostics: &mut Vec<Diagnostic>| {
7336        diagnostics.push(Diagnostic {
7337            related: Vec::new(),
7338            span,
7339            message: format!("unknown gauge `{name}`"),
7340            suggestion: Some(format!(
7341                "declare `gauge {name} {{ ... }}` or use a built-in gauge ({})",
7342                BUILTIN_GAUGES.join(", ")
7343            )),
7344        });
7345    };
7346    for gauge in &ir.gauges {
7347        if gauge.judge_kind == "coerce" {
7348            match ir
7349                .coerces
7350                .iter()
7351                .find(|coerce| coerce.name == gauge.judge_target)
7352            {
7353                None => {
7354                    diagnostics.push(Diagnostic {
7355                        related: Vec::new(),
7356                        span: gauge.span,
7357                        message: format!(
7358                            "gauge `{}` judges via undeclared coerce `{}`",
7359                            gauge.name, gauge.judge_target
7360                        ),
7361                        suggestion: Some("declare the coerce this gauge judges with".to_owned()),
7362                    });
7363                }
7364                // Explicit-argument binding (settled 2026-07-14): the
7365                // judge's data diet is written down, never inferred. The
7366                // single reserved `record` passes the whole judge-input
7367                // record to a one-parameter coerce; otherwise each path
7368                // (`input.…` / `facts.<Class>.<field>`) feeds the
7369                // parameter at its position, arity-checked here so a
7370                // drifted signature is a check error, not a silently
7371                // rebound judge.
7372                Some(coerce) if !gauge.judge_args.is_empty() => {
7373                    if gauge.judge_args.len() == 1 && gauge.judge_args[0] == "record" {
7374                        if coerce.params.len() != 1 {
7375                            diagnostics.push(Diagnostic {
7376                                related: Vec::new(),
7377                                span: gauge.span,
7378                                message: format!(
7379                                    "gauge `{}`: the reserved `(record)` form needs a \
7380                                     single-parameter coerce; `{}` takes {}",
7381                                    gauge.name,
7382                                    gauge.judge_target,
7383                                    coerce.params.len()
7384                                ),
7385                                suggestion: Some(
7386                                    "give the coerce one record-shaped parameter, or bind each \
7387                                     parameter to an explicit path"
7388                                        .to_owned(),
7389                                ),
7390                            });
7391                        }
7392                    } else {
7393                        for arg in &gauge.judge_args {
7394                            let head = arg.split('.').next().unwrap_or_default();
7395                            let valid = match head {
7396                                "record" => false, // reserved: only alone
7397                                "input" => true,
7398                                "facts" => arg.splitn(3, '.').count() == 3,
7399                                _ => false,
7400                            };
7401                            if !valid {
7402                                diagnostics.push(Diagnostic {
7403                                    related: Vec::new(),
7404                                    span: gauge.span,
7405                                    message: format!(
7406                                        "gauge `{}`: judge argument `{arg}` is not a record \
7407                                         path",
7408                                        gauge.name
7409                                    ),
7410                                    suggestion: Some(
7411                                        "arguments are `input.<path>`, \
7412                                         `facts.<Class>.<field...>`, or the single reserved \
7413                                         `record`"
7414                                            .to_owned(),
7415                                    ),
7416                                });
7417                            }
7418                        }
7419                        if gauge.judge_args.len() != coerce.params.len() {
7420                            diagnostics.push(Diagnostic {
7421                                related: Vec::new(),
7422                                span: gauge.span,
7423                                message: format!(
7424                                    "gauge `{}`: judge passes {} argument{} but coerce `{}` \
7425                                     takes {}",
7426                                    gauge.name,
7427                                    gauge.judge_args.len(),
7428                                    if gauge.judge_args.len() == 1 { "" } else { "s" },
7429                                    gauge.judge_target,
7430                                    coerce.params.len()
7431                                ),
7432                                suggestion: Some(
7433                                    "bind one path per coerce parameter, in order".to_owned(),
7434                                ),
7435                            });
7436                        }
7437                    }
7438                }
7439                Some(_) => {}
7440            }
7441        }
7442        if !gauge.inputs.is_empty() && gauge.judge_kind != "exec" {
7443            diagnostics.push(Diagnostic {
7444                related: Vec::new(),
7445                span: gauge.span,
7446                message: format!(
7447                    "derived gauge `{}` must judge via exec (its judge receives the input score vector)",
7448                    gauge.name
7449                ),
7450                suggestion: Some("use `judge via exec \"<validator>\"`".to_owned()),
7451            });
7452        }
7453        for input in &gauge.inputs {
7454            if input == &gauge.name {
7455                diagnostics.push(Diagnostic {
7456                    related: Vec::new(),
7457                    span: gauge.span,
7458                    message: format!("derived gauge `{}` cannot input itself", gauge.name),
7459                    suggestion: None,
7460                });
7461            } else if !resolves(input) {
7462                unknown(input, gauge.span, diagnostics);
7463            }
7464        }
7465    }
7466    for campaign in &ir.campaigns {
7467        let mut named: Vec<(&str, &'static str)> = Vec::new();
7468        for name in &campaign.ascend {
7469            named.push((name, "ascend"));
7470        }
7471        for reach in &campaign.reach {
7472            named.push((&reach.gauge, "reach"));
7473        }
7474        for guard in &campaign.guard {
7475            named.push((&guard.gauge, "guard"));
7476        }
7477        for name in &campaign.sacrifice {
7478            named.push((name, "sacrifice"));
7479        }
7480        let mut seen: BTreeMap<&str, &'static str> = BTreeMap::new();
7481        for (name, role) in named {
7482            if !resolves(name) {
7483                unknown(name, campaign.span, diagnostics);
7484            }
7485            if let Some(previous) = seen.insert(name, role) {
7486                let message = if previous == role {
7487                    format!(
7488                        "campaign `{}` names gauge `{name}` twice in {role}",
7489                        campaign.name
7490                    )
7491                } else {
7492                    format!(
7493                        "campaign `{}` names gauge `{name}` as both {previous} and {role}",
7494                        campaign.name
7495                    )
7496                };
7497                diagnostics.push(Diagnostic {
7498                    related: Vec::new(),
7499                    span: campaign.span,
7500                    message,
7501                    suggestion: Some("name each gauge once, in at most one clause".to_owned()),
7502                });
7503            }
7504        }
7505    }
7506}
7507
7508fn lower_harness(harness: HarnessDecl, ir: &mut IrProgram, _diagnostics: &mut [Diagnostic]) {
7509    // Provider/harness kind validity is registry-derived (spec/std-agent.md
7510    // "Open provider registry", DR-0009): the known kind set is contributed by
7511    // package manifests (embedded std + locked third-party), which the parser
7512    // cannot see. The CLI check validates kinds against that registry with the
7513    // M5 missing-package/missing-import diagnostic split; the parser accepts
7514    // the kind structurally.
7515    ir.harnesses.push(IrHarness {
7516        name: harness.name.name,
7517        kind: harness.kind.name,
7518        span: harness.span,
7519    });
7520}
7521
7522/// The harness class (DR-0034). `Managed` = WhippleScript is the agent runtime
7523/// (owned; hermetic context, full provenance, reproducible). `Delegated` = a foreign
7524/// runtime WhippleScript invokes, which assembles its own context. The guarantee is
7525/// two-valued, so the class is too.
7526#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7527pub enum HarnessClass {
7528    Managed,
7529    Delegated,
7530}
7531
7532impl HarnessClass {
7533    pub fn as_str(self) -> &'static str {
7534        match self {
7535            HarnessClass::Managed => "managed",
7536            HarnessClass::Delegated => "delegated",
7537        }
7538    }
7539}
7540
7541/// Classify a harness kind (DR-0034 Decision 6). Total over the supported kinds:
7542/// `owned` and the credential-free `fixture` model client are Managed; every other
7543/// kind (codex/claude sidecars, the `native-fixture` delegated adapter, `command`)
7544/// is Delegated. An unrecognized kind — validated registry-side by the CLI
7545/// (spec/std-agent.md "Open provider registry") — defaults to Delegated, never
7546/// granting the Managed guarantee to something unknown.
7547pub fn harness_class(kind: &str) -> HarnessClass {
7548    match kind {
7549        "owned" | "fixture" => HarnessClass::Managed,
7550        _ => HarnessClass::Delegated,
7551    }
7552}
7553
7554fn lower_agent(
7555    agent: AgentDecl,
7556    ir: &mut IrProgram,
7557    harness_kinds: &BTreeMap<String, String>,
7558    diagnostics: &mut Vec<Diagnostic>,
7559) {
7560    let mut lowered = IrAgent {
7561        name: agent.name.name.clone(),
7562        harness: agent.harness.as_ref().map(|harness| harness.name.clone()),
7563        provider: None,
7564        profile: None,
7565        capacity: None,
7566        skills: Vec::new(),
7567        capabilities: Vec::new(),
7568        requires: Vec::new(),
7569        tools: Vec::new(),
7570        compaction: None,
7571        thread: None,
7572        settings: None,
7573        // Filled after the field loop, once provider/harness are resolved.
7574        harness_class: HarnessClass::Managed,
7575    };
7576
7577    if let Some(harness) = &agent.harness {
7578        if !harness_kinds.contains_key(&harness.name) {
7579            diagnostics.push(Diagnostic {
7580                related: Vec::new(),
7581                span: harness.span,
7582                message: format!(
7583                    "agent `{}` uses unknown harness `{}`",
7584                    agent.name.name, harness.name
7585                ),
7586                suggestion: Some(format!(
7587                    "declare `harness {}: fixture` before using it",
7588                    harness.name
7589                )),
7590            });
7591        }
7592    }
7593
7594    // `delegated to <provider>` (DR-0034 Decision 2): the surface spelling of a
7595    // Delegated agent. It names the provider kind directly, so it must be a kind
7596    // that actually classifies Delegated — `delegated to owned` is a contradiction.
7597    // Kind existence is registry-validated by the CLI (spec/std-agent.md "Open
7598    // provider registry"); only the class contradiction is structural.
7599    if let Some(delegate) = &agent.delegated_to {
7600        if harness_class(&delegate.name) != HarnessClass::Delegated {
7601            diagnostics.push(Diagnostic {
7602                related: Vec::new(),
7603                span: delegate.span,
7604                message: format!(
7605                    "agent `{}` delegates to `{}`, which is a managed kind",
7606                    agent.name.name, delegate.name
7607                ),
7608                suggestion: Some(
7609                    "a plain `agent name { ... }` is managed by default; `delegated to` names a foreign runtime"
7610                        .to_owned(),
7611                ),
7612            });
7613        }
7614    }
7615
7616    // Knob spans held for the class-partition check below (DR-0034 Decision 3) —
7617    // the class is only resolved after the field loop.
7618    let mut compaction_span: Option<SourceSpan> = None;
7619    let mut thread_span: Option<SourceSpan> = None;
7620    let mut settings_span: Option<SourceSpan> = None;
7621
7622    for field in agent.fields {
7623        match field {
7624            AgentField::Provider(provider) => {
7625                if lowered.provider.is_some() {
7626                    diagnostics.push(Diagnostic {
7627                        related: Vec::new(),
7628                        span: provider.span,
7629                        message: format!(
7630                            "agent `{}` declares provider more than once",
7631                            agent.name.name
7632                        ),
7633                        suggestion: Some(
7634                            "keep exactly one `provider` field in the agent block".to_owned(),
7635                        ),
7636                    });
7637                }
7638                if agent.harness.is_some() {
7639                    diagnostics.push(Diagnostic { related: Vec::new(),
7640                        span: provider.span,
7641                        message: format!(
7642                            "agent `{}` declares both `using` harness and direct provider `{}`",
7643                            agent.name.name, provider.name
7644                        ),
7645                        suggestion: Some(
7646                            "use either `agent name using harness { ... }` or `provider codex`, not both"
7647                                .to_owned(),
7648                        ),
7649                    });
7650                }
7651                if agent.delegated_to.is_some() {
7652                    diagnostics.push(Diagnostic { related: Vec::new(),
7653                        span: provider.span,
7654                        message: format!(
7655                            "agent `{}` declares both `delegated to` and direct provider `{}`",
7656                            agent.name.name, provider.name
7657                        ),
7658                        suggestion: Some(
7659                            "use either `agent name delegated to <provider> { ... }` or a `provider` field, not both"
7660                                .to_owned(),
7661                        ),
7662                    });
7663                }
7664                // Provider-kind existence is registry-validated by the CLI
7665                // (spec/std-agent.md "Open provider registry"): the known kind
7666                // set is contributed by package manifests, not compiled in here.
7667                lowered.provider = Some(provider.name);
7668            }
7669            AgentField::Profile(profile) => lowered.profile = Some(profile.value),
7670            AgentField::Capacity(capacity, span) => {
7671                if capacity == 0 {
7672                    diagnostics.push(Diagnostic {
7673                        related: Vec::new(),
7674                        span,
7675                        message: format!(
7676                            "agent `{}` capacity must be greater than zero",
7677                            agent.name.name
7678                        ),
7679                        suggestion: Some("use `capacity 1` or a larger integer".to_owned()),
7680                    });
7681                }
7682                lowered.capacity = Some(capacity);
7683            }
7684            AgentField::Skills(skills, _) => {
7685                let mut seen = BTreeSet::new();
7686                for skill in skills {
7687                    if !seen.insert(skill.value.clone()) {
7688                        diagnostics.push(Diagnostic {
7689                            related: Vec::new(),
7690                            span: skill.span,
7691                            message: format!(
7692                                "agent `{}` attaches skill `{}` more than once",
7693                                agent.name.name, skill.value
7694                            ),
7695                            suggestion: Some("remove the duplicate skill entry".to_owned()),
7696                        });
7697                    }
7698                    lowered.skills.push(skill.value);
7699                }
7700            }
7701            AgentField::Capabilities(capabilities, _) => {
7702                let mut seen = BTreeSet::new();
7703                for capability in capabilities {
7704                    if !seen.insert(capability.value.clone()) {
7705                        diagnostics.push(Diagnostic {
7706                            related: Vec::new(),
7707                            span: capability.span,
7708                            message: format!(
7709                                "agent `{}` declares capability `{}` more than once",
7710                                agent.name.name, capability.value
7711                            ),
7712                            suggestion: Some("remove the duplicate capability entry".to_owned()),
7713                        });
7714                    }
7715                    lowered.capabilities.push(capability.value);
7716                }
7717            }
7718            AgentField::Requires(classes, _) => {
7719                let mut seen = BTreeSet::new();
7720                for class in classes {
7721                    // Taxonomy membership (DR-0015; spec/std-agent.md slice 6):
7722                    // a required class must be a member of the shared
7723                    // feature-class taxonomy. Validation against the selected
7724                    // provider's feature REPORT is the CLI's registry-side
7725                    // check — this is the vocabulary gate.
7726                    if !whipplescript_core::AGENT_FEATURE_CLASS_TAXONOMY
7727                        .contains(&class.name.as_str())
7728                    {
7729                        diagnostics.push(Diagnostic {
7730                            related: Vec::new(),
7731                            span: class.span,
7732                            message: format!(
7733                                "agent `{}` requires unknown feature class `{}`",
7734                                agent.name.name, class.name
7735                            ),
7736                            suggestion: Some(format!(
7737                                "feature classes come from the DR-0015 taxonomy: {}",
7738                                whipplescript_core::AGENT_FEATURE_CLASS_TAXONOMY.join(", ")
7739                            )),
7740                        });
7741                    }
7742                    if !seen.insert(class.name.clone()) {
7743                        diagnostics.push(Diagnostic {
7744                            related: Vec::new(),
7745                            span: class.span,
7746                            message: format!(
7747                                "agent `{}` requires feature class `{}` more than once",
7748                                agent.name.name, class.name
7749                            ),
7750                            suggestion: Some("remove the duplicate requires entry".to_owned()),
7751                        });
7752                    }
7753                    lowered.requires.push(class.name);
7754                }
7755            }
7756            AgentField::Tools(tools, _) => {
7757                let mut seen = BTreeSet::new();
7758                for tool in tools {
7759                    if !seen.insert(tool.name.clone()) {
7760                        diagnostics.push(Diagnostic {
7761                            related: Vec::new(),
7762                            span: tool.span,
7763                            message: format!(
7764                                "agent `{}` grants tool `{}` more than once",
7765                                agent.name.name, tool.name
7766                            ),
7767                            suggestion: Some("remove the duplicate tool entry".to_owned()),
7768                        });
7769                    }
7770                    lowered.tools.push(tool.name);
7771                }
7772            }
7773            AgentField::Compaction(strategy) => {
7774                const STRATEGIES: [&str; 4] = ["summarize", "hard_reset", "tool_results", "none"];
7775                if lowered.compaction.is_some() {
7776                    diagnostics.push(Diagnostic {
7777                        related: Vec::new(),
7778                        span: strategy.span,
7779                        message: format!(
7780                            "agent `{}` declares compaction more than once",
7781                            agent.name.name
7782                        ),
7783                        suggestion: Some("keep exactly one `compaction` field".to_owned()),
7784                    });
7785                }
7786                if !STRATEGIES.contains(&strategy.name.as_str()) {
7787                    diagnostics.push(Diagnostic {
7788                        related: Vec::new(),
7789                        span: strategy.span,
7790                        message: format!(
7791                            "agent `{}` uses unknown compaction strategy `{}`",
7792                            agent.name.name, strategy.name
7793                        ),
7794                        suggestion: Some(
7795                            "supported strategies are `summarize`, `hard_reset`, `tool_results`, and `none`"
7796                                .to_owned(),
7797                        ),
7798                    });
7799                }
7800                compaction_span = Some(strategy.span);
7801                lowered.compaction = Some(strategy.name);
7802            }
7803            AgentField::Thread(mode) => {
7804                const MODES: [&str; 2] = ["continue", "fresh"];
7805                if lowered.thread.is_some() {
7806                    diagnostics.push(Diagnostic {
7807                        related: Vec::new(),
7808                        span: mode.span,
7809                        message: format!(
7810                            "agent `{}` declares thread more than once",
7811                            agent.name.name
7812                        ),
7813                        suggestion: Some("keep exactly one `thread` field".to_owned()),
7814                    });
7815                }
7816                if !MODES.contains(&mode.name.as_str()) {
7817                    diagnostics.push(Diagnostic {
7818                        related: Vec::new(),
7819                        span: mode.span,
7820                        message: format!(
7821                            "agent `{}` uses unknown thread mode `{}`",
7822                            agent.name.name, mode.name
7823                        ),
7824                        suggestion: Some(
7825                            "supported thread modes are `continue` and `fresh`".to_owned(),
7826                        ),
7827                    });
7828                }
7829                thread_span = Some(mode.span);
7830                lowered.thread = Some(mode.name);
7831            }
7832            AgentField::Settings(sources) => {
7833                const SOURCES: [&str; 3] = ["project", "user", "none"];
7834                if lowered.settings.is_some() {
7835                    diagnostics.push(Diagnostic {
7836                        related: Vec::new(),
7837                        span: sources.span,
7838                        message: format!(
7839                            "agent `{}` declares settings more than once",
7840                            agent.name.name
7841                        ),
7842                        suggestion: Some("keep exactly one `settings` field".to_owned()),
7843                    });
7844                }
7845                if !SOURCES.contains(&sources.name.as_str()) {
7846                    diagnostics.push(Diagnostic {
7847                        related: Vec::new(),
7848                        span: sources.span,
7849                        message: format!(
7850                            "agent `{}` uses unknown settings source `{}`",
7851                            agent.name.name, sources.name
7852                        ),
7853                        suggestion: Some(
7854                            "supported settings sources are `project`, `user`, and `none`"
7855                                .to_owned(),
7856                        ),
7857                    });
7858                }
7859                settings_span = Some(sources.span);
7860                lowered.settings = Some(sources.name);
7861            }
7862            AgentField::Unknown { name, .. } => {
7863                diagnostics.push(Diagnostic { related: Vec::new(),
7864                    span: name.span,
7865                    message: format!(
7866                        "unknown agent field `{}` on agent `{}`",
7867                        name.name, agent.name.name
7868                    ),
7869                    suggestion: Some(
7870                        "supported agent fields are `provider`, `profile`, `capacity`, `skills`, `capabilities`, `tools`, `compaction`, and `settings`".to_owned(),
7871                    ),
7872                });
7873            }
7874        }
7875    }
7876
7877    // `delegated to` resolves as the provider binding. Applied after the field
7878    // loop so a duplicate `provider` field reports the `declares both` diagnostic
7879    // above rather than a spurious more-than-once. With no binding at all the
7880    // agent is Managed by default (DR-0034 Decision 2/6: managed is the
7881    // substrate, not a harness one binds).
7882    if lowered.provider.is_none() {
7883        if let Some(delegate) = &agent.delegated_to {
7884            lowered.provider = Some(delegate.name.clone());
7885        } else if lowered.harness.is_none() {
7886            lowered.provider = Some("owned".to_owned());
7887        }
7888    }
7889
7890    // Classify the harness (DR-0034 Decision 1/6): the class is derived from the
7891    // resolved kind — a direct `provider <kind>`, the `delegated to <provider>`
7892    // sugar, or the kind of the bound `using <harness>`. An agent with none of
7893    // these is Managed by default (Decision 6: managed is the substrate).
7894    let resolved_kind = lowered.provider.as_deref().or_else(|| {
7895        lowered
7896            .harness
7897            .as_deref()
7898            .and_then(|name| harness_kinds.get(name).map(String::as_str))
7899    });
7900    lowered.harness_class = resolved_kind
7901        .map(harness_class)
7902        .unwrap_or(HarnessClass::Managed);
7903
7904    // Partition the knobs (DR-0034 Decision 3): each class admits only its
7905    // meaningful knobs; the other class rejects them with a diagnostic rather than
7906    // silently ignoring them. Only enforced when the class actually resolved — an
7907    // agent with no provider binding already gets its own diagnostic below.
7908    if resolved_kind.is_some() {
7909        if lowered.harness_class == HarnessClass::Delegated {
7910            if let Some(span) = compaction_span {
7911                diagnostics.push(Diagnostic {
7912                    related: Vec::new(),
7913                    span,
7914                    message: format!(
7915                        "agent `{}` is delegated; `compaction` is a managed-harness knob",
7916                        agent.name.name
7917                    ),
7918                    suggestion: Some(
7919                        "remove `compaction` — a delegated harness compacts its own context"
7920                            .to_owned(),
7921                    ),
7922                });
7923            }
7924            if let Some(span) = thread_span {
7925                diagnostics.push(Diagnostic {
7926                    related: Vec::new(),
7927                    span,
7928                    message: format!(
7929                        "agent `{}` is delegated; `thread` is a managed-harness knob",
7930                        agent.name.name
7931                    ),
7932                    suggestion: Some(
7933                        "remove `thread` — a delegated harness owns its own conversation state"
7934                            .to_owned(),
7935                    ),
7936                });
7937            }
7938        } else if let Some(span) = settings_span {
7939            diagnostics.push(Diagnostic {
7940                related: Vec::new(),
7941                span,
7942                message: format!(
7943                    "agent `{}` is managed; `settings` is a delegated-harness knob",
7944                    agent.name.name
7945                ),
7946                suggestion: Some(
7947                    "remove `settings` — WhippleScript assembles a managed agent's context"
7948                        .to_owned(),
7949                ),
7950            });
7951        }
7952    }
7953
7954    // Defaults (2026-07-21): a bare `agent <name>` declaration is complete.
7955    // `capacity` defaults to 1 (serial); `profile` defaults to `no-repo`, the
7956    // least-authority builtin (no filesystem, no bash tools) — omitting the
7957    // field can never grant more than declaring one. `provider` was already
7958    // optional (managed by default, DR-0034).
7959    if lowered.profile.is_none() {
7960        lowered.profile = Some("no-repo".to_owned());
7961    }
7962
7963    if lowered.capacity.is_none() {
7964        lowered.capacity = Some(1);
7965    }
7966
7967    ir.agents.push(lowered);
7968}
7969
7970fn lower_enum(enum_decl: EnumDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
7971    let mut variants = BTreeSet::new();
7972    for variant in &enum_decl.variants {
7973        if !variants.insert(variant.name.name.clone()) {
7974            diagnostics.push(Diagnostic {
7975                related: Vec::new(),
7976                span: variant.span,
7977                message: format!(
7978                    "enum `{}` declares variant `{}` more than once",
7979                    enum_decl.name.name, variant.name.name
7980                ),
7981                suggestion: Some(
7982                    "remove the duplicate variant or give it a distinct name".to_owned(),
7983                ),
7984            });
7985        }
7986        // The discriminant is synthesized from the variant name
7987        // (spec/sum-types.md): `variant` is reserved inside variant bodies.
7988        for field in &variant.fields {
7989            if field.name.name == "variant" {
7990                diagnostics.push(Diagnostic {
7991                    related: Vec::new(),
7992                    span: field.name.span,
7993                    message: format!(
7994                        "variant `{}` of enum `{}` declares reserved field `variant`",
7995                        variant.name.name, enum_decl.name.name
7996                    ),
7997                    suggestion: Some(
7998                        "the discriminant is synthesized from the variant name; rename the field"
7999                            .to_owned(),
8000                    ),
8001                });
8002            }
8003        }
8004    }
8005
8006    // Each data-carrying variant lowers to a generated `<Enum>.<Variant>`
8007    // class holding the literal `variant` discriminant plus its payload
8008    // (spec/sum-types.md). The dotted name is unreachable from user source.
8009    for variant in &enum_decl.variants {
8010        if variant.fields.is_empty() {
8011            continue;
8012        }
8013        let mut fields = vec![IrClassField {
8014            name: "variant".to_owned(),
8015            ty: IrType::LiteralString(variant.name.name.clone()),
8016            is_key: false,
8017            presence_condition: None,
8018            span: variant.name.span,
8019        }];
8020        fields.extend(variant.fields.iter().map(|field| IrClassField {
8021            name: field.name.name.clone(),
8022            ty: lower_type(field.ty.clone()),
8023            is_key: false,
8024            presence_condition: field.presence_condition.clone(),
8025            span: field.span,
8026        }));
8027        ir.schemas.push(IrSchema::Class(IrClass {
8028            name: format!("{}.{}", enum_decl.name.name, variant.name.name),
8029            fields,
8030            span: variant.span,
8031        }));
8032    }
8033
8034    ir.schemas.push(IrSchema::Enum(IrEnum {
8035        name: enum_decl.name.name,
8036        variants: enum_decl
8037            .variants
8038            .into_iter()
8039            .map(|variant| variant.name.name)
8040            .collect(),
8041        span: enum_decl.span,
8042    }));
8043}
8044
8045fn validate_test_expr_source(
8046    label: &str,
8047    source: &str,
8048    span: SourceSpan,
8049    diagnostics: &mut Vec<Diagnostic>,
8050) {
8051    if source.trim().is_empty() {
8052        diagnostics.push(Diagnostic {
8053            related: Vec::new(),
8054            span,
8055            message: format!("{label} is empty"),
8056            suggestion: Some("provide an expression".to_owned()),
8057        });
8058        return;
8059    }
8060    if let Err(error) = parse_expression(source) {
8061        diagnostics.push(Diagnostic {
8062            related: Vec::new(),
8063            span,
8064            message: format!("{label} is not a valid expression: {error}"),
8065            suggestion: None,
8066        });
8067    }
8068}
8069
8070fn lower_test(test: TestDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8071    if ir
8072        .tests
8073        .iter()
8074        .any(|existing| existing.name == test.name.value)
8075    {
8076        diagnostics.push(Diagnostic {
8077            related: Vec::new(),
8078            span: test.name.span,
8079            message: format!("test `{}` is declared more than once", test.name.value),
8080            suggestion: Some("give each test scenario a distinct name".to_owned()),
8081        });
8082    }
8083    if !test
8084        .clauses
8085        .iter()
8086        .any(|clause| matches!(clause, TestClause::Expect(_)))
8087    {
8088        diagnostics.push(Diagnostic {
8089            related: Vec::new(),
8090            span: test.span,
8091            message: format!("test `{}` has no `expect` clause", test.name.value),
8092            suggestion: Some("a test must assert at least one expected outcome".to_owned()),
8093        });
8094    }
8095    // Validate that captured expression source (given field values, projection
8096    // predicates) parses, so `whip check` catches malformed test scenarios.
8097    for clause in &test.clauses {
8098        match clause {
8099            TestClause::Given(
8100                GivenClause::Input { fields, .. }
8101                | GivenClause::Fact { fields, .. }
8102                | GivenClause::Signal { fields, .. },
8103            ) => {
8104                for field in fields {
8105                    validate_test_expr_source(
8106                        &format!("given field `{}`", field.name.name),
8107                        &field.value,
8108                        field.span,
8109                        diagnostics,
8110                    );
8111                }
8112            }
8113            TestClause::Expect(ExpectClause {
8114                target: ExpectTarget::Projection(query),
8115                ..
8116            }) => match &query.kind {
8117                ProjQueryKind::Count { predicate, .. } | ProjQueryKind::Where { predicate } => {
8118                    validate_test_expr_source(
8119                        &format!("predicate on `{}`", query.noun),
8120                        predicate,
8121                        query.span,
8122                        diagnostics,
8123                    );
8124                }
8125                ProjQueryKind::Exists => {}
8126            },
8127            _ => {}
8128        }
8129    }
8130    ir.tests.push(IrTest {
8131        name: test.name.value,
8132        workflow: test.workflow.map(|identifier| identifier.name),
8133        clauses: test.clauses,
8134        span: test.span,
8135    });
8136}
8137
8138fn lower_source(source: SourceDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8139    if ir
8140        .sources
8141        .iter()
8142        .any(|existing| existing.name == source.name.name)
8143    {
8144        diagnostics.push(Diagnostic {
8145            related: Vec::new(),
8146            span: source.name.span,
8147            message: format!("source `{}` is declared more than once", source.name.name),
8148            suggestion: Some("remove the duplicate source declaration".to_owned()),
8149        });
8150    }
8151    // Clock-source static checks (spec/std-time.md): a recurring schedule must
8152    // declare a missed policy (no silent default), and a calendar schedule should
8153    // declare a timezone.
8154    if let Some(clock) = &source.clock {
8155        let recurring = !matches!(clock.recurrence, Recurrence::At { .. });
8156        if recurring && clock.missed.is_none() {
8157            diagnostics.push(Diagnostic {
8158                related: Vec::new(),
8159                span: clock.span,
8160                message: format!(
8161                    "recurring source `{}` must declare a `missed` policy",
8162                    source.name.name
8163                ),
8164                suggestion: Some(
8165                    "add `missed skip`, `missed coalesce`, or `missed catch_up limit N`".to_owned(),
8166                ),
8167            });
8168        }
8169        if matches!(clock.recurrence, Recurrence::EveryCalendar { .. }) && clock.timezone.is_none()
8170        {
8171            diagnostics.push(Diagnostic { related: Vec::new(),
8172                span: clock.span,
8173                message: format!(
8174                    "calendar source `{}` should declare a `timezone`",
8175                    source.name.name
8176                ),
8177                suggestion: Some(
8178                    "add `timezone \"America/New_York\"`; a calendar schedule without one defaults to UTC".to_owned(),
8179                ),
8180            });
8181        }
8182    }
8183    // File-source static checks (spec/std-ingress.md "Static checks" #3,
8184    // closed clause set): a `file` source reads exactly one of a declared
8185    // `path` (line mode) or `watch` glob (occurrence mode); both clauses are
8186    // meaningful only for `file` (rejecting them elsewhere keeps provider
8187    // intent unambiguous, mirroring the clock-only-clause rejection).
8188    let is_file = source.provider.name == "file";
8189    if is_file && source.path.is_none() && source.watch.is_none() {
8190        diagnostics.push(Diagnostic {
8191            related: Vec::new(),
8192            span: source.span,
8193            message: format!(
8194                "`file` source `{}` requires a `path` or `watch` clause",
8195                source.name.name
8196            ),
8197            suggestion: Some(
8198                "add `path \"./inbox.txt\"` (one signal per line) or `watch \"./drops/*.json\"` \
8199                 (one signal per new file-content occurrence)"
8200                    .to_owned(),
8201            ),
8202        });
8203    }
8204    if is_file && source.path.is_some() && source.watch.is_some() {
8205        diagnostics.push(Diagnostic {
8206            related: Vec::new(),
8207            span: source
8208                .watch
8209                .as_ref()
8210                .map(|watch| watch.span)
8211                .unwrap_or(source.span),
8212            message: format!(
8213                "`file` source `{}` declares both `path` and `watch`; the modes are exclusive",
8214                source.name.name
8215            ),
8216            suggestion: Some(
8217                "keep `path` for line-by-line admission or `watch` for per-file-content \
8218                 occurrences, not both"
8219                    .to_owned(),
8220            ),
8221        });
8222    }
8223    if !is_file {
8224        if let Some(path) = &source.path {
8225            diagnostics.push(Diagnostic {
8226                related: Vec::new(),
8227                span: path.span,
8228                message: format!(
8229                    "source `{}` declares a `path` clause but its provider is `{}`, not `file`",
8230                    source.name.name, source.provider.name
8231                ),
8232                suggestion: Some(
8233                    "use `source file as ...` for a `path`, or remove the clause".to_owned(),
8234                ),
8235            });
8236        }
8237        if let Some(watch) = &source.watch {
8238            diagnostics.push(Diagnostic {
8239                related: Vec::new(),
8240                span: watch.span,
8241                message: format!(
8242                    "source `{}` declares a `watch` clause but its provider is `{}`, not `file`",
8243                    source.name.name, source.provider.name
8244                ),
8245                suggestion: Some(
8246                    "use `source file as ...` for a `watch` glob, or remove the clause".to_owned(),
8247                ),
8248            });
8249        }
8250    }
8251    // HTTP-source static checks: an `http` source GETs a declared url, and the
8252    // `url` clause is meaningful only for `http` (rejecting it elsewhere keeps
8253    // provider intent unambiguous, mirroring the file-only-clause rejection).
8254    let is_http = source.provider.name == "http";
8255    if is_http && source.url.is_none() {
8256        diagnostics.push(Diagnostic {
8257            related: Vec::new(),
8258            span: source.span,
8259            message: format!(
8260                "`http` source `{}` requires a `url` clause",
8261                source.name.name
8262            ),
8263            suggestion: Some("add `url \"https://example.com/feed.json\"`".to_owned()),
8264        });
8265    }
8266    // The runtime GETs the url with an HTTP client, so it must be an absolute
8267    // http(s) URL — catch a scheme-less or wrong-scheme url at `whip check`
8268    // rather than as a runtime request error.
8269    if is_http {
8270        if let Some(url) = &source.url {
8271            let scheme_ok = url.value.starts_with("http://") || url.value.starts_with("https://");
8272            if !scheme_ok {
8273                diagnostics.push(Diagnostic {
8274                    related: Vec::new(),
8275                    span: url.span,
8276                    message: format!(
8277                        "`http` source `{}` url `{}` is not an absolute http(s) URL",
8278                        source.name.name, url.value
8279                    ),
8280                    suggestion: Some(
8281                        "use an absolute `http://` or `https://` URL the runtime can GET"
8282                            .to_owned(),
8283                    ),
8284                });
8285            }
8286        }
8287    }
8288    if !is_http {
8289        if let Some(url) = &source.url {
8290            diagnostics.push(Diagnostic {
8291                related: Vec::new(),
8292                span: url.span,
8293                message: format!(
8294                    "source `{}` declares a `url` clause but its provider is `{}`, not `http`",
8295                    source.name.name, source.provider.name
8296                ),
8297                suggestion: Some(
8298                    "use `source http as ...` for a `url`, or remove the clause".to_owned(),
8299                ),
8300            });
8301        }
8302    }
8303    let is_clock = source.clock.is_some();
8304    let is_file = source.provider.name == "file";
8305    let is_http = source.provider.name == "http";
8306    // `dedup <observe>.<field>` static checks (spec/std-ingress.md I2a):
8307    // meaningful only where a provider delivery identity can replace the
8308    // positional ordinal — `file` line mode and `http`. A `watch` source is
8309    // already content-keyed, and clock/cli sources carry their own identity.
8310    // The value must be a one-segment path off the observation binding naming
8311    // a known observation field (the same rule the emit mapping resolves by,
8312    // `clock_emit_payload`), else the runtime key would be silently null.
8313    let mut dedup_field = None;
8314    if let Some(dedup) = &source.dedup {
8315        let span = match dedup {
8316            SourceValue::Path { span, .. } => *span,
8317            SourceValue::String(literal) => literal.span,
8318            SourceValue::Number(_, span) => *span,
8319        };
8320        if !(is_http || is_file && source.watch.is_none()) {
8321            diagnostics.push(Diagnostic {
8322                related: Vec::new(),
8323                span,
8324                message: format!(
8325                    "source `{}` declares a `dedup` clause but its provider is `{}`{}",
8326                    source.name.name,
8327                    source.provider.name,
8328                    if is_file {
8329                        " in `watch` mode, which is already content-keyed"
8330                    } else {
8331                        "; `dedup` applies to `file` (line mode) and `http` sources"
8332                    }
8333                ),
8334                suggestion: Some("remove the `dedup` clause".to_owned()),
8335            });
8336        } else {
8337            match dedup {
8338                SourceValue::Path {
8339                    binding, segments, ..
8340                } if binding.name == source.observe_binding.name && segments.len() == 1 => {
8341                    dedup_field = Some(segments[0].name.clone());
8342                }
8343                _ => {
8344                    diagnostics.push(Diagnostic {
8345                        related: Vec::new(),
8346                        span,
8347                        message: format!(
8348                            "source `{}` `dedup` must name one observation field off the \
8349                             `observe` binding (e.g. `dedup {}.line`)",
8350                            source.name.name, source.observe_binding.name
8351                        ),
8352                        suggestion: Some(format!(
8353                            "the observation binding is `{}` (declared by `observe as {}`)",
8354                            source.observe_binding.name, source.observe_binding.name
8355                        )),
8356                    });
8357                }
8358            }
8359        }
8360    }
8361    let path = source.path.as_ref().map(|literal| literal.value.clone());
8362    let watch = source.watch.as_ref().map(|literal| literal.value.clone());
8363    let url = source.url.as_ref().map(|literal| literal.value.clone());
8364    let recurrence = source.clock.as_ref().map(|clock| clock.recurrence.clone());
8365    let timezone = source
8366        .clock
8367        .as_ref()
8368        .and_then(|clock| clock.timezone.as_ref().map(|tz| tz.value.clone()));
8369    let missed = source.clock.as_ref().and_then(|clock| clock.missed);
8370    ir.sources.push(IrSource {
8371        name: source.name.name,
8372        provider: source.provider.name,
8373        is_clock,
8374        is_file,
8375        is_http,
8376        recurrence,
8377        timezone,
8378        missed,
8379        path,
8380        watch,
8381        url,
8382        dedup_field,
8383        observe_binding: source.observe_binding.name,
8384        emit_signal: source.emit.signal,
8385        emit_from: source.emit.from.as_ref().map(|ident| ident.name.clone()),
8386        emit_fields: source
8387            .emit
8388            .fields
8389            .into_iter()
8390            .map(|field| IrSourceEmitField {
8391                name: field.name.name,
8392                value: field.value,
8393                span: field.span,
8394            })
8395            .collect(),
8396        span: source.span,
8397    });
8398}
8399
8400fn lower_event(event: EventDecl, ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
8401    if ir.events.iter().any(|existing| existing.name == event.name) {
8402        diagnostics.push(Diagnostic {
8403            related: Vec::new(),
8404            span: event.name_span,
8405            message: format!("signal `{}` is declared more than once", event.name),
8406            suggestion: Some("remove the duplicate signal declaration".to_owned()),
8407        });
8408    }
8409    let mut fields = BTreeSet::new();
8410    for field in &event.fields {
8411        if !fields.insert(field.name.name.clone()) {
8412            diagnostics.push(Diagnostic {
8413                related: Vec::new(),
8414                span: field.name.span,
8415                message: format!(
8416                    "signal `{}` declares field `{}` more than once",
8417                    event.name, field.name.name
8418                ),
8419                suggestion: Some(
8420                    "remove the duplicate field or give it a distinct name".to_owned(),
8421                ),
8422            });
8423        }
8424    }
8425    validate_presence_conditions(&event.name, &event.fields, diagnostics);
8426
8427    ir.events.push(IrEvent {
8428        name: event.name,
8429        fields: event
8430            .fields
8431            .into_iter()
8432            .map(|field| IrClassField {
8433                name: field.name.name,
8434                ty: lower_type(field.ty),
8435                is_key: false,
8436                presence_condition: field.presence_condition,
8437                span: field.span,
8438            })
8439            .collect(),
8440        span: event.span,
8441    });
8442}
8443
8444/// The string-literal values of a literal-union (or single-literal) type, or `None`
8445/// if the type is not a pure string-literal union. Used to validate Family B
8446/// discriminants.
8447fn literal_union_values(ty: &TypeSyntax) -> Option<Vec<String>> {
8448    match ty {
8449        TypeSyntax::LiteralString { value, .. } => Some(vec![value.clone()]),
8450        TypeSyntax::Union { variants, .. } => {
8451            let values = variants
8452                .iter()
8453                .filter_map(|variant| match variant {
8454                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
8455                    _ => None,
8456                })
8457                .collect::<Vec<_>>();
8458            (!values.is_empty() && values.len() == variants.len()).then_some(values)
8459        }
8460        _ => None,
8461    }
8462}
8463
8464/// Family B validation (spec/decision-records/discriminated-families-design.md §6.3):
8465/// every `<field> <T> when <disc> is "<lit>"` must name a same-schema discriminant
8466/// that is a string-literal union, and `<lit>` must be one of its values.
8467fn validate_presence_conditions(
8468    container: &str,
8469    fields: &[ClassField],
8470    diagnostics: &mut Vec<Diagnostic>,
8471) {
8472    for field in fields {
8473        let Some((disc, literal)) = &field.presence_condition else {
8474            continue;
8475        };
8476        let Some(disc_field) = fields.iter().find(|candidate| &candidate.name.name == disc) else {
8477            diagnostics.push(Diagnostic {
8478                related: Vec::new(),
8479                span: field.span,
8480                message: format!(
8481                    "`{container}` field `{}` is conditioned on unknown discriminant `{disc}`",
8482                    field.name.name
8483                ),
8484                suggestion: Some(
8485                    "`when <field> is \"...\"` must name a literal-union field of the same schema"
8486                        .to_owned(),
8487                ),
8488            });
8489            continue;
8490        };
8491        match literal_union_values(&disc_field.ty) {
8492            Some(values) if values.iter().any(|value| value == literal) => {}
8493            Some(values) => diagnostics.push(Diagnostic {
8494                related: Vec::new(),
8495                span: field.span,
8496                message: format!(
8497                    "`{container}` field `{}` is conditioned on `{disc} is \"{literal}\"`, which is not a value of `{disc}`",
8498                    field.name.name
8499                ),
8500                suggestion: Some(format!("use one of: {}", values.join(", "))),
8501            }),
8502            None => diagnostics.push(Diagnostic {
8503                related: Vec::new(),
8504                span: field.span,
8505                message: format!(
8506                    "`{container}` field `{}` is conditioned on `{disc}`, which is not a string-literal discriminant",
8507                    field.name.name
8508                ),
8509                suggestion: Some(
8510                    "the discriminant must be a string-literal union, e.g. `kind \"a\" | \"b\"`"
8511                        .to_owned(),
8512                ),
8513            }),
8514        }
8515    }
8516}
8517
8518fn lower_class(
8519    class_decl: ClassDecl,
8520    ir: &mut IrProgram,
8521    schema_names: &BTreeSet<String>,
8522    agent_names: &BTreeSet<String>,
8523    diagnostics: &mut Vec<Diagnostic>,
8524) {
8525    let mut fields = BTreeSet::new();
8526    for field in &class_decl.fields {
8527        if !fields.insert(field.name.name.clone()) {
8528            diagnostics.push(Diagnostic {
8529                related: Vec::new(),
8530                span: field.name.span,
8531                message: format!(
8532                    "class `{}` declares field `{}` more than once",
8533                    class_decl.name.name, field.name.name
8534                ),
8535                suggestion: Some(
8536                    "remove the duplicate field or give it a distinct name".to_owned(),
8537                ),
8538            });
8539        }
8540        validate_type_refs(&field.ty, schema_names, agent_names, diagnostics);
8541    }
8542
8543    // v0 allows a single natural key per class (import keys one fact per row).
8544    let key_fields = class_decl
8545        .fields
8546        .iter()
8547        .filter(|field| field.is_key)
8548        .collect::<Vec<_>>();
8549    if key_fields.len() > 1 {
8550        for field in key_fields.into_iter().skip(1) {
8551            diagnostics.push(Diagnostic {
8552                related: Vec::new(),
8553                span: field.span,
8554                message: format!(
8555                    "class `{}` declares more than one `@key` field",
8556                    class_decl.name.name
8557                ),
8558                suggestion: Some("a class has at most one `@key` natural key in v0".to_owned()),
8559            });
8560        }
8561    }
8562
8563    validate_presence_conditions(&class_decl.name.name, &class_decl.fields, diagnostics);
8564
8565    ir.schemas.push(IrSchema::Class(IrClass {
8566        name: class_decl.name.name,
8567        span: class_decl.span,
8568        fields: class_decl
8569            .fields
8570            .into_iter()
8571            .map(|field| IrClassField {
8572                name: field.name.name,
8573                ty: lower_type(field.ty),
8574                is_key: field.is_key,
8575                presence_condition: field.presence_condition,
8576                span: field.span,
8577            })
8578            .collect(),
8579    }));
8580}
8581
8582fn lower_table(
8583    table: TableDecl,
8584    semantic: &SemanticContext,
8585    workflow_contract_names: &WorkflowContractNames,
8586    ir: &mut IrProgram,
8587    diagnostics: &mut Vec<Diagnostic>,
8588) {
8589    if !semantic.schemas.class_exists(&table.schema.name) {
8590        diagnostics.push(Diagnostic {
8591            related: Vec::new(),
8592            span: table.schema.span,
8593            message: format!(
8594                "table `{}` targets unknown class `{}`",
8595                table.name.name, table.schema.name
8596            ),
8597            suggestion: Some("declare the class before seeding rows for it".to_owned()),
8598        });
8599        return;
8600    }
8601
8602    if table.rows.is_empty() {
8603        diagnostics.push(Diagnostic {
8604            related: Vec::new(),
8605            span: table.span,
8606            message: format!("table `{}` has no rows", table.name.name),
8607            suggestion: Some("add at least one `{ ... }` row".to_owned()),
8608        });
8609        return;
8610    }
8611
8612    let mut body = String::new();
8613    for row in &table.rows {
8614        push_line(&mut body, format!("record {} {{", table.schema.name));
8615        push_block_body(&row.body.text, &mut body);
8616        push_line(&mut body, "}");
8617        body.push('\n');
8618    }
8619    if body.ends_with('\n') {
8620        body.pop();
8621    }
8622
8623    let rule = RuleDecl {
8624        name: Ident {
8625            name: format!("table_{}", table.name.name),
8626            span: table.name.span,
8627        },
8628        tags: Vec::new(),
8629        description: None,
8630        whens: vec![WhenClause {
8631            text: "started".to_owned(),
8632            span: table.name.span,
8633        }],
8634        body: BlockSource {
8635            text: body,
8636            span: table.span,
8637        },
8638        span: table.span,
8639    };
8640
8641    let record_sources = table
8642        .rows
8643        .iter()
8644        .map(|row| IrRecordSource {
8645            schema: table.schema.name.clone(),
8646            construct: "table_row".to_owned(),
8647            span: row.span,
8648        })
8649        .collect::<Vec<_>>();
8650
8651    let rule_name = rule.name.name.clone();
8652    lower_rule(rule, semantic, workflow_contract_names, ir, diagnostics);
8653    if let Some(rule) = ir
8654        .rules
8655        .iter_mut()
8656        .rev()
8657        .find(|rule| rule.name == rule_name)
8658    {
8659        rule.metadata.record_sources = record_sources;
8660    }
8661}
8662
8663/// The coerce body is a clause list — `prompt` (single or multi-line) and
8664/// `provider <name>` — not free text. Reject anything else so a typo'd
8665/// `promt` (which would otherwise silently produce a coercion with NO prompt)
8666/// or a stray field fails at `check`, matching the agent-block posture.
8667fn validate_coerce_body_fields(coerce: &CoerceDecl, diagnostics: &mut Vec<Diagnostic>) {
8668    let mut in_prompt = false;
8669    let mut awaiting_opener = false;
8670    for line in coerce.body.text.lines() {
8671        let trimmed = line.trim();
8672        if in_prompt {
8673            // A line with an odd number of `"""` markers closes the prompt.
8674            if trimmed.matches("\"\"\"").count() % 2 == 1 {
8675                in_prompt = false;
8676            }
8677            continue;
8678        }
8679        if awaiting_opener {
8680            // Bare `prompt` on its own line: the opener is the next
8681            // non-empty line.
8682            if trimmed.is_empty() {
8683                continue;
8684            }
8685            awaiting_opener = false;
8686            if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
8687                if after_opener.matches("\"\"\"").count() % 2 == 0 {
8688                    in_prompt = true;
8689                }
8690                continue;
8691            }
8692            // fall through: not an opener — validate as a clause line
8693        }
8694        if trimmed.is_empty() || trimmed.starts_with('#') {
8695            continue;
8696        }
8697        if trimmed == "prompt" {
8698            awaiting_opener = true;
8699            continue;
8700        }
8701        if let Some(rest) = trimmed.strip_prefix("prompt ") {
8702            let rest = rest.trim_start();
8703            if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
8704                // `prompt """…` (optionally annotated): multi-line unless the
8705                // triple quote closes on the same line.
8706                if after_opener.matches("\"\"\"").count() % 2 == 0 {
8707                    in_prompt = true;
8708                }
8709            }
8710            // Single-quoted one-line prompts close on their own line.
8711            continue;
8712        }
8713        if let Some(rest) = trimmed.strip_prefix("provider ") {
8714            if rest.split_whitespace().count() != 1 {
8715                diagnostics.push(Diagnostic {
8716                    related: Vec::new(),
8717                    span: coerce.name.span,
8718                    message: format!(
8719                        "coerce `{}` has a malformed `provider` clause: `{trimmed}`",
8720                        coerce.name.name
8721                    ),
8722                    suggestion: Some("write `provider <name>`".to_owned()),
8723                });
8724            }
8725            continue;
8726        }
8727        let field = trimmed.split_whitespace().next().unwrap_or(trimmed);
8728        diagnostics.push(Diagnostic {
8729            related: Vec::new(),
8730            span: coerce.name.span,
8731            message: format!(
8732                "unknown coerce field `{field}` on coerce `{}`",
8733                coerce.name.name
8734            ),
8735            suggestion: Some("supported coerce fields are `prompt` and `provider`".to_owned()),
8736        });
8737    }
8738}
8739
8740fn lower_coerce(
8741    coerce: CoerceDecl,
8742    ir: &mut IrProgram,
8743    schema_names: &BTreeSet<String>,
8744    agent_names: &BTreeSet<String>,
8745    diagnostics: &mut Vec<Diagnostic>,
8746) {
8747    let mut params = BTreeSet::new();
8748    for param in &coerce.params {
8749        if !params.insert(param.name.name.clone()) {
8750            diagnostics.push(Diagnostic {
8751                related: Vec::new(),
8752                span: param.name.span,
8753                message: format!(
8754                    "coerce `{}` declares parameter `{}` more than once",
8755                    coerce.name.name, param.name.name
8756                ),
8757                suggestion: Some(
8758                    "remove the duplicate parameter or give it a distinct name".to_owned(),
8759                ),
8760            });
8761        }
8762        validate_type_refs(&param.ty, schema_names, agent_names, diagnostics);
8763    }
8764    validate_type_refs(&coerce.output, schema_names, agent_names, diagnostics);
8765    validate_coerce_prompt_content_type_annotations(&coerce, diagnostics);
8766    validate_coerce_body_fields(&coerce, diagnostics);
8767
8768    ir.coerces.push(IrCoerce {
8769        name: coerce.name.name,
8770        params: coerce
8771            .params
8772            .into_iter()
8773            .map(|param| IrParam {
8774                name: param.name.name,
8775                ty: lower_type(param.ty),
8776            })
8777            .collect(),
8778        output: lower_type(coerce.output),
8779        body: coerce.body.text,
8780    });
8781}
8782
8783fn validate_type_refs(
8784    ty: &TypeSyntax,
8785    schema_names: &BTreeSet<String>,
8786    agent_names: &BTreeSet<String>,
8787    diagnostics: &mut Vec<Diagnostic>,
8788) {
8789    match ty {
8790        TypeSyntax::Primitive { .. } | TypeSyntax::LiteralString { .. } => {}
8791        TypeSyntax::Ref { name } => {
8792            if !schema_names.contains(&name.name) && !is_builtin_schema_ref(&name.name) {
8793                diagnostics.push(Diagnostic {
8794                    related: Vec::new(),
8795                    span: name.span,
8796                    message: format!("unknown schema reference `{}`", name.name),
8797                    suggestion: Some(format!(
8798                        "declare `class {}` or `enum {}` before using it",
8799                        name.name, name.name
8800                    )),
8801                });
8802            }
8803        }
8804        TypeSyntax::AgentRef { agents, .. } => {
8805            let mut seen = BTreeSet::new();
8806            for agent in agents {
8807                if !seen.insert(agent.name.clone()) {
8808                    diagnostics.push(Diagnostic {
8809                        related: Vec::new(),
8810                        span: agent.span,
8811                        message: format!("AgentRef lists agent `{}` more than once", agent.name),
8812                        suggestion: Some(
8813                            "remove the duplicate agent from the AgentRef domain".to_owned(),
8814                        ),
8815                    });
8816                }
8817                if !agent_names.contains(&agent.name) {
8818                    diagnostics.push(Diagnostic {
8819                        related: Vec::new(),
8820                        span: agent.span,
8821                        message: format!("AgentRef references unknown agent `{}`", agent.name),
8822                        suggestion: Some(format!(
8823                            "declare `agent {}` before using it in AgentRef",
8824                            agent.name
8825                        )),
8826                    });
8827                }
8828            }
8829        }
8830        TypeSyntax::Optional { inner, .. }
8831        | TypeSyntax::Array { inner, .. }
8832        | TypeSyntax::Map { inner, .. } => {
8833            validate_type_refs(inner, schema_names, agent_names, diagnostics)
8834        }
8835        TypeSyntax::Union { variants, .. } => {
8836            for variant in variants {
8837                validate_type_refs(variant, schema_names, agent_names, diagnostics);
8838            }
8839        }
8840    }
8841}
8842
8843fn is_builtin_schema_ref(name: &str) -> bool {
8844    matches!(
8845        name,
8846        "AgentTurn"
8847            | "WorkItem"
8848            | "Evidence"
8849            | "TerminalFailed"
8850            | "TerminalTimedOut"
8851            | "TerminalCancelled"
8852            | "TerminalOutcome"
8853    )
8854}
8855
8856/// The terminal-family schemas are `origin = observer` (discriminated-families
8857/// design §5.4): the kernel projects them when it observes an effect or child
8858/// terminal, and user rules may only *eliminate* them (`after … fails/times
8859/// out/cancels as f`), never *construct* them. A rule that `record`s one would
8860/// forge a terminal outcome the kernel never produced, misleading the
8861/// `after`/terminal-case reaction machinery. Rejected at check time.
8862fn is_observer_only_schema(name: &str) -> bool {
8863    matches!(
8864        name,
8865        "TerminalFailed" | "TerminalTimedOut" | "TerminalCancelled" | "TerminalOutcome"
8866    )
8867}
8868
8869fn lower_rule(
8870    rule: RuleDecl,
8871    semantic: &SemanticContext,
8872    workflow_contract_names: &WorkflowContractNames,
8873    ir: &mut IrProgram,
8874    diagnostics: &mut Vec<Diagnostic>,
8875) {
8876    validate_canonical_rule_body_syntax(&rule, diagnostics);
8877    let metadata = analyze_rule(&rule, semantic, diagnostics);
8878    validate_workflow_terminal_actions(
8879        &rule,
8880        semantic,
8881        &binding_types_for_rule(&rule),
8882        &known_roots_for_rule(&rule),
8883        workflow_contract_names,
8884        diagnostics,
8885    );
8886    validate_effectful_self_trigger(&rule, &metadata, diagnostics);
8887    validate_send_channels(&rule, semantic, diagnostics);
8888    validate_message_from_channels(&rule, semantic, diagnostics);
8889    validate_evidence_fact_not_matched(&rule, diagnostics);
8890    validate_turn_access_grants(&rule, &metadata, diagnostics);
8891    ir.rules.push(IrRule {
8892        name: rule.name.name,
8893        whens: rule.whens.into_iter().map(lower_when_clause).collect(),
8894        body: rule.body.text,
8895        metadata,
8896    });
8897}
8898
8899fn lower_when_clause(when: WhenClause) -> IrWhen {
8900    let source = when.text;
8901    let (pattern, guard_source) = split_when_guard(&source);
8902    let pattern = pattern.to_owned();
8903    let guard = guard_source.and_then(|guard_source| {
8904        let guard_offset = source.find(guard_source).unwrap_or(0);
8905        lower_expression(
8906            guard_source,
8907            SourceSpan {
8908                start: when.span.start + guard_offset,
8909                end: when.span.start + guard_offset + guard_source.len(),
8910            },
8911        )
8912    });
8913    IrWhen {
8914        source,
8915        pattern,
8916        guard,
8917        span: when.span,
8918    }
8919}
8920
8921fn validate_canonical_rule_body_syntax(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
8922    for line in rule.body.text.lines().map(str::trim) {
8923        if line.starts_with("then ") {
8924            diagnostics.push(Diagnostic {
8925                related: Vec::new(),
8926                span: rule.body.span,
8927                message: format!(
8928                    "rule `{}` uses unsupported `then` sequencing",
8929                    rule.name.name
8930                ),
8931                suggestion: Some(
8932                    "use `after <effect> succeeds { ... }` blocks for effect sequencing".to_owned(),
8933                ),
8934            });
8935        }
8936        if line.starts_with("after ") && line.contains("=>") {
8937            diagnostics.push(Diagnostic {
8938                related: Vec::new(),
8939                span: rule.body.span,
8940                message: format!(
8941                    "rule `{}` uses unsupported `after ... =>` sequencing",
8942                    rule.name.name
8943                ),
8944                suggestion: Some("write `after <effect> succeeds { ... }`".to_owned()),
8945            });
8946        }
8947    }
8948}
8949
8950fn build_rule_dependencies(rules: &[IrRule]) -> Vec<IrRuleDependency> {
8951    let mut dependencies = Vec::new();
8952    for producer in rules {
8953        for produced_fact in &producer.metadata.fact_writes {
8954            for consumer in rules {
8955                if consumer.metadata.fact_reads.contains(produced_fact) {
8956                    dependencies.push(IrRuleDependency {
8957                        producer: producer.name.clone(),
8958                        consumer: consumer.name.clone(),
8959                        fact: produced_fact.clone(),
8960                    });
8961                }
8962            }
8963        }
8964    }
8965    dependencies.sort_by(|left, right| {
8966        (&left.producer, &left.consumer, &left.fact).cmp(&(
8967            &right.producer,
8968            &right.consumer,
8969            &right.fact,
8970        ))
8971    });
8972    dependencies
8973}
8974
8975/// `send via <channel>` (std.messaging) must name a declared `channel`. The
8976/// channel name is carried as the construct's `channel` field; an unknown channel
8977/// would lower to a `messaging.send` effect that no provider can route, so it is
8978/// rejected at compile time (mirrors `acquire`/`consume` resource-existence checks).
8979/// `when message from <channel> as msg` (spec/messaging.md) must name a declared
8980/// channel, mirroring the outbound `send via <channel>` check.
8981fn validate_message_from_channels(
8982    rule: &RuleDecl,
8983    semantic: &SemanticContext,
8984    diagnostics: &mut Vec<Diagnostic>,
8985) {
8986    for when in &rule.whens {
8987        let (pattern, _) = split_when_guard(&when.text);
8988        let Some(rest) = pattern.trim_start().strip_prefix("message from ") else {
8989            continue;
8990        };
8991        let Some(channel) = rest.split_whitespace().next() else {
8992            continue;
8993        };
8994        if !semantic.channels.iter().any(|c| c.as_str() == channel) {
8995            diagnostics.push(Diagnostic {
8996                related: Vec::new(),
8997                span: when.span,
8998                message: format!("`when message from {channel}` names an unknown channel"),
8999                suggestion: Some(
9000                    "declare it with `channel <name> { provider … }`, or correct the channel name"
9001                        .to_owned(),
9002                ),
9003            });
9004            continue;
9005        }
9006        // Capability-report-conditioned check (spec/std-messaging.md "Static
9007        // checks"): inbound observation requires the channel provider's report
9008        // `direction` ∈ {inbound_only, bidirectional}. Desktop channels are a
9009        // check error here — send/receive-capable are distinguishable (the v1
9010        // acceptance test). Unknown providers already errored at the channel
9011        // declaration, so they are not re-flagged here.
9012        if let Some(report) = semantic
9013            .channel_providers
9014            .get(channel)
9015            .and_then(|provider| channel_provider_report(provider))
9016        {
9017            if report.direction == "outbound_only" {
9018                diagnostics.push(Diagnostic {
9019                    related: Vec::new(),
9020                    span: when.span,
9021                    message: format!(
9022                        "`when message from {channel}` observes a channel whose provider `{}` is outbound-only (its capability report cannot deliver inbound messages)",
9023                        report.short_name
9024                    ),
9025                    suggestion: Some(
9026                        "route inbound observation through an inbound-capable provider (`local`, `stdio`, `fixture`)"
9027                            .to_owned(),
9028                    ),
9029                });
9030            }
9031        }
9032    }
9033}
9034
9035fn validate_send_channels(
9036    rule: &RuleDecl,
9037    semantic: &SemanticContext,
9038    diagnostics: &mut Vec<Diagnostic>,
9039) {
9040    let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9041    fn walk(
9042        statements: &[body::BodyStmt],
9043        semantic: &SemanticContext,
9044        diagnostics: &mut Vec<Diagnostic>,
9045    ) {
9046        for statement in statements {
9047            match statement {
9048                body::BodyStmt::Effect(effect) => {
9049                    if let body::BodyEffectKind::ConstructCapabilityCall {
9050                        keyword, fields, ..
9051                    } = &effect.kind
9052                    {
9053                        if keyword == "send" {
9054                            if let Some(channel) =
9055                                fields.iter().find(|field| field.name == "channel")
9056                            {
9057                                if !semantic.channels.contains(&channel.source) {
9058                                    diagnostics.push(Diagnostic {
9059                                        related: Vec::new(),
9060                                        span: effect.span,
9061                                        message: format!(
9062                                            "`send via {}` names an unknown channel",
9063                                            channel.source
9064                                        ),
9065                                        suggestion: Some(
9066                                            "declare it with `channel <name> { provider … }`, or correct the channel name"
9067                                                .to_owned(),
9068                                        ),
9069                                    });
9070                                } else if let Some(report) = semantic
9071                                    .channel_providers
9072                                    .get(&channel.source)
9073                                    .and_then(|provider| channel_provider_report(provider))
9074                                {
9075                                    // Capability-report-conditioned check
9076                                    // (spec/std-messaging.md "Static checks"):
9077                                    // outbound `send via` requires the provider
9078                                    // report `direction` ∈ {outbound_only,
9079                                    // bidirectional}. No v1 provider is
9080                                    // inbound-only, so this arm has no
9081                                    // reachable negative today; it exists so a
9082                                    // future inbound-only provider fails
9083                                    // closed at check time, not at dispatch.
9084                                    if report.direction == "inbound_only" {
9085                                        diagnostics.push(Diagnostic {
9086                                            related: Vec::new(),
9087                                            span: effect.span,
9088                                            message: format!(
9089                                                "`send via {}` targets a channel whose provider `{}` is inbound-only (its capability report cannot accept outbound sends)",
9090                                                channel.source, report.short_name
9091                                            ),
9092                                            suggestion: Some(
9093                                                "send through an outbound-capable provider (`local`, `desktop`, `stdio`, `fixture`)"
9094                                                    .to_owned(),
9095                                            ),
9096                                        });
9097                                    }
9098                                }
9099                            }
9100                        }
9101                        // MEM-1 check 1: the memory operations must name a
9102                        // DECLARED pool — the twin of the send-channel check.
9103                        if matches!(keyword.as_str(), "recall" | "learn" | "curate") {
9104                            if let Some(pool) = fields.iter().find(|field| field.name == "pool") {
9105                                if !semantic.memory_pools.contains(&pool.source) {
9106                                    diagnostics.push(Diagnostic {
9107                                        related: Vec::new(),
9108                                        span: effect.span,
9109                                        message: format!(
9110                                            "`{keyword}` names unknown memory pool `{}`",
9111                                            pool.source
9112                                        ),
9113                                        suggestion: Some(
9114                                            "declare it with `memory pool <name> { … }`, or correct the pool name"
9115                                                .to_owned(),
9116                                        ),
9117                                    });
9118                                }
9119                            }
9120                        }
9121                    }
9122                }
9123                body::BodyStmt::After(after) => walk(&after.body, semantic, diagnostics),
9124                body::BodyStmt::Case(case) => {
9125                    for branch in &case.branches {
9126                        walk(&branch.body, semantic, diagnostics);
9127                    }
9128                }
9129                _ => {}
9130            }
9131        }
9132    }
9133    walk(&ast.statements, semantic, diagnostics);
9134}
9135
9136/// In-turn agent observations — `agent.turn.streamed` (streamed progress),
9137/// `agent.turn.tool_requested` (in-turn tool call), and `agent.turn.artifact_captured`
9138/// (captured artifact/diff) — are recorded as EVIDENCE, never as rule-matchable facts
9139/// (spec/agent-harness.md). The rule-matchable lifecycle facts are
9140/// `agent.turn.started/completed/failed/timed_out/cancelled`. A `when` that matches an
9141/// evidence-only fact can never fire, so it is a compile-time error.
9142const EVIDENCE_ONLY_TURN_FACTS: [&str; 3] = [
9143    "agent.turn.streamed",
9144    "agent.turn.tool_requested",
9145    "agent.turn.artifact_captured",
9146];
9147
9148/// Structural well-formedness of access grants (`with access to <resource> { … }`):
9149/// a grant must grant at least one operation, and a single effect must not list the
9150/// same resource twice (merge them). The deeper "required
9151/// Resource/Operation/Capability ports" validation against the capability registry
9152/// is a separate construct-graph-layer concern, so this stays registry-independent
9153/// and zero-false-positive.
9154fn validate_turn_access_grants(
9155    rule: &RuleDecl,
9156    metadata: &IrRuleMetadata,
9157    diagnostics: &mut Vec<Diagnostic>,
9158) {
9159    for effect in &metadata.effects {
9160        if effect.access_grants.is_empty() {
9161            continue;
9162        }
9163        let mut seen = BTreeSet::new();
9164        for grant in &effect.access_grants {
9165            if grant.operations.is_empty() {
9166                diagnostics.push(Diagnostic {
9167                    related: Vec::new(),
9168                    span: effect.span,
9169                    message: format!(
9170                        "rule `{}` has a `with access to {}` grant that grants no operations",
9171                        rule.name.name, grant.resource
9172                    ),
9173                    suggestion: Some(
9174                        "list at least one operation in the grant block, or drop the grant"
9175                            .to_owned(),
9176                    ),
9177                });
9178            }
9179            if !seen.insert(grant.resource.clone()) {
9180                diagnostics.push(Diagnostic {
9181                    related: Vec::new(),
9182                    span: effect.span,
9183                    message: format!(
9184                        "rule `{}` lists access resource `{}` more than once on one effect",
9185                        rule.name.name, grant.resource
9186                    ),
9187                    suggestion: Some(
9188                        "merge the grant clauses for a resource into a single block".to_owned(),
9189                    ),
9190                });
9191            }
9192        }
9193    }
9194}
9195
9196fn validate_evidence_fact_not_matched(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
9197    for when in &rule.whens {
9198        let (pattern, _) = split_when_guard(&when.text);
9199        let Some(name) = runtime_fact_name_for_pattern(pattern) else {
9200            continue;
9201        };
9202        if EVIDENCE_ONLY_TURN_FACTS.contains(&name.as_str()) {
9203            diagnostics.push(Diagnostic { related: Vec::new(),
9204                span: when.span,
9205                message: format!(
9206                    "rule `{}` matches evidence-only fact `{name}`: in-turn observations are evidence, not rule-matchable facts",
9207                    rule.name.name
9208                ),
9209                suggestion: Some(
9210                    "match a lifecycle fact (`agent.turn.completed`/`failed`/`timed_out`/`cancelled`) and read in-turn detail from its evidence".to_owned(),
9211                ),
9212            });
9213        }
9214    }
9215}
9216
9217/// DR-0043 Decision 5: extracts each rule's `during`/`until` region (at most
9218/// one per rule in v1), REWRITES the rule body to the condition-HOLDS variant
9219/// (region content spliced inline — every downstream scanner and effect-id
9220/// derivation sees ordinary text), and returns the pre-rendered region
9221/// metadata (removed / lapsed variants, region effect scopes) to attach onto
9222/// the lowered `IrRule`s.
9223fn extract_rule_regions(
9224    items: &mut [Item],
9225    diagnostics: &mut Vec<Diagnostic>,
9226) -> BTreeMap<String, IrRegion> {
9227    let mut pending = BTreeMap::new();
9228    for item in items.iter_mut() {
9229        let Item::Rule(rule) = item else {
9230            continue;
9231        };
9232        let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9233        let mut regions = Vec::new();
9234        collect_region_blocks(&ast.statements, &mut regions);
9235        if regions.is_empty() {
9236            continue;
9237        }
9238        if regions.len() > 1 {
9239            diagnostics.push(Diagnostic {
9240                related: Vec::new(),
9241                span: regions[1].span,
9242                message: format!(
9243                    "rule `{}` declares more than one `during`/`until` region",
9244                    rule.name.name
9245                ),
9246                suggestion: Some(
9247                    "v1 supports one region per rule (including nested regions); split the \
9248                     rule or merge the conditions"
9249                        .to_owned(),
9250                ),
9251            });
9252            continue;
9253        }
9254        let region = regions[0].clone();
9255        if count_effect_statements(&region.body) == 0 {
9256            diagnostics.push(Diagnostic {
9257                related: Vec::new(),
9258                span: region.span,
9259                message: format!(
9260                    "the `{}` region in rule `{}` contains no progression",
9261                    if region.until { "until" } else { "during" },
9262                    rule.name.name
9263                ),
9264                suggestion: Some(
9265                    "a region around purely-atomic actions commits with admission and can \
9266                     never lapse between steps; it needs at least one effect with a \
9267                     continuation"
9268                        .to_owned(),
9269                ),
9270            });
9271            continue;
9272        }
9273        // Lapse-arm binding scope: the arm may run at ANY point inside the
9274        // region, so it may only reference bindings guaranteed at region
9275        // entry — never a binding the region itself introduces (the optional
9276        // progress view is the sanctioned window into those).
9277        let mut region_bindings = BTreeSet::new();
9278        collect_all_binding_names(&region.body, &mut region_bindings);
9279        if let Some(view) = &region.lapse_binding {
9280            region_bindings.remove(view);
9281        }
9282        let mut arm_roots = BTreeSet::new();
9283        collect_statement_roots(&region.lapse_body, &mut arm_roots);
9284        for root in &arm_roots {
9285            if region_bindings.contains(root) {
9286                diagnostics.push(Diagnostic {
9287                    related: Vec::new(),
9288                    span: region.span,
9289                    message: format!(
9290                        "the `on lapse` arm of rule `{}` references `{root}`, a binding the \
9291                         region introduces — it may not exist when the arm runs",
9292                        rule.name.name
9293                    ),
9294                    suggestion: Some(
9295                        "reference only bindings from before the region, or bind the \
9296                         progress view (`on lapse as got`) and read `got.<binding>` — its \
9297                         fields are present exactly if that step settled"
9298                            .to_owned(),
9299                    ),
9300                });
9301            }
9302        }
9303        // Variant surgery. All spans are absolute; rebase onto the body text.
9304        let base = rule.body.span.start;
9305        let text = rule.body.text.clone();
9306        let clamp = |offset: usize| offset.saturating_sub(base).min(text.len());
9307        let (r_start, r_end) = (clamp(region.span.start), clamp(region.span.end));
9308        let (b_start, b_end) = (clamp(region.body_span.start), clamp(region.body_span.end));
9309        let (l_start, l_end) = (clamp(region.lapse_span.start), clamp(region.lapse_span.end));
9310        if !(r_start <= b_start
9311            && b_start <= b_end
9312            && b_end <= l_start
9313            && l_start <= l_end
9314            && l_end <= r_end)
9315        {
9316            diagnostics.push(Diagnostic {
9317                related: Vec::new(),
9318                span: region.span,
9319                message: format!(
9320                    "internal: region span reconstruction failed for rule `{}`",
9321                    rule.name.name
9322                ),
9323                suggestion: None,
9324            });
9325            continue;
9326        }
9327        let body_content = &text[b_start..b_end];
9328        let arm_content = &text[l_start..l_end];
9329        let variant_holds = format!("{}{}{}", &text[..r_start], body_content, &text[r_end..]);
9330        let variant_removed = format!("{}{}", &text[..r_start], &text[r_end..]);
9331        let variant_lapsed = format!("{}{}{}", &text[..r_start], arm_content, &text[r_end..]);
9332        // Region effect scopes, computed on the HOLDS variant (the canonical
9333        // kernel body): each region-owned effect binding's LEVEL-1 `after`
9334        // ancestor is the scope the kernel keys its effect id under.
9335        let mut effect_bindings = BTreeSet::new();
9336        collect_effect_binding_names(&region.body, &mut effect_bindings);
9337        let (holds_ast, _) = body::parse_rule_body(&variant_holds, 0);
9338        let mut region_effects = Vec::new();
9339        assign_region_effect_scopes(
9340            &holds_ast.statements,
9341            None,
9342            &effect_bindings,
9343            &mut region_effects,
9344        );
9345        pending.insert(
9346            rule.name.name.clone(),
9347            IrRegion {
9348                until: region.until,
9349                condition: region.condition.clone(),
9350                lapse_binding: region.lapse_binding.clone(),
9351                effects: region_effects,
9352                body_removed: variant_removed,
9353                body_lapsed: variant_lapsed,
9354            },
9355        );
9356        rule.body.text = variant_holds;
9357    }
9358    pending
9359}
9360
9361fn collect_region_blocks(statements: &[body::BodyStmt], out: &mut Vec<body::RegionBlock>) {
9362    for statement in statements {
9363        match statement {
9364            body::BodyStmt::Region(region) => {
9365                out.push(region.clone());
9366                collect_region_blocks(&region.body, out);
9367                collect_region_blocks(&region.lapse_body, out);
9368            }
9369            body::BodyStmt::After(after) => collect_region_blocks(&after.body, out),
9370            body::BodyStmt::Case(case) => {
9371                for branch in &case.branches {
9372                    collect_region_blocks(&branch.body, out);
9373                }
9374            }
9375            _ => {}
9376        }
9377    }
9378}
9379
9380fn count_effect_statements(statements: &[body::BodyStmt]) -> usize {
9381    let mut count = 0;
9382    for statement in statements {
9383        match statement {
9384            body::BodyStmt::Effect(_) => count += 1,
9385            body::BodyStmt::After(after) => count += count_effect_statements(&after.body),
9386            body::BodyStmt::Case(case) => {
9387                for branch in &case.branches {
9388                    count += count_effect_statements(&branch.body);
9389                }
9390            }
9391            body::BodyStmt::Region(region) => {
9392                count += count_effect_statements(&region.body);
9393            }
9394            _ => {}
9395        }
9396    }
9397    count
9398}
9399
9400fn collect_effect_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
9401    for statement in statements {
9402        match statement {
9403            body::BodyStmt::Effect(effect) => {
9404                if let Some(binding) = &effect.binding {
9405                    out.insert(binding.clone());
9406                }
9407            }
9408            body::BodyStmt::After(after) => collect_effect_binding_names(&after.body, out),
9409            body::BodyStmt::Case(case) => {
9410                for branch in &case.branches {
9411                    collect_effect_binding_names(&branch.body, out);
9412                }
9413            }
9414            body::BodyStmt::Region(region) => {
9415                collect_effect_binding_names(&region.body, out);
9416            }
9417            _ => {}
9418        }
9419    }
9420}
9421
9422/// Walks the HOLDS-variant AST assigning each region-owned effect its LEVEL-1
9423/// `after` scope (the kernel's effect-id key component). `level1` is fixed at
9424/// the first `after` ancestor and inherited by everything deeper.
9425fn assign_region_effect_scopes(
9426    statements: &[body::BodyStmt],
9427    level1: Option<&(String, String)>,
9428    region_bindings: &BTreeSet<String>,
9429    out: &mut Vec<IrRegionEffect>,
9430) {
9431    for statement in statements {
9432        match statement {
9433            body::BodyStmt::Effect(effect) => {
9434                if let Some(binding) = &effect.binding {
9435                    if region_bindings.contains(binding)
9436                        && !out.iter().any(|known| &known.binding == binding)
9437                    {
9438                        out.push(IrRegionEffect {
9439                            binding: binding.clone(),
9440                            scope: level1.cloned(),
9441                        });
9442                    }
9443                }
9444            }
9445            body::BodyStmt::After(after) => {
9446                let own = (
9447                    after.binding.clone(),
9448                    after.predicate.kernel_str().to_owned(),
9449                );
9450                let next = level1.cloned().unwrap_or(own);
9451                assign_region_effect_scopes(&after.body, Some(&next), region_bindings, out);
9452            }
9453            body::BodyStmt::Case(case) => {
9454                for branch in &case.branches {
9455                    assign_region_effect_scopes(&branch.body, level1, region_bindings, out);
9456                }
9457            }
9458            body::BodyStmt::Region(region) => {
9459                assign_region_effect_scopes(&region.body, level1, region_bindings, out);
9460            }
9461            _ => {}
9462        }
9463    }
9464}
9465
9466/// Root identifiers referenced by a statement list's value positions: record
9467/// fields, terminal fields, done/cancel bindings, effect arguments, and
9468/// `{{ … }}` prompt interpolations. Used for the lapse-arm scope check.
9469fn collect_statement_roots(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
9470    fn roots_in_expr(source: &str, out: &mut BTreeSet<String>) {
9471        let bytes = source.as_bytes();
9472        let mut i = 0;
9473        let mut in_string = false;
9474        while i < bytes.len() {
9475            let c = bytes[i] as char;
9476            if c == '"' {
9477                in_string = !in_string;
9478                i += 1;
9479                continue;
9480            }
9481            if in_string {
9482                i += 1;
9483                continue;
9484            }
9485            if c.is_ascii_alphabetic() || c == '_' {
9486                let start = i;
9487                while i < bytes.len() {
9488                    let cj = bytes[i] as char;
9489                    if cj.is_ascii_alphanumeric() || cj == '_' {
9490                        i += 1;
9491                    } else {
9492                        break;
9493                    }
9494                }
9495                let preceded_by_dot = start > 0 && bytes[start - 1] as char == '.';
9496                if !preceded_by_dot {
9497                    out.insert(source[start..i].to_owned());
9498                }
9499                continue;
9500            }
9501            i += 1;
9502        }
9503    }
9504    fn roots_in_fields(fields: &[body::FieldAssign], out: &mut BTreeSet<String>) {
9505        for field in fields {
9506            match &field.value {
9507                body::FieldValue::Expr { source, .. } => roots_in_expr(source, out),
9508                body::FieldValue::Nested { fields, .. } => roots_in_fields(fields, out),
9509                body::FieldValue::Shorthand => {
9510                    out.insert(field.name.clone());
9511                }
9512            }
9513        }
9514    }
9515    fn roots_in_prompt(text: &str, out: &mut BTreeSet<String>) {
9516        let mut rest = text;
9517        while let Some(open) = rest.find("{{") {
9518            let tail = &rest[open + 2..];
9519            let Some(close) = tail.find("}}") else {
9520                break;
9521            };
9522            roots_in_expr(&tail[..close], out);
9523            rest = &tail[close + 2..];
9524        }
9525    }
9526    for statement in statements {
9527        match statement {
9528            body::BodyStmt::Record(record) => roots_in_fields(&record.fields, out),
9529            body::BodyStmt::Done {
9530                binding,
9531                replacement,
9532                ..
9533            } => {
9534                out.insert(binding.clone());
9535                if let Some(record) = replacement {
9536                    roots_in_fields(&record.fields, out);
9537                }
9538            }
9539            body::BodyStmt::Cancel { binding, .. } => {
9540                out.insert(binding.clone());
9541            }
9542            body::BodyStmt::Effect(effect) => {
9543                if let Some(prompt) = &effect.prompt {
9544                    roots_in_prompt(&prompt.text, out);
9545                }
9546                match &effect.kind {
9547                    body::BodyEffectKind::Coerce { args, .. } => {
9548                        for arg in args {
9549                            roots_in_expr(arg, out);
9550                        }
9551                    }
9552                    body::BodyEffectKind::TrackerFinish { item, fields } => {
9553                        out.insert(item.clone());
9554                        roots_in_fields(fields, out);
9555                    }
9556                    body::BodyEffectKind::TrackerRelease { item } => {
9557                        out.insert(item.clone());
9558                    }
9559                    _ => {}
9560                }
9561            }
9562            body::BodyStmt::Terminal(terminal) => {
9563                roots_in_fields(&terminal.fields, out);
9564                if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
9565                    roots_in_expr(source, out);
9566                }
9567            }
9568            body::BodyStmt::Milestone { fields, .. } => roots_in_fields(fields, out),
9569            body::BodyStmt::After(after) => collect_statement_roots(&after.body, out),
9570            body::BodyStmt::Case(case) => {
9571                roots_in_expr(&case.scrutinee, out);
9572                for branch in &case.branches {
9573                    collect_statement_roots(&branch.body, out);
9574                }
9575            }
9576            body::BodyStmt::Region(region) => {
9577                collect_statement_roots(&region.body, out);
9578                collect_statement_roots(&region.lapse_body, out);
9579            }
9580            body::BodyStmt::Redact { source, .. } => {
9581                out.insert(source.clone());
9582            }
9583        }
9584    }
9585}
9586
9587fn validate_effectful_self_trigger(
9588    rule: &RuleDecl,
9589    metadata: &IrRuleMetadata,
9590    diagnostics: &mut Vec<Diagnostic>,
9591) {
9592    if metadata.effects.is_empty() {
9593        return;
9594    }
9595
9596    for written_fact in &metadata.fact_writes {
9597        if metadata.fact_reads.contains(written_fact)
9598            && !metadata.fact_consumes.contains(written_fact)
9599        {
9600            diagnostics.push(Diagnostic { related: Vec::new(),
9601                span: rule.body.span,
9602                message: format!(
9603                    "effectful rule `{}` preserves trigger fact `{written_fact}`",
9604                    rule.name.name
9605                ),
9606                suggestion: Some(
9607                    "consume or advance the triggering fact, or move the next effect behind an external completion event"
9608                        .to_owned(),
9609                ),
9610            });
9611        }
9612    }
9613}
9614
9615fn binding_types_for_rule(rule: &RuleDecl) -> BTreeMap<String, String> {
9616    let mut binding_types = BTreeMap::new();
9617    for when in &rule.whens {
9618        if let Some((binding, schema)) = binding_from_when(&when.text) {
9619            binding_types.insert(binding, schema);
9620        }
9621    }
9622    binding_types
9623}
9624
9625fn validate_workflow_terminal_actions(
9626    rule: &RuleDecl,
9627    semantic: &SemanticContext,
9628    binding_types: &BTreeMap<String, String>,
9629    known_roots: &BTreeSet<String>,
9630    contracts: &WorkflowContractNames,
9631    diagnostics: &mut Vec<Diagnostic>,
9632) {
9633    for line in rule.body.text.lines().map(str::trim) {
9634        let terminal = line
9635            .strip_prefix("complete ")
9636            .map(|rest| ("complete", rest, &contracts.outputs))
9637            .or_else(|| {
9638                line.strip_prefix("fail ")
9639                    .map(|rest| ("fail", rest, &contracts.failures))
9640            });
9641        let Some((action, rest, declared)) = terminal else {
9642            continue;
9643        };
9644        // Scalar terminal form: `complete result 0.9` / `fail error "msg"` — a bare
9645        // value after the name, with no `{ }` block and no `from` projection.
9646        // Validated against a scalar (primitive) contract; class contracts still
9647        // require a field block (checked by the block path below).
9648        if !rest.contains('{') {
9649            let tokens: Vec<&str> = rest.split_whitespace().collect();
9650            let is_from = matches!(tokens.as_slice(), [_, "from", ..]) && action == "complete";
9651            if tokens.len() >= 2 && !is_from {
9652                let name = tokens[0];
9653                let value = rest.trim().get(name.len()..).unwrap_or("").trim();
9654                if !declared.contains_key(name) {
9655                    diagnostics.push(Diagnostic {
9656                        related: Vec::new(),
9657                        span: rule.body.span,
9658                        message: format!(
9659                            "rule `{}` {action}s unknown workflow terminal `{name}`",
9660                            rule.name.name
9661                        ),
9662                        suggestion: Some(format!(
9663                            "declare `{kind} {name} Type` on the workflow first",
9664                            kind = if action == "complete" {
9665                                "output"
9666                            } else {
9667                                "failure"
9668                            }
9669                        )),
9670                    });
9671                    continue;
9672                }
9673                if let Some(contract_ty) = declared.get(name) {
9674                    validate_scalar_terminal_payload(
9675                        rule,
9676                        action,
9677                        name,
9678                        value,
9679                        contract_ty,
9680                        semantic,
9681                        binding_types,
9682                        known_roots,
9683                        diagnostics,
9684                    );
9685                }
9686                continue;
9687            }
9688        }
9689        // Header is `<name>` or (for `complete`) `<name> from <binding>` — the
9690        // bounded-type projection form (DR-0027), whose payload copies the binding.
9691        let Some(name) = rest.split('{').next().and_then(|header| {
9692            let mut parts = header.split_whitespace();
9693            match (parts.next(), parts.next(), parts.next()) {
9694                (Some(name), None, _) => Some(name),
9695                (Some(name), Some("from"), Some(binding))
9696                    if action == "complete" && is_identifier(binding) =>
9697                {
9698                    Some(name)
9699                }
9700                _ => None,
9701            }
9702        }) else {
9703            diagnostics.push(Diagnostic {
9704                related: Vec::new(),
9705                span: rule.body.span,
9706                message: format!("rule `{}` has malformed `{action}` action", rule.name.name),
9707                suggestion: Some(format!(
9708                    "{action} a declared workflow terminal with a payload block"
9709                )),
9710            });
9711            continue;
9712        };
9713        if !declared.contains_key(name) {
9714            diagnostics.push(Diagnostic {
9715                related: Vec::new(),
9716                span: rule.body.span,
9717                message: format!(
9718                    "rule `{}` {action}s unknown workflow terminal `{name}`",
9719                    rule.name.name
9720                ),
9721                suggestion: Some(format!(
9722                    "declare `{kind} {name} Type` on the workflow first",
9723                    kind = if action == "complete" {
9724                        "output"
9725                    } else {
9726                        "failure"
9727                    }
9728                )),
9729            });
9730            continue;
9731        }
9732        let Some(contract_ty) = declared.get(name) else {
9733            continue;
9734        };
9735        validate_workflow_terminal_payload(
9736            rule,
9737            action,
9738            name,
9739            contract_ty,
9740            semantic,
9741            binding_types,
9742            known_roots,
9743            diagnostics,
9744        );
9745    }
9746}
9747
9748#[allow(clippy::too_many_arguments)]
9749fn validate_workflow_terminal_payload(
9750    rule: &RuleDecl,
9751    action: &str,
9752    terminal_name: &str,
9753    contract_ty: &TypeSyntax,
9754    semantic: &SemanticContext,
9755    binding_types: &BTreeMap<String, String>,
9756    known_roots: &BTreeSet<String>,
9757    diagnostics: &mut Vec<Diagnostic>,
9758) {
9759    let Some((_, _, body)) = workflow_terminal_blocks(&rule.body.text).into_iter().find(
9760        |(candidate_action, candidate_name, _)| {
9761            candidate_action == action && candidate_name == terminal_name
9762        },
9763    ) else {
9764        return;
9765    };
9766    let schema = match contract_ty {
9767        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => &name.name,
9768        TypeSyntax::Primitive { .. }
9769        | TypeSyntax::LiteralString { .. }
9770        | TypeSyntax::Union { .. } => {
9771            // A scalar (primitive/literal/union) contract takes a bare value, not
9772            // a field block.
9773            diagnostics.push(Diagnostic {
9774                related: Vec::new(),
9775                span: rule.body.span,
9776                message: format!(
9777                    "workflow terminal `{terminal_name}` has a scalar payload contract but is given a field block"
9778                ),
9779                suggestion: Some(format!(
9780                    "write a bare scalar value: `{action} {terminal_name} <value>`"
9781                )),
9782            });
9783            return;
9784        }
9785        _ => {
9786            diagnostics.push(Diagnostic { related: Vec::new(),
9787                span: rule.body.span,
9788                message: format!(
9789                    "workflow terminal `{terminal_name}` uses an unsupported payload contract type"
9790                ),
9791                suggestion: Some(
9792                    "declare the terminal payload as a class (field block) or a scalar type (number/string/bool)"
9793                        .to_owned(),
9794                ),
9795            });
9796            return;
9797        }
9798    };
9799    for assignment in collect_field_assignments(&body) {
9800        let (field, value) = match assignment {
9801            RecordFieldAssignment::Value { field, value } => (field, value),
9802            RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
9803        };
9804        let line = format!("{field} {value}");
9805        validate_record_field(
9806            rule,
9807            &line,
9808            schema,
9809            semantic,
9810            binding_types,
9811            known_roots,
9812            diagnostics,
9813        );
9814    }
9815    validate_required_terminal_fields(rule, schema, terminal_name, &body, semantic, diagnostics);
9816}
9817
9818/// Validates a bare-scalar terminal payload (`complete result 0.9` /
9819/// `fail error "reason"`) against a scalar output/failure contract. A class
9820/// contract is rejected (it needs a field block); a literal value is typechecked
9821/// against the primitive/enum/union contract, and a binding-expression value has
9822/// its roots and field path validated.
9823#[allow(clippy::too_many_arguments)]
9824fn validate_scalar_terminal_payload(
9825    rule: &RuleDecl,
9826    action: &str,
9827    terminal_name: &str,
9828    value: &str,
9829    contract_ty: &TypeSyntax,
9830    semantic: &SemanticContext,
9831    binding_types: &BTreeMap<String, String>,
9832    known_roots: &BTreeSet<String>,
9833    diagnostics: &mut Vec<Diagnostic>,
9834) {
9835    if let TypeSyntax::Ref { name } = contract_ty {
9836        if semantic.schemas.class_exists(&name.name) {
9837            diagnostics.push(Diagnostic {
9838                related: Vec::new(),
9839                span: rule.body.span,
9840                message: format!(
9841                    "workflow terminal `{terminal_name}` has a class payload contract `{}` but is given a bare scalar value",
9842                    name.name
9843                ),
9844                suggestion: Some(format!("write a field block: `{action} {terminal_name} {{ … }}`")),
9845            });
9846            return;
9847        }
9848    }
9849    if value.is_empty() {
9850        diagnostics.push(Diagnostic {
9851            related: Vec::new(),
9852            span: rule.body.span,
9853            message: format!("workflow terminal `{terminal_name}` is missing its scalar value"),
9854            suggestion: Some(format!("write `{action} {terminal_name} <value>`")),
9855        });
9856        return;
9857    }
9858    // A literal value is typechecked against the scalar contract; a binding
9859    // expression has its roots (and any field path) validated.
9860    validate_literal_assignment(
9861        rule,
9862        terminal_name,
9863        "value",
9864        contract_ty,
9865        value,
9866        semantic,
9867        diagnostics,
9868    );
9869    if let Some(root) = dangling_value_root(value, known_roots) {
9870        diagnostics.push(Diagnostic {
9871            related: Vec::new(),
9872            span: rule.body.span,
9873            message: format!(
9874                "rule `{}` has unknown binding `{root}` in `{action} {terminal_name}` value",
9875                rule.name.name
9876            ),
9877            suggestion: Some(
9878                "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
9879                    .to_owned(),
9880            ),
9881        });
9882    } else if let Some((root, path)) = expression_path(value) {
9883        if let Some(schema) = binding_types.get(&root) {
9884            if semantic.schemas.class_exists(schema) {
9885                if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
9886                    diagnostics.push(Diagnostic {
9887                        related: Vec::new(),
9888                        span: rule.body.span,
9889                        message: format!(
9890                            "rule `{}` has invalid field path `{root}.{}`: {message}",
9891                            rule.name.name,
9892                            path.join(".")
9893                        ),
9894                        suggestion: Some(
9895                            "use a field declared on the bound schema or add it to the class declaration"
9896                                .to_owned(),
9897                        ),
9898                    });
9899                }
9900            }
9901        }
9902    }
9903}
9904
9905fn validate_required_terminal_fields(
9906    rule: &RuleDecl,
9907    schema: &str,
9908    terminal_name: &str,
9909    body: &str,
9910    semantic: &SemanticContext,
9911    diagnostics: &mut Vec<Diagnostic>,
9912) {
9913    let Some(schema_fields) = semantic.schemas.classes.get(schema) else {
9914        return;
9915    };
9916    let seen = collect_field_assignments(body)
9917        .into_iter()
9918        .map(|assignment| match assignment {
9919            RecordFieldAssignment::Value { field, .. }
9920            | RecordFieldAssignment::Shorthand { field } => field,
9921        })
9922        .collect::<BTreeSet<_>>();
9923    for (required, ty) in schema_fields {
9924        if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
9925            continue;
9926        }
9927        diagnostics.push(Diagnostic { related: Vec::new(),
9928            span: rule.body.span,
9929            message: format!(
9930                "workflow terminal `{terminal_name}` is missing required field `{schema}.{required}`"
9931            ),
9932            suggestion: Some(format!("add `{required}` to the `{terminal_name}` payload")),
9933        });
9934    }
9935}
9936
9937/// Maximum nesting depth of `after` blocks across `statements` (an `after` inside an
9938/// `after` is depth 2, …). Other nesting (`case`/`when`/handlers) is descended into so
9939/// an `after` buried inside them still counts, but only `after` increments the depth —
9940/// it is `after`-chaining specifically that `lint.deep_after_nesting` advises moving to
9941/// a `flow`. Computed from the body AST so prompt braces never confuse it.
9942fn max_after_depth(statements: &[body::BodyStmt]) -> usize {
9943    use body::BodyStmt;
9944    statements
9945        .iter()
9946        .map(|statement| match statement {
9947            BodyStmt::After(after) => 1 + max_after_depth(&after.body),
9948            BodyStmt::Case(case) => case
9949                .branches
9950                .iter()
9951                .map(|branch| max_after_depth(&branch.body))
9952                .max()
9953                .unwrap_or(0),
9954            _ => 0,
9955        })
9956        .max()
9957        .unwrap_or(0)
9958}
9959
9960fn analyze_rule(
9961    rule: &RuleDecl,
9962    semantic: &SemanticContext,
9963    diagnostics: &mut Vec<Diagnostic>,
9964) -> IrRuleMetadata {
9965    // Statement-form gate: every body must parse into the body AST. Unknown
9966    // statements, malformed modifiers, and unclosed blocks are spanned
9967    // errors here rather than silent no-ops at lowering time.
9968    let (body_ast, body_diagnostics) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
9969    diagnostics.extend(body_diagnostics);
9970    let mut metadata = IrRuleMetadata {
9971        fact_reads: rule
9972            .whens
9973            .iter()
9974            .map(|when| fact_read_from_when(&when.text))
9975            .collect(),
9976        max_after_depth: max_after_depth(&body_ast.statements),
9977        ..IrRuleMetadata::default()
9978    };
9979    let mut seen_bindings = BTreeSet::new();
9980    let mut binding_types = BTreeMap::new();
9981    for when in &rule.whens {
9982        // A pattern that binds (`... as x`) but maps to no known readiness
9983        // form would otherwise be a silently-dead rule.
9984        let (pattern_text, _) = split_when_guard(&when.text);
9985        if binding_after_as(pattern_text).is_some()
9986            && binding_from_when(&when.text).is_none()
9987            && !pattern_text.ends_with(" is available")
9988        {
9989            diagnostics.push(Diagnostic {
9990                related: Vec::new(),
9991                span: when.span,
9992                message: format!(
9993                    "rule `{}` has unknown readiness pattern `{pattern_text}`",
9994                    rule.name.name
9995                ),
9996                suggestion: Some(
9997                    "match a class (`when Class as x`) or a runtime fact (`when fact <name> as x`)"
9998                        .to_owned(),
9999                ),
10000            });
10001        }
10002        if let Some((binding, schema)) = binding_from_when(&when.text) {
10003            validate_binding_name(rule, &binding, when.span, diagnostics);
10004            if !schema.contains('.') && !semantic.schemas.class_exists(&schema) {
10005                let suggestion = match closest_name(&schema, semantic.schemas.classes.keys()) {
10006                    Some(candidate) => {
10007                        format!("did you mean `{candidate}`? otherwise declare `class {schema}`")
10008                    }
10009                    None => format!("declare `class {schema}` before matching it"),
10010                };
10011                diagnostics.push(Diagnostic {
10012                    related: Vec::new(),
10013                    span: when.span,
10014                    message: format!("rule `{}` matches unknown class `{schema}`", rule.name.name),
10015                    suggestion: Some(suggestion),
10016                });
10017            }
10018            // The bare dotted form is the typed signal reaction
10019            // (spec/event-ingress.md): it requires a declared `signal`;
10020            // undeclared dotted facts keep the untyped `when fact` form.
10021            if schema.contains('.')
10022                && !pattern_text.trim_start().starts_with("fact ")
10023                && !semantic.schemas.events.contains(&schema)
10024            {
10025                diagnostics.push(Diagnostic { related: Vec::new(),
10026                    span: when.span,
10027                    message: format!(
10028                        "rule `{}` reacts to undeclared signal `{schema}`",
10029                        rule.name.name
10030                    ),
10031                    suggestion: Some(format!(
10032                        "declare `signal {schema} {{ ... }}` for a typed reaction, or use `when fact {schema} as ...` for an untyped one"
10033                    )),
10034                });
10035            }
10036            binding_types.insert(binding, schema);
10037        }
10038    }
10039    let mut effect_payload_types = collect_effect_payload_types(rule, semantic, diagnostics);
10040    // `exec ... -> Schema as binding` is parsed from the AST (the command text
10041    // can itself contain `->`/` as `, so a text scan is unsafe), giving its
10042    // result the same after-binding type flow a named `coerce -> Schema` gets.
10043    collect_exec_payload_types(&body_ast.statements, semantic, &mut effect_payload_types);
10044    // Inline `decide … as <binding>` carries the synthesized
10045    // `decide.<rule>.<binding>` class (see `collect_inline_decide_schemas`), so
10046    // its result is `case`able / field-accessible like a named coerce result.
10047    collect_decide_payload_types(
10048        &body_ast.statements,
10049        &rule.name.name,
10050        &mut effect_payload_types,
10051    );
10052    collect_prompt_payload_types(&body_ast.statements, &mut effect_payload_types);
10053    // `redact … as <binding>` result carries the synthesized `redact.<rule>.<binding>`
10054    // projected class (see `collect_redact_schemas`), so access through it resolves
10055    // against the kept-only fields.
10056    collect_redact_payload_types(
10057        &body_ast.statements,
10058        &rule.name.name,
10059        &mut effect_payload_types,
10060    );
10061    for (binding, payload_type) in &effect_payload_types {
10062        if let IrType::Ref(schema) = payload_type {
10063            binding_types.insert(binding.clone(), schema.clone());
10064        }
10065    }
10066    // Effect-kind map for the `fails`-arm static narrowing (DR-0032 P3): the
10067    // failing effect's kind is always statically known at the read site. Scan
10068    // raw lines first (every single-line effect form), then the balanced
10069    // multi-line statements (a `coerce` whose arguments span lines carries its
10070    // binding on the closing line).
10071    let mut effect_binding_kinds: BTreeMap<String, IrEffectKind> = rule
10072        .body
10073        .text
10074        .lines()
10075        .filter_map(|line| {
10076            let line = line.trim();
10077            // `exec` is not in parse_effect_line (whose other callers feed the
10078            // rule-metadata effect count); match it here for kind narrowing.
10079            if line.starts_with("exec ") {
10080                return Some((binding_after_as(line)?, IrEffectKind::ExecCommand));
10081            }
10082            let (kind, binding) = parse_effect_line(line)?;
10083            Some((binding?, kind))
10084        })
10085        .collect();
10086    for statement in effect_payload_statements(&rule.body.text) {
10087        if let Some((kind, Some(binding))) = parse_effect_line(statement.trim()) {
10088            effect_binding_kinds.insert(binding, kind);
10089        }
10090    }
10091    // `after <binding> <predicate> as <alias>`: the alias carries the
10092    // effect's completed payload type, so case dispatch and field access
10093    // through it type-check.
10094    for line in rule.body.text.lines() {
10095        let Some(rest) = line.trim().strip_prefix("after ") else {
10096            continue;
10097        };
10098        let mut words = rest.split_whitespace();
10099        let Some(binding) = words.next() else {
10100            continue;
10101        };
10102        let Some(predicate) = words.next() else {
10103            continue;
10104        };
10105        // Coordination ops are completion-valued: an `acquire` COMPLETES with
10106        // variant Held|Contended (counter `consume` with Ok|Over), so the
10107        // generic `succeeds` arm would fire on the negative outcome too — a
10108        // workflow proceeding "as if holding" on Contended. Reject `succeeds`
10109        // and force the variant vocabulary; `fails` (infra failures) and
10110        // `completes` (deliberate catch-all) stay legal.
10111        if predicate == "succeeds" {
10112            match effect_binding_kinds.get(binding) {
10113                Some(IrEffectKind::LeaseAcquire) => {
10114                    diagnostics.push(Diagnostic {
10115                        related: Vec::new(),
10116                        span: rule.body.span,
10117                        message: format!(
10118                            "rule `{}` observes acquire `{binding}` with `succeeds`, which also \
10119                             matches a Contended outcome (the acquire op completes either way)",
10120                            rule.name.name
10121                        ),
10122                        suggestion: Some(format!(
10123                            "use `after {binding} held` / `after {binding} contended` for the \
10124                             outcome variants, or `after {binding} completes` for any settled \
10125                             outcome"
10126                        )),
10127                    });
10128                }
10129                Some(IrEffectKind::CounterConsume) => {
10130                    diagnostics.push(Diagnostic {
10131                        related: Vec::new(),
10132                        span: rule.body.span,
10133                        message: format!(
10134                            "rule `{}` observes counter consume `{binding}` with `succeeds`, \
10135                             which also matches an Over outcome (the consume op completes \
10136                             either way)",
10137                            rule.name.name
10138                        ),
10139                        suggestion: Some(format!(
10140                            "use `after {binding} ok` / `after {binding} over` for the outcome \
10141                             variants, or `after {binding} completes` for any settled outcome"
10142                        )),
10143                    });
10144                }
10145                _ => {}
10146            }
10147        }
10148        // `after p reaches "<name>" as m` (Family C): the milestone name sits
10149        // between the predicate and `as`, so the alias lands one token later.
10150        // Type `m` to the child's declared milestone payload class.
10151        if predicate == "reaches" {
10152            let Some(quoted) = words.next() else {
10153                continue;
10154            };
10155            let milestone = quoted.trim_matches('"');
10156            let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
10157                continue;
10158            };
10159            let alias = alias.trim_end_matches('{').trim();
10160            if alias.is_empty() {
10161                continue;
10162            }
10163            if let Some(class) = milestone_payload_class(rule, binding, milestone, semantic) {
10164                if !class.is_empty() {
10165                    binding_types.insert(alias.to_owned(), class);
10166                }
10167            }
10168            continue;
10169        }
10170        // `times out` is the only two-token predicate; skip its second word so
10171        // the `as <alias>` clause lines up.
10172        if predicate == "times" && words.next() != Some("out") {
10173            continue;
10174        }
10175        let (Some(keyword), Some(alias)) = (words.next(), words.next()) else {
10176            continue;
10177        };
10178        if keyword != "as" {
10179            continue;
10180        }
10181        let alias = alias.trim_end_matches('{').trim();
10182        if alias.is_empty() {
10183            continue;
10184        }
10185        // Bind the alias to the terminal payload schema that matches the
10186        // predicate, consistent with the case-tag payload schemas
10187        // (terminal_payload_schema_for_tag): `times out` -> `TerminalTimedOut`,
10188        // `cancelled` -> `TerminalCancelled`. Other predicates carry the
10189        // effect's completed payload schema.
10190        match predicate {
10191            "times" => {
10192                binding_types.insert(alias.to_owned(), "TerminalTimedOut".to_owned());
10193            }
10194            "cancelled" => {
10195                binding_types.insert(alias.to_owned(), "TerminalCancelled".to_owned());
10196            }
10197            // `completes` binds the terminal-union ENVELOPE, not the success
10198            // schema: the runtime delivers {tag, status, summary, …} for ANY
10199            // settled outcome, and the payload is read via `case o {
10200            // Completed as v => … }`. The old success-schema typing approved
10201            // reads that were null on every non-success terminal.
10202            "completes" => {
10203                binding_types.insert(alias.to_owned(), "TerminalOutcome".to_owned());
10204            }
10205            // DR-0032: the `fails` branch binds the EffectError family — the
10206            // base `{reason, summary, effect_id, run_id, kind}` plus per-kind
10207            // extras narrowed STATICALLY by the binding's effect kind (P3 /
10208            // DQ-2): exec adds `exit_code`; schema.coerce adds `error_class` +
10209            // optional `http_status`; agent.tell adds `error_class`. Every
10210            // other kind stays on the plain base.
10211            //
10212            // Exception (typed invoke failure): when this is an invoke binding
10213            // whose child declares a SOLE, shared top-level FAILURE contract class,
10214            // bind the alias to THAT class so `f.<field>` type-checks against the
10215            // child's declared failure shape (the runtime merges the child payload
10216            // under the base). Invoke bindings with a child-local/unresolvable
10217            // failure class keep the `TerminalFailed` base.
10218            "fails" => {
10219                if let Some(class) = invoke_failure_class(rule, binding, semantic) {
10220                    binding_types.insert(alias.to_owned(), class);
10221                } else {
10222                    let schema = match effect_binding_kinds.get(binding) {
10223                        Some(IrEffectKind::ExecCommand) => "TerminalFailedExec",
10224                        Some(IrEffectKind::SchemaCoerce) => "TerminalFailedCoerce",
10225                        Some(IrEffectKind::AgentTell) => "TerminalFailedTell",
10226                        _ => "TerminalFailed",
10227                    };
10228                    binding_types.insert(alias.to_owned(), schema.to_owned());
10229                }
10230            }
10231            _ => {
10232                if let Some(IrType::Ref(schema)) = effect_payload_types.get(binding) {
10233                    binding_types.insert(alias.to_owned(), schema.clone());
10234                } else if let Some(class) = invoke_output_class(rule, binding, semantic) {
10235                    // Typed invoke result: `after <child> succeeds/completes as r`
10236                    // binds r to the child workflow's OUTPUT contract class, so
10237                    // `r.<field>` type-checks. (The runtime already carries the
10238                    // child's terminal payload into this binding.) The `fails` arm
10239                    // above keeps the DR-0032 failure base.
10240                    binding_types.insert(alias.to_owned(), class);
10241                }
10242            }
10243        }
10244    }
10245    for when in &rule.whens {
10246        if let (_, Some(guard)) = split_when_guard(&when.text) {
10247            validate_expression(rule, guard, semantic, &binding_types, "guard", diagnostics);
10248            validate_known_field_paths(rule, guard, semantic, &binding_types, diagnostics);
10249            if let Some(expr) = lower_expression(guard, when.span) {
10250                metadata
10251                    .projection_reads
10252                    .extend(collect_projection_reads(&expr.expr));
10253            }
10254        }
10255        validate_availability_when(rule, &when.text, semantic, &binding_types, diagnostics);
10256    }
10257    validate_case_blocks(rule, semantic, &binding_types, diagnostics);
10258    metadata.case_branches =
10259        collect_rule_case_metadata(rule, semantic, &binding_types, diagnostics);
10260    let terminal_metadata = collect_terminal_case_metadata(
10261        rule,
10262        semantic,
10263        &binding_types,
10264        &effect_payload_types,
10265        diagnostics,
10266    );
10267    // Complete value-position root set: typed bindings plus every binding NAME
10268    // the body introduces (AST-collected, so multi-line-prompt `tell`/`exec`
10269    // results and `case` payloads are covered, which `binding_types` omits).
10270    let mut known_roots: BTreeSet<String> = binding_types.keys().cloned().collect();
10271    collect_all_binding_names(&body_ast.statements, &mut known_roots);
10272    validate_record_blocks(rule, semantic, &binding_types, &known_roots, diagnostics);
10273    validate_effect_payloads(rule, semantic, &binding_types, &known_roots, diagnostics);
10274    validate_effect_field_roots(rule, &body_ast.statements, &known_roots, diagnostics);
10275    validate_emit_signal_declarations(
10276        rule,
10277        &body_ast.statements,
10278        &semantic.schemas.events,
10279        diagnostics,
10280    );
10281    validate_workflow_invocations(rule, semantic, &binding_types, &known_roots, diagnostics);
10282    validate_milestone_statements(rule, semantic, diagnostics);
10283    let mut block_stack: Vec<BlockFrame> = Vec::new();
10284    let mut misplaced_effect_bindings = BTreeSet::new();
10285    seed_ast_only_effect_bindings(&body_ast.statements, &mut seen_bindings, &mut binding_types);
10286    validate_body_effect_operands(
10287        rule,
10288        &body_ast.statements,
10289        semantic,
10290        &binding_types,
10291        diagnostics,
10292    );
10293    validate_coordination_discipline(rule, &body_ast.statements, diagnostics);
10294    // `redact <source> keep [..] as <out>`: the source must resolve to a known
10295    // schema and every kept field must exist on it (fail-closed).
10296    validate_redactions(
10297        rule,
10298        &body_ast.statements,
10299        semantic,
10300        &binding_types,
10301        diagnostics,
10302    );
10303    // Family B read-narrowing: a presence-conditioned field is readable only inside a
10304    // matching `case <root>.<disc>` arm (starts with nothing allowed at the rule top).
10305    validate_conditioned_field_reads(
10306        rule,
10307        &body_ast.statements,
10308        semantic,
10309        &binding_types,
10310        &BTreeSet::new(),
10311        diagnostics,
10312    );
10313    let mut anonymous_effects = 0usize;
10314    let mut record_depth = 0i32;
10315
10316    for raw_line in rule.body.text.lines() {
10317        let line = raw_line.trim();
10318        if line.is_empty() {
10319            continue;
10320        }
10321
10322        if record_depth > 0 {
10323            record_depth += brace_delta(line);
10324            continue;
10325        }
10326
10327        if let Some(binding) = binding_after_multiline_string_end(line) {
10328            misplaced_effect_bindings.insert(binding.clone());
10329            diagnostics.push(Diagnostic { related: Vec::new(),
10330                span: rule.body.span,
10331                message: format!(
10332                    "rule `{}` places effect binding `{binding}` after a multiline string delimiter",
10333                    rule.name.name
10334                ),
10335                suggestion: Some(format!(
10336                    "move `as {binding}` onto the effect line, before the multiline string body"
10337                )),
10338            });
10339            continue;
10340        }
10341        validate_rule_prompt_content_type_annotation(rule, line, diagnostics);
10342
10343        if line.starts_with('}') {
10344            block_stack.pop();
10345            continue;
10346        }
10347
10348        if line.starts_with("case ") || (!line.starts_with("after ") && is_case_branch_start(line))
10349        {
10350            validate_known_field_paths(rule, line, semantic, &binding_types, diagnostics);
10351            continue;
10352        }
10353
10354        let active_afters = after_scopes(&block_stack);
10355        validate_binding_uses(rule, line, &seen_bindings, &active_afters, diagnostics);
10356        validate_known_field_paths(rule, line, semantic, &binding_types, diagnostics);
10357
10358        if let Some(binding) = parse_consume_line(line) {
10359            match binding_types.get(&binding) {
10360                Some(schema) => metadata.fact_consumes.push(format!("schema:{schema}")),
10361                None => diagnostics.push(Diagnostic {
10362                    related: Vec::new(),
10363                    span: rule.body.span,
10364                    message: format!(
10365                        "rule `{}` consumes unknown fact binding `{binding}`",
10366                        rule.name.name
10367                    ),
10368                    suggestion: Some(
10369                        "consume a binding introduced by a `when Class as binding` clause"
10370                            .to_owned(),
10371                    ),
10372                }),
10373            }
10374            if !line.contains("->") {
10375                continue;
10376            }
10377        }
10378
10379        if line.starts_with("after ") {
10380            if let Some(alias) = binding_after_as(line) {
10381                validate_binding_name(rule, &alias, rule.body.span, diagnostics);
10382            }
10383            match parse_after_line(line) {
10384                Some((binding, predicate)) => {
10385                    if !seen_bindings.contains(&binding) {
10386                        let suggestion = if misplaced_effect_bindings.contains(&binding) {
10387                            format!(
10388                                "move `as {binding}` onto the effect line before the multiline string"
10389                            )
10390                        } else {
10391                            format!("create an effect with `as {binding}` before the `after` block")
10392                        };
10393                        diagnostics.push(Diagnostic { related: Vec::new(),
10394                            span: rule.body.span,
10395                            message: format!(
10396                                "rule `{}` has `after` block for unknown effect binding `{binding}`",
10397                                rule.name.name
10398                            ),
10399                            suggestion: Some(suggestion),
10400                        });
10401                    }
10402                    block_stack.push(BlockFrame::After { binding, predicate });
10403                }
10404                None => {
10405                    diagnostics.push(Diagnostic { related: Vec::new(),
10406                        span: rule.body.span,
10407                        message: format!(
10408                            "rule `{}` has unsupported `after` dependency predicate",
10409                            rule.name.name
10410                        ),
10411                        suggestion: Some(
10412                            "use `after name succeeds`, `after name fails`, `after name completes`, `after name times out`, or `after name cancelled`"
10413                                .to_owned(),
10414                        ),
10415                    });
10416                }
10417            }
10418            continue;
10419        }
10420
10421        if let Some((schema, _)) = parse_record_start(line) {
10422            if is_observer_only_schema(&schema) {
10423                diagnostics.push(Diagnostic {
10424                    related: Vec::new(),
10425                    span: rule.body.span,
10426                    message: format!(
10427                        "rule `{}` cannot record kernel-owned terminal schema `{schema}`",
10428                        rule.name.name
10429                    ),
10430                    suggestion: Some(
10431                        "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`"
10432                            .to_owned(),
10433                    ),
10434                });
10435            } else if !semantic.schemas.class_exists(&schema) {
10436                diagnostics.push(Diagnostic {
10437                    related: Vec::new(),
10438                    span: rule.body.span,
10439                    message: format!("rule `{}` records unknown class `{schema}`", rule.name.name),
10440                    suggestion: Some(format!("declare `class {schema}` before recording it")),
10441                });
10442            }
10443            metadata.fact_writes.push(format!("schema:{schema}"));
10444            record_depth = brace_delta(line).max(1);
10445            continue;
10446        }
10447
10448        if let Some((kind, binding)) = parse_effect_line(line) {
10449            validate_agent_tell_target(
10450                rule,
10451                line,
10452                &kind,
10453                semantic,
10454                &binding_types,
10455                &known_roots,
10456                diagnostics,
10457            );
10458            anonymous_effects += 1;
10459            let id = binding
10460                .clone()
10461                .unwrap_or_else(|| format!("effect{anonymous_effects}"));
10462            if let Some(binding) = &binding {
10463                validate_binding_name(rule, binding, rule.body.span, diagnostics);
10464                seen_bindings.insert(binding.clone());
10465                if let Some(schema) = effect_binding_schema(line, &kind, semantic) {
10466                    binding_types.insert(binding.clone(), schema);
10467                }
10468            }
10469            for (upstream, predicate) in after_scopes(&block_stack) {
10470                metadata.dependencies.push(IrEffectDependency {
10471                    upstream,
10472                    predicate,
10473                    downstream: id.clone(),
10474                });
10475            }
10476            let idempotency_key = effect_idempotency_key(&rule.name.name, &id, &kind, &binding);
10477            metadata.effects.push(IrEffectNode {
10478                id,
10479                kind,
10480                binding,
10481                required_capabilities: parse_required_capabilities(line),
10482                construct_use: None,
10483                idempotency_key,
10484                span: rule.body.span,
10485                timeout_seconds: None,
10486                // The line-scanner result is overwritten by collect_effects_from_ast
10487                // below (which carries the real grants); empty here is fine.
10488                access_grants: Vec::new(),
10489                turn_skills: Vec::new(),
10490                resource: None,
10491                agent: None,
10492                workflow_target: None,
10493                endorsed: false,
10494                declassified: false,
10495                selected_by: None,
10496                exec_target: None,
10497            });
10498        }
10499    }
10500
10501    let (ast_effects, ast_dependencies) =
10502        collect_effects_from_ast(&body_ast.statements, &rule.name.name);
10503    metadata.effects = ast_effects;
10504    metadata.dependencies = ast_dependencies;
10505
10506    // `exec ... -> each Schema` produces one `Schema` fact per stream element
10507    // (spec/json-ingestion.md) — a fact write for liveness and effect-graph
10508    // analysis, like `record`.
10509    push_ingest_fact_writes(&body_ast.statements, &mut metadata.fact_writes);
10510
10511    metadata.fact_reads.sort();
10512    metadata.fact_reads.dedup();
10513    sort_projection_reads(&mut metadata.projection_reads);
10514    metadata.fact_writes.sort();
10515    metadata.fact_writes.dedup();
10516    metadata.fact_consumes.sort();
10517    metadata.fact_consumes.dedup();
10518    metadata.terminal_outputs = terminal_metadata.outputs;
10519    metadata.terminal_branches = terminal_metadata.branches;
10520    // DR-0044 Q5 / P1: an after-arm `case … where <guard>` guard query observes
10521    // live fact state at continuation time — the same firing-decision implicit
10522    // flow as a `when`-guard query (the IFC checker reads `projection_reads` to
10523    // taint guard-gated egresses). Fold both case families' arm guards into
10524    // `projection_reads` so the analysis sees them; the `when`-guard queries were
10525    // added above.
10526    for branch in &metadata.case_branches {
10527        if let Some(guard) = &branch.guard {
10528            metadata
10529                .projection_reads
10530                .extend(collect_projection_reads(&guard.expr));
10531        }
10532    }
10533    for branch in &metadata.terminal_branches {
10534        if let Some(guard) = &branch.guard {
10535            metadata
10536                .projection_reads
10537                .extend(collect_projection_reads(&guard.expr));
10538        }
10539    }
10540    sort_projection_reads(&mut metadata.projection_reads);
10541    collect_terminal_complete_bindings(&body_ast.statements, &mut metadata.terminal_completes);
10542    metadata.terminal_completes.sort();
10543    metadata.terminal_completes.dedup();
10544    collect_redaction_metadata(
10545        &body_ast.statements,
10546        &binding_types,
10547        &mut metadata.redactions,
10548    );
10549    collect_bounded_egresses(
10550        &body_ast.statements,
10551        &binding_types,
10552        &mut metadata.bounded_egresses,
10553    );
10554    let mut egress_reads = Vec::new();
10555    collect_egress_payload_reads(&body_ast.statements, &mut egress_reads);
10556    for (sink, roots) in egress_reads {
10557        metadata
10558            .egress_payload_reads
10559            .entry(sink)
10560            .or_default()
10561            .extend(roots);
10562    }
10563    collect_complete_field_reads(&body_ast.statements, &mut metadata.complete_field_reads);
10564    collect_milestone_field_reads(&body_ast.statements, &mut metadata.milestone_field_reads);
10565    collect_crossing_roots(
10566        &body_ast.statements,
10567        &mut metadata.declassified_roots,
10568        &mut metadata.endorsed_roots,
10569    );
10570    collect_provenance_metadata(
10571        &body_ast.statements,
10572        &mut metadata.coerce_input_roots,
10573        &mut metadata.after_aliases,
10574    );
10575    collect_egress_case_influence(
10576        &body_ast.statements,
10577        &mut Vec::new(),
10578        &mut metadata.egress_case_influence,
10579    );
10580    // Redaction closure over marked roots (redact ∘ marked-crossing): a
10581    // `redact <marked-output> keep […] as out` projection is still the
10582    // crossing's carrier — a redaction can only NARROW what the marked
10583    // coercion released, and the kept fields are additionally held to their
10584    // per-field schema labels by the redact refinement. Fixpoint so
10585    // redactions of redactions chain.
10586    loop {
10587        let mut changed = false;
10588        for redaction in &metadata.redactions {
10589            if metadata.declassified_roots.contains(&redaction.source)
10590                && metadata
10591                    .declassified_roots
10592                    .insert(redaction.binding.clone())
10593            {
10594                changed = true;
10595            }
10596            if metadata.endorsed_roots.contains(&redaction.source)
10597                && metadata.endorsed_roots.insert(redaction.binding.clone())
10598            {
10599                changed = true;
10600            }
10601        }
10602        if !changed {
10603            break;
10604        }
10605    }
10606    metadata
10607}
10608
10609/// Collect, per egress sink, the binding roots of every enclosing `case`
10610/// scrutinee (DR-0046 selector influence). The active-scrutinee stack is
10611/// threaded through nesting; each egress statement records the union of the
10612/// stack at its position, keyed exactly like `collect_egress_payload_reads`.
10613fn collect_egress_case_influence(
10614    statements: &[body::BodyStmt],
10615    active: &mut Vec<BTreeSet<String>>,
10616    out: &mut BTreeMap<String, BTreeSet<String>>,
10617) {
10618    let record_sink = |sink: String,
10619                       active: &[BTreeSet<String>],
10620                       out: &mut BTreeMap<String, BTreeSet<String>>| {
10621        if active.is_empty() {
10622            return;
10623        }
10624        let entry = out.entry(sink).or_default();
10625        for roots in active {
10626            entry.extend(roots.iter().cloned());
10627        }
10628    };
10629    for statement in statements {
10630        match statement {
10631            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10632                record_sink(terminal.name.clone(), active, out);
10633            }
10634            body::BodyStmt::Record(record) => {
10635                record_sink(format!("fact:{}", record.schema), active, out);
10636            }
10637            body::BodyStmt::Done {
10638                replacement: Some(record),
10639                ..
10640            } => {
10641                record_sink(format!("fact:{}", record.schema), active, out);
10642            }
10643            body::BodyStmt::Milestone { name, .. } => {
10644                record_sink(format!("milestone:{name}"), active, out);
10645            }
10646            body::BodyStmt::Effect(effect) => match &effect.kind {
10647                body::BodyEffectKind::ConstructCapabilityCall {
10648                    keyword, fields, ..
10649                } if keyword == "send" => {
10650                    if let Some(channel) = fields
10651                        .iter()
10652                        .find(|field| field.name == "channel")
10653                        .map(|field| field.source.clone())
10654                    {
10655                        record_sink(channel, active, out);
10656                    }
10657                }
10658                body::BodyEffectKind::FileWrite { store, .. } => {
10659                    record_sink(store.clone(), active, out);
10660                }
10661                _ => {}
10662            },
10663            body::BodyStmt::After(after) => {
10664                collect_egress_case_influence(&after.body, active, out);
10665            }
10666            body::BodyStmt::Case(case) => {
10667                let mut roots = BTreeSet::new();
10668                if let Ok(expr) = parse_expression(&case.scrutinee) {
10669                    collect_expr_binding_roots(&expr, &mut roots);
10670                } else {
10671                    collect_template_binding_roots(&case.scrutinee, &mut roots);
10672                }
10673                active.push(roots);
10674                for branch in &case.branches {
10675                    collect_egress_case_influence(&branch.body, active, out);
10676                }
10677                active.pop();
10678            }
10679            _ => {}
10680        }
10681    }
10682}
10683
10684/// Collect the raw structure input-side provenance narrowing resolves over:
10685/// each coerce's argument-expression binding roots (template-scan fallback for
10686/// unparseable sources, same discipline as `send_payload_reads`), and the
10687/// `after … succeeds|completes as` alias map.
10688fn collect_provenance_metadata(
10689    statements: &[body::BodyStmt],
10690    coerce_input_roots: &mut BTreeMap<String, BTreeSet<String>>,
10691    after_aliases: &mut BTreeMap<String, String>,
10692) {
10693    for statement in statements {
10694        match statement {
10695            body::BodyStmt::Effect(effect) => {
10696                if let body::BodyEffectKind::Coerce { args, .. } = &effect.kind {
10697                    if let Some(binding) = &effect.binding {
10698                        let mut roots = BTreeSet::new();
10699                        for arg in args {
10700                            if let Ok(expr) = parse_expression(arg) {
10701                                collect_expr_binding_roots(&expr, &mut roots);
10702                            } else {
10703                                collect_template_binding_roots(arg, &mut roots);
10704                            }
10705                        }
10706                        coerce_input_roots
10707                            .entry(binding.clone())
10708                            .or_default()
10709                            .extend(roots);
10710                    }
10711                }
10712            }
10713            body::BodyStmt::After(after) => {
10714                if matches!(
10715                    after.predicate,
10716                    body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
10717                ) {
10718                    if let Some(alias) = &after.alias {
10719                        after_aliases.insert(alias.clone(), after.binding.clone());
10720                    }
10721                }
10722                collect_provenance_metadata(&after.body, coerce_input_roots, after_aliases);
10723            }
10724            body::BodyStmt::Case(case) => {
10725                for branch in &case.branches {
10726                    collect_provenance_metadata(&branch.body, coerce_input_roots, after_aliases);
10727                }
10728            }
10729            _ => {}
10730        }
10731    }
10732}
10733
10734/// Collect the output roots of marked crossings (`coerce … declassified` /
10735/// `coerce … endorsed`, DR-0027 I-IFC3): each marked coerce's binding, plus the
10736/// aliases its `after <binding> succeeds|completes as <alias>` branches bind —
10737/// the names an egress payload actually references. Two passes so an `after`
10738/// textually preceding nothing is impossible to miss; aliases of aliases cannot
10739/// occur (an `after` subject is always an effect binding).
10740fn collect_crossing_roots(
10741    statements: &[body::BodyStmt],
10742    declassified: &mut BTreeSet<String>,
10743    endorsed: &mut BTreeSet<String>,
10744) {
10745    fn collect_marked(
10746        statements: &[body::BodyStmt],
10747        declassified: &mut BTreeSet<String>,
10748        endorsed: &mut BTreeSet<String>,
10749    ) {
10750        for statement in statements {
10751            match statement {
10752                body::BodyStmt::Effect(effect) => {
10753                    if let body::BodyEffectKind::Coerce {
10754                        declassified: is_declassified,
10755                        endorsed: is_endorsed,
10756                        ..
10757                    } = &effect.kind
10758                    {
10759                        if let Some(binding) = &effect.binding {
10760                            if *is_declassified {
10761                                declassified.insert(binding.clone());
10762                            }
10763                            if *is_endorsed {
10764                                endorsed.insert(binding.clone());
10765                            }
10766                        }
10767                    }
10768                }
10769                body::BodyStmt::After(after) => collect_marked(&after.body, declassified, endorsed),
10770                body::BodyStmt::Case(case) => {
10771                    for branch in &case.branches {
10772                        collect_marked(&branch.body, declassified, endorsed);
10773                    }
10774                }
10775                _ => {}
10776            }
10777        }
10778    }
10779    fn collect_aliases(
10780        statements: &[body::BodyStmt],
10781        declassified: &mut BTreeSet<String>,
10782        endorsed: &mut BTreeSet<String>,
10783    ) {
10784        for statement in statements {
10785            match statement {
10786                body::BodyStmt::After(after) => {
10787                    if matches!(
10788                        after.predicate,
10789                        body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
10790                    ) {
10791                        if let Some(alias) = &after.alias {
10792                            if declassified.contains(&after.binding) {
10793                                declassified.insert(alias.clone());
10794                            }
10795                            if endorsed.contains(&after.binding) {
10796                                endorsed.insert(alias.clone());
10797                            }
10798                        }
10799                    }
10800                    collect_aliases(&after.body, declassified, endorsed);
10801                }
10802                body::BodyStmt::Case(case) => {
10803                    for branch in &case.branches {
10804                        collect_aliases(&branch.body, declassified, endorsed);
10805                    }
10806                }
10807                _ => {}
10808            }
10809        }
10810    }
10811    collect_marked(statements, declassified, endorsed);
10812    // Aliases can nest under other afters, so run the alias pass to a fixpoint
10813    // over the (small) statement tree: one extra pass suffices in practice, but
10814    // loop until stable so deep nesting cannot order-skip an alias.
10815    loop {
10816        let before = (declassified.len(), endorsed.len());
10817        collect_aliases(statements, declassified, endorsed);
10818        if (declassified.len(), endorsed.len()) == before {
10819            break;
10820        }
10821    }
10822}
10823
10824/// For each `complete <binding> { field: <expr>, … }` egress in a rule body
10825/// (recursing into nested blocks), the binding roots EACH result field references,
10826/// as `binding -> field -> {roots}`. A `Shorthand` field (`complete result from src
10827/// { f }`) resolves to the terminal's `from` binding. Unlike
10828/// `collect_egress_payload_reads` (which joins a sink's fields), this keeps fields
10829/// separate so the IFC engine can compute a per-field flow signature (DR-0030 X2
10830/// v2). Union across branches (a field completed in two arms references the union).
10831fn collect_complete_field_reads(
10832    statements: &[body::BodyStmt],
10833    out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
10834) {
10835    for statement in statements {
10836        match statement {
10837            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10838                let per_field = out.entry(terminal.name.clone()).or_default();
10839                for field in &terminal.fields {
10840                    let mut roots = BTreeSet::new();
10841                    match &field.value {
10842                        body::FieldValue::Shorthand => {
10843                            if let Some(root) = &terminal.from {
10844                                roots.insert(root.clone());
10845                            }
10846                        }
10847                        body::FieldValue::Expr { expr, .. } => {
10848                            collect_expr_binding_roots(expr, &mut roots)
10849                        }
10850                        body::FieldValue::Nested { fields, .. } => collect_payload_field_roots(
10851                            fields,
10852                            terminal.from.as_deref(),
10853                            &mut roots,
10854                        ),
10855                    }
10856                    per_field
10857                        .entry(field.name.clone())
10858                        .or_default()
10859                        .extend(roots);
10860                }
10861            }
10862            body::BodyStmt::After(after) => collect_complete_field_reads(&after.body, out),
10863            body::BodyStmt::Case(case) => {
10864                for branch in &case.branches {
10865                    collect_complete_field_reads(&branch.body, out);
10866                }
10867            }
10868            _ => {}
10869        }
10870    }
10871}
10872
10873/// For each `emit milestone "<name>" { field: <expr>, … }` egress in a rule body
10874/// (recursing into nested blocks), collect the binding roots EACH milestone field
10875/// references. This mirrors `collect_complete_field_reads`: the IFC checker uses it
10876/// to expose and gate a child-to-parent milestone payload with a per-field flow
10877/// signature (D3′).
10878fn collect_milestone_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::Milestone { name, fields, .. } => {
10885                let per_field = out.entry(name.clone()).or_default();
10886                for field in fields {
10887                    let mut roots = BTreeSet::new();
10888                    match &field.value {
10889                        body::FieldValue::Shorthand => {}
10890                        body::FieldValue::Expr { expr, .. } => {
10891                            collect_expr_binding_roots(expr, &mut roots)
10892                        }
10893                        body::FieldValue::Nested { fields, .. } => {
10894                            collect_payload_field_roots(fields, None, &mut roots)
10895                        }
10896                    }
10897                    per_field
10898                        .entry(field.name.clone())
10899                        .or_default()
10900                        .extend(roots);
10901                }
10902            }
10903            body::BodyStmt::After(after) => collect_milestone_field_reads(&after.body, out),
10904            body::BodyStmt::Case(case) => {
10905                for branch in &case.branches {
10906                    collect_milestone_field_reads(&branch.body, out);
10907                }
10908            }
10909            _ => {}
10910        }
10911    }
10912}
10913
10914/// Collects the `redact <source> keep [..] as <out>` projections of a rule body
10915/// (recursing into nested blocks) as IFC value-flow metadata, preserving body
10916/// order so a chained redaction's source resolves against the earlier projection.
10917/// `binding_types` (the rule's fully-resolved binding -> schema map, including
10918/// redaction outputs via their synthetic class) supplies each source's schema so
10919/// the IFC engine can derive the projection's per-field label.
10920fn collect_redaction_metadata(
10921    statements: &[body::BodyStmt],
10922    binding_types: &BTreeMap<String, String>,
10923    out: &mut Vec<IrRedaction>,
10924) {
10925    let mut redacts = Vec::new();
10926    collect_redact_effects(statements, &mut redacts);
10927    for (source, keep, binding, _span) in redacts {
10928        out.push(IrRedaction {
10929            source: source.to_owned(),
10930            keep: keep.to_vec(),
10931            binding: binding.to_owned(),
10932            source_schema: binding_types.get(source).cloned(),
10933        });
10934    }
10935}
10936
10937/// Collect the bounded-type projection egresses (`record <T> from <src>`) of a rule
10938/// body (recursing into nested blocks). A `record T from src` keeps exactly `T`'s
10939/// declared fields, copied from `src`, so the IFC engine can govern it by the kept
10940/// fields' per-field labels (sourced from `src`'s schema) — the "bounded-type"
10941/// auto-redaction reading. Only recorded when the source schema resolves and the
10942/// target type is declared; otherwise the egress stays conservative.
10943/// Records a bounded-type projection egress for a PURE `from` projection — a
10944/// `from <src>` egress every field of which is a shorthand copy of `src.<name>`.
10945/// The runtime materializes exactly these fields, so the kept set is their names,
10946/// governed by `src`'s schema per-field labels. `None` source schema, no `from`, or
10947/// any explicit value field → not a clean projection, so it stays conservative
10948/// (handled by the whole-read join). `sink` is the engine sink string
10949/// (`fact:<Schema>` for a record, the completed binding for a `complete`).
10950fn push_bounded_projection(
10951    from: Option<&str>,
10952    fields: &[body::FieldAssign],
10953    sink: String,
10954    binding_types: &BTreeMap<String, String>,
10955    out: &mut Vec<IrBoundedEgress>,
10956) {
10957    let Some(source_schema) = from.and_then(|src| binding_types.get(src)) else {
10958        return;
10959    };
10960    if fields.is_empty()
10961        || !fields
10962            .iter()
10963            .all(|field| matches!(field.value, body::FieldValue::Shorthand))
10964    {
10965        return;
10966    }
10967    out.push(IrBoundedEgress {
10968        sink,
10969        source_schema: source_schema.clone(),
10970        keep: fields.iter().map(|field| field.name.clone()).collect(),
10971    });
10972}
10973
10974fn push_bounded_record(
10975    record: &body::RecordStmt,
10976    binding_types: &BTreeMap<String, String>,
10977    out: &mut Vec<IrBoundedEgress>,
10978) {
10979    push_bounded_projection(
10980        record.from.as_deref(),
10981        &record.fields,
10982        format!("fact:{}", record.schema),
10983        binding_types,
10984        out,
10985    );
10986}
10987
10988fn collect_bounded_egresses(
10989    statements: &[body::BodyStmt],
10990    binding_types: &BTreeMap<String, String>,
10991    out: &mut Vec<IrBoundedEgress>,
10992) {
10993    for statement in statements {
10994        match statement {
10995            body::BodyStmt::Record(record) => push_bounded_record(record, binding_types, out),
10996            body::BodyStmt::Done {
10997                replacement: Some(record),
10998                ..
10999            } => push_bounded_record(record, binding_types, out),
11000            // `complete <T> from <src> { … }`: bounded-type projection to the invoker.
11001            // The engine sink for a complete is the completed binding (its name).
11002            body::BodyStmt::Terminal(terminal)
11003                if terminal.kind == body::TerminalKind::Complete && terminal.from.is_some() =>
11004            {
11005                push_bounded_projection(
11006                    terminal.from.as_deref(),
11007                    &terminal.fields,
11008                    terminal.name.clone(),
11009                    binding_types,
11010                    out,
11011                );
11012            }
11013            body::BodyStmt::After(after) => {
11014                collect_bounded_egresses(&after.body, binding_types, out)
11015            }
11016            body::BodyStmt::Case(case) => {
11017                for branch in &case.branches {
11018                    collect_bounded_egresses(&branch.body, binding_types, out);
11019                }
11020            }
11021            _ => {}
11022        }
11023    }
11024}
11025
11026/// Collect EVERY binding root referenced by an expression, for the information-flow
11027/// value-flow engine. SOUNDNESS: a missed reference under-approximates a payload's
11028/// sources — so this over-collects (an over-collected name that is not a relevant
11029/// binding contributes nothing downstream). It walks every `Expr` variant and, for
11030/// string literals, extracts `{{ … }}` interpolation roots (those refs live as raw
11031/// text inside the literal, not as structured nodes). A bare identifier parses as
11032/// `Literal(Ident)`, a dotted ref as `Path` — both are roots.
11033fn collect_expr_binding_roots(expr: &Expr, out: &mut BTreeSet<String>) {
11034    match expr {
11035        Expr::Literal(ExprLiteral::String(text)) => collect_template_binding_roots(text, out),
11036        Expr::Literal(ExprLiteral::Ident(name)) => {
11037            out.insert(name.clone());
11038        }
11039        Expr::Literal(ExprLiteral::Number(_) | ExprLiteral::Bool(_) | ExprLiteral::Null) => {}
11040        Expr::Path(segments) => {
11041            if let Some(root) = segments.first() {
11042                out.insert(root.clone());
11043            }
11044        }
11045        Expr::Index { target, key } => {
11046            collect_expr_binding_roots(target, out);
11047            collect_expr_binding_roots(key, out);
11048        }
11049        Expr::Array(items) => {
11050            for item in items {
11051                collect_expr_binding_roots(item, out);
11052            }
11053        }
11054        Expr::Object(fields) => {
11055            for field in fields {
11056                collect_expr_binding_roots(&field.value, out);
11057            }
11058        }
11059        Expr::Unary { expr, .. } => collect_expr_binding_roots(expr, out),
11060        Expr::Binary { left, right, .. } => {
11061            collect_expr_binding_roots(left, out);
11062            collect_expr_binding_roots(right, out);
11063        }
11064        Expr::Call { args, .. } => {
11065            for arg in args {
11066                collect_expr_binding_roots(arg, out);
11067            }
11068        }
11069        Expr::Query { head, guard, .. } => {
11070            out.insert(head.clone());
11071            if let Some(guard) = guard {
11072                collect_expr_binding_roots(guard, out);
11073            }
11074        }
11075    }
11076}
11077
11078/// Collect every binding root inside `{{ … }}` interpolations of a string. Unlike
11079/// `interpolation_roots` (first root per interpolation), value-flow needs EVERY
11080/// root, so `{{ a.b + c.d }}` yields both `a` and `c`. Each interpolation body is
11081/// parsed and walked; an unparseable body falls back to a conservative identifier
11082/// scan (over-collection is sound).
11083fn collect_template_binding_roots(text: &str, out: &mut BTreeSet<String>) {
11084    let mut rest = text;
11085    while let Some(open) = rest.find("{{") {
11086        let after_open = &rest[open + 2..];
11087        let Some(close) = after_open.find("}}") else {
11088            break;
11089        };
11090        let body = after_open[..close].trim();
11091        if let Ok(expr) = parse_expression(body) {
11092            collect_expr_binding_roots(&expr, out);
11093        } else {
11094            for token in body.split(|ch: char| !ch.is_alphanumeric() && ch != '_') {
11095                if token
11096                    .as_bytes()
11097                    .first()
11098                    .is_some_and(|byte| is_ident_start(*byte))
11099                {
11100                    out.insert(token.to_owned());
11101                }
11102            }
11103        }
11104        rest = &after_open[close + 2..];
11105    }
11106}
11107
11108/// Collect the binding roots a payload field list references, threading the
11109/// enclosing `from <binding>` source so a `Shorthand` field resolves to it.
11110fn collect_payload_field_roots(
11111    fields: &[body::FieldAssign],
11112    from_binding: Option<&str>,
11113    out: &mut BTreeSet<String>,
11114) {
11115    for field in fields {
11116        match &field.value {
11117            body::FieldValue::Shorthand => {
11118                if let Some(root) = from_binding {
11119                    out.insert(root.to_owned());
11120                }
11121            }
11122            body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, out),
11123            body::FieldValue::Nested { fields, .. } => {
11124                collect_payload_field_roots(fields, from_binding, out)
11125            }
11126        }
11127    }
11128}
11129
11130/// For each egress sink in a rule body (recursing into nested blocks), the set of
11131/// binding roots its payload references, keyed by the sink string the IFC engine
11132/// uses: a `complete <binding>` by its binding, a `record <Schema>` by
11133/// `fact:<Schema>`. Surfaced so the engine can recognize a FULLY-REDACTED egress —
11134/// one whose payload references only redaction outputs (and constants) — and
11135/// govern it by the projection's per-field label instead of the rule's whole read
11136/// set. A `record <Schema> from <binding>` references that `from` binding too (its
11137/// fields are copied). A sink with no recorded entry references nothing resolvable.
11138fn collect_egress_payload_reads(
11139    statements: &[body::BodyStmt],
11140    out: &mut Vec<(String, BTreeSet<String>)>,
11141) {
11142    for statement in statements {
11143        match statement {
11144            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
11145                let mut roots = BTreeSet::new();
11146                collect_payload_field_roots(&terminal.fields, None, &mut roots);
11147                // A bare scalar payload's value expression is the whole egress
11148                // value; its binding roots must join the sink's label (fail-closed
11149                // — otherwise `complete result secret.value` would under-report).
11150                if let Some(body::FieldValue::Expr { expr, .. }) = &terminal.scalar {
11151                    collect_expr_binding_roots(expr, &mut roots);
11152                }
11153                out.push((terminal.name.clone(), roots));
11154            }
11155            body::BodyStmt::Record(record) => out.push(record_payload_reads(record)),
11156            // `done <b> -> record <Schema> { … }` is also a record egress.
11157            body::BodyStmt::Done {
11158                replacement: Some(record),
11159                ..
11160            } => out.push(record_payload_reads(record)),
11161            body::BodyStmt::Milestone { name, fields, .. } => {
11162                let mut roots = BTreeSet::new();
11163                collect_payload_field_roots(fields, None, &mut roots);
11164                out.push((format!("milestone:{name}"), roots));
11165            }
11166            // `send via <channel> { text … }` egresses to the channel; its payload
11167            // fields (text/markdown/thread_id) are construct-use source text. Keyed by
11168            // the channel (the engine's send sink, per `resource_for_body`). A
11169            // `write … to <store>` is likewise an egress to the store: its body
11170            // AND path expressions are the payload (a path can encode data too),
11171            // keyed by the store handle.
11172            body::BodyStmt::Effect(effect) => match &effect.kind {
11173                body::BodyEffectKind::ConstructCapabilityCall {
11174                    keyword, fields, ..
11175                } if keyword == "send" => {
11176                    if let Some(reads) = send_payload_reads(fields) {
11177                        out.push(reads);
11178                    }
11179                }
11180                body::BodyEffectKind::FileWrite {
11181                    store, path, body, ..
11182                } => {
11183                    let mut roots = BTreeSet::new();
11184                    for source in [path, body] {
11185                        if let Ok(expr) = parse_expression(source) {
11186                            collect_expr_binding_roots(&expr, &mut roots);
11187                        } else {
11188                            collect_template_binding_roots(source, &mut roots);
11189                        }
11190                    }
11191                    out.push((store.clone(), roots));
11192                }
11193                _ => {}
11194            },
11195            body::BodyStmt::After(after) => collect_egress_payload_reads(&after.body, out),
11196            body::BodyStmt::Case(case) => {
11197                for branch in &case.branches {
11198                    collect_egress_payload_reads(&branch.body, out);
11199                }
11200            }
11201            _ => {}
11202        }
11203    }
11204}
11205
11206/// The channel sink key and the binding roots a `send` payload references. The
11207/// payload fields (`text`/`markdown`/`thread_id`) carry expression SOURCE TEXT, so
11208/// each is parsed and walked (a string literal's `{{ … }}` interpolations count);
11209/// the `channel` field names the sink. `None` if no channel is present.
11210fn send_payload_reads(fields: &[body::ConstructUseField]) -> Option<(String, BTreeSet<String>)> {
11211    let channel = fields
11212        .iter()
11213        .find(|field| field.name == "channel")
11214        .map(|field| field.source.clone())?;
11215    let mut roots = BTreeSet::new();
11216    for field in fields.iter().filter(|field| field.name != "channel") {
11217        if let Ok(expr) = parse_expression(&field.source) {
11218            collect_expr_binding_roots(&expr, &mut roots);
11219        } else {
11220            // Unparseable source: scan its interpolations conservatively.
11221            collect_template_binding_roots(&field.source, &mut roots);
11222        }
11223    }
11224    Some((channel, roots))
11225}
11226
11227/// The `fact:<Schema>` sink key and the binding roots a `record` payload
11228/// references — its explicit field values plus, for `record <S> from <b>`, the
11229/// copied-from binding `b`.
11230fn record_payload_reads(record: &body::RecordStmt) -> (String, BTreeSet<String>) {
11231    let mut roots = BTreeSet::new();
11232    if let Some(from) = &record.from {
11233        roots.insert(from.clone());
11234    }
11235    collect_payload_field_roots(&record.fields, record.from.as_deref(), &mut roots);
11236    (format!("fact:{}", record.schema), roots)
11237}
11238
11239#[derive(Clone, Debug, Default)]
11240struct TerminalMetadata {
11241    outputs: Vec<IrTerminalOutput>,
11242    branches: Vec<IrTerminalCaseBranch>,
11243}
11244
11245#[derive(Clone, Debug)]
11246struct TerminalBranchSource {
11247    scrutinee: String,
11248    pattern: String,
11249    guard: Option<String>,
11250    body: String,
11251    pattern_span: SourceSpan,
11252}
11253
11254#[derive(Clone, Debug)]
11255struct RuleCaseBranchSource {
11256    scrutinee: String,
11257    scrutinee_type: TypeSyntax,
11258    pattern: String,
11259    guard: Option<String>,
11260    body: String,
11261    pattern_span: SourceSpan,
11262}
11263
11264fn collect_effect_payload_types(
11265    rule: &RuleDecl,
11266    semantic: &SemanticContext,
11267    diagnostics: &mut Vec<Diagnostic>,
11268) -> BTreeMap<String, IrType> {
11269    let mut payloads = BTreeMap::new();
11270    for statement in effect_payload_statements(&rule.body.text) {
11271        let line = statement.trim();
11272        let Some((kind, Some(binding))) = parse_effect_line(line) else {
11273            continue;
11274        };
11275        let payload = terminal_completed_payload_type(line, &kind, semantic);
11276        // A binding name keys the per-rule payload map, so reusing it for two effects
11277        // with DIFFERENT result types makes `after <binding> …` ambiguous (§5.5).
11278        // Same-type reuse (and mutually-exclusive `case` arms, which never both run)
11279        // is harmless and left alone.
11280        match payloads.get(&binding) {
11281            Some(existing) if existing != &payload => {
11282                diagnostics.push(Diagnostic {
11283                    related: Vec::new(),
11284                    span: rule.body.span,
11285                    message: format!(
11286                        "rule `{}` reuses effect binding `{binding}` for effects with conflicting result types",
11287                        rule.name.name
11288                    ),
11289                    suggestion: Some(format!(
11290                        "give each effect a distinct binding — `as {binding}` is reused with a different result type, so `after {binding} …` is ambiguous"
11291                    )),
11292                });
11293            }
11294            Some(_) => {}
11295            None => {
11296                payloads.insert(binding, payload);
11297            }
11298        }
11299    }
11300
11301    payloads
11302}
11303
11304fn terminal_completed_payload_type(
11305    line: &str,
11306    kind: &IrEffectKind,
11307    semantic: &SemanticContext,
11308) -> IrType {
11309    match kind {
11310        IrEffectKind::SchemaCoerce if line.starts_with("prompt ") => {
11311            IrType::Primitive(IrPrimitiveType::String)
11312        }
11313        IrEffectKind::SchemaCoerce => parse_coerce_call_name(line)
11314            .and_then(|name| semantic.coerce_outputs.get(name))
11315            .cloned()
11316            .map(lower_type)
11317            .unwrap_or_else(terminal_unknown_payload_type),
11318        IrEffectKind::AgentTell => IrType::Ref("AgentTurn".to_owned()),
11319        IrEffectKind::CapabilityCall
11320        | IrEffectKind::EventEmit
11321        | IrEffectKind::WorkflowInvoke
11322        | IrEffectKind::TimerWait
11323        | IrEffectKind::ExecCommand
11324        | IrEffectKind::TrackerFile
11325        | IrEffectKind::TrackerClaim
11326        | IrEffectKind::TrackerRenew
11327        | IrEffectKind::TrackerRelease
11328        | IrEffectKind::TrackerFinish
11329        | IrEffectKind::LeaseAcquire
11330        | IrEffectKind::LeaseRenew
11331        | IrEffectKind::LedgerAppend
11332        | IrEffectKind::CounterConsume
11333        | IrEffectKind::SignalEmit
11334        | IrEffectKind::FileRead
11335        | IrEffectKind::FileWrite
11336        | IrEffectKind::FileImport
11337        | IrEffectKind::FileExport => terminal_unknown_payload_type(),
11338    }
11339}
11340
11341fn collect_rule_case_metadata(
11342    rule: &RuleDecl,
11343    semantic: &SemanticContext,
11344    binding_types: &BTreeMap<String, String>,
11345    diagnostics: &mut Vec<Diagnostic>,
11346) -> Vec<IrRuleCaseBranch> {
11347    let mut branches = Vec::new();
11348    for branch in rule_case_branch_sources(rule, semantic, binding_types) {
11349        let mut branch_scope = binding_types.clone();
11350        if let Some((binding, schema)) =
11351            case_branch_payload_binding(&branch.pattern, &branch.scrutinee_type, semantic)
11352        {
11353            branch_scope.insert(binding, schema);
11354        }
11355        if let Some(guard) = &branch.guard {
11356            validate_expression(
11357                rule,
11358                guard,
11359                semantic,
11360                &branch_scope,
11361                "case guard",
11362                diagnostics,
11363            );
11364            validate_known_field_paths_at_span(
11365                rule,
11366                guard,
11367                branch.pattern_span,
11368                semantic,
11369                &branch_scope,
11370                diagnostics,
11371            );
11372        }
11373        validate_known_field_paths_at_span(
11374            rule,
11375            &branch.body,
11376            branch.pattern_span,
11377            semantic,
11378            &branch_scope,
11379            diagnostics,
11380        );
11381        if let Some(pattern) = lower_case_pattern(&branch.pattern, &branch.scrutinee_type, semantic)
11382        {
11383            branches.push(IrRuleCaseBranch {
11384                scrutinee: branch.scrutinee,
11385                scrutinee_type: lower_type(branch.scrutinee_type),
11386                pattern,
11387                guard: branch.guard.as_ref().and_then(|guard| {
11388                    lower_expression(
11389                        guard,
11390                        SourceSpan {
11391                            start: branch.pattern_span.start,
11392                            end: branch.pattern_span.end,
11393                        },
11394                    )
11395                }),
11396                body_hash: stable_hash(&branch.body),
11397                pattern_span: branch.pattern_span,
11398            });
11399        }
11400    }
11401    branches.sort_by(|left, right| {
11402        (left.scrutinee.as_str(), left.pattern_span.start)
11403            .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
11404    });
11405    branches
11406}
11407
11408fn rule_case_branch_sources(
11409    rule: &RuleDecl,
11410    semantic: &SemanticContext,
11411    binding_types: &BTreeMap<String, String>,
11412) -> Vec<RuleCaseBranchSource> {
11413    let lines = rule
11414        .body
11415        .text
11416        .lines()
11417        .scan(0usize, |offset, line| {
11418            let current = *offset;
11419            *offset += line.len() + 1;
11420            Some((line, current))
11421        })
11422        .collect::<Vec<_>>();
11423    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
11424    let mut branches = Vec::new();
11425    let mut index = 0usize;
11426    while index < lines.len() {
11427        let (line, _) = lines[index];
11428        let trimmed = line.trim();
11429        let Some(scrutinee) = case_scrutinee(trimmed) else {
11430            index += 1;
11431            continue;
11432        };
11433        if active_completes_binding_for_case(&text_lines, index, scrutinee) {
11434            index += 1;
11435            continue;
11436        }
11437        let Some(scrutinee_type) = expression_type(scrutinee, semantic, binding_types) else {
11438            index += 1;
11439            continue;
11440        };
11441        let mut depth = brace_delta(trimmed).max(1);
11442        index += 1;
11443        while index < lines.len() && depth > 0 {
11444            let (branch_line, branch_line_offset) = lines[index];
11445            let branch_trimmed = branch_line.trim();
11446            if depth == 1 {
11447                if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
11448                    let pattern_column = case_pattern_column(branch_line, pattern);
11449                    let pattern_span = SourceSpan {
11450                        start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
11451                        end: rule_body_text_start(rule)
11452                            + branch_line_offset
11453                            + pattern_column
11454                            + pattern.len(),
11455                    };
11456                    let mut body_lines = Vec::new();
11457                    let mut branch_depth = brace_delta(body_start).max(1);
11458                    index += 1;
11459                    while index < lines.len() && branch_depth > 0 {
11460                        let body_line = lines[index].0;
11461                        let next_depth = branch_depth + brace_delta(body_line);
11462                        if next_depth >= 1 {
11463                            body_lines.push(body_line.to_owned());
11464                        }
11465                        branch_depth = next_depth;
11466                        index += 1;
11467                    }
11468                    branches.push(RuleCaseBranchSource {
11469                        scrutinee: scrutinee.to_owned(),
11470                        scrutinee_type: scrutinee_type.clone(),
11471                        pattern: pattern.to_owned(),
11472                        guard,
11473                        body: body_lines.join("\n"),
11474                        pattern_span,
11475                    });
11476                    continue;
11477                }
11478            }
11479            depth += brace_delta(branch_trimmed);
11480            index += 1;
11481        }
11482    }
11483    branches
11484}
11485
11486fn lower_case_pattern(
11487    pattern: &str,
11488    scrutinee_type: &TypeSyntax,
11489    semantic: &SemanticContext,
11490) -> Option<IrCasePattern> {
11491    if is_fallback_pattern(pattern) {
11492        return Some(IrCasePattern::Wildcard);
11493    }
11494    if pattern == "None" {
11495        return Some(IrCasePattern::OptionalNone);
11496    }
11497    if let Some(binding) = pattern.strip_prefix("Some ").map(str::trim) {
11498        if !binding.is_empty() {
11499            return Some(IrCasePattern::OptionalSome {
11500                binding: binding.to_owned(),
11501            });
11502        }
11503    }
11504    match scrutinee_type {
11505        TypeSyntax::Ref { name } if semantic.schemas.enums.contains_key(&name.name) => {
11506            // The IR pattern is the variant name; the payload binding is a
11507            // branch-scope concern, not part of dispatch identity.
11508            let (variant, _) = sum_case_pattern_parts(pattern);
11509            Some(IrCasePattern::EnumVariant(variant.to_owned()))
11510        }
11511        TypeSyntax::Union { .. } => parse_literal_expr(pattern).and_then(|literal| match literal {
11512            LiteralExpr::String(value) => Some(IrCasePattern::LiteralString(value.to_owned())),
11513            LiteralExpr::Ident(value) => Some(IrCasePattern::LiteralString(value.to_owned())),
11514            _ => None,
11515        }),
11516        TypeSyntax::AgentRef { .. } => {
11517            parse_literal_expr(pattern).and_then(|literal| match literal {
11518                LiteralExpr::String(value) | LiteralExpr::Ident(value) => {
11519                    Some(IrCasePattern::Agent(value.to_owned()))
11520                }
11521                _ => None,
11522            })
11523        }
11524        TypeSyntax::Optional { inner, .. } => lower_case_pattern(pattern, inner, semantic),
11525        _ => None,
11526    }
11527}
11528
11529fn case_branch_payload_binding(
11530    pattern: &str,
11531    scrutinee_type: &TypeSyntax,
11532    semantic: &SemanticContext,
11533) -> Option<(String, String)> {
11534    // Sum types: `Variant as b` binds the payload typed as the generated
11535    // `<Enum>.<Variant>` class (spec/sum-types.md).
11536    if let TypeSyntax::Ref { name } = scrutinee_type {
11537        if semantic.schemas.enums.contains_key(&name.name) {
11538            let (variant, binding) = sum_case_pattern_parts(pattern);
11539            let binding = binding?;
11540            let generated = format!("{}.{variant}", name.name);
11541            if binding.is_empty() || !semantic.schemas.class_exists(&generated) {
11542                return None;
11543            }
11544            return Some((binding.to_owned(), generated));
11545        }
11546    }
11547    let binding = pattern.strip_prefix("Some ").map(str::trim)?;
11548    if binding.is_empty() {
11549        return None;
11550    }
11551    let TypeSyntax::Optional { inner, .. } = scrutinee_type else {
11552        return None;
11553    };
11554    let schema = match inner.as_ref() {
11555        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
11556            Some(name.name.clone())
11557        }
11558        _ => None,
11559    }?;
11560    Some((binding.to_owned(), schema))
11561}
11562
11563fn collect_terminal_case_metadata(
11564    rule: &RuleDecl,
11565    semantic: &SemanticContext,
11566    binding_types: &BTreeMap<String, String>,
11567    effect_payload_types: &BTreeMap<String, IrType>,
11568    diagnostics: &mut Vec<Diagnostic>,
11569) -> TerminalMetadata {
11570    let mut metadata = TerminalMetadata::default();
11571    let mut output_bindings = BTreeSet::new();
11572
11573    for branch in terminal_case_branch_sources(rule) {
11574        if output_bindings.insert(branch.scrutinee.clone()) {
11575            let completed_payload = effect_payload_types
11576                .get(&branch.scrutinee)
11577                .cloned()
11578                .unwrap_or_else(terminal_unknown_payload_type);
11579            metadata.outputs.push(IrTerminalOutput {
11580                binding: branch.scrutinee.clone(),
11581                alternatives: terminal_alternatives(completed_payload, branch.pattern_span),
11582                span: branch.pattern_span,
11583            });
11584        }
11585
11586        let (tag, binding) = parse_terminal_pattern_parts(&branch.pattern);
11587        let mut branch_scope = binding_types.clone();
11588        if let (Some(tag), Some(binding)) = (&tag, &binding) {
11589            if let Some(schema) =
11590                terminal_payload_schema_for_tag(tag, &branch.scrutinee, effect_payload_types)
11591            {
11592                branch_scope.insert(binding.clone(), schema);
11593            }
11594        }
11595        if let Some(guard) = &branch.guard {
11596            validate_expression(
11597                rule,
11598                guard,
11599                semantic,
11600                &branch_scope,
11601                "case guard",
11602                diagnostics,
11603            );
11604            validate_known_field_paths(rule, guard, semantic, &branch_scope, diagnostics);
11605        }
11606        validate_known_field_paths(rule, &branch.body, semantic, &branch_scope, diagnostics);
11607        metadata.branches.push(IrTerminalCaseBranch {
11608            scrutinee: branch.scrutinee,
11609            tag,
11610            binding,
11611            guard: branch.guard.as_ref().and_then(|guard| {
11612                lower_expression(
11613                    guard,
11614                    SourceSpan {
11615                        start: branch.pattern_span.start,
11616                        end: branch.pattern_span.end,
11617                    },
11618                )
11619            }),
11620            body_hash: stable_hash(&branch.body),
11621            pattern_span: branch.pattern_span,
11622        });
11623    }
11624
11625    metadata
11626        .outputs
11627        .sort_by(|left, right| left.binding.cmp(&right.binding));
11628    metadata.branches.sort_by(|left, right| {
11629        (left.scrutinee.as_str(), left.pattern_span.start)
11630            .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
11631    });
11632    metadata
11633}
11634
11635fn terminal_case_branch_sources(rule: &RuleDecl) -> Vec<TerminalBranchSource> {
11636    let lines = rule
11637        .body
11638        .text
11639        .lines()
11640        .scan(0usize, |offset, line| {
11641            let current = *offset;
11642            *offset += line.len() + 1;
11643            Some((line, current))
11644        })
11645        .collect::<Vec<_>>();
11646    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
11647    let mut branches = Vec::new();
11648    let mut index = 0usize;
11649    while index < lines.len() {
11650        let (line, line_offset) = lines[index];
11651        let trimmed = line.trim();
11652        let Some(scrutinee) = case_scrutinee(trimmed) else {
11653            index += 1;
11654            continue;
11655        };
11656        if !active_completes_binding_for_case(&text_lines, index, scrutinee) {
11657            index += 1;
11658            continue;
11659        }
11660        let mut depth = brace_delta(trimmed).max(1);
11661        index += 1;
11662        while index < lines.len() && depth > 0 {
11663            let (branch_line, branch_line_offset) = lines[index];
11664            let branch_trimmed = branch_line.trim();
11665            if depth == 1 {
11666                if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
11667                    let pattern_column = case_pattern_column(branch_line, pattern);
11668                    let pattern_span = SourceSpan {
11669                        start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
11670                        end: rule_body_text_start(rule)
11671                            + branch_line_offset
11672                            + pattern_column
11673                            + pattern.len(),
11674                    };
11675                    let mut body_lines = Vec::new();
11676                    let mut branch_depth = brace_delta(body_start).max(1);
11677                    index += 1;
11678                    while index < lines.len() && branch_depth > 0 {
11679                        let body_line = lines[index].0;
11680                        let next_depth = branch_depth + brace_delta(body_line);
11681                        if next_depth >= 1 {
11682                            body_lines.push(body_line.to_owned());
11683                        }
11684                        branch_depth = next_depth;
11685                        index += 1;
11686                    }
11687                    branches.push(TerminalBranchSource {
11688                        scrutinee: scrutinee.to_owned(),
11689                        pattern: pattern.to_owned(),
11690                        guard,
11691                        body: body_lines.join("\n"),
11692                        pattern_span,
11693                    });
11694                    continue;
11695                }
11696            }
11697            depth += brace_delta(branch_trimmed);
11698            index += 1;
11699        }
11700        let _ = line_offset;
11701    }
11702    branches
11703}
11704
11705fn rule_body_text_start(rule: &RuleDecl) -> usize {
11706    rule.body.span.end.saturating_sub(2 + rule.body.text.len())
11707}
11708
11709fn terminal_branch_header(line: &str) -> Option<(&str, Option<String>, &str)> {
11710    let (head, body_start) = line.split_once("=>")?;
11711    let body_start = body_start.trim();
11712    if !body_start.starts_with('{') {
11713        return None;
11714    }
11715    let head = head.trim();
11716    let (pattern, guard) = match head.split_once(" where ") {
11717        Some((pattern, guard)) => (pattern.trim(), Some(guard.trim().to_owned())),
11718        None => (head, None),
11719    };
11720    Some((pattern, guard, body_start))
11721}
11722
11723fn case_pattern_column(line: &str, pattern: &str) -> usize {
11724    line.find(pattern).unwrap_or_else(|| {
11725        let indent = line.len().saturating_sub(line.trim_start().len());
11726        indent + line.trim_start().find(pattern).unwrap_or(0)
11727    })
11728}
11729
11730fn parse_terminal_pattern_parts(pattern: &str) -> (Option<String>, Option<String>) {
11731    if is_fallback_pattern(pattern) {
11732        return (None, None);
11733    }
11734    let mut parts = pattern.split_whitespace();
11735    let tag = parts.next().map(str::to_owned);
11736    // Binding is `Tag as binding` (Stage 1b: the space form `Tag binding` is gone).
11737    let second = parts.next();
11738    let binding = match second {
11739        Some("as") => parts.next().map(str::to_owned),
11740        Some(_) => return (tag, None),
11741        None => None,
11742    };
11743    if parts.next().is_some() {
11744        return (tag, None);
11745    }
11746    (tag, binding)
11747}
11748
11749fn terminal_payload_schema_for_tag(
11750    tag: &str,
11751    scrutinee: &str,
11752    effect_payload_types: &BTreeMap<String, IrType>,
11753) -> Option<String> {
11754    match tag {
11755        "Completed" => match effect_payload_types.get(scrutinee) {
11756            Some(IrType::Ref(schema)) => Some(schema.clone()),
11757            _ => None,
11758        },
11759        "Failed" => Some("TerminalFailed".to_owned()),
11760        "TimedOut" => Some("TerminalTimedOut".to_owned()),
11761        "Cancelled" => Some("TerminalCancelled".to_owned()),
11762        _ => None,
11763    }
11764}
11765
11766fn terminal_alternatives(
11767    completed_payload: IrType,
11768    span: SourceSpan,
11769) -> Vec<IrTerminalAlternative> {
11770    [
11771        ("Completed", completed_payload),
11772        ("Failed", terminal_failure_payload_type()),
11773        ("TimedOut", terminal_timeout_payload_type()),
11774        ("Cancelled", terminal_cancelled_payload_type()),
11775    ]
11776    .into_iter()
11777    .map(|(tag, payload_type)| IrTerminalAlternative {
11778        tag: tag.to_owned(),
11779        payload_type,
11780        source_span: span,
11781    })
11782    .collect()
11783}
11784
11785fn terminal_failure_payload_type() -> IrType {
11786    IrType::Object(vec![
11787        ir_field("reason", IrType::Primitive(IrPrimitiveType::String)),
11788        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11789        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11790        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11791    ])
11792}
11793
11794fn terminal_timeout_payload_type() -> IrType {
11795    IrType::Object(vec![
11796        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11797        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11798        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11799    ])
11800}
11801
11802fn terminal_cancelled_payload_type() -> IrType {
11803    IrType::Object(vec![
11804        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11805        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11806        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11807    ])
11808}
11809
11810fn terminal_unknown_payload_type() -> IrType {
11811    IrType::Object(vec![
11812        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
11813        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
11814        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
11815    ])
11816}
11817
11818fn ir_field(name: &str, ty: IrType) -> IrClassField {
11819    IrClassField {
11820        name: name.to_owned(),
11821        ty,
11822        is_key: false,
11823        presence_condition: None,
11824        span: SourceSpan { start: 0, end: 0 },
11825    }
11826}
11827
11828/// Lower parsed access grants to IR for effects that carry authority-narrowing
11829/// metadata (`tell` turns and `invoke` start grants).
11830fn ir_access_grants_for_body(kind: &body::BodyEffectKind) -> Vec<IrAccessGrant> {
11831    match kind {
11832        body::BodyEffectKind::Tell { access_grants, .. }
11833        | body::BodyEffectKind::Invoke { access_grants, .. } => access_grants
11834            .iter()
11835            .map(|grant| IrAccessGrant {
11836                resource: grant.resource.clone(),
11837                operations: grant
11838                    .operations
11839                    .iter()
11840                    .map(|op| IrAccessGrantOp {
11841                        operation: op.operation.clone(),
11842                        target: op.target.clone(),
11843                        globs: op.globs.clone(),
11844                    })
11845                    .collect(),
11846            })
11847            .collect(),
11848        _ => Vec::new(),
11849    }
11850}
11851
11852fn ir_effect_kind_for_body(kind: &body::BodyEffectKind) -> IrEffectKind {
11853    match kind {
11854        body::BodyEffectKind::Tell { .. } => IrEffectKind::AgentTell,
11855        body::BodyEffectKind::Coerce { .. }
11856        | body::BodyEffectKind::Prompt { .. }
11857        | body::BodyEffectKind::Decide { .. } => IrEffectKind::SchemaCoerce,
11858        body::BodyEffectKind::Call { .. }
11859        | body::BodyEffectKind::ConstructCapabilityCall { .. } => IrEffectKind::CapabilityCall,
11860        body::BodyEffectKind::Invoke { .. } => IrEffectKind::WorkflowInvoke,
11861        body::BodyEffectKind::Timer { .. } => IrEffectKind::TimerWait,
11862        body::BodyEffectKind::Exec { .. } => IrEffectKind::ExecCommand,
11863        body::BodyEffectKind::TrackerFile { .. } => IrEffectKind::TrackerFile,
11864        body::BodyEffectKind::TrackerClaim { .. } => IrEffectKind::TrackerClaim,
11865        body::BodyEffectKind::TrackerRelease { .. } => IrEffectKind::TrackerRelease,
11866        body::BodyEffectKind::TrackerFinish { .. } => IrEffectKind::TrackerFinish,
11867        body::BodyEffectKind::LeaseAcquire { .. } => IrEffectKind::LeaseAcquire,
11868        body::BodyEffectKind::LeaseRenew { .. } => IrEffectKind::LeaseRenew,
11869        body::BodyEffectKind::LedgerAppend { .. } => IrEffectKind::LedgerAppend,
11870        body::BodyEffectKind::CounterConsume { .. } => IrEffectKind::CounterConsume,
11871        body::BodyEffectKind::Notify { .. } => IrEffectKind::SignalEmit,
11872        body::BodyEffectKind::FileRead { .. } => IrEffectKind::FileRead,
11873        body::BodyEffectKind::FileWrite { .. } => IrEffectKind::FileWrite,
11874        body::BodyEffectKind::FileImport { .. } => IrEffectKind::FileImport,
11875        body::BodyEffectKind::FileExport { .. } => IrEffectKind::FileExport,
11876    }
11877}
11878
11879/// The agent a `tell` addresses, surfaced for information-flow analysis of the
11880/// turn's egress to the agent's provider. `None` for non-`tell` effects.
11881fn agent_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11882    match kind {
11883        body::BodyEffectKind::Tell { target, .. } => Some(target.clone()),
11884        _ => None,
11885    }
11886}
11887
11888/// Turn-scoped `with skills [...]` pinned onto an `agent.tell` effect (Phase 7).
11889fn turn_skills_for_body(kind: &body::BodyEffectKind) -> Vec<String> {
11890    match kind {
11891        body::BodyEffectKind::Tell { skills, .. } => skills.clone(),
11892        _ => Vec::new(),
11893    }
11894}
11895
11896/// The workflow an `invoke` targets, surfaced for IFC membrane-door enumeration.
11897fn workflow_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11898    match kind {
11899        body::BodyEffectKind::Invoke { workflow, .. } => Some(workflow.clone()),
11900        _ => None,
11901    }
11902}
11903
11904/// The `exec` surface form (raw command vs manifest capability), surfaced so
11905/// check-time gates classify exec effects without re-scanning rule-body text.
11906fn exec_target_for_body(kind: &body::BodyEffectKind) -> Option<IrExecTarget> {
11907    match kind {
11908        body::BodyEffectKind::Exec { target, .. } => Some(match target {
11909            body::ExecTarget::RawCommand(_) => IrExecTarget::Raw,
11910            body::ExecTarget::Capability { name, .. } => {
11911                IrExecTarget::Capability { name: name.clone() }
11912            }
11913        }),
11914        _ => None,
11915    }
11916}
11917
11918/// Whether an effect carries the `endorsed` source marker (I-IFC3) — a `coerce` the
11919/// author declared an integrity-raising crossing.
11920fn endorsed_for_body(kind: &body::BodyEffectKind) -> bool {
11921    matches!(kind, body::BodyEffectKind::Coerce { endorsed: true, .. })
11922}
11923
11924/// Whether an effect carries the `declassified` source marker (I-IFC3) — a `coerce`
11925/// the author declared a confidentiality-lowering crossing.
11926fn declassified_for_body(kind: &body::BodyEffectKind) -> bool {
11927    matches!(
11928        kind,
11929        body::BodyEffectKind::Coerce {
11930            declassified: true,
11931            ..
11932        }
11933    )
11934}
11935
11936/// The named resource a direct file/channel effect touches, surfaced for
11937/// information-flow analysis. `None` for effects with no named resource.
11938fn resource_for_body(kind: &body::BodyEffectKind) -> Option<String> {
11939    match kind {
11940        body::BodyEffectKind::FileRead { store, .. }
11941        | body::BodyEffectKind::FileWrite { store, .. }
11942        | body::BodyEffectKind::FileImport { store, .. }
11943        | body::BodyEffectKind::FileExport { store, .. } => Some(store.clone()),
11944        // `send via <channel>` carries the channel as a construct field.
11945        body::BodyEffectKind::ConstructCapabilityCall {
11946            keyword, fields, ..
11947        } if keyword == "send" => fields
11948            .iter()
11949            .find(|field| field.name == "channel")
11950            .map(|field| field.source.clone()),
11951        // `emit signal <name> to <peer>` touches the signal port `signal:<name>` (the
11952        // emit-port door, DR-0027 E6/H8); surfaced so the IFC checker can carry the
11953        // emitter's label to the receiver and enumerate the port in the surface.
11954        body::BodyEffectKind::Notify { event, .. } => Some(format!("signal:{event}")),
11955        // Coordination is governed as a resource label under E-COORD. The IFC
11956        // checker decides whether this declaration is partitioned or `shared`.
11957        body::BodyEffectKind::LeaseAcquire { resource, .. } => Some(format!("resource:{resource}")),
11958        body::BodyEffectKind::LedgerAppend { ledger, .. } => Some(format!("resource:{ledger}")),
11959        body::BodyEffectKind::CounterConsume { counter, .. } => Some(format!("resource:{counter}")),
11960        _ => None,
11961    }
11962}
11963
11964fn construct_use_for_body(kind: &body::BodyEffectKind) -> Option<IrConstructUse> {
11965    match kind {
11966        body::BodyEffectKind::ConstructCapabilityCall {
11967            keyword,
11968            target_capability,
11969            ..
11970        } => Some(IrConstructUse {
11971            keyword: keyword.clone(),
11972            scope: "rule_body".to_owned(),
11973            construct_family: "effect_operation".to_owned(),
11974            lowering_target: "capability_call".to_owned(),
11975            target_capability: target_capability.clone(),
11976        }),
11977        _ => None,
11978    }
11979}
11980
11981fn is_ast_only_effect_kind(kind: &body::BodyEffectKind) -> bool {
11982    // `send via <channel> { … } as x` closes its `as` on the block line (unlike
11983    // `recall`, whose `as` is inline), so the line scanner cannot see the binding;
11984    // seed it from the AST. Other `ConstructCapabilityCall`s (e.g. `recall`) are
11985    // line-visible and must NOT be treated as AST-only.
11986    if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = kind {
11987        return keyword == "send";
11988    }
11989    matches!(
11990        kind,
11991        body::BodyEffectKind::Prompt { .. }
11992            | body::BodyEffectKind::Timer { .. }
11993            | body::BodyEffectKind::Exec { .. }
11994            | body::BodyEffectKind::Decide { .. }
11995            | body::BodyEffectKind::TrackerFile { .. }
11996            | body::BodyEffectKind::TrackerClaim { .. }
11997            | body::BodyEffectKind::TrackerRelease { .. }
11998            | body::BodyEffectKind::TrackerFinish { .. }
11999            | body::BodyEffectKind::LeaseAcquire { .. }
12000            | body::BodyEffectKind::LeaseRenew { .. }
12001            | body::BodyEffectKind::LedgerAppend { .. }
12002            | body::BodyEffectKind::CounterConsume { .. }
12003            | body::BodyEffectKind::Notify { .. }
12004            // `invoke` payload blocks put post-payload modifiers on the closing line
12005            // in flow-generated rules, so the line scanner may miss `as <binding>`.
12006            | body::BodyEffectKind::Invoke { .. }
12007            // `write`/`export` put their `as <binding>` on the block's closing
12008            // line, so the line-based scanner cannot see it; seed it from the AST
12009            // so `after <binding>` blocks and sequence checks resolve.
12010            | body::BodyEffectKind::FileWrite { .. }
12011            | body::BodyEffectKind::FileExport { .. }
12012    )
12013}
12014
12015/// Bindings introduced by AST-only effect kinds are unknown to the
12016/// line-based scanner; seed them so sequence checks and `after` blocks see
12017/// them. Binding types for typed outputs are registered where known.
12018fn seed_ast_only_effect_bindings(
12019    statements: &[body::BodyStmt],
12020    seen_bindings: &mut BTreeSet<String>,
12021    binding_types: &mut BTreeMap<String, String>,
12022) {
12023    for statement in statements {
12024        match statement {
12025            body::BodyStmt::Effect(effect) if is_ast_only_effect_kind(&effect.kind) => {
12026                if let Some(binding) = &effect.binding {
12027                    seen_bindings.insert(binding.clone());
12028                    let _ = binding_types;
12029                }
12030            }
12031            body::BodyStmt::After(after) => {
12032                seed_ast_only_effect_bindings(&after.body, seen_bindings, binding_types)
12033            }
12034            body::BodyStmt::Case(case) => {
12035                for branch in &case.branches {
12036                    seed_ast_only_effect_bindings(&branch.body, seen_bindings, binding_types);
12037                }
12038            }
12039            _ => {}
12040        }
12041    }
12042}
12043
12044/// Derives effect nodes and dependency edges from the body AST, in document
12045/// order, with ids and idempotency keys identical to the historical
12046/// line-scanner derivation.
12047/// Collect the output bindings a rule `complete`s, recursing through the body's
12048/// nested blocks (after / case / branch / handler). A `complete <binding> {…}` is the
12049/// workflow's output to its invoker; the IFC checker treats it as an egress sink at
12050/// the invoker boundary (DR-0030 X2). `fail` terminals are NOT collected —
12051/// they carry an error to the runtime, not a value to the invoker.
12052fn collect_terminal_complete_bindings(statements: &[body::BodyStmt], out: &mut Vec<String>) {
12053    for statement in statements {
12054        match statement {
12055            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
12056                out.push(terminal.name.clone());
12057            }
12058            body::BodyStmt::After(after) => collect_terminal_complete_bindings(&after.body, out),
12059            body::BodyStmt::Case(case) => {
12060                for branch in &case.branches {
12061                    collect_terminal_complete_bindings(&branch.body, out);
12062                }
12063            }
12064            _ => {}
12065        }
12066    }
12067}
12068
12069fn collect_effects_from_ast(
12070    statements: &[body::BodyStmt],
12071    rule_name: &str,
12072) -> (Vec<IrEffectNode>, Vec<IrEffectDependency>) {
12073    let mut effects = Vec::new();
12074    let mut dependencies = Vec::new();
12075    let mut counter = 0usize;
12076    let mut after_stack: Vec<(String, DependencyPredicate)> = Vec::new();
12077    let mut case_stack: Vec<(String, String)> = Vec::new();
12078    // Renew disambiguation (T3, mirroring the shipped `release` split): a
12079    // `renew <binding>` whose binding names a same-rule `claim <issue> as
12080    // <binding>` is a tracker claim-renew (`tracker.renew`); one naming an
12081    // `acquire ... as <binding>` stays a lease renew (`lease.renew`). Collect
12082    // the claim `as` bindings up front so the walk can re-classify.
12083    let claim_bindings = collect_claim_bindings(statements);
12084    walk_effects(
12085        statements,
12086        rule_name,
12087        &claim_bindings,
12088        &mut counter,
12089        &mut after_stack,
12090        &mut case_stack,
12091        &mut effects,
12092        &mut dependencies,
12093    );
12094    (effects, dependencies)
12095}
12096
12097/// The `as` bindings of every `claim <issue> as <binding>` in a rule body — the
12098/// referent set the renew disambiguation flips `lease.renew` to `tracker.renew`
12099/// against (the claim result binding, whose output fact carries the issue id).
12100fn collect_claim_bindings(statements: &[body::BodyStmt]) -> BTreeSet<String> {
12101    let mut bindings = BTreeSet::new();
12102    for_each_body(statements, &mut |stmt| {
12103        if let body::BodyStmt::Effect(effect) = stmt {
12104            if matches!(effect.kind, body::BodyEffectKind::TrackerClaim { .. }) {
12105                if let Some(binding) = &effect.binding {
12106                    bindings.insert(binding.clone());
12107                }
12108            }
12109        }
12110    });
12111    bindings
12112}
12113
12114#[allow(clippy::too_many_arguments)]
12115fn walk_effects(
12116    statements: &[body::BodyStmt],
12117    rule_name: &str,
12118    claim_bindings: &BTreeSet<String>,
12119    counter: &mut usize,
12120    after_stack: &mut Vec<(String, DependencyPredicate)>,
12121    case_stack: &mut Vec<(String, String)>,
12122    effects: &mut Vec<IrEffectNode>,
12123    dependencies: &mut Vec<IrEffectDependency>,
12124) {
12125    for statement in statements {
12126        match statement {
12127            body::BodyStmt::Effect(effect) => {
12128                *counter += 1;
12129                let id = effect
12130                    .binding
12131                    .clone()
12132                    .unwrap_or_else(|| format!("effect{counter}"));
12133                // A `renew` naming a claim binding lowers to `tracker.renew`;
12134                // otherwise it stays the coord `lease.renew` its parser produced.
12135                let kind = match &effect.kind {
12136                    body::BodyEffectKind::LeaseRenew {
12137                        acquire_binding, ..
12138                    } if claim_bindings.contains(acquire_binding) => IrEffectKind::TrackerRenew,
12139                    other => ir_effect_kind_for_body(other),
12140                };
12141                for (upstream, predicate) in after_stack.iter() {
12142                    dependencies.push(IrEffectDependency {
12143                        upstream: upstream.clone(),
12144                        predicate: predicate.clone(),
12145                        downstream: id.clone(),
12146                    });
12147                }
12148                let idempotency_key =
12149                    effect_idempotency_key(rule_name, &id, &kind, &effect.binding);
12150                let mut required_capabilities = effect.requires.clone();
12151                match &effect.kind {
12152                    body::BodyEffectKind::Call { capability, .. } => {
12153                        required_capabilities.push(capability.clone());
12154                    }
12155                    body::BodyEffectKind::ConstructCapabilityCall {
12156                        target_capability, ..
12157                    } => {
12158                        required_capabilities.push(target_capability.clone());
12159                    }
12160                    _ => {}
12161                }
12162                required_capabilities.sort();
12163                required_capabilities.dedup();
12164                let construct_use = construct_use_for_body(&effect.kind);
12165                let access_grants = ir_access_grants_for_body(&effect.kind);
12166                let turn_skills = turn_skills_for_body(&effect.kind);
12167                let resource = resource_for_body(&effect.kind);
12168                let agent = agent_for_body(&effect.kind);
12169                let workflow_target = workflow_target_for_body(&effect.kind);
12170                let endorsed = endorsed_for_body(&effect.kind);
12171                let declassified = declassified_for_body(&effect.kind);
12172                let exec_target = exec_target_for_body(&effect.kind);
12173                effects.push(IrEffectNode {
12174                    id,
12175                    kind,
12176                    binding: effect.binding.clone(),
12177                    required_capabilities,
12178                    construct_use,
12179                    idempotency_key,
12180                    span: effect.span,
12181                    timeout_seconds: effect.timeout_seconds,
12182                    access_grants,
12183                    turn_skills,
12184                    resource,
12185                    agent,
12186                    workflow_target,
12187                    endorsed,
12188                    declassified,
12189                    selected_by: case_stack.last().cloned(),
12190                    exec_target,
12191                });
12192            }
12193            body::BodyStmt::After(after) => {
12194                let predicate = match after.predicate {
12195                    body::AfterPredicate::Succeeds => DependencyPredicate::Succeeds,
12196                    body::AfterPredicate::Fails => DependencyPredicate::Fails,
12197                    // `times out` / `cancelled` are distinct non-success terminal
12198                    // statuses, so the downstream effect releases only on that
12199                    // specific status (mirroring succeeds/fails), not on any
12200                    // terminal.
12201                    body::AfterPredicate::TimedOut => DependencyPredicate::TimedOut,
12202                    body::AfterPredicate::Cancelled => DependencyPredicate::Cancelled,
12203                    // Coordination outcomes are completion-valued: the downstream
12204                    // depends on the op reaching a terminal state; the outcome
12205                    // variant selects the arm at lowering.
12206                    body::AfterPredicate::Completes
12207                    | body::AfterPredicate::Held
12208                    | body::AfterPredicate::Contended
12209                    | body::AfterPredicate::Ok
12210                    | body::AfterPredicate::Over => DependencyPredicate::Completes,
12211                    // `reaches "<name>"` (Family C) is completion-shaped for the
12212                    // construct-graph provenance edge; the milestone-specific
12213                    // gating happens at runtime against the
12214                    // `workflow.invoke.reached:<name>` fact (text-keyed, see
12215                    // `fact_matches_after_predicate`), so this IR predicate is
12216                    // metadata only.
12217                    body::AfterPredicate::Reaches => DependencyPredicate::Completes,
12218                };
12219                after_stack.push((after.binding.clone(), predicate));
12220                walk_effects(
12221                    &after.body,
12222                    rule_name,
12223                    claim_bindings,
12224                    counter,
12225                    after_stack,
12226                    case_stack,
12227                    effects,
12228                    dependencies,
12229                );
12230                after_stack.pop();
12231            }
12232            body::BodyStmt::Case(case) => {
12233                for branch in &case.branches {
12234                    // Record the selector: an effect in this arm is gated by
12235                    // `case <scrutinee> { <pattern> => … }` (DR §7.4).
12236                    case_stack.push((case.scrutinee.clone(), branch.pattern.clone()));
12237                    walk_effects(
12238                        &branch.body,
12239                        rule_name,
12240                        claim_bindings,
12241                        counter,
12242                        after_stack,
12243                        case_stack,
12244                        effects,
12245                        dependencies,
12246                    );
12247                    case_stack.pop();
12248                }
12249            }
12250            _ => {}
12251        }
12252    }
12253}
12254
12255fn effect_idempotency_key(
12256    rule_name: &str,
12257    effect_id: &str,
12258    kind: &IrEffectKind,
12259    binding: &Option<String>,
12260) -> String {
12261    stable_hash(&format!(
12262        "rule={rule_name};effect={effect_id};kind={};binding={}",
12263        kind.as_str(),
12264        binding.as_deref().unwrap_or("-")
12265    ))
12266}
12267
12268fn validate_coerce_call(
12269    rule: &RuleDecl,
12270    line: &str,
12271    semantic: &SemanticContext,
12272    binding_types: &BTreeMap<String, String>,
12273    known_roots: &BTreeSet<String>,
12274    diagnostics: &mut Vec<Diagnostic>,
12275) {
12276    let Some((function_name, args)) = parse_coerce_call(line) else {
12277        diagnostics.push(Diagnostic {
12278            related: Vec::new(),
12279            span: rule.body.span,
12280            message: format!("rule `{}` has malformed coerce call", rule.name.name),
12281            suggestion: Some("write `coerce functionName(arg, ...) as name`".to_owned()),
12282        });
12283        return;
12284    };
12285    let Some(params) = semantic.coerce_params.get(function_name) else {
12286        diagnostics.push(Diagnostic {
12287            related: Vec::new(),
12288            span: rule.body.span,
12289            message: format!(
12290                "rule `{}` calls unknown coerce function `{function_name}`",
12291                rule.name.name
12292            ),
12293            suggestion: Some(format!(
12294                "declare `coerce {function_name}(...) -> Output {{ ... }}` before using it"
12295            )),
12296        });
12297        return;
12298    };
12299    if args.len() != params.len() {
12300        diagnostics.push(Diagnostic {
12301            related: Vec::new(),
12302            span: rule.body.span,
12303            message: format!(
12304                "rule `{}` calls coerce `{function_name}` with {} argument(s), expected {}",
12305                rule.name.name,
12306                args.len(),
12307                params.len()
12308            ),
12309            suggestion: Some("pass one argument for each declared coerce parameter".to_owned()),
12310        });
12311        return;
12312    }
12313    let scope = ExprScope::from_bindings(binding_types);
12314    for (arg, param) in args.iter().zip(params) {
12315        // Dangling-root check (mirrors record/terminal value validation): an arg
12316        // whose root is not a known binding is a typo/unbound reference, which the
12317        // type-checker below accepts leniently.
12318        if let Some(root) = dangling_value_root(arg, known_roots) {
12319            diagnostics.push(Diagnostic { related: Vec::new(),
12320                span: rule.body.span,
12321                message: format!(
12322                    "rule `{}` has unknown binding `{root}` in coerce `{function_name}` argument",
12323                    rule.name.name
12324                ),
12325                suggestion: Some(
12326                    "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
12327                        .to_owned(),
12328                ),
12329            });
12330        }
12331        validate_expr_source_against_type(
12332            rule,
12333            &format!("coerce `{function_name}`"),
12334            &param.name.name,
12335            &param.ty,
12336            arg,
12337            semantic,
12338            &scope,
12339            diagnostics,
12340        );
12341    }
12342}
12343
12344fn validate_effect_payloads(
12345    rule: &RuleDecl,
12346    semantic: &SemanticContext,
12347    binding_types: &BTreeMap<String, String>,
12348    known_roots: &BTreeSet<String>,
12349    diagnostics: &mut Vec<Diagnostic>,
12350) {
12351    for statement in effect_payload_statements(&rule.body.text) {
12352        let trimmed = statement.trim();
12353        if trimmed.starts_with("coerce ") {
12354            validate_coerce_call(
12355                rule,
12356                trimmed,
12357                semantic,
12358                binding_types,
12359                known_roots,
12360                diagnostics,
12361            );
12362        }
12363    }
12364}
12365
12366fn validate_workflow_invocations(
12367    rule: &RuleDecl,
12368    semantic: &SemanticContext,
12369    binding_types: &BTreeMap<String, String>,
12370    known_roots: &BTreeSet<String>,
12371    diagnostics: &mut Vec<Diagnostic>,
12372) {
12373    for statement in workflow_invoke_statements(&rule.body.text) {
12374        let Some((target, body)) = invoke_statement_parts(&statement) else {
12375            diagnostics.push(Diagnostic {
12376                related: Vec::new(),
12377                span: rule.body.span,
12378                message: format!(
12379                    "rule `{}` has malformed workflow invocation",
12380                    rule.name.name
12381                ),
12382                suggestion: Some("write `invoke Workflow { input value } as binding`".to_owned()),
12383            });
12384            continue;
12385        };
12386        if semantic.workflow.as_deref() == Some(target) {
12387            diagnostics.push(Diagnostic {
12388                related: Vec::new(),
12389                span: rule.body.span,
12390                message: format!(
12391                    "rule `{}` recursively invokes workflow `{target}`",
12392                    rule.name.name
12393                ),
12394                suggestion: Some(
12395                    "split recursive orchestration into an explicit bounded scheduler workflow"
12396                        .to_owned(),
12397                ),
12398            });
12399            continue;
12400        }
12401        let Some(surface) = semantic.workflow_inputs.get(target) else {
12402            diagnostics.push(Diagnostic {
12403                related: Vec::new(),
12404                span: rule.body.span,
12405                message: format!(
12406                    "rule `{}` invokes unknown workflow `{target}`",
12407                    rule.name.name
12408                ),
12409                suggestion: Some("invoke a workflow declared in this source bundle".to_owned()),
12410            });
12411            continue;
12412        };
12413
12414        let mut invocation_semantic = semantic.clone();
12415        invocation_semantic.schemas.merge(surface.schemas.clone());
12416        let assignments = collect_field_assignments(body);
12417        let mut seen = BTreeSet::new();
12418        for assignment in assignments {
12419            let (field, value) = match assignment {
12420                RecordFieldAssignment::Value { field, value } => (field, value),
12421                RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
12422            };
12423            if !seen.insert(field.clone()) {
12424                diagnostics.push(Diagnostic {
12425                    related: Vec::new(),
12426                    span: rule.body.span,
12427                    message: format!("workflow invocation `{target}` repeats input `{field}`"),
12428                    suggestion: Some("remove the duplicate invocation input".to_owned()),
12429                });
12430                continue;
12431            }
12432            let Some(input_ty) = surface.inputs.get(&field) else {
12433                let known = surface
12434                    .inputs
12435                    .keys()
12436                    .map(|input| format!("`{input}`"))
12437                    .collect::<Vec<_>>()
12438                    .join(", ");
12439                diagnostics.push(Diagnostic {
12440                    related: Vec::new(),
12441                    span: rule.body.span,
12442                    message: format!("workflow `{target}` has no input `{field}`"),
12443                    suggestion: Some(if known.is_empty() {
12444                        "remove the invocation payload; the target declares no inputs".to_owned()
12445                    } else {
12446                        format!("pass one of: {known}")
12447                    }),
12448                });
12449                continue;
12450            };
12451            if let Some(root) = dangling_value_root(&value, known_roots) {
12452                diagnostics.push(Diagnostic { related: Vec::new(),
12453                    span: rule.body.span,
12454                    message: format!(
12455                        "rule `{}` has unknown binding `{root}` in `invoke {target}` input `{field}`",
12456                        rule.name.name
12457                    ),
12458                    suggestion: Some(
12459                        "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
12460                            .to_owned(),
12461                    ),
12462                });
12463            }
12464            validate_expr_source_against_type(
12465                rule,
12466                target,
12467                &field,
12468                input_ty,
12469                &value,
12470                &invocation_semantic,
12471                &ExprScope::from_bindings(binding_types),
12472                diagnostics,
12473            );
12474        }
12475        for input in surface.inputs.keys() {
12476            if seen.contains(input) {
12477                continue;
12478            }
12479            diagnostics.push(Diagnostic {
12480                related: Vec::new(),
12481                span: rule.body.span,
12482                message: format!("workflow invocation `{target}` is missing input `{input}`"),
12483                suggestion: Some(format!(
12484                    "add `{input}` to the `{target}` invocation payload"
12485                )),
12486            });
12487        }
12488    }
12489}
12490
12491fn validate_agent_tell_target(
12492    rule: &RuleDecl,
12493    line: &str,
12494    kind: &IrEffectKind,
12495    semantic: &SemanticContext,
12496    binding_types: &BTreeMap<String, String>,
12497    known_roots: &BTreeSet<String>,
12498    diagnostics: &mut Vec<Diagnostic>,
12499) {
12500    if kind != &IrEffectKind::AgentTell {
12501        return;
12502    }
12503    let Some(target) = parse_tell_target(line) else {
12504        diagnostics.push(Diagnostic {
12505            related: Vec::new(),
12506            span: rule.body.span,
12507            message: format!("rule `{}` has malformed tell target", rule.name.name),
12508            suggestion: Some("write `tell agentName ...` or `tell task.agentRef ...`".to_owned()),
12509        });
12510        return;
12511    };
12512    if target.starts_with('"') {
12513        diagnostics.push(Diagnostic {
12514            related: Vec::new(),
12515            span: rule.body.span,
12516            message: format!(
12517                "rule `{}` uses a string literal as a tell target",
12518                rule.name.name
12519            ),
12520            suggestion: Some("use a declared agent name or an AgentRef field".to_owned()),
12521        });
12522        return;
12523    }
12524    let required_capabilities = parse_required_capabilities(line);
12525    if target.contains('.') {
12526        let Some(ty) = expression_type(target, semantic, binding_types) else {
12527            // Unknown type can mean a dangling root (the path's binding does not
12528            // exist) — caught here since the type lookup returns None silently. A
12529            // known root with a bad path is left to other validation.
12530            if let Some(root) = dangling_value_root(target, known_roots) {
12531                diagnostics.push(Diagnostic { related: Vec::new(),
12532                    span: rule.body.span,
12533                    message: format!(
12534                        "rule `{}` has unknown binding `{root}` in tell target `{target}`",
12535                        rule.name.name
12536                    ),
12537                    suggestion: Some(
12538                        "reference a binding from a `when ... as name` clause or an effect `as` binding"
12539                            .to_owned(),
12540                    ),
12541                });
12542            }
12543            return;
12544        };
12545        if let TypeSyntax::AgentRef { agents, .. } = ty {
12546            for agent in agents {
12547                validate_agent_capabilities(
12548                    rule,
12549                    &agent.name,
12550                    &required_capabilities,
12551                    semantic,
12552                    diagnostics,
12553                );
12554            }
12555        } else {
12556            diagnostics.push(Diagnostic {
12557                related: Vec::new(),
12558                span: rule.body.span,
12559                message: format!(
12560                    "rule `{}` uses non-AgentRef dynamic tell target `{target}`",
12561                    rule.name.name
12562                ),
12563                suggestion: Some(
12564                    "declare the field as `AgentRef<...>` before using it as a tell target"
12565                        .to_owned(),
12566                ),
12567            });
12568        }
12569        return;
12570    }
12571    if !semantic.agents.contains(target) {
12572        diagnostics.push(Diagnostic {
12573            related: Vec::new(),
12574            span: rule.body.span,
12575            message: format!("rule `{}` tells unknown agent `{target}`", rule.name.name),
12576            suggestion: Some("declare the target agent before telling it".to_owned()),
12577        });
12578        return;
12579    }
12580    validate_agent_capabilities(rule, target, &required_capabilities, semantic, diagnostics);
12581}
12582
12583fn validate_agent_capabilities(
12584    rule: &RuleDecl,
12585    agent: &str,
12586    required_capabilities: &[String],
12587    semantic: &SemanticContext,
12588    diagnostics: &mut Vec<Diagnostic>,
12589) {
12590    if required_capabilities.is_empty() {
12591        return;
12592    }
12593    let declared = semantic
12594        .agent_capabilities
12595        .get(agent)
12596        .cloned()
12597        .unwrap_or_default();
12598    for capability in required_capabilities {
12599        if !declared.contains(capability) {
12600            diagnostics.push(Diagnostic { related: Vec::new(),
12601                span: rule.body.span,
12602                message: format!(
12603                    "rule `{}` tells agent `{agent}` requiring undeclared capability `{capability}`",
12604                    rule.name.name
12605                ),
12606                suggestion: Some(format!(
12607                    "add `{capability}` to agent `{agent}` capabilities or choose another AgentRef target"
12608                )),
12609            });
12610        }
12611    }
12612}
12613
12614fn validate_availability_when(
12615    rule: &RuleDecl,
12616    when: &str,
12617    semantic: &SemanticContext,
12618    binding_types: &BTreeMap<String, String>,
12619    diagnostics: &mut Vec<Diagnostic>,
12620) {
12621    let (pattern, _) = split_when_guard(when);
12622    let Some(target) = pattern.strip_suffix(" is available").map(str::trim) else {
12623        return;
12624    };
12625    if target.contains('.') {
12626        let Some(ty) = expression_type(target, semantic, binding_types) else {
12627            return;
12628        };
12629        if !matches!(ty, TypeSyntax::AgentRef { .. }) {
12630            diagnostics.push(Diagnostic {
12631                related: Vec::new(),
12632                span: rule.body.span,
12633                message: format!(
12634                    "rule `{}` checks availability for non-AgentRef `{target}`",
12635                    rule.name.name
12636                ),
12637                suggestion: Some(
12638                    "availability checks must name a declared agent or an AgentRef field"
12639                        .to_owned(),
12640                ),
12641            });
12642        }
12643        return;
12644    }
12645    if !semantic.agents.contains(target) {
12646        diagnostics.push(Diagnostic {
12647            related: Vec::new(),
12648            span: rule.body.span,
12649            message: format!("rule `{}` checks unknown agent `{target}`", rule.name.name),
12650            suggestion: Some("declare the target agent before checking availability".to_owned()),
12651        });
12652    }
12653}
12654
12655#[derive(Clone, Debug, Default)]
12656struct ExprScope {
12657    binding_types: BTreeMap<String, String>,
12658    implicit_schema: Option<String>,
12659}
12660
12661impl ExprScope {
12662    fn from_bindings(binding_types: &BTreeMap<String, String>) -> Self {
12663        Self {
12664            binding_types: binding_types.clone(),
12665            implicit_schema: None,
12666        }
12667    }
12668
12669    fn with_implicit_schema(&self, schema: String) -> Self {
12670        let mut scope = self.clone();
12671        scope.implicit_schema = Some(schema);
12672        scope
12673    }
12674}
12675
12676#[derive(Clone, Debug)]
12677struct ExprValidationContext {
12678    subject: String,
12679    span: SourceSpan,
12680}
12681
12682impl ExprValidationContext {
12683    fn rule(rule: &RuleDecl) -> Self {
12684        Self {
12685            subject: format!("rule `{}`", rule.name.name),
12686            span: rule.body.span,
12687        }
12688    }
12689
12690    fn assertion(span: SourceSpan) -> Self {
12691        Self {
12692            subject: "assertion".to_owned(),
12693            span,
12694        }
12695    }
12696}
12697
12698fn validate_expression(
12699    rule: &RuleDecl,
12700    expr: &str,
12701    semantic: &SemanticContext,
12702    binding_types: &BTreeMap<String, String>,
12703    label: &str,
12704    diagnostics: &mut Vec<Diagnostic>,
12705) {
12706    match parse_expression(expr) {
12707        Ok(expr) => {
12708            validate_parsed_expression(
12709                &expr,
12710                semantic,
12711                &ExprScope::from_bindings(binding_types),
12712                &ExprValidationContext::rule(rule),
12713                label,
12714                diagnostics,
12715            );
12716        }
12717        Err(message) => diagnostics.push(Diagnostic { related: Vec::new(),
12718            span: rule.body.span,
12719            message: format!("rule `{}` has invalid {label} expression: {message}", rule.name.name),
12720            suggestion: Some("use deterministic field paths, literals, boolean operators, comparisons, membership, count, or exists".to_owned()),
12721        }),
12722    }
12723}
12724
12725fn validate_parsed_expression(
12726    expr: &Expr,
12727    semantic: &SemanticContext,
12728    scope: &ExprScope,
12729    context: &ExprValidationContext,
12730    label: &str,
12731    diagnostics: &mut Vec<Diagnostic>,
12732) {
12733    let presence_proofs = BTreeSet::new();
12734    validate_expr_node(
12735        expr,
12736        semantic,
12737        scope,
12738        context,
12739        &presence_proofs,
12740        diagnostics,
12741    );
12742    let ty = infer_expr_type(expr, semantic, scope, context, diagnostics);
12743    if ty != ExprType::Bool && ty != ExprType::Unknown {
12744        diagnostics.push(Diagnostic {
12745            related: Vec::new(),
12746            span: context.span,
12747            message: format!("{} has non-boolean {label} expression", context.subject),
12748            suggestion: Some(format!("{label} expressions must evaluate to bool")),
12749        });
12750    }
12751}
12752
12753fn validate_expr_node(
12754    expr: &Expr,
12755    semantic: &SemanticContext,
12756    scope: &ExprScope,
12757    context: &ExprValidationContext,
12758    presence_proofs: &BTreeSet<String>,
12759    diagnostics: &mut Vec<Diagnostic>,
12760) {
12761    match expr {
12762        Expr::Path(path) => {
12763            if path.len() < 2 {
12764                return;
12765            }
12766            let root = &path[0];
12767            let Some(schema) = scope.binding_types.get(root) else {
12768                if let Some(schema) = &scope.implicit_schema {
12769                    if let Err(message) =
12770                        validate_optional_path_access(schema, path, semantic, presence_proofs)
12771                    {
12772                        diagnostics.push(Diagnostic {
12773                            related: Vec::new(),
12774                            span: context.span,
12775                            message: format!(
12776                                "{} has unsafe optional path `{}`: {message}",
12777                                context.subject,
12778                                path.join(".")
12779                            ),
12780                            suggestion: Some(
12781                                "prove the optional value is present before reading through it"
12782                                    .to_owned(),
12783                            ),
12784                        });
12785                        return;
12786                    }
12787                    if let Err(message) = semantic.schemas.resolve_field_path(schema, path) {
12788                        diagnostics.push(Diagnostic {
12789                            related: Vec::new(),
12790                            span: context.span,
12791                            message: format!(
12792                                "{} has invalid expression path `{}`: {message}",
12793                                context.subject,
12794                                path.join(".")
12795                            ),
12796                            suggestion: Some(
12797                                "use a field declared on the queried schema".to_owned(),
12798                            ),
12799                        });
12800                    }
12801                    return;
12802                }
12803                diagnostics.push(Diagnostic {
12804                    related: Vec::new(),
12805                    span: context.span,
12806                    message: format!("{} has unknown expression root `{root}`", context.subject),
12807                    suggestion: Some(
12808                        "use a binding introduced by a `when ... as name` clause".to_owned(),
12809                    ),
12810                });
12811                return;
12812            };
12813            if let Err(message) =
12814                validate_optional_path_access(schema, &path[1..], semantic, presence_proofs)
12815            {
12816                diagnostics.push(Diagnostic {
12817                    related: Vec::new(),
12818                    span: context.span,
12819                    message: format!(
12820                        "{} has unsafe optional path `{}`: {message}",
12821                        context.subject,
12822                        path.join(".")
12823                    ),
12824                    suggestion: Some(
12825                        "prove the optional value is present before reading through it".to_owned(),
12826                    ),
12827                });
12828                return;
12829            }
12830            if let Err(message) = semantic.schemas.resolve_field_path(schema, &path[1..]) {
12831                diagnostics.push(Diagnostic {
12832                    related: Vec::new(),
12833                    span: context.span,
12834                    message: format!(
12835                        "{} has invalid expression path `{}`: {message}",
12836                        context.subject,
12837                        path.join(".")
12838                    ),
12839                    suggestion: Some("use a field declared on the bound schema".to_owned()),
12840                });
12841            }
12842        }
12843        Expr::Index { target, key } => {
12844            validate_expr_node(
12845                target,
12846                semantic,
12847                scope,
12848                context,
12849                presence_proofs,
12850                diagnostics,
12851            );
12852            validate_expr_node(key, semantic, scope, context, presence_proofs, diagnostics);
12853            let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
12854            if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
12855                diagnostics.push(Diagnostic {
12856                    related: Vec::new(),
12857                    span: context.span,
12858                    message: format!("{} indexes a map with a non-string key", context.subject),
12859                    suggestion: Some(
12860                        "use a string literal or string expression as the map key".to_owned(),
12861                    ),
12862                });
12863            }
12864        }
12865        Expr::Array(items) => {
12866            for item in items {
12867                validate_expr_node(item, semantic, scope, context, presence_proofs, diagnostics);
12868            }
12869        }
12870        Expr::Object(fields) => {
12871            diagnostics.push(Diagnostic {
12872                related: Vec::new(),
12873                span: context.span,
12874                message: format!(
12875                    "{} uses an object literal without an expected object or map type",
12876                    context.subject
12877                ),
12878                suggestion: Some(
12879                    "use object literals only in typed record fields or typed effect arguments"
12880                        .to_owned(),
12881                ),
12882            });
12883            for field in fields {
12884                validate_expr_node(
12885                    &field.value,
12886                    semantic,
12887                    scope,
12888                    context,
12889                    presence_proofs,
12890                    diagnostics,
12891                );
12892            }
12893        }
12894        Expr::Unary { expr, .. } => {
12895            validate_expr_node(expr, semantic, scope, context, presence_proofs, diagnostics)
12896        }
12897        Expr::Binary {
12898            op: BinaryOp::And,
12899            left,
12900            right,
12901        } => {
12902            validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
12903            let mut right_proofs = presence_proofs.clone();
12904            collect_presence_proofs(left, &mut right_proofs);
12905            validate_expr_node(right, semantic, scope, context, &right_proofs, diagnostics);
12906        }
12907        Expr::Binary { op, left, right } => {
12908            validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
12909            validate_expr_node(
12910                right,
12911                semantic,
12912                scope,
12913                context,
12914                presence_proofs,
12915                diagnostics,
12916            );
12917            validate_unknown_implicit_idents(
12918                *op,
12919                left,
12920                right,
12921                semantic,
12922                scope,
12923                context,
12924                diagnostics,
12925            );
12926            validate_finite_domain_expr(*op, left, right, semantic, scope, context, diagnostics);
12927        }
12928        Expr::Call { name, args } => {
12929            validate_function_call(name, args, semantic, scope, context, diagnostics);
12930            for arg in args {
12931                validate_expr_node(arg, semantic, scope, context, presence_proofs, diagnostics);
12932            }
12933        }
12934        Expr::Query { guard, .. } => {
12935            validate_query_expr(expr, semantic, scope, context, diagnostics);
12936            if let Some(guard) = guard {
12937                let guard_scope = query_guard_scope(expr, semantic, scope);
12938                validate_expr_node(
12939                    guard,
12940                    semantic,
12941                    &guard_scope,
12942                    context,
12943                    presence_proofs,
12944                    diagnostics,
12945                );
12946            }
12947        }
12948        Expr::Literal(_) => {}
12949    }
12950}
12951
12952fn validate_unknown_implicit_idents(
12953    op: BinaryOp,
12954    left: &Expr,
12955    right: &Expr,
12956    semantic: &SemanticContext,
12957    scope: &ExprScope,
12958    context: &ExprValidationContext,
12959    diagnostics: &mut Vec<Diagnostic>,
12960) {
12961    if !matches!(
12962        op,
12963        BinaryOp::Eq | BinaryOp::Ne | BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge
12964    ) {
12965        return;
12966    }
12967    validate_unknown_implicit_ident(left, right, semantic, scope, context, diagnostics);
12968    validate_unknown_implicit_ident(right, left, semantic, scope, context, diagnostics);
12969}
12970
12971fn validate_unknown_implicit_ident(
12972    expr: &Expr,
12973    other: &Expr,
12974    semantic: &SemanticContext,
12975    scope: &ExprScope,
12976    context: &ExprValidationContext,
12977    diagnostics: &mut Vec<Diagnostic>,
12978) {
12979    let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
12980        return;
12981    };
12982    let Some(schema) = &scope.implicit_schema else {
12983        return;
12984    };
12985    let field_exists = semantic
12986        .schemas
12987        .classes
12988        .get(schema)
12989        .is_some_and(|fields| fields.contains_key(name));
12990    if field_exists
12991        || expr_domain(other, semantic, scope).is_some()
12992        || implicit_ident_field_exists(other, semantic, scope)
12993    {
12994        return;
12995    }
12996    diagnostics.push(Diagnostic {
12997        related: Vec::new(),
12998        span: context.span,
12999        message: format!(
13000            "{} fact query `{schema}` has unknown field `{name}`",
13001            context.subject
13002        ),
13003        suggestion: Some(format!(
13004            "use a field declared on `{schema}` inside the query `where` expression"
13005        )),
13006    });
13007}
13008
13009fn implicit_ident_field_exists(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> bool {
13010    let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
13011        return false;
13012    };
13013    let Some(schema) = &scope.implicit_schema else {
13014        return false;
13015    };
13016    semantic
13017        .schemas
13018        .classes
13019        .get(schema)
13020        .is_some_and(|fields| fields.contains_key(name))
13021}
13022
13023fn validate_function_call(
13024    name: &str,
13025    args: &[Expr],
13026    semantic: &SemanticContext,
13027    scope: &ExprScope,
13028    context: &ExprValidationContext,
13029    diagnostics: &mut Vec<Diagnostic>,
13030) {
13031    match name {
13032        "count" => {
13033            if args.len() != 1 {
13034                diagnostics.push(Diagnostic { related: Vec::new(),
13035                    span: context.span,
13036                    message: format!(
13037                        "{} calls `count` with {} arguments, expected 1",
13038                        context.subject,
13039                        args.len()
13040                    ),
13041                    suggestion: Some(
13042                        "call `count` with exactly one array, map, fact query, or effect query argument"
13043                            .to_owned(),
13044                    ),
13045                });
13046                return;
13047            }
13048            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13049            if !is_countable_type(&ty) {
13050                diagnostics.push(Diagnostic {
13051                    related: Vec::new(),
13052                    span: context.span,
13053                    message: format!(
13054                        "{} calls `count` with unsupported argument type `{}`",
13055                        context.subject,
13056                        expr_type_label(&ty)
13057                    ),
13058                    suggestion: Some(
13059                        "use `count` only with arrays, maps, fact queries, or effect queries"
13060                            .to_owned(),
13061                    ),
13062                });
13063            }
13064        }
13065        "exists" => {
13066            if args.len() != 1 {
13067                diagnostics.push(Diagnostic {
13068                    related: Vec::new(),
13069                    span: context.span,
13070                    message: format!(
13071                        "{} calls `exists` with {} arguments, expected 1",
13072                        context.subject,
13073                        args.len()
13074                    ),
13075                    suggestion: Some("call `exists` with exactly one argument".to_owned()),
13076                });
13077                return;
13078            }
13079            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13080            if !matches!(args[0], Expr::Index { .. }) && !is_exists_type(&ty) {
13081                diagnostics.push(Diagnostic { related: Vec::new(),
13082                    span: context.span,
13083                    message: format!(
13084                        "{} calls `exists` with unsupported argument type `{}`",
13085                        context.subject,
13086                        expr_type_label(&ty)
13087                    ),
13088                    suggestion: Some(
13089                        "use `exists path` for optional/map presence checks or pass an array, map, fact query, or effect query"
13090                            .to_owned(),
13091                    ),
13092                });
13093            }
13094        }
13095        "empty" => {
13096            if args.len() != 1 {
13097                diagnostics.push(Diagnostic {
13098                    related: Vec::new(),
13099                    span: context.span,
13100                    message: format!(
13101                        "{} calls `empty` with {} arguments, expected 1",
13102                        context.subject,
13103                        args.len()
13104                    ),
13105                    suggestion: Some(
13106                        "call `empty` with exactly one array, map, string, fact query, or effect query argument"
13107                            .to_owned(),
13108                    ),
13109                });
13110                return;
13111            }
13112            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
13113            if !is_emptiable_type(&ty) {
13114                // An optional gets its own message: the inner type is what
13115                // makes it unsupported (spec: `empty(Optional<T>)` is defined
13116                // only when `empty(T)` is).
13117                let optional = matches!(ty, ExprType::Optional(_));
13118                diagnostics.push(Diagnostic {
13119                    related: Vec::new(),
13120                    span: context.span,
13121                    message: format!(
13122                        "{} calls `empty` with unsupported {}argument type `{}`",
13123                        context.subject,
13124                        if optional { "optional " } else { "" },
13125                        expr_type_label(&ty)
13126                    ),
13127                    suggestion: Some(
13128                        "use `empty` only with arrays, maps, strings, fact queries, effect queries, null, or supported optional values"
13129                            .to_owned(),
13130                    ),
13131                });
13132            }
13133        }
13134        _ => {}
13135    }
13136}
13137
13138fn validate_query_expr(
13139    expr: &Expr,
13140    semantic: &SemanticContext,
13141    scope: &ExprScope,
13142    context: &ExprValidationContext,
13143    diagnostics: &mut Vec<Diagnostic>,
13144) {
13145    let Expr::Query { kind, head, guard } = expr else {
13146        return;
13147    };
13148    if *kind == QueryKind::Fact {
13149        let Some(schema) = query_head_schema(head, semantic) else {
13150            diagnostics.push(Diagnostic {
13151                related: Vec::new(),
13152                span: context.span,
13153                message: format!(
13154                    "{} queries unknown fact schema `{}`",
13155                    context.subject,
13156                    head.trim()
13157                ),
13158                suggestion: Some("use a declared class name in fact queries".to_owned()),
13159            });
13160            return;
13161        };
13162        if let Some(guard) = guard {
13163            let guard_scope = scope.with_implicit_schema(schema);
13164            let ty = infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
13165            if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
13166                diagnostics.push(Diagnostic {
13167                    related: Vec::new(),
13168                    span: context.span,
13169                    message: format!(
13170                        "{} fact query `{}` has non-boolean `where` expression",
13171                        context.subject,
13172                        head.trim()
13173                    ),
13174                    suggestion: Some("query `where` expressions must evaluate to bool".to_owned()),
13175                });
13176            }
13177        }
13178    }
13179}
13180
13181fn validate_optional_path_access(
13182    root_schema: &str,
13183    path: &[String],
13184    semantic: &SemanticContext,
13185    presence_proofs: &BTreeSet<String>,
13186) -> Result<(), String> {
13187    let mut schema = root_schema.to_owned();
13188    let mut prefix = Vec::new();
13189    for (index, field) in path.iter().enumerate() {
13190        let Some(fields) = semantic.schemas.classes.get(&schema) else {
13191            return Ok(());
13192        };
13193        let Some(field_ty) = fields.get(field) else {
13194            return Ok(());
13195        };
13196        prefix.push(field.clone());
13197        if let TypeSyntax::Optional { inner, .. } = field_ty {
13198            if index + 1 < path.len() && !presence_proofs.contains(&prefix.join(".")) {
13199                return Err(format!(
13200                    "`{}` must be proven present before accessing `{}`",
13201                    prefix.join("."),
13202                    path[index + 1..].join(".")
13203                ));
13204            }
13205            if let Some(next_schema) = schema_name_for_path(inner) {
13206                schema = next_schema;
13207            }
13208            continue;
13209        }
13210        if let Some(next_schema) = schema_name_for_path(field_ty) {
13211            schema = next_schema;
13212        }
13213    }
13214    Ok(())
13215}
13216
13217fn collect_presence_proofs(expr: &Expr, proofs: &mut BTreeSet<String>) {
13218    match expr {
13219        Expr::Binary {
13220            op: BinaryOp::Ne,
13221            left,
13222            right,
13223        } => {
13224            if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
13225                if let Some(path) = expr_path_key(left) {
13226                    proofs.insert(path);
13227                }
13228            }
13229            if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
13230                if let Some(path) = expr_path_key(right) {
13231                    proofs.insert(path);
13232                }
13233            }
13234        }
13235        Expr::Unary {
13236            op: UnaryOp::Not,
13237            expr,
13238        } => {
13239            if let Expr::Binary {
13240                op: BinaryOp::Eq,
13241                left,
13242                right,
13243            } = expr.as_ref()
13244            {
13245                if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
13246                    if let Some(path) = expr_path_key(left) {
13247                        proofs.insert(path);
13248                    }
13249                }
13250                if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
13251                    if let Some(path) = expr_path_key(right) {
13252                        proofs.insert(path);
13253                    }
13254                }
13255            }
13256        }
13257        Expr::Call { name, args } if name == "exists" && args.len() == 1 => {
13258            if let Some(path) = expr_path_key(&args[0]) {
13259                proofs.insert(path);
13260            }
13261        }
13262        Expr::Binary {
13263            op: BinaryOp::And,
13264            left,
13265            right,
13266        } => {
13267            collect_presence_proofs(left, proofs);
13268            collect_presence_proofs(right, proofs);
13269        }
13270        _ => {}
13271    }
13272}
13273
13274fn expr_path_key(expr: &Expr) -> Option<String> {
13275    match expr {
13276        Expr::Literal(ExprLiteral::Ident(name)) => Some(name.clone()),
13277        Expr::Path(path) if path.len() >= 2 => Some(path[1..].join(".")),
13278        Expr::Index { target, key } => {
13279            let target = expr_path_key(target)?;
13280            let key = match key.as_ref() {
13281                Expr::Literal(ExprLiteral::String(value) | ExprLiteral::Ident(value)) => value,
13282                _ => return None,
13283            };
13284            Some(format!("{target}[{key:?}]"))
13285        }
13286        _ => None,
13287    }
13288}
13289
13290fn query_guard_scope(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> ExprScope {
13291    let Expr::Query {
13292        kind: QueryKind::Fact,
13293        head,
13294        ..
13295    } = expr
13296    else {
13297        return scope.clone();
13298    };
13299    query_head_schema(head, semantic)
13300        .map(|schema| scope.with_implicit_schema(schema))
13301        .unwrap_or_else(|| scope.clone())
13302}
13303
13304fn query_head_schema(head: &str, semantic: &SemanticContext) -> Option<String> {
13305    let mut parts = head.split_whitespace();
13306    let schema = parts.next()?;
13307    if parts.next().is_some() {
13308        return None;
13309    }
13310    semantic
13311        .schemas
13312        .class_exists(schema)
13313        .then(|| schema.to_owned())
13314}
13315
13316fn implicit_field_type(
13317    name: &str,
13318    semantic: &SemanticContext,
13319    scope: &ExprScope,
13320) -> Option<TypeSyntax> {
13321    let schema = scope.implicit_schema.as_ref()?;
13322    semantic
13323        .schemas
13324        .resolve_field_path(schema, &[name.to_owned()])
13325        .ok()
13326}
13327
13328fn infer_expr_type(
13329    expr: &Expr,
13330    semantic: &SemanticContext,
13331    scope: &ExprScope,
13332    context: &ExprValidationContext,
13333    diagnostics: &mut Vec<Diagnostic>,
13334) -> ExprType {
13335    match expr {
13336        Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)
13337            .map(|ty| expr_type_from_type_syntax(&ty, semantic))
13338            .unwrap_or_else(|| expr_literal_type(&ExprLiteral::Ident(name.clone()))),
13339        Expr::Literal(literal) => expr_literal_type(literal),
13340        Expr::Path(path) => expr_path_type(path, semantic, scope).unwrap_or(ExprType::Unknown),
13341        Expr::Index { target, key } => {
13342            let target_ty = infer_expr_type(target, semantic, scope, context, diagnostics);
13343            let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
13344            if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
13345                diagnostics.push(Diagnostic {
13346                    related: Vec::new(),
13347                    span: context.span,
13348                    message: format!("{} indexes a map with a non-string key", context.subject),
13349                    suggestion: Some(
13350                        "use a string literal or string expression as the map key".to_owned(),
13351                    ),
13352                });
13353            }
13354            match target_ty {
13355                ExprType::Map(inner) => *inner,
13356                ExprType::Unknown => ExprType::Unknown,
13357                _ => {
13358                    diagnostics.push(Diagnostic {
13359                        related: Vec::new(),
13360                        span: context.span,
13361                        message: format!("{} indexes a non-map expression", context.subject),
13362                        suggestion: Some("use indexing only on map values".to_owned()),
13363                    });
13364                    ExprType::Unknown
13365                }
13366            }
13367        }
13368        Expr::Array(items) => infer_array_type(items, semantic, scope, context, diagnostics),
13369        Expr::Object(fields) => {
13370            for field in fields {
13371                infer_expr_type(&field.value, semantic, scope, context, diagnostics);
13372            }
13373            ExprType::Object
13374        }
13375        Expr::Unary {
13376            op: UnaryOp::Not,
13377            expr,
13378        } => {
13379            let inner = infer_expr_type(expr, semantic, scope, context, diagnostics);
13380            if !matches!(inner, ExprType::Bool | ExprType::Unknown) {
13381                diagnostics.push(Diagnostic {
13382                    related: Vec::new(),
13383                    span: context.span,
13384                    message: format!(
13385                        "{} applies `!` to a non-boolean expression",
13386                        context.subject
13387                    ),
13388                    suggestion: Some("use `!` only with boolean expressions".to_owned()),
13389                });
13390            }
13391            ExprType::Bool
13392        }
13393        Expr::Binary { op, left, right } => {
13394            infer_binary_type(*op, left, right, semantic, scope, context, diagnostics)
13395        }
13396        Expr::Call { name, args } => match name.as_str() {
13397            "count" => ExprType::Int,
13398            "exists" => ExprType::Bool,
13399            "empty" => ExprType::Bool,
13400            _ => {
13401                diagnostics.push(Diagnostic {
13402                    related: Vec::new(),
13403                    span: context.span,
13404                    message: format!(
13405                        "{} calls unsupported expression function `{name}`",
13406                        context.subject
13407                    ),
13408                    suggestion: Some("use `count`, `exists`, or `empty`".to_owned()),
13409                });
13410                for arg in args {
13411                    infer_expr_type(arg, semantic, scope, context, diagnostics);
13412                }
13413                ExprType::Unknown
13414            }
13415        },
13416        Expr::Query { guard, .. } => {
13417            if let Some(guard) = guard {
13418                let guard_scope = query_guard_scope(expr, semantic, scope);
13419                infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
13420            }
13421            ExprType::Collection
13422        }
13423    }
13424}
13425
13426fn infer_binary_type(
13427    op: BinaryOp,
13428    left: &Expr,
13429    right: &Expr,
13430    semantic: &SemanticContext,
13431    scope: &ExprScope,
13432    context: &ExprValidationContext,
13433    diagnostics: &mut Vec<Diagnostic>,
13434) -> ExprType {
13435    let left_ty = infer_expr_type(left, semantic, scope, context, diagnostics);
13436    let right_ty = infer_expr_type(right, semantic, scope, context, diagnostics);
13437    match op {
13438        BinaryOp::And | BinaryOp::Or => {
13439            for ty in [&left_ty, &right_ty] {
13440                if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
13441                    diagnostics.push(Diagnostic {
13442                        related: Vec::new(),
13443                        span: context.span,
13444                        message: format!(
13445                            "{} uses boolean operator with non-boolean operand",
13446                            context.subject
13447                        ),
13448                        suggestion: Some(
13449                            "use `&&` and `||` only with boolean expressions".to_owned(),
13450                        ),
13451                    });
13452                    break;
13453                }
13454            }
13455            ExprType::Bool
13456        }
13457        BinaryOp::Eq | BinaryOp::Ne => {
13458            if !types_comparable(&left_ty, &right_ty) {
13459                diagnostics.push(Diagnostic {
13460                    related: Vec::new(),
13461                    span: context.span,
13462                    message: format!("{} compares incompatible expression types", context.subject),
13463                    suggestion: Some(
13464                        "compare values with compatible scalar or finite-domain types".to_owned(),
13465                    ),
13466                });
13467            }
13468            ExprType::Bool
13469        }
13470        BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
13471            if !is_orderable_pair(&left_ty, &right_ty) {
13472                diagnostics.push(Diagnostic {
13473                    related: Vec::new(),
13474                    span: context.span,
13475                    message: format!("{} orders non-orderable expression values", context.subject),
13476                    suggestion: Some(
13477                        "use ordering only with int, float, duration, or time values".to_owned(),
13478                    ),
13479                });
13480            }
13481            ExprType::Bool
13482        }
13483        BinaryOp::In | BinaryOp::NotIn => {
13484            match &right_ty {
13485                ExprType::Array(item_ty) => {
13486                    if !types_comparable(&left_ty, item_ty) {
13487                        diagnostics.push(Diagnostic {
13488                            related: Vec::new(),
13489                            span: context.span,
13490                            message: format!(
13491                                "{} uses membership with incompatible item type",
13492                                context.subject
13493                            ),
13494                            suggestion: Some(
13495                                "make the left value compatible with the array item type"
13496                                    .to_owned(),
13497                            ),
13498                        });
13499                    }
13500                }
13501                ExprType::Map(_) => {
13502                    if !is_string_like_key_type(&left_ty) {
13503                        diagnostics.push(Diagnostic {
13504                            related: Vec::new(),
13505                            span: context.span,
13506                            message: format!(
13507                                "{} uses map membership with a non-string key",
13508                                context.subject
13509                            ),
13510                            suggestion: Some(
13511                                "use a string value on the left side of map membership".to_owned(),
13512                            ),
13513                        });
13514                    }
13515                }
13516                ExprType::Unknown => {}
13517                _ => diagnostics.push(Diagnostic {
13518                    related: Vec::new(),
13519                    span: context.span,
13520                    message: format!(
13521                        "{} uses membership against a non-array/non-map expression",
13522                        context.subject
13523                    ),
13524                    suggestion: Some(
13525                        "use `in` with an array literal, array value, or map value".to_owned(),
13526                    ),
13527                }),
13528            }
13529            ExprType::Bool
13530        }
13531        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
13532            for ty in [&left_ty, &right_ty] {
13533                if !matches!(ty, ExprType::Int | ExprType::Float | ExprType::Unknown) {
13534                    diagnostics.push(Diagnostic {
13535                        related: Vec::new(),
13536                        span: context.span,
13537                        message: format!(
13538                            "{} uses arithmetic with a non-numeric operand",
13539                            context.subject
13540                        ),
13541                        suggestion: Some("use `+ - * /` only with int or float values".to_owned()),
13542                    });
13543                    break;
13544                }
13545            }
13546            if matches!(left_ty, ExprType::Float) || matches!(right_ty, ExprType::Float) {
13547                ExprType::Float
13548            } else if matches!(left_ty, ExprType::Int) && matches!(right_ty, ExprType::Int) {
13549                ExprType::Int
13550            } else {
13551                ExprType::Unknown
13552            }
13553        }
13554    }
13555}
13556
13557fn infer_array_type(
13558    items: &[Expr],
13559    semantic: &SemanticContext,
13560    scope: &ExprScope,
13561    context: &ExprValidationContext,
13562    diagnostics: &mut Vec<Diagnostic>,
13563) -> ExprType {
13564    let mut item_ty: Option<ExprType> = None;
13565    for item in items {
13566        let ty = infer_expr_type(item, semantic, scope, context, diagnostics);
13567        if matches!(ty, ExprType::Unknown) {
13568            continue;
13569        }
13570        match &item_ty {
13571            None => item_ty = Some(ty),
13572            Some(existing) if types_comparable(existing, &ty) => {}
13573            Some(_) => {
13574                diagnostics.push(Diagnostic {
13575                    related: Vec::new(),
13576                    span: context.span,
13577                    message: format!("{} has mixed-type array literal", context.subject),
13578                    suggestion: Some("use array literals whose elements share one type".to_owned()),
13579                });
13580                return ExprType::Array(Box::new(ExprType::Unknown));
13581            }
13582        }
13583    }
13584    ExprType::Array(Box::new(item_ty.unwrap_or(ExprType::Unknown)))
13585}
13586
13587fn expr_path_type(
13588    path: &[String],
13589    semantic: &SemanticContext,
13590    scope: &ExprScope,
13591) -> Option<ExprType> {
13592    if path.len() < 2 {
13593        return None;
13594    }
13595    if let Some(schema) = scope.binding_types.get(&path[0]) {
13596        if schema.contains('.') {
13597            // Untyped runtime fact binding (general `when fact <name>`).
13598            return Some(ExprType::Unknown);
13599        }
13600        return semantic
13601            .schemas
13602            .resolve_field_path(schema, &path[1..])
13603            .ok()
13604            .map(|ty| expr_type_from_type_syntax(&ty, semantic));
13605    }
13606    let schema = scope.implicit_schema.as_ref()?;
13607    if schema.contains('.') {
13608        return Some(ExprType::Unknown);
13609    }
13610    semantic
13611        .schemas
13612        .resolve_field_path(schema, path)
13613        .ok()
13614        .map(|ty| expr_type_from_type_syntax(&ty, semantic))
13615}
13616
13617fn expr_type_from_type_syntax(ty: &TypeSyntax, semantic: &SemanticContext) -> ExprType {
13618    match ty {
13619        TypeSyntax::Primitive { name, .. } => match name.as_str() {
13620            "bool" => ExprType::Bool,
13621            "int" => ExprType::Int,
13622            "float" => ExprType::Float,
13623            "string" => ExprType::String,
13624            "duration" => ExprType::Duration,
13625            "time" => ExprType::Time,
13626            _ => ExprType::Unknown,
13627        },
13628        TypeSyntax::LiteralString { value, .. } => ExprType::Finite {
13629            label: "literal".to_owned(),
13630            values: vec![value.clone()],
13631        },
13632        TypeSyntax::AgentRef { agents, .. } => ExprType::Finite {
13633            label: "AgentRef".to_owned(),
13634            values: agents.iter().map(|agent| agent.name.clone()).collect(),
13635        },
13636        TypeSyntax::Ref { name } => semantic
13637            .schemas
13638            .enums
13639            .get(&name.name)
13640            .map(|variants| ExprType::Finite {
13641                label: format!("enum `{}`", name.name),
13642                values: variants.iter().cloned().collect(),
13643            })
13644            .unwrap_or(ExprType::Object),
13645        TypeSyntax::Optional { inner, .. } => {
13646            ExprType::Optional(Box::new(expr_type_from_type_syntax(inner, semantic)))
13647        }
13648        TypeSyntax::Array { inner, .. } => {
13649            ExprType::Array(Box::new(expr_type_from_type_syntax(inner, semantic)))
13650        }
13651        TypeSyntax::Map { inner, .. } => {
13652            ExprType::Map(Box::new(expr_type_from_type_syntax(inner, semantic)))
13653        }
13654        TypeSyntax::Union { variants, .. } => {
13655            let values = variants
13656                .iter()
13657                .filter_map(|variant| match variant {
13658                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
13659                    _ => None,
13660                })
13661                .collect::<Vec<_>>();
13662            if values.len() == variants.len() && !values.is_empty() {
13663                ExprType::Finite {
13664                    label: "literal union".to_owned(),
13665                    values,
13666                }
13667            } else {
13668                ExprType::Unknown
13669            }
13670        }
13671    }
13672}
13673
13674fn expr_literal_type(literal: &ExprLiteral) -> ExprType {
13675    match literal {
13676        ExprLiteral::String(_) | ExprLiteral::Ident(_) => ExprType::String,
13677        ExprLiteral::Number(value) if value.contains('.') => ExprType::Float,
13678        ExprLiteral::Number(_) => ExprType::Int,
13679        ExprLiteral::Bool(_) => ExprType::Bool,
13680        ExprLiteral::Null => ExprType::Null,
13681    }
13682}
13683
13684fn types_comparable(left: &ExprType, right: &ExprType) -> bool {
13685    if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
13686        return true;
13687    }
13688    if matches!(left, ExprType::Null) || matches!(right, ExprType::Null) {
13689        return true;
13690    }
13691    if is_numeric_type(left) && is_numeric_type(right) {
13692        return true;
13693    }
13694    match (left, right) {
13695        (ExprType::Optional(left), right) | (right, ExprType::Optional(left)) => {
13696            types_comparable(left, right)
13697        }
13698        (ExprType::Finite { .. }, ExprType::String)
13699        | (ExprType::String, ExprType::Finite { .. })
13700        | (ExprType::Finite { .. }, ExprType::Finite { .. }) => true,
13701        _ => left == right,
13702    }
13703}
13704
13705fn is_numeric_type(ty: &ExprType) -> bool {
13706    matches!(ty, ExprType::Int | ExprType::Float)
13707}
13708
13709fn is_string_like_key_type(ty: &ExprType) -> bool {
13710    match ty {
13711        ExprType::String | ExprType::Unknown | ExprType::Finite { .. } => true,
13712        ExprType::Optional(inner) => is_string_like_key_type(inner),
13713        _ => false,
13714    }
13715}
13716
13717fn is_orderable_pair(left: &ExprType, right: &ExprType) -> bool {
13718    if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
13719        return true;
13720    }
13721    (is_numeric_type(left) && is_numeric_type(right))
13722        || matches!(
13723            (left, right),
13724            (ExprType::Duration, ExprType::Duration)
13725                | (ExprType::Time, ExprType::Time)
13726                // A quoted ISO-8601 string in a time-typed comparison is a
13727                // time literal (spec/scheduled-time.md).
13728                | (ExprType::Time, ExprType::String)
13729                | (ExprType::String, ExprType::Time)
13730        )
13731}
13732
13733fn is_countable_type(ty: &ExprType) -> bool {
13734    matches!(
13735        ty,
13736        ExprType::Array(_) | ExprType::Map(_) | ExprType::Collection | ExprType::Unknown
13737    )
13738}
13739
13740fn is_exists_type(ty: &ExprType) -> bool {
13741    matches!(
13742        ty,
13743        ExprType::Array(_)
13744            | ExprType::Map(_)
13745            | ExprType::Collection
13746            | ExprType::Optional(_)
13747            | ExprType::Unknown
13748    )
13749}
13750
13751/// Spec "Count And Empty": `empty` is a structural emptiness test for arrays,
13752/// maps, strings, fact/effect queries, and null; `empty(Optional<T>)` is
13753/// defined only when `empty(T)` is (so `empty(string?)` works, `empty(int?)`
13754/// does not). It never coerces scalars, objects, enum variants, or agent refs.
13755fn is_emptiable_type(ty: &ExprType) -> bool {
13756    match ty {
13757        ExprType::Array(_)
13758        | ExprType::Map(_)
13759        | ExprType::String
13760        | ExprType::Collection
13761        | ExprType::Null
13762        | ExprType::Unknown => true,
13763        ExprType::Optional(inner) => is_emptiable_type(inner),
13764        _ => false,
13765    }
13766}
13767
13768fn expr_type_label(ty: &ExprType) -> String {
13769    match ty {
13770        ExprType::Bool => "bool".to_owned(),
13771        ExprType::Int => "int".to_owned(),
13772        ExprType::Float => "float".to_owned(),
13773        ExprType::String => "string".to_owned(),
13774        ExprType::Finite { label, values } => format!("{label}<{}>", values.join(" | ")),
13775        ExprType::Duration => "duration".to_owned(),
13776        ExprType::Time => "time".to_owned(),
13777        ExprType::Null => "null".to_owned(),
13778        ExprType::Object => "object".to_owned(),
13779        ExprType::Array(inner) => format!("{}[]", expr_type_label(inner)),
13780        ExprType::Map(inner) => format!("map<{}>", expr_type_label(inner)),
13781        ExprType::Optional(inner) => format!("{}?", expr_type_label(inner)),
13782        ExprType::Collection => "query".to_owned(),
13783        ExprType::Unknown => "unknown".to_owned(),
13784    }
13785}
13786
13787fn validate_finite_domain_expr(
13788    op: BinaryOp,
13789    left: &Expr,
13790    right: &Expr,
13791    semantic: &SemanticContext,
13792    scope: &ExprScope,
13793    context: &ExprValidationContext,
13794    diagnostics: &mut Vec<Diagnostic>,
13795) {
13796    if !matches!(
13797        op,
13798        BinaryOp::Eq | BinaryOp::Ne | BinaryOp::In | BinaryOp::NotIn
13799    ) {
13800        return;
13801    }
13802    let Some((domain, literals)) = finite_domain_comparison(left, right, semantic, scope)
13803        .or_else(|| finite_domain_comparison(right, left, semantic, scope))
13804    else {
13805        validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
13806        return;
13807    };
13808    for literal in literals.into_iter().flatten() {
13809        if !domain.iter().any(|value| value == &literal) {
13810            diagnostics.push(Diagnostic {
13811                related: Vec::new(),
13812                span: context.span,
13813                message: format!(
13814                    "{} compares finite-domain value to unknown `{literal}`",
13815                    context.subject
13816                ),
13817                suggestion: Some(format!("use one of: {}", domain.join(", "))),
13818            });
13819        }
13820    }
13821    validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
13822}
13823
13824fn validate_finite_domain_relation(
13825    op: BinaryOp,
13826    left: &Expr,
13827    right: &Expr,
13828    semantic: &SemanticContext,
13829    scope: &ExprScope,
13830    context: &ExprValidationContext,
13831    diagnostics: &mut Vec<Diagnostic>,
13832) {
13833    match op {
13834        BinaryOp::Eq => {
13835            let Some(left_domain) = expr_domain(left, semantic, scope) else {
13836                return;
13837            };
13838            let Some(right_domain) = expr_domain(right, semantic, scope) else {
13839                return;
13840            };
13841            if left_domain
13842                .iter()
13843                .all(|value| !right_domain.iter().any(|right| right == value))
13844            {
13845                diagnostics.push(Diagnostic {
13846                    related: Vec::new(),
13847                    span: context.span,
13848                    message: format!(
13849                        "{} has statically unsatisfiable finite-domain equality",
13850                        context.subject
13851                    ),
13852                    suggestion: Some(format!(
13853                        "compare domains with at least one shared value; left: {}, right: {}",
13854                        left_domain.join(", "),
13855                        right_domain.join(", ")
13856                    )),
13857                });
13858            }
13859        }
13860        BinaryOp::In => {
13861            let Some(domain) = expr_domain(left, semantic, scope) else {
13862                return;
13863            };
13864            let Some(literals) = literal_array_values(right) else {
13865                return;
13866            };
13867            if literals
13868                .iter()
13869                .all(|literal| !domain.iter().any(|value| value == literal))
13870            {
13871                diagnostics.push(Diagnostic {
13872                    related: Vec::new(),
13873                    span: context.span,
13874                    message: format!(
13875                        "{} has statically unsatisfiable finite-domain membership",
13876                        context.subject
13877                    ),
13878                    suggestion: Some(format!("use one of: {}", domain.join(", "))),
13879                });
13880            }
13881        }
13882        BinaryOp::NotIn => {
13883            let Some(domain) = expr_domain(left, semantic, scope) else {
13884                return;
13885            };
13886            let Some(literals) = literal_array_values(right) else {
13887                return;
13888            };
13889            if !domain.is_empty()
13890                && domain
13891                    .iter()
13892                    .all(|value| literals.iter().any(|literal| literal == value))
13893            {
13894                diagnostics.push(Diagnostic {
13895                    related: Vec::new(),
13896                    span: context.span,
13897                    message: format!(
13898                        "{} has statically unsatisfiable finite-domain exclusion",
13899                        context.subject
13900                    ),
13901                    suggestion: Some(
13902                        "leave at least one domain value outside the exclusion set".to_owned(),
13903                    ),
13904                });
13905            }
13906        }
13907        _ => {}
13908    }
13909}
13910
13911fn finite_domain_comparison(
13912    domain_expr: &Expr,
13913    literal_expr: &Expr,
13914    semantic: &SemanticContext,
13915    scope: &ExprScope,
13916) -> Option<(Vec<String>, Vec<Option<String>>)> {
13917    let domain = expr_domain(domain_expr, semantic, scope)?;
13918    let literals = match literal_expr {
13919        Expr::Literal(literal) => vec![expr_literal_name(literal)],
13920        Expr::Array(items) => items
13921            .iter()
13922            .filter_map(|item| match item {
13923                Expr::Literal(literal) => Some(expr_literal_name(literal)),
13924                _ => None,
13925            })
13926            .collect(),
13927        _ => Vec::new(),
13928    };
13929    Some((domain, literals))
13930}
13931
13932fn expr_domain(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> Option<Vec<String>> {
13933    let ty = match expr {
13934        Expr::Path(path) => {
13935            let root = path.first()?;
13936            if let Some(schema) = scope.binding_types.get(root) {
13937                semantic
13938                    .schemas
13939                    .resolve_field_path(schema, path.get(1..)?)
13940                    .ok()?
13941            } else {
13942                let schema = scope.implicit_schema.as_ref()?;
13943                semantic.schemas.resolve_field_path(schema, path).ok()?
13944            }
13945        }
13946        Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)?,
13947        _ => return None,
13948    };
13949    finite_expr_domain(&ty, semantic)
13950}
13951
13952fn finite_expr_domain(ty: &TypeSyntax, semantic: &SemanticContext) -> Option<Vec<String>> {
13953    match ty {
13954        TypeSyntax::Ref { name } => semantic
13955            .schemas
13956            .enums
13957            .get(&name.name)
13958            .map(|variants| variants.iter().cloned().collect()),
13959        TypeSyntax::Union { variants, .. } => {
13960            let values = variants
13961                .iter()
13962                .filter_map(|variant| match variant {
13963                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
13964                    _ => None,
13965                })
13966                .collect::<Vec<_>>();
13967            (!values.is_empty()).then_some(values)
13968        }
13969        TypeSyntax::AgentRef { agents, .. } => {
13970            Some(agents.iter().map(|agent| agent.name.clone()).collect())
13971        }
13972        _ => None,
13973    }
13974}
13975
13976fn expr_literal_name(literal: &ExprLiteral) -> Option<String> {
13977    match literal {
13978        ExprLiteral::String(value) | ExprLiteral::Ident(value) => Some(value.clone()),
13979        _ => None,
13980    }
13981}
13982
13983fn literal_array_values(expr: &Expr) -> Option<Vec<String>> {
13984    let Expr::Array(items) = expr else {
13985        return None;
13986    };
13987    items
13988        .iter()
13989        .map(|item| match item {
13990            Expr::Literal(literal) => expr_literal_name(literal),
13991            _ => None,
13992        })
13993        .collect()
13994}
13995
13996fn parse_tell_target(line: &str) -> Option<&str> {
13997    line.strip_prefix("tell ")?
13998        .split_whitespace()
13999        .next()
14000        .filter(|target| !target.is_empty())
14001}
14002
14003fn parse_required_capabilities(line: &str) -> Vec<String> {
14004    let Some(rest) = line.split_once(" requires ") else {
14005        return Vec::new();
14006    };
14007    let Some(list) = rest.1.trim_start().strip_prefix('[') else {
14008        return Vec::new();
14009    };
14010    let Some((items, _)) = list.split_once(']') else {
14011        return Vec::new();
14012    };
14013    let mut capabilities = items
14014        .split(',')
14015        .filter_map(|item| {
14016            let value = item.trim().trim_matches('"');
14017            (!value.is_empty()).then(|| value.to_owned())
14018        })
14019        .collect::<Vec<_>>();
14020    capabilities.sort();
14021    capabilities.dedup();
14022    capabilities
14023}
14024
14025fn validate_case_blocks(
14026    rule: &RuleDecl,
14027    semantic: &SemanticContext,
14028    binding_types: &BTreeMap<String, String>,
14029    diagnostics: &mut Vec<Diagnostic>,
14030) {
14031    let lines = rule
14032        .body
14033        .text
14034        .lines()
14035        .scan(0usize, |offset, line| {
14036            let current = *offset;
14037            *offset += line.len() + 1;
14038            Some((line, current))
14039        })
14040        .collect::<Vec<_>>();
14041    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
14042    let mut index = 0usize;
14043    while index < lines.len() {
14044        let trimmed = lines[index].0.trim();
14045        let Some(scrutinee) = case_scrutinee(trimmed) else {
14046            index += 1;
14047            continue;
14048        };
14049        let scrutinee_ty = expression_type(scrutinee, semantic, binding_types);
14050        let terminal_case = scrutinee_ty.is_none()
14051            && active_completes_binding_for_case(&text_lines, index, scrutinee);
14052        if scrutinee_ty.is_none() && !terminal_case {
14053            diagnostics.push(Diagnostic {
14054                related: Vec::new(),
14055                span: rule.body.span,
14056                message: format!(
14057                    "rule `{}` has case scrutinee `{scrutinee}` that is not a typed path",
14058                    rule.name.name
14059                ),
14060                suggestion: Some("match on a bound field such as `task.provider`".to_owned()),
14061            });
14062        }
14063        let mut depth = brace_delta(trimmed).max(1);
14064        let mut case_index = index + 1;
14065        let mut branches = Vec::new();
14066        while case_index < lines.len() && depth > 0 {
14067            let (raw_line, line_offset) = lines[case_index];
14068            let line = raw_line.trim();
14069            if depth == 1 {
14070                if let Some(branch) = parse_case_branch_head(line) {
14071                    let pattern_column = case_pattern_column(raw_line, branch.pattern);
14072                    let branch = SpanCaseBranchHead {
14073                        pattern: branch.pattern,
14074                        guard: branch.guard,
14075                        pattern_span: SourceSpan {
14076                            start: rule_body_text_start(rule) + line_offset + pattern_column,
14077                            end: rule_body_text_start(rule)
14078                                + line_offset
14079                                + pattern_column
14080                                + branch.pattern.len(),
14081                        },
14082                    };
14083                    branches.push(branch);
14084                    if terminal_case {
14085                        validate_terminal_case_pattern(
14086                            rule,
14087                            branch.pattern,
14088                            branch.pattern_span,
14089                            diagnostics,
14090                        );
14091                    } else {
14092                        validate_case_pattern(
14093                            rule,
14094                            branch.pattern,
14095                            scrutinee_ty.as_ref(),
14096                            branch.pattern_span,
14097                            semantic,
14098                            diagnostics,
14099                        );
14100                    }
14101                    // Terminal-case guards are validated by
14102                    // `collect_terminal_case_metadata`, which is the only path
14103                    // with `effect_payload_types` and so the only one that can
14104                    // bind the tag-refined payload (`Completed as result where
14105                    // result.x ...`) into the guard scope. Validating them here
14106                    // too would reject that binding as an unknown root.
14107                    if let Some(guard) = branch.guard.filter(|_| !terminal_case) {
14108                        let mut branch_scope = binding_types.clone();
14109                        if let Some(scrutinee_ty) = scrutinee_ty.as_ref() {
14110                            if let Some((binding, schema)) =
14111                                case_branch_payload_binding(branch.pattern, scrutinee_ty, semantic)
14112                            {
14113                                branch_scope.insert(binding, schema);
14114                            }
14115                        }
14116                        validate_expression(
14117                            rule,
14118                            guard,
14119                            semantic,
14120                            &branch_scope,
14121                            "case guard",
14122                            diagnostics,
14123                        );
14124                        validate_known_field_paths_at_span(
14125                            rule,
14126                            guard,
14127                            branch.pattern_span,
14128                            semantic,
14129                            &branch_scope,
14130                            diagnostics,
14131                        );
14132                    }
14133                }
14134            }
14135            depth += brace_delta(line);
14136            case_index += 1;
14137        }
14138        if terminal_case {
14139            validate_terminal_case_coverage(rule, &branches, diagnostics);
14140        } else {
14141            validate_case_coverage(
14142                rule,
14143                scrutinee_ty.as_ref(),
14144                &branches,
14145                semantic,
14146                diagnostics,
14147            );
14148        }
14149        index += 1;
14150    }
14151}
14152
14153fn active_completes_binding_for_case(lines: &[&str], case_index: usize, scrutinee: &str) -> bool {
14154    let mut scopes: Vec<(String, DependencyPredicate, i32)> = Vec::new();
14155    for line in lines.iter().take(case_index) {
14156        let trimmed = line.trim();
14157        if let Some((binding, predicate)) = parse_after_line(trimmed) {
14158            scopes.push((binding, predicate, brace_delta(trimmed).max(1)));
14159        } else {
14160            let delta = brace_delta(trimmed);
14161            for (_, _, depth) in &mut scopes {
14162                *depth += delta;
14163            }
14164            scopes.retain(|(_, _, depth)| *depth > 0);
14165        }
14166    }
14167    scopes.iter().any(|(binding, predicate, _)| {
14168        binding == scrutinee && predicate == &DependencyPredicate::Completes
14169    })
14170}
14171
14172fn brace_delta(line: &str) -> i32 {
14173    line.chars().fold(0, |depth, ch| match ch {
14174        '{' => depth + 1,
14175        '}' => depth - 1,
14176        _ => depth,
14177    })
14178}
14179
14180fn case_scrutinee(line: &str) -> Option<&str> {
14181    let rest = line.strip_prefix("case ")?;
14182    let expr = rest.strip_suffix('{').unwrap_or(rest).trim();
14183    (!expr.is_empty()).then_some(expr)
14184}
14185
14186fn is_case_branch_start(line: &str) -> bool {
14187    line.contains("=>")
14188}
14189
14190#[derive(Clone, Copy)]
14191struct CaseBranchHead<'a> {
14192    pattern: &'a str,
14193    guard: Option<&'a str>,
14194}
14195
14196#[derive(Clone, Copy)]
14197struct SpanCaseBranchHead<'a> {
14198    pattern: &'a str,
14199    guard: Option<&'a str>,
14200    pattern_span: SourceSpan,
14201}
14202
14203fn parse_case_branch_head(line: &str) -> Option<CaseBranchHead<'_>> {
14204    let (pattern, _) = line.split_once("=>")?;
14205    let pattern = pattern.trim();
14206    if pattern.is_empty() {
14207        return None;
14208    }
14209    match pattern.split_once(" where ") {
14210        Some((pattern, guard)) => Some(CaseBranchHead {
14211            pattern: pattern.trim(),
14212            guard: Some(guard.trim()),
14213        }),
14214        None => Some(CaseBranchHead {
14215            pattern,
14216            guard: None,
14217        }),
14218    }
14219}
14220
14221fn expression_type(
14222    expr: &str,
14223    semantic: &SemanticContext,
14224    binding_types: &BTreeMap<String, String>,
14225) -> Option<TypeSyntax> {
14226    // A bare enum-typed binding is a valid scrutinee: `case o` dispatches a
14227    // sum-type payload (spec/sum-types.md). Class-typed bare bindings stay
14228    // untyped here so the "match on a bound field" guidance still fires.
14229    let is_bare_ident = !expr.is_empty()
14230        && expr.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
14231        && expr.chars().next().is_some_and(char::is_alphabetic);
14232    if is_bare_ident {
14233        let schema = binding_types.get(expr)?;
14234        if semantic.schemas.enums.contains_key(schema) {
14235            return Some(TypeSyntax::Ref {
14236                name: Ident {
14237                    name: schema.clone(),
14238                    span: zero_span(),
14239                },
14240            });
14241        }
14242        return None;
14243    }
14244    let (root, path) = expression_path(expr)?;
14245    let schema = binding_types.get(&root)?;
14246    semantic.schemas.resolve_field_path(schema, &path).ok()
14247}
14248
14249fn validate_case_pattern(
14250    rule: &RuleDecl,
14251    pattern: &str,
14252    scrutinee_ty: Option<&TypeSyntax>,
14253    span: SourceSpan,
14254    semantic: &SemanticContext,
14255    diagnostics: &mut Vec<Diagnostic>,
14256) {
14257    if matches!(pattern, "_" | "default") {
14258        return;
14259    }
14260    if pattern == "None" {
14261        if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
14262            diagnostics.push(Diagnostic {
14263                related: Vec::new(),
14264                span,
14265                message: format!(
14266                    "rule `{}` uses `None` for a non-optional case",
14267                    rule.name.name
14268                ),
14269                suggestion: Some("use `None` only when matching an optional field".to_owned()),
14270            });
14271        }
14272        return;
14273    }
14274    if pattern.starts_with("Some ") {
14275        if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
14276            diagnostics.push(Diagnostic {
14277                related: Vec::new(),
14278                span,
14279                message: format!(
14280                    "rule `{}` uses `Some` for a non-optional case",
14281                    rule.name.name
14282                ),
14283                suggestion: Some("use `Some name` only when matching an optional field".to_owned()),
14284            });
14285        }
14286        return;
14287    }
14288    let Some(scrutinee_ty) = scrutinee_ty else {
14289        return;
14290    };
14291    match scrutinee_ty {
14292        TypeSyntax::Ref { name } => {
14293            let Some(variants) = semantic.schemas.enums.get(&name.name) else {
14294                return;
14295            };
14296            let (variant, binding) = sum_case_pattern_parts(pattern);
14297            if !variants.contains(variant) {
14298                diagnostics.push(Diagnostic {
14299                    related: Vec::new(),
14300                    span,
14301                    message: format!("enum `{}` has no variant `{variant}`", name.name),
14302                    suggestion: Some(format!(
14303                        "use one of: {}",
14304                        variants.iter().cloned().collect::<Vec<_>>().join(", ")
14305                    )),
14306                });
14307                return;
14308            }
14309            // `as` binds a data-carrying variant's payload (spec/sum-types.md);
14310            // a bare variant has no payload to bind.
14311            if binding.is_some()
14312                && !semantic
14313                    .schemas
14314                    .class_exists(&format!("{}.{variant}", name.name))
14315            {
14316                diagnostics.push(Diagnostic {
14317                    related: Vec::new(),
14318                    span,
14319                    message: format!(
14320                        "variant `{variant}` of enum `{}` carries no payload to bind",
14321                        name.name
14322                    ),
14323                    suggestion: Some(format!("write `{variant} => {{ ... }}` without `as`")),
14324                });
14325            }
14326        }
14327        TypeSyntax::Union { variants, .. } => {
14328            let Some(literal) = parse_literal_expr(pattern) else {
14329                diagnostics.push(Diagnostic {
14330                    related: Vec::new(),
14331                    span,
14332                    message: format!(
14333                        "rule `{}` has unsupported case pattern `{pattern}`",
14334                        rule.name.name
14335                    ),
14336                    suggestion: Some("use a literal branch value or `_`".to_owned()),
14337                });
14338                return;
14339            };
14340            validate_union_case_pattern(rule, variants, &literal, span, diagnostics);
14341        }
14342        TypeSyntax::AgentRef { agents, .. } => {
14343            let Some(literal) = parse_literal_expr(pattern) else {
14344                diagnostics.push(Diagnostic {
14345                    related: Vec::new(),
14346                    span,
14347                    message: format!(
14348                        "rule `{}` has unsupported AgentRef case pattern `{pattern}`",
14349                        rule.name.name
14350                    ),
14351                    suggestion: Some(
14352                        "use a declared agent name, a string literal, or `_`".to_owned(),
14353                    ),
14354                });
14355                return;
14356            };
14357            validate_agent_ref_case_pattern(rule, agents, &literal, span, diagnostics);
14358        }
14359        TypeSyntax::Optional { inner, .. } => {
14360            validate_case_pattern(rule, pattern, Some(inner), span, semantic, diagnostics);
14361        }
14362        // `case` over a `bool` field: only the two literals `true`/`false` (plus
14363        // the `_`/`default` fallbacks already handled above) are valid patterns.
14364        TypeSyntax::Primitive { name, .. } if name == "bool" => {
14365            if !matches!(pattern, "true" | "false") {
14366                diagnostics.push(Diagnostic {
14367                    related: Vec::new(),
14368                    span,
14369                    message: format!(
14370                        "rule `{}` has case pattern `{pattern}` that is not a `bool` value",
14371                        rule.name.name
14372                    ),
14373                    suggestion: Some("match `true`, `false`, or `_`".to_owned()),
14374                });
14375            }
14376        }
14377        _ => {
14378            diagnostics.push(Diagnostic {
14379                related: Vec::new(),
14380                span,
14381                message: format!(
14382                    "rule `{}` cannot pattern-match this scrutinee type",
14383                    rule.name.name
14384                ),
14385                suggestion: Some(
14386                    "match an enum, literal union, optional, or tagged output union".to_owned(),
14387                ),
14388            });
14389        }
14390    }
14391}
14392
14393fn terminal_case_tags() -> [&'static str; 4] {
14394    ["Completed", "Failed", "TimedOut", "Cancelled"]
14395}
14396
14397fn validate_terminal_case_pattern(
14398    rule: &RuleDecl,
14399    pattern: &str,
14400    span: SourceSpan,
14401    diagnostics: &mut Vec<Diagnostic>,
14402) {
14403    if is_fallback_pattern(pattern) {
14404        return;
14405    }
14406    let mut parts = pattern.split_whitespace();
14407    let Some(tag) = parts.next() else {
14408        return;
14409    };
14410    // Binding is `Tag as binding` (Stage 1b: the legacy space form `Tag binding` is
14411    // no longer accepted — it aligns terminal cases with enum-variant `as` binding).
14412    let second = parts.next();
14413    let binding = match second {
14414        Some("as") => parts.next(),
14415        other => other,
14416    };
14417    let uses_as = matches!(second, Some("as"));
14418    if parts.next().is_some() || binding.is_none() || !uses_as {
14419        diagnostics.push(Diagnostic { related: Vec::new(),
14420            span,
14421            message: format!(
14422                "rule `{}` has malformed terminal-output case pattern `{pattern}`",
14423                rule.name.name
14424            ),
14425            suggestion: Some("write `Completed as result`, `Failed as failure`, `TimedOut as timeout`, or `Cancelled as cancel` (the `as` is required)".to_owned()),
14426        });
14427        return;
14428    }
14429    let tags = terminal_case_tags();
14430    if !tags.contains(&tag) {
14431        diagnostics.push(Diagnostic {
14432            related: Vec::new(),
14433            span,
14434            message: format!(
14435                "rule `{}` terminal-output case pattern cannot be `{tag}`",
14436                rule.name.name
14437            ),
14438            suggestion: Some(format!("use one of: {}", tags.join(", "))),
14439        });
14440    }
14441}
14442
14443fn validate_terminal_case_coverage(
14444    rule: &RuleDecl,
14445    branches: &[SpanCaseBranchHead<'_>],
14446    diagnostics: &mut Vec<Diagnostic>,
14447) {
14448    validate_unreachable_after_fallback(rule, branches, diagnostics);
14449    if branches.is_empty()
14450        || branches
14451            .iter()
14452            .any(|branch| is_fallback_pattern(branch.pattern))
14453    {
14454        validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
14455        return;
14456    }
14457    validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
14458    let covered = branches
14459        .iter()
14460        .filter(|branch| branch.guard.is_none())
14461        .filter_map(|branch| normalized_terminal_case_pattern(branch.pattern))
14462        .collect::<BTreeSet<_>>();
14463    let missing = terminal_case_tags()
14464        .iter()
14465        .filter(|tag| !covered.contains(**tag))
14466        .copied()
14467        .collect::<Vec<_>>();
14468    if !missing.is_empty() {
14469        diagnostics.push(Diagnostic {
14470            related: Vec::new(),
14471            span: rule.body.span,
14472            message: format!(
14473                "rule `{}` has non-exhaustive terminal-output case; missing {}",
14474                rule.name.name,
14475                missing.join(", ")
14476            ),
14477            suggestion: Some(
14478                "add terminal branches for every value or add `_ => { ... }`".to_owned(),
14479            ),
14480        });
14481    }
14482}
14483
14484fn validate_duplicate_terminal_case_patterns(
14485    rule: &RuleDecl,
14486    branches: &[SpanCaseBranchHead<'_>],
14487    diagnostics: &mut Vec<Diagnostic>,
14488) {
14489    let mut seen = BTreeSet::new();
14490    for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
14491        let Some(pattern) = normalized_terminal_case_pattern(branch.pattern) else {
14492            continue;
14493        };
14494        if !seen.insert(pattern.to_owned()) {
14495            diagnostics.push(Diagnostic {
14496                related: Vec::new(),
14497                span: branch.pattern_span,
14498                message: format!(
14499                    "rule `{}` has duplicate unguarded terminal-output case pattern `{pattern}`",
14500                    rule.name.name
14501                ),
14502                suggestion: Some(
14503                    "remove the duplicate branch or add mutually exclusive `where` guards"
14504                        .to_owned(),
14505                ),
14506            });
14507        }
14508    }
14509}
14510
14511fn validate_case_coverage(
14512    rule: &RuleDecl,
14513    scrutinee_ty: Option<&TypeSyntax>,
14514    branches: &[SpanCaseBranchHead<'_>],
14515    semantic: &SemanticContext,
14516    diagnostics: &mut Vec<Diagnostic>,
14517) {
14518    validate_unreachable_after_fallback(rule, branches, diagnostics);
14519    if branches.is_empty()
14520        || branches
14521            .iter()
14522            .any(|branch| is_fallback_pattern(branch.pattern))
14523    {
14524        validate_duplicate_case_patterns(rule, branches, diagnostics);
14525        return;
14526    }
14527    validate_duplicate_case_patterns(rule, branches, diagnostics);
14528
14529    let Some(domain) = finite_case_domain(scrutinee_ty, semantic) else {
14530        return;
14531    };
14532    let covered = branches
14533        .iter()
14534        .filter(|branch| branch.guard.is_none())
14535        .filter_map(|branch| normalized_case_pattern(branch.pattern))
14536        .collect::<BTreeSet<_>>();
14537    let missing = domain
14538        .iter()
14539        .filter(|value| !covered.contains(value.as_str()))
14540        .cloned()
14541        .collect::<Vec<_>>();
14542    if !missing.is_empty() {
14543        diagnostics.push(Diagnostic {
14544            related: Vec::new(),
14545            span: rule.body.span,
14546            message: format!(
14547                "rule `{}` has non-exhaustive case; missing {}",
14548                rule.name.name,
14549                missing.join(", ")
14550            ),
14551            suggestion: Some("add branches for every value or add `_ => { ... }`".to_owned()),
14552        });
14553    }
14554}
14555
14556fn validate_duplicate_case_patterns(
14557    rule: &RuleDecl,
14558    branches: &[SpanCaseBranchHead<'_>],
14559    diagnostics: &mut Vec<Diagnostic>,
14560) {
14561    let mut seen = BTreeSet::new();
14562    for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
14563        let Some(pattern) = normalized_case_pattern(branch.pattern) else {
14564            continue;
14565        };
14566        if !seen.insert(pattern.to_owned()) {
14567            diagnostics.push(Diagnostic {
14568                related: Vec::new(),
14569                span: branch.pattern_span,
14570                message: format!(
14571                    "rule `{}` has duplicate unguarded case pattern `{pattern}`",
14572                    rule.name.name
14573                ),
14574                suggestion: Some(
14575                    "remove the duplicate branch or add mutually exclusive `where` guards"
14576                        .to_owned(),
14577                ),
14578            });
14579        }
14580    }
14581}
14582
14583/// Flags case branches that can never be reached because an earlier *unguarded*
14584/// wildcard (`_`/`default`) already matches everything. Shared by rule cases and
14585/// terminal-output cases. Mirrors case-family.maude inv c (redundant-postwild): any
14586/// arm after the wildcard is redundant. A *guarded* fallback (`_ where g`) does not
14587/// shadow, since its guard can fail at runtime.
14588fn validate_unreachable_after_fallback(
14589    rule: &RuleDecl,
14590    branches: &[SpanCaseBranchHead<'_>],
14591    diagnostics: &mut Vec<Diagnostic>,
14592) {
14593    let mut ordered: Vec<&SpanCaseBranchHead<'_>> = branches.iter().collect();
14594    ordered.sort_by_key(|branch| branch.pattern_span.start);
14595    let mut fallback_span: Option<SourceSpan> = None;
14596    for branch in ordered {
14597        if let Some(prior) = fallback_span {
14598            diagnostics.push(
14599                Diagnostic {
14600                    related: Vec::new(),
14601                    span: branch.pattern_span,
14602                    message: format!(
14603                        "rule `{}` has an unreachable case branch after the `_` wildcard",
14604                        rule.name.name
14605                    ),
14606                    suggestion: Some(
14607                        "move this branch before the wildcard, or remove it".to_owned(),
14608                    ),
14609                }
14610                .with_related(
14611                    prior,
14612                    "this unguarded wildcard already matches every remaining value",
14613                ),
14614            );
14615        } else if branch.guard.is_none() && is_fallback_pattern(branch.pattern) {
14616            fallback_span = Some(branch.pattern_span);
14617        }
14618    }
14619}
14620
14621fn finite_case_domain(
14622    scrutinee_ty: Option<&TypeSyntax>,
14623    semantic: &SemanticContext,
14624) -> Option<Vec<String>> {
14625    match scrutinee_ty? {
14626        TypeSyntax::Ref { name } => semantic
14627            .schemas
14628            .enums
14629            .get(&name.name)
14630            .map(|variants| variants.iter().cloned().collect()),
14631        TypeSyntax::Union { variants, .. } => {
14632            let values = variants
14633                .iter()
14634                .filter_map(|variant| match variant {
14635                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
14636                    _ => None,
14637                })
14638                .collect::<Vec<_>>();
14639            (!values.is_empty()).then_some(values)
14640        }
14641        TypeSyntax::Optional { .. } => Some(vec!["Some".to_owned(), "None".to_owned()]),
14642        TypeSyntax::AgentRef { agents, .. } => {
14643            Some(agents.iter().map(|agent| agent.name.clone()).collect())
14644        }
14645        // `bool` is a finite two-value domain: an exhaustive `case` over it must
14646        // cover both `true` and `false` (or carry a `_`).
14647        TypeSyntax::Primitive { name, .. } if name == "bool" => {
14648            Some(vec!["true".to_owned(), "false".to_owned()])
14649        }
14650        _ => None,
14651    }
14652}
14653
14654/// Splits a sum-type case pattern `Variant as binding` into variant and
14655/// binding (spec/sum-types.md); a plain pattern returns no binding.
14656fn sum_case_pattern_parts(pattern: &str) -> (&str, Option<&str>) {
14657    match pattern.split_once(" as ") {
14658        Some((variant, binding)) => (variant.trim(), Some(binding.trim())),
14659        None => (pattern.trim(), None),
14660    }
14661}
14662
14663fn normalized_case_pattern(pattern: &str) -> Option<&str> {
14664    if is_fallback_pattern(pattern) {
14665        return None;
14666    }
14667    if pattern.starts_with("Some ") {
14668        return Some("Some");
14669    }
14670    if pattern == "None" {
14671        return Some("None");
14672    }
14673    // Coverage counts the variant, not its payload binding.
14674    let (pattern, _) = sum_case_pattern_parts(pattern);
14675    // `bool` literals parse to the value-less `LiteralExpr::Bool`; return them
14676    // verbatim so they count toward `true`/`false` coverage.
14677    if matches!(pattern, "true" | "false") {
14678        return Some(pattern);
14679    }
14680    parse_literal_expr(pattern).and_then(|literal| match literal {
14681        LiteralExpr::String(value) | LiteralExpr::Ident(value) => Some(value),
14682        _ => None,
14683    })
14684}
14685
14686fn normalized_terminal_case_pattern(pattern: &str) -> Option<&str> {
14687    if is_fallback_pattern(pattern) {
14688        return None;
14689    }
14690    pattern.split_whitespace().next()
14691}
14692
14693fn is_fallback_pattern(pattern: &str) -> bool {
14694    matches!(pattern, "_" | "default")
14695}
14696
14697fn validate_union_case_pattern(
14698    rule: &RuleDecl,
14699    variants: &[TypeSyntax],
14700    literal: &LiteralExpr<'_>,
14701    span: SourceSpan,
14702    diagnostics: &mut Vec<Diagnostic>,
14703) {
14704    let allowed = variants
14705        .iter()
14706        .filter_map(|variant| match variant {
14707            TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
14708            _ => None,
14709        })
14710        .collect::<Vec<_>>();
14711    if allowed.is_empty() {
14712        return;
14713    }
14714    let LiteralExpr::String(value) = literal else {
14715        diagnostics.push(Diagnostic {
14716            related: Vec::new(),
14717            span,
14718            message: format!(
14719                "rule `{}` case pattern must be one of its literal variants",
14720                rule.name.name
14721            ),
14722            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14723        });
14724        return;
14725    };
14726    if !allowed.contains(value) {
14727        diagnostics.push(Diagnostic {
14728            related: Vec::new(),
14729            span,
14730            message: format!("rule `{}` case pattern cannot be `{value}`", rule.name.name),
14731            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14732        });
14733    }
14734}
14735
14736fn validate_agent_ref_case_pattern(
14737    rule: &RuleDecl,
14738    agents: &[Ident],
14739    literal: &LiteralExpr<'_>,
14740    span: SourceSpan,
14741    diagnostics: &mut Vec<Diagnostic>,
14742) {
14743    let allowed = agents
14744        .iter()
14745        .map(|agent| agent.name.as_str())
14746        .collect::<Vec<_>>();
14747    let (LiteralExpr::String(value) | LiteralExpr::Ident(value)) = literal else {
14748        diagnostics.push(Diagnostic {
14749            related: Vec::new(),
14750            span,
14751            message: format!("rule `{}` has non-agent case pattern", rule.name.name),
14752            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14753        });
14754        return;
14755    };
14756    if !allowed.contains(value) {
14757        diagnostics.push(Diagnostic {
14758            related: Vec::new(),
14759            span,
14760            message: format!("AgentRef has no agent `{value}`"),
14761            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
14762        });
14763    }
14764}
14765
14766fn validate_binding_uses(
14767    rule: &RuleDecl,
14768    line: &str,
14769    seen_bindings: &BTreeSet<String>,
14770    scope_stack: &[(String, DependencyPredicate)],
14771    diagnostics: &mut Vec<Diagnostic>,
14772) {
14773    for root in interpolation_roots(line) {
14774        if !seen_bindings.contains(&root) {
14775            continue;
14776        }
14777        if scope_stack.iter().any(|(binding, _)| binding == &root) {
14778            continue;
14779        }
14780
14781        diagnostics.push(Diagnostic { related: Vec::new(),
14782            span: rule.body.span,
14783            message: format!(
14784                "rule `{}` uses effect output `{root}` outside a matching `after {root} ...` block",
14785                rule.name.name
14786            ),
14787            suggestion: Some(format!(
14788                "move this use into `after {root} succeeds {{ ... }}` or another matching terminal branch"
14789            )),
14790        });
14791    }
14792}
14793
14794fn after_scopes(block_stack: &[BlockFrame]) -> Vec<(String, DependencyPredicate)> {
14795    block_stack
14796        .iter()
14797        .map(|frame| match frame {
14798            BlockFrame::After { binding, predicate } => (binding.clone(), predicate.clone()),
14799        })
14800        .collect()
14801}
14802
14803/// The single lowering table for readiness sugar: maps a `when` pattern to
14804/// the runtime fact name it matches. The general form is
14805/// `when fact <name> as x`; the English phrases are documented abbreviations
14806/// of it.
14807pub fn runtime_fact_name_for_pattern(pattern: &str) -> Option<String> {
14808    let pattern = pattern.trim();
14809    if let Some(rest) = pattern.strip_prefix("fact ") {
14810        let name = rest.split_whitespace().next()?;
14811        return Some(name.to_owned());
14812    }
14813    // Inbound messaging (spec/messaging.md): `message from <channel>` matches the
14814    // channel-specific `message.<channel>` fact ingested by `whip message`.
14815    if let Some(rest) = pattern.strip_prefix("message from ") {
14816        if let Some(channel) = rest.split_whitespace().next() {
14817            return Some(format!("message.{channel}"));
14818        }
14819    }
14820    let mut words = pattern.split_whitespace();
14821    let first = words.next()?;
14822    if words.next() == Some("completed") && words.next() == Some("turn") {
14823        let _ = first;
14824        return Some("agent.turn.completed".to_owned());
14825    }
14826    {
14827        let mut words = pattern.split_whitespace();
14828        let _tracker = words.next();
14829        if words.next() == Some("has")
14830            && words.next() == Some("ready")
14831            && words.next() == Some("issue")
14832        {
14833            return Some("tracker.issue.ready".to_owned());
14834        }
14835    }
14836    if first.chars().next().is_some_and(char::is_uppercase) {
14837        return Some(first.to_owned());
14838    }
14839    None
14840}
14841
14842/// The schema used to type-check fields on the pattern's binding. Dotted
14843/// runtime fact names are untyped (no class declares them); the sugar forms
14844/// map to their builtin schemas.
14845fn binding_from_when(when: &str) -> Option<(String, String)> {
14846    let (pattern, _) = split_when_guard(when);
14847    let binding = binding_after_as(pattern)?;
14848    let first = pattern.split_whitespace().next()?;
14849    let completed_turn = {
14850        let mut words = pattern.split_whitespace();
14851        words.next();
14852        words.next() == Some("completed") && words.next() == Some("turn")
14853    };
14854    let has_ready_issue = {
14855        let mut words = pattern.split_whitespace();
14856        words.next();
14857        words.next() == Some("has")
14858            && words.next() == Some("ready")
14859            && words.next() == Some("issue")
14860    };
14861    let schema = if let Some(rest) = pattern.strip_prefix("fact ") {
14862        rest.split_whitespace().next()?.to_owned()
14863    } else if first.chars().next().is_some_and(char::is_uppercase) {
14864        first.to_owned()
14865    } else if first.contains('.') {
14866        // Bare dotted reaction `when deploy.finished as d` — typed against a
14867        // declared `event` (validated at the call site,
14868        // spec/event-ingress.md).
14869        first.to_owned()
14870    } else if completed_turn {
14871        "AgentTurn".to_owned()
14872    } else if has_ready_issue {
14873        "WorkItem".to_owned()
14874    } else if pattern.starts_with("message from ") {
14875        // Inbound messaging (spec/messaging.md): `when message from <channel> as
14876        // msg` binds the generic `Message` envelope, never a domain type.
14877        "Message".to_owned()
14878    } else {
14879        return None;
14880    };
14881
14882    Some((binding, schema))
14883}
14884
14885fn split_when_guard(when: &str) -> (&str, Option<&str>) {
14886    match when.split_once(" where ") {
14887        Some((pattern, guard)) => (pattern.trim(), Some(guard.trim())),
14888        None => (when.trim(), None),
14889    }
14890}
14891
14892fn effect_binding_schema(
14893    line: &str,
14894    kind: &IrEffectKind,
14895    semantic: &SemanticContext,
14896) -> Option<String> {
14897    match kind {
14898        IrEffectKind::SchemaCoerce => parse_coerce_call_name(line).and_then(|name| {
14899            semantic
14900                .coerce_outputs
14901                .get(name)
14902                .and_then(schema_name_for_path)
14903        }),
14904        IrEffectKind::AgentTell
14905        | IrEffectKind::CapabilityCall
14906        | IrEffectKind::EventEmit
14907        | IrEffectKind::WorkflowInvoke
14908        | IrEffectKind::TimerWait
14909        | IrEffectKind::ExecCommand
14910        | IrEffectKind::TrackerFile
14911        | IrEffectKind::TrackerClaim
14912        | IrEffectKind::TrackerRenew
14913        | IrEffectKind::TrackerRelease
14914        | IrEffectKind::TrackerFinish
14915        | IrEffectKind::LeaseAcquire
14916        | IrEffectKind::LeaseRenew
14917        | IrEffectKind::LedgerAppend
14918        | IrEffectKind::CounterConsume
14919        | IrEffectKind::SignalEmit
14920        | IrEffectKind::FileRead
14921        | IrEffectKind::FileWrite
14922        | IrEffectKind::FileImport
14923        | IrEffectKind::FileExport => None,
14924    }
14925}
14926
14927fn parse_coerce_call_name(line: &str) -> Option<&str> {
14928    let rest = line.strip_prefix("coerce ")?;
14929    rest.split_once('(').map(|(name, _)| name.trim())
14930}
14931
14932fn parse_coerce_call(line: &str) -> Option<(&str, Vec<&str>)> {
14933    let rest = line.strip_prefix("coerce ")?;
14934    let call = rest.split(" as ").next().unwrap_or(rest).trim();
14935    let (name, tail) = call.split_once('(')?;
14936    let (args, _) = tail.rsplit_once(')')?;
14937    Some((name.trim(), split_expression_args(args)))
14938}
14939
14940fn split_expression_args(args: &str) -> Vec<&str> {
14941    let mut values = Vec::new();
14942    let mut start = 0usize;
14943    let mut depth = 0i32;
14944    let mut in_string = false;
14945    let mut previous = '\0';
14946    for (index, ch) in args.char_indices() {
14947        if ch == '"' && previous != '\\' {
14948            in_string = !in_string;
14949        } else if !in_string {
14950            match ch {
14951                '(' | '[' | '{' => depth += 1,
14952                ')' | ']' | '}' => depth -= 1,
14953                ',' if depth == 0 => {
14954                    let value = args[start..index].trim();
14955                    if !value.is_empty() {
14956                        values.push(value);
14957                    }
14958                    start = index + ch.len_utf8();
14959                }
14960                _ => {}
14961            }
14962        }
14963        previous = ch;
14964    }
14965    let value = args[start..].trim();
14966    if !value.is_empty() {
14967        values.push(value);
14968    }
14969    values
14970}
14971
14972fn effect_payload_statements(body: &str) -> Vec<String> {
14973    collect_body_statements(body, effect_payload_statement_balance)
14974}
14975
14976fn workflow_invoke_statements(body: &str) -> Vec<String> {
14977    collect_body_statements(body, workflow_invoke_statement_balance)
14978}
14979
14980#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14981enum StatementBalance {
14982    None,
14983    Parens,
14984    Braces,
14985}
14986
14987fn collect_body_statements(
14988    body: &str,
14989    statement_balance: fn(&str) -> Option<StatementBalance>,
14990) -> Vec<String> {
14991    let lines = body.lines().collect::<Vec<_>>();
14992    let mut statements = Vec::new();
14993    let mut index = 0usize;
14994    let mut record_depth = 0i32;
14995    let mut multiline_string = false;
14996    while index < lines.len() {
14997        let trimmed = lines[index].trim();
14998        if trimmed.is_empty() {
14999            index += 1;
15000            continue;
15001        }
15002        if multiline_string {
15003            if trimmed.contains("\"\"\"") {
15004                multiline_string = false;
15005            }
15006            index += 1;
15007            continue;
15008        }
15009        if record_depth > 0 {
15010            record_depth += brace_delta(trimmed);
15011            index += 1;
15012            continue;
15013        }
15014        if parse_record_start(trimmed).is_some() {
15015            record_depth = brace_delta(trimmed).max(1);
15016            index += 1;
15017            continue;
15018        }
15019        if trimmed.contains("\"\"\"") {
15020            multiline_string = trimmed.matches("\"\"\"").count() % 2 == 1;
15021            index += 1;
15022            continue;
15023        }
15024        if let Some(balance) = statement_balance(trimmed) {
15025            match balance {
15026                StatementBalance::None => statements.push(trimmed.to_owned()),
15027                StatementBalance::Parens => {
15028                    let (statement, next_index) =
15029                        statement_until_balanced(&lines, index, trimmed, paren_delta);
15030                    statements.push(statement);
15031                    index = next_index + 1;
15032                    continue;
15033                }
15034                StatementBalance::Braces => {
15035                    let (statement, next_index) =
15036                        statement_until_balanced(&lines, index, trimmed, brace_delta);
15037                    statements.push(statement);
15038                    index = next_index + 1;
15039                    continue;
15040                }
15041            }
15042        }
15043        index += 1;
15044    }
15045    statements
15046}
15047
15048fn effect_payload_statement_balance(trimmed: &str) -> Option<StatementBalance> {
15049    if trimmed.starts_with("coerce ") {
15050        Some(StatementBalance::Parens)
15051    } else if trimmed.starts_with("claim ") {
15052        Some(StatementBalance::None)
15053    } else {
15054        None
15055    }
15056}
15057
15058fn workflow_invoke_statement_balance(trimmed: &str) -> Option<StatementBalance> {
15059    trimmed
15060        .starts_with("invoke ")
15061        .then_some(StatementBalance::Braces)
15062}
15063
15064fn invoke_statement_parts(statement: &str) -> Option<(&str, &str)> {
15065    let rest = statement.trim().strip_prefix("invoke ")?;
15066    let target = rest
15067        .split_whitespace()
15068        .next()
15069        .unwrap_or("")
15070        .trim_end_matches('{');
15071    if target.is_empty() {
15072        return None;
15073    }
15074    let open = statement.find('{')?;
15075    let mut depth = 0i32;
15076    let mut close = None;
15077    for (offset, ch) in statement[open..].char_indices() {
15078        match ch {
15079            '{' => depth += 1,
15080            '}' => {
15081                depth -= 1;
15082                if depth == 0 {
15083                    close = Some(open + offset);
15084                    break;
15085                }
15086            }
15087            _ => {}
15088        }
15089    }
15090    let close = close?;
15091    (close > open).then_some((target, statement[open + 1..close].trim()))
15092}
15093
15094fn statement_until_balanced(
15095    lines: &[&str],
15096    index: usize,
15097    trimmed: &str,
15098    delta: fn(&str) -> i32,
15099) -> (String, usize) {
15100    let mut statement = trimmed.to_owned();
15101    let mut depth = delta(trimmed);
15102    let mut cursor = index;
15103    while depth > 0 && cursor + 1 < lines.len() {
15104        cursor += 1;
15105        let next = lines[cursor].trim();
15106        statement.push(' ');
15107        statement.push_str(next);
15108        depth += delta(next);
15109    }
15110    (statement, cursor)
15111}
15112
15113fn paren_delta(line: &str) -> i32 {
15114    line.chars().fold(0, |depth, ch| match ch {
15115        '(' => depth + 1,
15116        ')' => depth - 1,
15117        _ => depth,
15118    })
15119}
15120
15121/// The hygienic class name synthesized for an inline `decide -> { … } as
15122/// <binding>`. Dots are illegal in user class names (like the `flow.<name>.seg*`
15123/// rule convention), so `decide.<rule>.<binding>` can never collide with a
15124/// declared schema. The lowering pass, the type checker, and the runtime fixture
15125/// all derive the same name, so the anonymous result shape flows exactly like a
15126/// named `coerce -> Schema`: `after <binding> succeeds as r` resolves `r`'s
15127/// fields for `case` dispatch and field access.
15128pub fn inline_decide_schema_name(rule: &str, binding: &str) -> String {
15129    format!("decide.{rule}.{binding}")
15130}
15131
15132/// A single-identifier `decide` field type is either a primitive keyword
15133/// (`bool`, `string`, …) or a reference to a declared class/enum. The `decide`
15134/// grammar only admits single identifiers, so no compound parsing is needed.
15135fn decide_field_type_syntax(ty: &str, span: SourceSpan) -> TypeSyntax {
15136    if is_primitive_type(ty) {
15137        TypeSyntax::Primitive {
15138            name: ty.to_owned(),
15139            span,
15140        }
15141    } else {
15142        TypeSyntax::Ref {
15143            name: Ident {
15144                name: ty.to_owned(),
15145                span,
15146            },
15147        }
15148    }
15149}
15150
15151/// Collects every inline `decide … as <binding>` in a rule body — recursing
15152/// through nested after/case/branch/handler blocks — yielding
15153/// `(binding, result_fields, span)` for synthesis and type registration.
15154#[allow(clippy::type_complexity)]
15155fn collect_decide_effects<'a>(
15156    statements: &'a [body::BodyStmt],
15157    out: &mut Vec<(&'a str, &'a [(String, String)], SourceSpan)>,
15158) {
15159    for statement in statements {
15160        match statement {
15161            body::BodyStmt::Effect(effect) => {
15162                if let body::BodyEffectKind::Decide { result_fields } = &effect.kind {
15163                    if let Some(binding) = &effect.binding {
15164                        out.push((binding.as_str(), result_fields.as_slice(), effect.span));
15165                    }
15166                }
15167            }
15168            body::BodyStmt::After(after) => collect_decide_effects(&after.body, out),
15169            body::BodyStmt::Case(case) => {
15170                for branch in &case.branches {
15171                    collect_decide_effects(&branch.body, out);
15172                }
15173            }
15174            _ => {}
15175        }
15176    }
15177}
15178
15179/// Registers each inline `decide … as <binding>` result as `Ref(decide.<rule>.<binding>)`
15180/// so the after-binding type flow resolves the anonymous shape's fields, exactly
15181/// like a named `coerce -> Schema`. The synthesized class is injected into both
15182/// the semantic schema index and the IR by [`collect_inline_decide_schemas`].
15183fn collect_decide_payload_types(
15184    statements: &[body::BodyStmt],
15185    rule_name: &str,
15186    payloads: &mut BTreeMap<String, IrType>,
15187) {
15188    let mut decides = Vec::new();
15189    collect_decide_effects(statements, &mut decides);
15190    for (binding, _fields, _span) in decides {
15191        payloads.insert(
15192            binding.to_owned(),
15193            IrType::Ref(inline_decide_schema_name(rule_name, binding)),
15194        );
15195    }
15196}
15197
15198fn collect_prompt_payload_types(
15199    statements: &[body::BodyStmt],
15200    payloads: &mut BTreeMap<String, IrType>,
15201) {
15202    for statement in statements {
15203        match statement {
15204            body::BodyStmt::Effect(effect) => {
15205                if matches!(&effect.kind, body::BodyEffectKind::Prompt { .. }) {
15206                    if let Some(binding) = &effect.binding {
15207                        payloads
15208                            .insert(binding.clone(), IrType::Primitive(IrPrimitiveType::String));
15209                    }
15210                }
15211            }
15212            body::BodyStmt::After(after) => collect_prompt_payload_types(&after.body, payloads),
15213            body::BodyStmt::Case(case) => {
15214                for branch in &case.branches {
15215                    collect_prompt_payload_types(&branch.body, payloads);
15216                }
15217            }
15218            _ => {}
15219        }
15220    }
15221}
15222
15223/// Synthesizes a hygienic `decide.<rule>.<binding>` class for every inline
15224/// `decide -> { … } as <binding>`, injecting it into both the semantic schema
15225/// index (so field access / `case` type-check) and the IR (so the runtime
15226/// fixture can generate the anonymous shape). Mirrors the generated
15227/// `<Enum>.<Variant>` class synthesis for data-carrying sum-type variants.
15228fn collect_inline_decide_schemas(
15229    items: &[Item],
15230    semantic: &mut SemanticContext,
15231    ir: &mut IrProgram,
15232) {
15233    for item in items {
15234        let Item::Rule(rule) = item else {
15235            continue;
15236        };
15237        let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15238        let mut decides = Vec::new();
15239        collect_decide_effects(&body_ast.statements, &mut decides);
15240        for (binding, fields, span) in decides {
15241            let name = inline_decide_schema_name(&rule.name.name, binding);
15242            // Build the field shape once as `TypeSyntax` (the schema-index form),
15243            // then lower it for the IR so both representations stay in lockstep.
15244            let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
15245            let mut ir_fields = Vec::new();
15246            for (field_name, field_ty) in fields {
15247                let ty = decide_field_type_syntax(field_ty, span);
15248                ir_fields.push(IrClassField {
15249                    name: field_name.clone(),
15250                    ty: lower_type(ty.clone()),
15251                    is_key: false,
15252                    presence_condition: None,
15253                    span,
15254                });
15255                syntax_fields.insert(field_name.clone(), ty);
15256            }
15257            semantic.schemas.classes.insert(name.clone(), syntax_fields);
15258            ir.schemas.push(IrSchema::Class(IrClass {
15259                name,
15260                fields: ir_fields,
15261                span,
15262            }));
15263        }
15264    }
15265}
15266
15267/// The hygienic synthetic class name for a `redact … as <binding>` projection:
15268/// `redact.<rule>.<binding>`, holding only the kept fields of the source schema.
15269pub fn redact_schema_name(rule: &str, binding: &str) -> String {
15270    format!("redact.{rule}.{binding}")
15271}
15272
15273/// Collects every `redact <source> keep [..] as <binding>` in a rule body —
15274/// recursing through nested after/case/branch/handler blocks — for projected-type
15275/// synthesis, type registration, and IFC value-flow.
15276#[allow(clippy::type_complexity)]
15277fn collect_redact_effects<'a>(
15278    statements: &'a [body::BodyStmt],
15279    out: &mut Vec<(&'a str, &'a [String], &'a str, SourceSpan)>,
15280) {
15281    for statement in statements {
15282        match statement {
15283            body::BodyStmt::Redact {
15284                source,
15285                keep,
15286                binding,
15287                span,
15288            } => out.push((source.as_str(), keep.as_slice(), binding.as_str(), *span)),
15289            body::BodyStmt::After(after) => collect_redact_effects(&after.body, out),
15290            body::BodyStmt::Case(case) => {
15291                for branch in &case.branches {
15292                    collect_redact_effects(&branch.body, out);
15293                }
15294            }
15295            _ => {}
15296        }
15297    }
15298}
15299
15300/// Resolves binding -> schema name for a rule's redact SOURCES: `when Class as x`
15301/// matches, plus coerce/decide/exec result bindings. Used only to find the schema
15302/// a `redact` projects from, so the synthetic projected class copies the kept
15303/// fields' types. (`after`-alias sources are a documented follow-up; an
15304/// unresolved source surfaces as an empty projection + a `validate_redactions`
15305/// diagnostic.) Diagnostics from the reused collector are discarded — the real
15306/// pass re-emits them.
15307fn rule_binding_schemas(rule: &RuleDecl, semantic: &SemanticContext) -> BTreeMap<String, String> {
15308    let mut schemas = binding_types_for_rule(rule);
15309    let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15310    let mut payloads = collect_effect_payload_types(rule, semantic, &mut Vec::new());
15311    collect_exec_payload_types(&body_ast.statements, semantic, &mut payloads);
15312    collect_decide_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
15313    collect_redact_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
15314    // `after <binding> <predicate> as <alias>` aliases the effect's completed
15315    // payload schema, so a `coerce … as c` then `after c succeeds as cust` then
15316    // `redact cust …` resolves (the primary read-then-redact flow). Only
15317    // payload-carrying predicates are mapped here; terminal predicates
15318    // (`times out`/`fails`) bind synthetic terminal schemas not usefully redacted.
15319    for line in rule.body.text.lines() {
15320        let Some(rest) = line.trim().strip_prefix("after ") else {
15321            continue;
15322        };
15323        let mut words = rest.split_whitespace();
15324        let Some(binding) = words.next() else {
15325            continue;
15326        };
15327        let Some(predicate) = words.next() else {
15328            continue;
15329        };
15330        if predicate == "times" && words.next() != Some("out") {
15331            continue;
15332        }
15333        let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
15334            continue;
15335        };
15336        let alias = alias.trim_end_matches('{').trim();
15337        if alias.is_empty() {
15338            continue;
15339        }
15340        if let Some(IrType::Ref(schema)) = payloads.get(binding) {
15341            schemas.insert(alias.to_owned(), schema.clone());
15342        }
15343    }
15344    for (binding, ty) in payloads {
15345        if let IrType::Ref(schema) = ty {
15346            schemas.insert(binding, schema);
15347        }
15348    }
15349    schemas
15350}
15351
15352/// Synthesizes a hygienic `redact.<rule>.<binding>` class for every
15353/// `redact <source> keep [..] as <binding>`, holding ONLY the kept fields of the
15354/// source schema (with their source types). This is what makes a redaction sound:
15355/// the projected binding cannot expose a dropped field (accessing one is a
15356/// type error, since it is absent from the synthetic class), so the lowered IFC
15357/// label the checker assigns the projection is honoured by the type system too.
15358/// Mirrors [`collect_inline_decide_schemas`]; run before the rule loop so
15359/// `analyze_rule` sees the class. A redact chained off an earlier redact's output
15360/// resolves via the local map built as the pass proceeds.
15361fn collect_redact_schemas(items: &[Item], semantic: &mut SemanticContext, ir: &mut IrProgram) {
15362    for item in items {
15363        let Item::Rule(rule) = item else {
15364            continue;
15365        };
15366        let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
15367        let mut redacts = Vec::new();
15368        collect_redact_effects(&body_ast.statements, &mut redacts);
15369        if redacts.is_empty() {
15370            continue;
15371        }
15372        let binding_schemas = rule_binding_schemas(rule, semantic);
15373        let mut local: BTreeMap<String, String> = BTreeMap::new();
15374        for (source, keep, binding, span) in redacts {
15375            let name = redact_schema_name(&rule.name.name, binding);
15376            let source_schema = binding_schemas
15377                .get(source)
15378                .cloned()
15379                .or_else(|| local.get(source).cloned());
15380            // Clone the kept fields' types out of the source schema first, so the
15381            // immutable borrow ends before we insert the new class.
15382            let projected: Vec<(String, TypeSyntax)> = source_schema
15383                .as_ref()
15384                .and_then(|schema| semantic.schemas.classes.get(schema))
15385                .map(|src_fields| {
15386                    keep.iter()
15387                        .filter_map(|field| {
15388                            src_fields.get(field).map(|ty| (field.clone(), ty.clone()))
15389                        })
15390                        .collect()
15391                })
15392                .unwrap_or_default();
15393            let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
15394            let mut ir_fields = Vec::new();
15395            for (field_name, ty) in &projected {
15396                syntax_fields.insert(field_name.clone(), ty.clone());
15397                ir_fields.push(IrClassField {
15398                    name: field_name.clone(),
15399                    ty: lower_type(ty.clone()),
15400                    is_key: false,
15401                    presence_condition: None,
15402                    span,
15403                });
15404            }
15405            semantic.schemas.classes.insert(name.clone(), syntax_fields);
15406            ir.schemas.push(IrSchema::Class(IrClass {
15407                name: name.clone(),
15408                fields: ir_fields,
15409                span,
15410            }));
15411            local.insert(binding.to_owned(), name);
15412        }
15413    }
15414}
15415
15416/// Registers each `redact … as <binding>` result as `Ref(redact.<rule>.<binding>)`
15417/// so field access / `case` through the projection resolves against the kept-only
15418/// synthetic class (a dropped field is an unknown-field error). Mirrors
15419/// [`collect_decide_payload_types`].
15420fn collect_redact_payload_types(
15421    statements: &[body::BodyStmt],
15422    rule_name: &str,
15423    payloads: &mut BTreeMap<String, IrType>,
15424) {
15425    let mut redacts = Vec::new();
15426    collect_redact_effects(statements, &mut redacts);
15427    for (_source, _keep, binding, _span) in redacts {
15428        payloads.insert(
15429            binding.to_owned(),
15430            IrType::Ref(redact_schema_name(rule_name, binding)),
15431        );
15432    }
15433}
15434
15435/// Validates each `redact <source> keep [..] as <out>`: the source must resolve to
15436/// a known schema, and every kept field must exist on it. Fail-closed — an
15437/// unresolvable source or unknown kept field is a hard error, so a redaction can
15438/// never silently project nothing (which would carry no data and mask a mistake).
15439fn validate_redactions(
15440    rule: &RuleDecl,
15441    statements: &[body::BodyStmt],
15442    semantic: &SemanticContext,
15443    binding_schemas: &BTreeMap<String, String>,
15444    diagnostics: &mut Vec<Diagnostic>,
15445) {
15446    let mut redacts = Vec::new();
15447    collect_redact_effects(statements, &mut redacts);
15448    let mut local: BTreeMap<String, String> = BTreeMap::new();
15449    for (source, keep, binding, span) in redacts {
15450        let source_schema = binding_schemas
15451            .get(source)
15452            .cloned()
15453            .or_else(|| local.get(source).cloned());
15454        local.insert(
15455            binding.to_owned(),
15456            redact_schema_name(&rule.name.name, binding),
15457        );
15458        let Some(schema) = source_schema else {
15459            diagnostics.push(Diagnostic {
15460                related: Vec::new(),
15461                span,
15462                message: format!(
15463                    "rule `{}` redacts `{source}`, which has no known schema",
15464                    rule.name.name
15465                ),
15466                suggestion: Some(
15467                    "redact a binding with a known record type — a matched `when Class as x`, or a \
15468                     coerce/decide/exec result"
15469                        .to_owned(),
15470                ),
15471            });
15472            continue;
15473        };
15474        let Some(src_fields) = semantic.schemas.classes.get(&schema) else {
15475            continue;
15476        };
15477        for field in keep {
15478            if !src_fields.contains_key(field) {
15479                diagnostics.push(Diagnostic {
15480                    related: Vec::new(),
15481                    span,
15482                    message: format!(
15483                        "rule `{}` redacts `{source}` keeping unknown field `{field}` of `{schema}`",
15484                        rule.name.name
15485                    ),
15486                    suggestion: Some(format!("keep a field declared on `{schema}`")),
15487                });
15488            }
15489        }
15490    }
15491}
15492
15493/// Registers the typed result of the single `exec "..." -> Schema as binding`
15494/// form so `after <binding> succeeds as r` resolves `r`'s fields — the same
15495/// after-binding type flow a named `coerce -> Schema` already gets. The
15496/// streaming `-> each Schema` form records one fact per element (not a single
15497/// bound value), so it is skipped here.
15498fn collect_exec_payload_types(
15499    statements: &[body::BodyStmt],
15500    semantic: &SemanticContext,
15501    payloads: &mut BTreeMap<String, IrType>,
15502) {
15503    for statement in statements {
15504        match statement {
15505            body::BodyStmt::Effect(effect) => {
15506                if let body::BodyEffectKind::Exec {
15507                    parse_target: Some(parse),
15508                    ..
15509                } = &effect.kind
15510                {
15511                    if !parse.each {
15512                        if let Some(binding) = &effect.binding {
15513                            if semantic.schemas.class_exists(&parse.schema) {
15514                                payloads.insert(binding.clone(), IrType::Ref(parse.schema.clone()));
15515                            }
15516                        }
15517                    }
15518                }
15519            }
15520            body::BodyStmt::After(after) => {
15521                collect_exec_payload_types(&after.body, semantic, payloads)
15522            }
15523            body::BodyStmt::Case(case) => {
15524                for branch in &case.branches {
15525                    collect_exec_payload_types(&branch.body, semantic, payloads);
15526                }
15527            }
15528            _ => {}
15529        }
15530    }
15531}
15532
15533/// Collects the schemas an `exec ... -> each` stream records as facts.
15534fn push_ingest_fact_writes(statements: &[body::BodyStmt], fact_writes: &mut Vec<String>) {
15535    for statement in statements {
15536        match statement {
15537            body::BodyStmt::Effect(effect) => {
15538                match &effect.kind {
15539                    body::BodyEffectKind::Exec {
15540                        parse_target: Some(parse),
15541                        ..
15542                    } if parse.each => {
15543                        fact_writes.push(format!("schema:{}", parse.schema));
15544                    }
15545                    // `import <fmt> <Schema>` admits one `<Schema>` fact per row
15546                    // (spec/std-library/files.md), so a `when <Schema>` rule has a
15547                    // producer for liveness/effect-graph analysis.
15548                    body::BodyEffectKind::FileImport { schema, .. } => {
15549                        fact_writes.push(format!("schema:{schema}"));
15550                    }
15551                    _ => {}
15552                }
15553            }
15554            body::BodyStmt::After(after) => push_ingest_fact_writes(&after.body, fact_writes),
15555            body::BodyStmt::Case(case) => {
15556                for branch in &case.branches {
15557                    push_ingest_fact_writes(&branch.body, fact_writes);
15558                }
15559            }
15560            _ => {}
15561        }
15562    }
15563}
15564
15565/// Body-effect operand checks that need schema knowledge:
15566/// - `timer until <operand>`: a non-literal operand must be a dotted path
15567///   resolving to a `time`-typed field (spec/scheduled-time.md). Literals were
15568///   format-validated by the body parser, so anything that still looks like an
15569///   instant here is a valid literal and passes.
15570/// - `exec ... -> Schema` / `-> each Schema`: the parse target must name a
15571///   declared class (spec/json-ingestion.md).
15572///
15573/// The coordination safety model (spec/coordination.md): at most one held
15574/// lease per progression (hard default), exhaustive outcome handling, and
15575/// the linear must-release discipline (instance terminals auto-release, so
15576/// a path that ends in `complete`/`fail` is safe without an explicit
15577/// `release`).
15578fn validate_coordination_discipline(
15579    rule: &RuleDecl,
15580    statements: &[body::BodyStmt],
15581    diagnostics: &mut Vec<Diagnostic>,
15582) {
15583    let mut acquires = Vec::new();
15584    let mut consumes = Vec::new();
15585    let mut claims = Vec::new();
15586    collect_coordination_effects(statements, &mut acquires, &mut consumes, &mut claims);
15587
15588    // A `renew <binding>` names ONE OF TWO legitimate referents (T3, mirroring
15589    // the `release` disambiguation below):
15590    //   (1) an `acquire ... as <binding>` LEASE binding acquired in this rule —
15591    //       lowers to `lease.renew` (std.coord); resolves the acquire's recorded
15592    //       resource/key at runtime;
15593    //   (2) a `claim <issue> as <binding>` CLAIM binding claimed in this rule —
15594    //       lowers to `tracker.renew` (std.tracker); resolves the claimed issue
15595    //       id from the claim's output fact.
15596    // A `renew <binding>` naming NEITHER renews nothing, so catch the typo at
15597    // `whip check`. (Scoped to `renew`, which is new.)
15598    let claim_bindings = collect_claim_bindings(statements);
15599    let renewable: BTreeSet<&str> = acquires
15600        .iter()
15601        .map(|(b, _, _)| b.as_str())
15602        .chain(claim_bindings.iter().map(String::as_str))
15603        .collect();
15604    for_each_body(statements, &mut |stmt| {
15605        if let body::BodyStmt::Effect(effect) = stmt {
15606            if let body::BodyEffectKind::LeaseRenew {
15607                acquire_binding, ..
15608            } = &effect.kind
15609            {
15610                if !renewable.contains(acquire_binding.as_str()) {
15611                    diagnostics.push(Diagnostic {
15612                        related: Vec::new(),
15613                        span: effect.span,
15614                        message: format!(
15615                            "rule `{}` renews unbound coordination binding `{}`",
15616                            rule.name.name, acquire_binding
15617                        ),
15618                        suggestion: Some(format!(
15619                            "`renew {acquire_binding}` must name a lease acquired here (`acquire ... as {acquire_binding}`) or an issue claimed here (`claim ... as {acquire_binding}`)"
15620                        )),
15621                    });
15622                }
15623            }
15624        }
15625    });
15626
15627    // A `release <x>` names ONE OF THREE legitimate referents (spec/coordination.md,
15628    // verified against the corpus + the rule-body matrix test):
15629    //   (1) an `acquire ... as <x>` LEASE binding acquired in this rule;
15630    //   (2) a `claim <x> as ...` — the *item* being claimed (the `TrackerClaim.item`,
15631    //       NOT the claim's `as` binding);
15632    //   (3) a `when <queue> has ready <x> as <x>` WORK-ITEM binding, released
15633    //       without a same-rule claim.
15634    // A naive `acquire ∪ claim-binding` model false-positives forms (2) and (3);
15635    // admit exactly these three and flag a `release <x>` that matches none as a
15636    // genuinely-unbound release. Scoped per-rule like the `renew` check above.
15637    let work_items: Vec<String> = rule
15638        .whens
15639        .iter()
15640        .filter_map(|when| when_has_ready_binding(&when.text))
15641        .collect();
15642    let releasable: BTreeSet<&str> = acquires
15643        .iter()
15644        .map(|(b, _, _)| b.as_str())
15645        .chain(claims.iter().map(|(item, _)| item.as_str()))
15646        .chain(work_items.iter().map(String::as_str))
15647        .collect();
15648    for_each_body(statements, &mut |stmt| {
15649        if let body::BodyStmt::Effect(effect) = stmt {
15650            if let body::BodyEffectKind::TrackerRelease { item } = &effect.kind {
15651                if !releasable.contains(item.as_str()) {
15652                    diagnostics.push(Diagnostic {
15653                        related: Vec::new(),
15654                        span: effect.span,
15655                        message: format!(
15656                            "rule `{}` releases unbound coordination item `{}`",
15657                            rule.name.name, item
15658                        ),
15659                        suggestion: Some(format!(
15660                            "`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"
15661                        )),
15662                    });
15663                }
15664            }
15665        }
15666    });
15667
15668    if acquires.len() > 1 {
15669        diagnostics.push(Diagnostic { related: Vec::new(),
15670            span: acquires[1].2,
15671            message: format!(
15672                "rule `{}` acquires more than one lease in a single progression",
15673                rule.name.name
15674            ),
15675            suggestion: Some(
15676                "the hard default is at most one held lease per progression (it breaks hold-and-wait); restructure into separate rules"
15677                    .to_owned(),
15678            ),
15679        });
15680    }
15681    for (binding, until_ttl, span) in &acquires {
15682        if *until_ttl {
15683            continue;
15684        }
15685        let mut predicates = BTreeSet::new();
15686        collect_after_predicates(statements, binding, &mut predicates);
15687        for required in ["held", "contended"] {
15688            if !predicates.contains(required) {
15689                diagnostics.push(Diagnostic { related: Vec::new(),
15690                    span: *span,
15691                    message: format!(
15692                        "rule `{}` does not handle the `{required}` outcome of lease `{binding}`",
15693                        rule.name.name
15694                    ),
15695                    suggestion: Some(format!(
15696                        "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
15697                    )),
15698                });
15699            }
15700        }
15701        if let Some(held_body) = find_after_body(statements, binding, body::AfterPredicate::Held) {
15702            if !releases_or_terminates(held_body, binding) {
15703                diagnostics.push(Diagnostic { related: Vec::new(),
15704                    span: *span,
15705                    message: format!(
15706                        "rule `{}` can hold lease `{binding}` forever: the `held` branch neither releases it nor reaches a workflow terminal",
15707                        rule.name.name
15708                    ),
15709                    suggestion: Some(format!(
15710                        "add `release {binding}` on every non-terminal path, or use `acquire ... until ttl` for fire-and-forget"
15711                    )),
15712                });
15713            }
15714        }
15715    }
15716    for (binding, span) in &consumes {
15717        let mut predicates = BTreeSet::new();
15718        collect_after_predicates(statements, binding, &mut predicates);
15719        for required in ["ok", "over"] {
15720            if !predicates.contains(required) {
15721                diagnostics.push(Diagnostic { related: Vec::new(),
15722                    span: *span,
15723                    message: format!(
15724                        "rule `{}` does not handle the `{required}` outcome of counter consume `{binding}`",
15725                        rule.name.name
15726                    ),
15727                    suggestion: Some(format!(
15728                        "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
15729                    )),
15730                });
15731            }
15732        }
15733    }
15734}
15735
15736fn collect_coordination_effects(
15737    statements: &[body::BodyStmt],
15738    acquires: &mut Vec<(String, bool, SourceSpan)>,
15739    consumes: &mut Vec<(String, SourceSpan)>,
15740    claims: &mut Vec<(String, SourceSpan)>,
15741) {
15742    for_each_body(statements, &mut |stmt| {
15743        if let body::BodyStmt::Effect(effect) = stmt {
15744            match &effect.kind {
15745                body::BodyEffectKind::LeaseAcquire { until_ttl, .. } => {
15746                    if let Some(binding) = &effect.binding {
15747                        acquires.push((binding.clone(), *until_ttl, effect.span));
15748                    }
15749                }
15750                body::BodyEffectKind::CounterConsume { .. } => {
15751                    if let Some(binding) = &effect.binding {
15752                        consumes.push((binding.clone(), effect.span));
15753                    }
15754                }
15755                // A `claim <item> as <lease>` makes `<item>` releasable: the
15756                // releasable referent is the *item* being claimed (the
15757                // `TrackerClaim.item`), not the claim's `as` binding.
15758                body::BodyEffectKind::TrackerClaim { item, .. } => {
15759                    claims.push((item.clone(), effect.span));
15760                }
15761                _ => {}
15762            }
15763        }
15764    });
15765}
15766
15767/// The work-item binding of a `when <queue> has ready <item> as <binding>`
15768/// reaction: the `as <binding>` names a claimable/releasable work item pulled
15769/// off the queue (spec/coordination.md). Returns `None` for every other `when`
15770/// pattern (plain fact binds are not releasable). Guards (`where ...`) are
15771/// stripped first so the pattern words line up.
15772fn when_has_ready_binding(when: &str) -> Option<String> {
15773    let (pattern, _) = split_when_guard(when);
15774    let mut words = pattern.split_whitespace();
15775    let _queue = words.next()?;
15776    if words.next() == Some("has") && words.next() == Some("ready") {
15777        return binding_after_as(pattern);
15778    }
15779    None
15780}
15781
15782fn collect_after_predicates(
15783    statements: &[body::BodyStmt],
15784    binding: &str,
15785    predicates: &mut BTreeSet<&'static str>,
15786) {
15787    for_each_body(statements, &mut |stmt| {
15788        if let body::BodyStmt::After(after) = stmt {
15789            if after.binding == binding {
15790                predicates.insert(after.predicate.as_str());
15791            }
15792        }
15793    });
15794}
15795
15796fn find_after_body<'a>(
15797    statements: &'a [body::BodyStmt],
15798    binding: &str,
15799    predicate: body::AfterPredicate,
15800) -> Option<&'a [body::BodyStmt]> {
15801    for statement in statements {
15802        match statement {
15803            body::BodyStmt::After(after) => {
15804                if after.binding == binding && after.predicate == predicate {
15805                    return Some(&after.body);
15806                }
15807                if let Some(found) = find_after_body(&after.body, binding, predicate) {
15808                    return Some(found);
15809                }
15810            }
15811            body::BodyStmt::Case(case) => {
15812                for branch in &case.branches {
15813                    if let Some(found) = find_after_body(&branch.body, binding, predicate) {
15814                        return Some(found);
15815                    }
15816                }
15817            }
15818            _ => {}
15819        }
15820    }
15821    None
15822}
15823
15824/// Linear must-release, prototype form: a statement list is safe if some
15825/// statement guarantees release — an explicit `release <binding>`, a
15826/// workflow terminal (instance-terminal auto-release), a nested after-block
15827/// that is safe, or a branching construct ALL of whose branches are safe.
15828fn releases_or_terminates(statements: &[body::BodyStmt], binding: &str) -> bool {
15829    statements.iter().any(|statement| match statement {
15830        body::BodyStmt::Effect(effect) => matches!(
15831            &effect.kind,
15832            body::BodyEffectKind::TrackerRelease { item } if item == binding
15833        ),
15834        body::BodyStmt::Terminal(_) => true,
15835        body::BodyStmt::After(after) => releases_or_terminates(&after.body, binding),
15836        body::BodyStmt::Case(case) => {
15837            !case.branches.is_empty()
15838                && case
15839                    .branches
15840                    .iter()
15841                    .all(|branch| releases_or_terminates(&branch.body, binding))
15842        }
15843        _ => false,
15844    })
15845}
15846
15847fn for_each_body(statements: &[body::BodyStmt], visit: &mut impl FnMut(&body::BodyStmt)) {
15848    for statement in statements {
15849        visit(statement);
15850        match statement {
15851            body::BodyStmt::After(after) => for_each_body(&after.body, visit),
15852            body::BodyStmt::Case(case) => {
15853                for branch in &case.branches {
15854                    for_each_body(&branch.body, visit);
15855                }
15856            }
15857            _ => {}
15858        }
15859    }
15860}
15861
15862/// Family B: the `(root, field)` pairs a `case <root>.<disc> { "<lit>" => ... }` arm
15863/// makes readable — the fields conditioned on `<disc> is "<lit>"`. Empty unless the
15864/// scrutinee is a single-level `<root>.<disc>` path bound to a schema and the arm
15865/// pattern is the matching string literal.
15866fn family_b_arm_allowed(
15867    scrutinee: &str,
15868    pattern: &str,
15869    binding_types: &BTreeMap<String, String>,
15870    semantic: &SemanticContext,
15871) -> BTreeSet<(String, String)> {
15872    let mut allowed = BTreeSet::new();
15873    let Some((root, disc)) = scrutinee.split_once('.') else {
15874        return allowed;
15875    };
15876    if disc.contains('.') {
15877        return allowed;
15878    }
15879    let trimmed = pattern.trim();
15880    if trimmed == "_" || trimmed == "default" {
15881        return allowed;
15882    }
15883    let literal = trimmed.trim_matches('"');
15884    if literal.is_empty() {
15885        return allowed;
15886    }
15887    let Some(schema) = binding_types.get(root) else {
15888        return allowed;
15889    };
15890    if let Some(conditions) = semantic.schemas.presence.get(schema) {
15891        for (field, (cond_disc, cond_literal)) in conditions {
15892            if cond_disc == disc && cond_literal == literal {
15893                allowed.insert((root.to_owned(), field.clone()));
15894            }
15895        }
15896    }
15897    allowed
15898}
15899
15900/// Reject reads of a Family B presence-conditioned field in `text` that are not
15901/// permitted by `allowed` (the conditioned fields this scope's `case` arm makes
15902/// present). `text` is any source fragment that may contain dotted field paths.
15903fn check_conditioned_reads_in_text(
15904    rule: &RuleDecl,
15905    text: &str,
15906    span: SourceSpan,
15907    semantic: &SemanticContext,
15908    binding_types: &BTreeMap<String, String>,
15909    allowed: &BTreeSet<(String, String)>,
15910    diagnostics: &mut Vec<Diagnostic>,
15911) {
15912    for (root, path) in dotted_paths(text) {
15913        let Some(first) = path.first() else {
15914            continue;
15915        };
15916        let Some(schema) = binding_types.get(&root) else {
15917            continue;
15918        };
15919        if let Some((disc, _literal)) = semantic.schemas.field_presence(schema, first) {
15920            if !allowed.contains(&(root.clone(), first.clone())) {
15921                diagnostics.push(Diagnostic {
15922                    related: Vec::new(),
15923                    span,
15924                    message: format!(
15925                        "rule `{}` reads conditional field `{root}.{first}` outside a matching `case {root}.{disc}` arm",
15926                        rule.name.name
15927                    ),
15928                    suggestion: Some(format!(
15929                        "read `{root}.{first}` inside `case {root}.{disc} {{ \"...\" => ... }}` — it is present only for a specific `{disc}`"
15930                    )),
15931                });
15932            }
15933        }
15934    }
15935}
15936
15937fn check_conditioned_reads_in_fields(
15938    rule: &RuleDecl,
15939    fields: &[body::FieldAssign],
15940    semantic: &SemanticContext,
15941    binding_types: &BTreeMap<String, String>,
15942    allowed: &BTreeSet<(String, String)>,
15943    diagnostics: &mut Vec<Diagnostic>,
15944) {
15945    for field in fields {
15946        match &field.value {
15947            body::FieldValue::Expr { source, .. } => check_conditioned_reads_in_text(
15948                rule,
15949                source,
15950                field.span,
15951                semantic,
15952                binding_types,
15953                allowed,
15954                diagnostics,
15955            ),
15956            body::FieldValue::Nested { fields, .. } => check_conditioned_reads_in_fields(
15957                rule,
15958                fields,
15959                semantic,
15960                binding_types,
15961                allowed,
15962                diagnostics,
15963            ),
15964            body::FieldValue::Shorthand => {}
15965        }
15966    }
15967}
15968
15969/// Family B read-narrowing (discriminated-families-design.md §5.6/§5.7): walk the
15970/// rule body and reject a read of a presence-conditioned field that is not inside a
15971/// matching `case <root>.<disc>` arm. Each `case` arm extends `allowed` with the
15972/// fields its discriminant=literal makes present. (v1 covers record/terminal/done
15973/// values, branch conditions, and case guards; effect prompt/argument positions are
15974/// a documented follow-up.)
15975fn validate_conditioned_field_reads(
15976    rule: &RuleDecl,
15977    statements: &[body::BodyStmt],
15978    semantic: &SemanticContext,
15979    binding_types: &BTreeMap<String, String>,
15980    allowed: &BTreeSet<(String, String)>,
15981    diagnostics: &mut Vec<Diagnostic>,
15982) {
15983    for statement in statements {
15984        match statement {
15985            body::BodyStmt::Record(record) => check_conditioned_reads_in_fields(
15986                rule,
15987                &record.fields,
15988                semantic,
15989                binding_types,
15990                allowed,
15991                diagnostics,
15992            ),
15993            body::BodyStmt::Terminal(terminal) => {
15994                check_conditioned_reads_in_fields(
15995                    rule,
15996                    &terminal.fields,
15997                    semantic,
15998                    binding_types,
15999                    allowed,
16000                    diagnostics,
16001                );
16002                // A bare scalar payload value is also an egress read.
16003                if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
16004                    check_conditioned_reads_in_text(
16005                        rule,
16006                        source,
16007                        terminal.span,
16008                        semantic,
16009                        binding_types,
16010                        allowed,
16011                        diagnostics,
16012                    );
16013                }
16014            }
16015            body::BodyStmt::Done {
16016                replacement: Some(record),
16017                ..
16018            } => check_conditioned_reads_in_fields(
16019                rule,
16020                &record.fields,
16021                semantic,
16022                binding_types,
16023                allowed,
16024                diagnostics,
16025            ),
16026            body::BodyStmt::Milestone { fields, .. } => check_conditioned_reads_in_fields(
16027                rule,
16028                fields,
16029                semantic,
16030                binding_types,
16031                allowed,
16032                diagnostics,
16033            ),
16034            body::BodyStmt::Done { .. }
16035            | body::BodyStmt::Cancel { .. }
16036            | body::BodyStmt::Redact { .. }
16037            | body::BodyStmt::Effect(_) => {}
16038            body::BodyStmt::After(after) => validate_conditioned_field_reads(
16039                rule,
16040                &after.body,
16041                semantic,
16042                binding_types,
16043                allowed,
16044                diagnostics,
16045            ),
16046            body::BodyStmt::Region(region) => {
16047                validate_conditioned_field_reads(
16048                    rule,
16049                    &region.body,
16050                    semantic,
16051                    binding_types,
16052                    allowed,
16053                    diagnostics,
16054                );
16055                validate_conditioned_field_reads(
16056                    rule,
16057                    &region.lapse_body,
16058                    semantic,
16059                    binding_types,
16060                    allowed,
16061                    diagnostics,
16062                );
16063            }
16064            body::BodyStmt::Case(case) => {
16065                for arm in &case.branches {
16066                    let mut arm_allowed = allowed.clone();
16067                    arm_allowed.extend(family_b_arm_allowed(
16068                        &case.scrutinee,
16069                        &arm.pattern,
16070                        binding_types,
16071                        semantic,
16072                    ));
16073                    if let Some(guard) = &arm.guard {
16074                        check_conditioned_reads_in_text(
16075                            rule,
16076                            guard,
16077                            arm.span,
16078                            semantic,
16079                            binding_types,
16080                            &arm_allowed,
16081                            diagnostics,
16082                        );
16083                    }
16084                    validate_conditioned_field_reads(
16085                        rule,
16086                        &arm.body,
16087                        semantic,
16088                        binding_types,
16089                        &arm_allowed,
16090                        diagnostics,
16091                    );
16092                }
16093            }
16094        }
16095    }
16096}
16097
16098fn validate_body_effect_operands(
16099    rule: &RuleDecl,
16100    statements: &[body::BodyStmt],
16101    semantic: &SemanticContext,
16102    binding_types: &BTreeMap<String, String>,
16103    diagnostics: &mut Vec<Diagnostic>,
16104) {
16105    for statement in statements {
16106        match statement {
16107            body::BodyStmt::Effect(effect) => {
16108                match &effect.kind {
16109                    body::BodyEffectKind::LeaseAcquire { resource, .. }
16110                        if !semantic.leases.contains(resource) =>
16111                    {
16112                        diagnostics.push(Diagnostic { related: Vec::new(),
16113                            span: effect.span,
16114                            message: format!(
16115                                "rule `{}` acquires undeclared lease `{resource}`",
16116                                rule.name.name
16117                            ),
16118                            suggestion: Some(format!(
16119                                "declare `lease {resource} {{ key <Type>  slots <N>  ttl <duration> }}`"
16120                            )),
16121                        });
16122                    }
16123                    body::BodyEffectKind::LedgerAppend { ledger, schema, .. } => {
16124                        if !semantic.ledgers.contains(ledger) {
16125                            diagnostics.push(Diagnostic { related: Vec::new(),
16126                                span: effect.span,
16127                                message: format!(
16128                                    "rule `{}` appends to undeclared ledger `{ledger}`",
16129                                    rule.name.name
16130                                ),
16131                                suggestion: Some(format!(
16132                                    "declare `ledger {ledger} {{ entry <Type>  partition by <field>  retain <duration> }}`"
16133                                )),
16134                            });
16135                        }
16136                        if !semantic.schemas.class_exists(schema) {
16137                            diagnostics.push(Diagnostic {
16138                                related: Vec::new(),
16139                                span: effect.span,
16140                                message: format!(
16141                                    "rule `{}` appends unknown entry class `{schema}`",
16142                                    rule.name.name
16143                                ),
16144                                suggestion: Some(format!("declare `class {schema}` first")),
16145                            });
16146                        }
16147                    }
16148                    body::BodyEffectKind::CounterConsume { counter, .. }
16149                        if !semantic.counters.contains(counter) =>
16150                    {
16151                        diagnostics.push(Diagnostic { related: Vec::new(),
16152                            span: effect.span,
16153                            message: format!(
16154                                "rule `{}` consumes undeclared counter `{counter}`",
16155                                rule.name.name
16156                            ),
16157                            suggestion: Some(format!(
16158                                "declare `counter {counter} {{ key <Type>  cap <N>  reset <period> }}`"
16159                            )),
16160                        });
16161                    }
16162                    _ => {}
16163                }
16164                // `exec <name> with <binding>` requires a typed record binding
16165                // (spec/std-script.md "Static checks" item 4): the binding is
16166                // serialized to the script's stdin as a typed record, so an
16167                // unknown or untyped binding cannot cross.
16168                if let body::BodyEffectKind::Exec {
16169                    target:
16170                        body::ExecTarget::Capability {
16171                            name,
16172                            stdin_binding,
16173                        },
16174                    ..
16175                } = &effect.kind
16176                {
16177                    match binding_types.get(stdin_binding) {
16178                        None => {
16179                            diagnostics.push(Diagnostic {
16180                                related: Vec::new(),
16181                                span: effect.span,
16182                                message: format!(
16183                                    "rule `{}` uses unknown binding `{stdin_binding}` in `exec {name} with {stdin_binding}` — `with` requires a typed record binding",
16184                                    rule.name.name
16185                                ),
16186                                suggestion: Some(format!(
16187                                    "bind a typed record first (e.g. `when <Class> as {stdin_binding}` or `coerce ... -> <Class> as {stdin_binding}`) and pass that binding to `with`"
16188                                )),
16189                            });
16190                        }
16191                        // A dotted binding without an indexed payload class is an
16192                        // untyped runtime fact (`when fact <name> as x`) — no
16193                        // static record shape crosses to stdin. Non-dotted
16194                        // unknown classes are already reported at their binding
16195                        // site (`matches unknown class` / unknown parse schema).
16196                        Some(schema)
16197                            if schema.contains('.') && !semantic.schemas.class_exists(schema) =>
16198                        {
16199                            diagnostics.push(Diagnostic {
16200                                related: Vec::new(),
16201                                span: effect.span,
16202                                message: format!(
16203                                    "rule `{}` passes untyped fact binding `{stdin_binding}` to `exec {name} with` — `with` requires a typed record binding",
16204                                    rule.name.name
16205                                ),
16206                                suggestion: Some(format!(
16207                                    "declare `signal {schema} {{ ... }}` for a typed reaction, or bind a declared class and pass that to `with`"
16208                                )),
16209                            });
16210                        }
16211                        Some(_) => {}
16212                    }
16213                }
16214                if let body::BodyEffectKind::Exec {
16215                    parse_target: Some(parse),
16216                    ..
16217                } = &effect.kind
16218                {
16219                    if !semantic.schemas.class_exists(&parse.schema) {
16220                        let suggestion =
16221                            match closest_name(&parse.schema, semantic.schemas.classes.keys()) {
16222                                Some(candidate) => format!(
16223                                    "did you mean `{candidate}`? otherwise declare `class {}`",
16224                                    parse.schema
16225                                ),
16226                                None => format!(
16227                                    "declare `class {}` before parsing into it",
16228                                    parse.schema
16229                                ),
16230                            };
16231                        diagnostics.push(Diagnostic {
16232                            related: Vec::new(),
16233                            span: effect.span,
16234                            message: format!(
16235                                "rule `{}` parses exec output into unknown schema `{}`",
16236                                rule.name.name, parse.schema
16237                            ),
16238                            suggestion: Some(suggestion),
16239                        });
16240                    }
16241                }
16242                let body::BodyEffectKind::Timer {
16243                    until: Some(until), ..
16244                } = &effect.kind
16245                else {
16246                    continue;
16247                };
16248                if body::is_iso8601_instant(until) {
16249                    continue;
16250                }
16251                let mut segments = until.split('.');
16252                let root = segments.next().unwrap_or_default();
16253                let path = segments.map(str::to_owned).collect::<Vec<_>>();
16254                let Some(schema) = binding_types.get(root) else {
16255                    diagnostics.push(Diagnostic { related: Vec::new(),
16256                        span: effect.span,
16257                        message: format!(
16258                            "rule `{}` uses unknown binding `{root}` in `timer until {until}`",
16259                            rule.name.name
16260                        ),
16261                        suggestion: Some(
16262                            "bind a fact in `when` and reference a `time` field on it, or use an ISO-8601 literal"
16263                                .to_owned(),
16264                        ),
16265                    });
16266                    continue;
16267                };
16268                // Dotted runtime fact bindings are untyped; their fields
16269                // cannot be statically checked.
16270                if schema.contains('.') {
16271                    continue;
16272                }
16273                let resolved = if path.is_empty() {
16274                    Err(format!(
16275                        "`{root}` is a `{schema}` record, not a `time` value"
16276                    ))
16277                } else {
16278                    semantic.schemas.resolve_field_path(schema, &path)
16279                };
16280                match resolved {
16281                    Ok(TypeSyntax::Primitive { ref name, .. }) if name == "time" => {}
16282                    Ok(_) => {
16283                        diagnostics.push(Diagnostic { related: Vec::new(),
16284                            span: effect.span,
16285                            message: format!(
16286                                "rule `{}` uses non-time operand `{until}` in `timer until`",
16287                                rule.name.name
16288                            ),
16289                            suggestion: Some(format!(
16290                                "declare the field as `time` on `{schema}` or use an ISO-8601 literal"
16291                            )),
16292                        });
16293                    }
16294                    Err(message) => {
16295                        diagnostics.push(Diagnostic { related: Vec::new(),
16296                            span: effect.span,
16297                            message: format!(
16298                                "rule `{}` has invalid `timer until` operand `{until}`: {message}",
16299                                rule.name.name
16300                            ),
16301                            suggestion: Some(
16302                                "reference a `time`-typed field on a bound fact, or use an ISO-8601 literal"
16303                                    .to_owned(),
16304                            ),
16305                        });
16306                    }
16307                }
16308            }
16309            body::BodyStmt::After(after) => {
16310                validate_body_effect_operands(
16311                    rule,
16312                    &after.body,
16313                    semantic,
16314                    binding_types,
16315                    diagnostics,
16316                );
16317            }
16318            body::BodyStmt::Case(case) => {
16319                for branch in &case.branches {
16320                    validate_body_effect_operands(
16321                        rule,
16322                        &branch.body,
16323                        semantic,
16324                        binding_types,
16325                        diagnostics,
16326                    );
16327                }
16328            }
16329            _ => {}
16330        }
16331    }
16332}
16333
16334fn validate_known_field_paths(
16335    rule: &RuleDecl,
16336    line: &str,
16337    semantic: &SemanticContext,
16338    binding_types: &BTreeMap<String, String>,
16339    diagnostics: &mut Vec<Diagnostic>,
16340) {
16341    validate_known_field_paths_at_span(
16342        rule,
16343        line,
16344        rule.body.span,
16345        semantic,
16346        binding_types,
16347        diagnostics,
16348    );
16349}
16350
16351fn validate_known_field_paths_at_span(
16352    rule: &RuleDecl,
16353    line: &str,
16354    span: SourceSpan,
16355    semantic: &SemanticContext,
16356    binding_types: &BTreeMap<String, String>,
16357    diagnostics: &mut Vec<Diagnostic>,
16358) {
16359    for (root, path) in dotted_paths(line) {
16360        let Some(schema) = binding_types.get(&root) else {
16361            continue;
16362        };
16363        if !semantic.schemas.class_exists(schema) {
16364            continue;
16365        }
16366        if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
16367            diagnostics.push(Diagnostic {
16368                related: Vec::new(),
16369                span,
16370                message: format!(
16371                    "rule `{}` has invalid field path `{root}.{}`: {message}",
16372                    rule.name.name,
16373                    path.join(".")
16374                ),
16375                suggestion: Some(
16376                    "use a field declared on the bound schema or add it to the class declaration"
16377                        .to_owned(),
16378                ),
16379            });
16380        }
16381    }
16382}
16383
16384fn dotted_paths(line: &str) -> Vec<(String, Vec<String>)> {
16385    let bytes = line.as_bytes();
16386    let mut paths = Vec::new();
16387    let mut index = 0;
16388
16389    while index < bytes.len() {
16390        if !is_ident_start(bytes[index]) {
16391            index += 1;
16392            continue;
16393        }
16394
16395        let root_start = index;
16396        index += 1;
16397        while index < bytes.len() && is_ident_continue(bytes[index]) {
16398            index += 1;
16399        }
16400        let root = &line[root_start..index];
16401        let mut fields = Vec::new();
16402
16403        while bytes.get(index) == Some(&b'.')
16404            && bytes
16405                .get(index + 1)
16406                .is_some_and(|byte| is_ident_start(*byte))
16407        {
16408            index += 1;
16409            let field_start = index;
16410            index += 1;
16411            while index < bytes.len() && is_ident_continue(bytes[index]) {
16412                index += 1;
16413            }
16414            fields.push(line[field_start..index].to_owned());
16415        }
16416
16417        if !fields.is_empty() {
16418            paths.push((root.to_owned(), fields));
16419        }
16420    }
16421
16422    paths
16423}
16424
16425fn interpolation_roots(line: &str) -> Vec<String> {
16426    let mut roots = Vec::new();
16427    let mut rest = line;
16428
16429    while let Some(open) = rest.find("{{") {
16430        let after_open = &rest[open + 2..];
16431        let Some(close) = after_open.find("}}") else {
16432            break;
16433        };
16434        let expr = after_open[..close].trim();
16435        if let Some(root) = expr
16436            .split(|ch: char| !ch.is_alphanumeric() && ch != '_')
16437            .find(|part| !part.is_empty())
16438        {
16439            roots.push(root.to_owned());
16440        }
16441        rest = &after_open[close + 2..];
16442    }
16443
16444    roots
16445}
16446
16447// `claim` stays bindable: `claim item as claim` is an established idiom and
16448// the trailing binding position is unambiguous.
16449const RESERVED_BINDING_KEYWORDS: &[&str] = &[
16450    "after", "call", "case", "coerce", "complete", "consume", "done", "emit", "fail", "invoke",
16451    "record", "tell", "when", "where",
16452];
16453
16454fn validate_binding_name(
16455    rule: &RuleDecl,
16456    binding: &str,
16457    span: SourceSpan,
16458    diagnostics: &mut Vec<Diagnostic>,
16459) {
16460    if RESERVED_BINDING_KEYWORDS.contains(&binding) {
16461        diagnostics.push(Diagnostic {
16462            related: Vec::new(),
16463            span,
16464            message: format!(
16465                "rule `{}` binds reserved keyword `{binding}`",
16466                rule.name.name
16467            ),
16468            suggestion: Some(format!(
16469                "`{binding}` is a rule body keyword; choose another binding name"
16470            )),
16471        });
16472    }
16473}
16474
16475fn closest_name<'a>(target: &str, candidates: impl Iterator<Item = &'a String>) -> Option<String> {
16476    let target_lower = target.to_lowercase();
16477    candidates
16478        .map(|candidate| {
16479            let distance = edit_distance(&target_lower, &candidate.to_lowercase());
16480            (distance, candidate)
16481        })
16482        .filter(|(distance, candidate)| {
16483            *distance <= 2 && *distance < target.len().min(candidate.len())
16484        })
16485        .min_by_key(|(distance, candidate)| (*distance, candidate.as_str().to_owned()))
16486        .map(|(_, candidate)| candidate.clone())
16487}
16488
16489fn edit_distance(a: &str, b: &str) -> usize {
16490    let a: Vec<char> = a.chars().collect();
16491    let b: Vec<char> = b.chars().collect();
16492    let mut previous: Vec<usize> = (0..=b.len()).collect();
16493    let mut current = vec![0usize; b.len() + 1];
16494    for (i, a_char) in a.iter().enumerate() {
16495        current[0] = i + 1;
16496        for (j, b_char) in b.iter().enumerate() {
16497            let substitution = previous[j] + usize::from(a_char != b_char);
16498            current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
16499        }
16500        std::mem::swap(&mut previous, &mut current);
16501    }
16502    previous[b.len()]
16503}
16504
16505fn fact_read_from_when(when: &str) -> String {
16506    let (pattern, _) = split_when_guard(when);
16507    let first = pattern.split_whitespace().next().unwrap_or("<empty>");
16508    if first.chars().next().is_some_and(char::is_uppercase) {
16509        format!("schema:{first}")
16510    } else {
16511        format!("pattern:{pattern}")
16512    }
16513}
16514
16515fn parse_record_start(line: &str) -> Option<(String, Option<String>)> {
16516    let rest = line.strip_prefix("record ").or_else(|| {
16517        line.strip_prefix("done ")
16518            .and_then(|rest| rest.split_once("->"))
16519            .map(|(_, record)| record.trim())
16520            .and_then(|record| record.strip_prefix("record "))
16521    })?;
16522    let before_brace = rest.split('{').next().unwrap_or(rest).trim();
16523    let mut parts = before_brace.split_whitespace();
16524    let schema = parts.next()?.to_owned();
16525    let from_binding = match (parts.next(), parts.next(), parts.next()) {
16526        (None, None, None) => None,
16527        (Some("from"), Some(binding), None) => Some(binding.to_owned()),
16528        _ => return None,
16529    };
16530    Some((schema, from_binding))
16531}
16532
16533fn validate_record_field(
16534    rule: &RuleDecl,
16535    line: &str,
16536    record_schema: &str,
16537    semantic: &SemanticContext,
16538    binding_types: &BTreeMap<String, String>,
16539    known_roots: &BTreeSet<String>,
16540    diagnostics: &mut Vec<Diagnostic>,
16541) {
16542    let Some((field, expr)) = record_field_assignment(line) else {
16543        diagnostics.push(Diagnostic {
16544            related: Vec::new(),
16545            span: rule.body.span,
16546            message: format!(
16547                "rule `{}` has malformed field assignment in `record {record_schema}`",
16548                rule.name.name
16549            ),
16550            suggestion: Some("write record fields as `field value`".to_owned()),
16551        });
16552        return;
16553    };
16554
16555    let Some(fields) = semantic.schemas.classes.get(record_schema) else {
16556        return;
16557    };
16558    let Some(field_ty) = fields.get(field) else {
16559        diagnostics.push(Diagnostic {
16560            related: Vec::new(),
16561            span: rule.body.span,
16562            message: format!("class `{record_schema}` has no field `{field}`"),
16563            suggestion: Some(format!(
16564                "add `{field}` to `class {record_schema}` or record an existing field"
16565            )),
16566        });
16567        return;
16568    };
16569
16570    if let Some((root, path)) = expression_path(expr) {
16571        if let Some(schema) = binding_types.get(&root) {
16572            if !semantic.schemas.class_exists(schema) {
16573                return;
16574            }
16575            if let Err(message) = semantic.schemas.resolve_field_path(schema, &path) {
16576                diagnostics.push(Diagnostic { related: Vec::new(),
16577                    span: rule.body.span,
16578                    message: format!(
16579                        "rule `{}` has invalid field path `{root}.{}`: {message}",
16580                        rule.name.name,
16581                        path.join(".")
16582                    ),
16583                    suggestion: Some(
16584                        "use a field declared on the bound schema or add it to the class declaration"
16585                            .to_owned(),
16586                    ),
16587                });
16588            }
16589        } else if let Some(root) = dangling_value_root(expr, known_roots) {
16590            // A field access whose root is neither a bound name nor a special
16591            // root is a dangling reference (the binding does not exist).
16592            diagnostics.push(Diagnostic { related: Vec::new(),
16593                span: rule.body.span,
16594                message: format!(
16595                    "rule `{}` has unknown binding `{root}` in `record {record_schema}` field `{field}`",
16596                    rule.name.name
16597                ),
16598                suggestion: Some(
16599                    "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16600                        .to_owned(),
16601                ),
16602            });
16603        }
16604    }
16605
16606    validate_literal_assignment(
16607        rule,
16608        record_schema,
16609        field,
16610        field_ty,
16611        expr,
16612        semantic,
16613        diagnostics,
16614    );
16615    validate_expected_assignment(
16616        rule,
16617        record_schema,
16618        field,
16619        field_ty,
16620        expr,
16621        semantic,
16622        binding_types,
16623        diagnostics,
16624    );
16625}
16626
16627fn record_field_assignment(line: &str) -> Option<(&str, &str)> {
16628    let field_end = line.find(char::is_whitespace)?;
16629    let field = &line[..field_end];
16630    let expr = line[field_end..].trim();
16631    (!field.is_empty() && !expr.is_empty()).then_some((field, expr))
16632}
16633
16634/// Roots valid in value positions without being author bindings: the
16635/// external-event payload and the coerce prompt context. An explicit allowlist
16636/// so genuine typos are still caught.
16637const SPECIAL_VALUE_ROOTS: &[&str] = &["external", "ctx"];
16638
16639/// Collects every binding NAME a rule body introduces, from the parsed AST so it
16640/// is robust to multi-line prompts and nesting (the line-based effect collectors
16641/// only track `coerce`/`claim`, so `tell`/`exec`/etc. bindings are invisible to
16642/// `binding_types`). Used to reject dangling roots in value positions without
16643/// false-flagging valid effect results, `after` aliases, or case bindings.
16644fn collect_all_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
16645    for statement in statements {
16646        match statement {
16647            body::BodyStmt::Effect(effect) => {
16648                if let Some(binding) = &effect.binding {
16649                    out.insert(binding.clone());
16650                }
16651            }
16652            body::BodyStmt::Region(region) => {
16653                if let Some(view) = &region.lapse_binding {
16654                    out.insert(view.clone());
16655                }
16656                collect_all_binding_names(&region.body, out);
16657                collect_all_binding_names(&region.lapse_body, out);
16658            }
16659            body::BodyStmt::After(after) => {
16660                if let Some(alias) = &after.alias {
16661                    out.insert(alias.clone());
16662                }
16663                collect_all_binding_names(&after.body, out);
16664            }
16665            body::BodyStmt::Case(case) => {
16666                for branch in &case.branches {
16667                    if let Some(binding) = &branch.binding {
16668                        out.insert(binding.clone());
16669                    }
16670                    collect_all_binding_names(&branch.body, out);
16671                }
16672            }
16673            // `redact … as <out>` introduces the projected binding `out`.
16674            body::BodyStmt::Redact { binding, .. } => {
16675                out.insert(binding.clone());
16676            }
16677            body::BodyStmt::Record(_)
16678            | body::BodyStmt::Done { .. }
16679            | body::BodyStmt::Terminal(_)
16680            | body::BodyStmt::Milestone { .. }
16681            | body::BodyStmt::Cancel { .. } => {}
16682        }
16683    }
16684}
16685
16686/// A `source`'s `emit <signal>` must name a declared `signal` — the ingestion
16687/// mirror of the rule-side "reacts to undeclared signal" check on `when <signal>`.
16688/// Only dotted names are typed signal declarations (a bare name is a class/fact,
16689/// consistent with the reaction check). Without this a source silently admits a
16690/// signal fact no rule can react to (rules may only react to declared signals),
16691/// so ingested data is dropped with no diagnostic — this lifts the guarantee to
16692/// static `whip check`, symmetric with the clock/file/http source runtime that
16693/// admits `emit_signal`.
16694fn validate_source_emit_signal_declared(
16695    source: &SourceDecl,
16696    declared_signals: &BTreeSet<String>,
16697    diagnostics: &mut Vec<Diagnostic>,
16698) {
16699    let signal = &source.emit.signal;
16700    if signal.contains('.') && !declared_signals.contains(signal) {
16701        let suggestion = match closest_name(signal, declared_signals.iter()) {
16702            Some(candidate) => {
16703                format!("did you mean `{candidate}`? otherwise declare `signal {signal} {{ ... }}`")
16704            }
16705            None => format!("declare `signal {signal} {{ ... }}` so rules can react to it"),
16706        };
16707        diagnostics.push(Diagnostic {
16708            related: Vec::new(),
16709            span: source.emit.signal_span,
16710            message: format!(
16711                "source `{}` emits undeclared signal `{}`",
16712                source.name.name, signal
16713            ),
16714            suggestion: Some(suggestion),
16715        });
16716    }
16717
16718    // The emit fields map the source's observation record onto the signal
16719    // payload by name. Each `<field> <observe>.<obsfield>` must read a real
16720    // observation field for this source's kind, else it silently maps null at
16721    // runtime. The observation schemas below MUST mirror the records built in
16722    // the CLI source resolvers (`resolve_due_{clock,file,http}_sources`): add a
16723    // field there → add it here. Unknown providers have no known schema, so
16724    // their emit fields are not checked (avoids false positives).
16725    let observation_fields: Option<&[&str]> = match source.provider.name.as_str() {
16726        "clock" => Some(&[
16727            "scheduled_at",
16728            "observed_at",
16729            "occurrence_id",
16730            "missed_count",
16731            "schedule_name",
16732        ]),
16733        // `file` observes lines in `path` mode and (path, content-hash)
16734        // occurrences in `watch` mode (spec/std-ingress.md I2a; content
16735        // READING stays std.files).
16736        "file" if source.watch.is_some() => Some(&["path", "content_hash", "watch"]),
16737        "file" => Some(&["line", "line_index", "path"]),
16738        "http" => Some(&["item", "item_index", "url"]),
16739        _ => None,
16740    };
16741    // `dedup` reads the same observation record the emit mapping reads: an
16742    // unknown field would make the admission key silently null at runtime.
16743    if let (Some(fields), Some(SourceValue::Path { segments, .. })) =
16744        (observation_fields, &source.dedup)
16745    {
16746        if let [field] = segments.as_slice() {
16747            if !fields.contains(&field.name.as_str()) {
16748                diagnostics.push(Diagnostic {
16749                    related: Vec::new(),
16750                    span: field.span,
16751                    message: format!(
16752                        "source `{}` `dedup` reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16753                        source.name.name,
16754                        source.observe_binding.name,
16755                        field.name,
16756                        source.provider.name,
16757                        field.name
16758                    ),
16759                    suggestion: Some(format!(
16760                        "available observation fields: {}",
16761                        fields.join(", ")
16762                    )),
16763                });
16764            }
16765        }
16766    }
16767    if let Some(fields) = observation_fields {
16768        let observe = &source.observe_binding.name;
16769        for emit_field in &source.emit.fields {
16770            let SourceValue::Path {
16771                binding,
16772                segments,
16773                span,
16774            } = &emit_field.value
16775            else {
16776                continue;
16777            };
16778            if &binding.name != observe {
16779                diagnostics.push(Diagnostic {
16780                    related: Vec::new(),
16781                    span: *span,
16782                    message: format!(
16783                        "source `{}` emit reads unknown binding `{}`",
16784                        source.name.name, binding.name
16785                    ),
16786                    suggestion: Some(format!(
16787                        "the source's observation binding is `{observe}` (declared by `observe as {observe}`)"
16788                    )),
16789                });
16790                continue;
16791            }
16792            if let Some(obs_field) = segments.first() {
16793                if !fields.contains(&obs_field.name.as_str()) {
16794                    diagnostics.push(Diagnostic {
16795                        related: Vec::new(),
16796                        span: obs_field.span,
16797                        message: format!(
16798                            "source `{}` emit reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16799                            source.name.name, observe, obs_field.name, source.provider.name, obs_field.name
16800                        ),
16801                        suggestion: Some(format!(
16802                            "available observation fields: {}",
16803                            fields.join(", ")
16804                        )),
16805                    });
16806                }
16807            }
16808        }
16809    }
16810}
16811
16812/// `emit signal <name> to <target>` requires `<name>` to be a declared `signal`
16813/// — the declaration is the typed payload contract at the emit site, symmetric
16814/// with the reaction-side "reacts to undeclared signal" check on `when <signal>`
16815/// (spec/event-ingress.md, "Directed injection"). Without this, an emit of an
16816/// undeclared signal only fails at runtime when the effect input is built
16817/// (`whipplescript_kernel::rule_lowering`, "emit signal of undeclared signal");
16818/// this lifts the same guarantee to static `whip check`. Recurses into
16819/// `after`/`case`/`branch`/`handler` bodies so a nested emit is covered too.
16820fn validate_emit_signal_declarations(
16821    rule: &RuleDecl,
16822    statements: &[body::BodyStmt],
16823    declared_signals: &BTreeSet<String>,
16824    diagnostics: &mut Vec<Diagnostic>,
16825) {
16826    for statement in statements {
16827        match statement {
16828            body::BodyStmt::Effect(effect) => {
16829                if let body::BodyEffectKind::Notify { event, .. } = &effect.kind {
16830                    if !declared_signals.contains(event) {
16831                        diagnostics.push(Diagnostic {
16832                            related: Vec::new(),
16833                            span: effect.span,
16834                            message: format!(
16835                                "rule `{}` emits undeclared signal `{event}`",
16836                                rule.name.name
16837                            ),
16838                            suggestion: Some(format!(
16839                                "declare `signal {event} {{ ... }}` so the emitted payload is typed and admissible, \
16840                                 or check the signal name"
16841                            )),
16842                        });
16843                    }
16844                }
16845            }
16846            body::BodyStmt::After(after) => {
16847                validate_emit_signal_declarations(rule, &after.body, declared_signals, diagnostics)
16848            }
16849            body::BodyStmt::Case(case) => {
16850                for branch in &case.branches {
16851                    validate_emit_signal_declarations(
16852                        rule,
16853                        &branch.body,
16854                        declared_signals,
16855                        diagnostics,
16856                    );
16857                }
16858            }
16859            _ => {}
16860        }
16861    }
16862}
16863
16864/// Flags dangling roots in the field payloads of body-AST effects that the
16865/// line-based validators don't reach: `emit`/`notify` (`Notify`), `file item
16866/// into` (`TrackerFile`), and ledger `append` (`LedgerAppend`). Uses the parsed
16867/// AST and the same root check as the record/coerce/tell/invoke validators.
16868fn validate_effect_field_roots(
16869    rule: &RuleDecl,
16870    statements: &[body::BodyStmt],
16871    known_roots: &BTreeSet<String>,
16872    diagnostics: &mut Vec<Diagnostic>,
16873) {
16874    for statement in statements {
16875        match statement {
16876            body::BodyStmt::Effect(effect) => match &effect.kind {
16877                body::BodyEffectKind::Notify {
16878                    target_expr,
16879                    event,
16880                    from,
16881                    fields,
16882                } => {
16883                    if let Some(from) = from {
16884                        check_operand_root(
16885                            rule,
16886                            &format!("emit `{event}` from"),
16887                            from,
16888                            known_roots,
16889                            diagnostics,
16890                        );
16891                    }
16892                    check_operand_root(
16893                        rule,
16894                        &format!("emit `{event}` target"),
16895                        target_expr,
16896                        known_roots,
16897                        diagnostics,
16898                    );
16899                    check_field_value_roots(
16900                        rule,
16901                        &format!("emit `{event}`"),
16902                        fields,
16903                        known_roots,
16904                        diagnostics,
16905                    );
16906                }
16907                body::BodyEffectKind::TrackerFile { queue, fields } => {
16908                    check_field_value_roots(
16909                        rule,
16910                        &format!("file into `{queue}`"),
16911                        fields,
16912                        known_roots,
16913                        diagnostics,
16914                    );
16915                }
16916                body::BodyEffectKind::TrackerFinish { item, fields } => {
16917                    check_operand_root(rule, "finish item", item, known_roots, diagnostics);
16918                    check_field_value_roots(rule, "finish", fields, known_roots, diagnostics);
16919                }
16920                body::BodyEffectKind::LedgerAppend { ledger, fields, .. } => {
16921                    check_field_value_roots(
16922                        rule,
16923                        &format!("append to `{ledger}`"),
16924                        fields,
16925                        known_roots,
16926                        diagnostics,
16927                    );
16928                }
16929                body::BodyEffectKind::LeaseAcquire {
16930                    resource, key_expr, ..
16931                } => {
16932                    check_operand_root(
16933                        rule,
16934                        &format!("acquire `{resource}` key"),
16935                        key_expr,
16936                        known_roots,
16937                        diagnostics,
16938                    );
16939                }
16940                body::BodyEffectKind::CounterConsume {
16941                    counter,
16942                    key_expr,
16943                    amount_expr,
16944                } => {
16945                    check_operand_root(
16946                        rule,
16947                        &format!("consume `{counter}` key"),
16948                        key_expr,
16949                        known_roots,
16950                        diagnostics,
16951                    );
16952                    check_operand_root(
16953                        rule,
16954                        &format!("consume `{counter}` amount"),
16955                        amount_expr,
16956                        known_roots,
16957                        diagnostics,
16958                    );
16959                }
16960                _ => {}
16961            },
16962            body::BodyStmt::After(after) => {
16963                validate_effect_field_roots(rule, &after.body, known_roots, diagnostics)
16964            }
16965            body::BodyStmt::Case(case) => {
16966                for branch in &case.branches {
16967                    validate_effect_field_roots(rule, &branch.body, known_roots, diagnostics);
16968                }
16969            }
16970            _ => {}
16971        }
16972    }
16973}
16974
16975/// The single source of truth for value-position root validation: returns the
16976/// dangling root of a single-path value expression — a `root.field…` access whose
16977/// root is neither a known binding nor a recognized special root — or `None`.
16978/// Bare atoms (agents, enum variants, literals) have no path and are ignored; the
16979/// `"`-guard skips values whose "path" was mis-extracted from inside a string
16980/// literal. Used by every value-position validator (record/terminal/coerce/tell/
16981/// invoke/effect payloads/operands).
16982fn dangling_value_root(value: &str, known_roots: &BTreeSet<String>) -> Option<String> {
16983    let (root, path) = expression_path(value)?;
16984    if !path.is_empty()
16985        && !value.contains('"')
16986        && !known_roots.contains(&root)
16987        && !SPECIAL_VALUE_ROOTS.contains(&root.as_str())
16988    {
16989        Some(root)
16990    } else {
16991        None
16992    }
16993}
16994
16995/// Flags a dangling root in a single effect-operand expression (e.g. an
16996/// `emit ... to <target>` target, a lease/counter `for <key>` key). Same check
16997/// as the field/record validators.
16998fn check_operand_root(
16999    rule: &RuleDecl,
17000    context: &str,
17001    operand: &str,
17002    known_roots: &BTreeSet<String>,
17003    diagnostics: &mut Vec<Diagnostic>,
17004) {
17005    if let Some(root) = dangling_value_root(operand, known_roots) {
17006        diagnostics.push(Diagnostic { related: Vec::new(),
17007            span: rule.body.span,
17008            message: format!(
17009                "rule `{}` has unknown binding `{root}` in {context} `{operand}`",
17010                rule.name.name
17011            ),
17012            suggestion: Some(
17013                "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
17014                    .to_owned(),
17015            ),
17016        });
17017    }
17018}
17019
17020fn check_field_value_roots(
17021    rule: &RuleDecl,
17022    context: &str,
17023    fields: &[body::FieldAssign],
17024    known_roots: &BTreeSet<String>,
17025    diagnostics: &mut Vec<Diagnostic>,
17026) {
17027    for field in fields {
17028        match &field.value {
17029            body::FieldValue::Expr { source, .. } => {
17030                if let Some(root) = dangling_value_root(source, known_roots) {
17031                    diagnostics.push(Diagnostic { related: Vec::new(),
17032                        span: rule.body.span,
17033                        message: format!(
17034                            "rule `{}` has unknown binding `{root}` in {context} field `{}`",
17035                            rule.name.name, field.name
17036                        ),
17037                        suggestion: Some(
17038                            "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
17039                                .to_owned(),
17040                        ),
17041                    });
17042                }
17043            }
17044            body::FieldValue::Nested { fields, .. } => {
17045                check_field_value_roots(rule, context, fields, known_roots, diagnostics)
17046            }
17047            body::FieldValue::Shorthand => {}
17048        }
17049    }
17050}
17051
17052/// The complete set of value-position binding roots for a rule: `when` bindings
17053/// plus every binding the body introduces, collected from the parsed AST.
17054fn known_roots_for_rule(rule: &RuleDecl) -> BTreeSet<String> {
17055    let mut roots: BTreeSet<String> = binding_types_for_rule(rule).into_keys().collect();
17056    let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
17057    collect_all_binding_names(&body_ast.statements, &mut roots);
17058    roots
17059}
17060
17061fn validate_record_blocks(
17062    rule: &RuleDecl,
17063    semantic: &SemanticContext,
17064    binding_types: &BTreeMap<String, String>,
17065    known_roots: &BTreeSet<String>,
17066    diagnostics: &mut Vec<Diagnostic>,
17067) {
17068    for (schema, from_binding, body) in record_blocks(&rule.body.text) {
17069        for assignment in collect_field_assignments(&body) {
17070            let (field, value) = match assignment {
17071                RecordFieldAssignment::Value { field, value } => (field, value),
17072                RecordFieldAssignment::Shorthand { field } => {
17073                    let value = from_binding
17074                        .as_ref()
17075                        .map(|binding| format!("{binding}.{field}"))
17076                        .unwrap_or_else(|| field.clone());
17077                    (field, value)
17078                }
17079            };
17080            let line = format!("{field} {value}");
17081            validate_record_field(
17082                rule,
17083                &line,
17084                &schema,
17085                semantic,
17086                binding_types,
17087                known_roots,
17088                diagnostics,
17089            );
17090        }
17091    }
17092}
17093
17094fn record_blocks(body: &str) -> Vec<(String, Option<String>, String)> {
17095    let mut blocks = Vec::new();
17096    let lines = body.lines().collect::<Vec<_>>();
17097    let mut index = 0usize;
17098    while index < lines.len() {
17099        let trimmed = lines[index].trim();
17100        let Some((schema, from_binding)) = parse_record_start(trimmed) else {
17101            index += 1;
17102            continue;
17103        };
17104        // Single-line record `record X { f y }`: opens and closes on one line
17105        // (brace_delta 0), so the multi-line loop below never collects its fields,
17106        // leaving them unvalidated. Extract the inner content directly.
17107        if brace_delta(trimmed) == 0 && trimmed.contains('{') {
17108            if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
17109                if close > open {
17110                    blocks.push((
17111                        schema,
17112                        from_binding,
17113                        trimmed[open + 1..close].trim().to_owned(),
17114                    ));
17115                }
17116            }
17117            index += 1;
17118            continue;
17119        }
17120        let mut depth = brace_delta(trimmed);
17121        let mut record_lines = Vec::new();
17122        index += 1;
17123        while index < lines.len() && depth > 0 {
17124            let line = lines[index];
17125            let before = depth;
17126            depth += brace_delta(line);
17127            if !(before == 1 && depth == 0 && line.trim() == "}") {
17128                record_lines.push(line.to_owned());
17129            }
17130            index += 1;
17131        }
17132        blocks.push((schema, from_binding, record_lines.join("\n")));
17133    }
17134    blocks
17135}
17136
17137fn workflow_terminal_blocks(body: &str) -> Vec<(String, String, String)> {
17138    let mut blocks = Vec::new();
17139    let lines = body.lines().collect::<Vec<_>>();
17140    let mut index = 0usize;
17141    while index < lines.len() {
17142        let trimmed = lines[index].trim();
17143        let terminal = trimmed
17144            .strip_prefix("complete ")
17145            .map(|rest| ("complete", rest))
17146            .or_else(|| trimmed.strip_prefix("fail ").map(|rest| ("fail", rest)));
17147        let Some((action, rest)) = terminal else {
17148            index += 1;
17149            continue;
17150        };
17151        let Some(name) = rest.split('{').next().and_then(|header| {
17152            let mut parts = header.split_whitespace();
17153            match (parts.next(), parts.next()) {
17154                (Some(name), None) => Some(name.to_owned()),
17155                _ => None,
17156            }
17157        }) else {
17158            index += 1;
17159            continue;
17160        };
17161        let mut depth = brace_delta(trimmed);
17162        let mut terminal_lines = Vec::new();
17163        if depth == 0 && trimmed.contains('{') {
17164            // Single-line block: `complete <name> { <fields> }` opens and closes
17165            // on this line, so its inner content never reaches the multi-line loop
17166            // below. Capture the content between the braces as the block body.
17167            if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
17168                if close > open {
17169                    let inner = trimmed[open + 1..close].trim();
17170                    if !inner.is_empty() {
17171                        terminal_lines.push(inner.to_owned());
17172                    }
17173                }
17174            }
17175            index += 1;
17176        } else {
17177            index += 1;
17178            while index < lines.len() && depth > 0 {
17179                let line = lines[index];
17180                let before = depth;
17181                depth += brace_delta(line);
17182                if !(before == 1 && depth == 0 && line.trim() == "}") {
17183                    terminal_lines.push(line.to_owned());
17184                }
17185                index += 1;
17186            }
17187        }
17188        blocks.push((action.to_owned(), name, terminal_lines.join("\n")));
17189    }
17190    blocks
17191}
17192
17193#[derive(Clone, Debug, Eq, PartialEq)]
17194enum RecordFieldAssignment {
17195    Value { field: String, value: String },
17196    Shorthand { field: String },
17197}
17198
17199fn collect_field_assignments(body: &str) -> Vec<RecordFieldAssignment> {
17200    // Token-level splitting (R5): structure comes from tokens, never line
17201    // breaks, so a single-line multi-field payload
17202    // (`complete result { first "a" second "b" }`) collects every field —
17203    // the same splitter the kernel and table rows already use.
17204    body::split_field_assignments(body)
17205        .into_iter()
17206        .map(|assignment| match assignment.value {
17207            Some(value) => RecordFieldAssignment::Value {
17208                field: assignment.name,
17209                value,
17210            },
17211            None => RecordFieldAssignment::Shorthand {
17212                field: assignment.name,
17213            },
17214        })
17215        .collect()
17216}
17217
17218fn expression_path(expr: &str) -> Option<(String, Vec<String>)> {
17219    let mut paths = dotted_paths(expr);
17220    if paths.len() != 1 {
17221        return None;
17222    }
17223    Some(paths.remove(0))
17224}
17225
17226fn validate_literal_assignment(
17227    rule: &RuleDecl,
17228    record_schema: &str,
17229    field: &str,
17230    field_ty: &TypeSyntax,
17231    expr: &str,
17232    semantic: &SemanticContext,
17233    diagnostics: &mut Vec<Diagnostic>,
17234) {
17235    let Some(literal) = parse_literal_expr(expr) else {
17236        return;
17237    };
17238
17239    match field_ty {
17240        TypeSyntax::Primitive { name, .. } => {
17241            validate_primitive_literal(rule, record_schema, field, name, &literal, diagnostics)
17242        }
17243        TypeSyntax::LiteralString { value, .. } => {
17244            if literal != LiteralExpr::String(value.as_str()) {
17245                diagnostics.push(Diagnostic {
17246                    related: Vec::new(),
17247                    span: rule.body.span,
17248                    message: format!(
17249                        "field `{record_schema}.{field}` expects literal string `{value}`"
17250                    ),
17251                    suggestion: Some(format!("record `{field} {value:?}`")),
17252                });
17253            }
17254        }
17255        TypeSyntax::Ref { name } => {
17256            validate_enum_literal(
17257                rule,
17258                record_schema,
17259                field,
17260                &name.name,
17261                &literal,
17262                semantic,
17263                diagnostics,
17264            );
17265        }
17266        TypeSyntax::Union { variants, .. } => {
17267            validate_union_literal(rule, record_schema, field, variants, &literal, diagnostics);
17268        }
17269        TypeSyntax::AgentRef { agents, .. } => {
17270            validate_agent_ref_literal(rule, record_schema, field, agents, &literal, diagnostics);
17271        }
17272        TypeSyntax::Optional { inner, .. } => {
17273            if literal != LiteralExpr::Null {
17274                validate_literal_assignment(
17275                    rule,
17276                    record_schema,
17277                    field,
17278                    inner,
17279                    expr,
17280                    semantic,
17281                    diagnostics,
17282                );
17283            }
17284        }
17285        TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17286    }
17287}
17288
17289#[allow(clippy::too_many_arguments)]
17290fn validate_expected_assignment(
17291    rule: &RuleDecl,
17292    record_schema: &str,
17293    field: &str,
17294    field_ty: &TypeSyntax,
17295    expr: &str,
17296    semantic: &SemanticContext,
17297    binding_types: &BTreeMap<String, String>,
17298    diagnostics: &mut Vec<Diagnostic>,
17299) {
17300    if !(expr.trim_start().starts_with('{') || expr.trim_start().starts_with('[')) {
17301        return;
17302    }
17303    validate_expr_source_against_type(
17304        rule,
17305        record_schema,
17306        field,
17307        field_ty,
17308        expr,
17309        semantic,
17310        &ExprScope::from_bindings(binding_types),
17311        diagnostics,
17312    );
17313}
17314
17315#[allow(clippy::too_many_arguments)]
17316fn validate_expr_source_against_type(
17317    rule: &RuleDecl,
17318    record_schema: &str,
17319    field: &str,
17320    expected_ty: &TypeSyntax,
17321    expr: &str,
17322    semantic: &SemanticContext,
17323    scope: &ExprScope,
17324    diagnostics: &mut Vec<Diagnostic>,
17325) {
17326    match expected_ty {
17327        TypeSyntax::Map { inner, .. } => {
17328            let parsed = match parse_expression(expr) {
17329                Ok(Expr::Object(fields)) => fields,
17330                Ok(_) => {
17331                    diagnostics.push(Diagnostic {
17332                        related: Vec::new(),
17333                        span: rule.body.span,
17334                        message: format!("field `{record_schema}.{field}` expects a map literal"),
17335                        suggestion: Some(format!("record `{field} {{ key value }}`")),
17336                    });
17337                    return;
17338                }
17339                Err(message) => {
17340                    diagnostics.push(Diagnostic {
17341                        related: Vec::new(),
17342                        span: rule.body.span,
17343                        message: format!(
17344                            "field `{record_schema}.{field}` expects a map literal: {message}"
17345                        ),
17346                        suggestion: Some(format!("record `{field} {{ key value }}`")),
17347                    });
17348                    return;
17349                }
17350            };
17351            for map_field in &parsed {
17352                validate_expr_against_type(
17353                    rule,
17354                    record_schema,
17355                    field,
17356                    inner,
17357                    &map_field.value,
17358                    semantic,
17359                    scope,
17360                    diagnostics,
17361                );
17362            }
17363        }
17364        TypeSyntax::Array { inner, .. } => match parse_expression(expr) {
17365            Ok(Expr::Array(items)) => {
17366                for item in items {
17367                    validate_expr_against_type(
17368                        rule,
17369                        record_schema,
17370                        field,
17371                        inner,
17372                        &item,
17373                        semantic,
17374                        scope,
17375                        diagnostics,
17376                    );
17377                }
17378            }
17379            Ok(expr) => validate_inferred_assignment_type(
17380                rule,
17381                record_schema,
17382                field,
17383                expected_ty,
17384                &expr,
17385                semantic,
17386                scope,
17387                diagnostics,
17388            ),
17389            Err(message) => {
17390                push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
17391            }
17392        },
17393        TypeSyntax::Optional { inner, .. } => {
17394            if expr.trim() != "null" {
17395                validate_expr_source_against_type(
17396                    rule,
17397                    record_schema,
17398                    field,
17399                    inner,
17400                    expr,
17401                    semantic,
17402                    scope,
17403                    diagnostics,
17404                );
17405            }
17406        }
17407        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
17408            let parsed = match parse_expression(expr) {
17409                Ok(Expr::Object(fields)) => fields,
17410                Ok(expr) => {
17411                    validate_inferred_assignment_type(
17412                        rule,
17413                        record_schema,
17414                        field,
17415                        expected_ty,
17416                        &expr,
17417                        semantic,
17418                        scope,
17419                        diagnostics,
17420                    );
17421                    return;
17422                }
17423                Err(message) => {
17424                    push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics);
17425                    return;
17426                }
17427            };
17428            validate_object_literal_fields(
17429                rule,
17430                record_schema,
17431                field,
17432                &name.name,
17433                &parsed,
17434                semantic,
17435                scope,
17436                diagnostics,
17437            );
17438        }
17439        _ => match parse_expression(expr) {
17440            Ok(expr) => validate_inferred_assignment_type(
17441                rule,
17442                record_schema,
17443                field,
17444                expected_ty,
17445                &expr,
17446                semantic,
17447                scope,
17448                diagnostics,
17449            ),
17450            Err(message) => {
17451                push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
17452            }
17453        },
17454    }
17455}
17456
17457#[allow(clippy::too_many_arguments)]
17458fn validate_expr_against_type(
17459    rule: &RuleDecl,
17460    record_schema: &str,
17461    field: &str,
17462    expected_ty: &TypeSyntax,
17463    expr: &Expr,
17464    semantic: &SemanticContext,
17465    scope: &ExprScope,
17466    diagnostics: &mut Vec<Diagnostic>,
17467) {
17468    match expr {
17469        Expr::Array(items) if matches!(expected_ty, TypeSyntax::Array { .. }) => {
17470            if let TypeSyntax::Array { inner, .. } = expected_ty {
17471                for item in items {
17472                    validate_expr_against_type(
17473                        rule,
17474                        record_schema,
17475                        field,
17476                        inner,
17477                        item,
17478                        semantic,
17479                        scope,
17480                        diagnostics,
17481                    );
17482                }
17483            }
17484        }
17485        Expr::Object(fields) => match expected_ty {
17486            TypeSyntax::Map { inner, .. } => {
17487                for field in fields {
17488                    validate_expr_against_type(
17489                        rule,
17490                        record_schema,
17491                        field.key.as_str(),
17492                        inner,
17493                        &field.value,
17494                        semantic,
17495                        scope,
17496                        diagnostics,
17497                    );
17498                }
17499            }
17500            TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
17501                validate_object_literal_fields(
17502                    rule,
17503                    record_schema,
17504                    field,
17505                    &name.name,
17506                    fields,
17507                    semantic,
17508                    scope,
17509                    diagnostics,
17510                );
17511            }
17512            _ => validate_inferred_assignment_type(
17513                rule,
17514                record_schema,
17515                field,
17516                expected_ty,
17517                expr,
17518                semantic,
17519                scope,
17520                diagnostics,
17521            ),
17522        },
17523        _ => validate_inferred_assignment_type(
17524            rule,
17525            record_schema,
17526            field,
17527            expected_ty,
17528            expr,
17529            semantic,
17530            scope,
17531            diagnostics,
17532        ),
17533    }
17534}
17535
17536fn push_invalid_assignment_expr(
17537    rule: &RuleDecl,
17538    record_schema: &str,
17539    field: &str,
17540    message: String,
17541    diagnostics: &mut Vec<Diagnostic>,
17542) {
17543    diagnostics.push(Diagnostic {
17544        related: Vec::new(),
17545        span: rule.body.span,
17546        message: format!(
17547            "rule `{}` has invalid expression for field `{record_schema}.{field}`: {message}",
17548            rule.name.name
17549        ),
17550        suggestion: Some(
17551            "use array literals or expected-schema object literals for collection fields"
17552                .to_owned(),
17553        ),
17554    });
17555}
17556
17557#[allow(clippy::too_many_arguments)]
17558fn validate_object_literal_fields(
17559    rule: &RuleDecl,
17560    record_schema: &str,
17561    field: &str,
17562    object_schema: &str,
17563    object_fields: &[ExprObjectField],
17564    semantic: &SemanticContext,
17565    scope: &ExprScope,
17566    diagnostics: &mut Vec<Diagnostic>,
17567) {
17568    let Some(schema_fields) = semantic.schemas.classes.get(object_schema) else {
17569        return;
17570    };
17571    let mut seen = BTreeSet::new();
17572    for object_field in object_fields {
17573        if !seen.insert(object_field.key.clone()) {
17574            diagnostics.push(Diagnostic {
17575                related: Vec::new(),
17576                span: rule.body.span,
17577                message: format!(
17578                    "field `{record_schema}.{field}` repeats object field `{}`",
17579                    object_field.key
17580                ),
17581                suggestion: Some("remove the duplicate object field".to_owned()),
17582            });
17583            continue;
17584        }
17585        let Some(field_ty) = schema_fields.get(&object_field.key) else {
17586            diagnostics.push(Diagnostic {
17587                related: Vec::new(),
17588                span: rule.body.span,
17589                message: format!(
17590                    "class `{object_schema}` has no field `{}`",
17591                    object_field.key
17592                ),
17593                suggestion: Some(format!(
17594                    "add `{}` to `class {object_schema}` or use an existing field",
17595                    object_field.key
17596                )),
17597            });
17598            continue;
17599        };
17600        validate_expr_against_type(
17601            rule,
17602            object_schema,
17603            &object_field.key,
17604            field_ty,
17605            &object_field.value,
17606            semantic,
17607            scope,
17608            diagnostics,
17609        );
17610    }
17611    for (required, ty) in schema_fields {
17612        if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
17613            continue;
17614        }
17615        diagnostics.push(Diagnostic { related: Vec::new(),
17616            span: rule.body.span,
17617            message: format!(
17618                "field `{record_schema}.{field}` is missing required object field `{object_schema}.{required}`"
17619            ),
17620            suggestion: Some(format!("add `{required}` to the `{field}` object literal")),
17621        });
17622    }
17623}
17624
17625#[allow(clippy::too_many_arguments)]
17626fn validate_inferred_assignment_type(
17627    rule: &RuleDecl,
17628    record_schema: &str,
17629    field: &str,
17630    expected_ty: &TypeSyntax,
17631    expr: &Expr,
17632    semantic: &SemanticContext,
17633    scope: &ExprScope,
17634    diagnostics: &mut Vec<Diagnostic>,
17635) {
17636    let literal = expr_literal_as_literal_expr(expr);
17637    if let Some(literal) = literal {
17638        validate_literal_against_type(
17639            rule,
17640            record_schema,
17641            field,
17642            expected_ty,
17643            &literal,
17644            semantic,
17645            diagnostics,
17646        );
17647        return;
17648    }
17649
17650    let context = ExprValidationContext::rule(rule);
17651    let mut local_diagnostics = Vec::new();
17652    let actual_ty = infer_expr_type(expr, semantic, scope, &context, &mut local_diagnostics);
17653    diagnostics.extend(local_diagnostics);
17654    let expected_expr_ty = expr_type_from_type_syntax(expected_ty, semantic);
17655    if !types_comparable(&actual_ty, &expected_expr_ty) {
17656        diagnostics.push(Diagnostic {
17657            related: Vec::new(),
17658            span: rule.body.span,
17659            message: format!(
17660                "field `{record_schema}.{field}` receives incompatible expression type"
17661            ),
17662            suggestion: Some(format!(
17663                "record a value compatible with `{}`",
17664                expected_ty.to_source()
17665            )),
17666        });
17667    }
17668}
17669
17670fn validate_literal_against_type(
17671    rule: &RuleDecl,
17672    record_schema: &str,
17673    field: &str,
17674    field_ty: &TypeSyntax,
17675    literal: &LiteralExpr<'_>,
17676    semantic: &SemanticContext,
17677    diagnostics: &mut Vec<Diagnostic>,
17678) {
17679    match field_ty {
17680        TypeSyntax::Primitive { name, .. } => {
17681            validate_primitive_literal(rule, record_schema, field, name, literal, diagnostics)
17682        }
17683        TypeSyntax::LiteralString { value, .. } => {
17684            if literal != &LiteralExpr::String(value.as_str()) {
17685                diagnostics.push(Diagnostic {
17686                    related: Vec::new(),
17687                    span: rule.body.span,
17688                    message: format!(
17689                        "field `{record_schema}.{field}` expects literal string `{value}`"
17690                    ),
17691                    suggestion: Some(format!("record `{field} {value:?}`")),
17692                });
17693            }
17694        }
17695        TypeSyntax::Ref { name } => {
17696            validate_enum_literal(
17697                rule,
17698                record_schema,
17699                field,
17700                &name.name,
17701                literal,
17702                semantic,
17703                diagnostics,
17704            );
17705        }
17706        TypeSyntax::Union { variants, .. } => {
17707            validate_union_literal(rule, record_schema, field, variants, literal, diagnostics);
17708        }
17709        TypeSyntax::AgentRef { agents, .. } => {
17710            validate_agent_ref_literal(rule, record_schema, field, agents, literal, diagnostics);
17711        }
17712        TypeSyntax::Optional { inner, .. } => {
17713            if literal != &LiteralExpr::Null {
17714                validate_literal_against_type(
17715                    rule,
17716                    record_schema,
17717                    field,
17718                    inner,
17719                    literal,
17720                    semantic,
17721                    diagnostics,
17722                );
17723            }
17724        }
17725        TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17726    }
17727}
17728
17729fn expr_literal_as_literal_expr(expr: &Expr) -> Option<LiteralExpr<'_>> {
17730    match expr {
17731        Expr::Literal(ExprLiteral::String(value)) => Some(LiteralExpr::String(value)),
17732        Expr::Literal(ExprLiteral::Number(value)) => Some(LiteralExpr::Number(value)),
17733        Expr::Literal(ExprLiteral::Bool(_)) => Some(LiteralExpr::Bool),
17734        Expr::Literal(ExprLiteral::Null) => Some(LiteralExpr::Null),
17735        Expr::Literal(ExprLiteral::Ident(value)) => Some(LiteralExpr::Ident(value)),
17736        _ => None,
17737    }
17738}
17739
17740fn validate_agent_ref_literal(
17741    rule: &RuleDecl,
17742    record_schema: &str,
17743    field: &str,
17744    agents: &[Ident],
17745    literal: &LiteralExpr<'_>,
17746    diagnostics: &mut Vec<Diagnostic>,
17747) {
17748    let allowed = agents
17749        .iter()
17750        .map(|agent| agent.name.as_str())
17751        .collect::<Vec<_>>();
17752    if let LiteralExpr::String(value) = literal {
17753        diagnostics.push(Diagnostic {
17754            related: Vec::new(),
17755            span: rule.body.span,
17756            message: format!(
17757                "field `{record_schema}.{field}` expects an AgentRef value, not string `{value}`"
17758            ),
17759            suggestion: Some(format!(
17760                "use an unquoted declared agent name: {}",
17761                allowed.join(", ")
17762            )),
17763        });
17764        return;
17765    }
17766    let LiteralExpr::Ident(value) = literal else {
17767        diagnostics.push(Diagnostic {
17768            related: Vec::new(),
17769            span: rule.body.span,
17770            message: format!("field `{record_schema}.{field}` expects an AgentRef value"),
17771            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17772        });
17773        return;
17774    };
17775    if !allowed.contains(value) {
17776        diagnostics.push(Diagnostic {
17777            related: Vec::new(),
17778            span: rule.body.span,
17779            message: format!("field `{record_schema}.{field}` cannot reference agent `{value}`"),
17780            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17781        });
17782    }
17783}
17784
17785fn parse_literal_expr(expr: &str) -> Option<LiteralExpr<'_>> {
17786    let expr = expr.trim().trim_end_matches(',');
17787    if let Some(value) = expr
17788        .strip_prefix('"')
17789        .and_then(|rest| rest.strip_suffix('"'))
17790    {
17791        return Some(LiteralExpr::String(value));
17792    }
17793    if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
17794        && expr.chars().any(|ch| ch.is_ascii_digit())
17795    {
17796        return Some(LiteralExpr::Number(expr));
17797    }
17798    match expr {
17799        "true" => Some(LiteralExpr::Bool),
17800        "false" => Some(LiteralExpr::Bool),
17801        "null" => Some(LiteralExpr::Null),
17802        value if value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') => {
17803            Some(LiteralExpr::Ident(value))
17804        }
17805        _ => None,
17806    }
17807}
17808
17809struct ExprParser<'a> {
17810    source: &'a str,
17811    tokens: Vec<ExprToken>,
17812    pos: usize,
17813    depth: usize,
17814}
17815
17816/// Recursion-depth ceiling for the guard-expression grammar. Every level of
17817/// `(`/`[`/`{` nesting and every prefix `!`/`not` descends through
17818/// `parse_unary`, so bounding it there stops a deeply-nested expression in a
17819/// workflow file from overflowing the stack and aborting the process — a
17820/// normal `Err` diagnostic is returned instead. Far above any real expression.
17821const MAX_EXPR_DEPTH: usize = 256;
17822
17823#[derive(Clone, Debug, Eq, PartialEq)]
17824struct ExprToken {
17825    kind: ExprTokenKind,
17826}
17827
17828#[derive(Clone, Debug, Eq, PartialEq)]
17829enum ExprTokenKind {
17830    Ident(String),
17831    String(String),
17832    Number(String),
17833    Symbol(char),
17834    Op(&'static str),
17835}
17836
17837impl<'a> ExprParser<'a> {
17838    fn new(source: &'a str) -> Self {
17839        Self {
17840            source,
17841            tokens: lex_expr(source),
17842            pos: 0,
17843            depth: 0,
17844        }
17845    }
17846
17847    fn parse(mut self) -> Result<Expr, String> {
17848        let expr = self.parse_or()?;
17849        if self.peek().is_some() {
17850            return Err(format!(
17851                "unexpected token in expression `{}`",
17852                self.source.trim()
17853            ));
17854        }
17855        Ok(expr)
17856    }
17857
17858    fn parse_or(&mut self) -> Result<Expr, String> {
17859        let mut expr = self.parse_and()?;
17860        while self.consume_op("||") || self.consume_ident("or") {
17861            let right = self.parse_and()?;
17862            expr = Expr::Binary {
17863                op: BinaryOp::Or,
17864                left: Box::new(expr),
17865                right: Box::new(right),
17866            };
17867        }
17868        Ok(expr)
17869    }
17870
17871    fn parse_and(&mut self) -> Result<Expr, String> {
17872        let mut expr = self.parse_comparison()?;
17873        while self.consume_op("&&") || self.consume_ident("and") {
17874            let right = self.parse_comparison()?;
17875            expr = Expr::Binary {
17876                op: BinaryOp::And,
17877                left: Box::new(expr),
17878                right: Box::new(right),
17879            };
17880        }
17881        Ok(expr)
17882    }
17883
17884    fn parse_comparison(&mut self) -> Result<Expr, String> {
17885        let mut expr = self.parse_additive()?;
17886        loop {
17887            let op = if self.consume_op("==") {
17888                Some(BinaryOp::Eq)
17889            } else if self.consume_op("!=") {
17890                Some(BinaryOp::Ne)
17891            } else if self.consume_op("<=") {
17892                Some(BinaryOp::Le)
17893            } else if self.consume_op(">=") {
17894                Some(BinaryOp::Ge)
17895            } else if self.consume_symbol('<') {
17896                Some(BinaryOp::Lt)
17897            } else if self.consume_symbol('>') {
17898                Some(BinaryOp::Gt)
17899            } else if self.consume_ident("not") {
17900                if !self.consume_ident("in") {
17901                    return Err("expected `in` after `not`".to_owned());
17902                }
17903                Some(BinaryOp::NotIn)
17904            } else if self.consume_ident("in") {
17905                Some(BinaryOp::In)
17906            } else {
17907                None
17908            };
17909            let Some(op) = op else {
17910                return Ok(expr);
17911            };
17912            let right = self.parse_additive()?;
17913            expr = Expr::Binary {
17914                op,
17915                left: Box::new(expr),
17916                right: Box::new(right),
17917            };
17918        }
17919    }
17920
17921    fn parse_additive(&mut self) -> Result<Expr, String> {
17922        let mut expr = self.parse_multiplicative()?;
17923        loop {
17924            let op = if self.consume_symbol('+') {
17925                Some(BinaryOp::Add)
17926            } else if self.consume_symbol('-') {
17927                Some(BinaryOp::Sub)
17928            } else {
17929                None
17930            };
17931            let Some(op) = op else {
17932                return Ok(expr);
17933            };
17934            let right = self.parse_multiplicative()?;
17935            expr = Expr::Binary {
17936                op,
17937                left: Box::new(expr),
17938                right: Box::new(right),
17939            };
17940        }
17941    }
17942
17943    fn parse_multiplicative(&mut self) -> Result<Expr, String> {
17944        let mut expr = self.parse_unary()?;
17945        loop {
17946            let op = if self.consume_symbol('*') {
17947                Some(BinaryOp::Mul)
17948            } else if self.consume_symbol('/') {
17949                Some(BinaryOp::Div)
17950            } else {
17951                None
17952            };
17953            let Some(op) = op else {
17954                return Ok(expr);
17955            };
17956            let right = self.parse_unary()?;
17957            expr = Expr::Binary {
17958                op,
17959                left: Box::new(expr),
17960                right: Box::new(right),
17961            };
17962        }
17963    }
17964
17965    fn parse_unary(&mut self) -> Result<Expr, String> {
17966        // Depth guard (every nesting level descends through here): return a
17967        // diagnostic rather than recurse the native stack to a crash.
17968        self.depth += 1;
17969        if self.depth > MAX_EXPR_DEPTH {
17970            self.depth -= 1;
17971            return Err(format!(
17972                "expression in `{}` is nested too deeply (limit {MAX_EXPR_DEPTH})",
17973                self.source.trim()
17974            ));
17975        }
17976        let result = self.parse_unary_inner();
17977        self.depth -= 1;
17978        result
17979    }
17980
17981    fn parse_unary_inner(&mut self) -> Result<Expr, String> {
17982        if self.consume_symbol('!') {
17983            return Ok(Expr::Unary {
17984                op: UnaryOp::Not,
17985                expr: Box::new(self.parse_unary()?),
17986            });
17987        }
17988        // Prefix `not` binds looser than comparisons so `not x in y`
17989        // reads as `not (x in y)`; binary `not in` is handled by
17990        // parse_comparison before this prefix form is reached.
17991        if self.consume_ident("not") {
17992            return Ok(Expr::Unary {
17993                op: UnaryOp::Not,
17994                expr: Box::new(self.parse_comparison()?),
17995            });
17996        }
17997        self.parse_postfix()
17998    }
17999
18000    fn parse_postfix(&mut self) -> Result<Expr, String> {
18001        let mut expr = self.parse_primary()?;
18002        loop {
18003            if self.consume_symbol('[') {
18004                let key = self.parse_or()?;
18005                self.expect_symbol(']')?;
18006                expr = Expr::Index {
18007                    target: Box::new(expr),
18008                    key: Box::new(key),
18009                };
18010                continue;
18011            }
18012            return Ok(expr);
18013        }
18014    }
18015
18016    fn parse_primary(&mut self) -> Result<Expr, String> {
18017        if self.consume_symbol('(') {
18018            let expr = self.parse_or()?;
18019            self.expect_symbol(')')?;
18020            return Ok(expr);
18021        }
18022        if self.consume_symbol('[') {
18023            let mut items = Vec::new();
18024            if self.consume_symbol(']') {
18025                return Ok(Expr::Array(items));
18026            }
18027            loop {
18028                items.push(self.parse_or()?);
18029                if self.consume_symbol(']') {
18030                    break;
18031                }
18032                self.expect_symbol(',')?;
18033            }
18034            return Ok(Expr::Array(items));
18035        }
18036        if self.consume_symbol('{') {
18037            let mut fields = Vec::new();
18038            if self.consume_symbol('}') {
18039                return Ok(Expr::Object(fields));
18040            }
18041            loop {
18042                let key = match self.advance().map(|token| token.kind.clone()) {
18043                    Some(ExprTokenKind::Ident(value) | ExprTokenKind::String(value)) => value,
18044                    _ => return Err("expected object field name".to_owned()),
18045                };
18046                let value = self.parse_or()?;
18047                fields.push(ExprObjectField { key, value });
18048                if self.consume_symbol('}') {
18049                    break;
18050                }
18051                let _ = self.consume_symbol(',');
18052            }
18053            return Ok(Expr::Object(fields));
18054        }
18055        match self.advance().map(|token| token.kind.clone()) {
18056            Some(ExprTokenKind::String(value)) => Ok(Expr::Literal(ExprLiteral::String(value))),
18057            Some(ExprTokenKind::Number(value)) => Ok(Expr::Literal(ExprLiteral::Number(value))),
18058            Some(ExprTokenKind::Ident(value)) if value == "true" => {
18059                Ok(Expr::Literal(ExprLiteral::Bool(true)))
18060            }
18061            Some(ExprTokenKind::Ident(value)) if value == "false" => {
18062                Ok(Expr::Literal(ExprLiteral::Bool(false)))
18063            }
18064            Some(ExprTokenKind::Ident(value)) if value == "null" => {
18065                Ok(Expr::Literal(ExprLiteral::Null))
18066            }
18067            Some(ExprTokenKind::Ident(value)) if value == "exists" && !self.at_symbol('(') => {
18068                let arg = match self.parse_postfix()? {
18069                    Expr::Literal(ExprLiteral::Ident(path)) => Expr::Path(vec![path]),
18070                    expr => expr,
18071                };
18072                Ok(Expr::Call {
18073                    name: value,
18074                    args: vec![arg],
18075                })
18076            }
18077            Some(ExprTokenKind::Ident(value))
18078                if matches!(value.as_str(), "count" | "exists" | "empty")
18079                    && self.at_symbol('(') =>
18080            {
18081                self.expect_symbol('(')?;
18082                if let Some(query) = self.try_parse_query()? {
18083                    self.expect_symbol(')')?;
18084                    Ok(Expr::Call {
18085                        name: value,
18086                        args: vec![query],
18087                    })
18088                } else {
18089                    let mut args = Vec::new();
18090                    if self.consume_symbol(')') {
18091                        return Ok(Expr::Call { name: value, args });
18092                    }
18093                    loop {
18094                        args.push(self.parse_or()?);
18095                        if self.consume_symbol(')') {
18096                            break;
18097                        }
18098                        self.expect_symbol(',')?;
18099                    }
18100                    Ok(Expr::Call { name: value, args })
18101                }
18102            }
18103            Some(ExprTokenKind::Ident(value)) => {
18104                let mut path = vec![value];
18105                while self.consume_symbol('.') {
18106                    let Some(ExprTokenKind::Ident(field)) =
18107                        self.advance().map(|token| token.kind.clone())
18108                    else {
18109                        return Err("expected field name after `.`".to_owned());
18110                    };
18111                    path.push(field);
18112                }
18113                if path.len() == 1 {
18114                    Ok(Expr::Literal(ExprLiteral::Ident(path.remove(0))))
18115                } else {
18116                    Ok(Expr::Path(path))
18117                }
18118            }
18119            _ => Err(format!("expected expression in `{}`", self.source.trim())),
18120        }
18121    }
18122
18123    fn try_parse_query(&mut self) -> Result<Option<Expr>, String> {
18124        let checkpoint = self.pos;
18125        let kind = if self.consume_ident("effect") {
18126            QueryKind::Effect
18127        } else if matches!(
18128            self.peek().map(|token| &token.kind),
18129            Some(ExprTokenKind::Ident(value)) if value.chars().next().is_some_and(char::is_uppercase)
18130        ) {
18131            QueryKind::Fact
18132        } else {
18133            return Ok(None);
18134        };
18135        let mut head = Vec::new();
18136        while let Some(token) = self.peek() {
18137            if self.at_symbol(')') || self.at_ident("where") {
18138                break;
18139            }
18140            head.push(self.token_text(token));
18141            self.pos += 1;
18142        }
18143        if head.is_empty() {
18144            self.pos = checkpoint;
18145            return Ok(None);
18146        }
18147        let guard = if self.consume_ident("where") {
18148            Some(Box::new(self.parse_or()?))
18149        } else {
18150            None
18151        };
18152        Ok(Some(Expr::Query {
18153            kind,
18154            head: join_query_head(&head),
18155            guard,
18156        }))
18157    }
18158
18159    fn token_text(&self, token: &ExprToken) -> String {
18160        match &token.kind {
18161            ExprTokenKind::Ident(value) | ExprTokenKind::Number(value) => value.clone(),
18162            ExprTokenKind::String(value) => format!("{value:?}"),
18163            ExprTokenKind::Symbol(value) => value.to_string(),
18164            ExprTokenKind::Op(value) => value.to_string(),
18165        }
18166    }
18167
18168    fn peek(&self) -> Option<&ExprToken> {
18169        self.tokens.get(self.pos)
18170    }
18171
18172    fn advance(&mut self) -> Option<&ExprToken> {
18173        let token = self.tokens.get(self.pos)?;
18174        self.pos += 1;
18175        Some(token)
18176    }
18177
18178    fn at_symbol(&self, symbol: char) -> bool {
18179        matches!(
18180            self.peek().map(|token| &token.kind),
18181            Some(ExprTokenKind::Symbol(value)) if *value == symbol
18182        )
18183    }
18184
18185    fn consume_symbol(&mut self, symbol: char) -> bool {
18186        if self.at_symbol(symbol) {
18187            self.pos += 1;
18188            true
18189        } else {
18190            false
18191        }
18192    }
18193
18194    fn expect_symbol(&mut self, symbol: char) -> Result<(), String> {
18195        if self.consume_symbol(symbol) {
18196            Ok(())
18197        } else {
18198            Err(format!("expected `{symbol}`"))
18199        }
18200    }
18201
18202    fn at_ident(&self, ident: &str) -> bool {
18203        matches!(
18204            self.peek().map(|token| &token.kind),
18205            Some(ExprTokenKind::Ident(value)) if value == ident
18206        )
18207    }
18208
18209    fn consume_ident(&mut self, ident: &str) -> bool {
18210        if self.at_ident(ident) {
18211            self.pos += 1;
18212            true
18213        } else {
18214            false
18215        }
18216    }
18217
18218    fn consume_op(&mut self, op: &'static str) -> bool {
18219        if matches!(
18220            self.peek().map(|token| &token.kind),
18221            Some(ExprTokenKind::Op(value)) if *value == op
18222        ) {
18223            self.pos += 1;
18224            true
18225        } else {
18226            false
18227        }
18228    }
18229}
18230
18231fn join_query_head(tokens: &[String]) -> String {
18232    let mut head = String::new();
18233    for token in tokens {
18234        if token == "." {
18235            head.push('.');
18236        } else if head.ends_with('.') || head.is_empty() {
18237            head.push_str(token);
18238        } else {
18239            head.push(' ');
18240            head.push_str(token);
18241        }
18242    }
18243    head
18244}
18245
18246fn lex_expr(source: &str) -> Vec<ExprToken> {
18247    let bytes = source.as_bytes();
18248    let mut tokens = Vec::new();
18249    let mut index = 0usize;
18250    while index < bytes.len() {
18251        let byte = bytes[index];
18252        if byte.is_ascii_whitespace() {
18253            index += 1;
18254            continue;
18255        }
18256        if is_ident_start(byte) {
18257            let start = index;
18258            index += 1;
18259            while index < bytes.len() && is_ident_continue(bytes[index]) {
18260                index += 1;
18261            }
18262            tokens.push(ExprToken {
18263                kind: ExprTokenKind::Ident(source[start..index].to_owned()),
18264            });
18265            continue;
18266        }
18267        if byte.is_ascii_digit() {
18268            let start = index;
18269            index += 1;
18270            while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
18271                index += 1;
18272            }
18273            tokens.push(ExprToken {
18274                kind: ExprTokenKind::Number(source[start..index].to_owned()),
18275            });
18276            continue;
18277        }
18278        if byte == b'"' {
18279            let start = index + 1;
18280            index += 1;
18281            while index < bytes.len() && bytes[index] != b'"' {
18282                index += 1;
18283            }
18284            let value = source[start..index.min(bytes.len())].to_owned();
18285            index = (index + 1).min(bytes.len());
18286            tokens.push(ExprToken {
18287                kind: ExprTokenKind::String(value),
18288            });
18289            continue;
18290        }
18291        let rest = &source[index..];
18292        if rest.starts_with("&&") {
18293            tokens.push(ExprToken {
18294                kind: ExprTokenKind::Op("&&"),
18295            });
18296            index += 2;
18297        } else if rest.starts_with("||") {
18298            tokens.push(ExprToken {
18299                kind: ExprTokenKind::Op("||"),
18300            });
18301            index += 2;
18302        } else if rest.starts_with("==") {
18303            tokens.push(ExprToken {
18304                kind: ExprTokenKind::Op("=="),
18305            });
18306            index += 2;
18307        } else if rest.starts_with("!=") {
18308            tokens.push(ExprToken {
18309                kind: ExprTokenKind::Op("!="),
18310            });
18311            index += 2;
18312        } else if rest.starts_with("<=") {
18313            tokens.push(ExprToken {
18314                kind: ExprTokenKind::Op("<="),
18315            });
18316            index += 2;
18317        } else if rest.starts_with(">=") {
18318            tokens.push(ExprToken {
18319                kind: ExprTokenKind::Op(">="),
18320            });
18321            index += 2;
18322        } else {
18323            tokens.push(ExprToken {
18324                kind: ExprTokenKind::Symbol(byte as char),
18325            });
18326            index += 1;
18327        }
18328    }
18329    tokens
18330}
18331
18332fn validate_primitive_literal(
18333    rule: &RuleDecl,
18334    record_schema: &str,
18335    field: &str,
18336    primitive: &str,
18337    literal: &LiteralExpr<'_>,
18338    diagnostics: &mut Vec<Diagnostic>,
18339) {
18340    let valid = matches!(
18341        (primitive, literal),
18342        ("string", LiteralExpr::String(_))
18343            | ("string", LiteralExpr::Ident(_))
18344            | ("int", LiteralExpr::Number(_))
18345            | ("float", LiteralExpr::Number(_))
18346            | ("bool", LiteralExpr::Bool)
18347            | ("null", LiteralExpr::Null)
18348            | ("duration", LiteralExpr::String(_))
18349            | ("time", LiteralExpr::String(_))
18350    );
18351    if !valid {
18352        diagnostics.push(Diagnostic {
18353            related: Vec::new(),
18354            span: rule.body.span,
18355            message: format!("field `{record_schema}.{field}` expects `{primitive}`"),
18356            suggestion: Some(format!("record a value compatible with `{primitive}`")),
18357        });
18358        return;
18359    }
18360    match (primitive, literal) {
18361        ("duration", LiteralExpr::String(value)) if parse_duration_seconds(value).is_none() => {
18362            diagnostics.push(Diagnostic {
18363                related: Vec::new(),
18364                span: rule.body.span,
18365                message: format!("field `{record_schema}.{field}` has invalid duration literal"),
18366                suggestion: Some("use an ISO-8601 duration such as `\"PT30M\"`".to_owned()),
18367            });
18368        }
18369        ("time", LiteralExpr::String(value)) if parse_time_epoch_seconds(value).is_none() => {
18370            diagnostics.push(Diagnostic {
18371                related: Vec::new(),
18372                span: rule.body.span,
18373                message: format!("field `{record_schema}.{field}` has invalid time literal"),
18374                suggestion: Some(
18375                    "use an RFC3339 timestamp such as `\"2026-05-29T10:00:00Z\"`".to_owned(),
18376                ),
18377            });
18378        }
18379        _ => {}
18380    }
18381}
18382
18383fn validate_enum_literal(
18384    rule: &RuleDecl,
18385    record_schema: &str,
18386    field: &str,
18387    schema: &str,
18388    literal: &LiteralExpr<'_>,
18389    semantic: &SemanticContext,
18390    diagnostics: &mut Vec<Diagnostic>,
18391) {
18392    let Some(variants) = semantic.schemas.enums.get(schema) else {
18393        return;
18394    };
18395    let LiteralExpr::Ident(variant) = literal else {
18396        diagnostics.push(Diagnostic {
18397            related: Vec::new(),
18398            span: rule.body.span,
18399            message: format!("field `{record_schema}.{field}` expects enum `{schema}`"),
18400            suggestion: Some(format!(
18401                "use one of: {}",
18402                variants.iter().cloned().collect::<Vec<_>>().join(", ")
18403            )),
18404        });
18405        return;
18406    };
18407    if !variants.contains(*variant) {
18408        diagnostics.push(Diagnostic {
18409            related: Vec::new(),
18410            span: rule.body.span,
18411            message: format!("enum `{schema}` has no variant `{variant}`"),
18412            suggestion: Some(format!(
18413                "use one of: {}",
18414                variants.iter().cloned().collect::<Vec<_>>().join(", ")
18415            )),
18416        });
18417    }
18418}
18419
18420fn validate_union_literal(
18421    rule: &RuleDecl,
18422    record_schema: &str,
18423    field: &str,
18424    variants: &[TypeSyntax],
18425    literal: &LiteralExpr<'_>,
18426    diagnostics: &mut Vec<Diagnostic>,
18427) {
18428    let allowed = variants
18429        .iter()
18430        .filter_map(|variant| match variant {
18431            TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
18432            _ => None,
18433        })
18434        .collect::<Vec<_>>();
18435    if allowed.is_empty() {
18436        return;
18437    }
18438    let LiteralExpr::String(value) = literal else {
18439        diagnostics.push(Diagnostic {
18440            related: Vec::new(),
18441            span: rule.body.span,
18442            message: format!("field `{record_schema}.{field}` expects one of its literal variants"),
18443            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
18444        });
18445        return;
18446    };
18447    if !allowed.contains(value) {
18448        diagnostics.push(Diagnostic {
18449            related: Vec::new(),
18450            span: rule.body.span,
18451            message: format!("field `{record_schema}.{field}` cannot be `{value}`"),
18452            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
18453        });
18454    }
18455}
18456
18457fn parse_effect_line(line: &str) -> Option<(IrEffectKind, Option<String>)> {
18458    let kind = if line.starts_with("tell ") {
18459        IrEffectKind::AgentTell
18460    } else if line.starts_with("coerce ") || line.starts_with("prompt ") {
18461        IrEffectKind::SchemaCoerce
18462    } else if line.starts_with("claim ") {
18463        IrEffectKind::TrackerClaim
18464    } else if line.starts_with("call ")
18465        || line.starts_with("recall ")
18466        || line.starts_with("learn ")
18467        || line.starts_with("curate ")
18468    {
18469        IrEffectKind::CapabilityCall
18470    } else if line.starts_with("emit ") {
18471        IrEffectKind::EventEmit
18472    } else if line.starts_with("invoke ") {
18473        IrEffectKind::WorkflowInvoke
18474    } else if line.starts_with("read ") {
18475        IrEffectKind::FileRead
18476    } else if line.starts_with("write ") {
18477        IrEffectKind::FileWrite
18478    } else if line.starts_with("import ") {
18479        IrEffectKind::FileImport
18480    } else if line.starts_with("export ") {
18481        IrEffectKind::FileExport
18482    } else if line.starts_with("acquire ") {
18483        IrEffectKind::LeaseAcquire
18484    } else if line.starts_with("renew ") {
18485        IrEffectKind::LeaseRenew
18486    } else if line.starts_with("append ") {
18487        IrEffectKind::LedgerAppend
18488    } else if line.starts_with("consume ") && line.contains(" for ") {
18489        // The counter verb (`consume <counter> for <key> …`); the bare
18490        // `consume <binding>` alias was removed, and `done` never reaches
18491        // this fn as an effect line.
18492        IrEffectKind::CounterConsume
18493    } else {
18494        return None;
18495    };
18496
18497    Some((kind, binding_after_as(line)))
18498}
18499
18500fn parse_consume_line(line: &str) -> Option<String> {
18501    // `done <binding>` consumes the matched fact. The bare `consume` alias for
18502    // `done` was removed; `consume <counter> for ...` is the distinct counter
18503    // verb (multi-word, so it never satisfies the identifier check below).
18504    let binding = line
18505        .trim()
18506        .trim_end_matches(';')
18507        .strip_prefix("done ")?
18508        .split("->")
18509        .next()
18510        .unwrap_or_default()
18511        .trim();
18512    let mut chars = binding.chars();
18513    let first = chars.next()?;
18514    if !(first.is_ascii_alphabetic() || first == '_') {
18515        return None;
18516    }
18517    chars
18518        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18519        .then(|| binding.to_owned())
18520}
18521
18522fn binding_after_multiline_string_end(line: &str) -> Option<String> {
18523    line.strip_prefix("\"\"\"")
18524        .and_then(|rest| rest.trim().strip_prefix("as "))
18525        .and_then(|rest| rest.split_whitespace().next())
18526        .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18527        .filter(|binding| !binding.is_empty())
18528        .map(str::to_owned)
18529}
18530
18531fn validate_rule_prompt_content_type_annotation(
18532    rule: &RuleDecl,
18533    line: &str,
18534    diagnostics: &mut Vec<Diagnostic>,
18535) {
18536    if !(line.starts_with("tell ") || line.starts_with("coerce ")) {
18537        return;
18538    }
18539    let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18540        return;
18541    };
18542    diagnostics.push(Diagnostic {
18543        related: Vec::new(),
18544        span: rule.body.span,
18545        message: format!(
18546            "rule `{}` has malformed multiline prompt content type `{annotation}`",
18547            rule.name.name
18548        ),
18549        suggestion: Some(
18550            "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18551                .to_owned(),
18552        ),
18553    });
18554}
18555
18556fn validate_coerce_prompt_content_type_annotations(
18557    coerce: &CoerceDecl,
18558    diagnostics: &mut Vec<Diagnostic>,
18559) {
18560    for line in coerce.body.text.lines().map(str::trim) {
18561        if !line.starts_with("prompt ") {
18562            continue;
18563        }
18564        let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18565            continue;
18566        };
18567        diagnostics.push(Diagnostic { related: Vec::new(),
18568            span: coerce.body.span,
18569            message: format!(
18570                "coerce `{}` has malformed multiline prompt content type `{annotation}`",
18571                coerce.name.name
18572            ),
18573            suggestion: Some(
18574                "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18575                    .to_owned(),
18576            ),
18577        });
18578    }
18579}
18580
18581fn malformed_prompt_content_type_annotation(line: &str) -> Option<String> {
18582    let (_, tail) = line.split_once("\"\"\"")?;
18583    let candidate = tail.trim();
18584    if candidate.is_empty() || candidate.contains("\"\"\"") {
18585        return None;
18586    }
18587    let mut parts = candidate.split_whitespace();
18588    let first = parts.next()?;
18589    let has_extra_text = parts.next().is_some();
18590    let first_is_supported = is_supported_prompt_content_type(first);
18591    let first_is_annotation_shaped = first_is_supported || first.contains('/');
18592    if has_extra_text && first_is_annotation_shaped {
18593        return Some(candidate.to_owned());
18594    }
18595    if first.contains('/') && !first_is_supported {
18596        return Some(first.to_owned());
18597    }
18598    None
18599}
18600
18601fn is_supported_prompt_content_type(candidate: &str) -> bool {
18602    if !is_prompt_content_type_token(candidate) {
18603        return false;
18604    }
18605    let normalized = candidate.to_ascii_lowercase();
18606    normalized.contains('/')
18607        || matches!(
18608            normalized.as_str(),
18609            "markdown" | "json" | "text" | "plain" | "html" | "xml" | "yaml" | "yml"
18610        )
18611}
18612
18613fn is_prompt_content_type_token(candidate: &str) -> bool {
18614    let mut chars = candidate.chars();
18615    let Some(first) = chars.next() else {
18616        return false;
18617    };
18618    first.is_ascii_alphanumeric()
18619        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '+' | '-' | '_'))
18620}
18621
18622fn binding_after_as(line: &str) -> Option<String> {
18623    let mut tokens = line.split_whitespace();
18624    while let Some(token) = tokens.next() {
18625        if token == "as" {
18626            return tokens
18627                .next()
18628                .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18629                .filter(|binding| !binding.is_empty())
18630                .map(str::to_owned);
18631        }
18632    }
18633    None
18634}
18635
18636fn parse_after_line(line: &str) -> Option<(String, DependencyPredicate)> {
18637    let rest = line.strip_prefix("after ")?;
18638    if rest.contains("=>") {
18639        return None;
18640    }
18641    let before_body = rest.split('{').next().unwrap_or(rest).trim();
18642    let mut parts = before_body.split_whitespace();
18643    let binding = parts.next()?.to_owned();
18644    let predicate = match parts.next()? {
18645        "succeeds" => DependencyPredicate::Succeeds,
18646        "fails" => DependencyPredicate::Fails,
18647        // `times out` / `cancelled` react only to that specific non-success
18648        // terminal status (spec/expression-kernel.md), mirroring succeeds/fails.
18649        "cancelled" => DependencyPredicate::Cancelled,
18650        "times" => {
18651            if parts.next()? != "out" {
18652                return None;
18653            }
18654            DependencyPredicate::TimedOut
18655        }
18656        // Coordination outcomes (spec/coordination.md) are completion-valued;
18657        // the arm dispatch happens on the outcome variant at lowering.
18658        "completes" | "held" | "contended" | "ok" | "over" => DependencyPredicate::Completes,
18659        // `after p reaches "<name>" [as m]` (Family C): consume the quoted
18660        // milestone name (which may contain whitespace — token-splitting used
18661        // to reject multi-word names); the IR predicate is completion-shaped
18662        // (runtime gating keys on the milestone-specific `reached` fact).
18663        "reaches" => {
18664            let rest = before_body.trim().strip_prefix(&binding)?.trim_start();
18665            let after_kw = rest.strip_prefix("reaches")?.trim_start();
18666            let quoted = after_kw.strip_prefix('"')?;
18667            let close = quoted.find('"')?;
18668            let tail = &quoted[close + 1..];
18669            let mut tail_parts = tail.split_whitespace();
18670            match (tail_parts.next(), tail_parts.next(), tail_parts.next()) {
18671                (None, None, None) => {}
18672                (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18673                _ => return None,
18674            }
18675            return Some((binding, DependencyPredicate::Completes));
18676        }
18677        _ => return None,
18678    };
18679    match (parts.next(), parts.next(), parts.next()) {
18680        (None, None, None) => {}
18681        (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18682        _ => return None,
18683    }
18684    Some((binding, predicate))
18685}
18686
18687pub(crate) fn is_identifier(value: &str) -> bool {
18688    let mut chars = value.chars();
18689    let Some(first) = chars.next() else {
18690        return false;
18691    };
18692    (first.is_ascii_alphabetic() || first == '_')
18693        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18694}
18695
18696fn lower_type(ty: TypeSyntax) -> IrType {
18697    match ty {
18698        TypeSyntax::Primitive { name, .. } => IrType::Primitive(lower_primitive_type(&name)),
18699        TypeSyntax::LiteralString { value, .. } => IrType::LiteralString(value),
18700        TypeSyntax::Ref { name } => IrType::Ref(name.name),
18701        TypeSyntax::AgentRef { agents, .. } => {
18702            IrType::AgentRef(agents.into_iter().map(|agent| agent.name).collect())
18703        }
18704        TypeSyntax::Optional { inner, .. } => IrType::Optional(Box::new(lower_type(*inner))),
18705        TypeSyntax::Array { inner, .. } => IrType::Array(Box::new(lower_type(*inner))),
18706        TypeSyntax::Map { inner, .. } => IrType::Map(Box::new(lower_type(*inner))),
18707        TypeSyntax::Union { variants, .. } => {
18708            IrType::Union(variants.into_iter().map(lower_type).collect())
18709        }
18710    }
18711}
18712
18713fn lower_primitive_type(name: &str) -> IrPrimitiveType {
18714    match name {
18715        "string" => IrPrimitiveType::String,
18716        "int" => IrPrimitiveType::Int,
18717        "float" => IrPrimitiveType::Float,
18718        "bool" => IrPrimitiveType::Bool,
18719        "null" => IrPrimitiveType::Null,
18720        "duration" => IrPrimitiveType::Duration,
18721        "time" => IrPrimitiveType::Time,
18722        "image" => IrPrimitiveType::Image,
18723        "audio" => IrPrimitiveType::Audio,
18724        "pdf" => IrPrimitiveType::Pdf,
18725        "video" => IrPrimitiveType::Video,
18726        _ => IrPrimitiveType::String,
18727    }
18728}
18729
18730fn push_line(snapshot: &mut String, line: impl AsRef<str>) {
18731    snapshot.push_str(line.as_ref());
18732    snapshot.push('\n');
18733}
18734
18735fn stable_hash(value: &str) -> String {
18736    // SHA-256 truncated to 128 bits (the FNV-collision hardening swap):
18737    // source_hash/ir_hash are program-version identity — colliding them
18738    // aliases two program revisions. Report-schema digest patterns and the
18739    // Python validator mirrors must stay in lockstep with this width.
18740    use sha2::Digest;
18741    let digest = sha2::Sha256::digest(value.as_bytes());
18742    let mut hex = String::with_capacity(32);
18743    for byte in &digest[..16] {
18744        hex.push_str(&format!("{byte:02x}"));
18745    }
18746    hex
18747}
18748
18749pub fn parse_duration_seconds(value: &str) -> Option<f64> {
18750    let value = value.strip_prefix('P')?;
18751    let mut rest = value;
18752    let mut seconds = 0.0;
18753    let mut consumed = false;
18754    let mut in_time = false;
18755
18756    while !rest.is_empty() {
18757        if let Some(next) = rest.strip_prefix('T') {
18758            if in_time {
18759                return None;
18760            }
18761            in_time = true;
18762            rest = next;
18763            continue;
18764        }
18765
18766        let number_len = rest
18767            .char_indices()
18768            .take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.')
18769            .map(|(index, ch)| index + ch.len_utf8())
18770            .last()?;
18771        let number = rest[..number_len].parse::<f64>().ok()?;
18772        if !number.is_finite() {
18773            return None;
18774        }
18775        let unit = rest[number_len..].chars().next()?;
18776        rest = &rest[number_len + unit.len_utf8()..];
18777        let multiplier = match (in_time, unit) {
18778            (false, 'D') => 86_400.0,
18779            (true, 'H') => 3_600.0,
18780            (true, 'M') => 60.0,
18781            (true, 'S') => 1.0,
18782            _ => return None,
18783        };
18784        seconds += number * multiplier;
18785        consumed = true;
18786    }
18787
18788    consumed.then_some(seconds)
18789}
18790
18791pub fn parse_time_epoch_seconds(value: &str) -> Option<f64> {
18792    if value.len() < 20 {
18793        return None;
18794    }
18795    let year = parse_fixed_i32(value, 0, 4)?;
18796    require_byte(value, 4, b'-')?;
18797    let month = parse_fixed_u32(value, 5, 2)?;
18798    require_byte(value, 7, b'-')?;
18799    let day = parse_fixed_u32(value, 8, 2)?;
18800    require_byte(value, 10, b'T')?;
18801    let hour = parse_fixed_u32(value, 11, 2)?;
18802    require_byte(value, 13, b':')?;
18803    let minute = parse_fixed_u32(value, 14, 2)?;
18804    require_byte(value, 16, b':')?;
18805    let second = parse_fixed_u32(value, 17, 2)?;
18806    let mut offset_start = 19;
18807    let mut fractional_second = 0.0;
18808    if value.as_bytes().get(offset_start).copied() == Some(b'.') {
18809        let fraction_start = offset_start + 1;
18810        let fraction_len = value[fraction_start..]
18811            .char_indices()
18812            .take_while(|(_, ch)| ch.is_ascii_digit())
18813            .map(|(index, ch)| index + ch.len_utf8())
18814            .last()?;
18815        let fraction = &value[fraction_start..fraction_start + fraction_len];
18816        let scale = 10_f64.powi(i32::try_from(fraction.len()).ok()?);
18817        fractional_second = fraction.parse::<f64>().ok()? / scale;
18818        offset_start = fraction_start + fraction_len;
18819    }
18820    if !(1..=12).contains(&month)
18821        || !(1..=days_in_month(year, month)).contains(&day)
18822        || hour > 23
18823        || minute > 59
18824        || second > 60
18825    {
18826        return None;
18827    }
18828
18829    let offset_seconds = match value.as_bytes().get(offset_start).copied()? {
18830        b'Z' if value.len() == offset_start + 1 => 0,
18831        b'+' | b'-' if value.len() == offset_start + 6 => {
18832            let sign = if value.as_bytes()[offset_start] == b'+' {
18833                1
18834            } else {
18835                -1
18836            };
18837            let offset_hour = parse_fixed_i32(value, offset_start + 1, 2)?;
18838            require_byte(value, offset_start + 3, b':')?;
18839            let offset_minute = parse_fixed_i32(value, offset_start + 4, 2)?;
18840            if offset_hour > 23 || offset_minute > 59 {
18841                return None;
18842            }
18843            sign * (offset_hour * 3_600 + offset_minute * 60)
18844        }
18845        _ => return None,
18846    };
18847
18848    let days = days_from_civil(year, month, day);
18849    let local_seconds = days * 86_400 + i64::from(hour * 3_600 + minute * 60 + second.min(59));
18850    Some((local_seconds - i64::from(offset_seconds)) as f64 + fractional_second)
18851}
18852
18853fn parse_fixed_i32(value: &str, start: usize, len: usize) -> Option<i32> {
18854    value.get(start..start + len)?.parse::<i32>().ok()
18855}
18856
18857fn parse_fixed_u32(value: &str, start: usize, len: usize) -> Option<u32> {
18858    value.get(start..start + len)?.parse::<u32>().ok()
18859}
18860
18861fn require_byte(value: &str, index: usize, expected: u8) -> Option<()> {
18862    (value.as_bytes().get(index).copied()? == expected).then_some(())
18863}
18864
18865fn days_in_month(year: i32, month: u32) -> u32 {
18866    match month {
18867        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
18868        4 | 6 | 9 | 11 => 30,
18869        2 if is_leap_year(year) => 29,
18870        2 => 28,
18871        _ => 0,
18872    }
18873}
18874
18875fn is_leap_year(year: i32) -> bool {
18876    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
18877}
18878
18879fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
18880    let year = year - i32::from(month <= 2);
18881    let era = if year >= 0 { year } else { year - 399 } / 400;
18882    let year_of_era = year - era * 400;
18883    let month = month as i32;
18884    let day = day as i32;
18885    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
18886    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
18887    i64::from(era * 146_097 + day_of_era - 719_468)
18888}
18889
18890fn format_syntax(program: Program) -> String {
18891    let mut formatted = String::new();
18892    if let Some(workflow) = program.workflow {
18893        format_tags(&program.workflow_tags, &mut formatted);
18894        format_description(program.workflow_description.as_ref(), &mut formatted);
18895        push_line(&mut formatted, format!("workflow {}", workflow.name));
18896        formatted.push('\n');
18897    }
18898
18899    let mut top_level_items = Vec::new();
18900    top_level_items.extend(program.patterns.into_iter().map(Item::Pattern));
18901    top_level_items.extend(program.items);
18902    format_items(top_level_items, &mut formatted);
18903
18904    if !formatted.is_empty() && !program.workflows.is_empty() {
18905        formatted.push('\n');
18906    }
18907    let workflow_count = program.workflows.len();
18908    for (index, workflow) in program.workflows.into_iter().enumerate() {
18909        format_workflow(workflow, &mut formatted);
18910        if index + 1 < workflow_count {
18911            formatted.push('\n');
18912        }
18913    }
18914
18915    formatted
18916}
18917
18918fn format_items(items: Vec<Item>, formatted: &mut String) {
18919    let item_count = items.len();
18920    for (index, item) in items.into_iter().enumerate() {
18921        format_item(item, formatted);
18922        if index + 1 < item_count {
18923            formatted.push('\n');
18924        }
18925    }
18926}
18927
18928fn format_item(item: Item, formatted: &mut String) {
18929    match item {
18930        Item::Include(include) => {
18931            push_line(formatted, format!("include {:?}", include.path.value));
18932        }
18933        Item::Use(use_decl) => {
18934            push_line(formatted, format!("use {}", use_decl.name.value));
18935        }
18936        Item::Tracker(queue) => {
18937            push_line(formatted, format!("tracker {} {{", queue.name.name));
18938            push_line(formatted, format!("  provider {}", queue.provider.name));
18939            push_line(formatted, "}");
18940        }
18941        Item::Mark(mark) => {
18942            push_line(
18943                formatted,
18944                format!("mark {:?} after {}", mark.name.value, mark.site),
18945            );
18946        }
18947        Item::Gauge(gauge) => {
18948            let mut header = format!("gauge {}", gauge.name.name);
18949            if let Some(site) = &gauge.site {
18950                header.push_str(&format!(" on {site}"));
18951            }
18952            header.push_str(" {");
18953            push_line(formatted, header);
18954            let judge = match &gauge.judge {
18955                GaugeJudge::Coerce(target, args) if args.is_empty() => {
18956                    format!("coerce {}", target.name)
18957                }
18958                GaugeJudge::Coerce(target, args) => {
18959                    format!("coerce {}({})", target.name, args.join(", "))
18960                }
18961                GaugeJudge::Prompt(template) => format!("prompt {:?}", template.value),
18962                GaugeJudge::Exec(command) => format!("exec {:?}", command.value),
18963                GaugeJudge::Labels(source) => format!("labels {:?}", source.value),
18964            };
18965            push_line(formatted, format!("  judge via {judge}"));
18966            if let Some(bar) = &gauge.expect {
18967                let subject = match &bar.subject {
18968                    GaugeBarSubject::Chance { field } => format!("P({})", field.name),
18969                    GaugeBarSubject::Stat { stat } => stat.name.clone(),
18970                };
18971                let direction = if bar.at_least { "at least" } else { "at most" };
18972                push_line(
18973                    formatted,
18974                    format!("  expect {subject} {direction} {}", bar.threshold),
18975                );
18976            }
18977            if !gauge.inputs.is_empty() {
18978                let names = gauge
18979                    .inputs
18980                    .iter()
18981                    .map(|input| input.name.as_str())
18982                    .collect::<Vec<_>>()
18983                    .join(", ");
18984                push_line(formatted, format!("  inputs {names}"));
18985            }
18986            push_line(formatted, "}");
18987        }
18988        Item::Campaign(campaign) => {
18989            push_line(formatted, format!("campaign {} {{", campaign.name.name));
18990            if !campaign.ascend.is_empty() {
18991                let names = campaign
18992                    .ascend
18993                    .iter()
18994                    .map(|gauge| gauge.name.as_str())
18995                    .collect::<Vec<_>>()
18996                    .join(", ");
18997                push_line(formatted, format!("  ascend {names}"));
18998            }
18999            for reach in &campaign.reach {
19000                let direction = if reach.at_least {
19001                    "at least"
19002                } else {
19003                    "at most"
19004                };
19005                let unit = reach.unit.as_deref().unwrap_or("");
19006                push_line(
19007                    formatted,
19008                    format!(
19009                        "  reach {} {direction} {}{unit}",
19010                        reach.gauge.name, reach.threshold
19011                    ),
19012                );
19013            }
19014            for guard in &campaign.guard {
19015                push_line(
19016                    formatted,
19017                    format!(
19018                        "  guard {} within {} percent",
19019                        guard.gauge.name, guard.band_percent
19020                    ),
19021                );
19022            }
19023            if !campaign.sacrifice.is_empty() {
19024                let names = campaign
19025                    .sacrifice
19026                    .iter()
19027                    .map(|gauge| gauge.name.as_str())
19028                    .collect::<Vec<_>>()
19029                    .join(", ");
19030                push_line(formatted, format!("  sacrifice {names}"));
19031            }
19032            if campaign.proposer_redacted {
19033                push_line(formatted, "  proposer redacted");
19034            }
19035            push_line(formatted, "}");
19036        }
19037        Item::Channel(channel) => {
19038            push_line(formatted, format!("channel {} {{", channel.name.name));
19039            push_line(formatted, format!("  provider {}", channel.provider.name));
19040            if let Some(workspace) = &channel.workspace {
19041                push_line(formatted, format!("  workspace {}", workspace.name));
19042            }
19043            if let Some(destination) = &channel.destination {
19044                push_line(formatted, format!("  destination {:?}", destination.value));
19045            }
19046            push_line(formatted, "}");
19047        }
19048        Item::FileStore(file_store) => {
19049            push_line(formatted, format!("file store {} {{", file_store.name.name));
19050            push_line(formatted, format!("  root {:?}", file_store.root));
19051            let format_globs = |formatted: &mut String, direction: &str, globs: &[String]| {
19052                if !globs.is_empty() {
19053                    let rendered = globs
19054                        .iter()
19055                        .map(|glob| format!("{glob:?}"))
19056                        .collect::<Vec<_>>()
19057                        .join(", ");
19058                    push_line(formatted, format!("  allow {direction} [{rendered}]"));
19059                }
19060            };
19061            format_globs(formatted, "read", &file_store.read_globs);
19062            format_globs(formatted, "write", &file_store.write_globs);
19063            if let Some(provider) = &file_store.provider {
19064                push_line(formatted, format!("  provider {}", provider.name));
19065            }
19066            push_line(formatted, "}");
19067        }
19068        Item::MemoryPool(pool) => {
19069            push_line(formatted, format!("memory pool {} {{", pool.name.name));
19070            if let Some(limit) = pool.context_limit {
19071                push_line(formatted, format!("  context limit {limit}"));
19072            }
19073            push_line(formatted, "}");
19074        }
19075        Item::Action(action) => {
19076            let params = action
19077                .params
19078                .iter()
19079                .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
19080                .collect::<Vec<_>>()
19081                .join(", ");
19082            push_line(
19083                formatted,
19084                format!("action {}({params}) {{", action.name.name),
19085            );
19086            for line in action.body.text.lines() {
19087                if line.trim().is_empty() {
19088                    push_line(formatted, "");
19089                } else {
19090                    push_line(formatted, line.trim_end());
19091                }
19092            }
19093            push_line(formatted, "}");
19094        }
19095        Item::Pattern(pattern) => format_pattern(pattern, formatted),
19096        Item::Apply(apply) => format_apply(apply, formatted),
19097        Item::WorkflowContract(contract) => {
19098            push_line(
19099                formatted,
19100                format!(
19101                    "{} {} {}",
19102                    contract.kind.as_str(),
19103                    contract.name.name,
19104                    contract.ty.to_source()
19105                ),
19106            );
19107        }
19108        Item::Harness(harness) => format_harness(harness, formatted),
19109        Item::Agent(agent) => format_agent(agent, formatted),
19110        Item::Enum(enum_decl) => format_enum(enum_decl, formatted),
19111        Item::Event(event) => format_event(event, formatted),
19112        Item::Source(source) => format_source(*source, formatted),
19113        Item::Test(test) => format_test(test, formatted),
19114        Item::Lease(lease) => {
19115            push_line(formatted, format!("lease {} {{", lease.name.name));
19116            if lease.shared {
19117                push_line(formatted, "  shared");
19118            }
19119            push_line(formatted, format!("  key {}", lease.key_type.name));
19120            push_line(formatted, format!("  slots {}", lease.slots));
19121            push_line(formatted, format!("  ttl {}s", lease.ttl_seconds));
19122            push_line(formatted, "}");
19123        }
19124        Item::Ledger(ledger) => {
19125            push_line(formatted, format!("ledger {} {{", ledger.name.name));
19126            if ledger.shared {
19127                push_line(formatted, "  shared");
19128            }
19129            push_line(formatted, format!("  entry {}", ledger.entry_schema.name));
19130            push_line(
19131                formatted,
19132                format!("  partition by {}", ledger.partition_field.name),
19133            );
19134            push_line(formatted, format!("  retain {}s", ledger.retain_seconds));
19135            push_line(formatted, "}");
19136        }
19137        Item::Counter(counter) => {
19138            push_line(formatted, format!("counter {} {{", counter.name.name));
19139            if counter.shared {
19140                push_line(formatted, "  shared");
19141            }
19142            push_line(formatted, format!("  key {}", counter.key_type.name));
19143            push_line(formatted, format!("  cap {}", counter.cap));
19144            push_line(formatted, format!("  reset {}", counter.reset));
19145            push_line(formatted, "}");
19146        }
19147        Item::Class(class_decl) => format_class(class_decl, formatted),
19148        Item::Table(table) => format_table(table, formatted),
19149        Item::Coerce(coerce) => format_coerce(coerce, formatted),
19150        Item::Assert(assertion) => {
19151            format_tags(&assertion.tags, formatted);
19152            format_description(assertion.description.as_ref(), formatted);
19153            push_line(formatted, format!("assert {}", assertion.expr));
19154        }
19155        Item::Rule(rule) => format_rule(rule, formatted),
19156    }
19157}
19158
19159fn format_tags(tags: &[TagDecl], formatted: &mut String) {
19160    for tag in tags {
19161        push_line(formatted, format!("@{}", tag.name));
19162    }
19163}
19164
19165fn format_description(description: Option<&StringLiteral>, formatted: &mut String) {
19166    if let Some(description) = description {
19167        push_line(formatted, format!("description {:?}", description.value));
19168    }
19169}
19170
19171fn format_pattern(pattern: PatternDecl, formatted: &mut String) {
19172    let params = if pattern.type_params.is_empty() {
19173        String::new()
19174    } else {
19175        format!(
19176            "<{}>",
19177            pattern
19178                .type_params
19179                .iter()
19180                .map(|param| param.name.as_str())
19181                .collect::<Vec<_>>()
19182                .join(", ")
19183        )
19184    };
19185    push_line(
19186        formatted,
19187        format!("pattern {}{} {{", pattern.name.name, params),
19188    );
19189    let mut inner = String::new();
19190    format_items(pattern.items, &mut inner);
19191    for line in inner.lines() {
19192        if line.is_empty() {
19193            formatted.push('\n');
19194        } else {
19195            push_line(formatted, format!("  {line}"));
19196        }
19197    }
19198    push_line(formatted, "}");
19199}
19200
19201fn format_apply(apply: ApplyDecl, formatted: &mut String) {
19202    let args = if apply.type_args.is_empty() {
19203        String::new()
19204    } else {
19205        format!(
19206            "<{}>",
19207            apply
19208                .type_args
19209                .iter()
19210                .map(TypeSyntax::to_source)
19211                .collect::<Vec<_>>()
19212                .join(", ")
19213        )
19214    };
19215    push_line(
19216        formatted,
19217        format!(
19218            "apply {}{} as {} {{",
19219            apply.pattern.name, args, apply.alias.name
19220        ),
19221    );
19222    format_block_body(&apply.body.text, formatted);
19223    push_line(formatted, "}");
19224}
19225
19226fn format_workflow(workflow: WorkflowDecl, formatted: &mut String) {
19227    format_tags(&workflow.tags, formatted);
19228    format_description(workflow.description.as_ref(), formatted);
19229    push_line(formatted, format!("workflow {} {{", workflow.name.name));
19230    let mut inner = String::new();
19231    format_items(workflow.items, &mut inner);
19232    for line in inner.lines() {
19233        if line.is_empty() {
19234            formatted.push('\n');
19235        } else {
19236            push_line(formatted, format!("  {line}"));
19237        }
19238    }
19239    push_line(formatted, "}");
19240}
19241
19242fn format_harness(harness: HarnessDecl, formatted: &mut String) {
19243    push_line(
19244        formatted,
19245        format!("harness {}: {}", harness.name.name, harness.kind.name),
19246    );
19247}
19248
19249fn format_agent(agent: AgentDecl, formatted: &mut String) {
19250    let harness = agent
19251        .harness
19252        .as_ref()
19253        .map(|harness| format!(" using {}", harness.name))
19254        .or_else(|| {
19255            agent
19256                .delegated_to
19257                .as_ref()
19258                .map(|delegate| format!(" delegated to {}", delegate.name))
19259        })
19260        .unwrap_or_default();
19261    push_line(
19262        formatted,
19263        format!("agent {}{} {{", agent.name.name, harness),
19264    );
19265    for field in agent.fields {
19266        match field {
19267            AgentField::Provider(provider) => {
19268                push_line(formatted, format!("  provider {}", provider.name));
19269            }
19270            AgentField::Profile(profile) => {
19271                push_line(formatted, format!("  profile {:?}", profile.value));
19272            }
19273            AgentField::Capacity(capacity, _) => {
19274                push_line(formatted, format!("  capacity {capacity}"));
19275            }
19276            AgentField::Skills(skills, _) => {
19277                let skills = skills
19278                    .into_iter()
19279                    .map(|skill| format!("{:?}", skill.value))
19280                    .collect::<Vec<_>>()
19281                    .join(", ");
19282                push_line(formatted, format!("  skills [{skills}]"));
19283            }
19284            AgentField::Capabilities(capabilities, _) => {
19285                let capabilities = capabilities
19286                    .into_iter()
19287                    .map(|capability| format!("{:?}", capability.value))
19288                    .collect::<Vec<_>>()
19289                    .join(", ");
19290                push_line(formatted, format!("  capabilities [{capabilities}]"));
19291            }
19292            AgentField::Requires(classes, _) => {
19293                let classes = classes
19294                    .into_iter()
19295                    .map(|class| class.name)
19296                    .collect::<Vec<_>>()
19297                    .join(", ");
19298                push_line(formatted, format!("  requires [{classes}]"));
19299            }
19300            AgentField::Tools(tools, _) => {
19301                let tools = tools
19302                    .into_iter()
19303                    .map(|tool| tool.name)
19304                    .collect::<Vec<_>>()
19305                    .join(", ");
19306                push_line(formatted, format!("  tools [{tools}]"));
19307            }
19308            AgentField::Compaction(strategy) => {
19309                push_line(formatted, format!("  compaction {}", strategy.name));
19310            }
19311            AgentField::Thread(mode) => {
19312                push_line(formatted, format!("  thread {}", mode.name));
19313            }
19314            AgentField::Settings(sources) => {
19315                push_line(formatted, format!("  settings {}", sources.name));
19316            }
19317            AgentField::Unknown { name, .. } => {
19318                push_line(formatted, format!("  {}", name.name));
19319            }
19320        }
19321    }
19322    push_line(formatted, "}");
19323}
19324
19325fn format_enum(enum_decl: EnumDecl, formatted: &mut String) {
19326    push_line(formatted, format!("enum {} {{", enum_decl.name.name));
19327    for variant in enum_decl.variants {
19328        if variant.fields.is_empty() {
19329            push_line(formatted, format!("  {}", variant.name.name));
19330            continue;
19331        }
19332        push_line(formatted, format!("  {} {{", variant.name.name));
19333        for field in variant.fields {
19334            push_line(
19335                formatted,
19336                format!("    {} {}", field.name.name, field.ty.to_source()),
19337            );
19338        }
19339        push_line(formatted, "  }");
19340    }
19341    push_line(formatted, "}");
19342}
19343
19344fn format_time_of_day(time: TimeOfDay) -> String {
19345    format!("{:02}:{:02}", time.hour, time.minute)
19346}
19347
19348fn format_weekday(day: Weekday) -> &'static str {
19349    match day {
19350        Weekday::Monday => "monday",
19351        Weekday::Tuesday => "tuesday",
19352        Weekday::Wednesday => "wednesday",
19353        Weekday::Thursday => "thursday",
19354        Weekday::Friday => "friday",
19355        Weekday::Saturday => "saturday",
19356        Weekday::Sunday => "sunday",
19357    }
19358}
19359
19360fn format_recurrence(recurrence: &Recurrence) -> String {
19361    match recurrence {
19362        Recurrence::At { time, .. } => format!("at {}", format_time_of_day(*time)),
19363        Recurrence::EveryDuration { source, .. } => format!("every {source}"),
19364        Recurrence::EveryCalendar { pattern, time, .. } => {
19365            let pattern = match pattern {
19366                CalendarPattern::Day => "day".to_owned(),
19367                CalendarPattern::Weekday => "weekday".to_owned(),
19368                CalendarPattern::Weekly(day) => format_weekday(*day).to_owned(),
19369            };
19370            format!("every {pattern} at {}", format_time_of_day(*time))
19371        }
19372    }
19373}
19374
19375fn format_source_value(value: &SourceValue) -> String {
19376    match value {
19377        SourceValue::Path {
19378            binding, segments, ..
19379        } => {
19380            let mut text = binding.name.clone();
19381            for segment in segments {
19382                text.push('.');
19383                text.push_str(&segment.name);
19384            }
19385            text
19386        }
19387        SourceValue::String(literal) => format!("{:?}", literal.value),
19388        SourceValue::Number(number, _) => number.clone(),
19389    }
19390}
19391
19392fn format_test_fields(fields: &[TestField], formatted: &mut String) {
19393    for field in fields {
19394        push_line(
19395            formatted,
19396            format!("    {} {}", field.name.name, field.value),
19397        );
19398    }
19399}
19400
19401fn format_test(test: TestDecl, formatted: &mut String) {
19402    push_line(formatted, format!("test {:?} {{", test.name.value));
19403    if let Some(workflow) = &test.workflow {
19404        push_line(formatted, format!("  workflow {}", workflow.name));
19405    }
19406    for clause in &test.clauses {
19407        match clause {
19408            TestClause::Given(given) => match given {
19409                GivenClause::Input { fields, .. } => {
19410                    push_line(formatted, "  given input {");
19411                    format_test_fields(fields, formatted);
19412                    push_line(formatted, "  }");
19413                }
19414                GivenClause::Fact { ty, fields, .. } => {
19415                    push_line(formatted, format!("  given fact {} {{", ty.name));
19416                    format_test_fields(fields, formatted);
19417                    push_line(formatted, "  }");
19418                }
19419                GivenClause::Signal { name, fields, .. } => {
19420                    push_line(formatted, format!("  given signal {name} {{"));
19421                    format_test_fields(fields, formatted);
19422                    push_line(formatted, "  }");
19423                }
19424                GivenClause::Clock { at, .. } => {
19425                    push_line(formatted, format!("  given clock at {:?}", at.value));
19426                }
19427                GivenClause::Tracker {
19428                    tracker, fields, ..
19429                } => {
19430                    push_line(formatted, format!("  given tracker {tracker} issue {{"));
19431                    format_test_fields(fields, formatted);
19432                    push_line(formatted, "  }");
19433                }
19434                GivenClause::File {
19435                    store,
19436                    path,
19437                    content,
19438                    ..
19439                } => {
19440                    push_line(
19441                        formatted,
19442                        format!(
19443                            "  given file {store} at {:?} {:?}",
19444                            path.value, content.value
19445                        ),
19446                    );
19447                }
19448            },
19449            TestClause::Stub(stub) => {
19450                let surface = stub.surface.join(" ");
19451                match &stub.payload {
19452                    Some(StubPayload::Message(message)) => push_line(
19453                        formatted,
19454                        format!("  stub {surface} {} {:?}", stub.outcome, message.value),
19455                    ),
19456                    Some(StubPayload::Record(fields)) => {
19457                        push_line(formatted, format!("  stub {surface} {} {{", stub.outcome));
19458                        format_test_fields(fields, formatted);
19459                        push_line(formatted, "  }");
19460                    }
19461                    None => push_line(formatted, format!("  stub {surface} {}", stub.outcome)),
19462                }
19463            }
19464            TestClause::Run(run) => {
19465                let text = match &run.kind {
19466                    RunKind::UntilIdle => "run until idle".to_owned(),
19467                    RunKind::UntilWorkflowCompleted => "run until workflow completed".to_owned(),
19468                    RunKind::UntilWorkflowFailed => "run until workflow failed".to_owned(),
19469                    RunKind::ForSteps(steps) => format!("run for {steps} steps"),
19470                };
19471                push_line(formatted, format!("  {text}"));
19472            }
19473            TestClause::Expect(expect) => {
19474                push_line(
19475                    formatted,
19476                    format!("  {}", format_expect_target(&expect.target)),
19477                );
19478            }
19479        }
19480    }
19481    push_line(formatted, "}");
19482}
19483
19484fn format_expect_target(target: &ExpectTarget) -> String {
19485    match target {
19486        ExpectTarget::WorkflowCompleted => "expect workflow completed".to_owned(),
19487        ExpectTarget::WorkflowFailed { failure: None } => "expect workflow failed".to_owned(),
19488        ExpectTarget::WorkflowFailed {
19489            failure: Some(failure),
19490        } => format!("expect workflow failed with {}", failure.name),
19491        ExpectTarget::Rule { name, status } => {
19492            let status = match status {
19493                RuleStatus::Fired => "fired".to_owned(),
19494                RuleStatus::FiredTimes(count) => format!("fired {count} times"),
19495                RuleStatus::DidNotFire => "did not fire".to_owned(),
19496            };
19497            format!("expect rule {} {status}", name.name)
19498        }
19499        ExpectTarget::Effect { name, status } => {
19500            let status = match status {
19501                EffectStatus::Requested => "requested",
19502                EffectStatus::Completed => "completed",
19503                EffectStatus::Failed => "failed",
19504            };
19505            format!("expect effect {name} {status}")
19506        }
19507        ExpectTarget::Diagnostic { code } => format!("expect diagnostic {code}"),
19508        ExpectTarget::NoEffect { name } => format!("expect no {name}"),
19509        ExpectTarget::Projection(query) => format!("expect {}", format_proj_query(query)),
19510    }
19511}
19512
19513fn format_proj_query(query: &ProjQuery) -> String {
19514    match &query.kind {
19515        ProjQueryKind::Exists => format!("{} exists", query.noun),
19516        ProjQueryKind::Count { predicate, count } => {
19517            format!("{} count where {predicate} is {count}", query.noun)
19518        }
19519        ProjQueryKind::Where { predicate } => {
19520            format!("{} where {predicate}", query.noun)
19521        }
19522    }
19523}
19524
19525fn format_source(source: SourceDecl, formatted: &mut String) {
19526    push_line(
19527        formatted,
19528        format!("source {} as {} {{", source.provider.name, source.name.name),
19529    );
19530    if let Some(clock) = &source.clock {
19531        push_line(
19532            formatted,
19533            format!("  {}", format_recurrence(&clock.recurrence)),
19534        );
19535        if let Some(timezone) = &clock.timezone {
19536            push_line(formatted, format!("  timezone {:?}", timezone.value));
19537        }
19538        match clock.missed {
19539            Some(MissedPolicy::Skip) => push_line(formatted, "  missed skip"),
19540            Some(MissedPolicy::Coalesce) => push_line(formatted, "  missed coalesce"),
19541            Some(MissedPolicy::CatchUp { limit }) => {
19542                push_line(formatted, format!("  missed catch_up limit {limit}"))
19543            }
19544            None => {}
19545        }
19546    }
19547    if let Some(path) = &source.path {
19548        push_line(formatted, format!("  path {:?}", path.value));
19549    }
19550    if let Some(watch) = &source.watch {
19551        push_line(formatted, format!("  watch {:?}", watch.value));
19552    }
19553    if let Some(url) = &source.url {
19554        push_line(formatted, format!("  url {:?}", url.value));
19555    }
19556    if let Some(dedup) = &source.dedup {
19557        push_line(formatted, format!("  dedup {}", format_source_value(dedup)));
19558    }
19559    push_line(
19560        formatted,
19561        format!("  observe as {}", source.observe_binding.name),
19562    );
19563    let from = source
19564        .emit
19565        .from
19566        .as_ref()
19567        .map(|ident| format!(" from {}", ident.name))
19568        .unwrap_or_default();
19569    if source.emit.fields.is_empty() && source.emit.from.is_some() {
19570        push_line(formatted, format!("  emit {}{from}", source.emit.signal));
19571    } else {
19572        push_line(formatted, format!("  emit {}{from} {{", source.emit.signal));
19573        for field in &source.emit.fields {
19574            push_line(
19575                formatted,
19576                format!(
19577                    "    {} {}",
19578                    field.name.name,
19579                    format_source_value(&field.value)
19580                ),
19581            );
19582        }
19583        push_line(formatted, "  }");
19584    }
19585    push_line(formatted, "}");
19586}
19587
19588fn format_event(event: EventDecl, formatted: &mut String) {
19589    push_line(formatted, format!("signal {} {{", event.name));
19590    for field in event.fields {
19591        push_line(
19592            formatted,
19593            format!("  {} {}", field.name.name, field.ty.to_source()),
19594        );
19595    }
19596    push_line(formatted, "}");
19597}
19598
19599fn format_class(class_decl: ClassDecl, formatted: &mut String) {
19600    push_line(formatted, format!("class {} {{", class_decl.name.name));
19601    for field in class_decl.fields {
19602        let key = if field.is_key { " @key" } else { "" };
19603        push_line(
19604            formatted,
19605            format!("  {} {}{key}", field.name.name, field.ty.to_source()),
19606        );
19607    }
19608    push_line(formatted, "}");
19609}
19610
19611fn format_table(table: TableDecl, formatted: &mut String) {
19612    format_tags(&table.tags, formatted);
19613    format_description(table.description.as_ref(), formatted);
19614    push_line(
19615        formatted,
19616        format!("table {} as {} [", table.name.name, table.schema.name),
19617    );
19618    for row in table.rows {
19619        push_line(formatted, "  {");
19620        for line in row.body.text.lines() {
19621            if line.trim().is_empty() {
19622                formatted.push('\n');
19623            } else {
19624                // `trim()` (not `trim_end()`): normalize the field to a fixed
19625                // 4-space indent rather than prepending to the row's existing
19626                // indent, which compounded every pass. Row bodies are flat field
19627                // lists, so a fixed indent is the canonical form.
19628                push_line(formatted, format!("    {}", line.trim()));
19629            }
19630        }
19631        push_line(formatted, "  }");
19632    }
19633    push_line(formatted, "]");
19634}
19635
19636fn format_coerce(coerce: CoerceDecl, formatted: &mut String) {
19637    let params = coerce
19638        .params
19639        .into_iter()
19640        .map(|param| format!("{} {}", param.name.name, param.ty.to_source()))
19641        .collect::<Vec<_>>()
19642        .join(", ");
19643    push_line(
19644        formatted,
19645        format!(
19646            "coerce {}({}) -> {} {{",
19647            coerce.name.name,
19648            params,
19649            coerce.output.to_source()
19650        ),
19651    );
19652    format_block_body(&coerce.body.text, formatted);
19653    push_line(formatted, "}");
19654}
19655
19656fn format_rule(rule: RuleDecl, formatted: &mut String) {
19657    format_tags(&rule.tags, formatted);
19658    format_description(rule.description.as_ref(), formatted);
19659    push_line(formatted, format!("rule {}", rule.name.name));
19660    for when in rule.whens {
19661        push_line(formatted, format!("  when {}", when.text));
19662    }
19663    push_line(formatted, "=> {");
19664    format_block_body(&rule.body.text, formatted);
19665    push_line(formatted, "}");
19666}
19667
19668/// Flat-prepend body emitter used where the output feeds IR construction (e.g.
19669/// a table's synthetic rule body, whose `body_hash` is part of program identity).
19670/// Kept byte-for-byte stable so the lowered IR / snapshots do not move; the
19671/// idempotent re-indenter for human formatting is [`format_block_body`].
19672fn push_block_body(body: &str, formatted: &mut String) {
19673    if body.is_empty() {
19674        return;
19675    }
19676    for line in body.lines() {
19677        if line.trim().is_empty() {
19678            formatted.push('\n');
19679        } else {
19680            push_line(formatted, format!("  {}", line.trim_end()));
19681        }
19682    }
19683}
19684
19685/// Re-indent a rule/apply body to a canonical form derived from brace nesting,
19686/// so `whip fmt` is idempotent. Two concerns make this non-trivial:
19687///   - **Bracket nesting:** code lines are indented by their `{`/`[`/`(` depth
19688///     (string-aware via `scan_braces`), not by a flat prepend that compounds on
19689///     nested `record`/`complete` blocks.
19690///   - **Multi-line `"""..."""` strings:** the content is dedented to its common
19691///     indent and re-indented to the block depth (preserving relative structure).
19692///     This matches the single-pass canonical form AND is stable across passes,
19693///     where the old flat prepend grew the string content every time.
19694fn format_block_body(body: &str, formatted: &mut String) {
19695    if body.trim().is_empty() {
19696        return;
19697    }
19698    let lines: Vec<&str> = body.lines().collect();
19699    let mut index = 0;
19700    let mut depth: i32 = 1;
19701    while index < lines.len() {
19702        let trimmed = lines[index].trim();
19703        if trimmed.is_empty() {
19704            formatted.push('\n');
19705            index += 1;
19706            continue;
19707        }
19708        let opens_with_closer = trimmed
19709            .chars()
19710            .next()
19711            .is_some_and(|ch| matches!(ch, '}' | ']' | ')'));
19712        let line_depth = if opens_with_closer {
19713            (depth - 1).max(0)
19714        } else {
19715            depth
19716        };
19717        let prefix = "  ".repeat(line_depth as usize);
19718        let (delta, opens_triple) = scan_braces(trimmed);
19719        push_line(formatted, format!("{prefix}{trimmed}"));
19720        if opens_triple {
19721            // Collect the string content up to the closing `"""`.
19722            let mut end = index + 1;
19723            while end < lines.len() && lines[end].matches("\"\"\"").count().is_multiple_of(2) {
19724                end += 1;
19725            }
19726            let content = &lines[index + 1..end];
19727            let common = content
19728                .iter()
19729                .filter(|line| !line.trim().is_empty())
19730                .map(|line| line.len() - line.trim_start().len())
19731                .min()
19732                .unwrap_or(0);
19733            for line in content {
19734                if line.trim().is_empty() {
19735                    formatted.push('\n');
19736                } else {
19737                    push_line(formatted, format!("{prefix}{}", &line[common..]));
19738                }
19739            }
19740            if end < lines.len() {
19741                // The closing-delimiter line, re-indented to the block depth.
19742                push_line(formatted, format!("{prefix}{}", lines[end].trim()));
19743            }
19744            index = end + 1;
19745        } else {
19746            index += 1;
19747        }
19748        depth = (depth + delta).max(0);
19749    }
19750}
19751
19752/// Net bracket-depth change for one line, ignoring brackets inside strings.
19753/// Returns `(delta, opens_unclosed_triple)`: a `true` second element means the
19754/// line starts a `"""..."""` that does not close on the same line, so following
19755/// lines are string content. ASCII markers only — UTF-8 string bytes can't
19756/// false-match.
19757fn scan_braces(line: &str) -> (i32, bool) {
19758    let bytes = line.as_bytes();
19759    let mut index = 0;
19760    let mut delta = 0i32;
19761    let mut in_string = false;
19762    while index < bytes.len() {
19763        if in_string {
19764            match bytes[index] {
19765                b'\\' => index += 1,
19766                b'"' => in_string = false,
19767                _ => {}
19768            }
19769            index += 1;
19770            continue;
19771        }
19772        if line[index..].starts_with("\"\"\"") {
19773            match line[index + 3..].find("\"\"\"") {
19774                Some(offset) => index += 3 + offset + 3,
19775                None => return (delta, true),
19776            }
19777            continue;
19778        }
19779        match bytes[index] {
19780            b'"' => in_string = true,
19781            b'{' | b'[' | b'(' => delta += 1,
19782            b'}' | b']' | b')' => delta -= 1,
19783            _ => {}
19784        }
19785        index += 1;
19786    }
19787    (delta, false)
19788}
19789
19790impl TypeSyntax {
19791    fn to_source(&self) -> String {
19792        match self {
19793            Self::Primitive { name, .. } => name.clone(),
19794            Self::LiteralString { value, .. } => format!("{value:?}"),
19795            Self::Ref { name } => name.name.clone(),
19796            Self::AgentRef { agents, .. } => {
19797                let agents = agents
19798                    .iter()
19799                    .map(|agent| agent.name.as_str())
19800                    .collect::<Vec<_>>()
19801                    .join(" | ");
19802                format!("AgentRef<{agents}>")
19803            }
19804            Self::Optional { inner, .. } => format!("{}?", inner.to_source()),
19805            Self::Array { inner, .. } => format!("{}[]", inner.to_source()),
19806            Self::Map { inner, .. } => format!("map<{}>", inner.to_source()),
19807            Self::Union { variants, .. } => variants
19808                .iter()
19809                .map(Self::to_source)
19810                .collect::<Vec<_>>()
19811                .join(" | "),
19812        }
19813    }
19814}
19815
19816/// Stage marker retained for the CLI scaffold.
19817pub fn parser_stage() -> &'static str {
19818    whipplescript_core::IMPLEMENTATION_STAGE
19819}
19820
19821#[derive(Clone, Debug, Eq, PartialEq)]
19822struct Lexed {
19823    tokens: Vec<Token>,
19824    diagnostics: Vec<Diagnostic>,
19825    comments: Vec<Comment>,
19826}
19827
19828#[derive(Clone, Debug, Eq, PartialEq)]
19829struct Token {
19830    kind: TokenKind,
19831    span: SourceSpan,
19832}
19833
19834#[derive(Clone, Debug, Eq, PartialEq)]
19835enum TokenKind {
19836    Ident(String),
19837    String(String),
19838    Number(String),
19839    Arrow,
19840    ThinArrow,
19841    Symbol(char),
19842}
19843
19844impl TokenKind {
19845    fn label(&self) -> String {
19846        match self {
19847            Self::Ident(value) => format!("identifier `{value}`"),
19848            Self::String(_) => "string literal".to_owned(),
19849            Self::Number(_) => "number literal".to_owned(),
19850            Self::Arrow => "`=>`".to_owned(),
19851            Self::ThinArrow => "`->`".to_owned(),
19852            Self::Symbol(value) => format!("`{value}`"),
19853        }
19854    }
19855}
19856
19857fn lex(source: &str) -> Lexed {
19858    let bytes = source.as_bytes();
19859    let mut tokens = Vec::new();
19860    let mut diagnostics = Vec::new();
19861    let mut comments = Vec::new();
19862    let mut index = 0;
19863
19864    while index < bytes.len() {
19865        let byte = bytes[index];
19866        if byte.is_ascii_whitespace() {
19867            index += 1;
19868            continue;
19869        }
19870
19871        if byte == b'#' {
19872            let end = skip_line(bytes, index + 1);
19873            comments.push(Comment {
19874                marker: CommentMarker::Hash,
19875                text: source[index + 1..end].trim().to_owned(),
19876                span: SourceSpan { start: index, end },
19877            });
19878            index = end;
19879            continue;
19880        }
19881
19882        if byte == b'/' && bytes.get(index + 1) == Some(&b'/') {
19883            let end = skip_line(bytes, index + 2);
19884            comments.push(Comment {
19885                marker: CommentMarker::Slash,
19886                text: source[index + 2..end].trim().to_owned(),
19887                span: SourceSpan { start: index, end },
19888            });
19889            index = end;
19890            continue;
19891        }
19892
19893        if is_ident_start(byte) {
19894            let start = index;
19895            index += 1;
19896            while index < bytes.len() && is_ident_continue(bytes[index]) {
19897                index += 1;
19898            }
19899            tokens.push(Token {
19900                kind: TokenKind::Ident(source[start..index].to_owned()),
19901                span: SourceSpan { start, end: index },
19902            });
19903            continue;
19904        }
19905
19906        if byte.is_ascii_digit() {
19907            let start = index;
19908            index += 1;
19909            while index < bytes.len() && bytes[index].is_ascii_digit() {
19910                index += 1;
19911            }
19912            tokens.push(Token {
19913                kind: TokenKind::Number(source[start..index].to_owned()),
19914                span: SourceSpan { start, end: index },
19915            });
19916            continue;
19917        }
19918
19919        if byte == b'"' {
19920            let (token, next, diagnostic) = lex_string(source, index);
19921            tokens.push(token);
19922            if let Some(diagnostic) = diagnostic {
19923                diagnostics.push(diagnostic);
19924            }
19925            index = next;
19926            continue;
19927        }
19928
19929        if byte == b'=' && bytes.get(index + 1) == Some(&b'>') {
19930            tokens.push(Token {
19931                kind: TokenKind::Arrow,
19932                span: SourceSpan {
19933                    start: index,
19934                    end: index + 2,
19935                },
19936            });
19937            index += 2;
19938            continue;
19939        }
19940
19941        if byte == b'=' && bytes.get(index + 1) == Some(&b'=') {
19942            index += 2;
19943            continue;
19944        }
19945
19946        if byte == b'!' && bytes.get(index + 1) == Some(&b'=') {
19947            index += 2;
19948            continue;
19949        }
19950
19951        if matches!(byte, b'<' | b'>') && bytes.get(index + 1) == Some(&b'=') {
19952            index += 2;
19953            continue;
19954        }
19955
19956        if matches!(byte, b'&' | b'|') && bytes.get(index + 1) == Some(&byte) {
19957            index += 2;
19958            continue;
19959        }
19960
19961        if byte == b'-' && bytes.get(index + 1) == Some(&b'>') {
19962            tokens.push(Token {
19963                kind: TokenKind::ThinArrow,
19964                span: SourceSpan {
19965                    start: index,
19966                    end: index + 2,
19967                },
19968            });
19969            index += 2;
19970            continue;
19971        }
19972
19973        // Arithmetic operators appear inside guard and field-value
19974        // expressions, which are re-parsed from raw source slices; the
19975        // file-level lexer only needs to step over them.
19976        if matches!(byte, b'*' | b'/' | b'-') {
19977            index += 1;
19978            continue;
19979        }
19980
19981        if b"{}[]()<>,?|.+!:@".contains(&byte) {
19982            tokens.push(Token {
19983                kind: TokenKind::Symbol(byte as char),
19984                span: SourceSpan {
19985                    start: index,
19986                    end: index + 1,
19987                },
19988            });
19989            index += 1;
19990            continue;
19991        }
19992
19993        diagnostics.push(Diagnostic {
19994            related: Vec::new(),
19995            span: SourceSpan {
19996                start: index,
19997                end: index + 1,
19998            },
19999            message: format!("unexpected character `{}`", byte as char),
20000            suggestion: None,
20001        });
20002        index += 1;
20003    }
20004
20005    Lexed {
20006        tokens,
20007        diagnostics,
20008        comments,
20009    }
20010}
20011
20012/// Extract the comments from a source program, in source order. Comments are not
20013/// part of the token stream or AST; this is the entry point tooling (`whip fmt`,
20014/// the LSP) uses to preserve them.
20015pub fn lex_comments(source: &str) -> Vec<Comment> {
20016    lex(source).comments
20017}
20018
20019/// Byte-span regions of string literals and comments in `source`. A tool that
20020/// edits identifier occurrences (e.g. `whip lsp` rename) consults these to avoid
20021/// touching text inside a prompt string or a comment — only code identifiers are
20022/// real references.
20023pub fn string_and_comment_spans(source: &str) -> Vec<SourceSpan> {
20024    let lexed = lex(source);
20025    let mut spans: Vec<SourceSpan> = lexed
20026        .tokens
20027        .iter()
20028        .filter(|token| matches!(token.kind, TokenKind::String(_)))
20029        .map(|token| token.span)
20030        .collect();
20031    spans.extend(lexed.comments.iter().map(|comment| comment.span));
20032    spans
20033}
20034
20035fn skip_line(bytes: &[u8], mut index: usize) -> usize {
20036    while index < bytes.len() && bytes[index] != b'\n' {
20037        index += 1;
20038    }
20039    index
20040}
20041
20042fn is_ident_start(byte: u8) -> bool {
20043    byte.is_ascii_alphabetic() || byte == b'_'
20044}
20045
20046fn is_ident_continue(byte: u8) -> bool {
20047    is_ident_start(byte) || byte.is_ascii_digit() || byte == b'-'
20048}
20049
20050fn lex_string(source: &str, start: usize) -> (Token, usize, Option<Diagnostic>) {
20051    let bytes = source.as_bytes();
20052    let triple = bytes.get(start..start + 3) == Some(b"\"\"\"");
20053    let content_start = if triple { start + 3 } else { start + 1 };
20054    let mut index = content_start;
20055
20056    while index < bytes.len() {
20057        if triple && bytes.get(index..index + 3) == Some(b"\"\"\"") {
20058            let end = index + 3;
20059            return (
20060                Token {
20061                    kind: TokenKind::String(source[content_start..index].to_owned()),
20062                    span: SourceSpan { start, end },
20063                },
20064                end,
20065                None,
20066            );
20067        }
20068
20069        if !triple && bytes[index] == b'"' {
20070            let end = index + 1;
20071            return (
20072                Token {
20073                    kind: TokenKind::String(source[content_start..index].to_owned()),
20074                    span: SourceSpan { start, end },
20075                },
20076                end,
20077                None,
20078            );
20079        }
20080
20081        if !triple && bytes[index] == b'\\' && index + 1 < bytes.len() {
20082            index += 2;
20083        } else {
20084            index += 1;
20085        }
20086    }
20087
20088    (
20089        Token {
20090            kind: TokenKind::String(source[content_start..].to_owned()),
20091            span: SourceSpan {
20092                start,
20093                end: source.len(),
20094            },
20095        },
20096        source.len(),
20097        Some(Diagnostic {
20098            related: Vec::new(),
20099            span: SourceSpan {
20100                start,
20101                end: source.len(),
20102            },
20103            message: "unterminated string literal".to_owned(),
20104            suggestion: Some("close the string literal".to_owned()),
20105        }),
20106    )
20107}
20108
20109/// The value kind of a `declaration_block` clause (Shape 1, DR-0011 amended
20110/// 2026-07-08 with `Duration`/`Glob`/`Schema`/`Scalar`/`Flag`). The order-free
20111/// analog of `body::SlotKind` for top-level declarations. A `Flag` clause is a
20112/// bare presence clause carrying no value.
20113#[derive(Clone, Copy, Debug)]
20114#[allow(dead_code)]
20115enum ClauseKind {
20116    Identifier,
20117    Expression,
20118    Duration,
20119    Glob,
20120    Schema,
20121    Scalar,
20122    Flag,
20123}
20124
20125/// The typed AST node a migrated declaration lowers to — the one hand-written
20126/// seam of the otherwise data-driven Shape 1 pipeline. `effect_operation`
20127/// lowers to a uniform node, but the seven decls each build a distinct typed
20128/// node, so a future dispatch slice switches on this. Unused in D2.0 beyond
20129/// being carried in the spec.
20130#[derive(Clone, Copy, Debug)]
20131#[allow(dead_code)]
20132enum DeclAstKind {
20133    Tracker,
20134    Channel,
20135    Counter,
20136    Lease,
20137    Ledger,
20138    MemoryPool,
20139    FileStore,
20140}
20141
20142/// One order-free clause of a `declaration_block` construct: a named value
20143/// (`words` = the build-time-split clause-name tokens; single-word names are a
20144/// one-element slice), an optional `connective` consumed before the value
20145/// (`Some("by")` for ledger `partition by`; shares Shape 2's vocabulary plus
20146/// `by`), a value `kind`, whether it is a `[ ... ]` `list`, and the
20147/// `unknown_hint` shown when a sibling clause name is not recognized. The
20148/// order-free analog of `body::EffectSlotSpec`.
20149///
20150/// `required`/`missing_summary` are NOT carried here: required-ness is a
20151/// validation concern, not a parse concern. For the std decls it is enforced
20152/// by the typed-node builder (`item_from_decl_ast`, the hand-written seam,
20153/// which also owns the bespoke domain guidance); the manifest still declares
20154/// `required`/`missing_summary` as the third-party contract validated by the
20155/// CLI manifest validator.
20156#[derive(Clone, Copy, Debug)]
20157struct ClauseSpec {
20158    name: &'static str,
20159    words: &'static [&'static str],
20160    connective: Option<&'static str>,
20161    kind: ClauseKind,
20162    list: bool,
20163    unknown_hint: &'static str,
20164}
20165
20166/// The full grammar of one `declaration_block` construct (Shape 1). `keyword`
20167/// is the full head phrase (`"memory pool"`/`"file store"`); `keyword_words`
20168/// is its whitespace-split tokens (head-word dispatch reads `keyword_words[0]`).
20169/// `ast_kind` is the hand-written builder seam.
20170#[derive(Clone, Copy, Debug)]
20171#[allow(dead_code)]
20172struct DeclarationBlockSpec {
20173    keyword: &'static str,
20174    keyword_words: &'static [&'static str],
20175    ast_kind: DeclAstKind,
20176    clauses: &'static [ClauseSpec],
20177}
20178
20179// The table itself is generated at build time from the grammar-only manifests
20180// (std/grammars/*.json) by build.rs, mirroring `EFFECT_OPERATION_GRAMMAR`: each
20181// declaration_block construct's DR-0011 `grammar` object transcribes into one
20182// `DeclarationBlockSpec` row, so the manifests are the single source of parse
20183// grammar and the table can never drift from them. D2.0 builds the table and
20184// unit-tests it; nothing dispatches through it yet.
20185include!(concat!(env!("OUT_DIR"), "/declaration_block_grammar.rs"));
20186
20187/// The parsed value of one matched `declaration_block` clause. A `Scalar` clause
20188/// is literal-polymorphic: `cap`/`slots`/`context limit` carry a `Number`, while
20189/// file-store `root` and channel `destination` carry `Str` — the grammar marks
20190/// both `scalar` and the per-decl builder casts to the field's width/shape.
20191/// `Missing` records a clause whose name matched but whose value failed to parse,
20192/// so the first-word span is still captured (file-store `root_span` is set on the
20193/// clause keyword regardless of the value's fate).
20194#[derive(Clone, Debug)]
20195enum ClauseValue {
20196    Ident(Ident),
20197    Duration(u64),
20198    Number(u32),
20199    Str(StringLiteral),
20200    Globs(Vec<String>),
20201    Flag,
20202    Missing,
20203}
20204
20205/// The order-free accumulator a generic `parse_declaration_block` fills as it
20206/// reads a decl's brace block, keyed by the spec clause `name`; the per-decl
20207/// typed-node builder reads it by name. Each record also carries the FIRST
20208/// clause-name-word token span (file-store/memory-pool serialize these into the
20209/// AST for `whip fmt`). Last write wins, matching the hand parsers' field
20210/// overwrite. The Shape-1 analog of the ordered field vector Shape 2 builds.
20211struct ClauseBag {
20212    records: Vec<(&'static str, SourceSpan, ClauseValue)>,
20213}
20214
20215impl ClauseBag {
20216    fn new() -> Self {
20217        ClauseBag {
20218            records: Vec::new(),
20219        }
20220    }
20221
20222    fn record(&mut self, name: &'static str, first_word_span: SourceSpan, value: ClauseValue) {
20223        self.records.push((name, first_word_span, value));
20224    }
20225
20226    fn get(&self, name: &str) -> Option<&(&'static str, SourceSpan, ClauseValue)> {
20227        self.records
20228            .iter()
20229            .rev()
20230            .find(|(clause, _, _)| *clause == name)
20231    }
20232
20233    fn ident(&self, name: &str) -> Option<Ident> {
20234        match self.get(name) {
20235            Some((_, _, ClauseValue::Ident(ident))) => Some(ident.clone()),
20236            _ => None,
20237        }
20238    }
20239
20240    fn duration(&self, name: &str) -> Option<u64> {
20241        match self.get(name) {
20242            Some((_, _, ClauseValue::Duration(seconds))) => Some(*seconds),
20243            _ => None,
20244        }
20245    }
20246
20247    fn number(&self, name: &str) -> Option<u32> {
20248        match self.get(name) {
20249            Some((_, _, ClauseValue::Number(value))) => Some(*value),
20250            _ => None,
20251        }
20252    }
20253
20254    fn text(&self, name: &str) -> Option<String> {
20255        self.text_literal(name).map(|literal| literal.value)
20256    }
20257
20258    fn text_literal(&self, name: &str) -> Option<StringLiteral> {
20259        match self.get(name) {
20260            Some((_, _, ClauseValue::Str(literal))) => Some(literal.clone()),
20261            _ => None,
20262        }
20263    }
20264
20265    fn globs(&self, name: &str) -> Vec<String> {
20266        match self.get(name) {
20267            Some((_, _, ClauseValue::Globs(values))) => values.clone(),
20268            _ => Vec::new(),
20269        }
20270    }
20271
20272    fn flag(&self, name: &str) -> bool {
20273        matches!(self.get(name), Some((_, _, ClauseValue::Flag)))
20274    }
20275
20276    fn span(&self, name: &str) -> Option<SourceSpan> {
20277        self.get(name).map(|(_, span, _)| *span)
20278    }
20279}
20280
20281struct Parser<'a> {
20282    source: &'a str,
20283    tokens: Vec<Token>,
20284    pos: usize,
20285    diagnostics: Vec<Diagnostic>,
20286    /// S7 inline contract payloads: classes synthesized from `output result {
20287    /// … }`-style blocks, appended to the item list after the parse loop.
20288    pending_contract_classes: Vec<ClassDecl>,
20289}
20290
20291struct ParsedWorkflow {
20292    decl: WorkflowDecl,
20293    explicit_body: bool,
20294}
20295
20296impl Parser<'_> {
20297    fn parse_program(&mut self) -> Program {
20298        let mut workflow = None;
20299        let mut workflow_tags = Vec::new();
20300        let mut workflow_description = None;
20301        let mut explicit_workflow_body = false;
20302        let mut workflows = Vec::new();
20303        let mut patterns = Vec::new();
20304        let mut items = Vec::new();
20305        let mut pending_tags = Vec::new();
20306        let mut pending_description = None;
20307
20308        while !self.is_at_end() {
20309            if self.at_symbol('@') {
20310                if let Some(tag) = self.parse_tag() {
20311                    pending_tags.push(tag);
20312                }
20313            } else if self.at_ident("description") {
20314                self.parse_pending_description(&mut pending_description);
20315            } else if self.at_ident("workflow") {
20316                if let Some(parsed_workflow) = self.parse_workflow(
20317                    std::mem::take(&mut pending_tags),
20318                    pending_description.take(),
20319                ) {
20320                    if parsed_workflow.explicit_body {
20321                        workflows.push(parsed_workflow.decl);
20322                    } else {
20323                        if workflow.is_some() {
20324                            self.diagnostics.push(Diagnostic { related: Vec::new(),
20325                                span: parsed_workflow.decl.name.span,
20326                                message: "multiple implicit workflow headers are not supported"
20327                                    .to_owned(),
20328                                suggestion: Some(
20329                                    "use explicit `workflow Name { ... }` declarations with `--root`"
20330                                        .to_owned(),
20331                                ),
20332                            });
20333                        }
20334                        workflow_tags = parsed_workflow.decl.tags;
20335                        workflow_description = parsed_workflow.decl.description;
20336                        // A header-form workflow carries no block, so its only
20337                        // items are the compact-signature contracts (if any);
20338                        // those are top-level for a single-workflow program.
20339                        items.extend(parsed_workflow.decl.items);
20340                        workflow = Some(parsed_workflow.decl.name);
20341                        explicit_workflow_body = false;
20342                    }
20343                }
20344            } else if self.at_ident("pattern") {
20345                self.reject_pending_tags(&mut pending_tags, "pattern");
20346                self.reject_pending_description(&mut pending_description, "pattern");
20347                if let Some(pattern) = self.parse_pattern() {
20348                    patterns.push(pattern);
20349                }
20350            } else if let Some(item) =
20351                self.parse_declaration_item(&mut pending_tags, &mut pending_description)
20352            {
20353                items.push(item);
20354            } else if self.reject_gherkin_misuse() {
20355                continue;
20356            } else {
20357                if self.is_at_end() {
20358                    break;
20359                }
20360                self.unexpected("top-level declaration");
20361                if !self.is_at_end() {
20362                    self.advance();
20363                }
20364            }
20365        }
20366
20367        // S7: classes synthesized from inline contract payloads join the item
20368        // list like ordinary declarations.
20369        items.extend(
20370            std::mem::take(&mut self.pending_contract_classes)
20371                .into_iter()
20372                .map(Item::Class),
20373        );
20374
20375        Program {
20376            workflow,
20377            workflow_tags,
20378            workflow_description,
20379            explicit_workflow_body,
20380            workflows,
20381            patterns,
20382            items,
20383        }
20384    }
20385
20386    fn parse_workflow(
20387        &mut self,
20388        tags: Vec<TagDecl>,
20389        description: Option<StringLiteral>,
20390    ) -> Option<ParsedWorkflow> {
20391        let start = self.expect_keyword("workflow")?.span.start;
20392        let name = self.expect_ident("workflow name")?;
20393        let mut explicit_body = false;
20394        let mut items = Vec::new();
20395        let mut end = name.span.end;
20396        // Optional compact contract signature: `Name(in: T, ...) -> Out [! Fail]`.
20397        // Desugars to the same `input`/`output`/`failure` contract decls as the
20398        // keyword form, with the output named `result` and the failure `error`
20399        // (the conventional names). Both forms are legal; `whip fmt` re-emits the
20400        // keyword lines (one canonical stored shape).
20401        if self.at_symbol('(') {
20402            if let Some((contracts, signature_end)) = self.parse_compact_contract_signature() {
20403                end = signature_end;
20404                items.extend(contracts.into_iter().map(Item::WorkflowContract));
20405            }
20406        }
20407        if self.at_symbol('{') {
20408            explicit_body = true;
20409            self.expect_symbol('{')?;
20410            let mut pending_tags = Vec::new();
20411            let mut pending_description = None;
20412            while !self.is_at_end() && !self.at_symbol('}') {
20413                if self.at_symbol('@') {
20414                    if let Some(tag) = self.parse_tag() {
20415                        pending_tags.push(tag);
20416                    }
20417                    continue;
20418                }
20419                if self.at_ident("description") {
20420                    self.parse_pending_description(&mut pending_description);
20421                    continue;
20422                }
20423                if self.at_ident("workflow") || self.at_ident("pattern") {
20424                    self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
20425                    self.reject_pending_description(
20426                        &mut pending_description,
20427                        "workflow body declaration",
20428                    );
20429                    self.unexpected("workflow body declaration");
20430                    self.advance();
20431                    continue;
20432                }
20433                if let Some(item) =
20434                    self.parse_declaration_item(&mut pending_tags, &mut pending_description)
20435                {
20436                    items.push(item);
20437                } else if self.reject_gherkin_misuse() {
20438                    continue;
20439                } else {
20440                    if self.is_at_end() {
20441                        break;
20442                    }
20443                    self.reject_pending_tags(&mut pending_tags, "workflow body declaration");
20444                    self.reject_pending_description(
20445                        &mut pending_description,
20446                        "workflow body declaration",
20447                    );
20448                    self.unexpected("workflow body declaration");
20449                    if !self.is_at_end() {
20450                        self.advance();
20451                    }
20452                }
20453            }
20454            if let Some(close) = self.expect_symbol('}') {
20455                end = close.span.end;
20456            }
20457        }
20458        // S7: classes synthesized from this workflow's inline contract payloads
20459        // stay in ITS scope (braced-workflow schemas are workflow-scoped), so
20460        // two workflows can both write `output result { … }` without their
20461        // `output.result` classes colliding.
20462        items.extend(
20463            std::mem::take(&mut self.pending_contract_classes)
20464                .into_iter()
20465                .map(Item::Class),
20466        );
20467        Some(ParsedWorkflow {
20468            decl: WorkflowDecl {
20469                name,
20470                tags,
20471                description,
20472                items,
20473                span: SourceSpan { start, end },
20474            },
20475            explicit_body,
20476        })
20477    }
20478
20479    /// Parses a compact contract signature `(name: Type, ...) -> Output [! Failure]`
20480    /// into the same contract decls the keyword form produces. The output binding
20481    /// is named `result` and the failure `error` — the conventional names used by
20482    /// `complete result` / `fail error`. Returns the contracts and the signature's
20483    /// end offset (so the workflow span covers it).
20484    fn parse_compact_contract_signature(&mut self) -> Option<(Vec<WorkflowContractDecl>, usize)> {
20485        self.expect_symbol('(')?;
20486        let mut contracts = Vec::new();
20487        while !self.is_at_end() && !self.at_symbol(')') {
20488            let name = self.expect_ident("workflow input name")?;
20489            self.expect_symbol(':')?;
20490            let ty = self.parse_type()?;
20491            let span = name.span.join(ty.span());
20492            contracts.push(WorkflowContractDecl {
20493                kind: WorkflowContractKind::Input,
20494                name,
20495                ty,
20496                span,
20497            });
20498            if self.at_symbol(',') {
20499                self.advance();
20500            } else if !self.at_symbol(')') {
20501                self.unexpected("`,` or `)`");
20502                while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
20503                    self.advance();
20504                }
20505            }
20506        }
20507        self.expect_symbol(')')?;
20508        self.expect_thin_arrow()?;
20509        let output_ty = self.parse_type()?;
20510        let output_span = output_ty.span();
20511        let mut end = output_span.end;
20512        contracts.push(WorkflowContractDecl {
20513            kind: WorkflowContractKind::Output,
20514            name: Ident {
20515                name: "result".to_owned(),
20516                span: output_span,
20517            },
20518            ty: output_ty,
20519            span: output_span,
20520        });
20521        if self.at_symbol('!') {
20522            self.advance();
20523            let failure_ty = self.parse_type()?;
20524            let failure_span = failure_ty.span();
20525            end = failure_span.end;
20526            contracts.push(WorkflowContractDecl {
20527                kind: WorkflowContractKind::Failure,
20528                name: Ident {
20529                    name: "error".to_owned(),
20530                    span: failure_span,
20531                },
20532                ty: failure_ty,
20533                span: failure_span,
20534            });
20535        }
20536        Some((contracts, end))
20537    }
20538
20539    fn parse_tag(&mut self) -> Option<TagDecl> {
20540        let at = self.expect_symbol('@')?;
20541        let name_start = at.span.end;
20542        let mut name_end = name_start;
20543        for (offset, ch) in self.source[name_start..].char_indices() {
20544            if ch.is_whitespace() {
20545                break;
20546            }
20547            name_end = name_start + offset + ch.len_utf8();
20548        }
20549        let name = self.source[name_start..name_end].to_owned();
20550        while !self.is_at_end() && self.peek().is_some_and(|token| token.span.start < name_end) {
20551            self.advance();
20552        }
20553        let span = SourceSpan {
20554            start: at.span.start,
20555            end: name_end,
20556        };
20557        if name.is_empty() {
20558            self.diagnostics.push(Diagnostic {
20559                related: Vec::new(),
20560                span,
20561                message: "tag is missing a name".to_owned(),
20562                suggestion: Some("write a tag such as `@fixture`".to_owned()),
20563            });
20564            return None;
20565        }
20566        if !name
20567            .chars()
20568            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | '.'))
20569        {
20570            self.diagnostics.push(Diagnostic {
20571                related: Vec::new(),
20572                span,
20573                message: format!("tag `@{name}` contains unsupported characters"),
20574                suggestion: Some(
20575                    "use letters, digits, `_`, `-`, `.`, or `:` in tag names".to_owned(),
20576                ),
20577            });
20578            return None;
20579        }
20580        Some(TagDecl { name, span })
20581    }
20582
20583    fn reject_pending_tags(&mut self, pending_tags: &mut Vec<TagDecl>, target: &str) {
20584        for tag in pending_tags.drain(..) {
20585            self.diagnostics.push(Diagnostic {
20586                related: Vec::new(),
20587                span: tag.span,
20588                message: format!("tag `@{}` cannot be attached to {target}", tag.name),
20589                suggestion: Some(
20590                    "place tags on workflows, matrices, assertions, or rules".to_owned(),
20591                ),
20592            });
20593        }
20594    }
20595
20596    fn parse_pending_description(&mut self, pending_description: &mut Option<StringLiteral>) {
20597        let Some(description) = self.parse_description() else {
20598            return;
20599        };
20600        if let Some(previous) = pending_description.replace(description) {
20601            self.diagnostics.push(Diagnostic { related: Vec::new(),
20602                span: previous.span,
20603                message: "description is not attached to a declaration".to_owned(),
20604                suggestion: Some(
20605                    "place only one `description \"...\"` immediately before the target declaration"
20606                        .to_owned(),
20607                ),
20608            });
20609        }
20610    }
20611
20612    fn parse_description(&mut self) -> Option<StringLiteral> {
20613        let description = self.expect_keyword("description")?;
20614        let Some(value) = self.expect_string("description string") else {
20615            return Some(StringLiteral {
20616                value: String::new(),
20617                span: description.span,
20618            });
20619        };
20620        Some(value)
20621    }
20622
20623    fn reject_pending_description(
20624        &mut self,
20625        pending_description: &mut Option<StringLiteral>,
20626        target: &str,
20627    ) {
20628        if let Some(description) = pending_description.take() {
20629            self.diagnostics.push(Diagnostic {
20630                related: Vec::new(),
20631                span: description.span,
20632                message: format!("description cannot be attached to {target}"),
20633                suggestion: Some(
20634                    "place descriptions on workflows, matrices, assertions, or rules".to_owned(),
20635                ),
20636            });
20637        }
20638    }
20639
20640    fn reject_gherkin_misuse(&mut self) -> bool {
20641        let Some(token) = self.peek() else {
20642            return false;
20643        };
20644        let TokenKind::Ident(keyword) = &token.kind else {
20645            return false;
20646        };
20647        if !is_gherkin_keyword(keyword) {
20648            return false;
20649        }
20650        let span = token.span;
20651        self.diagnostics.push(Diagnostic { related: Vec::new(),
20652            span,
20653            message: format!(
20654                "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
20655            ),
20656            suggestion: Some(
20657                "use `workflow`, `table`, `rule ... when ... => { ... }`, and `assert` instead of free-text Given/When/Then steps"
20658                    .to_owned(),
20659            ),
20660        });
20661        self.advance_to_line_end(span.start);
20662        true
20663    }
20664
20665    fn advance_to_line_end(&mut self, line_start: usize) {
20666        let line_end = self.source[line_start..]
20667            .find('\n')
20668            .map(|offset| line_start + offset)
20669            .unwrap_or(self.source.len());
20670        while self.peek().is_some_and(|token| token.span.start < line_end) {
20671            self.advance();
20672        }
20673    }
20674
20675    fn parse_declaration_item(
20676        &mut self,
20677        pending_tags: &mut Vec<TagDecl>,
20678        pending_description: &mut Option<StringLiteral>,
20679    ) -> Option<Item> {
20680        // Data-driven `declaration_block` dispatch (Shape 1), the first check —
20681        // the analog of `body.rs`'s `effect_operation_spec` hook. Head-word peek;
20682        // the five real exceptions (harness/agent/signal/source/coerce) and all
20683        // core decls are absent from the grammar table, so table membership is
20684        // the partition. Every declaration-family construct parses through
20685        // `parse_declaration_block` + its typed-node builder — no hand parsers.
20686        if let Some(spec) = self.declaration_block_spec_at() {
20687            self.reject_pending_tags(pending_tags, spec.keyword);
20688            self.reject_pending_description(pending_description, spec.keyword);
20689            return self.parse_declaration_block(spec);
20690        }
20691        if self.at_ident("include") {
20692            self.reject_pending_tags(pending_tags, "include");
20693            self.reject_pending_description(pending_description, "include");
20694            self.parse_include().map(Item::Include)
20695        } else if self.at_ident("use") {
20696            self.reject_pending_tags(pending_tags, "use");
20697            self.reject_pending_description(pending_description, "use");
20698            self.parse_use().map(Item::Use)
20699        } else if self.at_ident("pattern") {
20700            self.reject_pending_tags(pending_tags, "pattern");
20701            self.reject_pending_description(pending_description, "pattern");
20702            self.parse_pattern().map(Item::Pattern)
20703        } else if self.at_ident("apply") {
20704            self.reject_pending_tags(pending_tags, "apply");
20705            self.reject_pending_description(pending_description, "apply");
20706            self.parse_apply().map(Item::Apply)
20707        } else if self.at_ident("input") || self.at_ident("output") || self.at_ident("failure") {
20708            self.reject_pending_tags(pending_tags, "workflow contract");
20709            self.reject_pending_description(pending_description, "workflow contract");
20710            self.parse_workflow_contract().map(Item::WorkflowContract)
20711        } else if self.at_ident("flow") {
20712            // R2 (language-refinement campaign): the `flow` declaration was
20713            // REMOVED — sequential pipelines are written as a rule chaining
20714            // steps with `then <binding> <- <effect>`.
20715            let span = self
20716                .peek()
20717                .map(|token| token.span)
20718                .unwrap_or(SourceSpan { start: 0, end: 0 });
20719            self.diagnostics.push(Diagnostic {
20720                related: Vec::new(),
20721                span,
20722                message: "the `flow` declaration was removed".to_owned(),
20723                suggestion: Some(
20724                    "write a `rule` and chain sequential steps with `then <binding> <- <effect>`"
20725                        .to_owned(),
20726                ),
20727            });
20728            // Recovery: swallow the whole flow declaration (headers + balanced
20729            // body) so the body's statements don't each re-error as bogus
20730            // top-level declarations.
20731            let mut depth = 0usize;
20732            while !self.is_at_end() {
20733                let token = self.advance();
20734                match &token.kind {
20735                    TokenKind::Symbol('{') => depth += 1,
20736                    TokenKind::Symbol('}') => {
20737                        depth = depth.saturating_sub(1);
20738                        if depth == 0 {
20739                            break;
20740                        }
20741                    }
20742                    _ => {}
20743                }
20744            }
20745            None
20746        } else if self.at_ident("action") {
20747            self.reject_pending_tags(pending_tags, "action");
20748            self.reject_pending_description(pending_description, "action");
20749            self.parse_action().map(Item::Action)
20750        } else if self.at_ident("harness") {
20751            self.reject_pending_tags(pending_tags, "harness");
20752            self.reject_pending_description(pending_description, "harness");
20753            self.parse_harness().map(Item::Harness)
20754        } else if self.at_ident("agent") {
20755            self.reject_pending_tags(pending_tags, "agent");
20756            self.reject_pending_description(pending_description, "agent");
20757            self.parse_agent().map(Item::Agent)
20758        } else if self.at_ident("enum") {
20759            self.reject_pending_tags(pending_tags, "enum");
20760            self.reject_pending_description(pending_description, "enum");
20761            self.parse_enum().map(Item::Enum)
20762        } else if self.at_ident("signal") {
20763            self.reject_pending_tags(pending_tags, "signal");
20764            self.reject_pending_description(pending_description, "signal");
20765            self.parse_event().map(Item::Event)
20766        } else if self.at_ident("gauge") {
20767            self.reject_pending_tags(pending_tags, "gauge");
20768            self.reject_pending_description(pending_description, "gauge");
20769            self.parse_gauge().map(Item::Gauge)
20770        } else if self.at_ident("campaign") {
20771            self.reject_pending_tags(pending_tags, "campaign");
20772            self.reject_pending_description(pending_description, "campaign");
20773            self.parse_campaign().map(Item::Campaign)
20774        } else if self.at_ident("mark") {
20775            self.reject_pending_tags(pending_tags, "mark");
20776            self.reject_pending_description(pending_description, "mark");
20777            self.parse_mark().map(Item::Mark)
20778        } else if self.at_ident("source") {
20779            self.reject_pending_tags(pending_tags, "source");
20780            self.reject_pending_description(pending_description, "source");
20781            self.parse_source()
20782                .map(|source| Item::Source(Box::new(source)))
20783        } else if self.at_ident("test") {
20784            self.reject_pending_tags(pending_tags, "test");
20785            self.reject_pending_description(pending_description, "test");
20786            self.parse_test().map(Item::Test)
20787        } else if self.at_ident("class") {
20788            self.reject_pending_tags(pending_tags, "class");
20789            self.reject_pending_description(pending_description, "class");
20790            self.parse_class().map(Item::Class)
20791        } else if self.at_ident("table") {
20792            self.parse_table(std::mem::take(pending_tags), pending_description.take())
20793                .map(Item::Table)
20794        } else if self.at_ident("coerce") {
20795            self.reject_pending_tags(pending_tags, "coerce");
20796            self.reject_pending_description(pending_description, "coerce");
20797            self.parse_coerce().map(Item::Coerce)
20798        } else if self.at_ident("assert") {
20799            self.parse_assert(std::mem::take(pending_tags), pending_description.take())
20800                .map(Item::Assert)
20801        } else if self.at_ident("rule") {
20802            self.parse_rule(std::mem::take(pending_tags), pending_description.take())
20803                .map(Item::Rule)
20804        } else {
20805            None
20806        }
20807    }
20808
20809    /// Peek (without consuming) the `declaration_block` grammar whose keyword
20810    /// head word matches the current token. Head-word dispatch only
20811    /// (`keyword_words[0]`, NOT a 2-token peek — a 2-token peek mis-routes a
20812    /// malformed `file <x>` to the wrong diagnostic; tail validation belongs in
20813    /// the per-decl parser). The exact analog of `body::effect_operation_spec`.
20814    fn declaration_block_spec_at(&self) -> Option<&'static DeclarationBlockSpec> {
20815        let head = match self.peek().map(|token| &token.kind) {
20816            Some(TokenKind::Ident(value)) => value.as_str(),
20817            _ => return None,
20818        };
20819        DECLARATION_BLOCK_GRAMMAR
20820            .iter()
20821            .find(|spec| spec.keyword_words.first() == Some(&head))
20822    }
20823
20824    /// The single generic top-level `declaration_block` parser (Shape 1) — the
20825    /// analog of `body::parse_effect_operation`. The head word is already matched
20826    /// by `declaration_block_spec_at`; this consumes the keyword (validating any
20827    /// tail word, e.g. `store` after `file`), the name, and an order-free brace
20828    /// block of clauses into a `ClauseBag`, then hands off to the per-decl typed
20829    /// node builder (`item_from_decl_ast`). Unknown clauses emit the spec's
20830    /// `unknown_hint` and resynchronize (file-store precedent).
20831    fn parse_declaration_block(&mut self, spec: &'static DeclarationBlockSpec) -> Option<Item> {
20832        let head = *spec.keyword_words.first()?;
20833        let start = self.expect_keyword(head)?.span.start;
20834        for tail in &spec.keyword_words[1..] {
20835            if !self.consume_ident(tail) {
20836                self.expected(format!("`{tail}` after `{head}`"));
20837                return None;
20838            }
20839        }
20840        let name = self.expect_ident(&format!("{} name", spec.keyword))?;
20841        // Surface-defaults batch (R4 S1/S2): the block is optional. A bare
20842        // declaration (`tracker backlog`) parses with an empty clause bag;
20843        // constructs whose clauses are all optional/defaulted accept it, and
20844        // ones with required clauses report their own missing-field
20845        // diagnostics (better than "expected `{`").
20846        if !self.at_symbol('{') {
20847            let span = SourceSpan {
20848                start,
20849                end: name.span.end,
20850            };
20851            let bag = ClauseBag::new();
20852            return self.item_from_decl_ast(spec.ast_kind, &bag, name, span);
20853        }
20854        self.expect_symbol('{')?;
20855        let field_label = format!("{} field", spec.keyword);
20856        let mut bag = ClauseBag::new();
20857        while !self.is_at_end() && !self.at_symbol('}') {
20858            let Some(field) = self.expect_ident(&field_label) else {
20859                self.synchronize_to_block_item();
20860                continue;
20861            };
20862            // Greedy multi-word clause-name match: `field` is the first word;
20863            // extend with follow words while some clause name is a longer prefix
20864            // match (file-store `allow read`/`allow write`, memory `context limit`).
20865            let first_word_span = field.span;
20866            let mut words = vec![field.name];
20867            let matched = loop {
20868                let depth = words.len();
20869                if let Some(clause) = spec.clauses.iter().find(|clause| {
20870                    clause.words.len() == depth
20871                        && clause
20872                            .words
20873                            .iter()
20874                            .zip(&words)
20875                            .all(|(a, b)| *a == b.as_str())
20876                }) {
20877                    break Some(clause);
20878                }
20879                let extendable = spec.clauses.iter().any(|clause| {
20880                    clause.words.len() > depth
20881                        && clause
20882                            .words
20883                            .iter()
20884                            .zip(&words)
20885                            .all(|(a, b)| *a == b.as_str())
20886                });
20887                if !extendable {
20888                    break None;
20889                }
20890                let Some(next) = self.expect_ident("clause name") else {
20891                    break None;
20892                };
20893                words.push(next.name);
20894            };
20895            let Some(clause) = matched else {
20896                let hint = spec.clauses.first().map(|clause| clause.unknown_hint);
20897                self.diagnostics.push(Diagnostic {
20898                    related: Vec::new(),
20899                    span: first_word_span,
20900                    message: format!("unknown {} field `{}`", spec.keyword, words.join(" ")),
20901                    suggestion: hint.map(str::to_owned),
20902                });
20903                self.synchronize_to_block_item();
20904                continue;
20905            };
20906            // Clause connective (ledger `partition by`): mandatory (M2) — a
20907            // missing connective is a parse error, like a Shape 2 slot connective.
20908            if let Some(connective) = clause.connective {
20909                if !self.consume_ident(connective) {
20910                    self.diagnostics.push(Diagnostic {
20911                        related: Vec::new(),
20912                        span: first_word_span,
20913                        message: format!("expected `{connective}` after `{}`", clause.name),
20914                        suggestion: Some(format!("write `{} {connective} <field>`", clause.name)),
20915                    });
20916                    self.synchronize_to_block_item();
20917                    continue;
20918                }
20919            }
20920            let value = self.parse_clause_value(clause);
20921            bag.record(clause.name, first_word_span, value);
20922        }
20923        let close = self.expect_symbol('}')?;
20924        let span = SourceSpan {
20925            start,
20926            end: close.span.end,
20927        };
20928        self.item_from_decl_ast(spec.ast_kind, &bag, name, span)
20929    }
20930
20931    /// Parse one clause's value by its `ClauseKind`, recording `Missing` on a
20932    /// failed parse so the clause's first-word span is still captured. A `Scalar`
20933    /// is literal-polymorphic (number or string); the builder casts.
20934    fn parse_clause_value(&mut self, clause: &ClauseSpec) -> ClauseValue {
20935        match clause.kind {
20936            ClauseKind::Identifier | ClauseKind::Schema => self
20937                .expect_ident(&format!("{} value", clause.name))
20938                .map_or(ClauseValue::Missing, ClauseValue::Ident),
20939            ClauseKind::Duration => self
20940                .parse_decl_duration_seconds(&format!("{} duration", clause.name))
20941                .map_or(ClauseValue::Missing, ClauseValue::Duration),
20942            ClauseKind::Scalar => match self.peek().map(|token| &token.kind) {
20943                Some(TokenKind::String(_)) => self
20944                    .expect_string(&format!("{} value", clause.name))
20945                    .map_or(ClauseValue::Missing, ClauseValue::Str),
20946                _ => self
20947                    .expect_u32(&format!("{} value", clause.name))
20948                    .map_or(ClauseValue::Missing, |(value, _)| {
20949                        ClauseValue::Number(value)
20950                    }),
20951            },
20952            ClauseKind::Glob if clause.list => {
20953                // Route glob lists through the shared list parser UNCHANGED — it
20954                // keeps its "skill string" element label (avoids file-store
20955                // negative-fixture churn).
20956                let globs = self
20957                    .parse_string_list()
20958                    .map(|(literals, _)| literals.into_iter().map(|l| l.value).collect())
20959                    .unwrap_or_default();
20960                ClauseValue::Globs(globs)
20961            }
20962            ClauseKind::Glob => self
20963                .expect_string(&format!("{} value", clause.name))
20964                .map_or(ClauseValue::Missing, ClauseValue::Str),
20965            ClauseKind::Flag => ClauseValue::Flag,
20966            // No migrated decl carries an expression clause; recorded inert.
20967            ClauseKind::Expression => ClauseValue::Missing,
20968        }
20969    }
20970
20971    /// The one hand-written seam of the data-driven pipeline: build the distinct
20972    /// typed AST node each `declaration_block` lowers to from the order-free
20973    /// `ClauseBag`, reproducing each hand parser's required-field check, bespoke
20974    /// missing-field diagnostic, and field-width casts exactly (so success `.ir`
20975    /// and coord IR fields are byte-identical).
20976    fn item_from_decl_ast(
20977        &mut self,
20978        ast_kind: DeclAstKind,
20979        bag: &ClauseBag,
20980        name: Ident,
20981        span: SourceSpan,
20982    ) -> Option<Item> {
20983        match ast_kind {
20984            DeclAstKind::Tracker => {
20985                // S1: `tracker <name>` bare — provider defaults to `builtin`,
20986                // today's only provider.
20987                let provider = bag.ident("provider").unwrap_or_else(|| Ident {
20988                    name: "builtin".to_owned(),
20989                    span,
20990                });
20991                Some(Item::Tracker(TrackerDecl {
20992                    name,
20993                    provider,
20994                    span,
20995                }))
20996            }
20997            DeclAstKind::Channel => {
20998                // S2: `channel <name>` bare — provider defaults to `local`.
20999                let provider = bag.ident("provider").unwrap_or_else(|| Ident {
21000                    name: "local".to_owned(),
21001                    span,
21002                });
21003                Some(Item::Channel(ChannelDecl {
21004                    name,
21005                    provider,
21006                    workspace: bag.ident("workspace"),
21007                    destination: bag.text_literal("destination"),
21008                    span,
21009                }))
21010            }
21011            DeclAstKind::Counter => {
21012                let key_type = bag.ident("key");
21013                let cap = bag.number("cap").map(i64::from);
21014                // `reset`: identifier + membership check; an invalid period keeps
21015                // the value (matching the hand parser) but emits the enum diagnostic.
21016                let reset = bag.ident("reset").map(|period| {
21017                    if !matches!(
21018                        period.name.as_str(),
21019                        "hourly" | "daily" | "weekly" | "monthly"
21020                    ) {
21021                        self.diagnostics.push(Diagnostic {
21022                            related: Vec::new(),
21023                            span: period.span,
21024                            message: format!("unknown reset period `{}`", period.name),
21025                            suggestion: Some(
21026                                "use `hourly`, `daily`, `weekly`, or `monthly`".to_owned(),
21027                            ),
21028                        });
21029                    }
21030                    period.name
21031                });
21032                let shared = bag.flag("shared");
21033                let (Some(key_type), Some(cap), Some(reset)) = (key_type, cap, reset) else {
21034                    self.diagnostics.push(Diagnostic {
21035                        related: Vec::new(),
21036                        span,
21037                        message: format!(
21038                            "counter `{}` must declare `key`, `cap`, and `reset`",
21039                            name.name
21040                        ),
21041                        suggestion: Some(
21042                            "every counter is bounded: declare all three fields".to_owned(),
21043                        ),
21044                    });
21045                    return None;
21046                };
21047                Some(Item::Counter(CounterDecl {
21048                    name,
21049                    key_type,
21050                    cap,
21051                    reset,
21052                    timezone: bag.text("timezone"),
21053                    shared,
21054                    span,
21055                }))
21056            }
21057            DeclAstKind::Lease => {
21058                let key_type = bag.ident("key");
21059                let slots = bag.number("slots").unwrap_or(1);
21060                let ttl_seconds = bag.duration("ttl");
21061                let shared = bag.flag("shared");
21062                let (Some(key_type), Some(ttl_seconds)) = (key_type, ttl_seconds) else {
21063                    self.diagnostics.push(Diagnostic {
21064                        related: Vec::new(),
21065                        span,
21066                        message: format!(
21067                            "lease `{}` must declare a `key` type and a `ttl` backstop",
21068                            name.name
21069                        ),
21070                        suggestion: Some(
21071                            "every lease is bounded: declare `key <Type>` and `ttl <duration>`"
21072                                .to_owned(),
21073                        ),
21074                    });
21075                    return None;
21076                };
21077                Some(Item::Lease(LeaseDecl {
21078                    name,
21079                    key_type,
21080                    slots,
21081                    ttl_seconds,
21082                    shared,
21083                    span,
21084                }))
21085            }
21086            DeclAstKind::Ledger => {
21087                let entry_schema = bag.ident("entry");
21088                let partition_field = bag.ident("partition");
21089                let retain_seconds = bag.duration("retain");
21090                let shared = bag.flag("shared");
21091                let (Some(entry_schema), Some(partition_field), Some(retain_seconds)) =
21092                    (entry_schema, partition_field, retain_seconds)
21093                else {
21094                    self.diagnostics.push(Diagnostic {
21095                        related: Vec::new(),
21096                        span,
21097                        message: format!(
21098                            "ledger `{}` must declare `entry`, `partition by`, and `retain`",
21099                            name.name
21100                        ),
21101                        suggestion: Some(
21102                            "every ledger is bounded and partitioned: declare all three fields"
21103                                .to_owned(),
21104                        ),
21105                    });
21106                    return None;
21107                };
21108                Some(Item::Ledger(LedgerDecl {
21109                    name,
21110                    entry_schema,
21111                    partition_field,
21112                    retain_seconds,
21113                    shared,
21114                    span,
21115                }))
21116            }
21117            DeclAstKind::FileStore => {
21118                let root_span = bag.span("root");
21119                let read_span = bag.span("allow read");
21120                let write_span = bag.span("allow write");
21121                let provider_span = bag.span("provider");
21122                let read_globs = bag.globs("allow read");
21123                let write_globs = bag.globs("allow write");
21124                let provider = bag.ident("provider");
21125                let Some(root) = bag.text("root") else {
21126                    self.diagnostics.push(Diagnostic {
21127                        related: Vec::new(),
21128                        span,
21129                        message: format!("file store `{}` is missing a root", name.name),
21130                        suggestion: Some(
21131                            "add `root \"<dir>\"` inside the file store block".to_owned(),
21132                        ),
21133                    });
21134                    return None;
21135                };
21136                Some(Item::FileStore(FileStoreDecl {
21137                    name,
21138                    root,
21139                    read_globs,
21140                    write_globs,
21141                    provider,
21142                    root_span,
21143                    read_span,
21144                    write_span,
21145                    provider_span,
21146                    span,
21147                }))
21148            }
21149            DeclAstKind::MemoryPool => {
21150                let context_limit = bag.number("context limit").map(u64::from);
21151                // The span serializes only alongside a value (hand parser sets it
21152                // inside the successful-parse branch).
21153                let context_limit_span = context_limit.and(bag.span("context limit"));
21154                Some(Item::MemoryPool(MemoryPoolDecl {
21155                    name,
21156                    context_limit,
21157                    context_limit_span,
21158                    span,
21159                }))
21160            }
21161        }
21162    }
21163
21164    fn parse_pattern(&mut self) -> Option<PatternDecl> {
21165        let start = self.expect_keyword("pattern")?.span.start;
21166        let name = self.expect_ident("pattern name")?;
21167        let type_params = self.parse_type_param_list().unwrap_or_default();
21168        let open = self.expect_symbol('{')?;
21169        let mut items = Vec::new();
21170        let mut pending_tags = Vec::new();
21171        let mut pending_description = None;
21172        while !self.is_at_end() && !self.at_symbol('}') {
21173            if self.at_symbol('@') {
21174                if let Some(tag) = self.parse_tag() {
21175                    pending_tags.push(tag);
21176                }
21177                continue;
21178            }
21179            if self.at_ident("description") {
21180                self.parse_pending_description(&mut pending_description);
21181                continue;
21182            }
21183            if self.at_ident("workflow") || self.at_ident("pattern") {
21184                self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
21185                self.reject_pending_description(
21186                    &mut pending_description,
21187                    "pattern body declaration",
21188                );
21189                self.unexpected("pattern body declaration");
21190                self.advance();
21191                continue;
21192            }
21193            if let Some(item) =
21194                self.parse_declaration_item(&mut pending_tags, &mut pending_description)
21195            {
21196                items.push(item);
21197            } else if self.reject_gherkin_misuse() {
21198                continue;
21199            } else {
21200                if self.is_at_end() {
21201                    break;
21202                }
21203                self.reject_pending_tags(&mut pending_tags, "pattern body declaration");
21204                self.reject_pending_description(
21205                    &mut pending_description,
21206                    "pattern body declaration",
21207                );
21208                self.unexpected("pattern body declaration");
21209                self.advance();
21210            }
21211        }
21212        let end = self
21213            .expect_symbol('}')
21214            .map(|token| token.span.end)
21215            .unwrap_or(open.span.end);
21216        Some(PatternDecl {
21217            name,
21218            type_params,
21219            items,
21220            span: SourceSpan { start, end },
21221        })
21222    }
21223
21224    fn parse_type_param_list(&mut self) -> Option<Vec<Ident>> {
21225        if !self.at_symbol('<') {
21226            return Some(Vec::new());
21227        }
21228        self.expect_symbol('<')?;
21229        let mut params = Vec::new();
21230        while !self.is_at_end() && !self.at_symbol('>') {
21231            params.push(self.expect_ident("type parameter")?);
21232            if self.at_symbol(',') {
21233                self.advance();
21234            } else if !self.at_symbol('>') {
21235                self.unexpected("`,` or `>`");
21236                while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
21237                    self.advance();
21238                }
21239            }
21240        }
21241        self.expect_symbol('>')?;
21242        Some(params)
21243    }
21244
21245    fn parse_type_arg_list(&mut self) -> Option<Vec<TypeSyntax>> {
21246        if !self.at_symbol('<') {
21247            return Some(Vec::new());
21248        }
21249        self.expect_symbol('<')?;
21250        let mut args = Vec::new();
21251        while !self.is_at_end() && !self.at_symbol('>') {
21252            args.push(self.parse_type()?);
21253            if self.at_symbol(',') {
21254                self.advance();
21255            } else if !self.at_symbol('>') {
21256                self.unexpected("`,` or `>`");
21257                while !self.is_at_end() && !self.at_symbol('>') && !self.at_symbol(',') {
21258                    self.advance();
21259                }
21260            }
21261        }
21262        self.expect_symbol('>')?;
21263        Some(args)
21264    }
21265
21266    fn parse_apply(&mut self) -> Option<ApplyDecl> {
21267        let start = self.expect_keyword("apply")?.span.start;
21268        let pattern = self.expect_ident("pattern name")?;
21269        let type_args = self.parse_type_arg_list().unwrap_or_default();
21270        self.expect_keyword("as")?;
21271        let alias = self.expect_ident("pattern application alias")?;
21272        let body = self.parse_block_source()?;
21273        let span = SourceSpan {
21274            start,
21275            end: body.span.end,
21276        };
21277        Some(ApplyDecl {
21278            pattern,
21279            type_args,
21280            alias,
21281            body,
21282            span,
21283        })
21284    }
21285
21286    fn parse_include(&mut self) -> Option<IncludeDecl> {
21287        self.expect_keyword("include")?;
21288        Some(IncludeDecl {
21289            path: self.expect_string("include path")?,
21290        })
21291    }
21292
21293    fn parse_workflow_contract(&mut self) -> Option<WorkflowContractDecl> {
21294        let keyword = self.advance().clone();
21295        let kind = match &keyword.kind {
21296            TokenKind::Ident(value) if value == "input" => WorkflowContractKind::Input,
21297            TokenKind::Ident(value) if value == "output" => WorkflowContractKind::Output,
21298            TokenKind::Ident(value) if value == "failure" => WorkflowContractKind::Failure,
21299            _ => return None,
21300        };
21301        let name = self.expect_ident("workflow contract name")?;
21302        // S7 (surface-defaults batch): an inline payload block synthesizes a
21303        // hygienic anonymous class (the `decide` precedent) — `output result {
21304        // message string }` declares the class `output.result` implicitly. The
21305        // dotted name cannot collide with a user class (identifiers cannot
21306        // contain `.`).
21307        if self.at_symbol('{') {
21308            self.advance();
21309            let mut fields = Vec::new();
21310            while !self.is_at_end() && !self.at_symbol('}') {
21311                let Some(field_name) = self.expect_ident("contract payload field name") else {
21312                    self.synchronize_to_block_item();
21313                    continue;
21314                };
21315                let Some(field_ty) = self.parse_type() else {
21316                    self.synchronize_to_block_item();
21317                    continue;
21318                };
21319                let field_span = field_name.span.join(field_ty.span());
21320                fields.push(ClassField {
21321                    name: field_name,
21322                    ty: field_ty,
21323                    is_key: false,
21324                    presence_condition: None,
21325                    span: field_span,
21326                });
21327            }
21328            let close_span = self.peek().map(|token| token.span);
21329            self.expect_symbol('}')?;
21330            let contract_keyword = match kind {
21331                WorkflowContractKind::Input => "input",
21332                WorkflowContractKind::Output => "output",
21333                WorkflowContractKind::Failure => "failure",
21334            };
21335            let class_name = format!("{contract_keyword}.{}", name.name);
21336            let end = close_span.map(|span| span.end).unwrap_or(name.span.end);
21337            let span = SourceSpan {
21338                start: keyword.span.start,
21339                end,
21340            };
21341            self.pending_contract_classes.push(ClassDecl {
21342                name: Ident {
21343                    name: class_name.clone(),
21344                    span,
21345                },
21346                fields,
21347                span,
21348            });
21349            return Some(WorkflowContractDecl {
21350                kind,
21351                name,
21352                ty: TypeSyntax::Ref {
21353                    name: Ident {
21354                        name: class_name,
21355                        span,
21356                    },
21357                },
21358                span,
21359            });
21360        }
21361        let ty = self.parse_type()?;
21362        let span = keyword.span.join(ty.span());
21363        Some(WorkflowContractDecl {
21364            kind,
21365            name,
21366            ty,
21367            span,
21368        })
21369    }
21370
21371    fn parse_use(&mut self) -> Option<UseDecl> {
21372        self.expect_keyword("use")?;
21373        if self.at_ident("plugin") || self.at_ident("skill") {
21374            let removed_kind = self.advance().clone();
21375            let removed_label = match &removed_kind.kind {
21376                TokenKind::Ident(value) => value.as_str(),
21377                _ => "",
21378            };
21379            self.diagnostics.push(Diagnostic { related: Vec::new(),
21380                span: removed_kind.span,
21381                message: format!("`use {removed_label}` is no longer supported"),
21382                suggestion: Some(
21383                    "write `use std.memory` for package libraries; attach skills with `agent { skills [...] }`"
21384                        .to_owned(),
21385                ),
21386            });
21387        }
21388        Some(UseDecl {
21389            name: self.expect_use_name("package library name")?,
21390        })
21391    }
21392
21393    /// Parses `<n><unit>` durations at declaration level (`ttl 10m`,
21394    /// `retain 90d`) — the lexer splits them into a number and a unit ident.
21395    fn parse_decl_duration_seconds(&mut self, label: &str) -> Option<u64> {
21396        let (value, span) = self.expect_u32(label)?;
21397        let unit = self.expect_ident(label)?;
21398        match body::parse_short_duration_seconds(&format!("{value}{}", unit.name)) {
21399            Some(seconds) if seconds > 0 => Some(seconds),
21400            _ => {
21401                self.diagnostics.push(Diagnostic {
21402                    related: Vec::new(),
21403                    span: span.join(unit.span),
21404                    message: format!("invalid duration `{value}{}`", unit.name),
21405                    suggestion: Some("use `<n><unit>` with unit s, m, h, or d".to_owned()),
21406                });
21407                None
21408            }
21409        }
21410    }
21411
21412    fn parse_harness(&mut self) -> Option<HarnessDecl> {
21413        let start = self.expect_keyword("harness")?.span.start;
21414        let name = self.expect_ident("harness name")?;
21415        self.expect_symbol(':')?;
21416        let kind = self.expect_ident("harness kind")?;
21417        let span = SourceSpan {
21418            start,
21419            end: kind.span.end,
21420        };
21421        Some(HarnessDecl { name, kind, span })
21422    }
21423
21424    fn parse_agent(&mut self) -> Option<AgentDecl> {
21425        let start = self.expect_keyword("agent")?.span.start;
21426        let name = self.expect_ident("agent name")?;
21427        let harness = if self.at_ident("using") {
21428            self.advance();
21429            Some(self.expect_ident("harness name")?)
21430        } else {
21431            None
21432        };
21433        let delegated_to = if harness.is_none() && self.at_ident("delegated") {
21434            self.advance();
21435            self.expect_keyword("to")?;
21436            Some(self.expect_ident("delegate provider")?)
21437        } else {
21438            None
21439        };
21440        // A bare declaration (`agent researcher`) is valid: every field has a
21441        // default (managed provider, least-authority profile, capacity 1).
21442        if !self.at_symbol('{') {
21443            let end = delegated_to
21444                .as_ref()
21445                .map(|ident| ident.span.end)
21446                .or_else(|| harness.as_ref().map(|ident| ident.span.end))
21447                .unwrap_or(name.span.end);
21448            return Some(AgentDecl {
21449                name,
21450                harness,
21451                delegated_to,
21452                fields: Vec::new(),
21453                span: SourceSpan { start, end },
21454            });
21455        }
21456        let open = self.expect_symbol('{')?;
21457        let mut fields = Vec::new();
21458
21459        while !self.is_at_end() && !self.at_symbol('}') {
21460            let Some(field_name) = self.expect_ident("agent field") else {
21461                self.synchronize_to_block_item();
21462                continue;
21463            };
21464
21465            match field_name.name.as_str() {
21466                "provider" => {
21467                    if let Some(provider) = self.expect_ident("provider name") {
21468                        fields.push(AgentField::Provider(provider));
21469                    } else {
21470                        self.synchronize_to_block_item();
21471                    }
21472                }
21473                "profile" => {
21474                    if let Some(value) = self.expect_string("profile string") {
21475                        fields.push(AgentField::Profile(value));
21476                    } else {
21477                        self.synchronize_to_block_item();
21478                    }
21479                }
21480                "capacity" => {
21481                    if let Some((value, span)) = self.expect_u32("capacity value") {
21482                        fields.push(AgentField::Capacity(value, span));
21483                    } else {
21484                        self.synchronize_to_block_item();
21485                    }
21486                }
21487                "skills" => {
21488                    if let Some((skills, span)) = self.parse_string_list() {
21489                        fields.push(AgentField::Skills(skills, span));
21490                    } else {
21491                        self.synchronize_to_block_item();
21492                    }
21493                }
21494                "capabilities" => {
21495                    if let Some((capabilities, span)) = self.parse_string_list() {
21496                        fields.push(AgentField::Capabilities(capabilities, span));
21497                    } else {
21498                        self.synchronize_to_block_item();
21499                    }
21500                }
21501                "requires" => {
21502                    if let Some((classes, span)) = self.parse_feature_class_list() {
21503                        fields.push(AgentField::Requires(classes, span));
21504                    } else {
21505                        self.synchronize_to_block_item();
21506                    }
21507                }
21508                "tools" => {
21509                    if let Some((tools, span)) = self.parse_ident_list() {
21510                        fields.push(AgentField::Tools(tools, span));
21511                    } else {
21512                        self.synchronize_to_block_item();
21513                    }
21514                }
21515                "compaction" => {
21516                    if let Some(strategy) = self.expect_ident("compaction strategy") {
21517                        fields.push(AgentField::Compaction(strategy));
21518                    } else {
21519                        self.synchronize_to_block_item();
21520                    }
21521                }
21522                "thread" => {
21523                    if let Some(mode) = self.expect_ident("thread mode") {
21524                        fields.push(AgentField::Thread(mode));
21525                    } else {
21526                        self.synchronize_to_block_item();
21527                    }
21528                }
21529                "settings" => {
21530                    if let Some(sources) = self.expect_ident("settings source") {
21531                        fields.push(AgentField::Settings(sources));
21532                    } else {
21533                        self.synchronize_to_block_item();
21534                    }
21535                }
21536                _ => {
21537                    let span = field_name.span;
21538                    fields.push(AgentField::Unknown {
21539                        name: field_name,
21540                        span,
21541                    });
21542                    self.synchronize_to_block_item();
21543                }
21544            }
21545        }
21546
21547        let end = self
21548            .expect_symbol('}')
21549            .map(|token| token.span.end)
21550            .unwrap_or(open.span.end);
21551
21552        Some(AgentDecl {
21553            name,
21554            harness,
21555            delegated_to,
21556            fields,
21557            span: SourceSpan { start, end },
21558        })
21559    }
21560
21561    fn parse_enum(&mut self) -> Option<EnumDecl> {
21562        let start = self.expect_keyword("enum")?.span.start;
21563        let name = self.expect_ident("enum name")?;
21564        let open = self.expect_symbol('{')?;
21565        let mut variants = Vec::new();
21566
21567        let mut previous_variant_end: Option<usize> = None;
21568        while !self.is_at_end() && !self.at_symbol('}') {
21569            let Some(variant) = self.expect_ident("enum variant") else {
21570                self.synchronize_to_block_item();
21571                continue;
21572            };
21573            // One variant per line: two variants sharing a line is almost
21574            // always pasted prose or a forgotten `#` — and the stray words
21575            // would otherwise become variants that pollute the domain (and
21576            // reach coerce output schemas).
21577            if let Some(previous_end) = previous_variant_end {
21578                let line_of = |offset: usize| {
21579                    self.source[..offset.min(self.source.len())]
21580                        .bytes()
21581                        .filter(|byte| *byte == b'\n')
21582                        .count()
21583                };
21584                if line_of(previous_end) == line_of(variant.span.start) {
21585                    self.diagnostics.push(Diagnostic {
21586                        related: Vec::new(),
21587                        span: variant.span,
21588                        message: format!(
21589                            "enum `{}` declares variant `{}` on the same line as the previous variant",
21590                            name.name, variant.name
21591                        ),
21592                        suggestion: Some("write one enum variant per line".to_owned()),
21593                    });
21594                }
21595            }
21596            // A brace body makes this a data-carrying variant; the body
21597            // reuses the class field grammar (sum types, spec/sum-types.md).
21598            let mut fields = Vec::new();
21599            let mut end = variant.span.end;
21600            if self.at_symbol('{') {
21601                self.expect_symbol('{');
21602                while !self.is_at_end() && !self.at_symbol('}') {
21603                    let Some(field_name) = self.expect_ident("variant field name") else {
21604                        self.synchronize_to_block_item();
21605                        continue;
21606                    };
21607                    let Some(ty) = self.parse_type() else {
21608                        self.synchronize_to_block_item();
21609                        continue;
21610                    };
21611                    fields.push(ClassField {
21612                        span: field_name.span.join(ty.span()),
21613                        name: field_name,
21614                        ty,
21615                        is_key: false,
21616                        presence_condition: None,
21617                    });
21618                }
21619                if let Some(close) = self.expect_symbol('}') {
21620                    end = close.span.end;
21621                }
21622            }
21623            let span = SourceSpan {
21624                start: variant.span.start,
21625                end,
21626            };
21627            previous_variant_end = Some(end);
21628            variants.push(EnumVariantDecl {
21629                name: variant,
21630                fields,
21631                span,
21632            });
21633        }
21634
21635        let end = self
21636            .expect_symbol('}')
21637            .map(|token| token.span.end)
21638            .unwrap_or(open.span.end);
21639
21640        Some(EnumDecl {
21641            name,
21642            variants,
21643            span: SourceSpan { start, end },
21644        })
21645    }
21646
21647    fn parse_event(&mut self) -> Option<EventDecl> {
21648        let start = self.expect_keyword("signal")?.span.start;
21649        // Dotted lowercase name (`deploy.finished`), matching the `when fact`
21650        // convention and distinct from PascalCase classes.
21651        let first = self.expect_ident("signal name")?;
21652        let mut name = first.name.clone();
21653        let mut name_span = first.span;
21654        while self.at_symbol('.') {
21655            self.expect_symbol('.');
21656            let segment = self.expect_ident("signal name segment")?;
21657            name.push('.');
21658            name.push_str(&segment.name);
21659            name_span = name_span.join(segment.span);
21660        }
21661        if !name.contains('.')
21662            || name
21663                .split('.')
21664                .any(|segment| segment.chars().next().is_some_and(char::is_uppercase))
21665        {
21666            self.diagnostics.push(Diagnostic {
21667                related: Vec::new(),
21668                span: name_span,
21669                message: format!("signal name `{name}` must be dotted lowercase"),
21670                suggestion: Some(
21671                    "use a dotted lowercase name such as `deploy.finished`".to_owned(),
21672                ),
21673            });
21674        }
21675        let open = self.expect_symbol('{')?;
21676        let mut fields = Vec::new();
21677        while !self.is_at_end() && !self.at_symbol('}') {
21678            let Some(field_name) = self.expect_ident("signal field name") else {
21679                self.synchronize_to_block_item();
21680                continue;
21681            };
21682            let Some(ty) = self.parse_type() else {
21683                self.synchronize_to_block_item();
21684                continue;
21685            };
21686            let presence_condition = self.parse_field_presence_condition();
21687            fields.push(ClassField {
21688                span: field_name.span.join(ty.span()),
21689                name: field_name,
21690                ty,
21691                is_key: false,
21692                presence_condition,
21693            });
21694        }
21695        let end = self
21696            .expect_symbol('}')
21697            .map(|token| token.span.end)
21698            .unwrap_or(open.span.end);
21699        Some(EventDecl {
21700            name,
21701            name_span,
21702            fields,
21703            span: SourceSpan { start, end },
21704        })
21705    }
21706
21707    /// A dotted name (`summarize.extract`, `std.spend`) as one string + span.
21708    fn parse_dotted_name_spanned(&mut self, label: &str) -> Option<(String, SourceSpan)> {
21709        let first = self.expect_ident(label)?;
21710        let mut name = first.name.clone();
21711        let mut span = first.span;
21712        while self.at_symbol('.') {
21713            self.expect_symbol('.');
21714            let segment = self.expect_ident(label)?;
21715            name.push('.');
21716            name.push_str(&segment.name);
21717            span = span.join(segment.span);
21718        }
21719        Some((name, span))
21720    }
21721
21722    fn parse_gauge_ref(&mut self, label: &str) -> Option<GaugeRef> {
21723        let (name, span) = self.parse_dotted_name_spanned(label)?;
21724        Some(GaugeRef { name, span })
21725    }
21726
21727    /// A comma-separated gauge-reference list (`ascend a, std.spend`).
21728    fn parse_gauge_ref_list(&mut self, label: &str, into: &mut Vec<GaugeRef>) -> Option<()> {
21729        into.push(self.parse_gauge_ref(label)?);
21730        while self.at_symbol(',') {
21731            self.expect_symbol(',');
21732            into.push(self.parse_gauge_ref(label)?);
21733        }
21734        Some(())
21735    }
21736
21737    /// A numeric literal as exact source text: `800` or `0.9`. The
21738    /// declaration lexer emits digits-only Number tokens, so a fraction is
21739    /// Number `.` Number.
21740    fn parse_decl_number_text(&mut self, label: &str) -> Option<(String, SourceSpan)> {
21741        let token = self.peek()?;
21742        let TokenKind::Number(whole) = token.kind.clone() else {
21743            self.expected(label);
21744            return None;
21745        };
21746        let mut span = token.span;
21747        let mut text = whole;
21748        self.advance();
21749        if self.at_symbol('.') {
21750            self.expect_symbol('.');
21751            let token = self.peek()?;
21752            let TokenKind::Number(fraction) = token.kind.clone() else {
21753                self.expected(format!("{label} fraction digits"));
21754                return None;
21755            };
21756            text.push('.');
21757            text.push_str(&fraction);
21758            span = span.join(token.span);
21759            self.advance();
21760        }
21761        Some((text, span))
21762    }
21763
21764    /// Bar direction: `at least` (true) / `at most` (false). The declaration
21765    /// tokenizer steps over `>=`/`<=` silently (the `is` precedent), so a
21766    /// user writing an operator gets a targeted diagnostic instead of a
21767    /// direction-less bar: the raw source gap before the next token is
21768    /// inspected for the dropped operator.
21769    fn parse_bar_direction(&mut self, label: &str) -> Option<bool> {
21770        if self.consume_ident("at") {
21771            if self.consume_ident("least") {
21772                return Some(true);
21773            }
21774            if self.consume_ident("most") {
21775                return Some(false);
21776            }
21777            self.expected(format!("`least` or `most` after `at` in {label}"));
21778            return None;
21779        }
21780        let gap_start = self.last_span_end();
21781        let gap_end = self
21782            .peek()
21783            .map(|token| token.span.start)
21784            .unwrap_or(gap_start);
21785        let gap = &self.source[gap_start..gap_end.max(gap_start)];
21786        let suggestion = if gap.contains(">=") {
21787            Some("write `at least` (the declaration grammar uses words, not `>=`)".to_owned())
21788        } else if gap.contains("<=") {
21789            Some("write `at most` (the declaration grammar uses words, not `<=`)".to_owned())
21790        } else {
21791            Some("write `at least <n>` or `at most <n>`".to_owned())
21792        };
21793        self.diagnostics.push(Diagnostic {
21794            related: Vec::new(),
21795            span: SourceSpan {
21796                start: gap_start,
21797                end: gap_end.max(gap_start),
21798            },
21799            message: format!("expected `at least` or `at most` in {label}"),
21800            suggestion,
21801        });
21802        None
21803    }
21804
21805    /// `mark "<name>" after <site>`.
21806    fn parse_mark(&mut self) -> Option<MarkDecl> {
21807        let start = self.expect_keyword("mark")?.span.start;
21808        let name = self.expect_string("mark name")?;
21809        if !self.consume_ident("after") {
21810            self.expected("`after` and a committing site in the mark declaration");
21811            return None;
21812        }
21813        let (site, site_span) = self.parse_dotted_name_spanned("mark site")?;
21814        Some(MarkDecl {
21815            name,
21816            site,
21817            site_span,
21818            span: SourceSpan {
21819                start,
21820                end: site_span.end,
21821            },
21822        })
21823    }
21824
21825    /// `gauge <name> [on <site>] { judge via <form> [expect <bar>]
21826    /// [inputs <gauges>] }`.
21827    fn parse_gauge(&mut self) -> Option<GaugeDecl> {
21828        let start = self.expect_keyword("gauge")?.span.start;
21829        let name = self.expect_ident("gauge name")?;
21830        let (site, site_span) = if self.consume_ident("on") {
21831            let (site, span) = self.parse_dotted_name_spanned("gauge site")?;
21832            (Some(site), Some(span))
21833        } else {
21834            (None, None)
21835        };
21836        let open = self.expect_symbol('{')?;
21837        let mut judge: Option<GaugeJudge> = None;
21838        let mut expect: Option<GaugeBar> = None;
21839        let mut inputs: Vec<GaugeRef> = Vec::new();
21840        while !self.is_at_end() && !self.at_symbol('}') {
21841            if self.at_ident("judge") {
21842                let keyword = self.advance().clone();
21843                if !self.consume_ident("via") {
21844                    self.expected("`via` after `judge`");
21845                    self.synchronize_to_block_item();
21846                    continue;
21847                }
21848                let form = if self.consume_ident("coerce") {
21849                    self.expect_ident("coerce judge name").and_then(|name| {
21850                        let mut args = Vec::new();
21851                        if self.at_symbol('(') {
21852                            self.expect_symbol('(');
21853                            loop {
21854                                let (path, _) = self.parse_dotted_name_spanned("judge argument")?;
21855                                args.push(path);
21856                                if !self.at_symbol(',') {
21857                                    break;
21858                                }
21859                                self.expect_symbol(',')?;
21860                            }
21861                            self.expect_symbol(')')?;
21862                        }
21863                        Some(GaugeJudge::Coerce(name, args))
21864                    })
21865                } else if self.consume_ident("prompt") {
21866                    self.expect_string("prompt judge template")
21867                        .map(GaugeJudge::Prompt)
21868                } else if self.consume_ident("exec") {
21869                    self.expect_string("exec judge command")
21870                        .map(GaugeJudge::Exec)
21871                } else if self.consume_ident("labels") {
21872                    self.expect_string("labels source").map(GaugeJudge::Labels)
21873                } else {
21874                    self.diagnostics.push(Diagnostic {
21875                        related: Vec::new(),
21876                        span: keyword.span,
21877                        message: "unknown judge form".to_owned(),
21878                        suggestion: Some(
21879                            "judge forms are `coerce <Name>`, `prompt \"<template>\"`, \
21880                             `exec \"<command>\"`, and `labels \"<source>\"`"
21881                                .to_owned(),
21882                        ),
21883                    });
21884                    None
21885                };
21886                let Some(form) = form else {
21887                    self.synchronize_to_block_item();
21888                    continue;
21889                };
21890                if judge.is_some() {
21891                    self.diagnostics.push(Diagnostic {
21892                        related: Vec::new(),
21893                        span: keyword.span,
21894                        message: "gauge declares more than one judge".to_owned(),
21895                        suggestion: Some("a gauge has exactly one judge".to_owned()),
21896                    });
21897                } else {
21898                    judge = Some(form);
21899                }
21900            } else if self.at_ident("expect") {
21901                let keyword = self.advance().clone();
21902                let subject_ident = match self.expect_ident("bar subject") {
21903                    Some(ident) => ident,
21904                    None => {
21905                        self.synchronize_to_block_item();
21906                        continue;
21907                    }
21908                };
21909                let subject = if subject_ident.name == "P" && self.at_symbol('(') {
21910                    self.expect_symbol('(');
21911                    let Some(field) = self.expect_ident("chance bar field") else {
21912                        self.synchronize_to_block_item();
21913                        continue;
21914                    };
21915                    if self.expect_symbol(')').is_none() {
21916                        self.synchronize_to_block_item();
21917                        continue;
21918                    }
21919                    GaugeBarSubject::Chance { field }
21920                } else {
21921                    let stat = &subject_ident.name;
21922                    let is_quantile = stat.len() > 1
21923                        && stat.starts_with('p')
21924                        && stat[1..].chars().all(|ch| ch.is_ascii_digit());
21925                    if stat != "mean" && !is_quantile {
21926                        self.diagnostics.push(Diagnostic {
21927                            related: Vec::new(),
21928                            span: subject_ident.span,
21929                            message: format!("unknown bar statistic `{stat}`"),
21930                            suggestion: Some(
21931                                "bars are chance-shaped (`P(<field>)`) or stat-shaped \
21932                                 (`mean`, `p10`, `p90`, ...)"
21933                                    .to_owned(),
21934                            ),
21935                        });
21936                    }
21937                    GaugeBarSubject::Stat {
21938                        stat: subject_ident,
21939                    }
21940                };
21941                let Some(at_least) = self.parse_bar_direction("the gauge bar") else {
21942                    self.synchronize_to_block_item();
21943                    continue;
21944                };
21945                let Some((threshold, threshold_span)) =
21946                    self.parse_decl_number_text("bar threshold")
21947                else {
21948                    self.synchronize_to_block_item();
21949                    continue;
21950                };
21951                if expect.is_some() {
21952                    self.diagnostics.push(Diagnostic {
21953                        related: Vec::new(),
21954                        span: keyword.span,
21955                        message: "gauge declares more than one bar".to_owned(),
21956                        suggestion: Some("a gauge has at most one `expect` bar".to_owned()),
21957                    });
21958                } else {
21959                    expect = Some(GaugeBar {
21960                        subject,
21961                        at_least,
21962                        threshold,
21963                        span: keyword.span.join(threshold_span),
21964                    });
21965                }
21966            } else if self.at_ident("inputs") {
21967                self.advance();
21968                if self
21969                    .parse_gauge_ref_list("input gauge name", &mut inputs)
21970                    .is_none()
21971                {
21972                    self.synchronize_to_block_item();
21973                }
21974            } else {
21975                let span = self.peek().map(|token| token.span).unwrap_or(open.span);
21976                self.diagnostics.push(Diagnostic {
21977                    related: Vec::new(),
21978                    span,
21979                    message: "unknown gauge clause".to_owned(),
21980                    suggestion: Some(
21981                        "gauge clauses are `judge via`, `expect`, and `inputs`".to_owned(),
21982                    ),
21983                });
21984                self.synchronize_to_block_item();
21985            }
21986        }
21987        let end = self
21988            .expect_symbol('}')
21989            .map(|token| token.span.end)
21990            .unwrap_or(open.span.end);
21991        let Some(judge) = judge else {
21992            self.diagnostics.push(Diagnostic {
21993                related: Vec::new(),
21994                span: name.span,
21995                message: format!("gauge `{}` declares no judge", name.name),
21996                suggestion: Some(
21997                    "add `judge via coerce <Name>`, `judge via prompt \"<template>\"`, \
21998                     `judge via exec \"<command>\"`, or `judge via labels \"<source>\"`"
21999                        .to_owned(),
22000                ),
22001            });
22002            return None;
22003        };
22004        Some(GaugeDecl {
22005            name,
22006            site,
22007            site_span,
22008            judge,
22009            expect,
22010            inputs,
22011            span: SourceSpan { start, end },
22012        })
22013    }
22014
22015    /// `campaign <name> { ascend … [reach …] [guard …] [sacrifice …] }`.
22016    fn parse_campaign(&mut self) -> Option<CampaignDecl> {
22017        let start = self.expect_keyword("campaign")?.span.start;
22018        let name = self.expect_ident("campaign name")?;
22019        let open = self.expect_symbol('{')?;
22020        let mut ascend: Vec<GaugeRef> = Vec::new();
22021        let mut reach: Vec<CampaignReach> = Vec::new();
22022        let mut guard: Vec<CampaignGuard> = Vec::new();
22023        let mut sacrifice: Vec<GaugeRef> = Vec::new();
22024        let mut proposer_redacted = false;
22025        while !self.is_at_end() && !self.at_symbol('}') {
22026            if self.at_ident("ascend") {
22027                self.advance();
22028                if self
22029                    .parse_gauge_ref_list("ascend gauge name", &mut ascend)
22030                    .is_none()
22031                {
22032                    self.synchronize_to_block_item();
22033                }
22034            } else if self.at_ident("reach") {
22035                let keyword = self.advance().clone();
22036                let Some(gauge) = self.parse_gauge_ref("reach gauge name") else {
22037                    self.synchronize_to_block_item();
22038                    continue;
22039                };
22040                let Some(at_least) = self.parse_bar_direction("the reach target") else {
22041                    self.synchronize_to_block_item();
22042                    continue;
22043                };
22044                let Some((threshold, threshold_span)) =
22045                    self.parse_decl_number_text("reach threshold")
22046                else {
22047                    self.synchronize_to_block_item();
22048                    continue;
22049                };
22050                // A trailing duration unit (`800ms`) lexes as a separate
22051                // ident; a clause keyword never collides with the unit set.
22052                let unit = if self
22053                    .peek()
22054                    .map(|token| {
22055                        matches!(&token.kind, TokenKind::Ident(name)
22056                            if matches!(name.as_str(), "ms" | "s" | "m" | "h" | "d"))
22057                    })
22058                    .unwrap_or(false)
22059                {
22060                    self.expect_ident("unit").map(|ident| ident.name)
22061                } else {
22062                    None
22063                };
22064                reach.push(CampaignReach {
22065                    gauge,
22066                    at_least,
22067                    threshold,
22068                    unit,
22069                    span: keyword.span.join(threshold_span),
22070                });
22071            } else if self.at_ident("guard") {
22072                let keyword = self.advance().clone();
22073                let Some(gauge) = self.parse_gauge_ref("guard gauge name") else {
22074                    self.synchronize_to_block_item();
22075                    continue;
22076                };
22077                if !self.consume_ident("within") {
22078                    self.expected("`within` after the guarded gauge");
22079                    self.synchronize_to_block_item();
22080                    continue;
22081                }
22082                let Some((band_percent, band_span)) = self.parse_decl_number_text("guard band")
22083                else {
22084                    self.synchronize_to_block_item();
22085                    continue;
22086                };
22087                if !self.consume_ident("percent") {
22088                    self.expected("`percent` after the guard band");
22089                    self.synchronize_to_block_item();
22090                    continue;
22091                }
22092                guard.push(CampaignGuard {
22093                    gauge,
22094                    band_percent,
22095                    span: keyword.span.join(band_span),
22096                });
22097            } else if self.at_ident("sacrifice") {
22098                self.advance();
22099                if self
22100                    .parse_gauge_ref_list("sacrifice gauge name", &mut sacrifice)
22101                    .is_none()
22102                {
22103                    self.synchronize_to_block_item();
22104                }
22105            } else if self.at_ident("proposer") {
22106                self.advance();
22107                if self.consume_ident("redacted") {
22108                    proposer_redacted = true;
22109                } else {
22110                    self.expected("`redacted` after `proposer`");
22111                    self.synchronize_to_block_item();
22112                }
22113            } else {
22114                let span = self.peek().map(|token| token.span).unwrap_or(open.span);
22115                self.diagnostics.push(Diagnostic {
22116                    related: Vec::new(),
22117                    span,
22118                    message: "unknown campaign clause".to_owned(),
22119                    suggestion: Some(
22120                        "campaign clauses are `ascend`, `reach`, `guard`, `sacrifice`, \
22121                         and `proposer redacted`"
22122                            .to_owned(),
22123                    ),
22124                });
22125                self.synchronize_to_block_item();
22126            }
22127        }
22128        let end = self
22129            .expect_symbol('}')
22130            .map(|token| token.span.end)
22131            .unwrap_or(open.span.end);
22132        if ascend.is_empty() && reach.is_empty() {
22133            self.diagnostics.push(Diagnostic {
22134                related: Vec::new(),
22135                span: name.span,
22136                message: format!("campaign `{}` names nothing to improve", name.name),
22137                suggestion: Some("add an `ascend` or `reach` clause".to_owned()),
22138            });
22139        }
22140        Some(CampaignDecl {
22141            name,
22142            ascend,
22143            reach,
22144            guard,
22145            sacrifice,
22146            proposer_redacted,
22147            span: SourceSpan { start, end },
22148        })
22149    }
22150
22151    fn last_span_end(&self) -> usize {
22152        self.pos
22153            .checked_sub(1)
22154            .and_then(|index| self.tokens.get(index))
22155            .map(|token| token.span.end)
22156            .unwrap_or(0)
22157    }
22158
22159    fn parse_dotted_name(&mut self, label: &str) -> Option<String> {
22160        let first = self.expect_ident(label)?;
22161        let mut name = first.name.clone();
22162        while self.at_symbol('.') {
22163            self.advance();
22164            let segment = self.expect_ident(label)?;
22165            name.push('.');
22166            name.push_str(&segment.name);
22167        }
22168        Some(name)
22169    }
22170
22171    /// Capture the source text of an expression from the current token to end of
22172    /// line (the `assert`/guard idiom), advancing past the consumed tokens.
22173    fn capture_expr_to_line_end(&mut self) -> (String, SourceSpan) {
22174        let start = self
22175            .peek()
22176            .map(|token| token.span.start)
22177            .unwrap_or(self.source.len());
22178        let line_end = self.source[start..]
22179            .find('\n')
22180            .map(|offset| start + offset)
22181            .unwrap_or(self.source.len());
22182        let mut end = start;
22183        while !self.is_at_end() {
22184            let Some(token) = self.peek() else { break };
22185            if token.span.start >= line_end {
22186                break;
22187            }
22188            let token_end = token.span.end.min(line_end);
22189            self.advance();
22190            end = token_end;
22191        }
22192        let span = SourceSpan { start, end };
22193        trimmed_source_text(self.source_text(span), span)
22194    }
22195
22196    /// Capture source text up to (but not including) a terminator identifier or a
22197    /// closing brace — used for a predicate bounded by `is`.
22198    fn capture_expr_until_ident(&mut self, terminator: &str) -> (String, SourceSpan) {
22199        let start = self
22200            .peek()
22201            .map(|token| token.span.start)
22202            .unwrap_or(self.source.len());
22203        let mut end = start;
22204        while !self.is_at_end() && !self.at_ident(terminator) && !self.at_symbol('}') {
22205            let Some(token) = self.peek() else { break };
22206            let token_end = token.span.end;
22207            self.advance();
22208            end = token_end;
22209        }
22210        let span = SourceSpan { start, end };
22211        trimmed_source_text(self.source_text(span), span)
22212    }
22213
22214    fn parse_test(&mut self) -> Option<TestDecl> {
22215        let start = self.expect_keyword("test")?.span.start;
22216        let name = self.expect_string("test name")?;
22217        let open = self.expect_symbol('{')?;
22218        let mut workflow = None;
22219        let mut clauses = Vec::new();
22220        while !self.is_at_end() && !self.at_symbol('}') {
22221            if self.at_ident("workflow") {
22222                self.advance();
22223                match self.expect_ident("workflow name") {
22224                    Some(name) => {
22225                        if workflow.is_some() {
22226                            self.diagnostics.push(Diagnostic {
22227                                related: Vec::new(),
22228                                span: name.span,
22229                                message: "a test scenario binds at most one `workflow`".to_owned(),
22230                                suggestion: Some(
22231                                    "remove the extra `workflow <Name>` header".to_owned(),
22232                                ),
22233                            });
22234                        }
22235                        workflow = Some(name);
22236                    }
22237                    None => self.synchronize_to_block_item(),
22238                }
22239            } else if self.at_ident("given") {
22240                match self.parse_given() {
22241                    Some(clause) => clauses.push(TestClause::Given(clause)),
22242                    None => self.synchronize_to_block_item(),
22243                }
22244            } else if self.at_ident("stub") {
22245                match self.parse_stub() {
22246                    Some(clause) => clauses.push(TestClause::Stub(clause)),
22247                    None => self.synchronize_to_block_item(),
22248                }
22249            } else if self.at_ident("run") {
22250                match self.parse_run() {
22251                    Some(clause) => clauses.push(TestClause::Run(clause)),
22252                    None => self.synchronize_to_block_item(),
22253                }
22254            } else if self.at_ident("expect") {
22255                match self.parse_expect() {
22256                    Some(clause) => clauses.push(TestClause::Expect(clause)),
22257                    None => self.synchronize_to_block_item(),
22258                }
22259            } else {
22260                self.unexpected("a test clause (`workflow`, `given`, `stub`, `run`, or `expect`)");
22261                self.synchronize_to_block_item();
22262            }
22263        }
22264        let end = self
22265            .expect_symbol('}')
22266            .map(|token| token.span.end)
22267            .unwrap_or(open.span.end);
22268        Some(TestDecl {
22269            name,
22270            workflow,
22271            clauses,
22272            span: SourceSpan { start, end },
22273        })
22274    }
22275
22276    fn parse_test_record(&mut self) -> Option<(Vec<TestField>, usize)> {
22277        let open = self.expect_symbol('{')?;
22278        let mut fields = Vec::new();
22279        while !self.is_at_end() && !self.at_symbol('}') {
22280            let Some(name) = self.expect_ident("test field name") else {
22281                self.synchronize_to_block_item();
22282                continue;
22283            };
22284            let (value, value_span) = self.capture_expr_to_line_end();
22285            fields.push(TestField {
22286                span: name.span.join(value_span),
22287                name,
22288                value,
22289            });
22290        }
22291        let end = self
22292            .expect_symbol('}')
22293            .map(|token| token.span.end)
22294            .unwrap_or(open.span.end);
22295        Some((fields, end))
22296    }
22297
22298    fn parse_given(&mut self) -> Option<GivenClause> {
22299        let start = self.expect_keyword("given")?.span.start;
22300        if self.consume_ident("input") {
22301            let (fields, end) = self.parse_test_record()?;
22302            Some(GivenClause::Input {
22303                fields,
22304                span: SourceSpan { start, end },
22305            })
22306        } else if self.consume_ident("fact") {
22307            let ty = self.expect_ident("fact type")?;
22308            let (fields, end) = self.parse_test_record()?;
22309            Some(GivenClause::Fact {
22310                ty,
22311                fields,
22312                span: SourceSpan { start, end },
22313            })
22314        } else if self.consume_ident("signal") {
22315            let name = self.parse_dotted_name("signal name")?;
22316            let (fields, end) = self.parse_test_record()?;
22317            Some(GivenClause::Signal {
22318                name,
22319                fields,
22320                span: SourceSpan { start, end },
22321            })
22322        } else if self.consume_ident("clock") {
22323            if !self.consume_ident("at") {
22324                self.expected("`at <timestamp>` after `given clock`");
22325            }
22326            let at = self.expect_string("clock timestamp")?;
22327            let end = at.span.end;
22328            Some(GivenClause::Clock {
22329                at,
22330                span: SourceSpan { start, end },
22331            })
22332        } else if self.consume_ident("tracker") {
22333            let tracker = self.parse_dotted_name("tracker name")?;
22334            if !self.consume_ident("issue") {
22335                self.expected("`issue { … }` after `given tracker <name>`");
22336            }
22337            let (fields, end) = self.parse_test_record()?;
22338            Some(GivenClause::Tracker {
22339                tracker,
22340                fields,
22341                span: SourceSpan { start, end },
22342            })
22343        } else if self.consume_ident("file") {
22344            let store = self.parse_dotted_name("file store name")?;
22345            if !self.consume_ident("at") {
22346                self.expected("`at <path> \"<content>\"` after `given file <store>`");
22347            }
22348            let path = self.expect_string("file path")?;
22349            let content = self.expect_string("file content")?;
22350            let end = content.span.end;
22351            Some(GivenClause::File {
22352                store,
22353                path,
22354                content,
22355                span: SourceSpan { start, end },
22356            })
22357        } else {
22358            self.unexpected(
22359                "`input`, `fact`, `signal`, `clock`, `tracker`, or `file` after `given`",
22360            );
22361            None
22362        }
22363    }
22364
22365    fn parse_stub(&mut self) -> Option<StubClause> {
22366        let start = self.expect_keyword("stub")?.span.start;
22367        // Surface path: dotted-name segments up to the outcome, all on the `stub`
22368        // line. The trailing segment (before a `{`, string, or end-of-line) is the
22369        // outcome; the rest is the surface. v0 keeps this lexical: at least one
22370        // surface segment + one outcome.
22371        let line_end = self.source[start..]
22372            .find('\n')
22373            .map(|offset| start + offset)
22374            .unwrap_or(self.source.len());
22375        let mut segments = Vec::new();
22376        while matches!(
22377            self.peek().map(|token| &token.kind),
22378            Some(TokenKind::Ident(_))
22379        ) && self.peek().is_some_and(|token| token.span.start < line_end)
22380        {
22381            match self.parse_dotted_name("stub surface") {
22382                Some(segment) => segments.push(segment),
22383                None => break,
22384            }
22385        }
22386        if segments.len() < 2 {
22387            self.diagnostics.push(Diagnostic {
22388                related: Vec::new(),
22389                span: SourceSpan {
22390                    start,
22391                    end: self.last_span_end(),
22392                },
22393                message: "stub needs a surface and an outcome (e.g. `stub agent triager succeeds`)"
22394                    .to_owned(),
22395                suggestion: Some("write `stub <surface...> <outcome> [payload]`".to_owned()),
22396            });
22397            return None;
22398        }
22399        let outcome = segments.pop().expect("outcome present");
22400        let surface = segments;
22401        let payload = if self.at_symbol('{') {
22402            let (fields, _) = self.parse_test_record()?;
22403            Some(StubPayload::Record(fields))
22404        } else if matches!(
22405            self.peek().map(|token| &token.kind),
22406            Some(TokenKind::String(_))
22407        ) {
22408            Some(StubPayload::Message(self.expect_string("stub message")?))
22409        } else {
22410            None
22411        };
22412        let end = self.last_span_end();
22413        Some(StubClause {
22414            surface,
22415            outcome,
22416            payload,
22417            span: SourceSpan { start, end },
22418        })
22419    }
22420
22421    fn parse_run(&mut self) -> Option<RunClause> {
22422        let start = self.expect_keyword("run")?.span.start;
22423        let kind = if self.consume_ident("until") {
22424            if self.consume_ident("idle") {
22425                RunKind::UntilIdle
22426            } else if self.consume_ident("workflow") {
22427                if self.consume_ident("completed") {
22428                    RunKind::UntilWorkflowCompleted
22429                } else if self.consume_ident("failed") {
22430                    RunKind::UntilWorkflowFailed
22431                } else {
22432                    self.expected("`completed` or `failed` after `workflow`");
22433                    return None;
22434                }
22435            } else {
22436                self.expected("`idle` or `workflow completed|failed` after `until`");
22437                return None;
22438            }
22439        } else if self.consume_ident("for") {
22440            let (steps, _) = self.expect_u32("step count")?;
22441            if !self.consume_ident("steps") {
22442                self.expected("`steps` after the step count");
22443            }
22444            RunKind::ForSteps(steps)
22445        } else {
22446            self.expected("`until ...` or `for <N> steps` after `run`");
22447            return None;
22448        };
22449        let end = self.last_span_end();
22450        Some(RunClause {
22451            kind,
22452            span: SourceSpan { start, end },
22453        })
22454    }
22455
22456    fn parse_expect(&mut self) -> Option<ExpectClause> {
22457        let start = self.expect_keyword("expect")?.span.start;
22458        let target = if self.consume_ident("workflow") {
22459            if self.consume_ident("completed") {
22460                ExpectTarget::WorkflowCompleted
22461            } else if self.consume_ident("failed") {
22462                let failure = if self.consume_ident("with") {
22463                    self.expect_ident("failure type")
22464                } else {
22465                    None
22466                };
22467                ExpectTarget::WorkflowFailed { failure }
22468            } else {
22469                self.expected("`completed` or `failed` after `workflow`");
22470                return None;
22471            }
22472        } else if self.consume_ident("rule") {
22473            let name = self.expect_ident("rule name")?;
22474            let status = if self.consume_ident("fired") {
22475                if matches!(
22476                    self.peek().map(|token| &token.kind),
22477                    Some(TokenKind::Number(_))
22478                ) {
22479                    let (count, _) = self.expect_u32("fired count")?;
22480                    if !self.consume_ident("times") {
22481                        self.expected("`times` after the fired count");
22482                    }
22483                    RuleStatus::FiredTimes(count)
22484                } else {
22485                    RuleStatus::Fired
22486                }
22487            } else if self.consume_ident("did") {
22488                if !self.consume_ident("not") {
22489                    self.expected("`not` in `did not fire`");
22490                }
22491                if !self.consume_ident("fire") {
22492                    self.expected("`fire` in `did not fire`");
22493                }
22494                RuleStatus::DidNotFire
22495            } else {
22496                self.expected("`fired`, `fired <N> times`, or `did not fire`");
22497                return None;
22498            };
22499            ExpectTarget::Rule { name, status }
22500        } else if self.consume_ident("effect") {
22501            let name = self.parse_dotted_name("effect name")?;
22502            let status = if self.consume_ident("requested") {
22503                EffectStatus::Requested
22504            } else if self.consume_ident("completed") {
22505                EffectStatus::Completed
22506            } else if self.consume_ident("failed") {
22507                EffectStatus::Failed
22508            } else {
22509                self.expected("`requested`, `completed`, or `failed` after the effect name");
22510                return None;
22511            };
22512            ExpectTarget::Effect { name, status }
22513        } else if self.consume_ident("diagnostic") {
22514            let code = self.parse_dotted_name("diagnostic code")?;
22515            ExpectTarget::Diagnostic { code }
22516        } else if self.consume_ident("no") {
22517            let name = self.parse_dotted_name("forbidden effect name")?;
22518            ExpectTarget::NoEffect { name }
22519        } else {
22520            let noun = self.parse_dotted_name("projection noun")?;
22521            let kind = self.parse_proj_query_kind()?;
22522            let end = self.last_span_end();
22523            ExpectTarget::Projection(ProjQuery {
22524                noun,
22525                kind,
22526                span: SourceSpan { start, end },
22527            })
22528        };
22529        let end = self.last_span_end();
22530        Some(ExpectClause {
22531            target,
22532            span: SourceSpan { start, end },
22533        })
22534    }
22535
22536    fn parse_proj_query_kind(&mut self) -> Option<ProjQueryKind> {
22537        if self.consume_ident("exists") {
22538            return Some(ProjQueryKind::Exists);
22539        }
22540        if self.consume_ident("count") {
22541            if !self.consume_ident("where") {
22542                self.expected("`where <predicate> is <N>` after `count`");
22543                return None;
22544            }
22545            let (predicate, _) = self.capture_expr_until_ident("is");
22546            if !self.consume_ident("is") {
22547                self.expected("`is <N>` after the count predicate");
22548                return None;
22549            }
22550            let (count, _) = self.expect_u32("count value")?;
22551            return Some(ProjQueryKind::Count { predicate, count });
22552        }
22553        if self.consume_ident("where") {
22554            let (predicate, _) = self.capture_expr_to_line_end();
22555            return Some(ProjQueryKind::Where { predicate });
22556        }
22557        self.expected("`exists`, `count where ... is <N>`, or `where ...`");
22558        None
22559    }
22560
22561    fn parse_source(&mut self) -> Option<SourceDecl> {
22562        let start = self.expect_keyword("source")?.span.start;
22563        let provider = self.expect_ident("source provider")?;
22564        let is_clock = provider.name == "clock";
22565        if !self.consume_ident("as") {
22566            self.expected("`as <name>` after the source provider");
22567            return None;
22568        }
22569        let name = self.expect_ident("source name")?;
22570        let open = self.expect_symbol('{')?;
22571
22572        let mut recurrence: Option<Recurrence> = None;
22573        let mut timezone: Option<StringLiteral> = None;
22574        let mut missed: Option<MissedPolicy> = None;
22575        let mut path: Option<StringLiteral> = None;
22576        let mut watch: Option<StringLiteral> = None;
22577        let mut url: Option<StringLiteral> = None;
22578        let mut dedup: Option<SourceValue> = None;
22579        let mut observe_binding: Option<Ident> = None;
22580        let mut emit: Option<SourceEmit> = None;
22581
22582        while !self.is_at_end() && !self.at_symbol('}') {
22583            if self.at_ident("every") || self.at_ident("at") {
22584                if let Some(parsed) = self.parse_recurrence() {
22585                    recurrence = Some(parsed);
22586                } else {
22587                    self.synchronize_to_block_item();
22588                }
22589            } else if self.at_ident("timezone") {
22590                self.advance();
22591                timezone = self.expect_string("timezone string");
22592            } else if self.at_ident("path") {
22593                self.advance();
22594                path = self.expect_string("path string");
22595            } else if self.at_ident("watch") {
22596                self.advance();
22597                watch = self.expect_string("watch glob string");
22598            } else if self.at_ident("url") {
22599                self.advance();
22600                url = self.expect_string("url string");
22601            } else if self.at_ident("dedup") {
22602                self.advance();
22603                dedup = self.parse_source_value();
22604            } else if self.at_ident("missed") {
22605                missed = self.parse_missed_policy();
22606            } else if self.at_ident("observe") {
22607                self.advance();
22608                if !self.consume_ident("as") {
22609                    self.expected("`as <binding>` after `observe`");
22610                }
22611                observe_binding = self.expect_ident("observe binding");
22612            } else if self.at_ident("emit") {
22613                emit = self.parse_source_emit();
22614            } else {
22615                self.unexpected(
22616                    "a source clause (`every`/`at`, `timezone`, `path`, `watch`, `url`, `dedup`, `missed`, `observe`, `emit`)",
22617                );
22618                self.synchronize_to_block_item();
22619            }
22620        }
22621        let end = self
22622            .expect_symbol('}')
22623            .map(|token| token.span.end)
22624            .unwrap_or(open.span.end);
22625        let span = SourceSpan { start, end };
22626
22627        let observe_binding = match observe_binding {
22628            Some(binding) => binding,
22629            None => {
22630                self.diagnostics.push(Diagnostic {
22631                    related: Vec::new(),
22632                    span,
22633                    message: format!("source `{}` must declare `observe as <binding>`", name.name),
22634                    suggestion: Some("add `observe as tick`".to_owned()),
22635                });
22636                return None;
22637            }
22638        };
22639        let emit = match emit {
22640            Some(emit) => emit,
22641            None => {
22642                self.diagnostics.push(Diagnostic {
22643                    related: Vec::new(),
22644                    span,
22645                    message: format!(
22646                        "source `{}` must declare `emit <signal> {{ ... }}`",
22647                        name.name
22648                    ),
22649                    suggestion: Some("add `emit triage.tick { ... }`".to_owned()),
22650                });
22651                return None;
22652            }
22653        };
22654
22655        let clock = if is_clock {
22656            let recurrence = match recurrence {
22657                Some(recurrence) => recurrence,
22658                None => {
22659                    self.diagnostics.push(Diagnostic {
22660                        related: Vec::new(),
22661                        span,
22662                        message: format!("clock source `{}` must declare a recurrence", name.name),
22663                        suggestion: Some(
22664                            "add `every weekday at 09:00`, `every 5m`, or `at 09:00`".to_owned(),
22665                        ),
22666                    });
22667                    return None;
22668                }
22669            };
22670            Some(ClockPolicy {
22671                recurrence,
22672                timezone,
22673                missed,
22674                span,
22675            })
22676        } else {
22677            if recurrence.is_some() || timezone.is_some() || missed.is_some() {
22678                self.diagnostics.push(Diagnostic {
22679                    related: Vec::new(),
22680                    span,
22681                    message: format!(
22682                        "source `{}` uses clock-only clauses but its provider is `{}`, not `clock`",
22683                        name.name, provider.name
22684                    ),
22685                    suggestion: Some(
22686                        "use `source clock as ...` for recurrence, timezone, or missed clauses"
22687                            .to_owned(),
22688                    ),
22689                });
22690            }
22691            None
22692        };
22693
22694        Some(SourceDecl {
22695            name,
22696            provider,
22697            clock,
22698            path,
22699            watch,
22700            url,
22701            dedup,
22702            observe_binding,
22703            emit,
22704            span,
22705        })
22706    }
22707
22708    fn parse_recurrence(&mut self) -> Option<Recurrence> {
22709        if self.at_ident("at") {
22710            let at = self.expect_keyword("at")?;
22711            let time = self.parse_time_of_day()?;
22712            return Some(Recurrence::At {
22713                span: at.span.join(time.span),
22714                time,
22715            });
22716        }
22717        let every = self.expect_keyword("every")?;
22718        if matches!(
22719            self.peek().map(|token| &token.kind),
22720            Some(TokenKind::Number(_))
22721        ) {
22722            let (value, _) = self.expect_u32("recurrence interval")?;
22723            let unit = self.expect_ident("duration unit (`s`, `m`, `h`, or `d`)")?;
22724            let seconds = match unit.name.as_str() {
22725                "s" => value as u64,
22726                "m" => value as u64 * 60,
22727                "h" => value as u64 * 3_600,
22728                "d" => value as u64 * 86_400,
22729                other => {
22730                    self.diagnostics.push(Diagnostic {
22731                        related: Vec::new(),
22732                        span: unit.span,
22733                        message: format!("unknown duration unit `{other}`"),
22734                        suggestion: Some("use `s`, `m`, `h`, or `d`".to_owned()),
22735                    });
22736                    return None;
22737                }
22738            };
22739            return Some(Recurrence::EveryDuration {
22740                seconds,
22741                source: format!("{value}{}", unit.name),
22742                span: every.span.join(unit.span),
22743            });
22744        }
22745        let pattern_ident =
22746            self.expect_ident("calendar pattern (`day`, `weekday`, or a weekday)")?;
22747        let pattern = match pattern_ident.name.as_str() {
22748            "day" => CalendarPattern::Day,
22749            "weekday" => CalendarPattern::Weekday,
22750            "monday" => CalendarPattern::Weekly(Weekday::Monday),
22751            "tuesday" => CalendarPattern::Weekly(Weekday::Tuesday),
22752            "wednesday" => CalendarPattern::Weekly(Weekday::Wednesday),
22753            "thursday" => CalendarPattern::Weekly(Weekday::Thursday),
22754            "friday" => CalendarPattern::Weekly(Weekday::Friday),
22755            "saturday" => CalendarPattern::Weekly(Weekday::Saturday),
22756            "sunday" => CalendarPattern::Weekly(Weekday::Sunday),
22757            other => {
22758                self.diagnostics.push(Diagnostic {
22759                    related: Vec::new(),
22760                    span: pattern_ident.span,
22761                    message: format!("unknown calendar pattern `{other}`"),
22762                    suggestion: Some(
22763                        "use `day`, `weekday`, or a weekday such as `monday`".to_owned(),
22764                    ),
22765                });
22766                return None;
22767            }
22768        };
22769        if !self.consume_ident("at") {
22770            self.expected("`at <hh:mm>` after the calendar pattern");
22771            return None;
22772        }
22773        let time = self.parse_time_of_day()?;
22774        Some(Recurrence::EveryCalendar {
22775            pattern,
22776            span: every.span.join(time.span),
22777            time,
22778        })
22779    }
22780
22781    fn parse_time_of_day(&mut self) -> Option<TimeOfDay> {
22782        let (hour, hour_span) = self.expect_u32("hour")?;
22783        self.expect_symbol(':')?;
22784        let (minute, minute_span) = self.expect_u32("minute")?;
22785        if hour > 23 || minute > 59 {
22786            self.diagnostics.push(Diagnostic {
22787                related: Vec::new(),
22788                span: hour_span.join(minute_span),
22789                message: format!("invalid time of day `{hour:02}:{minute:02}`"),
22790                suggestion: Some("use a 24-hour `hh:mm` such as `09:00`".to_owned()),
22791            });
22792            return None;
22793        }
22794        Some(TimeOfDay {
22795            hour: hour as u8,
22796            minute: minute as u8,
22797            span: hour_span.join(minute_span),
22798        })
22799    }
22800
22801    fn parse_missed_policy(&mut self) -> Option<MissedPolicy> {
22802        self.expect_keyword("missed")?;
22803        if self.consume_ident("skip") {
22804            return Some(MissedPolicy::Skip);
22805        }
22806        if self.consume_ident("coalesce") {
22807            return Some(MissedPolicy::Coalesce);
22808        }
22809        if self.consume_ident("catch_up") {
22810            if !self.consume_ident("limit") {
22811                self.expected("`limit <N>` after `catch_up`");
22812                return None;
22813            }
22814            let (limit, _) = self.expect_u32("catch_up limit")?;
22815            return Some(MissedPolicy::CatchUp { limit });
22816        }
22817        self.expected("`skip`, `coalesce`, or `catch_up limit <N>`");
22818        None
22819    }
22820
22821    fn parse_source_emit(&mut self) -> Option<SourceEmit> {
22822        let emit = self.expect_keyword("emit")?;
22823        let first = self.expect_ident("emit signal name")?;
22824        let mut signal = first.name.clone();
22825        let mut signal_span = first.span;
22826        while self.at_symbol('.') {
22827            self.advance();
22828            let segment = self.expect_ident("signal name segment")?;
22829            signal.push('.');
22830            signal.push_str(&segment.name);
22831            signal_span = signal_span.join(segment.span);
22832        }
22833        let from = if self.consume_ident("from") {
22834            Some(self.expect_ident("binding name after `from`")?)
22835        } else {
22836            None
22837        };
22838        if from.is_some() && !self.at_symbol('{') {
22839            let end = from.as_ref().map(|ident| ident.span.end).unwrap_or(0);
22840            return Some(SourceEmit {
22841                signal,
22842                signal_span,
22843                from,
22844                fields: Vec::new(),
22845                span: SourceSpan {
22846                    start: emit.span.start,
22847                    end,
22848                },
22849            });
22850        }
22851        let open = self.expect_symbol('{')?;
22852        let mut fields = Vec::new();
22853        while !self.is_at_end() && !self.at_symbol('}') {
22854            let Some(field_name) = self.expect_ident("emit field name") else {
22855                self.synchronize_to_block_item();
22856                continue;
22857            };
22858            let Some(value) = self.parse_source_value() else {
22859                self.synchronize_to_block_item();
22860                continue;
22861            };
22862            let value_span = match &value {
22863                SourceValue::Path { span, .. } => *span,
22864                SourceValue::String(literal) => literal.span,
22865                SourceValue::Number(_, span) => *span,
22866            };
22867            fields.push(SourceEmitField {
22868                span: field_name.span.join(value_span),
22869                name: field_name,
22870                value,
22871            });
22872        }
22873        let end = self
22874            .expect_symbol('}')
22875            .map(|token| token.span.end)
22876            .unwrap_or(open.span.end);
22877        Some(SourceEmit {
22878            signal,
22879            signal_span,
22880            from,
22881            fields,
22882            span: SourceSpan {
22883                start: emit.span.start,
22884                end,
22885            },
22886        })
22887    }
22888
22889    fn parse_source_value(&mut self) -> Option<SourceValue> {
22890        match self.peek().map(|token| &token.kind) {
22891            Some(TokenKind::String(_)) => self.expect_string("value").map(SourceValue::String),
22892            Some(TokenKind::Number(_)) => {
22893                let token = self.advance().clone();
22894                if let TokenKind::Number(value) = token.kind {
22895                    Some(SourceValue::Number(value, token.span))
22896                } else {
22897                    None
22898                }
22899            }
22900            Some(TokenKind::Ident(_)) => {
22901                let binding = self.expect_ident("value path")?;
22902                let mut segments = Vec::new();
22903                let mut span = binding.span;
22904                while self.at_symbol('.') {
22905                    self.advance();
22906                    let segment = self.expect_ident("path segment")?;
22907                    span = span.join(segment.span);
22908                    segments.push(segment);
22909                }
22910                Some(SourceValue::Path {
22911                    binding,
22912                    segments,
22913                    span,
22914                })
22915            }
22916            _ => {
22917                self.expected("a value (observation path, string, or number)");
22918                None
22919            }
22920        }
22921    }
22922
22923    fn parse_class(&mut self) -> Option<ClassDecl> {
22924        let start = self.expect_keyword("class")?.span.start;
22925        let name = self.expect_ident("class name")?;
22926        let open = self.expect_symbol('{')?;
22927        let mut fields = Vec::new();
22928
22929        while !self.is_at_end() && !self.at_symbol('}') {
22930            let Some(field_name) = self.expect_ident("class field name") else {
22931                self.synchronize_to_block_item();
22932                continue;
22933            };
22934            let Some(ty) = self.parse_type() else {
22935                self.synchronize_to_block_item();
22936                continue;
22937            };
22938            // `@key`: mark this field as the class's natural key (import per-row
22939            // idempotency, spec/std-library/files.md).
22940            let mut is_key = false;
22941            if self.at_symbol('@') {
22942                if let Some(tag) = self.parse_tag() {
22943                    if tag.name == "key" {
22944                        is_key = true;
22945                    } else {
22946                        self.diagnostics.push(Diagnostic {
22947                            related: Vec::new(),
22948                            span: tag.span,
22949                            message: format!("unknown field tag `@{}`", tag.name),
22950                            suggestion: Some(
22951                                "the only field tag is `@key` (the class natural key)".to_owned(),
22952                            ),
22953                        });
22954                    }
22955                }
22956            }
22957            let presence_condition = self.parse_field_presence_condition();
22958            let span = field_name.span.join(ty.span());
22959            fields.push(ClassField {
22960                span,
22961                name: field_name,
22962                ty,
22963                is_key,
22964                presence_condition,
22965            });
22966        }
22967
22968        let end = self
22969            .expect_symbol('}')
22970            .map(|token| token.span.end)
22971            .unwrap_or(open.span.end);
22972
22973        Some(ClassDecl {
22974            name,
22975            fields,
22976            span: SourceSpan { start, end },
22977        })
22978    }
22979
22980    fn parse_table(
22981        &mut self,
22982        tags: Vec<TagDecl>,
22983        description: Option<StringLiteral>,
22984    ) -> Option<TableDecl> {
22985        let start = self.expect_keyword("table")?.span.start;
22986        let name = self.expect_ident("table name")?;
22987        self.expect_keyword("as")?;
22988        let schema = self.expect_ident("table row class")?;
22989        let open = self.expect_symbol('[')?;
22990        let mut rows = Vec::new();
22991
22992        while !self.is_at_end() && !self.at_symbol(']') {
22993            if self.at_symbol(',') {
22994                self.advance();
22995                continue;
22996            }
22997            if !self.at_symbol('{') {
22998                self.unexpected("table row `{ ... }`");
22999                self.synchronize_to_table_row();
23000                continue;
23001            }
23002            if let Some(row) = self.parse_table_row() {
23003                rows.push(row);
23004            }
23005            if self.at_symbol(',') {
23006                self.advance();
23007            }
23008        }
23009
23010        let end = self
23011            .expect_symbol(']')
23012            .map(|token| token.span.end)
23013            .unwrap_or(open.span.end);
23014        Some(TableDecl {
23015            name,
23016            tags,
23017            description,
23018            schema,
23019            rows,
23020            span: SourceSpan { start, end },
23021        })
23022    }
23023
23024    fn parse_table_row(&mut self) -> Option<TableRow> {
23025        let open = self.expect_symbol('{')?;
23026        let body_start = open.span.end;
23027        let mut depth = 1usize;
23028        let mut body_end = body_start;
23029        let mut close_end = open.span.end;
23030
23031        while !self.is_at_end() {
23032            let token = self.advance().clone();
23033            match token.kind {
23034                TokenKind::Symbol('{') => {
23035                    depth += 1;
23036                    body_end = token.span.end;
23037                }
23038                TokenKind::Symbol('}') => {
23039                    depth -= 1;
23040                    if depth == 0 {
23041                        body_end = token.span.start;
23042                        close_end = token.span.end;
23043                        break;
23044                    }
23045                    body_end = token.span.end;
23046                }
23047                _ => body_end = token.span.end,
23048            }
23049        }
23050
23051        if depth != 0 {
23052            self.diagnostics.push(Diagnostic {
23053                related: Vec::new(),
23054                span: SourceSpan {
23055                    start: open.span.start,
23056                    end: body_end,
23057                },
23058                message: "unterminated table row".to_owned(),
23059                suggestion: Some("close the table row with `}`".to_owned()),
23060            });
23061            return None;
23062        }
23063
23064        let body_span = SourceSpan {
23065            start: body_start,
23066            end: body_end,
23067        };
23068        let (text, span) = trimmed_source_text(self.source_text(body_span), body_span);
23069        Some(TableRow {
23070            body: BlockSource { text, span },
23071            span: SourceSpan {
23072                start: open.span.start,
23073                end: close_end,
23074            },
23075        })
23076    }
23077
23078    fn parse_coerce(&mut self) -> Option<CoerceDecl> {
23079        let start = self.expect_keyword("coerce")?.span.start;
23080        let name = self.expect_ident("coerce name")?;
23081        let params = self.parse_param_list()?;
23082        self.expect_thin_arrow()?;
23083        let output = self.parse_type()?;
23084        // Block-less prompt-only form: `coerce f(a) -> T """…"""` — sugar for
23085        // a block whose sole clause is the prompt. Desugared here to the same
23086        // body text (`prompt <raw string>`), so lowering, prompt extraction,
23087        // and fingerprints are byte-for-byte the block form's.
23088        if !self.at_symbol('{') {
23089            if let Some(TokenKind::String(_)) = self.peek().map(|token| &token.kind) {
23090                let token = self.advance().clone();
23091                let raw = self
23092                    .source_text(SourceSpan {
23093                        start: token.span.start,
23094                        end: token.span.end,
23095                    })
23096                    .to_owned();
23097                let body = BlockSource {
23098                    text: format!("prompt {raw}"),
23099                    span: token.span,
23100                };
23101                let span = SourceSpan {
23102                    start,
23103                    end: body.span.end,
23104                };
23105                return Some(CoerceDecl {
23106                    name,
23107                    params,
23108                    output,
23109                    body,
23110                    span,
23111                });
23112            }
23113        }
23114        let body = self.parse_block_source()?;
23115        let span = SourceSpan {
23116            start,
23117            end: body.span.end,
23118        };
23119        Some(CoerceDecl {
23120            name,
23121            params,
23122            output,
23123            body,
23124            span,
23125        })
23126    }
23127
23128    fn parse_param_list(&mut self) -> Option<Vec<ParamDecl>> {
23129        self.expect_symbol('(')?;
23130        let mut params = Vec::new();
23131
23132        while !self.is_at_end() && !self.at_symbol(')') {
23133            let name = self.expect_ident("parameter name")?;
23134            let ty = self.parse_type()?;
23135            params.push(ParamDecl {
23136                span: name.span.join(ty.span()),
23137                name,
23138                ty,
23139            });
23140
23141            if self.at_symbol(',') {
23142                self.advance();
23143            } else if !self.at_symbol(')') {
23144                self.unexpected("`,` or `)`");
23145                while !self.is_at_end() && !self.at_symbol(')') && !self.at_symbol(',') {
23146                    self.advance();
23147                }
23148            }
23149        }
23150
23151        self.expect_symbol(')')?;
23152        Some(params)
23153    }
23154
23155    /// `action <name>(<param: type>, …) { <effect chain> }` (DR-0023). The body
23156    /// is captured as a block source; expansion at call sites is a later slice.
23157    fn parse_action(&mut self) -> Option<ActionDecl> {
23158        let start = self.expect_keyword("action")?.span.start;
23159        let name = self.expect_ident("action name")?;
23160        self.expect_symbol('(')?;
23161        let mut params = Vec::new();
23162        while !self.is_at_end() && !self.at_symbol(')') {
23163            let param_name = self.expect_ident("action parameter name")?;
23164            let ty = self.parse_type()?;
23165            let span = param_name.span.join(ty.span());
23166            params.push(ActionParam {
23167                name: param_name,
23168                ty,
23169                span,
23170            });
23171            if self.at_symbol(',') {
23172                self.advance();
23173            }
23174        }
23175        self.expect_symbol(')')?;
23176        let body = self.parse_block_source()?;
23177        let span = SourceSpan {
23178            start,
23179            end: body.span.end,
23180        };
23181        Some(ActionDecl {
23182            name,
23183            params,
23184            body,
23185            span,
23186        })
23187    }
23188
23189    fn parse_rule(
23190        &mut self,
23191        tags: Vec<TagDecl>,
23192        description: Option<StringLiteral>,
23193    ) -> Option<RuleDecl> {
23194        let start = self.expect_keyword("rule")?.span.start;
23195        let name = self.expect_ident("rule name")?;
23196        let mut whens = Vec::new();
23197
23198        while !self.is_at_end() && !self.at_arrow() {
23199            if self.at_ident("when") {
23200                whens.extend(self.parse_when_clauses()?);
23201            } else if self.at_ident("with") {
23202                let span = self
23203                    .peek()
23204                    .map(|token| token.span)
23205                    .unwrap_or(SourceSpan { start, end: start });
23206                self.diagnostics.push(Diagnostic {
23207                    related: Vec::new(),
23208                    span,
23209                    message: "`with` is not a rule readiness clause".to_owned(),
23210                    suggestion: Some("use `when` for rule conditions".to_owned()),
23211                });
23212                self.advance();
23213            } else {
23214                self.unexpected("`when` clause or `=>`");
23215                self.advance();
23216            }
23217        }
23218
23219        self.expect_arrow()?;
23220        let body = self.parse_block_source()?;
23221        let span = SourceSpan {
23222            start,
23223            end: body.span.end,
23224        };
23225        Some(RuleDecl {
23226            name,
23227            tags,
23228            description,
23229            whens,
23230            body,
23231            span,
23232        })
23233    }
23234
23235    fn parse_when_clauses(&mut self) -> Option<Vec<WhenClause>> {
23236        let when = self.expect_keyword("when")?;
23237        if self.at_symbol('{') {
23238            return self.parse_grouped_when_clauses(when.span);
23239        }
23240
23241        Some(vec![self.parse_when_clause_after_keyword(when.span)?])
23242    }
23243
23244    fn parse_assert(
23245        &mut self,
23246        tags: Vec<TagDecl>,
23247        description: Option<StringLiteral>,
23248    ) -> Option<AssertDecl> {
23249        let assert = self.expect_keyword("assert")?;
23250        let expr_start = assert.span.end;
23251        let line_end = self.source[expr_start..]
23252            .find('\n')
23253            .map(|offset| expr_start + offset)
23254            .unwrap_or(self.source.len());
23255        let mut expr_end = line_end;
23256
23257        while !self.is_at_end() && self.peek()?.span.start < line_end {
23258            expr_end = self.peek()?.span.end.min(line_end);
23259            self.advance();
23260        }
23261        expr_end = Self::extend_span_over_skipped_operators(self.source, expr_end, line_end);
23262
23263        let span = SourceSpan {
23264            start: expr_start,
23265            end: expr_end,
23266        };
23267        let (expr, span) = trimmed_source_text(self.source_text(span), span);
23268        Some(AssertDecl {
23269            tags,
23270            description,
23271            expr,
23272            span,
23273        })
23274    }
23275
23276    fn parse_when_clause_after_keyword(&mut self, when: SourceSpan) -> Option<WhenClause> {
23277        self.parse_when_clause_with_stop(when, false)
23278    }
23279
23280    /// Flow headers terminate at the body `{`; rule headers at `=>`.
23281    fn parse_when_clause_with_stop(
23282        &mut self,
23283        when: SourceSpan,
23284        stop_at_brace: bool,
23285    ) -> Option<WhenClause> {
23286        let text_start = when.end;
23287        let mut text_end = text_start;
23288
23289        while !(self.is_at_end()
23290            || self.at_arrow()
23291            || self.at_ident("when")
23292            || self.at_ident("rule")
23293            || stop_at_brace && self.at_symbol('{'))
23294        {
23295            text_end = self.peek()?.span.end;
23296            self.advance();
23297        }
23298        let limit = self
23299            .peek()
23300            .map(|token| token.span.start)
23301            .unwrap_or(self.source.len());
23302        text_end = Self::extend_span_over_skipped_operators(self.source, text_end, limit);
23303
23304        let span = SourceSpan {
23305            start: text_start,
23306            end: text_end,
23307        };
23308        let (text, span) = trimmed_source_text(self.source_text(span), span);
23309        Some(WhenClause { text, span })
23310    }
23311
23312    /// The file-level lexer steps over expression operators (`==`, `!=`,
23313    /// `<=`, `>=`, `&&`, `||`, `*`, `/`, `-`) without emitting tokens —
23314    /// expressions are re-parsed from raw source slices. A raw capture that
23315    /// walks TOKEN spans therefore stops short when the clause ends in a
23316    /// dangling operator, silently truncating `assert a ==` to `assert a`
23317    /// (which then mis-diagnoses as a non-boolean expression instead of a
23318    /// syntax error). Extend `end` across same-line trailing operator bytes
23319    /// so the expression parser sees the dangling operator. `=>`/`->` are
23320    /// real tokens and `//` starts a comment; neither is consumed here.
23321    fn extend_span_over_skipped_operators(source: &str, mut end: usize, limit: usize) -> usize {
23322        let bytes = source.as_bytes();
23323        loop {
23324            let mut cursor = end;
23325            while cursor < limit && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' {
23326                cursor += 1;
23327            }
23328            let width = match (bytes.get(cursor), bytes.get(cursor + 1)) {
23329                _ if cursor >= limit => break,
23330                (Some(b'='), Some(b'='))
23331                | (Some(b'!'), Some(b'='))
23332                | (Some(b'<'), Some(b'='))
23333                | (Some(b'>'), Some(b'='))
23334                | (Some(b'&'), Some(b'&'))
23335                | (Some(b'|'), Some(b'|')) => 2,
23336                (Some(b'/'), Some(b'/')) => break,
23337                (Some(b'-'), Some(b'>')) => break,
23338                (Some(b'*' | b'/' | b'-'), _) => 1,
23339                _ => break,
23340            };
23341            if cursor + width > limit {
23342                break;
23343            }
23344            end = cursor + width;
23345        }
23346        end
23347    }
23348
23349    fn parse_grouped_when_clauses(&mut self, when: SourceSpan) -> Option<Vec<WhenClause>> {
23350        let open = self.expect_symbol('{')?;
23351        let body_start = open.span.end;
23352        let mut depth = 1usize;
23353        let mut body_end = body_start;
23354        let mut close_end = open.span.end;
23355
23356        while !self.is_at_end() {
23357            let token = self.advance().clone();
23358            match token.kind {
23359                TokenKind::Symbol('{') => {
23360                    depth += 1;
23361                    body_end = token.span.end;
23362                }
23363                TokenKind::Symbol('}') => {
23364                    depth -= 1;
23365                    if depth == 0 {
23366                        body_end = token.span.start;
23367                        close_end = token.span.end;
23368                        break;
23369                    }
23370                    body_end = token.span.end;
23371                }
23372                _ => body_end = token.span.end,
23373            }
23374        }
23375
23376        if depth != 0 {
23377            self.diagnostics.push(Diagnostic {
23378                related: Vec::new(),
23379                span: SourceSpan {
23380                    start: when.start,
23381                    end: body_end,
23382                },
23383                message: "unterminated grouped `when` block".to_owned(),
23384                suggestion: Some("close the grouped readiness block with `}`".to_owned()),
23385            });
23386            return Some(Vec::new());
23387        }
23388
23389        let body_span = SourceSpan {
23390            start: body_start,
23391            end: body_end,
23392        };
23393        let mut clauses = Vec::new();
23394        let mut offset = 0usize;
23395        for line in self.source_text(body_span).split_inclusive('\n') {
23396            let line_without_newline = line.trim_end_matches('\n');
23397            let line_start = body_span.start + offset;
23398            offset += line.len();
23399            let leading = line_without_newline.len() - line_without_newline.trim_start().len();
23400            let trailing = line_without_newline.len() - line_without_newline.trim_end().len();
23401            let trimmed_start = line_start + leading;
23402            let trimmed_end = line_start + line_without_newline.len().saturating_sub(trailing);
23403            if trimmed_start >= trimmed_end {
23404                continue;
23405            }
23406            clauses.push(WhenClause {
23407                text: self.source[trimmed_start..trimmed_end].to_owned(),
23408                span: SourceSpan {
23409                    start: trimmed_start,
23410                    end: trimmed_end,
23411                },
23412            });
23413        }
23414
23415        if clauses.is_empty() {
23416            self.diagnostics.push(Diagnostic {
23417                related: Vec::new(),
23418                span: SourceSpan {
23419                    start: when.start,
23420                    end: close_end,
23421                },
23422                message: "grouped `when` block has no readiness clauses".to_owned(),
23423                suggestion: Some(
23424                    "add one condition per line, such as `started` or `Class as binding`"
23425                        .to_owned(),
23426                ),
23427            });
23428        }
23429
23430        Some(clauses)
23431    }
23432
23433    fn parse_block_source(&mut self) -> Option<BlockSource> {
23434        let open = self.expect_symbol('{')?;
23435        let body_start = open.span.end;
23436        let mut depth = 1usize;
23437        let mut body_end = body_start;
23438
23439        while !self.is_at_end() {
23440            let token = self.advance().clone();
23441            match token.kind {
23442                TokenKind::Symbol('{') => {
23443                    depth += 1;
23444                    body_end = token.span.end;
23445                }
23446                TokenKind::Symbol('}') => {
23447                    depth -= 1;
23448                    if depth == 0 {
23449                        body_end = token.span.start;
23450                        return Some(BlockSource {
23451                            text: self
23452                                .source_text(SourceSpan {
23453                                    start: body_start,
23454                                    end: body_end,
23455                                })
23456                                .trim()
23457                                .to_owned(),
23458                            span: SourceSpan {
23459                                start: open.span.start,
23460                                end: token.span.end,
23461                            },
23462                        });
23463                    }
23464                    body_end = token.span.end;
23465                }
23466                _ => {
23467                    body_end = token.span.end;
23468                }
23469            }
23470        }
23471
23472        self.diagnostics.push(Diagnostic {
23473            related: Vec::new(),
23474            span: SourceSpan {
23475                start: open.span.start,
23476                end: body_end,
23477            },
23478            message: "unterminated block".to_owned(),
23479            suggestion: Some("add a closing `}`".to_owned()),
23480        });
23481        Some(BlockSource {
23482            text: self
23483                .source_text(SourceSpan {
23484                    start: body_start,
23485                    end: body_end,
23486                })
23487                .trim()
23488                .to_owned(),
23489            span: SourceSpan {
23490                start: open.span.start,
23491                end: body_end,
23492            },
23493        })
23494    }
23495
23496    fn parse_type(&mut self) -> Option<TypeSyntax> {
23497        let first = self.parse_type_atom()?;
23498        let first = self.parse_type_suffixes(first);
23499
23500        if !self.at_symbol('|') {
23501            return Some(first);
23502        }
23503
23504        let start = first.span().start;
23505        let mut end = first.span().end;
23506        let mut variants = vec![first];
23507
23508        while self.at_symbol('|') {
23509            self.advance();
23510            let variant = self.parse_type_atom()?;
23511            let variant = self.parse_type_suffixes(variant);
23512            end = variant.span().end;
23513            variants.push(variant);
23514        }
23515
23516        Some(TypeSyntax::Union {
23517            variants,
23518            span: SourceSpan { start, end },
23519        })
23520    }
23521
23522    fn parse_type_atom(&mut self) -> Option<TypeSyntax> {
23523        Some(if self.at_ident("AgentRef") {
23524            let agent_ref = self.advance().clone();
23525            self.expect_symbol('<')?;
23526            let mut agents = Vec::new();
23527            while !self.is_at_end() && !self.at_symbol('>') {
23528                if self.at_symbol('|') {
23529                    self.advance();
23530                    continue;
23531                }
23532                let Some(agent) = self.expect_ident("agent reference") else {
23533                    break;
23534                };
23535                agents.push(agent);
23536            }
23537            let close = self.expect_symbol('>')?;
23538            TypeSyntax::AgentRef {
23539                agents,
23540                span: agent_ref.span.join(close.span),
23541            }
23542        } else if self.at_ident("map") {
23543            let map = self.advance().clone();
23544            self.expect_symbol('<')?;
23545            let inner = self.parse_type()?;
23546            let close = self.expect_symbol('>')?;
23547            TypeSyntax::Map {
23548                span: map.span.join(close.span),
23549                inner: Box::new(inner),
23550            }
23551        } else if matches!(
23552            self.peek().map(|token| &token.kind),
23553            Some(TokenKind::String(_))
23554        ) {
23555            let literal = self.expect_string("literal type")?;
23556            TypeSyntax::LiteralString {
23557                value: literal.value,
23558                span: literal.span,
23559            }
23560        } else {
23561            let ident = self.expect_ident("type name")?;
23562            if is_primitive_type(&ident.name) {
23563                TypeSyntax::Primitive {
23564                    name: ident.name,
23565                    span: ident.span,
23566                }
23567            } else {
23568                TypeSyntax::Ref { name: ident }
23569            }
23570        })
23571    }
23572
23573    fn parse_type_suffixes(&mut self, mut ty: TypeSyntax) -> TypeSyntax {
23574        loop {
23575            if self.at_symbol('?') {
23576                let question = self.advance().clone();
23577                ty = TypeSyntax::Optional {
23578                    span: ty.span().join(question.span),
23579                    inner: Box::new(ty),
23580                };
23581            } else if self.at_symbol('[') {
23582                self.advance();
23583                let Some(close) = self.expect_symbol(']') else {
23584                    return ty;
23585                };
23586                ty = TypeSyntax::Array {
23587                    span: ty.span().join(close.span),
23588                    inner: Box::new(ty),
23589                };
23590            } else {
23591                return ty;
23592            }
23593        }
23594    }
23595
23596    fn parse_string_list(&mut self) -> Option<(Vec<StringLiteral>, SourceSpan)> {
23597        let open = self.expect_symbol('[')?;
23598        let mut values = Vec::new();
23599
23600        while !self.is_at_end() && !self.at_symbol(']') {
23601            values.push(self.expect_string("skill string")?);
23602            if self.at_symbol(',') {
23603                self.advance();
23604            } else if !self.at_symbol(']') {
23605                self.unexpected("`,` or `]`");
23606                self.synchronize_to_block_item();
23607                break;
23608            }
23609        }
23610
23611        let close = self.expect_symbol(']')?;
23612        Some((values, open.span.join(close.span)))
23613    }
23614
23615    /// Parse a bracketed list of identifiers, e.g. `[WordCount, OpenPr]`. Used for
23616    /// the agent `tools` grant, whose entries reference declared workflows by name.
23617    fn parse_ident_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
23618        let open = self.expect_symbol('[')?;
23619        let mut values = Vec::new();
23620
23621        while !self.is_at_end() && !self.at_symbol(']') {
23622            values.push(self.expect_ident("tool workflow name")?);
23623            if self.at_symbol(',') {
23624                self.advance();
23625            } else if !self.at_symbol(']') {
23626                self.unexpected("`,` or `]`");
23627                self.synchronize_to_block_item();
23628                break;
23629            }
23630        }
23631
23632        let close = self.expect_symbol(']')?;
23633        Some((values, open.span.join(close.span)))
23634    }
23635
23636    /// Parse a bracketed list of dotted feature-class names, e.g.
23637    /// `[session.resume, turn.cancel]` (agent `requires`, DR-0015 taxonomy).
23638    /// Each entry is one or more identifiers joined by `.`; taxonomy
23639    /// membership is validated at lowering.
23640    fn parse_feature_class_list(&mut self) -> Option<(Vec<Ident>, SourceSpan)> {
23641        let open = self.expect_symbol('[')?;
23642        let mut values = Vec::new();
23643
23644        while !self.is_at_end() && !self.at_symbol(']') {
23645            let head = self.expect_ident("feature class")?;
23646            let mut name = head.name.clone();
23647            let mut span = head.span;
23648            while self.at_symbol('.') {
23649                self.advance();
23650                let part = self.expect_ident("feature class segment")?;
23651                name.push('.');
23652                name.push_str(&part.name);
23653                span = span.join(part.span);
23654            }
23655            values.push(Ident { name, span });
23656            if self.at_symbol(',') {
23657                self.advance();
23658            } else if !self.at_symbol(']') {
23659                self.unexpected("`,` or `]`");
23660                self.synchronize_to_block_item();
23661                break;
23662            }
23663        }
23664
23665        let close = self.expect_symbol(']')?;
23666        Some((values, open.span.join(close.span)))
23667    }
23668
23669    fn expect_keyword(&mut self, keyword: &str) -> Option<Token> {
23670        if self.at_ident(keyword) {
23671            Some(self.advance().clone())
23672        } else {
23673            self.expected(format!("`{keyword}`"));
23674            None
23675        }
23676    }
23677
23678    fn expect_ident(&mut self, label: &str) -> Option<Ident> {
23679        let token = self.peek()?;
23680        if let TokenKind::Ident(name) = &token.kind {
23681            let ident = Ident {
23682                name: name.clone(),
23683                span: token.span,
23684            };
23685            self.advance();
23686            Some(ident)
23687        } else {
23688            self.expected(label);
23689            None
23690        }
23691    }
23692
23693    /// Family B: an optional `when <discriminant> is "<literal>"` suffix on a
23694    /// schema/signal field — the field is present only when the literal-union
23695    /// discriminant field equals the literal. `is` is used instead of `==` to stay
23696    /// within the declaration tokenizer; the meaning is equality
23697    /// (spec/decision-records/discriminated-families-design.md §5.7).
23698    fn parse_field_presence_condition(&mut self) -> Option<(String, String)> {
23699        if !self.at_ident("when") {
23700            return None;
23701        }
23702        self.advance(); // `when`
23703        let disc = self.expect_ident("discriminant field name after `when`")?;
23704        if self.at_ident("is") {
23705            self.advance();
23706        } else {
23707            self.expected("`is` after the discriminant field");
23708            return None;
23709        }
23710        let literal = self.expect_string("discriminant literal value")?;
23711        Some((disc.name, literal.value))
23712    }
23713
23714    fn expect_string(&mut self, label: &str) -> Option<StringLiteral> {
23715        let token = self.peek()?;
23716        if let TokenKind::String(value) = &token.kind {
23717            let literal = StringLiteral {
23718                value: value.clone(),
23719                span: token.span,
23720            };
23721            self.advance();
23722            Some(literal)
23723        } else {
23724            self.expected(label);
23725            None
23726        }
23727    }
23728
23729    fn expect_use_name(&mut self, label: &str) -> Option<StringLiteral> {
23730        let token = self.peek()?;
23731        match &token.kind {
23732            // A package name may be a dotted path (`std.messaging`, `std.coord`)
23733            // or a bare ident (`memory`); a string literal is also accepted.
23734            TokenKind::Ident(value) => {
23735                let mut name = value.clone();
23736                let mut span = token.span;
23737                self.advance();
23738                while self.at_symbol('.') {
23739                    self.expect_symbol('.');
23740                    let Some(segment) = self.expect_ident("package name segment") else {
23741                        break;
23742                    };
23743                    name.push('.');
23744                    name.push_str(&segment.name);
23745                    span = span.join(segment.span);
23746                }
23747                Some(StringLiteral { value: name, span })
23748            }
23749            TokenKind::String(value) => {
23750                let literal = StringLiteral {
23751                    value: value.clone(),
23752                    span: token.span,
23753                };
23754                self.advance();
23755                Some(literal)
23756            }
23757            _ => {
23758                self.expected(label);
23759                None
23760            }
23761        }
23762    }
23763
23764    fn expect_u32(&mut self, label: &str) -> Option<(u32, SourceSpan)> {
23765        let token = self.peek()?;
23766        if let TokenKind::Number(value) = &token.kind {
23767            let span = token.span;
23768            let parsed = value.parse::<u32>();
23769            self.advance();
23770            match parsed {
23771                Ok(value) => Some((value, span)),
23772                Err(_) => {
23773                    self.diagnostics.push(Diagnostic {
23774                        related: Vec::new(),
23775                        span,
23776                        message: format!("{label} must fit in u32"),
23777                        suggestion: Some("use a non-negative integer such as `1`".to_owned()),
23778                    });
23779                    None
23780                }
23781            }
23782        } else {
23783            self.expected(label);
23784            None
23785        }
23786    }
23787
23788    fn expect_symbol(&mut self, symbol: char) -> Option<Token> {
23789        if self.at_symbol(symbol) {
23790            Some(self.advance().clone())
23791        } else {
23792            self.expected(format!("`{symbol}`"));
23793            None
23794        }
23795    }
23796
23797    fn expect_arrow(&mut self) -> Option<Token> {
23798        if self.at_arrow() {
23799            Some(self.advance().clone())
23800        } else {
23801            self.expected("`=>`");
23802            None
23803        }
23804    }
23805
23806    fn expect_thin_arrow(&mut self) -> Option<Token> {
23807        if self.at_thin_arrow() {
23808            Some(self.advance().clone())
23809        } else {
23810            self.expected("`->`");
23811            None
23812        }
23813    }
23814
23815    fn at_ident(&self, expected: &str) -> bool {
23816        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Ident(value)) if value == expected)
23817    }
23818
23819    fn consume_ident(&mut self, expected: &str) -> bool {
23820        if self.at_ident(expected) {
23821            self.advance();
23822            true
23823        } else {
23824            false
23825        }
23826    }
23827
23828    fn at_symbol(&self, expected: char) -> bool {
23829        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Symbol(value)) if *value == expected)
23830    }
23831
23832    fn at_arrow(&self) -> bool {
23833        matches!(self.peek().map(|token| &token.kind), Some(TokenKind::Arrow))
23834    }
23835
23836    fn at_thin_arrow(&self) -> bool {
23837        matches!(
23838            self.peek().map(|token| &token.kind),
23839            Some(TokenKind::ThinArrow)
23840        )
23841    }
23842
23843    fn peek(&self) -> Option<&Token> {
23844        self.tokens.get(self.pos)
23845    }
23846
23847    fn advance(&mut self) -> &Token {
23848        let index = self.pos;
23849        self.pos += 1;
23850        &self.tokens[index]
23851    }
23852
23853    fn is_at_end(&self) -> bool {
23854        self.pos >= self.tokens.len()
23855    }
23856
23857    fn expected(&mut self, expected: impl fmt::Display) {
23858        let expected = expected.to_string();
23859        let (span, found) = match self.peek() {
23860            Some(token) => (token.span, token.kind.label()),
23861            None => (
23862                SourceSpan {
23863                    start: self.source.len(),
23864                    end: self.source.len(),
23865                },
23866                "end of file".to_owned(),
23867            ),
23868        };
23869        self.diagnostics.push(Diagnostic {
23870            related: Vec::new(),
23871            span,
23872            message: format!("expected {expected}, found {found}"),
23873            suggestion: suggestion_for_expected(&expected),
23874        });
23875    }
23876
23877    fn unexpected(&mut self, expected: impl fmt::Display) {
23878        let Some(token) = self.peek() else {
23879            self.expected(expected);
23880            return;
23881        };
23882        let expected = expected.to_string();
23883        self.diagnostics.push(Diagnostic {
23884            related: Vec::new(),
23885            span: token.span,
23886            message: format!("expected {expected}, found {}", token.kind.label()),
23887            suggestion: suggestion_for_expected(&expected),
23888        });
23889    }
23890
23891    fn synchronize_to_block_item(&mut self) {
23892        while !self.is_at_end() {
23893            if self.at_symbol('}')
23894                || self.at_ident("profile")
23895                || self.at_ident("provider")
23896                || self.at_ident("capacity")
23897                || self.at_ident("skills")
23898                || self.at_ident("capabilities")
23899                || self.at_ident("tools")
23900                || self.at_ident("compaction")
23901                || self.at_ident("settings")
23902            {
23903                return;
23904            }
23905            self.advance();
23906        }
23907    }
23908
23909    fn synchronize_to_table_row(&mut self) {
23910        while !self.is_at_end() {
23911            if self.at_symbol('{') || self.at_symbol(']') {
23912                return;
23913            }
23914            self.advance();
23915        }
23916    }
23917
23918    fn source_text(&self, span: SourceSpan) -> &str {
23919        &self.source[span.start..span.end]
23920    }
23921}
23922
23923fn trimmed_source_text(source: &str, span: SourceSpan) -> (String, SourceSpan) {
23924    let leading = source.len() - source.trim_start().len();
23925    let trailing = source.len() - source.trim_end().len();
23926    let end = source.len().saturating_sub(trailing);
23927    if leading > end {
23928        return (
23929            String::new(),
23930            SourceSpan {
23931                start: span.end,
23932                end: span.end,
23933            },
23934        );
23935    }
23936    (
23937        source[leading..end].to_owned(),
23938        SourceSpan {
23939            start: span.start + leading,
23940            end: span.start + end,
23941        },
23942    )
23943}
23944
23945fn is_primitive_type(name: &str) -> bool {
23946    matches!(
23947        name,
23948        "string"
23949            | "int"
23950            | "float"
23951            | "bool"
23952            | "null"
23953            | "duration"
23954            | "time"
23955            | "image"
23956            | "audio"
23957            | "pdf"
23958            | "video"
23959    )
23960}
23961
23962fn is_gherkin_keyword(keyword: &str) -> bool {
23963    matches!(
23964        keyword,
23965        "Feature"
23966            | "Rule"
23967            | "Background"
23968            | "Scenario"
23969            | "ScenarioOutline"
23970            | "Scenario-Outline"
23971            | "Examples"
23972            | "Given"
23973            | "When"
23974            | "Then"
23975            | "And"
23976            | "But"
23977    )
23978}
23979
23980fn suggestion_for_expected(expected: &str) -> Option<String> {
23981    match expected {
23982        "`{`" => Some("add a `{ ... }` block".to_owned()),
23983        "`=>`" => Some("add `=> { ... }` after the rule conditions".to_owned()),
23984        "`->`" => Some("add `-> OutputType` before the coerce prompt block".to_owned()),
23985        "profile string" => Some("write `profile \"profile-name\"`".to_owned()),
23986        "capacity value" => Some("write `capacity 1`".to_owned()),
23987        "package library name" => Some("write a package library name, such as `memory`".to_owned()),
23988        "type name" => Some("write a primitive type or schema name".to_owned()),
23989        _ => None,
23990    }
23991}
23992
23993#[cfg(test)]
23994mod tests {
23995    use super::*;
23996
23997    #[test]
23998    fn parser_scaffold_links_to_core() {
23999        assert_eq!(parser_stage(), "release");
24000    }
24001
24002    #[test]
24003    fn declaration_block_grammar_table_is_complete() {
24004        // Drift canary for the build.rs-generated DECLARATION_BLOCK_GRAMMAR
24005        // table (D2.0 scaffolding). The table exists and is validated here; no
24006        // dispatch consumes it yet.
24007        let keywords: Vec<&str> = DECLARATION_BLOCK_GRAMMAR
24008            .iter()
24009            .map(|spec| spec.keyword)
24010            .collect();
24011        assert_eq!(
24012            keywords.len(),
24013            7,
24014            "expected exactly 7 declaration_block specs"
24015        );
24016        for expected in [
24017            "tracker",
24018            "channel",
24019            "counter",
24020            "lease",
24021            "ledger",
24022            "file store",
24023            "memory pool",
24024        ] {
24025            assert!(
24026                keywords.contains(&expected),
24027                "missing declaration_block keyword `{expected}`; got {keywords:?}"
24028            );
24029        }
24030
24031        let find = |keyword: &str| -> &DeclarationBlockSpec {
24032            DECLARATION_BLOCK_GRAMMAR
24033                .iter()
24034                .find(|spec| spec.keyword == keyword)
24035                .unwrap_or_else(|| panic!("no spec for `{keyword}`"))
24036        };
24037        let clause = |keyword: &str, name: &str| -> &ClauseSpec {
24038            find(keyword)
24039                .clauses
24040                .iter()
24041                .find(|clause| clause.name == name)
24042                .unwrap_or_else(|| panic!("no clause `{name}` on `{keyword}`"))
24043        };
24044
24045        // Multi-word keywords split into two words; single-word into one.
24046        assert_eq!(find("memory pool").keyword_words, &["memory", "pool"]);
24047        assert_eq!(find("file store").keyword_words, &["file", "store"]);
24048        assert_eq!(find("tracker").keyword_words, &["tracker"]);
24049
24050        // ledger `partition by` is the M2 connective clause.
24051        assert_eq!(clause("ledger", "partition").connective, Some("by"));
24052
24053        // lease `shared` is a flag (no value).
24054        assert!(matches!(clause("lease", "shared").kind, ClauseKind::Flag));
24055        assert!(!clause("lease", "shared").list);
24056        assert_eq!(clause("lease", "shared").connective, None);
24057
24058        // file-store `allow read`/`allow write` are multi-word glob lists.
24059        for (name, words) in [
24060            ("allow read", ["allow", "read"]),
24061            ("allow write", ["allow", "write"]),
24062        ] {
24063            let allow = clause("file store", name);
24064            assert!(allow.list, "`{name}` must be list:true");
24065            assert_eq!(allow.words, words);
24066            assert!(matches!(allow.kind, ClauseKind::Glob));
24067        }
24068
24069        // Exercise the head-word peek so the scaffolding method is live.
24070        let mut parser = Parser {
24071            source: "memory pool p { }",
24072            tokens: lex("memory pool p { }").tokens,
24073            pos: 0,
24074            diagnostics: Vec::new(),
24075            pending_contract_classes: Vec::new(),
24076        };
24077        let spec = parser
24078            .declaration_block_spec_at()
24079            .expect("head word `memory` must resolve to the memory pool spec");
24080        assert_eq!(spec.keyword, "memory pool");
24081        // It only peeks — position is unchanged.
24082        assert_eq!(parser.pos, 0);
24083        parser.diagnostics.clear();
24084    }
24085
24086    const SEND_PROGRAM: &str = r##"
24087@service
24088workflow Notify
24089
24090class Trigger { id string }
24091
24092agent worker { provider fixture  profile "r"  capacity 1 }
24093
24094channel alerts { provider fixture  destination "#ops" }
24095
24096table seed as Trigger [ { id "t" } ]
24097
24098rule notify
24099  when Trigger as t
24100=> {
24101  send via alerts {
24102    text "hello"
24103  } as sent
24104}
24105"##;
24106
24107    #[test]
24108    fn send_lowers_to_messaging_capability_call_without_builtin_registration() {
24109        // `send via <channel>` lowers to a `messaging.send` capability.call
24110        // construct use. The construct/contract registration itself is NOT a
24111        // parser builtin any more: it comes from the embedded `std.messaging`
24112        // manifest, merged by the CLI when the program imports the package
24113        // (`use std.messaging`). The parser still registers the `std.messaging`
24114        // standard library from the `channel` declaration (the ambient decl tier).
24115        let compiled = compile_program(SEND_PROGRAM);
24116        assert_eq!(
24117            compiled.diagnostics,
24118            Vec::new(),
24119            "{:?}",
24120            compiled.diagnostics
24121        );
24122        let ir = compiled.ir.expect("lowered IR");
24123        let uses = ir.construct_uses();
24124        assert_eq!(uses.len(), 1);
24125        assert_eq!(uses[0].keyword, "send");
24126        assert_eq!(uses[0].target_capability, "messaging.send");
24127        let registry = ir.contract_registry();
24128        assert!(
24129            registry.constructs.is_empty(),
24130            "the parser registers no builtin constructs: {:?}",
24131            registry.constructs
24132        );
24133        assert!(
24134            !registry
24135                .effect_contracts
24136                .iter()
24137                .any(|c| c.id == "messaging.send"),
24138            "the messaging.send contract comes from the embedded manifest, not the parser"
24139        );
24140        assert!(
24141            registry
24142                .libraries
24143                .iter()
24144                .any(|lib| lib.id == "std.messaging" && lib.standard),
24145            "the channel declaration still registers the std.messaging standard library"
24146        );
24147    }
24148
24149    #[test]
24150    fn send_to_unknown_channel_is_rejected() {
24151        let source = SEND_PROGRAM.replace("send via alerts", "send via ghost");
24152        let compiled = compile_program(&source);
24153        let violations: Vec<&Diagnostic> = compiled
24154            .diagnostics
24155            .iter()
24156            .filter(|d| d.message.contains("unknown channel"))
24157            .collect();
24158        assert_eq!(violations.len(), 1, "{:?}", compiled.diagnostics);
24159        assert!(violations[0].message.contains("ghost"));
24160    }
24161
24162    #[test]
24163    fn derives_contract_registry_from_imports_and_effects() {
24164        let source = r#"
24165workflow RegistrySlice
24166
24167use memory
24168
24169class Task {
24170  title string
24171}
24172
24173class Review {
24174  accepted bool
24175}
24176
24177coerce reviewTask(title string) -> Review {
24178  prompt """
24179  Review {{ title }}
24180  """
24181}
24182
24183agent worker {
24184  provider fixture
24185  profile "repo-writer"
24186  capacity 1
24187}
24188
24189rule start
24190  when Task as task
24191=> {
24192  tell worker as turn """
24193  Work on {{ task.title }}
24194  """
24195
24196  after turn succeeds {
24197    coerce reviewTask(task.title) as review
24198  }
24199}
24200"#;
24201
24202        let compiled = compile_program(source);
24203        assert_eq!(compiled.diagnostics, Vec::new());
24204        let ir = compiled.ir.expect("program compiles");
24205        let registry = ir.contract_registry();
24206        assert_eq!(registry.validate(), Vec::new());
24207
24208        assert!(registry
24209            .libraries
24210            .iter()
24211            .any(|library| library.id == "memory" && !library.standard));
24212        assert!(registry
24213            .libraries
24214            .iter()
24215            .any(|library| library.id == "std.agent" && library.standard));
24216        assert!(registry
24217            .libraries
24218            .iter()
24219            .any(|library| library.id == "std.coercion" && library.standard));
24220
24221        let coerce = registry
24222            .effect_contracts
24223            .iter()
24224            .find(|contract| contract.id == "schema.coerce")
24225            .expect("coerce contract");
24226        assert_eq!(coerce.library_id, "std.coercion");
24227        assert_eq!(coerce.validation, TypedOutputValidation::RuntimeBoundary);
24228        assert!(coerce.source_forms.contains(&"coerce".to_owned()));
24229        assert!(coerce.source_forms.contains(&"prompt".to_owned()));
24230        assert!(coerce
24231            .required_capabilities
24232            .contains(&"schema.coerce".to_owned()));
24233        assert_eq!(coerce.provider_kinds, vec!["schema_coercer".to_owned()]);
24234
24235        let agent = registry
24236            .effect_contracts
24237            .iter()
24238            .find(|contract| contract.id == "agent.tell")
24239            .expect("agent contract");
24240        assert_eq!(agent.library_id, "std.agent");
24241        assert_eq!(agent.output_schema.as_deref(), Some("AgentTurn"));
24242    }
24243
24244    #[test]
24245    fn capability_calls_require_the_target_capability() {
24246        let source = r#"
24247workflow PackageCall
24248
24249use memory
24250
24251class Task {
24252  title string
24253}
24254
24255rule start
24256  when Task as task
24257=> {
24258  call memory.query for task as context
24259}
24260"#;
24261
24262        let compiled = compile_program(source);
24263        assert_eq!(compiled.diagnostics, Vec::new());
24264        let ir = compiled.ir.expect("program compiles");
24265        let effect = ir.rules[0]
24266            .metadata
24267            .effects
24268            .iter()
24269            .find(|effect| effect.kind == IrEffectKind::CapabilityCall)
24270            .expect("capability call effect");
24271        assert_eq!(
24272            effect.required_capabilities,
24273            vec!["memory.query".to_owned()]
24274        );
24275
24276        let registry = ir.contract_registry();
24277        let contract = registry
24278            .effect_contracts
24279            .iter()
24280            .find(|contract| contract.id == "capability.call")
24281            .expect("capability call contract");
24282        assert!(contract
24283            .required_capabilities
24284            .contains(&"memory.query".to_owned()));
24285        assert!(!contract
24286            .required_capabilities
24287            .contains(&"capability.call".to_owned()));
24288    }
24289
24290    #[test]
24291    fn package_recall_form_lowers_to_capability_call_marker() {
24292        let source = r#"
24293workflow PackageRecall
24294
24295use memory
24296
24297memory pool project_memory {
24298  context limit 8
24299}
24300
24301class Task {
24302  title string
24303}
24304
24305rule start
24306  when Task as task
24307=> {
24308  recall project_memory for task as context
24309}
24310"#;
24311
24312        let compiled = compile_program(source);
24313        assert_eq!(compiled.diagnostics, Vec::new());
24314        let ir = compiled.ir.expect("program compiles");
24315        let effect = ir.rules[0]
24316            .metadata
24317            .effects
24318            .iter()
24319            .find(|effect| effect.kind == IrEffectKind::CapabilityCall)
24320            .expect("capability call effect");
24321        assert_eq!(effect.binding.as_deref(), Some("context"));
24322        assert_eq!(
24323            effect.required_capabilities,
24324            vec!["memory.query".to_owned()]
24325        );
24326        assert_eq!(
24327            effect.construct_use,
24328            Some(IrConstructUse {
24329                keyword: "recall".to_owned(),
24330                scope: "rule_body".to_owned(),
24331                construct_family: "effect_operation".to_owned(),
24332                lowering_target: "capability_call".to_owned(),
24333                target_capability: "memory.query".to_owned(),
24334            })
24335        );
24336        assert_eq!(ir.construct_uses().len(), 1);
24337        assert!(ir.to_snapshot().contains("construct=recall->memory.query"));
24338    }
24339
24340    fn b1g_body_matrix_program(body: &str) -> String {
24341        r#"
24342workflow B1gMatrix {
24343  use memory
24344
24345  memory pool project_memory {
24346    context limit 8
24347  }
24348
24349  output result Done
24350  failure error Failed
24351
24352  class Ticket {
24353    id string
24354    title string
24355    due_at time
24356    amount int
24357  }
24358
24359  class TicketPublic {
24360    id string
24361    title string
24362  }
24363
24364  class Workspace {
24365    id string
24366  }
24367
24368  class Note {
24369    text string
24370  }
24371
24372  class Done {
24373    ok bool
24374  }
24375
24376  class Failed {
24377    reason string
24378  }
24379
24380  class Review {
24381    summary string
24382    fixed bool
24383  }
24384
24385  class LedgerEntry {
24386    area string
24387    text string
24388  }
24389
24390  class Row {
24391    title string
24392  }
24393
24394  signal deploy.finished {
24395    service string
24396    status string
24397  }
24398
24399  tracker backlog {
24400    provider builtin
24401  }
24402
24403  lease workspace_slot {
24404    key Workspace
24405    slots 1
24406    ttl 30m
24407  }
24408
24409  ledger review_log {
24410    entry LedgerEntry
24411    partition by area
24412    retain 30d
24413  }
24414
24415  counter request_budget {
24416    key Ticket
24417    cap 10
24418    reset daily
24419  }
24420
24421  file store docs {
24422    root "./data"
24423    allow write ["**"]
24424  }
24425
24426  channel ops_room {
24427    provider fixture
24428  }
24429
24430  agent worker {
24431    provider fixture
24432    profile "repo-writer"
24433    capacity 1
24434  }
24435
24436  coerce classify(title string) -> Review {
24437    prompt "classify"
24438  }
24439
24440  rule probe
24441    when Ticket as ticket
24442    when Workspace as workspace
24443    when backlog has ready issue as item
24444    when worker is available
24445  => {
24446__BODY__
24447  }
24448}
24449
24450workflow Child {
24451  input task ChildTask
24452  output result ChildResult
24453
24454  class ChildTask {
24455    title string
24456  }
24457
24458  class ChildResult {
24459    summary string
24460  }
24461
24462  rule finish
24463    when ChildTask as task
24464  => {
24465    complete result {
24466      summary task.title
24467    }
24468  }
24469}
24470"#
24471        .replace("__BODY__", body)
24472    }
24473
24474    #[test]
24475    fn coordination_shared_declarations_lower_to_ir() {
24476        let source = r#"
24477workflow SharedCoord
24478
24479class Key {
24480  id string
24481}
24482
24483class Entry {
24484  area string
24485}
24486
24487lease shared_slot {
24488  shared
24489  key Key
24490  slots 1
24491  ttl 30m
24492}
24493
24494ledger shared_log {
24495  shared
24496  entry Entry
24497  partition by area
24498  retain 30d
24499}
24500
24501counter shared_budget {
24502  shared
24503  key Key
24504  cap 10
24505  reset daily
24506}
24507"#;
24508        let compiled = compile_program(source);
24509        assert!(
24510            compiled.diagnostics.is_empty(),
24511            "unexpected diagnostics: {:?}",
24512            compiled.diagnostics
24513        );
24514        let ir = compiled.ir.expect("valid IR");
24515        assert!(ir
24516            .leases
24517            .iter()
24518            .any(|lease| lease.name == "shared_slot" && lease.shared && lease.ttl_seconds == 1800));
24519        assert!(ir
24520            .ledgers
24521            .iter()
24522            .any(|ledger| ledger.name == "shared_log" && ledger.shared));
24523        assert!(ir
24524            .counters
24525            .iter()
24526            .any(|counter| counter.name == "shared_budget" && counter.shared));
24527    }
24528
24529    #[test]
24530    fn file_store_clause_spans_are_first_word_tokens() {
24531        // HARD INVARIANT: root_span/read_span/write_span are the FIRST
24532        // clause-name-word token span (`root`/`allow`/`allow`), not the value or
24533        // a joined multi-word span. These serialize into FileStoreDecl and feed
24534        // `whip fmt`; the `.ir` golden does not witness them, so assert directly
24535        // (mirrors examples/file-store-demo.whip).
24536        let source = r#"
24537workflow FileSpanProbe
24538file store notes_store {
24539  root "./data"
24540  allow read ["notes/**"]
24541  allow write ["notes/**"]
24542}
24543"#;
24544        let parsed = parse_program(source);
24545        assert_eq!(
24546            parsed.diagnostics,
24547            Vec::new(),
24548            "unexpected diagnostics: {:?}",
24549            parsed.diagnostics
24550        );
24551        let store = parsed
24552            .program
24553            .items
24554            .iter()
24555            .find_map(|item| match item {
24556                Item::FileStore(decl) => Some(decl),
24557                _ => None,
24558            })
24559            .expect("file store decl");
24560        let text_at = |span: SourceSpan| &source[span.start..span.end];
24561        assert_eq!(text_at(store.root_span.expect("root span")), "root");
24562        assert_eq!(text_at(store.read_span.expect("read span")), "allow");
24563        assert_eq!(text_at(store.write_span.expect("write span")), "allow");
24564        // No `provider` clause: the decl records None (the runtime default is
24565        // `local`), and the `.ir` snapshot stays provider-free (F5 zero-churn).
24566        assert_eq!(store.provider, None);
24567        let ir = compile_program(source).ir.expect("valid IR");
24568        assert_eq!(ir.file_stores[0].provider, None);
24569        assert!(!ir.to_snapshot().contains("provider"));
24570    }
24571
24572    #[test]
24573    fn file_store_provider_clause_parses_and_unknown_is_rejected() {
24574        // spec/std-files.md "Surface" (slice F5): the optional block-internal
24575        // `provider <ident>` clause parses (aligning with the channel
24576        // declaration's provider clause), serializes into the snapshot, and
24577        // an identifier outside the v1 provider list is a check error.
24578        let source = r#"
24579workflow FileProviderProbe
24580file store notes_store {
24581  root "./data"
24582  allow read ["notes/**"]
24583  provider local
24584}
24585"#;
24586        let compiled = compile_program(source);
24587        assert_eq!(
24588            compiled.diagnostics,
24589            Vec::new(),
24590            "unexpected diagnostics: {:?}",
24591            compiled.diagnostics
24592        );
24593        let ir = compiled.ir.expect("valid IR");
24594        assert_eq!(ir.file_stores[0].provider.as_deref(), Some("local"));
24595        assert!(ir.to_snapshot().contains("    provider local"));
24596
24597        let unknown = source.replace("provider local", "provider s3");
24598        let compiled = compile_program(&unknown);
24599        assert!(
24600            compiled.diagnostics.iter().any(|diagnostic| {
24601                diagnostic
24602                    .message
24603                    .contains("file store `notes_store` names unknown provider `s3`")
24604            }),
24605            "unknown provider must be a check error: {:?}",
24606            compiled.diagnostics
24607        );
24608    }
24609
24610    #[test]
24611    fn formatter_preserves_file_store_provider_clause() {
24612        let source = r#"
24613workflow FileProviderFmt
24614file store notes_store { root "./data" provider local }
24615"#;
24616        let formatted = format_program(source);
24617        assert_eq!(formatted.diagnostics, Vec::new());
24618        let formatted = formatted.formatted.expect("formats");
24619        assert!(
24620            formatted.contains("file store notes_store {\n  root \"./data\"\n  provider local\n}"),
24621            "{formatted}"
24622        );
24623    }
24624
24625    #[test]
24626    fn formatter_preserves_shared_coordination_declarations() {
24627        let source = r#"
24628workflow SharedCoord
24629class Key { id string }
24630lease shared_slot { shared key Key slots 1 ttl 30m }
24631"#;
24632        let formatted = format_program(source);
24633        assert_eq!(formatted.diagnostics, Vec::new());
24634        let formatted = formatted.formatted.expect("formats");
24635        assert!(formatted.contains("lease shared_slot {\n  shared\n  key Key"));
24636    }
24637
24638    fn b1g_probe_rule(case_name: &str, body: &str) -> IrRule {
24639        let source = b1g_body_matrix_program(body);
24640        let compiled = compile_program_with_root(&source, Some("B1gMatrix"));
24641        assert!(
24642            compiled.diagnostics.is_empty(),
24643            "{case_name} emitted diagnostics: {:?}",
24644            compiled.diagnostics
24645        );
24646        let ir = compiled.ir.expect("valid matrix IR");
24647        ir.rules
24648            .into_iter()
24649            .find(|rule| rule.name == "probe")
24650            .expect("probe rule")
24651    }
24652
24653    fn b1g_effect<'a>(
24654        rule: &'a IrRule,
24655        kind: IrEffectKind,
24656        binding: Option<&str>,
24657        case_name: &str,
24658    ) -> &'a IrEffectNode {
24659        rule.metadata
24660            .effects
24661            .iter()
24662            .find(|effect| effect.kind == kind && effect.binding.as_deref() == binding)
24663            .unwrap_or_else(|| {
24664                panic!(
24665                    "{case_name} did not lower {kind:?} / {binding:?}; effects: {:?}",
24666                    rule.metadata.effects
24667                )
24668            })
24669    }
24670
24671    #[test]
24672    fn accepted_rule_body_matrix_has_no_silent_noops() {
24673        let effect_cases = [
24674            (
24675                "tell",
24676                r#"    tell worker as turn "go""#,
24677                IrEffectKind::AgentTell,
24678                Some("turn"),
24679            ),
24680            (
24681                "coerce",
24682                r#"    coerce classify(ticket.title) as review"#,
24683                IrEffectKind::SchemaCoerce,
24684                Some("review"),
24685            ),
24686            (
24687                "prompt",
24688                r#"    prompt "Summarize {{ ticket.title }}" using fixture as summary"#,
24689                IrEffectKind::SchemaCoerce,
24690                Some("summary"),
24691            ),
24692            (
24693                "decide",
24694                r#"    decide "fixed?" -> { fixed bool } as verdict"#,
24695                IrEffectKind::SchemaCoerce,
24696                Some("verdict"),
24697            ),
24698            (
24699                "call",
24700                r#"    call memory.query for ticket as called"#,
24701                IrEffectKind::CapabilityCall,
24702                Some("called"),
24703            ),
24704            (
24705                "recall",
24706                r#"    recall project_memory for ticket.title as memories"#,
24707                IrEffectKind::CapabilityCall,
24708                Some("memories"),
24709            ),
24710            (
24711                "send",
24712                r#"    send via ops_room { text ticket.title } as sent"#,
24713                IrEffectKind::CapabilityCall,
24714                Some("sent"),
24715            ),
24716            (
24717                "invoke",
24718                r#"    invoke Child { task { title ticket.title } } as child"#,
24719                IrEffectKind::WorkflowInvoke,
24720                Some("child"),
24721            ),
24722            (
24723                "timer_duration",
24724                r#"    timer 5m as wait"#,
24725                IrEffectKind::TimerWait,
24726                Some("wait"),
24727            ),
24728            (
24729                "timer_until",
24730                r#"    timer until ticket.due_at as deadline"#,
24731                IrEffectKind::TimerWait,
24732                Some("deadline"),
24733            ),
24734            (
24735                "exec_raw",
24736                r#"    exec "echo hi" as run"#,
24737                IrEffectKind::ExecCommand,
24738                Some("run"),
24739            ),
24740            (
24741                "queue_file",
24742                r#"    file issue into backlog { title ticket.title body "body" } as filed"#,
24743                IrEffectKind::TrackerFile,
24744                Some("filed"),
24745            ),
24746            (
24747                "queue_claim",
24748                r#"    claim item as lease"#,
24749                IrEffectKind::TrackerClaim,
24750                Some("lease"),
24751            ),
24752            (
24753                "queue_release",
24754                r#"    release item"#,
24755                IrEffectKind::TrackerRelease,
24756                None,
24757            ),
24758            (
24759                "queue_finish",
24760                r#"    finish item { summary ticket.title }"#,
24761                IrEffectKind::TrackerFinish,
24762                None,
24763            ),
24764            (
24765                "lease_acquire",
24766                r#"    acquire workspace_slot for workspace until ttl as slot"#,
24767                IrEffectKind::LeaseAcquire,
24768                Some("slot"),
24769            ),
24770            (
24771                "ledger_append",
24772                r#"    append LedgerEntry { area ticket.id text ticket.title } to review_log as entry"#,
24773                IrEffectKind::LedgerAppend,
24774                Some("entry"),
24775            ),
24776            (
24777                "counter_consume",
24778                r#"    consume request_budget for ticket amount ticket.amount as spend
24779
24780    after spend ok {
24781      record Note { text "ok" }
24782    }
24783
24784    after spend over {
24785      record Note { text "over" }
24786    }"#,
24787                IrEffectKind::CounterConsume,
24788                Some("spend"),
24789            ),
24790            (
24791                "notify",
24792                r#"    emit signal deploy.finished to ticket.id { service ticket.title status "ok" } as signal_sent"#,
24793                IrEffectKind::SignalEmit,
24794                Some("signal_sent"),
24795            ),
24796            (
24797                "file_read",
24798                r#"    read text from docs at "note.md" as file_read"#,
24799                IrEffectKind::FileRead,
24800                Some("file_read"),
24801            ),
24802            (
24803                "file_write",
24804                r#"    write text to docs at "out.md" { body ticket.title mode create } as file_write"#,
24805                IrEffectKind::FileWrite,
24806                Some("file_write"),
24807            ),
24808            (
24809                "file_import",
24810                r#"    import json Row from docs at "rows.json" as imported"#,
24811                IrEffectKind::FileImport,
24812                Some("imported"),
24813            ),
24814            (
24815                "file_export",
24816                r#"    export json Row to docs at "rows.json" { mode create } as exported"#,
24817                IrEffectKind::FileExport,
24818                Some("exported"),
24819            ),
24820        ];
24821
24822        for (case_name, body, kind, binding) in effect_cases {
24823            let rule = b1g_probe_rule(case_name, body);
24824            let effect = b1g_effect(&rule, kind, binding, case_name);
24825            match case_name {
24826                "send" => {
24827                    assert_eq!(effect.resource.as_deref(), Some("ops_room"));
24828                    assert_eq!(
24829                        effect
24830                            .construct_use
24831                            .as_ref()
24832                            .map(|use_| use_.keyword.as_str()),
24833                        Some("send")
24834                    );
24835                }
24836                "notify" => {
24837                    assert_eq!(effect.resource.as_deref(), Some("signal:deploy.finished"));
24838                }
24839                "file_read" | "file_write" | "file_import" | "file_export" => {
24840                    assert_eq!(effect.resource.as_deref(), Some("docs"));
24841                }
24842                _ => {}
24843            }
24844        }
24845
24846        let record = b1g_probe_rule("record", r#"    record Note { text ticket.title }"#);
24847        assert!(record
24848            .metadata
24849            .fact_writes
24850            .contains(&"schema:Note".to_owned()));
24851        assert!(record
24852            .metadata
24853            .egress_payload_reads
24854            .get("fact:Note")
24855            .is_some_and(|roots| roots.contains("ticket")));
24856
24857        let done = b1g_probe_rule("done", r#"    done ticket"#);
24858        assert!(done
24859            .metadata
24860            .fact_consumes
24861            .contains(&"schema:Ticket".to_owned()));
24862
24863        let done_replacement = b1g_probe_rule(
24864            "done_replacement",
24865            r#"    done ticket -> record Note { text ticket.title }"#,
24866        );
24867        assert!(done_replacement
24868            .metadata
24869            .fact_consumes
24870            .contains(&"schema:Ticket".to_owned()));
24871        assert!(done_replacement
24872            .metadata
24873            .fact_writes
24874            .contains(&"schema:Note".to_owned()));
24875
24876        let complete = b1g_probe_rule("complete", r#"    complete result { ok true }"#);
24877        assert!(complete
24878            .metadata
24879            .terminal_completes
24880            .contains(&"result".to_owned()));
24881
24882        let fail = b1g_probe_rule("fail", r#"    fail error { reason "bad" }"#);
24883        assert_eq!(fail.metadata.effects, Vec::new());
24884
24885        let exec_each = b1g_probe_rule("exec_each", r#"    exec "printf '{}'" -> each Row"#);
24886        b1g_effect(&exec_each, IrEffectKind::ExecCommand, None, "exec_each");
24887        assert!(exec_each
24888            .metadata
24889            .fact_writes
24890            .contains(&"schema:Row".to_owned()));
24891
24892        let bounded = b1g_probe_rule(
24893            "bounded_record",
24894            r#"    record TicketPublic from ticket {
24895      id
24896      title
24897    }"#,
24898        );
24899        assert!(
24900            bounded
24901                .metadata
24902                .bounded_egresses
24903                .iter()
24904                .any(|egress| egress.sink == "fact:TicketPublic"
24905                    && egress.keep == vec!["id".to_owned(), "title".to_owned()]),
24906            "{:?}",
24907            bounded.metadata.bounded_egresses
24908        );
24909
24910        let redaction = b1g_probe_rule(
24911            "redaction",
24912            r#"    redact ticket keep [id, title] as safe
24913    record TicketPublic from safe {
24914      id
24915      title
24916    }"#,
24917        );
24918        assert!(redaction
24919            .metadata
24920            .redactions
24921            .iter()
24922            .any(|projection| projection.source == "ticket" && projection.binding == "safe"));
24923        assert!(redaction
24924            .metadata
24925            .fact_writes
24926            .contains(&"schema:TicketPublic".to_owned()));
24927    }
24928
24929    #[test]
24930    fn prompt_lowers_to_coerce_with_string_payload() {
24931        let source = r#"
24932workflow PromptText
24933
24934output result string
24935
24936class Ticket {
24937  title string
24938}
24939
24940rule ask
24941  when Ticket as ticket
24942=> {
24943  prompt "Summarize {{ ticket.title }}" using fixture as answer
24944
24945  after answer succeeds as text {
24946    complete result text
24947  }
24948}
24949"#;
24950
24951        let compiled = compile_program(source);
24952        assert!(
24953            compiled.diagnostics.is_empty(),
24954            "prompt program diagnostics: {:?}",
24955            compiled.diagnostics
24956        );
24957        let ir = compiled.ir.expect("program compiles");
24958        let rule = ir
24959            .rules
24960            .iter()
24961            .find(|rule| rule.name == "ask")
24962            .expect("ask rule");
24963        let effect = rule
24964            .metadata
24965            .effects
24966            .iter()
24967            .find(|effect| effect.binding.as_deref() == Some("answer"))
24968            .expect("prompt effect");
24969        assert_eq!(effect.kind, IrEffectKind::SchemaCoerce);
24970
24971        let coerce = ir
24972            .contract_registry()
24973            .effect_contracts
24974            .into_iter()
24975            .find(|contract| contract.id == "schema.coerce")
24976            .expect("coerce contract");
24977        assert!(coerce.source_forms.contains(&"prompt".to_owned()));
24978    }
24979
24980    #[test]
24981    fn parses_schema_agent_and_rule_slice() {
24982        let source = r#"
24983workflow QueueWorkerSlice
24984
24985use memory
24986
24987tracker backlog {
24988  provider builtin
24989}
24990
24991enum ReviewStatus {
24992  Accept
24993  Revise
24994}
24995
24996class WorkReview {
24997  state "accepted" | "rejected"
24998  status ReviewStatus
24999  followups string[]
25000  maybeReason string?
25001  scores map<int>
25002}
25003
25004coerce reviewWork(issueTitle string, changedFiles string[]) -> WorkReview {
25005  prompt """
25006  Review {{ issueTitle }} with files {{ changedFiles }}
25007  """
25008}
25009
25010agent worker {
25011  provider fixture
25012  profile "repo-writer"
25013  capacity 1
25014  skills ["repo-user"]
25015}
25016
25017rule start_ready_item
25018  when backlog has ready issue as item
25019  when worker is available
25020=> {
25021  claim item as claim
25022
25023  after claim succeeds {
25024    tell worker """
25025    Implement {{ item.title }}
25026    """
25027  }
25028}
25029"#;
25030
25031        let parsed = parse_program(source);
25032        assert_eq!(parsed.diagnostics, Vec::new());
25033        let workflow = parsed
25034            .program
25035            .workflow
25036            .as_ref()
25037            .map(|ident| ident.name.as_str());
25038        assert_eq!(workflow, Some("QueueWorkerSlice"));
25039        assert_eq!(parsed.program.items.len(), 7);
25040
25041        let coerce = parsed.program.items.iter().find_map(|item| match item {
25042            Item::Coerce(coerce) => Some(coerce),
25043            _ => None,
25044        });
25045        let coerce = match coerce {
25046            Some(coerce) => coerce,
25047            None => panic!("expected coerce item"),
25048        };
25049        assert_eq!(coerce.params.len(), 2);
25050
25051        let rule = parsed.program.items.iter().find_map(|item| match item {
25052            Item::Rule(rule) => Some(rule),
25053            _ => None,
25054        });
25055        let rule = match rule {
25056            Some(rule) => rule,
25057            None => panic!("expected rule item"),
25058        };
25059        assert_eq!(rule.whens.len(), 2);
25060        assert_eq!(rule.whens[0].text, "backlog has ready issue as item");
25061        assert!(rule.body.text.contains("after claim succeeds"));
25062    }
25063
25064    #[test]
25065    fn parses_and_lowers_static_table_rows() {
25066        let source = r#"
25067workflow TableSeed
25068
25069agent codex {
25070  provider codex
25071  profile "repo-writer"
25072  capacity 1
25073}
25074
25075class Task {
25076  provider AgentRef<codex>
25077  title string
25078  priority int
25079  status "queued"
25080}
25081
25082table tasks as Task [
25083  {
25084    provider codex
25085    title "Review parser"
25086    priority 1
25087    status "queued"
25088  }
25089
25090  {
25091    provider codex
25092    title "Review runtime"
25093    priority 2
25094    status "queued"
25095  }
25096]
25097"#;
25098
25099        let parsed = parse_program(source);
25100        assert_eq!(parsed.diagnostics, Vec::new());
25101        let table = parsed
25102            .program
25103            .items
25104            .iter()
25105            .find_map(|item| match item {
25106                Item::Table(table) => Some(table),
25107                _ => None,
25108            })
25109            .expect("table item");
25110        assert_eq!(table.rows.len(), 2);
25111        let row_spans = table.rows.iter().map(|row| row.span).collect::<Vec<_>>();
25112
25113        let compiled = compile_program(source);
25114        let ir = compiled
25115            .ir
25116            .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25117        let table_rule = ir
25118            .rules
25119            .iter()
25120            .find(|rule| rule.name == "table_tasks")
25121            .expect("table lowers to generated started rule");
25122        assert_eq!(table_rule.whens[0].pattern, "started");
25123        assert!(table_rule.body.contains("record Task"));
25124        assert_eq!(table_rule.metadata.fact_writes, vec!["schema:Task"]);
25125        assert_eq!(table_rule.metadata.record_sources.len(), 2);
25126        assert_eq!(
25127            table_rule
25128                .metadata
25129                .record_sources
25130                .iter()
25131                .map(|source| (
25132                    source.schema.as_str(),
25133                    source.construct.as_str(),
25134                    source.span
25135                ))
25136                .collect::<Vec<_>>(),
25137            row_spans
25138                .iter()
25139                .map(|span| ("Task", "table_row", *span))
25140                .collect::<Vec<_>>()
25141        );
25142    }
25143
25144    #[test]
25145    fn rejects_old_matrix_declarations() {
25146        let source = r#"
25147workflow MatrixSeed
25148
25149class Task {
25150  title string
25151  status "queued"
25152}
25153
25154matrix tasks as Task [
25155  {
25156    title "Review parser"
25157    status "queued"
25158  }
25159]
25160"#;
25161
25162        let compiled = compile_program(source);
25163        assert!(compiled.ir.is_none());
25164        assert!(compiled.diagnostics.iter().any(|diagnostic| {
25165            diagnostic
25166                .message
25167                .contains("expected top-level declaration, found identifier `matrix`")
25168        }));
25169    }
25170
25171    #[test]
25172    fn rejects_table_rows_that_violate_row_schema() {
25173        let source = r#"
25174workflow BadTable
25175
25176agent codex {
25177  provider codex
25178  profile "repo-writer"
25179  capacity 1
25180}
25181
25182class Task {
25183  provider AgentRef<codex>
25184  status "queued"
25185}
25186
25187table tasks as Task [
25188  {
25189    provider "codex"
25190    status "done"
25191  }
25192]
25193"#;
25194
25195        let compiled = compile_program(source);
25196
25197        assert!(compiled.ir.is_none());
25198        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25199            .message
25200            .contains("expects an AgentRef value, not string `codex`")));
25201        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25202            .message
25203            .contains("expects literal string `queued`")));
25204    }
25205
25206    #[test]
25207    fn parses_formats_and_lowers_source_tags_as_metadata() {
25208        let source = r#"
25209@fixture
25210@release-gate
25211workflow Tagged
25212
25213class Task {
25214  status "queued"
25215}
25216
25217@seed
25218table tasks as Task [
25219  {
25220    status "queued"
25221  }
25222]
25223
25224@acceptance
25225assert count(Task where status == "queued") == 1
25226
25227@dispatch
25228rule consume_task
25229  when Task as task
25230=> {
25231  done task
25232}
25233"#;
25234
25235        let parsed = parse_program(source);
25236        assert_eq!(parsed.diagnostics, Vec::new());
25237        assert_eq!(
25238            parsed
25239                .program
25240                .workflow_tags
25241                .iter()
25242                .map(|tag| tag.name.as_str())
25243                .collect::<Vec<_>>(),
25244            vec!["fixture", "release-gate"]
25245        );
25246
25247        let formatted = format_program(source).formatted.expect("formats");
25248        assert!(formatted.contains("@fixture\n@release-gate\nworkflow Tagged"));
25249        assert!(formatted.contains("@seed\ntable tasks as Task"));
25250        assert!(formatted.contains("@acceptance\nassert count"));
25251        assert!(formatted.contains("@dispatch\nrule consume_task"));
25252
25253        let compiled = compile_program(source);
25254        let ir = compiled
25255            .ir
25256            .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25257        let tags = ir
25258            .source_tags
25259            .iter()
25260            .map(|tag| {
25261                (
25262                    tag.name.as_str(),
25263                    tag.target_kind.as_str(),
25264                    tag.target.as_str(),
25265                )
25266            })
25267            .collect::<Vec<_>>();
25268        assert!(tags.contains(&("fixture", "workflow", "Tagged")));
25269        assert!(tags.contains(&("release-gate", "workflow", "Tagged")));
25270        assert!(tags.contains(&("seed", "table", "tasks")));
25271        assert!(tags.contains(&("dispatch", "rule", "consume_task")));
25272        assert!(ir
25273            .source_tags
25274            .iter()
25275            .any(|tag| tag.name == "acceptance" && tag.target_kind == "assertion"));
25276    }
25277
25278    #[test]
25279    fn parses_formats_and_lowers_source_descriptions_as_metadata() {
25280        let source = r#"
25281@fixture
25282description "Fixture-backed acceptance workflow"
25283workflow Described
25284
25285class Task {
25286  status "queued"
25287}
25288
25289description "Static task seed rows"
25290table tasks as Task [
25291  {
25292    status "queued"
25293  }
25294]
25295
25296description "All seed tasks were consumed"
25297assert count(Task where status == "queued") == 0
25298
25299description "Consume one queued task"
25300rule consume_task
25301  when Task as task
25302=> {
25303  done task
25304}
25305"#;
25306
25307        let parsed = parse_program(source);
25308        assert_eq!(parsed.diagnostics, Vec::new());
25309        assert_eq!(
25310            parsed
25311                .program
25312                .workflow_description
25313                .as_ref()
25314                .map(|description| description.value.as_str()),
25315            Some("Fixture-backed acceptance workflow")
25316        );
25317
25318        let formatted = format_program(source).formatted.expect("formats");
25319        assert!(formatted.contains(
25320            "@fixture\ndescription \"Fixture-backed acceptance workflow\"\nworkflow Described"
25321        ));
25322        assert!(formatted.contains("description \"Static task seed rows\"\ntable tasks as Task"));
25323        assert!(formatted.contains("description \"All seed tasks were consumed\"\nassert count"));
25324        assert!(formatted.contains("description \"Consume one queued task\"\nrule consume_task"));
25325
25326        let compiled = compile_program(source);
25327        let ir = compiled
25328            .ir
25329            .unwrap_or_else(|| panic!("source compiles: {:?}", compiled.diagnostics));
25330        let descriptions = ir
25331            .source_descriptions
25332            .iter()
25333            .map(|description| {
25334                (
25335                    description.value.as_str(),
25336                    description.target_kind.as_str(),
25337                    description.target.as_str(),
25338                )
25339            })
25340            .collect::<Vec<_>>();
25341        assert!(descriptions.contains(&(
25342            "Fixture-backed acceptance workflow",
25343            "workflow",
25344            "Described"
25345        )));
25346        assert!(descriptions.contains(&("Static task seed rows", "table", "tasks")));
25347        assert!(descriptions.contains(&("Consume one queued task", "rule", "consume_task")));
25348        assert!(ir
25349            .source_descriptions
25350            .iter()
25351            .any(
25352                |description| description.value == "All seed tasks were consumed"
25353                    && description.target_kind == "assertion"
25354            ));
25355    }
25356
25357    #[test]
25358    fn rejects_descriptions_on_unsupported_declarations_for_now() {
25359        let source = r#"
25360workflow BadDescriptions
25361
25362description "Task schema"
25363class Task {
25364  status "queued"
25365}
25366"#;
25367
25368        let parsed = parse_program(source);
25369
25370        assert_eq!(parsed.diagnostics.len(), 1);
25371        assert_eq!(
25372            parsed.diagnostics[0].message,
25373            "description cannot be attached to class"
25374        );
25375    }
25376
25377    #[test]
25378    fn rejects_tags_on_unsupported_declarations_for_now() {
25379        let source = r#"
25380workflow BadTags
25381
25382@schema
25383class Task {
25384  status "queued"
25385}
25386"#;
25387
25388        let parsed = parse_program(source);
25389
25390        assert_eq!(parsed.diagnostics.len(), 1);
25391        assert_eq!(
25392            parsed.diagnostics[0].message,
25393            "tag `@schema` cannot be attached to class"
25394        );
25395    }
25396
25397    #[test]
25398    fn use_short_form_imports_package_libraries_and_rejects_removed_kinds() {
25399        let parsed = parse_program("workflow Imports\n\nuse memory\n");
25400        assert_eq!(parsed.diagnostics, Vec::new());
25401        let use_decl = parsed.program.items.iter().find_map(|item| match item {
25402            Item::Use(use_decl) => Some(use_decl),
25403            _ => None,
25404        });
25405        assert_eq!(
25406            use_decl.map(|decl| decl.name.value.as_str()),
25407            Some("memory")
25408        );
25409
25410        let removed_plugin = parse_program("workflow Imports\n\nuse plugin \"memory\"\n");
25411        assert_eq!(removed_plugin.diagnostics.len(), 1);
25412        assert_eq!(
25413            removed_plugin.diagnostics[0].message,
25414            "`use plugin` is no longer supported"
25415        );
25416
25417        let removed_skill = parse_program("workflow Imports\n\nuse skill \"repo-user\"\n");
25418        assert_eq!(removed_skill.diagnostics.len(), 1);
25419        assert_eq!(
25420            removed_skill.diagnostics[0].message,
25421            "`use skill` is no longer supported"
25422        );
25423    }
25424
25425    #[test]
25426    fn parses_include_declarations_and_records_ir_metadata() {
25427        let source = r#"include "library.whip"
25428
25429workflow Imports
25430
25431class Task {
25432  id string
25433}
25434"#;
25435        let parsed = parse_program(source);
25436        assert_eq!(parsed.diagnostics, Vec::new());
25437        let include = parsed.program.items.iter().find_map(|item| match item {
25438            Item::Include(include) => Some(include),
25439            _ => None,
25440        });
25441        assert_eq!(
25442            include.map(|decl| decl.path.value.as_str()),
25443            Some("library.whip")
25444        );
25445
25446        let compiled = compile_program(source);
25447        let ir = compiled.ir.expect("source compiles");
25448        assert_eq!(ir.includes[0].path, "library.whip");
25449        assert!(ir.to_snapshot().contains("includes\n  library.whip\n"));
25450    }
25451
25452    #[test]
25453    fn parses_explicit_workflow_block_and_contracts() {
25454        let source = r#"
25455workflow ReviewPhase {
25456  input phase PhaseReviewRequest
25457  output result PhaseReviewResult
25458  failure error ReviewFailure
25459
25460  class PhaseReviewRequest {
25461    title string
25462  }
25463
25464  class PhaseReviewResult {
25465    accepted bool
25466  }
25467
25468  class ReviewFailure {
25469    reason string
25470  }
25471
25472  rule noop
25473    when started
25474  => {
25475  }
25476}
25477"#;
25478        let compiled = compile_program(source);
25479        assert_eq!(compiled.diagnostics, Vec::new());
25480        let ir = compiled.ir.expect("source compiles");
25481        assert_eq!(ir.workflow, "ReviewPhase");
25482        assert_eq!(ir.workflow_contracts.len(), 3);
25483        let snapshot = ir.to_snapshot();
25484        assert!(snapshot.contains("workflow_contracts\n  input phase ref<PhaseReviewRequest>"));
25485        assert!(snapshot.contains("  output result ref<PhaseReviewResult>"));
25486        assert!(snapshot.contains("  failure error ref<ReviewFailure>"));
25487    }
25488
25489    #[test]
25490    fn revision_fixture_bundles_compile_with_expected_contract_shapes() {
25491        let compatible_v1 =
25492            compile_program(include_str!("../fixtures/revision-compatible-v1.whip"));
25493        let compatible_v2 =
25494            compile_program(include_str!("../fixtures/revision-compatible-v2.whip"));
25495        let incompatible_v2 =
25496            compile_program(include_str!("../fixtures/revision-incompatible-v2.whip"));
25497        for compiled in [&compatible_v1, &compatible_v2, &incompatible_v2] {
25498            assert_eq!(compiled.diagnostics, Vec::new());
25499        }
25500        let compatible_v1 = compatible_v1.ir.expect("compatible v1 compiles");
25501        let compatible_v2 = compatible_v2.ir.expect("compatible v2 compiles");
25502        let incompatible_v2 = incompatible_v2.ir.expect("incompatible v2 compiles");
25503
25504        assert_eq!(compatible_v1.workflow, "RevisionFixture");
25505        assert_eq!(compatible_v2.workflow, "RevisionFixture");
25506        assert_eq!(incompatible_v2.workflow, "RevisionFixture");
25507        assert_eq!(
25508            compatible_v1
25509                .workflow_contracts
25510                .iter()
25511                .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25512                .collect::<Vec<_>>(),
25513            compatible_v2
25514                .workflow_contracts
25515                .iter()
25516                .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25517                .collect::<Vec<_>>()
25518        );
25519        assert_ne!(
25520            compatible_v1
25521                .workflow_contracts
25522                .iter()
25523                .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25524                .collect::<Vec<_>>(),
25525            incompatible_v2
25526                .workflow_contracts
25527                .iter()
25528                .map(|contract| (&contract.kind, contract.name.as_str(), &contract.ty))
25529                .collect::<Vec<_>>()
25530        );
25531        assert!(compatible_v2
25532            .schemas
25533            .iter()
25534            .any(|schema| matches!(schema, IrSchema::Class(class) if class.name == "AuditTrail")));
25535    }
25536
25537    #[test]
25538    fn expands_pattern_applications_with_hygienic_names() {
25539        let source = r#"
25540pattern Review<Input> {
25541  class Result {
25542    item Input
25543  }
25544
25545  rule dispatch
25546    when Input as item
25547  => {
25548  }
25549}
25550
25551workflow Root {
25552  class Task {
25553    title string
25554  }
25555
25556  apply Review<Task> as taskReview {
25557  }
25558}
25559"#;
25560        let compiled = compile_program(source);
25561        assert_eq!(compiled.diagnostics, Vec::new());
25562        let ir = compiled.ir.expect("source compiles");
25563        let snapshot = ir.to_snapshot();
25564        assert!(snapshot.contains("pattern_applications\n  Review as taskReview<ref<Task>>"));
25565        assert!(snapshot.contains("    generated class:taskReview_Result"));
25566        assert!(snapshot.contains("    generated rule:taskReview_dispatch"));
25567        assert!(snapshot.contains("class taskReview_Result"));
25568        assert!(snapshot.contains("    item ref<Task>"));
25569        assert!(snapshot.contains("rule taskReview_dispatch"));
25570        assert!(snapshot.contains("    when Task as item"));
25571    }
25572
25573    #[test]
25574    fn pattern_application_records_definition_and_application_spans() {
25575        let source = r#"
25576pattern Review<Input> {
25577  rule dispatch
25578    when Input as item
25579  => {
25580  }
25581}
25582
25583workflow Root {
25584  class Task {
25585    title string
25586  }
25587
25588  apply Review<Task> as taskReview {
25589  }
25590}
25591"#;
25592        let compiled = compile_program(source);
25593        assert_eq!(compiled.diagnostics, Vec::new());
25594        let ir = compiled.ir.expect("source compiles");
25595        let application = ir
25596            .pattern_applications
25597            .first()
25598            .expect("one pattern application");
25599
25600        // The recorded definition span must cover the `pattern Review<...> { ... }`
25601        // declaration, and the application span the `apply ... as ... { ... }` site.
25602        let definition =
25603            &source[application.definition_span.start..application.definition_span.end];
25604        assert!(definition.starts_with("pattern Review"));
25605        assert!(definition.ends_with('}'));
25606        let application_site =
25607            &source[application.application_span.start..application.application_span.end];
25608        assert!(application_site.starts_with("apply Review<Task> as taskReview"));
25609        assert!(application_site.ends_with('}'));
25610
25611        let snapshot = ir.to_snapshot();
25612        assert!(snapshot.contains(&format!(
25613            "    defined-at {}..{}",
25614            application.definition_span.start, application.definition_span.end
25615        )));
25616        assert!(snapshot.contains(&format!(
25617            "    applied-at {}..{}",
25618            application.application_span.start, application.application_span.end
25619        )));
25620    }
25621
25622    #[test]
25623    fn rejects_terminal_statement_in_pattern_body() {
25624        let source = r#"
25625pattern Finisher<Input> {
25626  rule wrap_up
25627    when Input as item
25628  => {
25629    complete result {
25630      done 1
25631    }
25632  }
25633}
25634
25635workflow Root {
25636  output result Summary
25637
25638  class Summary {
25639    done int
25640  }
25641
25642  class Task {
25643    title string
25644  }
25645
25646  apply Finisher<Task> as finish {
25647  }
25648}
25649"#;
25650        let compiled = compile_program(source);
25651        assert!(compiled.ir.is_none());
25652        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25653            .message
25654            .contains("cannot reach a workflow terminal")));
25655    }
25656
25657    #[test]
25658    fn rejects_workflow_contract_in_pattern_body() {
25659        let source = r#"
25660pattern Contracted<Input> {
25661  output result Input
25662
25663  rule dispatch
25664    when Input as item
25665  => {
25666  }
25667}
25668
25669workflow Root {
25670  class Task {
25671    title string
25672  }
25673
25674  apply Contracted<Task> as contracted {
25675  }
25676}
25677"#;
25678        let compiled = compile_program(source);
25679        assert!(compiled.ir.is_none());
25680        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25681            .message
25682            .contains("workflow contracts are not allowed in pattern bodies")));
25683    }
25684
25685    #[test]
25686    fn parses_workflow_invoke_effect_metadata() {
25687        let source = r#"
25688workflow Parent {
25689  class Task {
25690    title string
25691  }
25692
25693  rule dispatch
25694    when Task as task
25695  => {
25696    invoke Child { task task } as child
25697  }
25698}
25699
25700workflow Child {
25701  input task Task
25702
25703  class Task {
25704    title string
25705  }
25706}
25707"#;
25708        let compiled = compile_program_with_root(source, Some("Parent"));
25709        assert_eq!(compiled.diagnostics, Vec::new());
25710        let ir = compiled.ir.expect("source compiles");
25711        let rule = ir
25712            .rules
25713            .iter()
25714            .find(|rule| rule.name == "dispatch")
25715            .expect("dispatch rule lowers");
25716        assert_eq!(rule.metadata.effects.len(), 1);
25717        assert_eq!(rule.metadata.effects[0].kind, IrEffectKind::WorkflowInvoke);
25718        assert_eq!(rule.metadata.effects[0].binding.as_deref(), Some("child"));
25719        assert_eq!(
25720            rule.metadata.effects[0].workflow_target.as_deref(),
25721            Some("Child")
25722        );
25723        assert!(ir
25724            .to_snapshot()
25725            .contains("child kind=workflow.invoke binding=child"));
25726    }
25727
25728    #[test]
25729    fn rejects_unknown_workflow_invocation_target() {
25730        let source = r#"
25731workflow Parent {
25732  class Task {
25733    title string
25734  }
25735
25736  rule dispatch
25737    when Task as task
25738  => {
25739    invoke Missing { task task } as child
25740  }
25741}
25742"#;
25743        let compiled = compile_program(source);
25744        assert!(compiled.ir.is_none());
25745        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25746            .message
25747            .contains("invokes unknown workflow `Missing`")));
25748    }
25749
25750    #[test]
25751    fn validates_workflow_invocation_inputs_against_target_contract() {
25752        let source = r#"
25753workflow Parent {
25754  class Task {
25755    title string
25756  }
25757
25758  rule dispatch
25759    when Task as task
25760  => {
25761    invoke Child { wrong task } as child
25762  }
25763}
25764
25765workflow Child {
25766  input task Task
25767
25768  class Task {
25769    title string
25770  }
25771}
25772"#;
25773        let compiled = compile_program_with_root(source, Some("Parent"));
25774        assert!(compiled.ir.is_none());
25775        let messages = compiled
25776            .diagnostics
25777            .iter()
25778            .map(|diagnostic| diagnostic.message.as_str())
25779            .collect::<Vec<_>>();
25780        assert!(
25781            messages
25782                .iter()
25783                .any(|message| message.contains("workflow `Child` has no input `wrong`")),
25784            "{messages:#?}"
25785        );
25786        assert!(
25787            messages
25788                .iter()
25789                .any(|message| message
25790                    .contains("workflow invocation `Child` is missing input `task`")),
25791            "{messages:#?}"
25792        );
25793    }
25794
25795    #[test]
25796    fn validates_nested_workflow_invocation_input_payloads() {
25797        let source = r#"
25798workflow Parent {
25799  class Task {
25800    title string
25801  }
25802
25803  rule dispatch
25804    when Task as task
25805  => {
25806    invoke Child { task { count "bad" } } as child
25807  }
25808}
25809
25810workflow Child {
25811  input task ChildTask
25812
25813  class ChildTask {
25814    count int
25815  }
25816}
25817"#;
25818        let compiled = compile_program_with_root(source, Some("Parent"));
25819        assert!(compiled.ir.is_none());
25820        assert!(compiled.diagnostics.iter().any(|diagnostic| {
25821            diagnostic
25822                .message
25823                .contains("field `ChildTask.count` expects `int`")
25824        }));
25825    }
25826
25827    #[test]
25828    fn rejects_direct_recursive_workflow_invocation() {
25829        let source = r#"
25830workflow Parent {
25831  input task Task
25832
25833  class Task {
25834    title string
25835  }
25836
25837  rule dispatch
25838    when Task as task
25839  => {
25840    invoke Parent { task task } as next
25841  }
25842}
25843"#;
25844        let compiled = compile_program(source);
25845        assert!(compiled.ir.is_none());
25846        assert!(compiled.diagnostics.iter().any(|diagnostic| {
25847            diagnostic
25848                .message
25849                .contains("recursively invokes workflow `Parent`")
25850        }));
25851    }
25852
25853    #[test]
25854    fn expands_pattern_application_value_arguments() {
25855        let source = r#"
25856pattern Review<Input> {
25857  rule dispatch
25858    when Input as item
25859  => {
25860  }
25861}
25862
25863workflow Root {
25864  class Task {
25865    title string
25866  }
25867
25868  apply Review<Task> as taskReview {
25869    item task
25870  }
25871}
25872"#;
25873        let compiled = compile_program(source);
25874        assert_eq!(compiled.diagnostics, Vec::new());
25875        let snapshot = compiled.ir.expect("source compiles").to_snapshot();
25876        assert!(snapshot.contains("    arg item task"));
25877    }
25878
25879    #[test]
25880    fn rejects_malformed_pattern_application_arguments() {
25881        let source = r#"
25882pattern Review<Input> {
25883  rule dispatch
25884    when Input as item
25885  => {
25886  }
25887}
25888
25889workflow Root {
25890  class Task {
25891    title string
25892  }
25893
25894  apply Review<Task> as taskReview {
25895    item
25896    item task
25897  }
25898}
25899"#;
25900        let compiled = compile_program(source);
25901        assert!(compiled.ir.is_none());
25902        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25903            .message
25904            .contains("argument `item` is missing a value")));
25905    }
25906
25907    #[test]
25908    fn rejects_unknown_workflow_terminal_actions() {
25909        let source = r#"
25910workflow BadTerminal {
25911  output result Result
25912
25913  class Result {
25914    status "ok"
25915  }
25916
25917  rule bad
25918    when started
25919  => {
25920    complete missing {
25921      status "ok"
25922    }
25923  }
25924}
25925"#;
25926        let compiled = compile_program(source);
25927        assert!(compiled.ir.is_none());
25928        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25929            .message
25930            .contains("completes unknown workflow terminal `missing`")));
25931    }
25932
25933    #[test]
25934    fn rejects_duplicate_workflow_inputs() {
25935        let source = r#"
25936workflow DuplicateInput {
25937  input phase PhaseRequest
25938  input phase PhaseRequest
25939
25940  class PhaseRequest {
25941    title string
25942  }
25943
25944  rule noop
25945    when started
25946  => {
25947  }
25948}
25949"#;
25950        let compiled = compile_program(source);
25951        assert!(compiled.ir.is_none());
25952        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25953            .message
25954            .contains("workflow declares input `phase` more than once")));
25955    }
25956
25957    #[test]
25958    fn rejects_with_as_rule_readiness_alias() {
25959        let source = r#"
25960workflow WithIsNotWhen
25961
25962rule bad
25963  with started
25964=> {
25965}
25966"#;
25967        let compiled = compile_program(source);
25968
25969        assert!(compiled.ir.is_none());
25970        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25971            .message
25972            .contains("`with` is not a rule readiness clause")));
25973        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
25974            .suggestion
25975            .as_deref()
25976            .is_some_and(|suggestion| suggestion.contains("use `when` for rule conditions"))));
25977    }
25978
25979    #[test]
25980    fn parses_grouped_when_clauses_as_ordinary_readiness_clauses() {
25981        let source = r#"
25982workflow GroupedWhen
25983
25984class Task {
25985  status "queued"
25986}
25987
25988agent worker {
25989  provider fixture
25990  profile "repo-writer"
25991  capacity 1
25992}
25993
25994rule start
25995  when {
25996    Task as task where task.status == "queued"
25997    worker is available
25998  }
25999=> {
26000  tell worker "do it"
26001}
26002"#;
26003        let compiled = compile_program(source);
26004        let ir = compiled.ir.expect("program compiles");
26005        let rule = &ir.rules[0];
26006
26007        assert_eq!(rule.whens.len(), 2);
26008        assert_eq!(rule.whens[0].pattern, "Task as task");
26009        assert_eq!(
26010            rule.whens[0]
26011                .guard
26012                .as_ref()
26013                .map(|guard| guard.expr.to_snapshot()),
26014            Some("task.status == \"queued\"".to_owned())
26015        );
26016        assert_eq!(rule.whens[1].pattern, "worker is available");
26017        assert!(ir
26018            .to_snapshot()
26019            .contains("    when Task as task where task.status == \"queued\""));
26020        assert!(ir.to_snapshot().contains("    when worker is available"));
26021    }
26022
26023    #[test]
26024    fn accepts_harness_declarations_and_agent_bindings() {
26025        let source = r#"
26026workflow HarnessTopology
26027
26028harness coder: codex
26029harness reviewer: claude
26030
26031agent implementer using coder {
26032  profile "repo-writer"
26033  capacity 1
26034}
26035
26036agent critic using reviewer {
26037  profile "repo-reader"
26038  capacity 1
26039}
26040
26041rule start
26042  when started
26043=> {
26044  tell implementer as turn "implement"
26045}
26046"#;
26047
26048        let compiled = compile_program(source);
26049        assert_eq!(compiled.diagnostics, Vec::new());
26050        let ir = compiled.ir.expect("program compiles");
26051        assert_eq!(ir.harnesses.len(), 2);
26052        assert_eq!(ir.harnesses[0].name, "coder");
26053        assert_eq!(ir.harnesses[0].kind, "codex");
26054        assert_eq!(
26055            ir.agents
26056                .iter()
26057                .find(|agent| agent.name == "implementer")
26058                .and_then(|agent| agent.harness.as_deref()),
26059            Some("coder")
26060        );
26061        let snapshot = ir.to_snapshot();
26062        assert!(snapshot.contains("harness coder kind=codex"));
26063        assert!(snapshot.contains("agent implementer harness=coder"));
26064    }
26065
26066    #[test]
26067    fn rejects_agent_binding_to_unknown_harness() {
26068        let source = r#"
26069workflow UnknownHarness
26070
26071agent worker using missing {
26072  profile "repo-writer"
26073  capacity 1
26074}
26075"#;
26076
26077        let compiled = compile_program(source);
26078        assert!(compiled.ir.is_none());
26079        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26080            .message
26081            .contains("agent `worker` uses unknown harness `missing`")));
26082    }
26083
26084    #[test]
26085    fn rejects_duplicate_harness_declarations_and_accepts_kinds_structurally() {
26086        let source = r#"
26087workflow BadHarnesses
26088
26089harness coder: spaceship
26090harness coder: codex
26091
26092agent worker using coder {
26093  profile "repo-writer"
26094  capacity 1
26095}
26096"#;
26097
26098        let compiled = compile_program(source);
26099        assert!(compiled.ir.is_none());
26100        let messages = compiled
26101            .diagnostics
26102            .iter()
26103            .map(|diagnostic| diagnostic.message.as_str())
26104            .collect::<Vec<_>>();
26105        assert!(
26106            messages
26107                .iter()
26108                .any(|message| message.contains("harness `coder` is declared more than once")),
26109            "{messages:#?}"
26110        );
26111        // Kind validity is registry-derived and validated by the CLI
26112        // (spec/std-agent.md "Open provider registry"): the parser no longer
26113        // holds a compiled-in kind set, so `spaceship` draws no parser
26114        // diagnostic here.
26115        assert!(
26116            !messages
26117                .iter()
26118                .any(|message| message.contains("unsupported kind")),
26119            "{messages:#?}"
26120        );
26121    }
26122
26123    #[test]
26124    fn validates_workflow_terminal_payload_fields() {
26125        let source = r#"
26126workflow BadTerminalPayload {
26127  output result Result
26128
26129  class Result {
26130    status "ok"
26131    summary string
26132  }
26133
26134  rule bad
26135    when started
26136  => {
26137    complete result {
26138      status "bad"
26139      extra "ignored"
26140    }
26141  }
26142}
26143"#;
26144        let compiled = compile_program(source);
26145        assert!(compiled.ir.is_none());
26146        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26147            .message
26148            .contains("field `Result.status` expects literal string `ok`")));
26149        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26150            .message
26151            .contains("class `Result` has no field `extra`")));
26152        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26153            .message
26154            .contains("workflow terminal `result` is missing required field `Result.summary`")));
26155    }
26156
26157    #[test]
26158    fn accepts_workflow_terminal_actions_in_header_style_workflows() {
26159        let source = r#"
26160workflow ImplicitTerminal
26161
26162output result Result
26163
26164class Result {
26165  status "ok"
26166}
26167
26168rule finish
26169  when started
26170=> {
26171  complete result {
26172    status "ok"
26173  }
26174}
26175"#;
26176        let compiled = compile_program(source);
26177        assert_eq!(compiled.diagnostics, Vec::new());
26178        let ir = compiled.ir.expect("header-style terminals compile");
26179        assert_eq!(ir.workflow_contracts.len(), 1);
26180    }
26181
26182    #[test]
26183    fn rejects_header_style_terminal_for_undeclared_contract() {
26184        let source = r#"
26185workflow ImplicitTerminal
26186
26187class Result {
26188  status "ok"
26189}
26190
26191rule bad
26192  when started
26193=> {
26194  complete result {
26195    status "ok"
26196  }
26197}
26198"#;
26199        let compiled = compile_program(source);
26200        assert!(compiled.ir.is_none());
26201        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26202            .message
26203            .contains("completes unknown workflow terminal `result`")));
26204    }
26205
26206    #[test]
26207    fn scalar_terminal_contract_accepts_bare_value() {
26208        // A scalar output contract takes a bare scalar terminal payload.
26209        let source = r#"
26210workflow ScalarTerminal {
26211  output result float
26212  failure error string
26213
26214  rule good
26215    when started
26216  => {
26217    complete result 0.9
26218  }
26219}
26220"#;
26221        let compiled = compile_program(source);
26222        assert!(
26223            compiled.diagnostics.is_empty(),
26224            "scalar terminal payload rejected: {:?}",
26225            compiled.diagnostics
26226        );
26227        assert!(compiled.ir.is_some());
26228    }
26229
26230    #[test]
26231    fn scalar_terminal_contract_rejects_a_field_block() {
26232        // A scalar contract with a `{ ... }` block is a shape mismatch.
26233        let source = r#"
26234workflow ScalarTerminal {
26235  output result float
26236
26237  rule bad
26238    when started
26239  => {
26240    complete result {
26241      value 0.9
26242    }
26243  }
26244}
26245"#;
26246        let compiled = compile_program(source);
26247        assert!(compiled.ir.is_none());
26248        assert!(
26249            compiled.diagnostics.iter().any(|d| d
26250                .message
26251                .contains("has a scalar payload contract but is given a field block")),
26252            "{:?}",
26253            compiled.diagnostics
26254        );
26255    }
26256
26257    #[test]
26258    fn class_terminal_contract_rejects_a_bare_scalar() {
26259        // A class contract given a bare scalar value is a shape mismatch.
26260        let source = r#"
26261workflow ClassTerminal {
26262  output result Score
26263  class Score { value number }
26264
26265  rule bad
26266    when started
26267  => {
26268    complete result 0.9
26269  }
26270}
26271"#;
26272        let compiled = compile_program(source);
26273        assert!(compiled.ir.is_none());
26274        assert!(
26275            compiled.diagnostics.iter().any(|d| d
26276                .message
26277                .contains("has a class payload contract `Score` but is given a bare scalar value")),
26278            "{:?}",
26279            compiled.diagnostics
26280        );
26281    }
26282
26283    #[test]
26284    fn scalar_terminal_value_is_typechecked_against_the_contract() {
26285        // A string literal against a `number` scalar contract is a type error.
26286        let source = r#"
26287workflow ScalarTerminal {
26288  output result float
26289
26290  rule bad
26291    when started
26292  => {
26293    complete result "not a number"
26294  }
26295}
26296"#;
26297        let compiled = compile_program(source);
26298        assert!(compiled.ir.is_none());
26299        assert!(
26300            compiled
26301                .diagnostics
26302                .iter()
26303                .any(|d| d.message.contains("result.value") || d.message.contains("number")),
26304            "{:?}",
26305            compiled.diagnostics
26306        );
26307    }
26308
26309    #[test]
26310    fn selects_root_from_multiple_explicit_workflows() {
26311        let source = r#"
26312class Shared {
26313  id string
26314}
26315
26316workflow First {
26317  rule one
26318    when started
26319  => {
26320    record Shared {
26321      id "first"
26322    }
26323  }
26324}
26325
26326workflow Second {
26327  rule two
26328    when started
26329  => {
26330    record Shared {
26331      id "second"
26332    }
26333  }
26334}
26335"#;
26336        let ambiguous = compile_program(source);
26337        assert!(ambiguous.ir.is_none());
26338        assert!(ambiguous.diagnostics.iter().any(|diagnostic| diagnostic
26339            .message
26340            .contains("multiple workflow declarations require an explicit root")));
26341
26342        let compiled = compile_program_with_root(source, Some("Second"));
26343        assert_eq!(compiled.diagnostics, Vec::new());
26344        let ir = compiled.ir.expect("selected root compiles");
26345        assert_eq!(ir.workflow, "Second");
26346        assert_eq!(ir.rules.len(), 1);
26347        assert_eq!(ir.rules[0].name, "two");
26348        assert!(ir.to_snapshot().contains("class Shared"));
26349    }
26350
26351    #[test]
26352    fn reports_recoverable_diagnostics() {
26353        let source = r#"
26354workflow Broken
26355
26356agent worker {
26357  provider fixture
26358  profile 42
26359  capacity nope
26360}
26361
26362rule missing_body
26363  when started
26364=>
26365"#;
26366
26367        let parsed = parse_program(source);
26368        assert!(parsed.diagnostics.len() >= 3);
26369        assert!(parsed
26370            .diagnostics
26371            .iter()
26372            .any(|diagnostic| diagnostic.message.contains("profile string")));
26373        assert!(parsed
26374            .diagnostics
26375            .iter()
26376            .any(|diagnostic| diagnostic.suggestion.as_deref()
26377                == Some("write `profile \"profile-name\"`")));
26378        assert!(parsed
26379            .diagnostics
26380            .iter()
26381            .any(|diagnostic| diagnostic.message.contains("capacity value")));
26382        assert!(parsed
26383            .diagnostics
26384            .iter()
26385            .any(|diagnostic| diagnostic.message.contains("`{`")));
26386    }
26387
26388    #[test]
26389    fn lowers_and_formats_agent_tools_grant() {
26390        // DR-0025: an agent may declare a `tools [...]` grant of workflows it can
26391        // invoke as typed tools. The grant lowers to `IrAgent.tools`, survives a
26392        // format round-trip, and de-duplicates entries with a diagnostic.
26393        let source = r#"
26394workflow GrantHost
26395
26396agent worker {
26397  provider owned
26398  profile "repo-writer"
26399  capacity 1
26400  tools [WordCount, OpenPr]
26401}
26402"#;
26403        let compiled = compile_program(source);
26404        assert_eq!(compiled.diagnostics, Vec::new());
26405        let ir = compiled.ir.expect("valid ir");
26406        let agent = ir
26407            .agents
26408            .iter()
26409            .find(|agent| agent.name == "worker")
26410            .expect("worker agent");
26411        assert_eq!(
26412            agent.tools,
26413            vec!["WordCount".to_owned(), "OpenPr".to_owned()]
26414        );
26415
26416        let formatted = format_program(source).formatted.expect("formats");
26417        assert!(
26418            formatted.contains("tools [WordCount, OpenPr]"),
26419            "formatted: {formatted}"
26420        );
26421
26422        // A duplicate grant entry is rejected.
26423        let dup = compile_program(
26424            "workflow Dup\nagent a {\n  provider owned\n  profile \"p\"\n  tools [X, X]\n}\n",
26425        );
26426        assert!(
26427            dup.diagnostics
26428                .iter()
26429                .any(|d| d.message.contains("grants tool `X` more than once")),
26430            "diagnostics: {:?}",
26431            dup.diagnostics
26432        );
26433    }
26434
26435    #[test]
26436    fn harness_class_classifies_managed_vs_delegated_and_emits_only_delegated() {
26437        // Classifier is total: owned/fixture Managed, the rest Delegated.
26438        assert_eq!(harness_class("owned"), HarnessClass::Managed);
26439        assert_eq!(harness_class("fixture"), HarnessClass::Managed);
26440        assert_eq!(harness_class("claude"), HarnessClass::Delegated);
26441        assert_eq!(harness_class("codex"), HarnessClass::Delegated);
26442        assert_eq!(harness_class("native-fixture"), HarnessClass::Delegated);
26443        assert_eq!(harness_class("command"), HarnessClass::Delegated);
26444
26445        // A `provider owned` agent lowers Managed and emits NO class token (default).
26446        let managed = compile_program(
26447            "workflow W\nagent m {\n  provider owned\n  profile \"p\"\n  capacity 1\n}\n",
26448        );
26449        let managed_ir = managed.ir.expect("ir");
26450        assert_eq!(managed_ir.agents[0].harness_class, HarnessClass::Managed);
26451        assert!(!managed_ir.to_snapshot().contains("class="));
26452
26453        // A `provider claude` agent lowers Delegated and emits `class=delegated`.
26454        let delegated = compile_program(
26455            "workflow W\nagent d {\n  provider claude\n  profile \"repo-writer\"\n  capacity 1\n}\n",
26456        );
26457        let delegated_ir = delegated.ir.expect("ir");
26458        assert_eq!(
26459            delegated_ir.agents[0].harness_class,
26460            HarnessClass::Delegated
26461        );
26462        assert!(delegated_ir.to_snapshot().contains("class=delegated"));
26463
26464        // `using <harness>` resolves the class through the harness's kind.
26465        let via_harness = compile_program(
26466            "workflow W\nharness box: claude\nagent d using box {\n  profile \"repo-writer\"\n  capacity 1\n}\n",
26467        );
26468        let via_ir = via_harness.ir.expect("ir");
26469        assert_eq!(via_ir.agents[0].harness_class, HarnessClass::Delegated);
26470    }
26471
26472    #[test]
26473    fn tell_with_skills_lowers_to_effect_turn_skills_and_ir_snapshot() {
26474        let source = concat!(
26475            "workflow W\n",
26476            "agent coder {\n  provider owned\n  profile \"p\"\n  capacity 1\n}\n",
26477            "class Task {\n  note string\n}\n",
26478            "rule go\n  when Task as t\n=> {\n  tell coder with skills [\"review\", \"lint\"] \"do it\" as turn\n}\n",
26479        );
26480        let compiled = compile_program(source);
26481        assert!(
26482            compiled.diagnostics.is_empty(),
26483            "{:?}",
26484            compiled.diagnostics
26485        );
26486        let ir = compiled.ir.expect("ir");
26487        let effect = ir
26488            .rules
26489            .iter()
26490            .flat_map(|rule| &rule.metadata.effects)
26491            .find(|effect| effect.kind == IrEffectKind::AgentTell)
26492            .expect("tell effect");
26493        assert_eq!(
26494            effect.turn_skills,
26495            vec!["review".to_owned(), "lint".to_owned()]
26496        );
26497        // The .ir snapshot carries the pin (appended only when present).
26498        assert!(
26499            ir.to_snapshot().contains("skills=review,lint"),
26500            "{}",
26501            ir.to_snapshot()
26502        );
26503    }
26504
26505    #[test]
26506    fn agent_compaction_strategy_parses_lowers_formats_and_validates() {
26507        let source = compile_program(
26508            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n  compaction hard_reset\n}\n",
26509        );
26510        assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26511        let ir = source.ir.expect("ir");
26512        let agent = ir.agents.iter().find(|a| a.name == "w").expect("agent");
26513        assert_eq!(agent.compaction.as_deref(), Some("hard_reset"));
26514
26515        // Round-trips through the formatter and the .ir snapshot.
26516        let formatted = format_program(
26517            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n  compaction hard_reset\n}\n",
26518        )
26519        .formatted
26520        .expect("formats");
26521        assert!(formatted.contains("compaction hard_reset"), "{formatted}");
26522        assert!(ir.to_snapshot().contains("compaction=hard_reset"));
26523
26524        // An unknown strategy is a diagnostic (not silently accepted).
26525        let bad = compile_program(
26526            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n  compaction squish\n}\n",
26527        );
26528        assert!(
26529            bad.diagnostics
26530                .iter()
26531                .any(|d| d.message.contains("unknown compaction strategy `squish`")),
26532            "diagnostics: {:?}",
26533            bad.diagnostics
26534        );
26535
26536        // An agent with no compaction field lowers to None and adds no .ir token.
26537        let plain = compile_program(
26538            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n}\n",
26539        );
26540        let plain_ir = plain.ir.expect("ir");
26541        assert_eq!(plain_ir.agents[0].compaction, None);
26542        assert!(!plain_ir.to_snapshot().contains("compaction="));
26543    }
26544
26545    #[test]
26546    fn agent_settings_source_parses_lowers_formats_and_validates() {
26547        // DR-0034 Decision 4: the delegated ambient-config knob.
26548        let source = compile_program(
26549            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings project\n}\n",
26550        );
26551        assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26552        let ir = source.ir.expect("ir");
26553        let agent = ir.agents.iter().find(|a| a.name == "w").expect("agent");
26554        assert_eq!(agent.settings.as_deref(), Some("project"));
26555
26556        // Round-trips through the formatter and the .ir snapshot.
26557        let formatted = format_program(
26558            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings project\n}\n",
26559        )
26560        .formatted
26561        .expect("formats");
26562        assert!(formatted.contains("settings project"), "{formatted}");
26563        assert!(ir.to_snapshot().contains("settings=project"));
26564
26565        // An unknown source is a diagnostic (not silently accepted).
26566        let bad = compile_program(
26567            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings everything\n}\n",
26568        );
26569        assert!(
26570            bad.diagnostics
26571                .iter()
26572                .any(|d| d.message.contains("unknown settings source `everything`")),
26573            "diagnostics: {:?}",
26574            bad.diagnostics
26575        );
26576
26577        // Declaring settings twice is a diagnostic.
26578        let dup = compile_program(
26579            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  settings project\n  settings user\n}\n",
26580        );
26581        assert!(
26582            dup.diagnostics
26583                .iter()
26584                .any(|d| d.message.contains("declares settings more than once")),
26585            "diagnostics: {:?}",
26586            dup.diagnostics
26587        );
26588
26589        // An agent with no settings field lowers to None and adds no .ir token —
26590        // unset means the provider's own default, not the empty set.
26591        let plain = compile_program(
26592            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n}\n",
26593        );
26594        let plain_ir = plain.ir.expect("ir");
26595        assert_eq!(plain_ir.agents[0].settings, None);
26596        assert!(!plain_ir.to_snapshot().contains("settings="));
26597    }
26598
26599    #[test]
26600    fn agent_thread_mode_parses_lowers_and_partitions() {
26601        // `thread continue` on a Managed agent lowers into the IR.
26602        let source = "workflow ChatDemo\n\noutput result Done\n\nclass Done {\n  ok int\n}\n\n\
26603             agent helper {\n  provider owned\n  profile \"repo-reader\"\n  capacity 1\n  thread continue\n}\n\n\
26604             rule go\n  when started\n=> {\n  tell helper as reply \"\"\"\n  Hi.\n  \"\"\"\n\n\
26605             \x20 after reply succeeds {\n    complete result { ok 1 }\n  }\n}\n";
26606        let compiled = compile_program(source);
26607        let ir = compiled.ir.expect("thread continue compiles");
26608        let agent = ir
26609            .agents
26610            .iter()
26611            .find(|agent| agent.name == "helper")
26612            .expect("agent lowered");
26613        assert_eq!(agent.thread.as_deref(), Some("continue"));
26614
26615        // An unknown mode is a diagnostic.
26616        let bad = source.replace("thread continue", "thread sometimes");
26617        let compiled = compile_program(&bad);
26618        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26619            .message
26620            .contains("unknown thread mode `sometimes`")));
26621
26622        // `thread` on a Delegated agent is a managed-knob partition error.
26623        let delegated = source.replace("provider owned", "provider codex");
26624        let compiled = compile_program(&delegated);
26625        assert!(
26626            compiled.diagnostics.iter().any(|diagnostic| diagnostic
26627                .message
26628                .contains("is delegated; `thread` is a managed-harness knob")),
26629            "{:?}",
26630            compiled
26631                .diagnostics
26632                .iter()
26633                .map(|d| &d.message)
26634                .collect::<Vec<_>>()
26635        );
26636    }
26637
26638    #[test]
26639    fn agent_knobs_partition_by_harness_class() {
26640        // DR-0034 Decision 3: each class admits only its meaningful knobs; the
26641        // other class rejects them with a diagnostic, never a silent no-op.
26642
26643        // `compaction` on a Delegated agent is an error — you cannot tell Claude
26644        // how to compact; it does its own.
26645        let bad = compile_program(
26646            "workflow C\nagent w {\n  provider claude\n  profile \"p\"\n  capacity 1\n  compaction summarize\n}\n",
26647        );
26648        assert!(
26649            bad.diagnostics.iter().any(|d| d
26650                .message
26651                .contains("is delegated; `compaction` is a managed-harness knob")),
26652            "diagnostics: {:?}",
26653            bad.diagnostics
26654        );
26655
26656        // `settings` on a Managed agent is an error — WhippleScript already
26657        // assembles the context; there is nothing foreign to configure.
26658        let bad = compile_program(
26659            "workflow C\nagent w {\n  provider owned\n  profile \"p\"\n  capacity 1\n  settings project\n}\n",
26660        );
26661        assert!(
26662            bad.diagnostics.iter().any(|d| d
26663                .message
26664                .contains("is managed; `settings` is a delegated-harness knob")),
26665            "diagnostics: {:?}",
26666            bad.diagnostics
26667        );
26668
26669        // The class resolves through a `using <harness>` binding too.
26670        let bad = compile_program(
26671            "workflow C\nharness box: claude\nagent w using box {\n  profile \"p\"\n  capacity 1\n  compaction summarize\n}\n",
26672        );
26673        assert!(
26674            bad.diagnostics.iter().any(|d| d
26675                .message
26676                .contains("is delegated; `compaction` is a managed-harness knob")),
26677            "diagnostics: {:?}",
26678            bad.diagnostics
26679        );
26680
26681        // The right-class pairings stay legal.
26682        let good = compile_program(
26683            "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",
26684        );
26685        assert!(good.diagnostics.is_empty(), "{:?}", good.diagnostics);
26686
26687        // An agent with no provider binding is Managed by default (D8-5), so a
26688        // managed-only knob is legal on it.
26689        let unbound = compile_program(
26690            "workflow C\nagent w {\n  profile \"p\"\n  capacity 1\n  compaction summarize\n}\n",
26691        );
26692        assert!(
26693            !unbound
26694                .diagnostics
26695                .iter()
26696                .any(|d| d.message.contains("managed-harness knob")),
26697            "diagnostics: {:?}",
26698            unbound.diagnostics
26699        );
26700    }
26701
26702    /// `requires [<feature.class>]` (spec/std-agent.md slice 6): dotted
26703    /// taxonomy classes parse into `IrAgent::requires`, render in the .ir
26704    /// snapshot only when declared, format canonically, and a non-taxonomy
26705    /// class or duplicate is a lowering diagnostic.
26706    #[test]
26707    fn agent_requires_parses_taxonomy_classes() {
26708        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";
26709        let compiled = compile_program(program);
26710        assert!(
26711            compiled.diagnostics.is_empty(),
26712            "{:?}",
26713            compiled.diagnostics
26714        );
26715        let ir = compiled.ir.expect("ir");
26716        let agent = ir.agents.first().expect("agent");
26717        assert_eq!(
26718            agent.requires,
26719            vec!["session.resume".to_owned(), "turn.cancel".to_owned()]
26720        );
26721        assert!(ir
26722            .to_snapshot()
26723            .contains("requires=[session.resume, turn.cancel]"));
26724
26725        // The formatter preserves the field.
26726        let formatted = format_program(program).formatted.expect("formats");
26727        assert!(
26728            formatted.contains("  requires [session.resume, turn.cancel]"),
26729            "{formatted}"
26730        );
26731
26732        // Agents without `requires` keep an unchanged snapshot (no ripple).
26733        let plain = compile_program(
26734            "workflow R\n\nagent a {\n  provider owned\n  profile \"p\"\n  capacity 1\n}\n",
26735        );
26736        assert!(!plain.ir.expect("ir").to_snapshot().contains("requires="));
26737
26738        // Taxonomy membership bites (DR-0015): a made-up class is rejected.
26739        let unknown = compile_program(
26740            "workflow R\n\nagent a {\n  provider owned\n  profile \"p\"\n  capacity 1\n  requires [warp.drive]\n}\n",
26741        );
26742        assert!(
26743            unknown.diagnostics.iter().any(|d| d
26744                .message
26745                .contains("requires unknown feature class `warp.drive`")),
26746            "{:?}",
26747            unknown.diagnostics
26748        );
26749
26750        // Duplicates are rejected.
26751        let duplicate = compile_program(
26752            "workflow R\n\nagent a {\n  provider owned\n  profile \"p\"\n  capacity 1\n  requires [turn.cancel, turn.cancel]\n}\n",
26753        );
26754        assert!(
26755            duplicate.diagnostics.iter().any(|d| d
26756                .message
26757                .contains("requires feature class `turn.cancel` more than once")),
26758            "{:?}",
26759            duplicate.diagnostics
26760        );
26761    }
26762
26763    #[test]
26764    fn agent_delegated_to_sugar_and_managed_default() {
26765        // `agent d delegated to <provider>` is the Delegated surface spelling
26766        // (DR-0034 Decision 2); it names the provider kind directly.
26767        let program = "workflow C\nagent d delegated to claude {\n  profile \"p\"\n  capacity 1\n  settings project\n}\n";
26768        let source = compile_program(program);
26769        assert!(source.diagnostics.is_empty(), "{:?}", source.diagnostics);
26770        let ir = source.ir.expect("ir");
26771        let agent = ir.agents.iter().find(|a| a.name == "d").expect("agent");
26772        assert_eq!(agent.provider.as_deref(), Some("claude"));
26773        assert_eq!(agent.harness_class, HarnessClass::Delegated);
26774        assert!(ir.to_snapshot().contains("class=delegated"));
26775
26776        // The formatter preserves the sugar.
26777        let formatted = format_program(program).formatted.expect("formats");
26778        assert!(
26779            formatted.contains("agent d delegated to claude {"),
26780            "{formatted}"
26781        );
26782
26783        // `delegated to owned` is a contradiction — owned is the managed substrate.
26784        let bad = compile_program(
26785            "workflow C\nagent d delegated to owned {\n  profile \"p\"\n  capacity 1\n}\n",
26786        );
26787        assert!(
26788            bad.diagnostics.iter().any(|d| d
26789                .message
26790                .contains("delegates to `owned`, which is a managed kind")),
26791            "diagnostics: {:?}",
26792            bad.diagnostics
26793        );
26794
26795        // An unknown delegate kind parses structurally: kind existence is
26796        // registry-validated by the CLI (spec/std-agent.md "Open provider
26797        // registry"), and an unrecognized kind classifies Delegated (never
26798        // Managed), so `delegated to mystery` draws no parser diagnostic.
26799        let unknown = compile_program(
26800            "workflow C\nagent d delegated to mystery {\n  profile \"p\"\n  capacity 1\n}\n",
26801        );
26802        assert!(
26803            !unknown
26804                .diagnostics
26805                .iter()
26806                .any(|d| d.message.contains("unsupported provider")),
26807            "diagnostics: {:?}",
26808            unknown.diagnostics
26809        );
26810
26811        // `delegated to` plus a `provider` field is an error, not a silent pick.
26812        let both = compile_program(
26813            "workflow C\nagent d delegated to claude {\n  provider codex\n  profile \"p\"\n  capacity 1\n}\n",
26814        );
26815        assert!(
26816            both.diagnostics.iter().any(|d| d
26817                .message
26818                .contains("declares both `delegated to` and direct provider")),
26819            "diagnostics: {:?}",
26820            both.diagnostics
26821        );
26822
26823        // A bare `agent m { … }` is Managed by default (Decision 6: managed is
26824        // the substrate) — it lowers to the owned provider with no diagnostic.
26825        let plain = compile_program("workflow C\nagent m {\n  profile \"p\"\n  capacity 1\n}\n");
26826        assert!(plain.diagnostics.is_empty(), "{:?}", plain.diagnostics);
26827        let plain_ir = plain.ir.expect("ir");
26828        assert_eq!(plain_ir.agents[0].provider.as_deref(), Some("owned"));
26829        assert_eq!(plain_ir.agents[0].harness_class, HarnessClass::Managed);
26830    }
26831
26832    #[test]
26833    fn accepts_agent_ref_dynamic_tell_targets() {
26834        let source = r#"
26835workflow AgentRefRouting
26836
26837agent codex {
26838  provider codex
26839  profile "repo-writer"
26840  capacity 1
26841  capabilities ["agent.tell"]
26842}
26843
26844agent claude {
26845  provider claude
26846  profile "repo-writer"
26847  capacity 1
26848  capabilities ["agent.tell"]
26849}
26850
26851class LanguageTask {
26852  provider AgentRef<codex | claude>
26853  prompt string
26854}
26855
26856rule run_task
26857  when LanguageTask as task
26858  when task.provider is available
26859=> {
26860  tell task.provider requires ["agent.tell"] as turn "{{ task.prompt }}"
26861}
26862"#;
26863
26864        let compiled = compile_program(source);
26865        assert_eq!(compiled.diagnostics, Vec::new());
26866        let ir = compiled.ir.expect("valid ir");
26867        let rule = ir
26868            .rules
26869            .iter()
26870            .find(|rule| rule.name == "run_task")
26871            .expect("run_task");
26872        assert_eq!(rule.metadata.effects.len(), 1);
26873        assert_eq!(rule.metadata.effects[0].kind, IrEffectKind::AgentTell);
26874    }
26875
26876    #[test]
26877    fn rejects_agent_ref_targets_missing_required_capabilities() {
26878        let source = r#"
26879workflow BadAgentRefCapabilities
26880
26881agent codex {
26882  provider codex
26883  profile "repo-writer"
26884  capacity 1
26885  capabilities ["agent.tell", "repo.write"]
26886}
26887
26888agent claude {
26889  provider claude
26890  profile "repo-reader"
26891  capacity 1
26892  capabilities ["agent.tell"]
26893}
26894
26895class LanguageTask {
26896  provider AgentRef<codex | claude>
26897  prompt string
26898}
26899
26900rule run_task
26901  when LanguageTask as task
26902=> {
26903  tell task.provider requires ["repo.write"] as turn """
26904  {{ task.prompt }}
26905  """
26906}
26907"#;
26908
26909        let compiled = compile_program(source);
26910        assert!(compiled.ir.is_none());
26911        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26912            .message
26913            .contains("agent `claude` requiring undeclared capability `repo.write`")));
26914    }
26915
26916    #[test]
26917    fn rejects_plain_string_dynamic_tell_targets() {
26918        let source = r#"
26919workflow BadAgentRefRouting
26920
26921agent codex {
26922  provider codex
26923  profile "repo-writer"
26924  capacity 1
26925}
26926
26927class LanguageTask {
26928  provider string
26929}
26930
26931rule run_task
26932  when LanguageTask as task
26933=> {
26934  tell task.provider "bad"
26935}
26936"#;
26937
26938        let compiled = compile_program(source);
26939        assert!(compiled.ir.is_none());
26940        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26941            .message
26942            .contains("non-AgentRef dynamic tell target `task.provider`")));
26943    }
26944
26945    #[test]
26946    fn rejects_unknown_agent_ref_domain_values() {
26947        let source = r#"
26948workflow BadAgentRefDomain
26949
26950agent codex {
26951  provider codex
26952  profile "repo-writer"
26953  capacity 1
26954}
26955
26956class LanguageTask {
26957  provider AgentRef<codex | ghost>
26958}
26959
26960rule seed
26961  when started
26962=> {
26963  record LanguageTask {
26964    provider claude
26965  }
26966}
26967"#;
26968
26969        let compiled = compile_program(source);
26970        assert!(compiled.ir.is_none());
26971        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26972            .message
26973            .contains("AgentRef references unknown agent `ghost`")));
26974        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
26975            .message
26976            .contains("field `LanguageTask.provider` cannot reference agent `claude`")));
26977    }
26978
26979    #[test]
26980    fn rejects_quoted_agent_ref_record_values() {
26981        let source = r#"
26982workflow BadQuotedAgentRef
26983
26984agent codex {
26985  provider codex
26986  profile "repo-writer"
26987  capacity 1
26988}
26989
26990class LanguageTask {
26991  provider AgentRef<codex>
26992}
26993
26994rule seed
26995  when started
26996=> {
26997  record LanguageTask {
26998    provider "codex"
26999  }
27000}
27001"#;
27002
27003        let compiled = compile_program(source);
27004        assert!(compiled.ir.is_none());
27005        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27006            .message
27007            .contains("expects an AgentRef value, not string `codex`")));
27008    }
27009
27010    #[test]
27011    fn requires_presence_proof_for_optional_field_access() {
27012        let source = r#"
27013workflow OptionalProof
27014
27015class Person {
27016  name string
27017}
27018
27019class Issue {
27020  assignee Person?
27021}
27022
27023rule unsafe_optional
27024  when Issue as issue where issue.assignee.name == "Ada"
27025=> {
27026}
27027"#;
27028
27029        let compiled = compile_program(source);
27030        assert!(compiled.ir.is_none());
27031        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27032            .message
27033            .contains("unsafe optional path `issue.assignee.name`")));
27034    }
27035
27036    #[test]
27037    fn accepts_presence_proof_before_optional_field_access() {
27038        let source = r#"
27039workflow OptionalProof
27040
27041class Person {
27042  name string
27043}
27044
27045class Issue {
27046  assignee Person?
27047}
27048
27049rule safe_optional
27050  when Issue as issue where issue.assignee != null && issue.assignee.name == "Ada"
27051=> {
27052}
27053
27054rule safe_exists
27055  when Issue as issue where exists issue.assignee && issue.assignee.name == "Ada"
27056=> {
27057}
27058
27059rule safe_not_null
27060  when Issue as issue where !(issue.assignee == null) && issue.assignee.name == "Ada"
27061=> {
27062}
27063"#;
27064
27065        let compiled = compile_program(source);
27066        assert_eq!(compiled.diagnostics, Vec::new());
27067        assert!(compiled.ir.is_some());
27068    }
27069
27070    #[test]
27071    fn parses_expression_kernel_surface() {
27072        let cases = [
27073            ("true || false && !ready", "true || (false && !ready)"),
27074            (
27075                "count(task.labels) == 0 || exists(Result where status == \"done\")",
27076                "(count(task.labels) == 0) || exists(Result where status == \"done\")",
27077            ),
27078            (
27079                "task.labels[\"priority\"] == [\"high\", \"urgent\"][0]",
27080                "task.labels[\"priority\"] == [\"high\", \"urgent\"][0]",
27081            ),
27082            (
27083                "exists issue.assignee && issue.assignee.name == \"Ada\"",
27084                "exists(issue.assignee) && (issue.assignee.name == \"Ada\")",
27085            ),
27086            (
27087                "{title task.title, metadata {phase \"kernel\"}}",
27088                "{title task.title, metadata {phase \"kernel\"}}",
27089            ),
27090            (
27091                "count(effect agent.tell where target == \"worker\") >= 1",
27092                "count(effect agent.tell where target == \"worker\") >= 1",
27093            ),
27094        ];
27095
27096        for (source, expected) in cases {
27097            let expr = parse_expression(source).expect(source);
27098            assert_eq!(expr.to_snapshot(), expected);
27099        }
27100
27101        for source in ["task.labels[", "count(Result where)", "[1,,2]"] {
27102            assert!(
27103                parse_expression(source).is_err(),
27104                "{source} unexpectedly parsed"
27105            );
27106        }
27107    }
27108
27109    /// Every expression form in the spec's grammar (expression-kernel.md
27110    /// "Expression Forms") parses and renders to a pinned snapshot, so parser
27111    /// precedence and snapshot parenthesization are locked for each form.
27112    #[test]
27113    fn deeply_nested_expression_errors_instead_of_overflowing_the_stack() {
27114        // Run on a production-sized stack (compile happens on the 8 MB main
27115        // thread, not a 2 MB test thread): a pathologically nested guard
27116        // expression must return a normal Err diagnostic, not abort the
27117        // process with a stack overflow.
27118        std::thread::Builder::new()
27119            .stack_size(8 * 1024 * 1024)
27120            .spawn(|| {
27121                let deep = format!("{}task.done{}", "(".repeat(8000), ")".repeat(8000));
27122                let result = parse_expression(&deep);
27123                assert!(
27124                    result
27125                        .as_ref()
27126                        .err()
27127                        .is_some_and(|message| message.contains("nested too deeply")),
27128                    "expected a depth-limit diagnostic, got {result:?}"
27129                );
27130                // A reasonably nested expression still parses (guard is generous).
27131                let ok = format!("{}task.done{}", "(".repeat(64), ")".repeat(64));
27132                assert!(parse_expression(&ok).is_ok(), "64-deep nesting must parse");
27133            })
27134            .expect("spawn")
27135            .join()
27136            .expect("nested-expression parse must not crash");
27137    }
27138
27139    #[test]
27140    fn parses_every_expression_form_with_pinned_precedence() {
27141        let cases = [
27142            // Literals.
27143            ("\"text\"", "\"text\""),
27144            ("42", "42"),
27145            ("2.5", "2.5"),
27146            ("true && false", "true && false"),
27147            ("task.note == null", "task.note == null"),
27148            // Paths and (map) indexing, including chained indexes.
27149            (
27150                "task.meta[\"a\"][\"b\"] == \"c\"",
27151                "task.meta[\"a\"][\"b\"] == \"c\"",
27152            ),
27153            // Unary and word-form connectives normalize to symbol operators.
27154            ("not task.done", "!task.done"),
27155            ("!!task.done", "!!task.done"),
27156            (
27157                "task.a and task.b or task.c",
27158                "(task.a && task.b) || task.c",
27159            ),
27160            // Prefix `not` binds looser than comparisons.
27161            ("not task.state == \"open\"", "!(task.state == \"open\")"),
27162            // Boolean precedence: `&&` over `||`, unary over both.
27163            (
27164                "task.a || task.b && !task.c",
27165                "task.a || (task.b && !task.c)",
27166            ),
27167            // Arithmetic precedence: `* /` over `+ -`, both over comparisons.
27168            ("1 + 2 * 3 == 7", "(1 + (2 * 3)) == 7"),
27169            ("10 - 4 / 2 >= 8", "(10 - (4 / 2)) >= 8"),
27170            // Comparisons/membership are ONE flat left-associative level:
27171            // `a == b < c` groups as `(a == b) < c` (the spec sketch draws
27172            // ordering tighter than equality; the divergence is only
27173            // observable in chains, which the type checker rejects anyway —
27174            // this pin documents the implemented grouping).
27175            ("task.a == task.b < task.c", "(task.a == task.b) < task.c"),
27176            // Ordering and membership forms.
27177            ("task.n <= 5 && task.n > 0", "(task.n <= 5) && (task.n > 0)"),
27178            ("\"x\" in task.labels", "\"x\" in task.labels"),
27179            ("\"x\" not in task.labels", "\"x\" not in task.labels"),
27180            // Presence proof form vs collection form.
27181            ("exists task.owner", "exists(task.owner)"),
27182            (
27183                "exists(Task where done == false)",
27184                "exists(Task where done == false)",
27185            ),
27186            // count/empty over arrays, queries, and effect queries.
27187            ("count([1, 2]) == 2", "count([1, 2]) == 2"),
27188            (
27189                "empty(Task where done == false)",
27190                "empty(Task where done == false)",
27191            ),
27192            ("empty(task.labels)", "empty(task.labels)"),
27193            ("empty([])", "empty([])"),
27194            (
27195                "count(effect kind agent.tell where target == \"w\") == 0",
27196                "count(effect kind agent.tell where target == \"w\") == 0",
27197            ),
27198            (
27199                "exists(effect kind schema.coerce)",
27200                "exists(effect kind schema.coerce)",
27201            ),
27202            // Array and object literals.
27203            ("[\"a\", \"b\"]", "[\"a\", \"b\"]"),
27204            (
27205                "{title task.title, meta {phase \"kernel\"}}",
27206                "{title task.title, meta {phase \"kernel\"}}",
27207            ),
27208        ];
27209
27210        for (source, expected) in cases {
27211            let expr = parse_expression(source).expect(source);
27212            assert_eq!(expr.to_snapshot(), expected, "for `{source}`");
27213        }
27214    }
27215
27216    /// Invalid expression syntax fails deterministically with an actionable
27217    /// message: dangling operators, unclosed delimiters, malformed queries,
27218    /// and unsupported call syntax (tracker "invalid syntax diagnostics").
27219    #[test]
27220    fn invalid_expression_syntax_produces_deterministic_errors() {
27221        let cases = [
27222            // Dangling operators.
27223            ("task.a ==", "expected expression"),
27224            ("1 +", "expected expression"),
27225            ("task.a && || task.b", "expected expression"),
27226            ("task.a == == 1", "expected expression"),
27227            ("!", "expected expression"),
27228            // Unclosed delimiters.
27229            ("(task.a == 1", "expected `)`"),
27230            ("task.labels[\"k\"", "expected `]`"),
27231            ("[1, 2", "expected `,`"),
27232            ("{a 1", "expected object field name"),
27233            // Trailing garbage after a complete expression.
27234            ("task.a == 1)", "unexpected token"),
27235            ("in task.a", "unexpected token"),
27236            // Malformed queries and paths.
27237            ("count(Task where", "expected expression"),
27238            ("count(Task where )", "expected expression"),
27239            ("task..a", "expected field name after `.`"),
27240            ("task.a not b", "expected `in` after `not`"),
27241        ];
27242
27243        for (source, expected) in cases {
27244            let message = parse_expression(source).expect_err(source);
27245            assert!(
27246                message.contains(expected),
27247                "`{source}` -> `{message}` (expected `{expected}`)"
27248            );
27249        }
27250    }
27251
27252    /// Syntax errors inside real guards and assertions surface as compile
27253    /// diagnostics naming the rule/assertion, not as panics or silent drops.
27254    #[test]
27255    fn guard_and_assertion_syntax_errors_surface_with_context() {
27256        let source = r#"
27257workflow BadExpressionSyntax
27258
27259class Task {
27260  title string
27261}
27262
27263assert count(Task) ==
27264
27265rule dangling_guard
27266  when Task as task where task.title ==
27267=> {
27268}
27269"#;
27270
27271        let compiled = compile_program(source);
27272        assert!(compiled.ir.is_none());
27273        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27274            .message
27275            .contains("invalid assertion expression: expected expression")));
27276        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27277            .message
27278            .contains("rule `dangling_guard` has invalid guard expression: expected expression")));
27279    }
27280
27281    /// `empty` static checks: arity, scalar rejection, and the optional rule —
27282    /// `empty(Optional<T>)` is defined only when `empty(T)` is, so an optional
27283    /// string is accepted while an optional int is rejected.
27284    #[test]
27285    fn validates_empty_call_arity_and_optional_arguments() {
27286        let source = r#"
27287workflow EmptyCallChecks
27288
27289class Task {
27290  title string
27291  note string?
27292  age int?
27293  done bool
27294}
27295
27296assert empty() == true
27297assert empty(["a"], ["b"]) == true
27298assert count(Task where empty(note) && empty(title)) == 0
27299assert count(Task where empty(age)) == 0
27300assert count(Task where empty(done)) == 0
27301"#;
27302
27303        let compiled = compile_program(source);
27304        assert!(compiled.ir.is_none());
27305        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27306            .message
27307            .contains("calls `empty` with 0 arguments, expected 1")));
27308        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27309            .message
27310            .contains("calls `empty` with 2 arguments, expected 1")));
27311        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27312            .message
27313            .contains("calls `empty` with unsupported optional argument type `int?`")));
27314        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27315            .message
27316            .contains("calls `empty` with unsupported argument type `bool`")));
27317        // The accepted forms produce no `empty` diagnostics of their own.
27318        assert!(!compiled
27319            .diagnostics
27320            .iter()
27321            .any(|diagnostic| diagnostic.message.contains("`string?`")));
27322        assert!(!compiled.diagnostics.iter().any(|diagnostic| diagnostic
27323            .message
27324            .contains("unsupported argument type `string`")));
27325    }
27326
27327    /// The formatter never re-renders expression text: guards and assertions
27328    /// keep the author's exact spelling (word-form connectives, redundant
27329    /// parentheses), and formatting is idempotent over them. Rendered
27330    /// parenthesization belongs to the IR snapshot (see
27331    /// `parses_every_expression_form_with_pinned_precedence`), never to `fmt`.
27332    #[test]
27333    fn format_preserves_expression_source_text_verbatim() {
27334        let source = r#"workflow FormatExpressions
27335
27336class Task {
27337  title string
27338  done bool
27339}
27340
27341assert count(Task where (done == false) and not done) == 0
27342
27343rule keep_spelling
27344  when Task as task where not task.done and (task.title == "a" or task.title == "b")
27345=> {
27346}
27347"#;
27348
27349        let formatted = format_program(source);
27350        assert_eq!(formatted.diagnostics, Vec::new());
27351        let once = formatted.formatted.expect("formats");
27352        assert!(once.contains(
27353            "when Task as task where not task.done and (task.title == \"a\" or task.title == \"b\")"
27354        ));
27355        assert!(once.contains("assert count(Task where (done == false) and not done) == 0"));
27356
27357        let twice = format_program(&once).formatted.expect("formats twice");
27358        assert_eq!(once, twice, "formatting is idempotent over expressions");
27359    }
27360
27361    #[test]
27362    fn validates_expected_schema_object_and_map_record_fields() {
27363        let source = r#"
27364workflow ObjectRecordFields
27365
27366class Owner {
27367  name string
27368}
27369
27370class Task {
27371  title string
27372  metadata map<string>
27373  owner Owner?
27374}
27375
27376rule seed
27377  when started
27378=> {
27379    record Task {
27380    title "Implement object literals"
27381    metadata { phase "kernel" }
27382    owner { name "Ada" }
27383  }
27384
27385  record Task {
27386    title "Implement multiline object literals"
27387    metadata {
27388      phase "kernel"
27389      owner "Ada"
27390    }
27391    owner {
27392      name "Ada"
27393    }
27394  }
27395}
27396"#;
27397
27398        let compiled = compile_program(source);
27399        assert_eq!(compiled.diagnostics, Vec::new());
27400        assert!(compiled.ir.is_some());
27401    }
27402
27403    #[test]
27404    fn rejects_invalid_expected_schema_object_and_map_record_fields() {
27405        let source = r#"
27406workflow BadObjectRecordFields
27407
27408class Owner {
27409  name string
27410}
27411
27412class Task {
27413  metadata map<string>
27414  owner Owner
27415}
27416
27417rule seed
27418  when started
27419=> {
27420  record Task {
27421    metadata { phase 1 }
27422    owner { alias "Ada" }
27423  }
27424}
27425
27426rule bad_guard
27427  when Task as task where { phase "kernel" } == task.metadata
27428=> {
27429}
27430"#;
27431
27432        let compiled = compile_program(source);
27433        assert!(compiled.ir.is_none());
27434        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27435            .message
27436            .contains("field `Task.metadata` expects `string`")));
27437        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27438            .message
27439            .contains("class `Owner` has no field `alias`")));
27440        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27441            .message
27442            .contains("missing required object field `Owner.name`")));
27443        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27444            .message
27445            .contains("compares incompatible expression types")));
27446    }
27447
27448    #[test]
27449    fn rejects_invalid_expression_types() {
27450        let source = r#"
27451workflow BadExpressionTypes
27452
27453class Task {
27454  title string
27455  labels map<string>
27456  priority int
27457  ready bool
27458}
27459
27460rule non_bool_guard
27461  when Task as task where task.priority
27462=> {
27463}
27464
27465rule bad_ordering
27466  when Task as task where task.title > "abc"
27467=> {
27468}
27469
27470rule bad_membership
27471  when Task as task where task.title in task.priority
27472=> {
27473}
27474
27475rule bad_equality
27476  when Task as task where task.ready == "yes"
27477=> {
27478}
27479
27480rule bad_array
27481  when Task as task where task.title in ["abc", 1]
27482=> {
27483}
27484
27485rule bad_map_key
27486  when Task as task where task.labels[1] == "urgent"
27487=> {
27488}
27489
27490rule bad_map_membership
27491  when Task as task where 1 in task.labels
27492=> {
27493}
27494"#;
27495
27496        let compiled = compile_program(source);
27497        assert!(compiled.ir.is_none());
27498        assert!(compiled
27499            .diagnostics
27500            .iter()
27501            .any(|diagnostic| diagnostic.message.contains("non-boolean guard expression")));
27502        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27503            .message
27504            .contains("orders non-orderable expression values")));
27505        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27506            .message
27507            .contains("uses membership against a non-array/non-map expression")));
27508        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27509            .message
27510            .contains("compares incompatible expression types")));
27511        assert!(compiled
27512            .diagnostics
27513            .iter()
27514            .any(|diagnostic| diagnostic.message.contains("mixed-type array literal")));
27515        assert!(compiled
27516            .diagnostics
27517            .iter()
27518            .any(|diagnostic| diagnostic.message.contains("non-string key")));
27519        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27520            .message
27521            .contains("map membership with a non-string key")));
27522    }
27523
27524    #[test]
27525    fn validates_duration_and_time_ordering_and_literals() {
27526        let source = r#"
27527workflow DurationTimeExpressions
27528
27529class Window {
27530  elapsed duration
27531  limit duration
27532  opened_at time
27533  due_at time
27534}
27535
27536assert exists(Window where elapsed < limit)
27537assert exists(Window where opened_at <= due_at)
27538
27539rule seed
27540  when started
27541=> {
27542  record Window {
27543    elapsed "PT30.5M"
27544    limit "PT1.25H"
27545    opened_at "2026-05-29T10:00:00.250-04:00"
27546    due_at "2026-05-29T14:00:00.500Z"
27547  }
27548}
27549"#;
27550
27551        let compiled = compile_program(source);
27552        assert_eq!(compiled.diagnostics, Vec::new());
27553        assert!(compiled.ir.is_some());
27554    }
27555
27556    #[test]
27557    fn rejects_invalid_duration_and_time_literals() {
27558        let source = r#"
27559workflow BadDurationTimeExpressions
27560
27561class Window {
27562  elapsed duration
27563  limit duration
27564  opened_at time
27565}
27566
27567rule seed
27568  when started
27569=> {
27570  record Window {
27571    elapsed "thirty minutes"
27572    limit "P1M"
27573    opened_at "morning"
27574  }
27575}
27576"#;
27577
27578        let compiled = compile_program(source);
27579        assert!(compiled.ir.is_none());
27580        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27581            .message
27582            .contains("field `Window.elapsed` has invalid duration literal")));
27583        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27584            .message
27585            .contains("field `Window.limit` has invalid duration literal")));
27586        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27587            .message
27588            .contains("field `Window.opened_at` has invalid time literal")));
27589    }
27590
27591    #[test]
27592    fn validates_assertion_expression_types_and_paths() {
27593        let source = r#"
27594workflow BadAssertions
27595
27596class Task {
27597  provider "codex" | "claude"
27598  priority int
27599}
27600
27601assert count(Task where provider == "bad") == 0
27602assert count(Task)
27603assert missing.root == "value"
27604"#;
27605
27606        let compiled = compile_program(source);
27607        assert!(compiled.ir.is_none());
27608        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27609            .message
27610            .contains("assertion compares finite-domain value to unknown `bad`")));
27611        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27612            .message
27613            .contains("assertion has non-boolean assertion expression")));
27614        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27615            .message
27616            .contains("assertion has unknown expression root `missing`")));
27617    }
27618
27619    #[test]
27620    fn validates_symmetric_finite_domain_literals_and_unknown_guard_roots() {
27621        let source = r#"
27622workflow SymmetricFiniteDomain
27623
27624enum ReviewStatus {
27625  Accept
27626  Revise
27627}
27628
27629class Task {
27630  status ReviewStatus
27631  provider "codex" | "claude"
27632}
27633
27634rule symmetric_literal
27635  when Task as task where "bad" == task.provider
27636=> {
27637}
27638
27639rule enum_variant_literal
27640  when Task as task where Missing == task.status
27641=> {
27642}
27643
27644rule array_membership_literal
27645  when Task as task where task.provider in ["codex", "bad"]
27646=> {
27647}
27648
27649rule implicit_query_head
27650  when Task as task where exists(Task where status == Missing)
27651=> {
27652}
27653
27654rule unknown_root
27655  when Task as task where other.provider == "codex"
27656=> {
27657}
27658"#;
27659
27660        let compiled = compile_program(source);
27661        assert!(compiled.ir.is_none());
27662        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27663            .message
27664            .contains("finite-domain value to unknown `bad`")));
27665        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27666            .message
27667            .contains("finite-domain value to unknown `Missing`")));
27668        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27669            .message
27670            .contains("unknown expression root `other`")));
27671    }
27672
27673    #[test]
27674    fn rejects_unsatisfiable_finite_domain_expression_relations() {
27675        let source = r#"
27676workflow UnsatisfiableFiniteDomains
27677
27678class Task {
27679  provider "codex" | "claude"
27680  route "cache" | "coerce"
27681}
27682
27683rule disjoint_equality
27684  when Task as task where task.provider == task.route
27685=> {
27686}
27687
27688rule empty_membership
27689  when Task as task where task.provider in []
27690=> {
27691}
27692
27693rule excluded_membership
27694  when Task as task where task.provider not in ["codex", "claude"]
27695=> {
27696}
27697"#;
27698
27699        let compiled = compile_program(source);
27700        assert!(compiled.ir.is_none());
27701        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27702            .message
27703            .contains("statically unsatisfiable finite-domain equality")));
27704        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27705            .message
27706            .contains("statically unsatisfiable finite-domain membership")));
27707        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
27708            .message
27709            .contains("statically unsatisfiable finite-domain exclusion")));
27710    }
27711
27712    #[test]
27713    fn accepts_map_index_expressions() {
27714        let source = r#"
27715workflow MapIndex
27716
27717class Task {
27718  labels map<string>
27719}
27720
27721rule route
27722  when Task as task where task.labels["priority"] == "high"
27723=> {
27724}
27725"#;
27726
27727        let compiled = compile_program(source);
27728        assert_eq!(compiled.diagnostics, Vec::new());
27729        let ir = compiled.ir.expect("valid ir");
27730        let guard = ir.rules[0].whens[0].guard.as_ref().expect("guard");
27731        assert_eq!(
27732            guard.expr.to_snapshot(),
27733            "task.labels[\"priority\"] == \"high\""
27734        );
27735    }
27736
27737    #[test]
27738    fn lowers_deterministic_ir_snapshot() {
27739        let source = r#"
27740workflow Snapshot
27741
27742
27743class Work {
27744  title string
27745  files string[]
27746  state "open" | "done"
27747}
27748
27749class Result {
27750  title string
27751  files string[]
27752}
27753
27754agent worker {
27755  provider fixture
27756  profile "repo-writer"
27757  capacity 2
27758  skills ["repo-user"]
27759}
27760
27761rule start
27762  when Work as work
27763=>
27764{
27765  tell worker "{{ work.title }}"
27766}
27767
27768rule finish
27769  when Result as result
27770=>
27771{
27772  record Work {
27773    title result.title
27774    files result.files
27775    state "done"
27776  }
27777}
27778"#;
27779
27780        let compiled = compile_program(source);
27781        assert_eq!(compiled.diagnostics, Vec::new());
27782        let ir = match compiled.ir {
27783            Some(ir) => ir,
27784            None => panic!("expected lowered IR"),
27785        };
27786
27787        let expected = "\
27788workflow Snapshot
27789schemas
27790  class Work
27791    title string
27792    files array<string>
27793    state union<literal<\"open\"> | literal<\"done\">>
27794  class Result
27795    title string
27796    files array<string>
27797agents
27798  agent worker harness=<fallback> provider=fixture profile=repo-writer capacity=2 skills=[repo-user] capabilities=[] tools=[]
27799rules
27800  rule start
27801    when Work as work
27802    reads
27803      schema:Work
27804    effects
27805      effect1 kind=agent.tell binding=- key=ef8ed2edd19578a222b6ad56ea1bffa8
27806    body_hash 96e148d2d421ee97960ef8f3edf61db9
27807  rule finish
27808    when Result as result
27809    reads
27810      schema:Result
27811    writes
27812      schema:Work
27813    body_hash 4a5ce925842b5b0bceb64bf33361d523
27814rule_dependencies
27815  finish --schema:Work--> start
27816";
27817
27818        assert_eq!(ir.to_snapshot(), expected);
27819    }
27820
27821    #[test]
27822    fn example_ir_snapshots_are_stable() {
27823        let examples = [
27824            (
27825                include_str!("../../../examples/minimal-noop.whip"),
27826                include_str!("../../../examples/minimal-noop.ir"),
27827            ),
27828            (
27829                include_str!("../../../examples/queue-worker-with-review.whip"),
27830                include_str!("../../../examples/queue-worker-with-review.ir"),
27831            ),
27832            (
27833                include_str!("../../../examples/circuit-breaker.whip"),
27834                include_str!("../../../examples/circuit-breaker.ir"),
27835            ),
27836            (
27837                include_str!("../../../examples/coerce-branch.whip"),
27838                include_str!("../../../examples/coerce-branch.ir"),
27839            ),
27840            (
27841                include_str!("../../../examples/terminal-output-union.whip"),
27842                include_str!("../../../examples/terminal-output-union.ir"),
27843            ),
27844            (
27845                include_str!("../../../examples/triage-chain.whip"),
27846                include_str!("../../../examples/triage-chain.ir"),
27847            ),
27848            (
27849                include_str!("../../../examples/incident-router.whip"),
27850                include_str!("../../../examples/incident-router.ir"),
27851            ),
27852            (
27853                include_str!("../../../examples/expression-kernel.whip"),
27854                include_str!("../../../examples/expression-kernel.ir"),
27855            ),
27856            (
27857                include_str!("../../../examples/multi-agent-bounded-concurrency.whip"),
27858                include_str!("../../../examples/multi-agent-bounded-concurrency.ir"),
27859            ),
27860            // `openclaw-lite` now imports the external `memory` package, so it
27861            // only compiles clean with a `whip.lock`; this parser-level snapshot
27862            // has no package resolution. Its IR stability is covered by the
27863            // lock-aware `dev_openclaw_lite_observes_heartbeat_and_files_work`.
27864            (
27865                include_str!("../../../examples/scheduled-escalation.whip"),
27866                include_str!("../../../examples/scheduled-escalation.ir"),
27867            ),
27868            (
27869                include_str!("../../../examples/event-bridge.whip"),
27870                include_str!("../../../examples/event-bridge.ir"),
27871            ),
27872            (
27873                include_str!("../../../examples/reusable-review-pattern.whip"),
27874                include_str!("../../../examples/reusable-review-pattern.ir"),
27875            ),
27876            (
27877                include_str!("../../../examples/reusable-action-chain.whip"),
27878                include_str!("../../../examples/reusable-action-chain.ir"),
27879            ),
27880            (
27881                include_str!("../../../examples/exec-json-ingest.whip"),
27882                include_str!("../../../examples/exec-json-ingest.ir"),
27883            ),
27884            (
27885                include_str!("../../../examples/deterministic-validation.whip"),
27886                include_str!("../../../examples/deterministic-validation.ir"),
27887            ),
27888            (
27889                include_str!("../../../examples/autoresearch-lite.whip"),
27890                include_str!("../../../examples/autoresearch-lite.ir"),
27891            ),
27892            (
27893                include_str!("../../../examples/gastown-lite.whip"),
27894                include_str!("../../../examples/gastown-lite.ir"),
27895            ),
27896            (
27897                include_str!("../../../examples/ralph.whip"),
27898                include_str!("../../../examples/ralph.ir"),
27899            ),
27900        ];
27901
27902        for (source, expected) in examples {
27903            let compiled = compile_program(source);
27904            assert_eq!(compiled.diagnostics, Vec::new());
27905            let ir = match compiled.ir {
27906                Some(ir) => ir,
27907                None => panic!("expected lowered IR"),
27908            };
27909            assert_eq!(ir.to_snapshot(), expected);
27910        }
27911    }
27912
27913    #[test]
27914    fn revision_examples_compile() {
27915        let examples = [
27916            (
27917                include_str!("../../../examples/revision-ticket-v1.whip"),
27918                Some("RevisionTicket"),
27919            ),
27920            (
27921                include_str!("../../../examples/revision-ticket-v2.whip"),
27922                Some("RevisionTicket"),
27923            ),
27924            (
27925                include_str!("../../../examples/revision-repair-planner.whip"),
27926                Some("RevisionRepairPlanner"),
27927            ),
27928            (
27929                include_str!("../../../examples/revision-running-cancel.whip"),
27930                Some("RevisionRunningCancel"),
27931            ),
27932            (
27933                include_str!("../../../examples/revision-parent-child.whip"),
27934                Some("ParentRevisionExample"),
27935            ),
27936            (
27937                include_str!("../../../examples/revision-validation-approval.whip"),
27938                Some("RevisionValidation"),
27939            ),
27940        ];
27941
27942        for (source, root) in examples {
27943            let compiled = compile_program_with_root(source, root);
27944            assert_eq!(compiled.diagnostics, Vec::new());
27945            assert!(compiled.ir.is_some());
27946        }
27947    }
27948
27949    #[test]
27950    fn rejects_unknown_schema_references() {
27951        let source = include_str!("../../../examples/invalid/unknown-schema.whip");
27952        let compiled = compile_program(source);
27953
27954        assert!(compiled.ir.is_none());
27955        assert_eq!(compiled.diagnostics.len(), 2);
27956        assert!(compiled
27957            .diagnostics
27958            .iter()
27959            .any(|diagnostic| diagnostic.message == "unknown schema reference `MissingStatus`"));
27960        assert!(compiled
27961            .diagnostics
27962            .iter()
27963            .any(|diagnostic| diagnostic.message == "unknown schema reference `MissingOutput`"));
27964    }
27965
27966    #[test]
27967    fn emit_of_undeclared_signal_is_flagged_statically() {
27968        // Q1: `emit signal <name>` must name a declared signal, caught at
27969        // `whip check` time (previously only at runtime effect-input building).
27970        let source = "\
27971workflow Emitter
27972signal trigger.x { peer string }
27973signal known.sig { note string }
27974rule relay
27975  when trigger.x as t
27976=> {
27977  emit signal known.sig to t.peer { note \"ok\" } as a
27978  emit signal unknown.sig to t.peer { note \"bad\" } as b
27979}
27980";
27981        let compiled = compile_program(source);
27982        let messages: Vec<&str> = compiled
27983            .diagnostics
27984            .iter()
27985            .map(|d| d.message.as_str())
27986            .collect();
27987        assert!(
27988            messages.contains(&"rule `relay` emits undeclared signal `unknown.sig`"),
27989            "expected the undeclared-emit diagnostic, got {messages:?}"
27990        );
27991        // The declared emit (`known.sig`) is not flagged.
27992        assert!(
27993            !messages
27994                .iter()
27995                .any(|m| m.contains("emits undeclared signal `known.sig`")),
27996            "a declared signal must not be flagged: {messages:?}"
27997        );
27998    }
27999
28000    #[test]
28001    fn source_emit_of_undeclared_signal_is_flagged_statically() {
28002        // The ingestion mirror of the reaction-side check: a `source` whose
28003        // `emit <signal>` names an undeclared signal is caught at `whip check`
28004        // time, so it cannot silently admit a fact no rule can react to.
28005        let source = "\
28006workflow SourceEmit
28007signal ingress.known { text string }
28008source file as feed {
28009  path \"/tmp/x.txt\"
28010  observe as obs
28011  emit ingress.unknown { text obs.line }
28012}
28013output result Done
28014class Done { ok string }
28015rule react
28016  when ingress.known as k
28017=> {
28018  complete result { ok \"ok\" }
28019}
28020";
28021        let compiled = compile_program(source);
28022        let messages: Vec<&str> = compiled
28023            .diagnostics
28024            .iter()
28025            .map(|d| d.message.as_str())
28026            .collect();
28027        assert!(
28028            messages.contains(&"source `feed` emits undeclared signal `ingress.unknown`"),
28029            "expected the undeclared source-emit diagnostic, got {messages:?}"
28030        );
28031        // A source emitting a declared signal must not be flagged.
28032        let ok_source = source.replace("ingress.unknown", "ingress.known");
28033        let ok_compiled = compile_program(&ok_source);
28034        assert!(
28035            !ok_compiled
28036                .diagnostics
28037                .iter()
28038                .any(|d| d.message.contains("emits undeclared signal")),
28039            "a declared signal must not be flagged: {:?}",
28040            ok_compiled
28041                .diagnostics
28042                .iter()
28043                .map(|d| d.message.as_str())
28044                .collect::<Vec<_>>()
28045        );
28046    }
28047
28048    #[test]
28049    fn source_emit_of_unknown_observation_field_is_flagged_statically() {
28050        // A source emit maps the observation record by name; reading a field the
28051        // source kind's observation doesn't have (here a `file` source has only
28052        // line/line_index/path) is caught at `whip check`, not silently mapped
28053        // null at runtime.
28054        let source = "\
28055workflow BadObs
28056signal ingress.fed { text string }
28057source file as feed {
28058  path \"/tmp/x.txt\"
28059  observe as obs
28060  emit ingress.fed { text obs.nosuchfield }
28061}
28062output result Done
28063class Done { ok string }
28064rule react
28065  when ingress.fed as f
28066=> { complete result { ok \"ok\" } }
28067";
28068        let messages: Vec<String> = compile_program(source)
28069            .diagnostics
28070            .iter()
28071            .map(|d| d.message.clone())
28072            .collect();
28073        assert!(
28074            messages.iter().any(|m| m.contains(
28075                "emit reads `obs.nosuchfield`, but a `file` source's observation has no field"
28076            )),
28077            "expected the unknown-observation-field diagnostic, got {messages:?}"
28078        );
28079        // A valid observation field (`line`) must not be flagged.
28080        let ok = source.replace("obs.nosuchfield", "obs.line");
28081        assert!(
28082            !compile_program(&ok)
28083                .diagnostics
28084                .iter()
28085                .any(|d| d.message.contains("observation has no field")),
28086            "a valid observation field must not be flagged"
28087        );
28088    }
28089
28090    #[test]
28091    fn renew_of_unacquired_lease_is_flagged_statically() {
28092        // `renew <binding>` must name a lease acquired in the same rule; a typo
28093        // (here `nonexistent`, no matching acquire) is caught at `whip check`
28094        // rather than renewing nothing at runtime.
28095        let source = "\
28096workflow RenewTypo
28097class Ticket { id string }
28098class Done { ok string }
28099lease slot { shared key Ticket slots 1 ttl 60s }
28100output result Done
28101table seed as Ticket [ { id \"t\" } ]
28102rule grab
28103  when Ticket as t
28104=> {
28105  acquire slot for t.id until ttl as held
28106  after held held {
28107    renew nonexistent until 300s as r
28108    complete result { ok \"ok\" }
28109  }
28110}
28111";
28112        let messages: Vec<String> = compile_program(source)
28113            .diagnostics
28114            .iter()
28115            .map(|d| d.message.clone())
28116            .collect();
28117        assert!(
28118            messages
28119                .iter()
28120                .any(|m| m.contains("renews unbound coordination binding `nonexistent`")),
28121            "expected the unbound-renew diagnostic, got {messages:?}"
28122        );
28123        // Renewing the actually-acquired lease (`held`) must not be flagged.
28124        let ok = source.replace("renew nonexistent", "renew held");
28125        assert!(
28126            !compile_program(&ok)
28127                .diagnostics
28128                .iter()
28129                .any(|d| d.message.contains("renews unbound coordination binding")),
28130            "renewing an acquired lease must not be flagged"
28131        );
28132    }
28133
28134    /// T3: a `renew <binding>` naming a same-rule `claim ... as <binding>` CLAIM
28135    /// binding is accepted at `whip check` (it lowers to `tracker.renew`), and
28136    /// the parser emits the `tracker.renew` effect kind for it.
28137    #[test]
28138    fn renew_of_a_claim_binding_is_accepted_and_lowers_to_tracker_renew() {
28139        let source = "\
28140workflow RenewClaim
28141class Done { ok string }
28142tracker backlog { provider builtin }
28143output result Done
28144rule work
28145  when backlog has ready issue as issue
28146=> {
28147  claim issue ttl 1h as active
28148
28149  after active succeeds {
28150    renew active as renewed
28151  }
28152
28153  after renewed succeeds {
28154    complete result { ok \"ok\" }
28155  }
28156}
28157";
28158        let compiled = compile_program(source);
28159        assert!(
28160            !compiled
28161                .diagnostics
28162                .iter()
28163                .any(|d| d.message.contains("renews unbound coordination binding")),
28164            "renewing a claim binding must not be flagged: {:?}",
28165            compiled.diagnostics
28166        );
28167        let ir = compiled.ir.expect("compiles");
28168        let work = ir
28169            .rules
28170            .iter()
28171            .find(|rule| rule.name == "work")
28172            .expect("work rule");
28173        assert!(
28174            work.metadata
28175                .effects
28176                .iter()
28177                .any(|effect| effect.kind == IrEffectKind::TrackerRenew),
28178            "a renew of a claim binding lowers to TrackerRenew: {:?}",
28179            work.metadata.effects
28180        );
28181        // And the coord `LeaseRenew` kind was NOT emitted for it.
28182        assert!(
28183            !work
28184                .metadata
28185                .effects
28186                .iter()
28187                .any(|effect| effect.kind == IrEffectKind::LeaseRenew),
28188            "no lease.renew for a claim-binding renew: {:?}",
28189            work.metadata.effects
28190        );
28191    }
28192
28193    #[test]
28194    fn release_of_each_bound_coordination_form_is_accepted() {
28195        // A `release <x>` legitimately names ONE OF THREE referents
28196        // (spec/coordination.md): (1) an `acquire ... as <x>` lease binding,
28197        // (2) a `claim <x> as ...` item, or (3) a `when <queue> has ready ... as
28198        // <x>` work-item binding. All three must pass `whip check`; the naive
28199        // acquire∪claim-binding model false-positives (2) and (3).
28200        let source = "\
28201workflow ReleaseForms
28202class Ticket { id string }
28203class Done { ok string }
28204lease slot { key Ticket slots 1 ttl 60s }
28205tracker backlog { provider builtin }
28206agent worker { provider fixture profile \"repo-writer\" capacity 1 }
28207output result Done
28208rule work
28209  when backlog has ready issue as issue
28210  when worker is available
28211=> {
28212  acquire slot for issue.id until ttl as held
28213  claim issue as active_claim
28214  after active_claim succeeds {
28215    release held
28216    release issue
28217    complete result { ok \"done\" }
28218  }
28219  after active_claim fails {
28220    release held
28221    complete result { ok \"gave-up\" }
28222  }
28223}
28224";
28225        let messages: Vec<String> = compile_program(source)
28226            .diagnostics
28227            .iter()
28228            .map(|d| d.message.clone())
28229            .collect();
28230        assert!(
28231            !messages
28232                .iter()
28233                .any(|m| m.contains("releases unbound coordination item")),
28234            "no bound release form must be flagged, got {messages:?}"
28235        );
28236    }
28237
28238    #[test]
28239    fn release_of_unbound_coordination_item_is_flagged_statically() {
28240        // `release <x>` where `<x>` matches none of the three legitimate
28241        // referents (no acquire binding, no claim item, no when-has-ready
28242        // work-item binding) is a genuinely-unbound release: caught at
28243        // `whip check` rather than releasing nothing at runtime.
28244        let source = "\
28245workflow ReleaseTypo
28246class Done { ok string }
28247tracker backlog { provider builtin }
28248agent worker { provider fixture profile \"repo-writer\" capacity 1 }
28249output result Done
28250rule work
28251  when backlog has ready issue as issue
28252  when worker is available
28253=> {
28254  claim issue as active_claim
28255  after active_claim succeeds {
28256    release nonexistent
28257    complete result { ok \"done\" }
28258  }
28259  after active_claim fails {
28260    complete result { ok \"gave-up\" }
28261  }
28262}
28263";
28264        let messages: Vec<String> = compile_program(source)
28265            .diagnostics
28266            .iter()
28267            .map(|d| d.message.clone())
28268            .collect();
28269        assert!(
28270            messages
28271                .iter()
28272                .any(|m| m.contains("releases unbound coordination item `nonexistent`")),
28273            "expected the unbound-release diagnostic, got {messages:?}"
28274        );
28275        // Releasing the actually-bound work item (`issue`) must not be flagged.
28276        let ok = source.replace("release nonexistent", "release issue");
28277        assert!(
28278            !compile_program(&ok)
28279                .diagnostics
28280                .iter()
28281                .any(|d| d.message.contains("releases unbound coordination item")),
28282            "releasing a bound work item must not be flagged"
28283        );
28284    }
28285
28286    #[test]
28287    fn http_source_url_must_have_an_http_scheme() {
28288        // The runtime GETs the url, so a scheme-less url is caught at check time.
28289        let source = "\
28290workflow BadUrl
28291signal ingress.fed { text string }
28292source http as feed {
28293  url \"not-a-real-url\"
28294  observe as obs
28295  emit ingress.fed { text obs.item }
28296}
28297output result Done
28298class Done { ok string }
28299rule react
28300  when ingress.fed as f
28301=> { complete result { ok \"ok\" } }
28302";
28303        let messages: Vec<String> = compile_program(source)
28304            .diagnostics
28305            .iter()
28306            .map(|d| d.message.clone())
28307            .collect();
28308        assert!(
28309            messages
28310                .iter()
28311                .any(|m| m.contains("is not an absolute http(s) URL")),
28312            "expected the http url-scheme diagnostic, got {messages:?}"
28313        );
28314        // A well-formed https url must not be flagged.
28315        let ok = source.replace("not-a-real-url", "https://example.com/feed.json");
28316        assert!(
28317            !compile_program(&ok)
28318                .diagnostics
28319                .iter()
28320                .any(|d| d.message.contains("absolute http(s) URL")),
28321            "a well-formed url must not be flagged"
28322        );
28323    }
28324
28325    /// I2a `watch` clause (spec/std-ingress.md): a `file` source in occurrence
28326    /// mode parses, lowers `watch` into the IR, formats idempotently, and the
28327    /// watch-mode observation schema (`path`/`content_hash`/`watch`) governs
28328    /// the emit-field check.
28329    #[test]
28330    fn file_watch_source_parses_lowers_and_formats() {
28331        let source = "\
28332workflow WatchSource
28333signal drop.arrived { path string digest string }
28334source file as drops {
28335  watch \"./drops/*.json\"
28336  observe as obs
28337  emit drop.arrived {
28338    path obs.path
28339    digest obs.content_hash
28340  }
28341}
28342output result Done
28343class Done { ok string }
28344rule react
28345  when drop.arrived as f
28346=> { complete result { ok \"ok\" } }
28347";
28348        let compiled = compile_program(source);
28349        let ir = compiled.ir.expect("watch source compiles");
28350        let decl = ir.sources.first().expect("source lowered");
28351        assert!(decl.is_file);
28352        assert_eq!(decl.watch.as_deref(), Some("./drops/*.json"));
28353        assert_eq!(decl.path, None);
28354        let formatted = format_program(source).formatted.expect("formats");
28355        assert!(
28356            formatted.contains("watch \"./drops/*.json\""),
28357            "{formatted}"
28358        );
28359        assert_eq!(
28360            format_program(&formatted).formatted.expect("reformats"),
28361            formatted,
28362            "fmt must be idempotent over the watch clause"
28363        );
28364        // The watch-mode observation record has no `line`: reading it is the
28365        // unknown-observation-field diagnostic.
28366        let bad = source.replace("digest obs.content_hash", "digest obs.line");
28367        let messages: Vec<String> = compile_program(&bad)
28368            .diagnostics
28369            .iter()
28370            .map(|d| d.message.clone())
28371            .collect();
28372        assert!(
28373            messages
28374                .iter()
28375                .any(|m| m.contains("observation has no field `line`")),
28376            "watch-mode emit must validate against the occurrence schema, got {messages:?}"
28377        );
28378    }
28379
28380    /// I2a closed clause sets (spec/std-ingress.md "Static checks" #3): `watch`
28381    /// is file-only; `path`/`watch` are exclusive; a file source must declare
28382    /// one of them.
28383    #[test]
28384    fn file_source_clause_set_is_closed() {
28385        let watch_on_clock = "\
28386workflow BadWatch
28387signal tick.fired { at time }
28388source clock as ticker {
28389  every 5m
28390  missed skip
28391  watch \"./drops/*.json\"
28392  observe as tick
28393  emit tick.fired { at tick.scheduled_at }
28394}
28395output result Done
28396class Done { ok string }
28397rule react
28398  when tick.fired as f
28399=> { complete result { ok \"ok\" } }
28400";
28401        let messages: Vec<String> = compile_program(watch_on_clock)
28402            .diagnostics
28403            .iter()
28404            .map(|d| d.message.clone())
28405            .collect();
28406        assert!(
28407            messages
28408                .iter()
28409                .any(|m| m.contains("`watch` clause but its provider is `clock`")),
28410            "watch outside `file` must be rejected, got {messages:?}"
28411        );
28412
28413        let both_modes = "\
28414workflow BothModes
28415signal ingress.fed { text string }
28416source file as feed {
28417  path \"./inbox.txt\"
28418  watch \"./drops/*.txt\"
28419  observe as obs
28420  emit ingress.fed { text obs.path }
28421}
28422output result Done
28423class Done { ok string }
28424rule react
28425  when ingress.fed as f
28426=> { complete result { ok \"ok\" } }
28427";
28428        let messages: Vec<String> = compile_program(both_modes)
28429            .diagnostics
28430            .iter()
28431            .map(|d| d.message.clone())
28432            .collect();
28433        assert!(
28434            messages
28435                .iter()
28436                .any(|m| m.contains("declares both `path` and `watch`")),
28437            "path+watch must be rejected as exclusive modes, got {messages:?}"
28438        );
28439
28440        let neither = "\
28441workflow Neither
28442signal ingress.fed { text string }
28443source file as feed {
28444  observe as obs
28445  emit ingress.fed { text obs.line }
28446}
28447output result Done
28448class Done { ok string }
28449rule react
28450  when ingress.fed as f
28451=> { complete result { ok \"ok\" } }
28452";
28453        let messages: Vec<String> = compile_program(neither)
28454            .diagnostics
28455            .iter()
28456            .map(|d| d.message.clone())
28457            .collect();
28458        assert!(
28459            messages
28460                .iter()
28461                .any(|m| m.contains("requires a `path` or `watch` clause")),
28462            "a file source with neither mode must be rejected, got {messages:?}"
28463        );
28464    }
28465
28466    /// I2a `dedup` clause (spec/std-ingress.md): parses and lowers to
28467    /// `dedup_field` for http/file(line) sources; rejected where no provider
28468    /// delivery identity applies (clock, watch mode) and when the path does
28469    /// not name a known observation field off the observe binding.
28470    #[test]
28471    fn dedup_clause_parses_lowers_and_validates() {
28472        let source = "\
28473workflow DedupSource
28474signal ingress.ingested { text string }
28475source http as feed {
28476  url \"https://example.com/feed.json\"
28477  dedup obs.item
28478  observe as obs
28479  emit ingress.ingested { text obs.item }
28480}
28481output result Done
28482class Done { ok string }
28483rule react
28484  when ingress.ingested as f
28485=> { complete result { ok \"ok\" } }
28486";
28487        let compiled = compile_program(source);
28488        let ir = compiled.ir.expect("dedup source compiles");
28489        let decl = ir.sources.first().expect("source lowered");
28490        assert!(decl.is_http);
28491        assert_eq!(decl.dedup_field.as_deref(), Some("item"));
28492        let formatted = format_program(source).formatted.expect("formats");
28493        assert!(formatted.contains("dedup obs.item"), "{formatted}");
28494        assert_eq!(
28495            format_program(&formatted).formatted.expect("reformats"),
28496            formatted,
28497            "fmt must be idempotent over the dedup clause"
28498        );
28499
28500        // Unknown observation field: the admission key would be silently null.
28501        let bad_field = source.replace("dedup obs.item", "dedup obs.delivery_id");
28502        let messages: Vec<String> = compile_program(&bad_field)
28503            .diagnostics
28504            .iter()
28505            .map(|d| d.message.clone())
28506            .collect();
28507        assert!(
28508            messages
28509                .iter()
28510                .any(|m| m.contains("observation has no field `delivery_id`")),
28511            "an unknown dedup field must be rejected, got {messages:?}"
28512        );
28513
28514        // Wrong binding root: dedup reads the observation, nothing else.
28515        let bad_root = source.replace("dedup obs.item", "dedup other.item");
28516        let messages: Vec<String> = compile_program(&bad_root)
28517            .diagnostics
28518            .iter()
28519            .map(|d| d.message.clone())
28520            .collect();
28521        assert!(
28522            messages
28523                .iter()
28524                .any(|m| m.contains("`dedup` must name one observation field")),
28525            "a dedup path off a foreign binding must be rejected, got {messages:?}"
28526        );
28527
28528        // No provider delivery identity on a clock source.
28529        let on_clock = "\
28530workflow DedupClock
28531signal tick.fired { at time }
28532source clock as ticker {
28533  every 5m
28534  missed skip
28535  dedup tick.occurrence_id
28536  observe as tick
28537  emit tick.fired { at tick.scheduled_at }
28538}
28539output result Done
28540class Done { ok string }
28541rule react
28542  when tick.fired as f
28543=> { complete result { ok \"ok\" } }
28544";
28545        let messages: Vec<String> = compile_program(on_clock)
28546            .diagnostics
28547            .iter()
28548            .map(|d| d.message.clone())
28549            .collect();
28550        assert!(
28551            messages
28552                .iter()
28553                .any(|m| m.contains("declares a `dedup` clause but its provider is `clock`")),
28554            "dedup on a clock source must be rejected, got {messages:?}"
28555        );
28556    }
28557
28558    #[test]
28559    fn enum_variants_are_one_per_line() {
28560        // Pasted prose / a forgotten `#` would otherwise become variants that
28561        // pollute the domain (and reach coerce output schemas).
28562        let garbage = compile_program(
28563            "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",
28564        );
28565        assert!(garbage.diagnostics.iter().any(|d| d
28566            .message
28567            .contains("on the same line as the previous variant")));
28568        // Payload-carrying variants (sum types) are untouched.
28569        let payload = compile_program(
28570            "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",
28571        );
28572        assert!(
28573            !payload
28574                .diagnostics
28575                .iter()
28576                .any(|d| d.message.contains("same line")),
28577            "{:?}",
28578            payload.diagnostics
28579        );
28580    }
28581
28582    #[test]
28583    fn unknown_std_package_import_is_a_check_error() {
28584        // std resolution is a built-in registry: a typo'd name can never
28585        // resolve, so it must not silently import nothing.
28586        let typo = compile_program(
28587            "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",
28588        );
28589        assert!(typo
28590            .diagnostics
28591            .iter()
28592            .any(|d| d.message.contains("unknown standard package `std.coercon`")));
28593        // Every real std package name passes.
28594        for id in STD_PACKAGE_IDS {
28595            let ok = compile_program(&format!(
28596                "use {id}\nworkflow T\noutput result D\nclass D {{ a string }}\nrule r\n  when started\n=> {{\n  complete result {{ a \"x\" }}\n}}\n"
28597            ));
28598            assert!(
28599                !ok.diagnostics
28600                    .iter()
28601                    .any(|d| d.message.contains("unknown standard package")),
28602                "{id}: {:?}",
28603                ok.diagnostics
28604            );
28605        }
28606        // Non-std package names stay lock-resolved, not registry-checked.
28607        let nonstd = compile_program(
28608            "use notes\nworkflow T\noutput result D\nclass D { a string }\nrule r\n  when started\n=> {\n  complete result { a \"x\" }\n}\n",
28609        );
28610        assert!(!nonstd
28611            .diagnostics
28612            .iter()
28613            .any(|d| d.message.contains("unknown standard package")));
28614    }
28615
28616    #[test]
28617    fn blockless_coerce_desugars_to_the_prompt_clause() {
28618        let block = compile_program(
28619            "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",
28620        );
28621        let blockless = compile_program(
28622            "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",
28623        );
28624        let block_ir = block.ir.expect("block form compiles");
28625        let blockless_ir = blockless.ir.expect("blockless form compiles");
28626        // Same declaration, same clause: the sugar prefixes `prompt ` onto the
28627        // raw string, so the body is the block form's sole clause.
28628        assert!(blockless_ir.coerces[0].body.starts_with("prompt \"\"\""));
28629        assert_eq!(block_ir.coerces[0].name, blockless_ir.coerces[0].name);
28630    }
28631
28632    #[test]
28633    fn coerce_body_is_a_validated_clause_list() {
28634        let source = |body: &str| {
28635            format!(
28636                "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"
28637            )
28638        };
28639        // A typo'd `promt` — previously a SILENT no-prompt coercion — errors.
28640        let typo = compile_program(&source("  promt \"Judge {{ x }}\""));
28641        assert!(typo
28642            .diagnostics
28643            .iter()
28644            .any(|d| d.message.contains("unknown coerce field `promt`")));
28645        // Unknown fields error.
28646        let junk = compile_program(&source("  prompt \"Judge {{ x }}\"\n  mystery field"));
28647        assert!(junk
28648            .diagnostics
28649            .iter()
28650            .any(|d| d.message.contains("unknown coerce field `mystery`")));
28651        // The legal clauses pass: prompt (multi-line, annotated) + provider,
28652        // with comments and blank lines interleaved.
28653        let legal = compile_program(&source(
28654            "  # choose the fixture\n  provider fixture\n\n  prompt \"\"\"markdown\n  Judge {{ x }}.\n  {{ ctx.output_format }}\n  \"\"\"",
28655        ));
28656        assert!(
28657            !legal
28658                .diagnostics
28659                .iter()
28660                .any(|d| d.message.contains("unknown coerce field")),
28661            "{:?}",
28662            legal.diagnostics
28663        );
28664        // A malformed provider clause errors.
28665        let malformed = compile_program(&source("  provider one two\n  prompt \"Judge {{ x }}\""));
28666        assert!(malformed
28667            .diagnostics
28668            .iter()
28669            .any(|d| d.message.contains("malformed `provider` clause")));
28670    }
28671
28672    #[test]
28673    fn rejects_invalid_agent_declarations() {
28674        let source = include_str!("../../../examples/invalid/bad-agent.whip");
28675        let compiled = compile_program(source);
28676
28677        assert!(compiled.ir.is_none());
28678        assert_eq!(compiled.diagnostics.len(), 3);
28679        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
28680            .message
28681            .contains("capacity must be greater than zero")));
28682        assert!(compiled
28683            .diagnostics
28684            .iter()
28685            .any(|diagnostic| diagnostic.message.contains("more than once")));
28686        assert!(compiled
28687            .diagnostics
28688            .iter()
28689            .any(|diagnostic| diagnostic.message.contains("unknown agent field")));
28690        // A missing profile is no longer a diagnostic: it defaults to the
28691        // least-authority `no-repo` (agent-declaration defaults, 2026-07-21).
28692        assert!(!compiled
28693            .diagnostics
28694            .iter()
28695            .any(|diagnostic| diagnostic.message.contains("missing a profile")));
28696    }
28697
28698    #[test]
28699    fn rejects_invalid_effect_dependencies() {
28700        let source = include_str!("../../../examples/invalid/bad-effect-graph.whip");
28701        let compiled = compile_program(source);
28702
28703        assert!(compiled.ir.is_none());
28704        assert!(compiled
28705            .diagnostics
28706            .iter()
28707            .any(|diagnostic| diagnostic.message.contains("unknown effect binding")));
28708        assert!(compiled
28709            .diagnostics
28710            .iter()
28711            .any(|diagnostic| diagnostic.message.contains("unsupported `after`")));
28712    }
28713
28714    #[test]
28715    fn accepts_equality_guards_in_when_clauses() {
28716        let source = r#"
28717workflow GuardGuess
28718
28719class WorkItem {
28720  state "ready" | "blocked"
28721}
28722
28723rule branch
28724  when WorkItem as item where item.state == "ready"
28725=> {
28726}
28727"#;
28728        let compiled = compile_program(source);
28729        assert_eq!(compiled.diagnostics, Vec::new());
28730        let ir = compiled.ir.expect("valid ir");
28731        let when = ir
28732            .rules
28733            .iter()
28734            .flat_map(|rule| &rule.whens)
28735            .find(|when| when.source == "WorkItem as item where item.state == \"ready\"")
28736            .expect("guarded when");
28737        assert_eq!(when.pattern, "WorkItem as item");
28738        assert_eq!(
28739            when.guard.as_ref().map(|guard| guard.expr.to_snapshot()),
28740            Some("item.state == \"ready\"".to_owned())
28741        );
28742    }
28743
28744    #[test]
28745    fn lowers_assertions_to_parsed_expression_ir() {
28746        let source = r#"
28747workflow AssertionGuess
28748
28749class Result {
28750  status "done"
28751}
28752
28753assert count(Result where status == "done") == 1
28754"#;
28755        let compiled = compile_program(source);
28756        assert_eq!(compiled.diagnostics, Vec::new());
28757        let ir = compiled.ir.expect("valid ir");
28758        let assertion = ir.assertions.first().expect("assertion");
28759        assert_eq!(
28760            assertion.expr.source,
28761            "count(Result where status == \"done\") == 1"
28762        );
28763        assert_eq!(
28764            assertion.expr.expr.to_snapshot(),
28765            "count(Result where status == \"done\") == 1"
28766        );
28767        assert_eq!(
28768            assertion
28769                .projection_reads
28770                .iter()
28771                .map(IrProjectionRead::to_snapshot)
28772                .collect::<Vec<_>>(),
28773            vec!["fact:Result where status == \"done\""]
28774        );
28775    }
28776
28777    #[test]
28778    fn lowers_guard_projection_reads_to_rule_metadata() {
28779        let source = r#"
28780workflow GuardProjection
28781
28782class Task {
28783  status "ready"
28784}
28785
28786class Result {
28787  status "done"
28788}
28789
28790rule gated
28791  when Task as task where exists(Result where status == "done")
28792=> {
28793}
28794"#;
28795        let compiled = compile_program(source);
28796        assert_eq!(compiled.diagnostics, Vec::new());
28797        let ir = compiled.ir.expect("valid ir");
28798        let rule = ir.rules.first().expect("rule");
28799        assert_eq!(
28800            rule.metadata
28801                .projection_reads
28802                .iter()
28803                .map(IrProjectionRead::to_snapshot)
28804                .collect::<Vec<_>>(),
28805            vec!["fact:Result where status == \"done\""]
28806        );
28807    }
28808
28809    fn read_codec_program(format: &str) -> String {
28810        format!(
28811            r#"
28812workflow ReadBody
28813
28814output result Result
28815
28816class Result {{
28817  status string
28818}}
28819
28820file store project_files {{
28821  root "./data"
28822}}
28823
28824rule pick
28825  when started
28826=> {{
28827  read {format} from project_files at "note.md" as fileResult
28828  after fileResult succeeds as result {{
28829    complete result {{
28830      status "ok"
28831    }}
28832  }}
28833}}
28834"#
28835        )
28836    }
28837
28838    #[test]
28839    fn read_accepts_text_and_markdown_body_codecs() {
28840        for format in ["text", "markdown"] {
28841            let compiled = compile_program(&read_codec_program(format));
28842            assert_eq!(
28843                compiled.diagnostics,
28844                Vec::new(),
28845                "`read {format}` compiles clean"
28846            );
28847            assert!(compiled.ir.is_some(), "`read {format}` produces IR");
28848        }
28849    }
28850
28851    #[test]
28852    fn read_rejects_structured_and_binary_codecs() {
28853        // Structured codecs are the `import` surface; `bytes` is a deferred read
28854        // codec. `read` decodes only body formats in v0.
28855        for format in ["json", "jsonl", "csv", "bytes"] {
28856            let compiled = compile_program(&read_codec_program(format));
28857            assert!(
28858                compiled
28859                    .diagnostics
28860                    .iter()
28861                    .any(|diagnostic| diagnostic.message.contains("not supported")),
28862                "`read {format}` is rejected with a diagnostic; got {:?}",
28863                compiled.diagnostics
28864            );
28865        }
28866    }
28867
28868    fn write_program(format: &str, mode_clause: &str) -> String {
28869        format!(
28870            r#"
28871workflow WriteBody
28872
28873output result Result
28874
28875class Result {{
28876  status string
28877}}
28878
28879file store out_files {{
28880  root "./data"
28881  allow write ["**"]
28882}}
28883
28884rule pick
28885  when started
28886=> {{
28887  write {format} to out_files at "report.md" {{
28888    body "hello"
28889    {mode_clause}
28890  }} as written
28891  after written succeeds as result {{
28892    complete result {{
28893      status "ok"
28894    }}
28895  }}
28896}}
28897"#
28898        )
28899    }
28900
28901    #[test]
28902    fn write_accepts_text_and_markdown_with_explicit_mode() {
28903        for format in ["text", "markdown"] {
28904            let compiled = compile_program(&write_program(format, "mode create"));
28905            assert_eq!(
28906                compiled.diagnostics,
28907                Vec::new(),
28908                "`write {format}` with an explicit mode compiles clean"
28909            );
28910            assert!(compiled.ir.is_some(), "`write {format}` produces IR");
28911        }
28912    }
28913
28914    #[test]
28915    fn write_rejects_structured_codecs() {
28916        for format in ["json", "csv", "bytes"] {
28917            let compiled = compile_program(&write_program(format, "mode create"));
28918            assert!(
28919                compiled
28920                    .diagnostics
28921                    .iter()
28922                    .any(|diagnostic| diagnostic.message.contains("not supported")),
28923                "`write {format}` is rejected; got {:?}",
28924                compiled.diagnostics
28925            );
28926        }
28927    }
28928
28929    #[test]
28930    fn write_requires_an_explicit_mode() {
28931        // "No silent overwrite": omitting the mode is a check error.
28932        let compiled = compile_program(&write_program("text", ""));
28933        assert!(
28934            compiled
28935                .diagnostics
28936                .iter()
28937                .any(|diagnostic| diagnostic.message.contains("explicit `mode`")),
28938            "`write` without a mode is rejected; got {:?}",
28939            compiled.diagnostics
28940        );
28941    }
28942
28943    #[test]
28944    fn write_rejects_unknown_mode() {
28945        let compiled = compile_program(&write_program("text", "mode clobber"));
28946        assert!(
28947            compiled
28948                .diagnostics
28949                .iter()
28950                .any(|diagnostic| diagnostic.message.contains("unknown write mode")),
28951            "an unknown write mode is rejected; got {:?}",
28952            compiled.diagnostics
28953        );
28954    }
28955
28956    fn import_program(format: &str) -> String {
28957        format!(
28958            r#"
28959workflow ImportRows
28960
28961output result Result
28962
28963class Result {{
28964  status string
28965}}
28966
28967class IssueRow {{
28968  title string
28969  priority string
28970}}
28971
28972file store data_files {{
28973  root "./data"
28974}}
28975
28976rule pick
28977  when started
28978=> {{
28979  import {format} IssueRow from data_files at "issues.in" as imported
28980  after imported succeeds as r {{
28981    complete result {{
28982      status "ok"
28983    }}
28984  }}
28985}}
28986"#
28987        )
28988    }
28989
28990    #[test]
28991    fn import_accepts_structured_codecs_and_lowers_to_file_import() {
28992        for format in ["jsonl", "json", "csv"] {
28993            let compiled = compile_program(&import_program(format));
28994            assert_eq!(
28995                compiled.diagnostics,
28996                Vec::new(),
28997                "`import {format}` compiles clean"
28998            );
28999            let ir = compiled.ir.expect("import produces IR");
29000            let rule = ir.rules.first().expect("rule");
29001            assert!(
29002                rule.metadata
29003                    .effects
29004                    .iter()
29005                    .any(|effect| effect.kind == IrEffectKind::FileImport),
29006                "`import {format}` lowers to a file.import effect"
29007            );
29008        }
29009    }
29010
29011    #[test]
29012    fn import_rejects_unsupported_codecs() {
29013        // `import` decodes structured row codecs only; body/binary formats are
29014        // not import surfaces.
29015        for format in ["xml", "text", "markdown", "bytes"] {
29016            let compiled = compile_program(&import_program(format));
29017            assert!(
29018                compiled
29019                    .diagnostics
29020                    .iter()
29021                    .any(|diagnostic| diagnostic.message.contains("not supported")),
29022                "`import {format}` is rejected; got {:?}",
29023                compiled.diagnostics
29024            );
29025        }
29026    }
29027
29028    #[test]
29029    fn class_field_key_annotation_lowers_and_rejects_duplicates() {
29030        let single = compile_program(
29031            r#"
29032workflow Keyed
29033
29034class Row {
29035  id string @key
29036  title string
29037}
29038"#,
29039        );
29040        assert_eq!(
29041            single.diagnostics,
29042            Vec::new(),
29043            "single `@key` compiles clean"
29044        );
29045        let ir = single.ir.expect("ir");
29046        let class = ir
29047            .schemas
29048            .iter()
29049            .find_map(|schema| match schema {
29050                IrSchema::Class(class) if class.name == "Row" => Some(class),
29051                _ => None,
29052            })
29053            .expect("Row class");
29054        let key_fields = class
29055            .fields
29056            .iter()
29057            .filter(|field| field.is_key)
29058            .map(|field| field.name.as_str())
29059            .collect::<Vec<_>>();
29060        assert_eq!(key_fields, vec!["id"], "the `@key` field is recorded");
29061
29062        let dual = compile_program(
29063            r#"
29064workflow Keyed
29065
29066class Row {
29067  a string @key
29068  b string @key
29069}
29070"#,
29071        );
29072        assert!(
29073            dual.diagnostics
29074                .iter()
29075                .any(|diagnostic| diagnostic.message.contains("more than one `@key`")),
29076            "two `@key` fields are rejected; got {:?}",
29077            dual.diagnostics
29078        );
29079    }
29080
29081    #[test]
29082    fn single_line_terminal_block_validates_its_fields() {
29083        // Regression: `complete <name> { <field> }` on one line was reported as
29084        // missing the field, because the terminal-block extractor only captured
29085        // subsequent lines (a single-line block has brace-delta 0). Both the
29086        // single-line and multi-line forms must validate identically.
29087        for body in [
29088            "  complete result { status \"ok\" }",
29089            "  complete result {\n    status \"ok\"\n  }",
29090        ] {
29091            let source = format!(
29092                r#"
29093workflow S
29094
29095output result Result
29096
29097class Result {{
29098  status string
29099}}
29100
29101rule go
29102  when started
29103=> {{
29104{body}
29105}}
29106"#
29107            );
29108            let compiled = compile_program(&source);
29109            assert!(
29110                !compiled
29111                    .diagnostics
29112                    .iter()
29113                    .any(|diagnostic| diagnostic.message.contains("missing required field")),
29114                "terminal block validates its field; got {:?}",
29115                compiled.diagnostics
29116            );
29117        }
29118    }
29119
29120    #[test]
29121    fn action_declaration_parses_and_is_inert_until_expansion() {
29122        // DR-0023 slice 1: an `action` template parses (typed params + a block
29123        // body) and lowers away cleanly (inert until call-site expansion in
29124        // slice 2), so a program declaring an unused action compiles with no
29125        // diagnostics.
29126        let compiled = compile_program(
29127            r#"
29128workflow A
29129
29130output result Result
29131
29132class Result {
29133  status string
29134}
29135
29136class Task {
29137  name string
29138}
29139
29140action do_it(task Task, label string) {
29141  record Result {
29142    status label
29143  }
29144}
29145
29146rule go
29147  when started
29148=> {
29149  complete result {
29150    status "ok"
29151  }
29152}
29153"#,
29154        );
29155        assert_eq!(
29156            compiled.diagnostics,
29157            Vec::new(),
29158            "an unused action declaration compiles clean"
29159        );
29160        let ir = compiled.ir.expect("program with an action lowers");
29161        // The action is a template consumed before lowering — it is not a runtime
29162        // construct, so it leaves no rule/schema behind beyond the workflow's own.
29163        assert!(
29164            ir.rules.iter().any(|rule| rule.name == "go"),
29165            "the ordinary rule still lowers alongside the action template"
29166        );
29167    }
29168
29169    #[test]
29170    fn accepts_typed_case_branches_in_rule_bodies() {
29171        let source = r#"
29172workflow CaseGuess
29173
29174enum ReviewStatus {
29175  Accept
29176  Revise
29177  Blocked
29178}
29179
29180class Review {
29181  status ReviewStatus
29182  assignee string?
29183}
29184
29185class Routed {
29186  status ReviewStatus
29187}
29188
29189rule route
29190  when Review as review
29191=> {
29192  case review.status {
29193    Accept => {
29194      record Routed {
29195        status Accept
29196      }
29197    }
29198    Revise => {
29199      record Routed {
29200        status Revise
29201      }
29202    }
29203    Blocked => {
29204      record Routed {
29205        status Blocked
29206      }
29207    }
29208  }
29209
29210  case review.assignee {
29211    Some owner => {
29212      record Routed {
29213        status Accept
29214      }
29215    }
29216    None => {
29217      record Routed {
29218        status Blocked
29219      }
29220    }
29221  }
29222}
29223"#;
29224        let compiled = compile_program(source);
29225        assert_eq!(compiled.diagnostics, Vec::new());
29226        assert!(compiled.ir.is_some());
29227    }
29228
29229    #[test]
29230    fn accepts_terminal_output_case_branches_inside_completes_after() {
29231        let source = r#"
29232workflow TerminalCaseGuess
29233
29234class WorkItem {
29235  title string
29236}
29237
29238class MessageClassification {
29239  summary string
29240}
29241
29242class Routed {
29243  branch string
29244  detail string
29245}
29246
29247coerce classifyMessage(title string) -> MessageClassification {
29248  prompt "Classify"
29249}
29250
29251rule classify
29252  when WorkItem as item
29253=> {
29254  coerce classifyMessage(item.title) as classification
29255
29256  after classification completes {
29257    case classification {
29258      Completed as result => {
29259        record Routed {
29260          branch "completed"
29261          detail result.summary
29262        }
29263      }
29264      Failed as failure => {
29265        record Routed {
29266          branch "failed"
29267          detail failure.reason
29268        }
29269      }
29270      TimedOut as timeout => {
29271        record Routed {
29272          branch "timed_out"
29273          detail timeout.summary
29274        }
29275      }
29276      Cancelled as cancel => {
29277        record Routed {
29278          branch "cancelled"
29279          detail cancel.summary
29280        }
29281      }
29282    }
29283  }
29284}
29285"#;
29286        let compiled = compile_program(source);
29287        assert_eq!(compiled.diagnostics, Vec::new());
29288        assert!(compiled.ir.is_some());
29289    }
29290
29291    #[test]
29292    fn accepts_terminal_output_case_as_binding_form() {
29293        // `Completed as result` is accepted alongside the space form `Completed result`
29294        // and binds/narrows identically (Stage 1b surface unification).
29295        let source = r#"
29296workflow T
29297
29298class WorkItem { title string }
29299class MessageClassification { summary string }
29300class Routed {
29301  branch string
29302  detail string
29303}
29304
29305coerce classifyMessage(title string) -> MessageClassification {
29306  prompt "Classify"
29307}
29308
29309rule classify
29310  when WorkItem as item
29311=> {
29312  coerce classifyMessage(item.title) as classification
29313
29314  after classification completes {
29315    case classification {
29316      Completed as result => {
29317        record Routed { branch "completed" detail result.summary }
29318      }
29319      Failed as failure => {
29320        record Routed { branch "failed" detail failure.reason }
29321      }
29322      TimedOut as timeout => {
29323        record Routed { branch "timed_out" detail timeout.summary }
29324      }
29325      Cancelled as cancel => {
29326        record Routed { branch "cancelled" detail cancel.summary }
29327      }
29328    }
29329  }
29330}
29331"#;
29332        let compiled = compile_program(source);
29333        assert_eq!(compiled.diagnostics, Vec::new());
29334        assert!(compiled.ir.is_some());
29335    }
29336
29337    #[test]
29338    fn accepts_after_times_out_branch_and_types_payload_alias() {
29339        let source = r#"
29340workflow TimedOutBranch
29341
29342class WorkItem {
29343  title string
29344}
29345
29346class MessageClassification {
29347  summary string
29348}
29349
29350class Routed {
29351  branch string
29352  detail string
29353}
29354
29355coerce classifyMessage(title string) -> MessageClassification {
29356  prompt "Classify"
29357}
29358
29359rule classify
29360  when WorkItem as item
29361=> {
29362  coerce classifyMessage(item.title) as classification
29363
29364  after classification times out as t {
29365    record Routed {
29366      branch "timed_out"
29367      detail t.summary
29368    }
29369  }
29370}
29371"#;
29372        let compiled = compile_program(source);
29373        assert_eq!(compiled.diagnostics, Vec::new());
29374        assert!(compiled.ir.is_some());
29375    }
29376
29377    #[test]
29378    fn accepts_after_cancelled_branch_and_types_payload_alias() {
29379        let source = r#"
29380workflow CancelledBranch
29381
29382class WorkItem {
29383  title string
29384}
29385
29386class MessageClassification {
29387  summary string
29388}
29389
29390class Routed {
29391  branch string
29392  detail string
29393}
29394
29395coerce classifyMessage(title string) -> MessageClassification {
29396  prompt "Classify"
29397}
29398
29399rule classify
29400  when WorkItem as item
29401=> {
29402  coerce classifyMessage(item.title) as classification
29403
29404  after classification cancelled as c {
29405    record Routed {
29406      branch "cancelled"
29407      detail c.summary
29408    }
29409  }
29410}
29411"#;
29412        let compiled = compile_program(source);
29413        assert_eq!(compiled.diagnostics, Vec::new());
29414        assert!(compiled.ir.is_some());
29415    }
29416
29417    #[test]
29418    fn rejects_invalid_after_predicate_during_compilation() {
29419        let source = r#"
29420workflow BadPredicate
29421
29422class WorkItem {
29423  title string
29424}
29425
29426class MessageClassification {
29427  summary string
29428}
29429
29430class Routed {
29431  branch string
29432}
29433
29434coerce classifyMessage(title string) -> MessageClassification {
29435  prompt "Classify"
29436}
29437
29438rule classify
29439  when WorkItem as item
29440=> {
29441  coerce classifyMessage(item.title) as classification
29442
29443  after classification explodes {
29444    record Routed {
29445      branch "boom"
29446    }
29447  }
29448}
29449"#;
29450        let compiled = compile_program(source);
29451        assert!(compiled.diagnostics.iter().any(|d| d
29452            .message
29453            .contains("unsupported `after` predicate `explodes`")));
29454    }
29455
29456    #[test]
29457    fn lowers_terminal_output_case_branches_to_typed_ir() {
29458        let source = include_str!("../../../examples/terminal-output-union.whip");
29459        let compiled = compile_program(source);
29460        assert_eq!(compiled.diagnostics, Vec::new());
29461        let ir = compiled.ir.expect("expected lowered IR");
29462        let rule = ir
29463            .rules
29464            .iter()
29465            .find(|rule| rule.name == "classify_work")
29466            .expect("rule");
29467
29468        let terminal_output = rule
29469            .metadata
29470            .terminal_outputs
29471            .iter()
29472            .find(|output| output.binding == "classification")
29473            .expect("terminal output");
29474        assert_eq!(terminal_output.alternatives.len(), 4);
29475        assert_eq!(
29476            terminal_output.alternatives[0].payload_type,
29477            IrType::Ref("Classification".to_owned())
29478        );
29479        assert_eq!(
29480            rule.metadata
29481                .terminal_branches
29482                .iter()
29483                .map(|branch| {
29484                    (
29485                        branch.tag.as_deref().unwrap_or("_"),
29486                        branch.binding.as_deref().unwrap_or("-"),
29487                    )
29488                })
29489                .collect::<Vec<_>>(),
29490            vec![
29491                ("Completed", "result"),
29492                ("Failed", "failure"),
29493                ("TimedOut", "timeout"),
29494                ("Cancelled", "cancel"),
29495            ]
29496        );
29497    }
29498
29499    #[test]
29500    fn rejects_terminal_payload_fields_outside_refined_tag_schema() {
29501        let source = r#"
29502workflow BadTerminalPayload
29503
29504class WorkItem {
29505  title string
29506}
29507
29508class Classification {
29509  summary string
29510}
29511
29512class TerminalRoute {
29513  detail string
29514}
29515
29516coerce classify(title string) -> Classification {
29517  prompt "Classify"
29518}
29519
29520rule classify_work
29521  when WorkItem as item
29522=> {
29523  coerce classify(item.title) as classification
29524
29525  after classification completes {
29526    case classification {
29527      Completed as result => {
29528        record TerminalRoute {
29529          detail result.reason
29530        }
29531      }
29532      Failed as failure => {
29533        record TerminalRoute {
29534          detail failure.reason
29535        }
29536      }
29537      TimedOut as timeout => {
29538        record TerminalRoute {
29539          detail timeout.summary
29540        }
29541      }
29542      Cancelled as cancel => {
29543        record TerminalRoute {
29544          detail cancel.summary
29545        }
29546      }
29547    }
29548  }
29549}
29550"#;
29551        let compiled = compile_program(source);
29552        assert!(compiled.ir.is_none());
29553        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29554            .message
29555            .contains("invalid field path `result.reason`")));
29556    }
29557
29558    #[test]
29559    fn rejects_invalid_terminal_output_case_branches() {
29560        let source = r#"
29561workflow BadTerminalCaseGuess
29562
29563class WorkItem {
29564  title string
29565}
29566
29567class MessageClassification {
29568  summary string
29569}
29570
29571coerce classifyMessage(title string) -> MessageClassification {
29572  prompt "Classify"
29573}
29574
29575rule classify
29576  when WorkItem as item
29577=> {
29578  coerce classifyMessage(item.title) as classification
29579
29580  after classification completes {
29581    case classification {
29582      Success as result => {
29583      }
29584      Completed as result => {
29585      }
29586    }
29587  }
29588}
29589"#;
29590        let compiled = compile_program(source);
29591        assert!(compiled.ir.is_none());
29592        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29593            .message
29594            .contains("terminal-output case pattern cannot be `Success`")));
29595        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29596            .message
29597            .contains("non-exhaustive terminal-output case; missing Failed, TimedOut, Cancelled")));
29598    }
29599
29600    /// Source for terminal-output case tests: a coerce whose `Completed` payload
29601    /// is `MessageClassification`, matched in an `after ... completes` case. The
29602    /// `{cases}` placeholder is filled per test.
29603    fn terminal_case_program(cases: &str) -> String {
29604        format!(
29605            r#"
29606workflow TerminalCaseMatrix
29607
29608class WorkItem {{
29609  title string
29610}}
29611
29612class MessageClassification {{
29613  summary string
29614}}
29615
29616class Routed {{
29617  branch string
29618}}
29619
29620coerce classifyMessage(title string) -> MessageClassification {{
29621  prompt "Classify"
29622}}
29623
29624rule classify
29625  when WorkItem as item
29626=> {{
29627  coerce classifyMessage(item.title) as classification
29628
29629  after classification completes {{
29630    case classification {{
29631{cases}
29632    }}
29633  }}
29634}}
29635"#
29636        )
29637    }
29638
29639    #[test]
29640    fn accepts_guarded_terminal_case_branch_referencing_refined_payload() {
29641        // Regression: a `where` guard on a tagged terminal branch must be able to
29642        // read the tag-refined payload binding (`result.summary`). It was wrongly
29643        // rejected as an unknown root because `validate_case_blocks` could not
29644        // bind the terminal payload into the guard scope.
29645        let source = terminal_case_program(
29646            "      Completed as result where result.summary == \"ok\" => { record Routed { branch \"ok\" } }\n      _ => { record Routed { branch \"other\" } }",
29647        );
29648        let compiled = compile_program(&source);
29649        assert_eq!(
29650            compiled.diagnostics,
29651            Vec::new(),
29652            "{:?}",
29653            compiled.diagnostics
29654        );
29655        assert!(compiled.ir.is_some());
29656    }
29657
29658    #[test]
29659    fn rejects_terminal_case_guard_referencing_unknown_payload_field() {
29660        let source = terminal_case_program(
29661            "      Completed as result where result.nonexistent == \"ok\" => { record Routed { branch \"ok\" } }\n      _ => { record Routed { branch \"other\" } }",
29662        );
29663        let compiled = compile_program(&source);
29664        assert!(compiled.ir.is_none());
29665        assert!(
29666            compiled.diagnostics.iter().any(|d| d
29667                .message
29668                .contains("schema `MessageClassification` has no field `nonexistent`")),
29669            "{:?}",
29670            compiled.diagnostics
29671        );
29672    }
29673
29674    #[test]
29675    fn rejects_non_boolean_terminal_case_guard() {
29676        let source = terminal_case_program(
29677            "      Completed as result where result.summary => { record Routed { branch \"ok\" } }\n      _ => { record Routed { branch \"other\" } }",
29678        );
29679        let compiled = compile_program(&source);
29680        assert!(compiled.ir.is_none());
29681        assert!(
29682            compiled
29683                .diagnostics
29684                .iter()
29685                .any(|d| d.message.contains("non-boolean case guard expression")),
29686            "{:?}",
29687            compiled.diagnostics
29688        );
29689    }
29690
29691    #[test]
29692    fn rejects_duplicate_terminal_output_case_tag() {
29693        let source = terminal_case_program(
29694            "      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\" } }",
29695        );
29696        let compiled = compile_program(&source);
29697        assert!(compiled.ir.is_none());
29698        assert!(
29699            compiled.diagnostics.iter().any(|d| d
29700                .message
29701                .contains("duplicate unguarded terminal-output case pattern `Completed`")),
29702            "{:?}",
29703            compiled.diagnostics
29704        );
29705    }
29706
29707    #[test]
29708    fn rejects_terminal_output_case_branch_without_payload_binding() {
29709        let source = terminal_case_program(
29710            "      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\" } }",
29711        );
29712        let compiled = compile_program(&source);
29713        assert!(compiled.ir.is_none());
29714        assert!(
29715            compiled.diagnostics.iter().any(|d| d
29716                .message
29717                .contains("malformed terminal-output case pattern `Completed`")),
29718            "{:?}",
29719            compiled.diagnostics
29720        );
29721    }
29722
29723    #[test]
29724    fn rejects_invalid_case_branch_patterns() {
29725        let source = r#"
29726workflow BadCaseGuess
29727
29728enum ReviewStatus {
29729  Accept
29730  Revise
29731}
29732
29733class Review {
29734  status ReviewStatus
29735  assignee string
29736}
29737
29738rule route
29739  when Review as review
29740=> {
29741  case review.status {
29742    Missing => {
29743    }
29744  }
29745
29746  case review.assignee {
29747    Some owner => {
29748    }
29749  }
29750}
29751"#;
29752        let compiled = compile_program(source);
29753        assert!(compiled.ir.is_none());
29754        let missing = compiled
29755            .diagnostics
29756            .iter()
29757            .find(|diagnostic| {
29758                diagnostic
29759                    .message
29760                    .contains("enum `ReviewStatus` has no variant `Missing`")
29761            })
29762            .expect("missing variant diagnostic");
29763        assert!(source[missing.span.start..missing.span.end].contains("Mis"));
29764        let some = compiled
29765            .diagnostics
29766            .iter()
29767            .find(|diagnostic| {
29768                diagnostic
29769                    .message
29770                    .contains("uses `Some` for a non-optional case")
29771            })
29772            .expect("some diagnostic");
29773        assert!(source[some.span.start..some.span.end].contains("Some"));
29774    }
29775
29776    #[test]
29777    fn diagnoses_non_exhaustive_and_duplicate_case_branches() {
29778        let source = r#"
29779workflow CaseCoverageGuess
29780
29781enum ReviewStatus {
29782  Accept
29783  Revise
29784  Blocked
29785}
29786
29787class Review {
29788  status ReviewStatus
29789  provider "codex" | "claude"
29790  owner string?
29791}
29792
29793rule route
29794  when Review as review
29795=> {
29796  case review.status {
29797    Accept => {
29798    }
29799    Accept => {
29800    }
29801    Revise => {
29802    }
29803  }
29804
29805  case review.provider {
29806    "codex" => {
29807    }
29808  }
29809
29810  case review.owner {
29811    Some owner => {
29812    }
29813  }
29814}
29815"#;
29816        let compiled = compile_program(source);
29817        assert!(compiled.ir.is_none());
29818        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29819            .message
29820            .contains("duplicate unguarded case pattern `Accept`")));
29821        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29822            .message
29823            .contains("non-exhaustive case; missing Blocked")));
29824        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29825            .message
29826            .contains("non-exhaustive case; missing claude")));
29827        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
29828            .message
29829            .contains("non-exhaustive case; missing None")));
29830    }
29831
29832    #[test]
29833    fn accepts_fallback_and_guarded_duplicate_case_branches() {
29834        let source = r#"
29835workflow CaseFallbackGuess
29836
29837enum ReviewStatus {
29838  Accept
29839  Revise
29840  Blocked
29841}
29842
29843class Review {
29844  status ReviewStatus
29845  owner string?
29846}
29847
29848rule route
29849  when Review as review
29850=> {
29851  case review.status {
29852    Accept where review.owner != null => {
29853    }
29854    Accept where review.owner == null => {
29855    }
29856    _ => {
29857    }
29858  }
29859}
29860"#;
29861        let compiled = compile_program(source);
29862        assert_eq!(compiled.diagnostics, Vec::new());
29863        assert!(compiled.ir.is_some());
29864    }
29865
29866    #[test]
29867    fn rejects_unreachable_case_branch_after_wildcard() {
29868        // A branch placed after an unguarded `_` can never match (case-family.maude
29869        // inv c, redundant-postwild).
29870        let source = r#"
29871workflow CaseUnreachableGuess
29872
29873enum ReviewStatus {
29874  Accept
29875  Revise
29876  Blocked
29877}
29878
29879class Review {
29880  status ReviewStatus
29881}
29882
29883rule route
29884  when Review as review
29885=> {
29886  case review.status {
29887    Accept => {
29888    }
29889    _ => {
29890    }
29891    Revise => {
29892    }
29893  }
29894}
29895"#;
29896        let compiled = compile_program(source);
29897        assert!(compiled.ir.is_none());
29898        assert!(
29899            compiled.diagnostics.iter().any(|diagnostic| diagnostic
29900                .message
29901                .contains("unreachable case branch after the `_` wildcard")),
29902            "expected unreachable-after-wildcard diagnostic: {:?}",
29903            compiled.diagnostics
29904        );
29905    }
29906
29907    #[test]
29908    fn family_b_presence_condition_validates_discriminant() {
29909        let program = |fields: &str| {
29910            format!(
29911                r#"
29912workflow B
29913input e Event
29914output result Done
29915class Done {{ ok bool }}
29916class Event {{
29917{fields}
29918}}
29919rule r
29920  when Event as e
29921=> {{
29922  complete result {{ ok true }}
29923}}
29924"#
29925            )
29926        };
29927        // Valid: a literal-union discriminant with an in-range `when` literal.
29928        let ok = compile_program(&program(
29929            "  kind \"deploy\" | \"rollback\"\n  region string when kind is \"deploy\"",
29930        ));
29931        assert_eq!(ok.diagnostics, Vec::new());
29932        assert!(ok.ir.is_some());
29933        // Unknown discriminant.
29934        let bad1 = compile_program(&program(
29935            "  kind \"deploy\" | \"rollback\"\n  region string when missing is \"deploy\"",
29936        ));
29937        assert!(bad1
29938            .diagnostics
29939            .iter()
29940            .any(|d| d.message.contains("unknown discriminant `missing`")));
29941        // Literal not in the discriminant union.
29942        let bad2 = compile_program(&program(
29943            "  kind \"deploy\" | \"rollback\"\n  region string when kind is \"ship\"",
29944        ));
29945        assert!(bad2
29946            .diagnostics
29947            .iter()
29948            .any(|d| d.message.contains("not a value of `kind`")));
29949        // Discriminant is not a string-literal union.
29950        let bad3 = compile_program(&program(
29951            "  kind string\n  region string when kind is \"deploy\"",
29952        ));
29953        assert!(bad3
29954            .diagnostics
29955            .iter()
29956            .any(|d| d.message.contains("not a string-literal discriminant")));
29957    }
29958
29959    #[test]
29960    fn case_arm_effect_records_its_selector() {
29961        // An effect inside a `case <scrutinee> { <pattern> => … }` arm records the
29962        // selector `(scrutinee, pattern)` so the IFC checker can apply
29963        // NMIF-on-the-selector to a crossing (DR §7.4).
29964        let source = r#"
29965workflow S
29966
29967input item WorkItem
29968output result R
29969class WorkItem { kind "a" | "b" }
29970class R { ok bool }
29971class V { ok bool }
29972
29973coerce f(t string) -> V { prompt "x" }
29974
29975rule r
29976  when WorkItem as item
29977=> {
29978  case item.kind {
29979    "a" => {
29980      coerce f("hi") as v
29981      after v succeeds {
29982        complete result { ok v.ok }
29983      }
29984    }
29985    "b" => {
29986      complete result { ok false }
29987    }
29988  }
29989}
29990"#;
29991        let ir = compile_program(source).ir.expect("compiles");
29992        let rule = ir.rules.iter().find(|r| r.name == "r").expect("rule r");
29993        let coerce = rule
29994            .metadata
29995            .effects
29996            .iter()
29997            .find(|e| e.binding.as_deref() == Some("v"))
29998            .expect("coerce effect v");
29999        let (scrutinee, pattern) = coerce
30000            .selected_by
30001            .as_ref()
30002            .expect("coerce in a case arm records its selector");
30003        assert_eq!(scrutinee, "item.kind");
30004        assert_eq!(pattern, "\"a\"");
30005        // An effect outside any case (the `complete` is in an arm, but there are no
30006        // top-level effects here) — sanity: a fresh top-level coerce has no selector.
30007        // (Covered by every other example whose effects are top-level: selected_by None.)
30008    }
30009
30010    #[test]
30011    fn family_b_read_narrowing_restricts_conditioned_reads() {
30012        let program = |body: &str| {
30013            format!(
30014                r#"
30015workflow B
30016input e Event
30017output result Done
30018class Done {{ region string }}
30019class Event {{
30020  kind "deploy" | "rollback"
30021  region string when kind is "deploy"
30022}}
30023rule r
30024  when Event as e
30025=> {{
30026{body}
30027}}
30028"#
30029            )
30030        };
30031        // Outside any case arm: a conditioned read is rejected.
30032        let outside = compile_program(&program("  complete result { region e.region }"));
30033        assert!(
30034            outside
30035                .diagnostics
30036                .iter()
30037                .any(|d| d.message.contains("conditional field `e.region`")),
30038            "{:?}",
30039            outside.diagnostics
30040        );
30041        // Inside the matching `deploy` arm: allowed.
30042        let matching = compile_program(&program(
30043            "  case e.kind {\n    \"deploy\" => { complete result { region e.region } }\n    \"rollback\" => { complete result { region \"none\" } }\n  }",
30044        ));
30045        assert_eq!(matching.diagnostics, Vec::new());
30046        assert!(matching.ir.is_some());
30047        // Inside the wrong (`rollback`) arm: rejected (region is a deploy-only field).
30048        let wrong = compile_program(&program(
30049            "  case e.kind {\n    \"deploy\" => { complete result { region \"x\" } }\n    \"rollback\" => { complete result { region e.region } }\n  }",
30050        ));
30051        assert!(
30052            wrong
30053                .diagnostics
30054                .iter()
30055                .any(|d| d.message.contains("conditional field `e.region`")),
30056            "{:?}",
30057            wrong.diagnostics
30058        );
30059    }
30060
30061    #[test]
30062    fn rejects_conflicting_reused_effect_binding() {
30063        // Reusing an effect binding for two effects with DIFFERENT result types makes
30064        // `after <binding> …` ambiguous (§5.5). Same-type reuse is harmless and allowed.
30065        let source = r#"
30066workflow D
30067
30068output result R
30069class R { x string }
30070class WorkItem { title string }
30071class A { a string }
30072class B { b string }
30073
30074coerce fa(t string) -> A { prompt "x" }
30075coerce fb(t string) -> B { prompt "x" }
30076
30077rule r
30078  when WorkItem as item
30079=> {
30080  coerce fa(item.title) as v
30081  coerce fb(item.title) as v
30082  complete result { x "done" }
30083}
30084"#;
30085        let compiled = compile_program(source);
30086        assert!(
30087            compiled
30088                .diagnostics
30089                .iter()
30090                .any(|d| d.message.contains("reuses effect binding `v`")),
30091            "{:?}",
30092            compiled.diagnostics
30093        );
30094    }
30095
30096    #[test]
30097    fn rejects_transitive_workflow_invocation_cycle() {
30098        // A invokes B invokes A — a runtime invoke cycle with no compile-time
30099        // convergence proof (RESOLVED 2026-07-01). Rejected before root selection.
30100        let source = r#"
30101workflow A {
30102  input task TA
30103  output result RA
30104  class TA { id string }
30105  class RA { id string }
30106  rule go
30107    when TA as t
30108  => {
30109    invoke B { task { id t.id } } as b
30110    after b succeeds as r { complete result { id r.id } }
30111  }
30112}
30113
30114workflow B {
30115  input task TB
30116  output result RB
30117  class TB { id string }
30118  class RB { id string }
30119  rule go
30120    when TB as t
30121  => {
30122    invoke A { task { id t.id } } as a
30123    after a succeeds as r { complete result { id r.id } }
30124  }
30125}
30126"#;
30127        let compiled = compile_program_with_root(source, Some("A"));
30128        assert!(compiled.ir.is_none());
30129        assert!(
30130            compiled.diagnostics.iter().any(|d| d
30131                .message
30132                .contains("graph.unbounded_workflow_invocation_recursion")
30133                && d.message.contains("A -> B -> A")),
30134            "{:?}",
30135            compiled.diagnostics
30136        );
30137    }
30138
30139    #[test]
30140    fn accepts_acyclic_workflow_invocation_chain() {
30141        // A invokes B invokes C — a finite chain, never flagged (bite: the cycle
30142        // detector must not over-reject non-recursive nesting).
30143        let source = r#"
30144workflow A {
30145  input task TA
30146  output result RA
30147  class TA { id string }
30148  class RA { id string }
30149  rule go
30150    when TA as t
30151  => {
30152    invoke B { task { id t.id } } as b
30153    after b succeeds as r { complete result { id r.id } }
30154  }
30155}
30156
30157workflow B {
30158  input task TB
30159  output result RB
30160  class TB { id string }
30161  class RB { id string }
30162  rule go
30163    when TB as t
30164  => {
30165    invoke C { task { id t.id } } as c
30166    after c succeeds as r { complete result { id r.id } }
30167  }
30168}
30169
30170workflow C {
30171  input task TC
30172  output result RC
30173  class TC { id string }
30174  class RC { id string }
30175  rule go
30176    when TC as t
30177  => {
30178    complete result { id t.id }
30179  }
30180}
30181"#;
30182        let compiled = compile_program_with_root(source, Some("A"));
30183        assert!(
30184            !compiled.diagnostics.iter().any(|d| d
30185                .message
30186                .contains("graph.unbounded_workflow_invocation_recursion")),
30187            "acyclic chain wrongly flagged: {:?}",
30188            compiled.diagnostics
30189        );
30190    }
30191
30192    #[test]
30193    fn rejects_invoking_private_sibling_workflow() {
30194        let source = r#"
30195class Job { id string }
30196class Report { id string }
30197
30198@private
30199workflow Child {
30200  input task Job
30201  output result Report
30202  rule work
30203    when Job as t
30204  => {
30205    complete result { id t.id }
30206  }
30207}
30208
30209workflow Parent {
30210  input task Job
30211  output result Report
30212  rule go
30213    when Job as t
30214  => {
30215    invoke Child { task t } as child
30216    after child succeeds as r { complete result { id r.id } }
30217  }
30218}
30219"#;
30220        let compiled = compile_program_with_root(source, Some("Parent"));
30221        assert!(compiled.ir.is_none());
30222        assert!(
30223            compiled
30224                .diagnostics
30225                .iter()
30226                .any(|d| d.message.contains("private workflow `Child`")),
30227            "{:?}",
30228            compiled.diagnostics
30229        );
30230    }
30231
30232    #[test]
30233    fn accepts_private_workflow_as_selected_root() {
30234        let source = r#"
30235class Job { id string }
30236class Report { id string }
30237
30238@private
30239workflow Child {
30240  input task Job
30241  output result Report
30242  rule work
30243    when Job as t
30244  => {
30245    complete result { id t.id }
30246  }
30247}
30248"#;
30249        let compiled = compile_program_with_root(source, Some("Child"));
30250        let ir = compiled
30251            .ir
30252            .unwrap_or_else(|| panic!("private root compiles: {:?}", compiled.diagnostics));
30253        assert!(ir.source_tags.iter().any(|tag| {
30254            tag.name == "private" && tag.target_kind == "workflow" && tag.target == "Child"
30255        }));
30256    }
30257
30258    #[test]
30259    fn typed_invoke_result_checks_field_access_against_child_output() {
30260        // The child's output is a shared top-level class `Report`. The parent's
30261        // `after child succeeds as r` binds r to Report, so `r.missing` (not a
30262        // field of Report) is rejected — invoke results are no longer opaque.
30263        let source = r#"
30264class Report { id string }
30265class Job { id string }
30266
30267workflow Parent {
30268  input task Job
30269  output result Report
30270  rule go
30271    when Job as t
30272  => {
30273    invoke Child { task { id t.id } } as child
30274    after child succeeds as r {
30275      complete result { id r.missing }
30276    }
30277  }
30278}
30279
30280workflow Child {
30281  input task Job
30282  output result Report
30283  rule work
30284    when Job as t
30285  => {
30286    complete result { id t.id }
30287  }
30288}
30289"#;
30290        let compiled = compile_program_with_root(source, Some("Parent"));
30291        assert!(
30292            compiled.ir.is_none(),
30293            "unknown field on invoke result must not compile"
30294        );
30295        assert!(
30296            compiled
30297                .diagnostics
30298                .iter()
30299                .any(|d| d.message.contains("r.missing") || d.message.contains("missing")),
30300            "typed invoke result did not reject r.missing: {:?}",
30301            compiled.diagnostics
30302        );
30303    }
30304
30305    #[test]
30306    fn typed_invoke_result_accepts_a_valid_child_output_field() {
30307        // Bite: a field that IS on the child's output contract resolves and the
30308        // program compiles.
30309        let source = r#"
30310class Report { id string }
30311class Job { id string }
30312
30313workflow Parent {
30314  input task Job
30315  output result Report
30316  rule go
30317    when Job as t
30318  => {
30319    invoke Child { task { id t.id } } as child
30320    after child succeeds as r {
30321      complete result { id r.id }
30322    }
30323  }
30324}
30325
30326workflow Child {
30327  input task Job
30328  output result Report
30329  rule work
30330    when Job as t
30331  => {
30332    complete result { id t.id }
30333  }
30334}
30335"#;
30336        let compiled = compile_program_with_root(source, Some("Parent"));
30337        assert!(
30338            compiled.diagnostics.is_empty(),
30339            "valid invoke-result field access wrongly rejected: {:?}",
30340            compiled.diagnostics
30341        );
30342        assert!(compiled.ir.is_some());
30343    }
30344
30345    #[test]
30346    fn typed_invoke_failure_checks_field_access_against_child_failure() {
30347        // The child's failure is a shared top-level class `ChildError`. The
30348        // parent's `after child fails as f` binds f to ChildError, so
30349        // `f.nonexistent` (not a field of ChildError) is rejected — the failure
30350        // binding is the child's declared failure shape, not the opaque base.
30351        let source = r#"
30352class Report { id string }
30353class Job { id string }
30354class ChildError { reason string }
30355class ParentError { detail string }
30356
30357workflow Parent {
30358  input task Job
30359  output result Report
30360  failure err ParentError
30361  rule go
30362    when Job as t
30363  => {
30364    invoke Child { task { id t.id } } as child
30365    after child succeeds as r {
30366      complete result { id r.id }
30367    }
30368    after child fails as f {
30369      fail err { detail f.nonexistent }
30370    }
30371  }
30372}
30373
30374workflow Child {
30375  input task Job
30376  output result Report
30377  failure err ChildError
30378  rule work
30379    when Job as t
30380  => {
30381    fail err { reason t.id }
30382  }
30383}
30384"#;
30385        let compiled = compile_program_with_root(source, Some("Parent"));
30386        assert!(
30387            compiled.ir.is_none(),
30388            "unknown field on invoke failure must not compile"
30389        );
30390        assert!(
30391            compiled
30392                .diagnostics
30393                .iter()
30394                .any(|d| d.message.contains("f.nonexistent") || d.message.contains("nonexistent")),
30395            "typed invoke failure did not reject f.nonexistent: {:?}",
30396            compiled.diagnostics
30397        );
30398    }
30399
30400    #[test]
30401    fn typed_invoke_failure_accepts_a_valid_child_failure_field() {
30402        // Bite: a field that IS on the child's shared failure contract resolves
30403        // and the program compiles — the `fails` binding is no longer the opaque
30404        // `TerminalFailed` base when the child declares a shared failure class.
30405        let source = r#"
30406class Report { id string }
30407class Job { id string }
30408class ChildError { reason string }
30409class ParentError { detail string }
30410
30411workflow Parent {
30412  input task Job
30413  output result Report
30414  failure err ParentError
30415  rule go
30416    when Job as t
30417  => {
30418    invoke Child { task { id t.id } } as child
30419    after child succeeds as r {
30420      complete result { id r.id }
30421    }
30422    after child fails as f {
30423      fail err { detail f.reason }
30424    }
30425  }
30426}
30427
30428workflow Child {
30429  input task Job
30430  output result Report
30431  failure err ChildError
30432  rule work
30433    when Job as t
30434  => {
30435    fail err { reason t.id }
30436  }
30437}
30438"#;
30439        let compiled = compile_program_with_root(source, Some("Parent"));
30440        assert!(
30441            compiled.diagnostics.is_empty(),
30442            "valid invoke-failure field access wrongly rejected: {:?}",
30443            compiled.diagnostics
30444        );
30445        assert!(compiled.ir.is_some());
30446    }
30447
30448    #[test]
30449    fn whole_program_validation_catches_a_broken_sibling_under_any_root() {
30450        // Two workflows; `Good` is well-formed, `Broken` references an undeclared
30451        // schema in its own scope. Compiling with `--root Good` must still catch
30452        // `Broken`'s error — the pre-pass validates EVERY workflow, not just the
30453        // selected root (RESOLVED 2026-07-01). Before this, a broken sibling was
30454        // silently discarded by root selection and never validated.
30455        let source = r#"
30456workflow Good {
30457  input task TG
30458  output result RG
30459  class TG { id string }
30460  class RG { id string }
30461  rule go
30462    when TG as t
30463  => {
30464    complete result { id t.id }
30465  }
30466}
30467
30468workflow Broken {
30469  input task TB
30470  output result RB
30471  class TB { id string }
30472  class RB { id string }
30473  rule go
30474    when Nonexistent as t
30475  => {
30476    complete result { id t.id }
30477  }
30478}
30479"#;
30480        let compiled = compile_program_with_root(source, Some("Good"));
30481        assert!(
30482            compiled.ir.is_none(),
30483            "a program with a broken sibling must not compile"
30484        );
30485        assert!(
30486            compiled
30487                .diagnostics
30488                .iter()
30489                .any(|d| d.message.contains("Nonexistent")),
30490            "the broken sibling's error was not surfaced: {:?}",
30491            compiled.diagnostics
30492        );
30493    }
30494
30495    #[test]
30496    fn cross_workflow_reference_to_sibling_local_is_annotated() {
30497        // `Consumer` references class `Secret`, which is declared *inside* sibling
30498        // workflow `Owner` (private to it). The reference is an error (isolation),
30499        // and it carries a related note pointing at Owner's declaration so the
30500        // author knows the name exists but is out of scope.
30501        let source = r#"
30502workflow Owner {
30503  input task TO
30504  output result RO
30505  class TO { id string }
30506  class RO { id string }
30507  class Secret { id string }
30508  rule go
30509    when TO as t
30510  => {
30511    complete result { id t.id }
30512  }
30513}
30514
30515workflow Consumer {
30516  input task TC
30517  output result RC
30518  class TC { id string }
30519  class RC { id string }
30520  rule go
30521    when Secret as s
30522  => {
30523    complete result { id s.id }
30524  }
30525}
30526"#;
30527        let compiled = compile_program_with_root(source, Some("Consumer"));
30528        assert!(
30529            compiled.ir.is_none(),
30530            "sibling-local reference must not compile"
30531        );
30532        let leak = compiled
30533            .diagnostics
30534            .iter()
30535            .find(|d| d.message.contains("`Secret`"))
30536            .expect("an unknown-name diagnostic for Secret");
30537        assert!(
30538            leak.related
30539                .iter()
30540                .any(|r| r.message.contains("workflow `Owner`")
30541                    && r.message.contains("private to that workflow")),
30542            "missing sibling-local leak note: {:?}",
30543            leak.related
30544        );
30545    }
30546
30547    #[test]
30548    fn shared_top_level_name_is_not_annotated_as_a_leak() {
30549        // Bite: a class declared at the TOP LEVEL is global — both workflows may
30550        // reference it, and no leak note is attached. (Also confirms the program
30551        // compiles: the shared global resolves in each workflow.)
30552        let source = r#"
30553class Shared { id string }
30554
30555workflow Alpha {
30556  input task Shared
30557  output result RA
30558  class RA { id string }
30559  rule go
30560    when Shared as s
30561  => {
30562    complete result { id s.id }
30563  }
30564}
30565
30566workflow Beta {
30567  input task Shared
30568  output result RB
30569  class RB { id string }
30570  rule go
30571    when Shared as s
30572  => {
30573    complete result { id s.id }
30574  }
30575}
30576"#;
30577        let compiled = compile_program_with_root(source, Some("Alpha"));
30578        assert!(
30579            compiled.diagnostics.is_empty(),
30580            "shared top-level global wrongly rejected: {:?}",
30581            compiled.diagnostics
30582        );
30583        assert!(compiled.ir.is_some());
30584    }
30585
30586    #[test]
30587    fn whole_program_validation_accepts_all_well_formed_workflows() {
30588        // Bite: when every workflow is well-formed, the pre-pass adds no spurious
30589        // diagnostics and the selected root still compiles to IR.
30590        let source = r#"
30591workflow Alpha {
30592  input task TA
30593  output result RA
30594  class TA { id string }
30595  class RA { id string }
30596  rule go
30597    when TA as t
30598  => {
30599    complete result { id t.id }
30600  }
30601}
30602
30603workflow Beta {
30604  input task TB
30605  output result RB
30606  class TB { id string }
30607  class RB { id string }
30608  rule go
30609    when TB as t
30610  => {
30611    complete result { id t.id }
30612  }
30613}
30614"#;
30615        let compiled = compile_program_with_root(source, Some("Alpha"));
30616        assert!(
30617            compiled.diagnostics.is_empty(),
30618            "well-formed multi-workflow program emitted diagnostics: {:?}",
30619            compiled.diagnostics
30620        );
30621        assert!(compiled.ir.is_some(), "selected root failed to compile");
30622    }
30623
30624    #[test]
30625    fn compact_workflow_signature_desugars_to_keyword_contracts() {
30626        // `Name(in: T) -> Out ! Fail` must produce exactly the contracts the
30627        // keyword form produces, with output named `result` and failure `error`.
30628        let compact = r#"
30629workflow Triage(ticket: Ticket) -> Resolution ! TriageFailed
30630
30631class Ticket { id string }
30632class Resolution { id string }
30633class TriageFailed { reason string }
30634
30635rule go
30636  when Ticket as t
30637=> {
30638  complete result { id t.id }
30639}
30640"#;
30641        let keyword = r#"
30642workflow Triage
30643
30644input ticket Ticket
30645output result Resolution
30646failure error TriageFailed
30647
30648class Ticket { id string }
30649class Resolution { id string }
30650class TriageFailed { reason string }
30651
30652rule go
30653  when Ticket as t
30654=> {
30655  complete result { id t.id }
30656}
30657"#;
30658        let compact_ir = compile_program_with_root(compact, None);
30659        let keyword_ir = compile_program_with_root(keyword, None);
30660        assert!(
30661            compact_ir.diagnostics.is_empty(),
30662            "compact form did not compile: {:?}",
30663            compact_ir.diagnostics
30664        );
30665        assert!(
30666            keyword_ir.diagnostics.is_empty(),
30667            "keyword form did not compile: {:?}",
30668            keyword_ir.diagnostics
30669        );
30670        // Compare the semantic triple (kind, name, type) — spans naturally differ
30671        // between the two source layouts.
30672        let project = |ir: &IrProgram| {
30673            ir.workflow_contracts
30674                .iter()
30675                .map(|c| {
30676                    (
30677                        format!("{:?}", c.kind),
30678                        c.name.clone(),
30679                        format!("{:?}", c.ty),
30680                    )
30681                })
30682                .collect::<Vec<_>>()
30683        };
30684        assert_eq!(
30685            project(&compact_ir.ir.expect("compact ir")),
30686            project(&keyword_ir.ir.expect("keyword ir")),
30687            "compact signature did not desugar to the same contracts"
30688        );
30689    }
30690
30691    #[test]
30692    fn compact_signature_supports_multiple_inputs_and_optional_failure() {
30693        // Multiple comma-separated inputs; failure clause omitted.
30694        let source = r#"
30695workflow Merge(left: LeftIn, right: RightIn) -> Merged
30696
30697class LeftIn { id string }
30698class RightIn { id string }
30699class Merged { id string }
30700
30701rule go
30702  when {
30703    LeftIn as l
30704    RightIn as r
30705  }
30706=> {
30707  complete result { id l.id }
30708}
30709"#;
30710        let compiled = compile_program_with_root(source, None);
30711        assert!(
30712            compiled.diagnostics.is_empty(),
30713            "multi-input compact form did not compile: {:?}",
30714            compiled.diagnostics
30715        );
30716        let ir = compiled.ir.expect("ir");
30717        let inputs = ir
30718            .workflow_contracts
30719            .iter()
30720            .filter(|c| matches!(c.kind, IrWorkflowContractKind::Input))
30721            .count();
30722        let failures = ir
30723            .workflow_contracts
30724            .iter()
30725            .filter(|c| matches!(c.kind, IrWorkflowContractKind::Failure))
30726            .count();
30727        assert_eq!(inputs, 2, "expected two inputs");
30728        assert_eq!(
30729            failures, 0,
30730            "omitted failure clause must add no failure contract"
30731        );
30732    }
30733
30734    #[test]
30735    fn rejects_headerless_program_with_no_workflow() {
30736        // The implicit compatibility root is removed (RESOLVED 2026-07-01): a
30737        // source with no explicit `workflow` (only shared types/patterns) is a
30738        // library fragment, not a runnable program, and is rejected.
30739        let source = r#"
30740class SharedTicket {
30741  id string
30742}
30743
30744pattern TagReviewed<Input> {
30745  rule tag
30746    when Input as item
30747  => {
30748    record SharedTicket { id item.id }
30749  }
30750}
30751"#;
30752        let compiled = compile_program_with_root(source, None);
30753        assert!(compiled.ir.is_none());
30754        assert!(
30755            compiled
30756                .diagnostics
30757                .iter()
30758                .any(|d| d.message.contains("program declares no `workflow`")),
30759            "{:?}",
30760            compiled.diagnostics
30761        );
30762    }
30763
30764    #[test]
30765    fn accepts_single_workflow_header_program() {
30766        // Bite: the headerless reject must not fire on a program that declares a
30767        // workflow via the header form (the common single-workflow shape).
30768        let source = r#"
30769workflow OnlyOne
30770
30771input item Job
30772output result Done
30773
30774class Job { id string }
30775class Done { id string }
30776
30777rule go
30778  when Job as j
30779=> {
30780  complete result { id j.id }
30781}
30782"#;
30783        let compiled = compile_program_with_root(source, None);
30784        assert!(
30785            !compiled
30786                .diagnostics
30787                .iter()
30788                .any(|d| d.message.contains("program declares no `workflow`")),
30789            "header-form program wrongly rejected as headerless: {:?}",
30790            compiled.diagnostics
30791        );
30792    }
30793
30794    #[test]
30795    fn rejects_recording_observer_only_terminal_schema() {
30796        // §5.4: the terminal family is `origin = observer` — the kernel projects
30797        // it, user rules may only eliminate it. A rule that `record`s a terminal
30798        // schema forges an outcome the kernel never produced, so it is rejected.
30799        for schema in ["TerminalFailed", "TerminalTimedOut", "TerminalCancelled"] {
30800            let source = format!(
30801                r#"
30802workflow Forge
30803
30804input item Job
30805output result Done
30806
30807class Job {{ id string }}
30808class Done {{ id string }}
30809
30810rule sneak
30811  when Job as q
30812=> {{
30813  record {schema} {{ reason "x" summary "y" }}
30814  complete result {{ id q.id }}
30815}}
30816"#
30817            );
30818            let compiled = compile_program(&source);
30819            assert!(
30820                compiled
30821                    .diagnostics
30822                    .iter()
30823                    .any(|d| d.message.contains(&format!(
30824                        "cannot record kernel-owned terminal schema `{schema}`"
30825                    ))),
30826                "expected rejection for {schema}, got {:?}",
30827                compiled.diagnostics
30828            );
30829        }
30830    }
30831
30832    #[test]
30833    fn allows_recording_user_writable_builtin_schema() {
30834        // Regression guard for the fix above: `WorkItem` is a builtin schema ref
30835        // but user-writable (work-tracking state), so recording it must NOT be
30836        // rejected as observer-only.
30837        let source = r#"
30838workflow WriteWork
30839
30840input item Job
30841output result Done
30842
30843class Job { id string }
30844class Done { id string }
30845
30846rule track
30847  when Job as q
30848=> {
30849  record WorkItem { title "t" status "reviewed" }
30850  complete result { id q.id }
30851}
30852"#;
30853        let compiled = compile_program(source);
30854        assert!(
30855            !compiled.diagnostics.iter().any(|d| d
30856                .message
30857                .contains("cannot record kernel-owned terminal schema")),
30858            "WorkItem must remain user-writable, got {:?}",
30859            compiled.diagnostics
30860        );
30861    }
30862
30863    #[test]
30864    fn exhaustive_bool_case_compiles() {
30865        // `case` over a `bool` field is valid when both `true` and `false` are
30866        // covered (the finite two-value domain).
30867        let source = r#"
30868workflow BoolCaseOk
30869
30870output result Done
30871
30872class Done {
30873  note string
30874}
30875
30876class Flag {
30877  ready bool
30878}
30879
30880rule route
30881  when Flag as f
30882=> {
30883  case f.ready {
30884    true => {
30885      complete result {
30886        note "t"
30887      }
30888    }
30889    false => {
30890      complete result {
30891        note "f"
30892      }
30893    }
30894  }
30895}
30896"#;
30897        let compiled = compile_program(source);
30898        assert_eq!(
30899            compiled.diagnostics,
30900            Vec::new(),
30901            "{:?}",
30902            compiled.diagnostics
30903        );
30904        assert!(compiled.ir.is_some());
30905    }
30906
30907    #[test]
30908    fn bool_case_rejects_non_exhaustive_and_non_bool_patterns() {
30909        let source = r#"
30910workflow BoolCaseBad
30911
30912class Flag {
30913  ready bool
30914}
30915
30916rule route
30917  when Flag as f
30918=> {
30919  case f.ready {
30920    true => {
30921    }
30922  }
30923
30924  case f.ready {
30925    maybe => {
30926    }
30927    false => {
30928    }
30929  }
30930}
30931"#;
30932        let compiled = compile_program(source);
30933        assert!(compiled.ir.is_none());
30934        assert!(
30935            compiled
30936                .diagnostics
30937                .iter()
30938                .any(|d| d.message.contains("non-exhaustive case; missing false")),
30939            "expected non-exhaustive diagnostic: {:?}",
30940            compiled.diagnostics
30941        );
30942        assert!(
30943            compiled.diagnostics.iter().any(|d| d
30944                .message
30945                .contains("case pattern `maybe` that is not a `bool` value")),
30946            "expected non-bool pattern diagnostic: {:?}",
30947            compiled.diagnostics
30948        );
30949    }
30950
30951    #[test]
30952    fn exec_schema_result_resolves_typed_fields_for_case() {
30953        // `exec "..." -> Schema as v` registers its result type, so an
30954        // `after v succeeds as r` branch can `case` / field-access `r`'s fields —
30955        // the same after-binding type flow a named `coerce -> Schema` already
30956        // gets. Before the fix this produced "case scrutinee `r.kind` ... not a
30957        // typed path".
30958        let source = r#"
30959@service
30960workflow ExecTyped
30961
30962class Pick { kind "a" | "b" }
30963class R { choice string }
30964
30965output result R
30966
30967signal go.now {
30968  x string
30969}
30970
30971rule j
30972  when go.now as g
30973=> {
30974  exec "echo hi" -> Pick as v
30975
30976  after v succeeds as r {
30977    case r.kind {
30978      "a" => {
30979        complete result {
30980          choice "a"
30981        }
30982      }
30983      "b" => {
30984        complete result {
30985          choice "b"
30986        }
30987      }
30988    }
30989  }
30990}
30991"#;
30992        let compiled = compile_program(source);
30993        assert!(
30994            !compiled
30995                .diagnostics
30996                .iter()
30997                .any(|d| d.message.contains("not a typed path")),
30998            "exec -> Schema result fields should resolve: {:?}",
30999            compiled.diagnostics
31000        );
31001        assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31002    }
31003
31004    #[test]
31005    fn exec_with_requires_typed_record_binding() {
31006        // spec/std-script.md "Static checks" item 4: `exec <name> with <binding>`
31007        // serializes the binding to the script's stdin as a typed record, so the
31008        // binding must be a typed record binding — unknown or untyped-fact
31009        // bindings are check errors.
31010        let source = |with_line: &str, when_line: &str| {
31011            format!(
31012                r#"
31013@service
31014workflow ExecWith
31015
31016class Request {{ text string }}
31017class Report {{ message string }}
31018
31019output result Report
31020
31021rule go
31022  when {when_line}
31023=> {{
31024  exec echo_report with {with_line} -> Report as report
31025
31026  after report succeeds as out {{
31027    complete result {{
31028      message out.message
31029    }}
31030  }}
31031}}
31032"#
31033            )
31034        };
31035
31036        // Positive: a class-typed `when` binding is a typed record binding.
31037        let compiled = compile_program(&source("request", "Request as request"));
31038        assert!(
31039            !compiled
31040                .diagnostics
31041                .iter()
31042                .any(|d| d.message.contains("typed record binding")),
31043            "typed record binding must pass: {:?}",
31044            compiled.diagnostics
31045        );
31046        assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31047
31048        // Negative: an unknown binding.
31049        let compiled = compile_program(&source("missing", "Request as request"));
31050        assert!(
31051            compiled.diagnostics.iter().any(|d| d
31052                .message
31053                .contains("uses unknown binding `missing` in `exec echo_report with missing`")),
31054            "unknown binding must be rejected: {:?}",
31055            compiled.diagnostics
31056        );
31057
31058        // Negative: an untyped runtime fact binding carries no record shape.
31059        let compiled = compile_program(&source("g", "fact foo.bar as g"));
31060        assert!(
31061            compiled.diagnostics.iter().any(|d| d
31062                .message
31063                .contains("passes untyped fact binding `g` to `exec echo_report with`")),
31064            "untyped fact binding must be rejected: {:?}",
31065            compiled.diagnostics
31066        );
31067
31068        // Positive: a typed result binding (`exec -> Schema as r`) is a typed
31069        // record binding for a downstream `with`.
31070        let chained = r#"
31071@service
31072workflow ExecWithChained
31073
31074class Request { text string }
31075class Report { message string }
31076
31077output result Report
31078
31079rule go
31080  when Request as request
31081=> {
31082  exec fetch_request with request -> Request as fetched
31083
31084  after fetched succeeds as staged {
31085    exec echo_report with staged -> Report as report
31086
31087    after report succeeds as out {
31088      complete result {
31089        message out.message
31090      }
31091    }
31092  }
31093}
31094"#;
31095        let compiled = compile_program(chained);
31096        assert!(
31097            !compiled
31098                .diagnostics
31099                .iter()
31100                .any(|d| d.message.contains("typed record binding")),
31101            "typed exec-result binding must pass: {:?}",
31102            compiled.diagnostics
31103        );
31104        assert!(compiled.ir.is_some(), "{:?}", compiled.diagnostics);
31105    }
31106
31107    #[test]
31108    fn redact_projection_keeps_only_kept_fields() {
31109        // `redact c keep [id, status] as safe` synthesizes a projected class
31110        // holding only the kept fields, so `safe.id` resolves but `safe.ssn`
31111        // (dropped) is an unknown field — the type-system half of redaction
31112        // soundness (a dropped field cannot be reached through the projection).
31113        let kept = r#"
31114@service
31115workflow RedactKept
31116
31117class Customer { id string  ssn string  status string }
31118class Result { tag string }
31119output result Result
31120
31121signal go.now { x string }
31122
31123coerce read_customer(x string) -> Customer { prompt "x" }
31124
31125rule r
31126  when go.now as g
31127=> {
31128  coerce read_customer(g.x) as c
31129  after c succeeds as cust {
31130    redact cust keep [id, status] as safe
31131    complete result {
31132      tag safe.id
31133    }
31134  }
31135}
31136"#;
31137        let compiled = compile_program(kept);
31138        assert!(
31139            !compiled
31140                .diagnostics
31141                .iter()
31142                .any(|d| d.message.contains("unknown field") || d.message.contains("not a typed")),
31143            "kept field `safe.id` should resolve: {:?}",
31144            compiled.diagnostics
31145        );
31146
31147        assert!(
31148            compiled.ir.is_some(),
31149            "kept program should compile: {compiled:?}"
31150        );
31151
31152        let dropped = kept.replace("tag safe.id", "tag safe.ssn");
31153        let compiled = compile_program(&dropped);
31154        assert!(
31155            compiled
31156                .diagnostics
31157                .iter()
31158                .any(|d| d.message.contains("safe.ssn") || d.message.contains("`ssn`")),
31159            "dropped field `safe.ssn` should be rejected: {:?}",
31160            compiled.diagnostics
31161        );
31162    }
31163
31164    #[test]
31165    fn redact_unknown_kept_field_is_rejected() {
31166        let source = r#"
31167@service
31168workflow RedactBadKeep
31169
31170class Customer { id string  status string }
31171class Result { tag string }
31172output result Result
31173
31174signal go.now { x string }
31175
31176coerce read_customer(x string) -> Customer { prompt "x" }
31177
31178rule r
31179  when go.now as g
31180=> {
31181  coerce read_customer(g.x) as c
31182  after c succeeds as cust {
31183    redact cust keep [id, nonexistent] as safe
31184    complete result {
31185      tag safe.id
31186    }
31187  }
31188}
31189"#;
31190        let compiled = compile_program(source);
31191        assert!(
31192            compiled
31193                .diagnostics
31194                .iter()
31195                .any(|d| d.message.contains("keeping unknown field `nonexistent`")),
31196            "expected unknown-kept-field rejection: {:?}",
31197            compiled.diagnostics
31198        );
31199    }
31200
31201    #[test]
31202    fn inline_decide_result_resolves_typed_fields_for_case() {
31203        // `decide -> { fixed bool } as v` synthesizes a hygienic anonymous result
31204        // class, so `after v succeeds as r` can `case` / field-access `r`'s
31205        // fields — the same after-binding type flow a named `coerce -> Schema`
31206        // gets. Before this, an inline decide result had no type, so `r.fixed`
31207        // was "not a typed path" and `case r.fixed { true/false }` could not bind.
31208        let source = r#"
31209@service
31210workflow InlineDecideTyped
31211
31212class R { choice string }
31213output result R
31214
31215signal go.now {
31216  x string
31217}
31218
31219rule j
31220  when go.now as g
31221=> {
31222  decide "is it fixed?" -> { fixed bool } as v
31223
31224  after v succeeds as r {
31225    case r.fixed {
31226      true => {
31227        complete result {
31228          choice "a"
31229        }
31230      }
31231      false => {
31232        complete result {
31233          choice "b"
31234        }
31235      }
31236    }
31237  }
31238}
31239"#;
31240        let compiled = compile_program(source);
31241        assert!(
31242            !compiled
31243                .diagnostics
31244                .iter()
31245                .any(|d| d.message.contains("not a typed path")),
31246            "inline decide result fields should resolve: {:?}",
31247            compiled.diagnostics
31248        );
31249        let ir = compiled.ir.expect("compiles");
31250        // The synthesized hygienic class is visible in the IR (so the runtime
31251        // fixture can generate the anonymous shape).
31252        assert!(
31253            ir.schemas.iter().any(|schema| matches!(
31254                schema,
31255                IrSchema::Class(class) if class.name == "decide.j.v"
31256            )),
31257            "expected synthesized inline-decide class `decide.j.v` in IR schemas"
31258        );
31259    }
31260
31261    #[test]
31262    fn rejects_malformed_multiline_prompt_content_type_on_rule_prompt() {
31263        let source = r#"
31264workflow PromptAnnotationGuess
31265
31266agent worker {
31267  provider fixture
31268  profile "repo-writer"
31269  capacity 1
31270}
31271
31272rule ask
31273  when started
31274=> {
31275  tell worker as turn """markdown extra
31276  do work
31277  """
31278}
31279"#;
31280        let compiled = compile_program(source);
31281
31282        assert!(compiled.ir.is_none());
31283        assert!(compiled.diagnostics.iter().any(|diagnostic| {
31284            diagnostic
31285                .message
31286                .contains("malformed multiline prompt content type `markdown extra`")
31287                && diagnostic.suggestion.as_deref().is_some_and(|suggestion| {
31288                    suggestion.contains("put prompt text on the next line")
31289                })
31290        }));
31291    }
31292
31293    #[test]
31294    fn rejects_malformed_multiline_prompt_content_type_on_coerce_prompt() {
31295        let source = r#"
31296workflow CoerceAnnotationGuess
31297
31298class Review {
31299  status "ok"
31300}
31301
31302coerce review() -> Review {
31303  prompt """text/markdown extra
31304  classify the review
31305  """
31306}
31307
31308rule run
31309  when started
31310=> {
31311  coerce review() as result
31312}
31313"#;
31314        let compiled = compile_program(source);
31315
31316        assert!(compiled.ir.is_none());
31317        assert!(compiled
31318            .diagnostics
31319            .iter()
31320            .any(|diagnostic| diagnostic.message.contains(
31321                "coerce `review` has malformed multiline prompt content type `text/markdown extra`"
31322            )));
31323    }
31324
31325    #[test]
31326    fn rejects_pasted_top_level_gherkin_with_targeted_diagnostic() {
31327        let source = r#"
31328Feature: provider language routing
31329
31330Scenario: fixture provider reviews every language task
31331  Given a queued language task
31332  When the provider turn completes
31333  Then the language result is reviewed
31334"#;
31335        let compiled = compile_program(source);
31336
31337        assert!(compiled.ir.is_none());
31338        assert!(compiled.diagnostics.iter().any(|diagnostic| {
31339            diagnostic
31340                .message
31341                .contains("Gherkin keyword `Feature` is not WhippleScript workflow syntax")
31342                && diagnostic.suggestion.as_deref().is_some_and(|suggestion| {
31343                    suggestion.contains("use `workflow`, `table`, `rule")
31344                        && suggestion.contains("instead of free-text Given/When/Then steps")
31345                })
31346        }));
31347        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31348            .message
31349            .contains("Gherkin keyword `Given` is not WhippleScript workflow syntax")));
31350    }
31351
31352    #[test]
31353    fn rejects_pasted_gherkin_inside_workflow_body_with_targeted_diagnostic() {
31354        let source = r#"
31355workflow PastedGherkin {
31356  Scenario: fixture provider reviews every language task
31357  Given a queued language task
31358  When the provider turn completes
31359  Then the language result is reviewed
31360}
31361"#;
31362        let compiled = compile_program(source);
31363
31364        assert!(compiled.ir.is_none());
31365        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31366            .message
31367            .contains("Gherkin keyword `Scenario` is not WhippleScript workflow syntax")));
31368        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31369            .message
31370            .contains("Gherkin keyword `Then` is not WhippleScript workflow syntax")));
31371    }
31372
31373    #[test]
31374    fn rejects_pasted_gherkin_background_outline_examples_and_continuations() {
31375        let source = r#"
31376Feature: provider language routing
31377
31378Rule: provider execution remains explicit
31379
31380Background:
31381  Given a seeded provider table
31382  And all provider profiles are available
31383
31384Scenario Outline: provider reviews language task
31385  When <provider> completes <language>
31386  But the review is missing
31387  Then the fixture fails
31388
31389Examples:
31390  | provider | language |
31391  | codex    | French   |
31392"#;
31393        let compiled = compile_program(source);
31394
31395        assert!(compiled.ir.is_none());
31396        for keyword in ["Rule", "Background", "And", "Scenario", "But", "Examples"] {
31397            assert!(
31398                compiled
31399                    .diagnostics
31400                    .iter()
31401                    .any(|diagnostic| diagnostic.message.contains(&format!(
31402                        "Gherkin keyword `{keyword}` is not WhippleScript workflow syntax"
31403                    ))),
31404                "missing diagnostic for {keyword}: {:?}",
31405                compiled
31406                    .diagnostics
31407                    .iter()
31408                    .map(|diagnostic| diagnostic.message.as_str())
31409                    .collect::<Vec<_>>()
31410            );
31411        }
31412    }
31413
31414    #[test]
31415    fn explains_multiline_string_binding_position() {
31416        let source = r#"
31417workflow BindingGuess
31418
31419agent worker {
31420  provider fixture
31421  profile "repo-writer"
31422  capacity 1
31423}
31424
31425rule branch
31426  when started
31427=> {
31428  tell worker """
31429  do work
31430  """ as turn
31431
31432  after turn succeeds {
31433    tell worker "review" as review
31434  }
31435}
31436"#;
31437        let compiled = compile_program(source);
31438
31439        assert!(compiled.ir.is_none());
31440        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
31441            .message
31442            .contains("places effect binding `turn` after a multiline string delimiter")
31443            && diagnostic.suggestion.as_deref().is_some_and(
31444                |suggestion| suggestion.contains("move `as turn` onto the effect line")
31445            )));
31446    }
31447
31448    #[test]
31449    fn invalid_fixtures_have_actionable_diagnostics() {
31450        let fixtures = [
31451            (
31452                "bad-agent",
31453                include_str!("../../../examples/invalid/bad-agent.whip"),
31454            ),
31455            (
31456                "bad-record",
31457                include_str!("../../../examples/invalid/bad-record.whip"),
31458            ),
31459            (
31460                "bad-terminal-payload",
31461                include_str!("../../../examples/invalid/bad-terminal-payload.whip"),
31462            ),
31463            (
31464                "recursive-workflow-invocation",
31465                include_str!("../../../examples/invalid/recursive-workflow-invocation.whip"),
31466            ),
31467            (
31468                "bad-effect-graph",
31469                include_str!("../../../examples/invalid/bad-effect-graph.whip"),
31470            ),
31471            (
31472                "bad-effect-payload",
31473                include_str!("../../../examples/invalid/bad-effect-payload.whip"),
31474            ),
31475            (
31476                "bad-expression-functions",
31477                include_str!("../../../examples/invalid/bad-expression-functions.whip"),
31478            ),
31479            (
31480                "bad-finite-domain",
31481                include_str!("../../../examples/invalid/bad-finite-domain.whip"),
31482            ),
31483            (
31484                "broken",
31485                include_str!("../../../examples/invalid/broken.whip"),
31486            ),
31487            (
31488                "effect-output-scope",
31489                include_str!("../../../examples/invalid/effect-output-scope.whip"),
31490            ),
31491            (
31492                "effectful-self-loop",
31493                include_str!("../../../examples/invalid/effectful-self-loop.whip"),
31494            ),
31495            (
31496                "recursive-pattern",
31497                include_str!("../../../examples/invalid/recursive-pattern.whip"),
31498            ),
31499            (
31500                "evidence-fact-match",
31501                include_str!("../../../examples/invalid/evidence-fact-match.whip"),
31502            ),
31503            (
31504                "unknown-schema",
31505                include_str!("../../../examples/invalid/unknown-schema.whip"),
31506            ),
31507            (
31508                "headerless-library",
31509                include_str!("../../../examples/invalid/headerless-library.whip"),
31510            ),
31511        ];
31512
31513        for (name, source) in fixtures {
31514            let compiled = compile_program(source);
31515            assert!(compiled.ir.is_none(), "{name} unexpectedly compiled");
31516            assert!(
31517                !compiled.diagnostics.is_empty(),
31518                "{name} did not emit diagnostics"
31519            );
31520            assert!(
31521                compiled
31522                    .diagnostics
31523                    .iter()
31524                    .all(|diagnostic| diagnostic.suggestion.is_some()),
31525                "{name} emitted a diagnostic without a suggestion: {:?}",
31526                compiled.diagnostics
31527            );
31528        }
31529    }
31530
31531    #[test]
31532    fn rejects_dangling_root_in_record_value() {
31533        // A record value referencing a binding that does not exist (a typo or an
31534        // unbound name) is a dangling reference that previously compiled silently.
31535        let source = r#"
31536@service
31537workflow DanglingRoot
31538
31539class Ticket { id string }
31540class Note { text string }
31541
31542table seed as Ticket [ { id "1" } ]
31543
31544rule r
31545  when Ticket as ticket
31546=> {
31547  record Note {
31548    text tikcet.id
31549  }
31550}
31551"#;
31552        let compiled = compile_program(source);
31553        assert!(compiled.ir.is_none());
31554        assert!(
31555            compiled
31556                .diagnostics
31557                .iter()
31558                .any(|d| d.message.contains("unknown binding `tikcet`")),
31559            "{:?}",
31560            compiled.diagnostics
31561        );
31562    }
31563
31564    #[test]
31565    fn rejects_dangling_root_in_single_line_record() {
31566        // Single-line records (`record X { f y }`) were skipped by the line-based
31567        // extractor (brace_delta 0) and so were never field-validated; they are
31568        // now covered.
31569        let source = r#"
31570@service
31571workflow DanglingSingleLine
31572
31573class Ticket { id string }
31574class Note { text string }
31575
31576table seed as Ticket [ { id "1" } ]
31577
31578rule r
31579  when Ticket as ticket
31580=> {
31581  record Note { text tikcet.id }
31582}
31583"#;
31584        let compiled = compile_program(source);
31585        assert!(compiled.ir.is_none());
31586        assert!(
31587            compiled
31588                .diagnostics
31589                .iter()
31590                .any(|d| d.message.contains("unknown binding `tikcet`")),
31591            "{:?}",
31592            compiled.diagnostics
31593        );
31594    }
31595
31596    #[test]
31597    fn rejects_dangling_root_in_coerce_argument() {
31598        // Coerce arguments are another value position where a typo'd/unbound root
31599        // was previously accepted leniently by the type-checker.
31600        let source = r#"
31601@service
31602workflow DanglingCoerceArg
31603
31604class Ticket { id string  title string }
31605class Review { summary string }
31606
31607coerce classify(title string) -> Review { prompt "c" }
31608
31609agent reviewer { provider fixture  profile "r"  capacity 1 }
31610
31611table seed as Ticket [ { id "1"  title "t" } ]
31612
31613rule r
31614  when Ticket as ticket
31615  when reviewer is available
31616=> {
31617  coerce classify(tikcet.title) as rev
31618}
31619"#;
31620        let compiled = compile_program(source);
31621        assert!(compiled.ir.is_none());
31622        assert!(
31623            compiled
31624                .diagnostics
31625                .iter()
31626                .any(|d| d.message.contains("unknown binding `tikcet`")
31627                    && d.message.contains("coerce `classify`")),
31628            "{:?}",
31629            compiled.diagnostics
31630        );
31631    }
31632
31633    #[test]
31634    fn rejects_dangling_root_in_counter_consume_operand() {
31635        // Non-field effect operands (lease/counter `for <key>`, `emit ... to
31636        // <target>`) are also checked via `check_operand_root`.
31637        let source = r#"
31638@service
31639workflow CounterOperandDangling
31640
31641class CallFailed { service string }
31642class Service { id string }
31643
31644counter failure_budget { key Service cap 3 reset daily }
31645
31646table seed as CallFailed [ { service "x" } ]
31647
31648rule strike
31649  when CallFailed as f
31650=> {
31651  consume failure_budget for fff.service amount 1 as strike
31652}
31653"#;
31654        let compiled = compile_program(source);
31655        assert!(compiled.ir.is_none());
31656        assert!(
31657            compiled
31658                .diagnostics
31659                .iter()
31660                .any(|d| d.message.contains("unknown binding `fff`")
31661                    && d.message.contains("consume")),
31662            "{:?}",
31663            compiled.diagnostics
31664        );
31665    }
31666
31667    #[test]
31668    fn rejects_dangling_root_in_queue_file_payload() {
31669        // Body-AST effect payloads (`file issue into`, `emit`, ledger `append`)
31670        // are validated via `validate_effect_field_roots`, not the line-based
31671        // validators; their field values were previously unchecked for roots.
31672        let source = r#"
31673@service
31674workflow QueueFieldDangling
31675
31676class Ticket { id string }
31677
31678tracker backlog { provider builtin }
31679
31680table seed as Ticket [ { id "1" } ]
31681
31682rule r
31683  when Ticket as ticket
31684=> {
31685  file issue into backlog {
31686    title tikcet.id
31687    body "x"
31688  }
31689}
31690"#;
31691        let compiled = compile_program(source);
31692        assert!(compiled.ir.is_none());
31693        assert!(
31694            compiled
31695                .diagnostics
31696                .iter()
31697                .any(|d| d.message.contains("unknown binding `tikcet`")
31698                    && d.message.contains("file into")),
31699            "{:?}",
31700            compiled.diagnostics
31701        );
31702    }
31703
31704    #[test]
31705    fn rejects_dangling_root_in_invoke_input() {
31706        // Invoke payload inputs are another value position the type-checker
31707        // accepted leniently for unknown roots.
31708        let source = r#"
31709workflow Parent {
31710  input task Task
31711  output result Out
31712
31713  class Task { id string }
31714  class Out { x string }
31715
31716  rule r
31717    when Task as task
31718  => {
31719    invoke Child { item tikcet.id } as c
31720    after c succeeds as cr {
31721      done task
31722      complete result { x cr.summary }
31723    }
31724  }
31725}
31726
31727workflow Child {
31728  input item string
31729  output result ChildOut
31730  class ChildOut { y string }
31731  rule c
31732    when item as i
31733  => {
31734    complete result { y "done" }
31735  }
31736}
31737"#;
31738        let compiled = compile_program_with_root(source, Some("Parent"));
31739        assert!(compiled.ir.is_none());
31740        assert!(
31741            compiled
31742                .diagnostics
31743                .iter()
31744                .any(|d| d.message.contains("unknown binding `tikcet`")
31745                    && d.message.contains("invoke Child")),
31746            "{:?}",
31747            compiled.diagnostics
31748        );
31749    }
31750
31751    #[test]
31752    fn rejects_dangling_root_in_tell_target() {
31753        // A dynamic tell target with a typo'd/unbound root was silently accepted
31754        // (the type lookup returned None and bailed).
31755        let source = r#"
31756@service
31757workflow DanglingTellTarget
31758
31759class Ticket { id string  provider AgentRef<reviewer> }
31760
31761agent reviewer { provider fixture  profile "r"  capacity 1 }
31762
31763table seed as Ticket [ { id "1"  provider reviewer } ]
31764
31765rule r
31766  when Ticket as ticket
31767=> {
31768  tell tikcet.provider as turn "go"
31769}
31770"#;
31771        let compiled = compile_program(source);
31772        assert!(compiled.ir.is_none());
31773        assert!(
31774            compiled
31775                .diagnostics
31776                .iter()
31777                .any(|d| d.message.contains("unknown binding `tikcet`")
31778                    && d.message.contains("tell target")),
31779            "{:?}",
31780            compiled.diagnostics
31781        );
31782    }
31783
31784    #[test]
31785    fn accepts_effect_binding_root_in_record_value() {
31786        // The AST-collected root set must include `tell`/`after` results (which
31787        // the typed `binding_types` map omits), so reading an effect-result field
31788        // in a record value compiles. This is the case a naive binding_types-only
31789        // check wrongly rejected.
31790        let source = r#"
31791@service
31792workflow EffectRoot
31793
31794class Ticket { id string }
31795class Note { text string }
31796
31797agent reviewer { provider fixture  profile "r"  capacity 1 }
31798
31799table seed as Ticket [ { id "1" } ]
31800
31801rule r
31802  when Ticket as ticket
31803  when reviewer is available
31804=> {
31805  tell reviewer as turn "review"
31806  after turn succeeds {
31807    record Note {
31808      text turn.summary
31809    }
31810  }
31811}
31812"#;
31813        let compiled = compile_program(source);
31814        assert_eq!(
31815            compiled.diagnostics,
31816            Vec::new(),
31817            "{:?}",
31818            compiled.diagnostics
31819        );
31820        assert!(compiled.ir.is_some());
31821    }
31822
31823    #[test]
31824    fn rejects_invalid_record_fields_paths_and_literals() {
31825        let source = include_str!("../../../examples/invalid/bad-record.whip");
31826        let compiled = compile_program(source);
31827
31828        assert!(compiled.ir.is_none());
31829        assert_eq!(compiled.diagnostics.len(), 5);
31830        assert!(compiled
31831            .diagnostics
31832            .iter()
31833            .any(|diagnostic| diagnostic.message.contains("request.missing")));
31834        assert!(compiled
31835            .diagnostics
31836            .iter()
31837            .any(|diagnostic| diagnostic.message.contains("no variant `Maybe`")));
31838        assert!(compiled
31839            .diagnostics
31840            .iter()
31841            .any(|diagnostic| diagnostic.message.contains("expects `float`")));
31842        assert!(compiled
31843            .diagnostics
31844            .iter()
31845            .any(|diagnostic| diagnostic.message.contains("cannot be `scripted`")));
31846        assert!(compiled
31847            .diagnostics
31848            .iter()
31849            .any(|diagnostic| diagnostic.message.contains("no field `extra`")));
31850    }
31851
31852    #[test]
31853    fn rejects_effect_output_outside_after_scope() {
31854        let source = include_str!("../../../examples/invalid/effect-output-scope.whip");
31855        let compiled = compile_program(source);
31856
31857        assert!(compiled.ir.is_none());
31858        assert_eq!(compiled.diagnostics.len(), 1);
31859        assert!(compiled.diagnostics[0]
31860            .message
31861            .contains("outside a matching `after claim ...` block"));
31862    }
31863
31864    /// DR-0043 Decision 5: a `during`/`until` region compiles; the canonical
31865    /// IR body is the condition-HOLDS splice (no region syntax left), and the
31866    /// metadata carries the removed/lapsed variants and the region effects
31867    /// with their level-1 scopes.
31868    #[test]
31869    fn region_compiles_and_ir_carries_variants() {
31870        let source = r#"
31871workflow Deploy
31872
31873output result Done
31874failure error Halted
31875
31876class Incident {
31877  sev string
31878}
31879
31880class Done {
31881  note string
31882}
31883
31884class Halted {
31885  reason string
31886}
31887
31888rule ship
31889  when started
31890=> {
31891  until exists(Incident where sev == "sev1") {
31892    then plan <- timer 1s
31893    then approved <- timer 1s
31894    complete result {
31895      note "shipped"
31896    }
31897  } on lapse as got {
31898    fail error {
31899      reason "halted"
31900    }
31901  }
31902}
31903"#;
31904        let compiled = compile_program(source);
31905        assert!(
31906            compiled.diagnostics.is_empty(),
31907            "region must compile: {:?}",
31908            compiled.diagnostics
31909        );
31910        let ir = compiled.ir.expect("ir");
31911        let rule = &ir.rules[0];
31912        assert!(
31913            !rule.body.contains("until exists") && !rule.body.contains("on lapse"),
31914            "canonical body is the HOLDS splice: {}",
31915            rule.body
31916        );
31917        let region = rule.metadata.region.as_ref().expect("region metadata");
31918        assert!(region.until);
31919        assert_eq!(region.condition, "exists(Incident where sev == \"sev1\")");
31920        assert_eq!(region.lapse_binding.as_deref(), Some("got"));
31921        assert!(
31922            region.body_lapsed.contains("fail error"),
31923            "lapsed variant carries the arm: {}",
31924            region.body_lapsed
31925        );
31926        assert!(
31927            !region.body_removed.contains("timer") && !region.body_removed.contains("fail error"),
31928            "removed variant drops region AND arm: {}",
31929            region.body_removed
31930        );
31931        let bindings: Vec<&str> = region
31932            .effects
31933            .iter()
31934            .map(|effect| effect.binding.as_str())
31935            .collect();
31936        assert!(
31937            bindings.contains(&"__then_plan") && bindings.contains(&"__then_approved"),
31938            "region effects recorded: {bindings:?}"
31939        );
31940    }
31941
31942    /// v1 limit: one region per rule; the second draws a spanned error.
31943    #[test]
31944    fn two_regions_in_one_rule_rejected() {
31945        let source = r#"
31946workflow Two
31947
31948output result Done
31949
31950class Done {
31951  note string
31952}
31953
31954class Flag {
31955  on string
31956}
31957
31958rule go
31959  when started
31960=> {
31961  during empty(Flag) {
31962    timer 1s as a
31963
31964    after a completes {
31965      record Flag {
31966        on "x"
31967      }
31968    }
31969  } on lapse {
31970    complete result {
31971      note "one"
31972    }
31973  }
31974
31975  during empty(Flag) {
31976    timer 1s as b
31977
31978    after b completes {
31979      complete result {
31980        note "two"
31981      }
31982    }
31983  } on lapse {
31984    complete result {
31985      note "three"
31986    }
31987  }
31988}
31989"#;
31990        let compiled = compile_program(source);
31991        assert!(
31992            compiled
31993                .diagnostics
31994                .iter()
31995                .any(|d| d.message.contains("more than one `during`/`until` region")),
31996            "second region rejected: {:?}",
31997            compiled.diagnostics
31998        );
31999    }
32000
32001    /// The lapse arm may not reference bindings the region introduces — they
32002    /// may not exist when the arm runs; the progress view is the window.
32003    #[test]
32004    fn lapse_arm_referencing_region_binding_rejected() {
32005        let source = r#"
32006workflow Scope
32007
32008output result Done
32009failure error Halted
32010
32011class Incident {
32012  sev string
32013}
32014
32015class Done {
32016  note string
32017}
32018
32019class Halted {
32020  reason string
32021}
32022
32023rule go
32024  when started
32025=> {
32026  until exists(Incident where sev == "sev1") {
32027    then plan <- timer 1s
32028    complete result {
32029      note plan.status
32030    }
32031  } on lapse {
32032    fail error {
32033      reason plan.status
32034    }
32035  }
32036}
32037"#;
32038        let compiled = compile_program(source);
32039        assert!(
32040            compiled
32041                .diagnostics
32042                .iter()
32043                .any(|d| d.message.contains("references `plan`, a binding the")),
32044            "arm scope violation rejected: {:?}",
32045            compiled.diagnostics
32046        );
32047    }
32048
32049    /// Full-line `#` comments are legal in rule bodies (ruling 2026-07-21):
32050    /// at statement positions, inside `after` arms, between `then` lines
32051    /// (blanked BEFORE then-expansion, so a comment containing braces cannot
32052    /// corrupt the wrap-depth accounting), while a `#` inside a `"""` prompt
32053    /// stays content -- the markdown heading survives to the effect prompt.
32054    #[test]
32055    fn full_line_comments_in_rule_bodies_compile_and_prompts_keep_hashes() {
32056        let source = r#"
32057use std.script
32058
32059workflow Commented
32060
32061output result Done
32062
32063class Done {
32064  note string
32065}
32066
32067agent helper
32068
32069rule go
32070  when started
32071=> {
32072  # request the probe command
32073  exec "true" as probe
32074
32075  after probe succeeds {
32076    # a comment with braces { and quotes " should be inert
32077    then turn <- tell helper """markdown
32078    # This heading is prompt CONTENT, not a comment.
32079    Summarize.
32080    """
32081    # comment between then chain and terminal
32082    complete result {
32083      note turn.summary
32084    }
32085  }
32086
32087  after probe fails {
32088    # losing is fine
32089  }
32090}
32091"#;
32092        let compiled = compile_program(source);
32093        assert!(
32094            compiled.diagnostics.is_empty(),
32095            "comments must not produce diagnostics: {:?}",
32096            compiled.diagnostics
32097        );
32098        let ir = compiled.ir.expect("compiles");
32099        let rule = &ir.rules[0];
32100        assert!(
32101            !rule.body.contains("# request"),
32102            "compile-path body text is comment-blanked: {}",
32103            rule.body
32104        );
32105        assert!(
32106            rule.body.contains("# This heading is prompt CONTENT"),
32107            "prompt interiors are untouched by blanking: {}",
32108            rule.body
32109        );
32110    }
32111
32112    /// A `#` after code on the same line is still an error -- trailing
32113    /// comments are top-level-only.
32114    #[test]
32115    fn trailing_comment_in_rule_body_still_rejected() {
32116        let source = r#"
32117workflow Trailing
32118
32119output result Done
32120
32121class Done {
32122  note string
32123}
32124
32125rule go
32126  when started
32127=> {
32128  complete result {
32129    note "x"
32130  } # not allowed here
32131}
32132"#;
32133        let compiled = compile_program(source);
32134        assert!(
32135            compiled
32136                .diagnostics
32137                .iter()
32138                .any(|d| d.message.contains("unexpected character `#`")),
32139            "trailing comment must still be rejected: {:?}",
32140            compiled.diagnostics
32141        );
32142    }
32143
32144    #[test]
32145    fn rejects_effectful_self_trigger_loop() {
32146        let source = include_str!("../../../examples/invalid/effectful-self-loop.whip");
32147        let compiled = compile_program(source);
32148
32149        assert!(compiled.ir.is_none());
32150        assert_eq!(compiled.diagnostics.len(), 1);
32151        assert!(compiled.diagnostics[0]
32152            .message
32153            .contains("preserves trigger fact `schema:WorkItem`"));
32154    }
32155
32156    #[test]
32157    fn rejects_non_file_operation_on_a_file_store_grant() {
32158        // A grant on a declared file store may only use file operations; a non-file op
32159        // (e.g. `recall`) is rejected. A non-file-store resource is left alone.
32160        let program = |op: &str, resource: &str, store: &str| {
32161            format!(
32162                r#"
32163@service
32164workflow FileGrant
32165
32166output result R
32167class R {{ ok bool }}
32168class Ticket {{ id string  status "open" }}
32169
32170agent coder {{ provider fixture  profile "repo-writer"  capacity 1 }}
32171
32172file store {store} {{ root "./data"  allow read ["docs/**"] }}
32173
32174table seed as Ticket [ {{ id "T1"  status "open" }} ]
32175
32176rule work
32177  when Ticket as ticket where ticket.status == "open"
32178  when coder is available
32179=> {{
32180  tell coder as turn
32181    with access to {resource} {{
32182      {op}
32183    }}
32184  "go"
32185
32186  after turn succeeds as outcome {{
32187    complete result {{ ok true }}
32188  }}
32189}}
32190"#
32191            )
32192        };
32193
32194        // `recall` on a declared file store is not a file operation.
32195        let bad = compile_program(&program(
32196            "recall for ticket",
32197            "project_files",
32198            "project_files",
32199        ));
32200        assert!(
32201            bad.diagnostics
32202                .iter()
32203                .any(|d| d.message.contains("not a file operation")),
32204            "{:?}",
32205            bad.diagnostics
32206        );
32207        // The same `recall` on a non-file-store resource is left alone (could be a
32208        // package resource) — no false positive.
32209        let ok = compile_program(&program(
32210            "recall for ticket",
32211            "project_memory",
32212            "project_files",
32213        ));
32214        assert!(
32215            !ok.diagnostics
32216                .iter()
32217                .any(|d| d.message.contains("not a file operation")),
32218            "{:?}",
32219            ok.diagnostics
32220        );
32221    }
32222
32223    #[test]
32224    fn parses_memory_pool_declaration_and_snapshots_it() {
32225        // MEM-1: a `memory pool` declaration lowers to a metadata-only pool with
32226        // its optional `context limit`, and the .ir snapshot renders it.
32227        let source = r#"
32228workflow PoolDecl
32229
32230memory pool project_memory {
32231  context limit 8
32232}
32233"#;
32234        let compiled = compile_program(source);
32235        let ir = compiled.ir.expect("compiles");
32236        assert_eq!(ir.memory_pools.len(), 1);
32237        assert_eq!(ir.memory_pools[0].name, "project_memory");
32238        assert_eq!(ir.memory_pools[0].context_limit, Some(8));
32239        let snapshot = ir.to_snapshot();
32240        assert!(snapshot.contains("memory_pools"), "{snapshot}");
32241        assert!(
32242            snapshot.contains("memory pool project_memory"),
32243            "{snapshot}"
32244        );
32245        assert!(snapshot.contains("context limit 8"), "{snapshot}");
32246
32247        // `context limit` is optional — an absent clause lowers to `None` and
32248        // omits the snapshot line (no ripple), mirroring file-store globs.
32249        let bare = compile_program("workflow Bare\n\nmemory pool p {\n}\n")
32250            .ir
32251            .expect("bare pool compiles");
32252        assert_eq!(bare.memory_pools[0].context_limit, None);
32253        assert!(!bare.to_snapshot().contains("context limit"));
32254    }
32255
32256    #[test]
32257    fn rejects_unknown_and_provider_memory_pool_clauses() {
32258        // Unknown clauses are rejected (file-store precedent). Since the
32259        // declaration-family migration (M2), `provider` is no longer a bespoke
32260        // "pools have no provider" clause but a generic unknown field — v1 pools
32261        // are provider-less, so it is simply not in the grammar.
32262        let unknown = compile_program("workflow U\n\nmemory pool p {\n  retention 5\n}\n");
32263        assert!(
32264            unknown
32265                .diagnostics
32266                .iter()
32267                .any(|d| d.message.contains("unknown memory pool field `retention`")),
32268            "{:?}",
32269            unknown.diagnostics
32270        );
32271        let provider = compile_program("workflow P\n\nmemory pool p {\n  provider local\n}\n");
32272        assert!(
32273            provider
32274                .diagnostics
32275                .iter()
32276                .any(|d| d.message.contains("unknown memory pool field `provider`")),
32277            "{:?}",
32278            provider.diagnostics
32279        );
32280    }
32281
32282    #[test]
32283    fn rejects_non_memory_operation_on_a_memory_pool_grant() {
32284        // MEM-1 static check 2 (mirrors the file-store grant precedent): a grant on
32285        // a declared memory pool may only use memory operations; a non-memory op
32286        // (e.g. a file `read`) is rejected. A grant on a non-pool resource is left
32287        // alone (zero-false-positive) — this closes the memory-grant-validation
32288        // deferral, now that `ir.memory_pools` gives a declared-pool list.
32289        let program = |op: &str, resource: &str, pool: &str| {
32290            format!(
32291                r#"
32292@service
32293workflow MemoryGrant
32294
32295output result R
32296class R {{ ok bool }}
32297class Ticket {{ id string  status "open" }}
32298
32299agent coder {{ provider fixture  profile "repo-writer"  capacity 1 }}
32300
32301memory pool {pool} {{ context limit 8 }}
32302
32303table seed as Ticket [ {{ id "T1"  status "open" }} ]
32304
32305rule work
32306  when Ticket as ticket where ticket.status == "open"
32307  when coder is available
32308=> {{
32309  tell coder as turn
32310    with access to {resource} {{
32311      {op}
32312    }}
32313  "go"
32314
32315  after turn succeeds as outcome {{
32316    complete result {{ ok true }}
32317  }}
32318}}
32319"#
32320            )
32321        };
32322
32323        // A file `read` on a declared memory pool is not a memory operation.
32324        let bad = compile_program(&program(
32325            r#"read ["docs/**"]"#,
32326            "project_memory",
32327            "project_memory",
32328        ));
32329        assert!(
32330            bad.diagnostics
32331                .iter()
32332                .any(|d| d.message.contains("not a memory operation")),
32333            "{:?}",
32334            bad.diagnostics
32335        );
32336
32337        // A memory operation (`recall`/`learn`) on the declared pool is accepted.
32338        let ok_recall = compile_program(&program(
32339            "recall for ticket\n      learn for ticket",
32340            "project_memory",
32341            "project_memory",
32342        ));
32343        assert!(
32344            !ok_recall
32345                .diagnostics
32346                .iter()
32347                .any(|d| d.message.contains("not a memory operation")),
32348            "{:?}",
32349            ok_recall.diagnostics
32350        );
32351
32352        // The same file `read` on a non-pool resource is left alone (no false
32353        // positive) — it could be a file store or a package-provided resource.
32354        let ok_other = compile_program(&program(
32355            r#"read ["docs/**"]"#,
32356            "project_files",
32357            "project_memory",
32358        ));
32359        assert!(
32360            !ok_other
32361                .diagnostics
32362                .iter()
32363                .any(|d| d.message.contains("not a memory operation")),
32364            "{:?}",
32365            ok_other.diagnostics
32366        );
32367    }
32368
32369    #[test]
32370    fn rejects_malformed_turn_access_grants() {
32371        // An empty grant block and a duplicate resource on one `tell` are both
32372        // structurally malformed.
32373        let program = |grant_block: &str| {
32374            format!(
32375                r#"
32376@service
32377workflow GrantCheck
32378
32379output result R
32380class R {{ ok bool }}
32381class Ticket {{ id string  status "open" }}
32382
32383agent coder {{ provider fixture  profile "repo-writer"  capacity 1 }}
32384
32385table seed as Ticket [ {{ id "T1"  status "open" }} ]
32386
32387rule work
32388  when Ticket as ticket where ticket.status == "open"
32389  when coder is available
32390=> {{
32391  tell coder as turn
32392{grant_block}
32393  "Work it."
32394
32395  after turn succeeds as outcome {{
32396    complete result {{ ok true }}
32397  }}
32398}}
32399"#
32400            )
32401        };
32402
32403        let empty = compile_program(&program("    with access to project_memory {\n    }\n"));
32404        assert!(
32405            empty
32406                .diagnostics
32407                .iter()
32408                .any(|d| d.message.contains("grants no operations")),
32409            "{:?}",
32410            empty.diagnostics
32411        );
32412
32413        let duplicate = compile_program(&program(
32414            "    with access to project_memory {\n      recall for ticket\n    }\n    with access to project_memory {\n      learn for ticket\n    }\n",
32415        ));
32416        assert!(
32417            duplicate
32418                .diagnostics
32419                .iter()
32420                .any(|d| d.message.contains("more than once")),
32421            "{:?}",
32422            duplicate.diagnostics
32423        );
32424    }
32425
32426    /// MEM-5 static check 4: a memory grant on a tell whose harness is a
32427    /// native adapter warns (the grant is inert there); the same grant on
32428    /// an owned harness stays quiet.
32429    #[test]
32430    fn warns_inert_memory_grant_on_a_native_adapter_tell() {
32431        let program = |harness_kind: &str| {
32432            format!(
32433                r#"
32434@service
32435workflow InertGrant
32436
32437output result R
32438class R {{ ok bool }}
32439class Ticket {{ id string  status "open" }}
32440
32441memory pool project_memory {{
32442  context limit 4
32443}}
32444
32445harness h: {harness_kind}
32446agent coder using h {{ profile "repo-writer"  capacity 1 }}
32447
32448table seed as Ticket [ {{ id "T1"  status "open" }} ]
32449
32450rule work
32451  when Ticket as ticket where ticket.status == "open"
32452  when coder is available
32453=> {{
32454  tell coder as turn
32455    with access to project_memory {{
32456      recall for ticket
32457    }}
32458  "Work it."
32459
32460  after turn succeeds as outcome {{
32461    complete result {{ ok true }}
32462  }}
32463}}
32464"#
32465            )
32466        };
32467        let native = compile_program(&program("codex"));
32468        assert!(
32469            native.diagnostics.is_empty(),
32470            "the grant itself is legal: {:?}",
32471            native.diagnostics
32472        );
32473        assert!(
32474            native
32475                .warnings
32476                .iter()
32477                .any(|warning| warning.message.contains("inert")),
32478            "a codex-harness tell warns: {:?}",
32479            native.warnings
32480        );
32481        let owned = compile_program(&program("owned"));
32482        assert!(
32483            owned
32484                .warnings
32485                .iter()
32486                .all(|warning| !warning.message.contains("inert")),
32487            "an owned-harness tell does not warn: {:?}",
32488            owned.warnings
32489        );
32490    }
32491
32492    #[test]
32493    fn counter_timezone_clause_parses_and_default_utc_warns() {
32494        // std.coord slice 3: `timezone "<IANA zone>"` anchors the counter's
32495        // reset-period boundary; omitting it is legal but draws the
32496        // default-UTC warning.
32497        let program = |timezone_clause: &str| {
32498            format!(
32499                r#"
32500@service
32501workflow CounterTz
32502
32503class CallFailed {{ service string }}
32504class Service {{ id string }}
32505output result CallFailed
32506failure trouble CallFailed
32507
32508counter failure_budget {{ key Service cap 3 reset daily {timezone_clause} }}
32509
32510rule strike
32511  when CallFailed as f
32512=> {{
32513  consume failure_budget for f.service amount 1 as strike
32514  after strike ok {{
32515    complete result {{ service f.service }}
32516  }}
32517  after strike over {{
32518    fail trouble {{ service f.service }}
32519  }}
32520}}
32521"#
32522            )
32523        };
32524        let anchored = compile_program(&program(r#"timezone "America/New_York""#));
32525        assert!(
32526            anchored.diagnostics.is_empty(),
32527            "timezone clause parses: {:?}",
32528            anchored.diagnostics
32529        );
32530        let ir = anchored.ir.expect("anchored program compiles");
32531        assert_eq!(ir.counters[0].timezone.as_deref(), Some("America/New_York"));
32532        assert!(
32533            anchored
32534                .warnings
32535                .iter()
32536                .all(|warning| !warning.message.contains("timezone")),
32537            "an anchored counter does not warn: {:?}",
32538            anchored.warnings
32539        );
32540
32541        let unanchored = compile_program(&program(""));
32542        assert!(
32543            unanchored.diagnostics.is_empty(),
32544            "omitting timezone stays legal: {:?}",
32545            unanchored.diagnostics
32546        );
32547        let ir = unanchored.ir.expect("unanchored program compiles");
32548        assert_eq!(ir.counters[0].timezone, None);
32549        assert!(
32550            unanchored
32551                .warnings
32552                .iter()
32553                .any(|warning| warning.message.contains("anchors to UTC")),
32554            "an unanchored counter draws the default-UTC warning: {:?}",
32555            unanchored.warnings
32556        );
32557    }
32558
32559    #[test]
32560    fn then_sugar_desugars_to_nested_after_and_composes_in_after_blocks() {
32561        // R2: `then <binding> <- <effect>` is pure parser sugar for
32562        // `after <handle> succeeds as <binding> { … }` with a synthetic
32563        // `__then_*` handle; it composes inside after blocks; and the reserved
32564        // namespace is rejected in author text.
32565        let source = r#"
32566use std.script
32567
32568workflow ThenSugar
32569
32570output result Done
32571
32572class Done {
32573  note string
32574}
32575
32576class Trigger {
32577  id string
32578}
32579
32580table seed as Trigger [
32581  { id "t" }
32582]
32583
32584rule pipeline
32585  when Trigger as t
32586=> {
32587  exec "true" as pre
32588
32589  after pre succeeds {
32590    then a <- exec "one"
32591    then b <- exec "two"
32592    complete result {
32593      note b.stdout
32594    }
32595  }
32596}
32597"#;
32598        let compiled = compile_program(source);
32599        assert_eq!(compiled.diagnostics, Vec::new());
32600        let ir = compiled.ir.expect("compiles");
32601        let body = &ir
32602            .rules
32603            .iter()
32604            .find(|rule| rule.name == "pipeline")
32605            .expect("rule")
32606            .body;
32607        assert!(
32608            body.contains("exec \"one\" as __then_a"),
32609            "the chained effect binds the synthetic handle:\n{body}"
32610        );
32611        assert!(
32612            body.contains("after __then_a succeeds as a {"),
32613            "the continuation nests under the success predicate:\n{body}"
32614        );
32615        assert!(
32616            body.contains("after __then_b succeeds as b {"),
32617            "chained thens nest:\n{body}"
32618        );
32619        assert!(!body.contains("then a <-"), "no sugar survives:\n{body}");
32620
32621        let reserved = compile_program(
32622            r#"
32623use std.script
32624
32625workflow Reserved
32626
32627output result Done
32628
32629class Done {
32630  note string
32631}
32632
32633rule r
32634  when started
32635=> {
32636  exec "true" as __then_x
32637
32638  after __then_x succeeds {
32639    complete result { note "no" }
32640  }
32641}
32642"#,
32643        );
32644        assert!(
32645            reserved
32646                .diagnostics
32647                .iter()
32648                .any(|d| d.message.contains("reserved `__then_` binding namespace")),
32649            "{:?}",
32650            reserved.diagnostics
32651        );
32652    }
32653
32654    #[test]
32655    fn warns_on_unhandled_effect_failure_and_stays_quiet_when_observed() {
32656        // Auto-fail R1a: an effect handled only via `after … succeeds` draws the
32657        // prominent unhandled-failure warning; a `fails` or `completes` observer
32658        // silences it.
32659        let program = |handler: &str| {
32660            format!(
32661                r#"
32662use std.script
32663
32664workflow AutoFailWarn
32665
32666output result Done
32667failure error Broken
32668
32669class Done {{ note string }}
32670class Broken {{ reason string }}
32671class Trigger {{ id string }}
32672
32673table seed as Trigger [
32674  {{ id "t" }}
32675]
32676
32677rule r
32678  when Trigger as t
32679=> {{
32680  exec "true" as x
32681
32682  after x succeeds {{
32683    complete result {{ note "ok" }}
32684  }}
32685{handler}}}
32686"#
32687            )
32688        };
32689        let unhandled = compile_program(&program(""));
32690        assert!(
32691            unhandled.diagnostics.is_empty(),
32692            "{:?}",
32693            unhandled.diagnostics
32694        );
32695        assert!(
32696            unhandled
32697                .warnings
32698                .iter()
32699                .any(|warning| warning.message.contains("`x`'s failure is unhandled")),
32700            "succeeds-only handling draws the R1a warning: {:?}",
32701            unhandled.warnings
32702        );
32703
32704        for observer in [
32705            "\n  after x fails {\n    fail error { reason \"broken\" }\n  }\n",
32706            "\n  after x completes {\n    complete result { note \"any\" }\n  }\n",
32707            "\n  after x times out {\n    fail error { reason \"slow\" }\n  }\n",
32708        ] {
32709            let observed = compile_program(&program(observer));
32710            assert!(
32711                observed.diagnostics.is_empty(),
32712                "{:?}",
32713                observed.diagnostics
32714            );
32715            assert!(
32716                observed
32717                    .warnings
32718                    .iter()
32719                    .all(|warning| !warning.message.contains("failure is unhandled")),
32720                "an observer silences the warning ({observer:?}): {:?}",
32721                observed.warnings
32722            );
32723        }
32724    }
32725
32726    #[test]
32727    fn unhandled_failure_warning_exempts_services_timers_and_coordination() {
32728        // Auto-fail R1a exemptions: `@service` workflows record diagnostics
32729        // instead of auto-failing (so the warning's framing would be wrong),
32730        // timers cannot fail, and coordination outcome observers
32731        // (`held`/`contended`/`ok`/`over`) count as observers at check time.
32732        let service = compile_program(
32733            r#"
32734use std.script
32735
32736@service
32737workflow ServiceQuiet
32738
32739class Trigger { id string }
32740class Seen { note string }
32741
32742table seed as Trigger [
32743  { id "t" }
32744]
32745
32746rule r
32747  when Trigger as t
32748=> {
32749  exec "true" as x
32750
32751  after x succeeds {
32752    record Seen { note "ok" }
32753  }
32754}
32755"#,
32756        );
32757        assert!(service.diagnostics.is_empty(), "{:?}", service.diagnostics);
32758        assert!(
32759            service
32760                .warnings
32761                .iter()
32762                .all(|warning| !warning.message.contains("failure is unhandled")),
32763            "@service is exempt: {:?}",
32764            service.warnings
32765        );
32766
32767        let timer = compile_program(
32768            r#"
32769workflow TimerQuiet
32770
32771output result Done
32772
32773class Done { note string }
32774class Trigger { id string }
32775
32776table seed as Trigger [
32777  { id "t" }
32778]
32779
32780rule r
32781  when Trigger as t
32782=> {
32783  timer 5m as pause
32784
32785  after pause completes {
32786    complete result { note "ok" }
32787  }
32788}
32789"#,
32790        );
32791        assert!(timer.diagnostics.is_empty(), "{:?}", timer.diagnostics);
32792        assert!(
32793            timer
32794                .warnings
32795                .iter()
32796                .all(|warning| !warning.message.contains("failure is unhandled")),
32797            "timers are exempt: {:?}",
32798            timer.warnings
32799        );
32800
32801        let coordination = compile_program(
32802            r#"
32803workflow CoordQuiet
32804
32805output result Done
32806failure error Broken
32807
32808class Done { note string }
32809class Broken { reason string }
32810class Trigger { id string }
32811
32812lease build_slot { key Trigger ttl 10m }
32813
32814table seed as Trigger [
32815  { id "t" }
32816]
32817
32818rule r
32819  when Trigger as t
32820=> {
32821  acquire build_slot for t.id as slot
32822
32823  after slot held {
32824    complete result { note "ok" }
32825  }
32826
32827  after slot contended {
32828    fail error { reason "busy" }
32829  }
32830}
32831"#,
32832        );
32833        assert!(
32834            coordination.diagnostics.is_empty(),
32835            "{:?}",
32836            coordination.diagnostics
32837        );
32838        assert!(
32839            coordination
32840                .warnings
32841                .iter()
32842                .all(|warning| !warning.message.contains("failure is unhandled")),
32843            "coordination outcome observers count at check time: {:?}",
32844            coordination.warnings
32845        );
32846    }
32847
32848    #[test]
32849    fn lowers_turn_access_grants_onto_the_agent_tell_effect() {
32850        // `with access to <resource> { … }` on a tell lowers to `access_grants` on the
32851        // agent.tell IR effect (Proposal A authority-narrowing metadata).
32852        let source = r#"
32853@service
32854workflow GrantDemo
32855
32856output result R
32857class R { ok bool }
32858class Ticket { id string  status "open" }
32859
32860agent coder { provider fixture  profile "repo-writer"  capacity 1 }
32861
32862table seed as Ticket [ { id "T1"  status "open" } ]
32863
32864rule work
32865  when Ticket as ticket where ticket.status == "open"
32866  when coder is available
32867=> {
32868  tell coder as turn
32869    with access to project_memory {
32870      recall for ticket
32871      learn for ticket
32872    }
32873    with access to project_files {
32874      read ["docs/**"]
32875    }
32876  "Work it."
32877
32878  after turn succeeds as outcome {
32879    complete result { ok true }
32880  }
32881}
32882"#;
32883        let compiled = compile_program(source);
32884        let ir = compiled.ir.expect("compiles");
32885        let tell = ir
32886            .rules
32887            .iter()
32888            .flat_map(|rule| rule.metadata.effects.iter())
32889            .find(|effect| effect.kind == IrEffectKind::AgentTell)
32890            .expect("agent.tell effect");
32891        assert_eq!(tell.access_grants.len(), 2);
32892        let memory = &tell.access_grants[0];
32893        assert_eq!(memory.resource, "project_memory");
32894        assert_eq!(memory.operations.len(), 2);
32895        assert_eq!(memory.operations[0].operation, "recall");
32896        assert_eq!(memory.operations[0].target.as_deref(), Some("ticket"));
32897        let files = &tell.access_grants[1];
32898        assert_eq!(files.resource, "project_files");
32899        assert_eq!(files.operations[0].operation, "read");
32900        assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
32901    }
32902
32903    #[test]
32904    fn lowers_start_access_grants_onto_the_workflow_invoke_effect() {
32905        // `with access to <resource> { … }` on an invoke lowers to the same
32906        // `access_grants` metadata, ready for the start-grant attenuation seam.
32907        let source = r#"
32908workflow Parent {
32909  class Task { id string }
32910
32911  rule dispatch
32912    when Task as task
32913  => {
32914    invoke Child { task task }
32915      with access to project_files {
32916        read ["docs/**"]
32917      }
32918      as child
32919  }
32920}
32921
32922workflow Child {
32923  input task Task
32924  class Task { id string }
32925}
32926"#;
32927        let compiled = compile_program_with_root(source, Some("Parent"));
32928        let ir = compiled.ir.unwrap_or_else(|| {
32929            panic!(
32930                "source should compile, diagnostics: {:?}",
32931                compiled
32932                    .diagnostics
32933                    .iter()
32934                    .map(|d| &d.message)
32935                    .collect::<Vec<_>>()
32936            )
32937        });
32938        let invoke = ir
32939            .rules
32940            .iter()
32941            .flat_map(|rule| rule.metadata.effects.iter())
32942            .find(|effect| effect.kind == IrEffectKind::WorkflowInvoke)
32943            .expect("workflow.invoke effect");
32944        assert_eq!(invoke.binding.as_deref(), Some("child"));
32945        assert_eq!(invoke.access_grants.len(), 1);
32946        let files = &invoke.access_grants[0];
32947        assert_eq!(files.resource, "project_files");
32948        assert_eq!(files.operations[0].operation, "read");
32949        assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
32950    }
32951
32952    #[test]
32953    fn lowers_resource_less_start_access_grant_shorthand_onto_the_workflow_invoke_effect() {
32954        // `with access to { <resource> { ... } ... }` is syntax sugar for multiple
32955        // resource-specific start grants.
32956        let source = r#"
32957workflow Parent {
32958  class Task { id string }
32959
32960  rule dispatch
32961    when Task as task
32962  => {
32963    invoke Child { task task }
32964      with access to {
32965        project_memory {
32966          recall for task
32967        }
32968        project_files {
32969          read ["docs/**"]
32970        }
32971      }
32972      as child
32973  }
32974}
32975
32976workflow Child {
32977  input task Task
32978  class Task { id string }
32979}
32980"#;
32981        let compiled = compile_program_with_root(source, Some("Parent"));
32982        let ir = compiled.ir.unwrap_or_else(|| {
32983            panic!(
32984                "source should compile, diagnostics: {:?}",
32985                compiled
32986                    .diagnostics
32987                    .iter()
32988                    .map(|d| &d.message)
32989                    .collect::<Vec<_>>()
32990            )
32991        });
32992        let invoke = ir
32993            .rules
32994            .iter()
32995            .flat_map(|rule| rule.metadata.effects.iter())
32996            .find(|effect| effect.kind == IrEffectKind::WorkflowInvoke)
32997            .expect("workflow.invoke effect");
32998        assert_eq!(invoke.binding.as_deref(), Some("child"));
32999        assert_eq!(invoke.access_grants.len(), 2);
33000        let memory = &invoke.access_grants[0];
33001        assert_eq!(memory.resource, "project_memory");
33002        assert_eq!(memory.operations[0].operation, "recall");
33003        assert_eq!(memory.operations[0].target.as_deref(), Some("task"));
33004        let files = &invoke.access_grants[1];
33005        assert_eq!(files.resource, "project_files");
33006        assert_eq!(files.operations[0].operation, "read");
33007        assert_eq!(files.operations[0].globs, vec!["docs/**".to_owned()]);
33008    }
33009
33010    #[test]
33011    fn rejects_rule_matching_evidence_only_turn_fact() {
33012        // In-turn observations (streamed/tool_requested/artifact_captured) are
33013        // evidence, never rule-matchable (spec/agent-harness.md); a `when` on them is
33014        // an error. The lifecycle facts (completed/failed/…) stay matchable.
33015        for evidence in [
33016            "agent.turn.streamed",
33017            "agent.turn.tool_requested",
33018            "agent.turn.artifact_captured",
33019        ] {
33020            let source = format!(
33021                "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"
33022            );
33023            let compiled = compile_program(&source);
33024            assert!(compiled.ir.is_none(), "{evidence} should be rejected");
33025            assert!(
33026                compiled
33027                    .diagnostics
33028                    .iter()
33029                    .any(|d| d.message.contains("evidence-only fact")
33030                        && d.message.contains(evidence)),
33031                "{evidence}: {:?}",
33032                compiled.diagnostics
33033            );
33034        }
33035        // A matchable lifecycle fact does NOT get the evidence error (it may fail a
33036        // different check, e.g. needing a producer, but not this one).
33037        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("}}", "}");
33038        let compiled = compile_program(&matchable);
33039        assert!(
33040            !compiled
33041                .diagnostics
33042                .iter()
33043                .any(|d| d.message.contains("evidence-only fact")),
33044            "completed must not be flagged as evidence-only: {:?}",
33045            compiled.diagnostics
33046        );
33047    }
33048
33049    #[test]
33050    fn rejects_self_recursive_pattern_application() {
33051        // A pattern whose body applies itself is unbounded recursion: it can never
33052        // elaborate into a finite first-order program (graph.unbounded_pattern_recursion).
33053        let source = include_str!("../../../examples/invalid/recursive-pattern.whip");
33054        let compiled = compile_program(source);
33055
33056        assert!(compiled.ir.is_none());
33057        // Exactly the precise recursion diagnostic — the generic "nested apply not
33058        // supported yet" message must be suppressed for the recursive case.
33059        assert_eq!(compiled.diagnostics.len(), 1, "{:?}", compiled.diagnostics);
33060        let diagnostic = &compiled.diagnostics[0];
33061        assert!(
33062            diagnostic
33063                .message
33064                .contains("graph.unbounded_pattern_recursion"),
33065            "{}",
33066            diagnostic.message
33067        );
33068        assert!(
33069            diagnostic.message.contains("expansion cycle Loop -> Loop"),
33070            "the diagnostic names the cycle: {}",
33071            diagnostic.message
33072        );
33073    }
33074
33075    #[test]
33076    fn rejects_mutually_recursive_pattern_application() {
33077        // A cycle that spans two patterns is rejected once, naming the full cycle.
33078        let source = r#"
33079workflow MutualRecursion
33080
33081class Item {
33082  id string
33083}
33084
33085pattern Ping<T> {
33086  apply Pong<T> as a {
33087  }
33088}
33089
33090pattern Pong<T> {
33091  apply Ping<T> as b {
33092  }
33093}
33094
33095apply Ping<Item> as top {
33096}
33097"#;
33098        let compiled = compile_program(source);
33099
33100        assert!(compiled.ir.is_none());
33101        let recursion: Vec<&Diagnostic> = compiled
33102            .diagnostics
33103            .iter()
33104            .filter(|d| d.message.contains("graph.unbounded_pattern_recursion"))
33105            .collect();
33106        // One cycle, reported once (members covered are not re-reported).
33107        assert_eq!(recursion.len(), 1, "{:?}", compiled.diagnostics);
33108        assert!(
33109            recursion[0].message.contains("Ping -> Pong -> Ping"),
33110            "names the full cycle: {}",
33111            recursion[0].message
33112        );
33113    }
33114
33115    #[test]
33116    fn allows_non_recursive_nested_apply_without_recursion_error() {
33117        // A nested apply that does NOT form a cycle is a separate v0 limitation
33118        // (the generic "not supported yet" message), NOT a recursion error.
33119        let source = r#"
33120workflow NonRecursive
33121
33122class Item {
33123  id string
33124}
33125
33126pattern Inner<T> {
33127}
33128
33129pattern Outer<T> {
33130  apply Inner<T> as x {
33131  }
33132}
33133
33134apply Outer<Item> as top {
33135}
33136"#;
33137        let compiled = compile_program(source);
33138
33139        assert!(
33140            !compiled
33141                .diagnostics
33142                .iter()
33143                .any(|d| d.message.contains("graph.unbounded_pattern_recursion")),
33144            "non-recursive nesting must not be flagged as recursion: {:?}",
33145            compiled.diagnostics
33146        );
33147    }
33148
33149    #[test]
33150    fn rejects_unknown_or_wrong_arity_coerce_calls() {
33151        let source = r#"
33152workflow BadCoerce
33153
33154class Review {
33155  reason string
33156}
33157
33158coerce review(summary string) -> Review {
33159  prompt "review"
33160}
33161
33162rule bad
33163  when started
33164=> {
33165  coerce missing("x") as one
33166  coerce review("x", "y") as two
33167}
33168"#;
33169        let compiled = compile_program(source);
33170
33171        assert!(compiled.ir.is_none());
33172        assert_eq!(compiled.diagnostics.len(), 2);
33173        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33174            .message
33175            .contains("unknown coerce function `missing`")));
33176        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33177            .message
33178            .contains("with 2 argument(s), expected 1")));
33179    }
33180
33181    #[test]
33182    fn rejects_bad_effect_payload_argument_types() {
33183        let source = r#"
33184workflow BadEffectPayloads
33185
33186class Owner {
33187  name string
33188}
33189
33190class Payload {
33191  title string
33192  owner Owner
33193  metadata map<string>
33194  tags string[]
33195}
33196
33197class Task {
33198  title string
33199  owner string
33200}
33201
33202class Review {
33203  accepted bool
33204}
33205
33206coerce reviewPayload(payload Payload, metadata map<string>, score int) -> Review {
33207  prompt "review"
33208}
33209
33210rule bad_coerce
33211  when Task as task where { owner "Ada" } == task.owner
33212=> {
33213  coerce reviewPayload(
33214    {
33215      title task.title
33216      owner { handle task.owner }
33217      metadata { phase 3 }
33218      tags ["object", 7]
33219      extra "bad"
33220    },
33221    { phase task.owner, count 3 },
33222    "high"
33223  ) as review
33224}
33225"#;
33226        let compiled = compile_program(source);
33227
33228        assert!(compiled.ir.is_none());
33229        let messages = compiled
33230            .diagnostics
33231            .iter()
33232            .map(|diagnostic| diagnostic.message.as_str())
33233            .collect::<Vec<_>>();
33234        assert!(messages
33235            .iter()
33236            .any(|message| message.contains("object literal without an expected object")));
33237        assert!(messages
33238            .iter()
33239            .any(|message| message.contains("class `Owner` has no field `handle`")));
33240        assert!(messages
33241            .iter()
33242            .any(|message| message.contains("missing required object field `Owner.name`")));
33243        assert!(messages
33244            .iter()
33245            .any(|message| message.contains("class `Payload` has no field `extra`")));
33246        assert!(messages
33247            .iter()
33248            .any(|message| message
33249                .contains("field `coerce `reviewPayload`.metadata` expects `string`")));
33250        assert!(messages.iter().any(|message| {
33251            message.contains("field `coerce `reviewPayload`.score` expects `int`")
33252        }));
33253    }
33254
33255    #[test]
33256    fn lowers_fact_consumption_metadata() {
33257        let source = r#"
33258workflow ConsumeTask
33259
33260class Task {
33261  status "queued"
33262}
33263
33264rule finish
33265  when Task as task
33266=> {
33267  done task
33268}
33269"#;
33270        let compiled = compile_program(source);
33271        let ir = compiled.ir.expect("program compiles");
33272
33273        assert_eq!(ir.rules[0].metadata.fact_consumes, vec!["schema:Task"]);
33274        assert!(ir.to_snapshot().contains("consumes\n      schema:Task"));
33275    }
33276
33277    #[test]
33278    fn rejects_unknown_fact_consumption_binding() {
33279        let source = r#"
33280workflow BadConsume
33281
33282class Task {
33283  status "queued"
33284}
33285
33286rule finish
33287  when Task as task
33288=> {
33289  done missing
33290}
33291"#;
33292        let compiled = compile_program(source);
33293
33294        assert!(compiled.ir.is_none());
33295        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33296            .message
33297            .contains("consumes unknown fact binding `missing`")));
33298    }
33299
33300    #[test]
33301    fn rejects_then_sequencing() {
33302        let source = r#"
33303workflow NoThen
33304
33305class Task {
33306  topic string
33307  status "queued"
33308}
33309
33310class Result {
33311  topic string
33312  turn AgentTurn
33313  status "done"
33314}
33315
33316agent codex {
33317  provider codex
33318  profile "repo-writer"
33319  capacity 1
33320}
33321
33322assert count(Task where status == "queued") == 0
33323assert count(Result where status == "done") == 1
33324
33325rule finish
33326  when Task as task where task.status == "queued"
33327  when codex is available
33328=> {
33329  tell codex as turn "write"
33330  then done task -> record Result from task {
33331    topic
33332    turn turn
33333    status "done"
33334  }
33335}
33336"#;
33337        let compiled = compile_program(source);
33338        assert!(compiled.ir.is_none());
33339        assert!(compiled
33340            .diagnostics
33341            .iter()
33342            .any(|diagnostic| diagnostic.message.contains("unsupported `then` sequencing")));
33343    }
33344
33345    #[test]
33346    fn rejects_after_arrow_sequencing() {
33347        let source = r#"
33348workflow NoAfterArrow
33349
33350agent codex {
33351  provider codex
33352  profile "repo-writer"
33353  capacity 1
33354}
33355
33356rule finish
33357  when started
33358  when codex is available
33359=> {
33360  tell codex as turn "write"
33361
33362  after turn succeeds => {
33363    record Done {
33364      status "done"
33365    }
33366  }
33367}
33368"#;
33369        let compiled = compile_program(source);
33370        assert!(compiled.ir.is_none());
33371        assert!(compiled.diagnostics.iter().any(|diagnostic| diagnostic
33372            .message
33373            .contains("unsupported `after ... =>` sequencing")));
33374    }
33375
33376    #[test]
33377    fn formats_top_level_syntax_scaffold() {
33378        let source = r#"workflow Messy
33379class Status {
33380kind "open"|"done"
33381}
33382rule start
33383when started
33384=> {tell worker "hi"}
33385"#;
33386
33387        let formatted = format_program(source);
33388        assert_eq!(formatted.diagnostics, Vec::new());
33389        let expected = concat!(
33390            "workflow Messy\n",
33391            "\n",
33392            "class Status {\n",
33393            "  kind \"open\" | \"done\"\n",
33394            "}\n",
33395            "\n",
33396            "rule start\n",
33397            "  when started\n",
33398            "=> {\n",
33399            "  tell worker \"hi\"\n",
33400            "}\n",
33401        );
33402
33403        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33404    }
33405
33406    #[test]
33407    fn formats_content_typed_multiline_prompts() {
33408        let source = r#"workflow PromptFormat
33409class Review {
33410status "ok"
33411}
33412coerce review() -> Review {
33413prompt """markdown
33414classify
33415"""
33416}
33417agent worker {
33418  provider fixture
33419profile "repo-writer"
33420capacity 1
33421}
33422rule start
33423when started
33424=> {tell worker as turn """markdown
33425write
33426"""
33427tell worker """application/json
33428{"question":"approve?"}
33429"""}
33430"#;
33431
33432        let formatted = format_program(source);
33433        assert_eq!(formatted.diagnostics, Vec::new());
33434        let expected = concat!(
33435            "workflow PromptFormat\n",
33436            "\n",
33437            "class Review {\n",
33438            "  status \"ok\"\n",
33439            "}\n",
33440            "\n",
33441            "coerce review() -> Review {\n",
33442            "  prompt \"\"\"markdown\n",
33443            "  classify\n",
33444            "  \"\"\"\n",
33445            "}\n",
33446            "\n",
33447            "agent worker {\n",
33448            "  provider fixture\n",
33449            "  profile \"repo-writer\"\n",
33450            "  capacity 1\n",
33451            "}\n",
33452            "\n",
33453            "rule start\n",
33454            "  when started\n",
33455            "=> {\n",
33456            "  tell worker as turn \"\"\"markdown\n",
33457            "  write\n",
33458            "  \"\"\"\n",
33459            "  tell worker \"\"\"application/json\n",
33460            "  {\"question\":\"approve?\"}\n",
33461            "  \"\"\"\n",
33462            "}\n",
33463        );
33464
33465        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33466    }
33467
33468    #[test]
33469    fn formats_harness_declarations_and_agent_bindings() {
33470        let source = r#"workflow HarnessFormat
33471harness coder: codex
33472agent implementer using coder {
33473profile "repo-writer"
33474capacity 1
33475}
33476"#;
33477
33478        let formatted = format_program(source);
33479        assert_eq!(formatted.diagnostics, Vec::new());
33480        let expected = concat!(
33481            "workflow HarnessFormat\n",
33482            "\n",
33483            "harness coder: codex\n",
33484            "\n",
33485            "agent implementer using coder {\n",
33486            "  profile \"repo-writer\"\n",
33487            "  capacity 1\n",
33488            "}\n",
33489        );
33490
33491        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33492    }
33493
33494    #[test]
33495    fn formats_explicit_workflow_blocks() {
33496        let source = r#"class Shared {
33497id string
33498}
33499workflow One {
33500input item Shared
33501rule start
33502when Shared as item
33503=> {complete result {id item.id}}
33504}
33505"#;
33506
33507        let formatted = format_program(source);
33508        assert_eq!(formatted.diagnostics, Vec::new());
33509        let expected = concat!(
33510            "class Shared {\n",
33511            "  id string\n",
33512            "}\n",
33513            "\n",
33514            "workflow One {\n",
33515            "  input item Shared\n",
33516            "\n",
33517            "  rule start\n",
33518            "    when Shared as item\n",
33519            "  => {\n",
33520            "    complete result {id item.id}\n",
33521            "  }\n",
33522            "}\n",
33523        );
33524
33525        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33526    }
33527
33528    #[test]
33529    fn formats_invoke_start_access_grants() {
33530        let source = r#"workflow Parent {
33531file store project_files { root "./data" allow read ["docs/**"] allow write ["reports/**"] }
33532class Task { id string }
33533rule dispatch
33534when Task as task
33535=> {
33536invoke Child {
33537task task
33538}
33539with access to project_files {
33540read ["docs/**"]
33541write ["reports/**"]
33542}
33543as child
33544}
33545}
33546
33547workflow Child {
33548input task Task
33549class Task { id string }
33550}
33551"#;
33552
33553        let formatted = format_program(source);
33554        assert_eq!(formatted.diagnostics, Vec::new());
33555        let expected = concat!(
33556            "workflow Parent {\n",
33557            "  file store project_files {\n",
33558            "    root \"./data\"\n",
33559            "    allow read [\"docs/**\"]\n",
33560            "    allow write [\"reports/**\"]\n",
33561            "  }\n",
33562            "\n",
33563            "  class Task {\n",
33564            "    id string\n",
33565            "  }\n",
33566            "\n",
33567            "  rule dispatch\n",
33568            "    when Task as task\n",
33569            "  => {\n",
33570            "    invoke Child {\n",
33571            "      task task\n",
33572            "    }\n",
33573            "    with access to project_files {\n",
33574            "      read [\"docs/**\"]\n",
33575            "      write [\"reports/**\"]\n",
33576            "    }\n",
33577            "    as child\n",
33578            "  }\n",
33579            "}\n",
33580            "\n",
33581            "workflow Child {\n",
33582            "  input task Task\n",
33583            "\n",
33584            "  class Task {\n",
33585            "    id string\n",
33586            "  }\n",
33587            "}\n",
33588        );
33589
33590        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33591    }
33592
33593    #[test]
33594    fn formats_patterns_and_apply_syntax() {
33595        let source = r#"pattern Review<Input>{
33596rule dispatch
33597when Input as item
33598=> {}
33599}
33600workflow Root {
33601apply Review<Task> as taskReview {}
33602}
33603"#;
33604
33605        let formatted = format_program(source);
33606        assert_eq!(formatted.diagnostics, Vec::new());
33607        let expected = concat!(
33608            "pattern Review<Input> {\n",
33609            "  rule dispatch\n",
33610            "    when Input as item\n",
33611            "  => {\n",
33612            "  }\n",
33613            "}\n",
33614            "\n",
33615            "workflow Root {\n",
33616            "  apply Review<Task> as taskReview {\n",
33617            "  }\n",
33618            "}\n",
33619        );
33620
33621        assert_eq!(formatted.formatted.as_deref(), Some(expected));
33622    }
33623
33624    #[test]
33625    fn lexer_captures_comments_without_affecting_tokens() {
33626        let source =
33627            "# top comment\nworkflow Demo\n\nclass Task {\n  title string  // trailing\n}\n";
33628        let comments = lex_comments(source);
33629        assert_eq!(comments.len(), 2);
33630        assert_eq!(comments[0].marker, CommentMarker::Hash);
33631        assert_eq!(comments[0].text, "top comment");
33632        assert_eq!(comments[1].marker, CommentMarker::Slash);
33633        assert_eq!(comments[1].text, "trailing");
33634        // Spans point back at the original source slice (marker through line end).
33635        let first = &comments[0];
33636        assert_eq!(&source[first.span.start..first.span.end], "# top comment");
33637        // Comments stay out of the parse: the program still compiles cleanly.
33638        let compiled = compile_program(source);
33639        assert_eq!(compiled.diagnostics, Vec::new());
33640    }
33641
33642    #[test]
33643    fn test_block_parses_given_run_and_expect_clauses() {
33644        let source = r#"
33645@service
33646workflow Demo
33647
33648test "ci triage" {
33649  given signal github.workflow_failed {
33650    run_id "run_123"
33651  }
33652  stub agent triager succeeds
33653  run until idle
33654  expect issue count where external_id == "run_123" is 1
33655  expect rule triage_failed_run fired
33656}
33657"#;
33658        let compiled = compile_program(source);
33659        assert_eq!(compiled.diagnostics, Vec::new());
33660        let ir = compiled.ir.expect("program compiles");
33661        assert_eq!(ir.tests.len(), 1);
33662        let test = &ir.tests[0];
33663        assert_eq!(test.name, "ci triage");
33664        assert_eq!(test.clauses.len(), 5);
33665
33666        match &test.clauses[0] {
33667            TestClause::Given(GivenClause::Signal { name, fields, .. }) => {
33668                assert_eq!(name, "github.workflow_failed");
33669                assert_eq!(fields.len(), 1);
33670                assert_eq!(fields[0].name.name, "run_id");
33671                assert_eq!(fields[0].value, "\"run_123\"");
33672            }
33673            other => panic!("expected given signal, got {other:?}"),
33674        }
33675        match &test.clauses[1] {
33676            TestClause::Stub(stub) => {
33677                assert_eq!(stub.surface, vec!["agent".to_owned(), "triager".to_owned()]);
33678                assert_eq!(stub.outcome, "succeeds");
33679            }
33680            other => panic!("expected stub, got {other:?}"),
33681        }
33682        assert!(matches!(
33683            &test.clauses[2],
33684            TestClause::Run(RunClause {
33685                kind: RunKind::UntilIdle,
33686                ..
33687            })
33688        ));
33689        match &test.clauses[3] {
33690            TestClause::Expect(ExpectClause {
33691                target: ExpectTarget::Projection(query),
33692                ..
33693            }) => {
33694                assert_eq!(query.noun, "issue");
33695                match &query.kind {
33696                    ProjQueryKind::Count { predicate, count } => {
33697                        assert_eq!(predicate, "external_id == \"run_123\"");
33698                        assert_eq!(*count, 1);
33699                    }
33700                    other => panic!("expected count query, got {other:?}"),
33701                }
33702            }
33703            other => panic!("expected expect projection, got {other:?}"),
33704        }
33705        match &test.clauses[4] {
33706            TestClause::Expect(ExpectClause {
33707                target: ExpectTarget::Rule { name, status },
33708                ..
33709            }) => {
33710                assert_eq!(name.name, "triage_failed_run");
33711                assert_eq!(*status, RuleStatus::Fired);
33712            }
33713            other => panic!("expected expect rule, got {other:?}"),
33714        }
33715    }
33716
33717    #[test]
33718    fn test_block_rejects_a_malformed_predicate() {
33719        let source = r#"
33720@service
33721workflow Demo
33722
33723test "bad predicate" {
33724  run until idle
33725  expect issue count where == == is 1
33726}
33727"#;
33728        let compiled = compile_program(source);
33729        assert!(
33730            compiled
33731                .diagnostics
33732                .iter()
33733                .any(|diagnostic| diagnostic.message.contains("predicate on `issue`")),
33734            "{:?}",
33735            compiled.diagnostics
33736        );
33737    }
33738
33739    #[test]
33740    fn source_clock_block_lowers_to_clock_source() {
33741        let source = r#"
33742workflow ClockSource
33743
33744signal triage.tick {
33745  scheduled_at time
33746  observed_at time
33747  occurrence_id string
33748  missed_count int
33749}
33750
33751source clock as daily_triage {
33752  every weekday at 09:00
33753  timezone "America/New_York"
33754  missed coalesce
33755
33756  observe as tick
33757  emit triage.tick {
33758    scheduled_at tick.scheduled_at
33759    observed_at tick.observed_at
33760    occurrence_id tick.occurrence_id
33761    missed_count tick.missed_count
33762  }
33763}
33764"#;
33765        let compiled = compile_program(source);
33766        assert_eq!(compiled.diagnostics, Vec::new());
33767        let ir = compiled.ir.expect("program compiles");
33768        assert_eq!(ir.sources.len(), 1);
33769        let decl = &ir.sources[0];
33770        assert_eq!(decl.name, "daily_triage");
33771        assert_eq!(decl.provider, "clock");
33772        assert!(decl.is_clock);
33773        assert_eq!(decl.observe_binding, "tick");
33774        assert_eq!(decl.emit_signal, "triage.tick");
33775        assert_eq!(decl.emit_fields.len(), 4);
33776        assert_eq!(decl.timezone.as_deref(), Some("America/New_York"));
33777        assert_eq!(decl.missed, Some(MissedPolicy::Coalesce));
33778        match &decl.recurrence {
33779            Some(Recurrence::EveryCalendar { pattern, time, .. }) => {
33780                assert_eq!(*pattern, CalendarPattern::Weekday);
33781                assert_eq!(time.hour, 9);
33782                assert_eq!(time.minute, 0);
33783            }
33784            other => panic!("expected calendar recurrence, got {other:?}"),
33785        }
33786        // T1 (spec/std-time.md): declaring a clock source registers the
33787        // std.time standard library, exactly as a channel declaration
33788        // registers std.messaging.
33789        let registry = ir.contract_registry();
33790        assert!(
33791            registry
33792                .libraries
33793                .iter()
33794                .any(|library| library.id == "std.time" && library.standard),
33795            "clock source registers std.time: {:?}",
33796            registry.libraries
33797        );
33798    }
33799
33800    #[test]
33801    fn gauge_and_campaign_declarations_parse_and_lower() {
33802        let source = r##"
33803@service
33804workflow Improve
33805
33806output result R
33807class R { v string }
33808signal go.now { x string }
33809
33810coerce DueDateJudge(v string) -> R {
33811  prompt """markdown
33812  Judge {{ v }}.
33813
33814  {{ ctx.output_format }}
33815  """
33816}
33817
33818gauge extract_quality on j.result {
33819  judge via coerce DueDateJudge
33820  expect P(due_date_correct) at least 0.9
33821}
33822
33823gauge tail_latency {
33824  judge via exec "./latency_check.py"
33825  expect p90 at most 800
33826}
33827
33828gauge fulfillment_cost {
33829  judge via exec "./cost_model.py"
33830  inputs extract_quality, std.spend
33831}
33832
33833campaign release_tuning {
33834  ascend extract_quality
33835  reach std.latency at most 800ms
33836  guard tail_latency within 2 percent
33837  sacrifice fulfillment_cost
33838  proposer redacted
33839}
33840
33841rule j
33842  when go.now as g
33843=> {
33844  complete result {
33845    v "ok"
33846  }
33847}
33848"##;
33849        let compiled = compile_program(source);
33850        assert_eq!(compiled.diagnostics, Vec::new());
33851        let ir = compiled.ir.expect("program compiles");
33852        assert_eq!(ir.gauges.len(), 3);
33853        let extract = &ir.gauges[0];
33854        assert_eq!(extract.name, "extract_quality");
33855        assert_eq!(extract.site.as_deref(), Some("j.result"));
33856        assert_eq!(extract.judge_kind, "coerce");
33857        assert_eq!(extract.judge_target, "DueDateJudge");
33858        let bar = extract.expect.as_ref().expect("bar declared");
33859        assert_eq!(
33860            (
33861                bar.form.as_str(),
33862                bar.subject.as_str(),
33863                bar.op.as_str(),
33864                bar.threshold.as_str()
33865            ),
33866            ("chance", "due_date_correct", ">=", "0.9")
33867        );
33868        let tail = &ir.gauges[1];
33869        let tail_bar = tail.expect.as_ref().expect("stat bar declared");
33870        assert_eq!(
33871            (
33872                tail_bar.form.as_str(),
33873                tail_bar.subject.as_str(),
33874                tail_bar.op.as_str()
33875            ),
33876            ("stat", "p90", "<=")
33877        );
33878        let derived = &ir.gauges[2];
33879        assert_eq!(derived.judge_kind, "exec");
33880        assert_eq!(derived.inputs, vec!["extract_quality", "std.spend"]);
33881        assert_eq!(ir.campaigns.len(), 1);
33882        let campaign = &ir.campaigns[0];
33883        assert_eq!(campaign.ascend, vec!["extract_quality"]);
33884        assert_eq!(campaign.reach.len(), 1);
33885        assert_eq!(campaign.reach[0].gauge, "std.latency");
33886        assert_eq!(campaign.reach[0].op, "<=");
33887        assert_eq!(campaign.reach[0].threshold, "800");
33888        assert_eq!(campaign.reach[0].unit.as_deref(), Some("ms"));
33889        assert_eq!(campaign.guard[0].gauge, "tail_latency");
33890        assert_eq!(campaign.guard[0].band_percent, "2");
33891        assert_eq!(campaign.sacrifice, vec!["fulfillment_cost"]);
33892        assert!(campaign.proposer_redacted);
33893        let snapshot = ir.to_snapshot();
33894        assert!(snapshot.contains("gauge extract_quality judge=coerce:DueDateJudge site=j.result expect=chance:due_date_correct>=0.9"));
33895        assert!(snapshot.contains(
33896            "campaign release_tuning ascend=extract_quality reach=std.latency<=800ms guard=tail_latency:within:2% sacrifice=fulfillment_cost proposer=redacted"
33897        ));
33898    }
33899
33900    #[test]
33901    fn mark_declaration_parses_lowers_and_validates() {
33902        let source = r##"
33903@service
33904workflow Improve
33905
33906output result R
33907class R { v string }
33908signal go.now { x string }
33909
33910mark "triaged" after j
33911
33912rule j
33913  when go.now as g
33914=> {
33915  complete result {
33916    v "ok"
33917  }
33918}
33919"##;
33920        let compiled = compile_program(source);
33921        assert_eq!(compiled.diagnostics, Vec::new());
33922        let ir = compiled.ir.expect("program compiles");
33923        assert_eq!(ir.marks.len(), 1);
33924        assert_eq!(ir.marks[0].name, "triaged");
33925        assert_eq!(ir.marks[0].site, "j");
33926        assert!(ir.to_snapshot().contains("mark \"triaged\" after j"));
33927        // An unknown site is a diagnostic.
33928        let unknown = compile_program(&source.replace(
33929            "mark \"triaged\" after j",
33930            "mark \"nowhere\" after missing_rule",
33931        ));
33932        assert!(unknown.diagnostics.iter().any(|d| d
33933            .message
33934            .contains("mark `nowhere` rides unknown site `missing_rule`")));
33935        // Duplicate names are rejected.
33936        let dup = compile_program(&source.replace(
33937            "mark \"triaged\" after j",
33938            "mark \"triaged\" after j\nmark \"triaged\" after j",
33939        ));
33940        assert!(dup.diagnostics.iter().any(|d| d
33941            .message
33942            .contains("mark `triaged` is declared more than once")));
33943        // Formatting is idempotent.
33944        let formatted = format_program(source).formatted.expect("formats");
33945        assert!(formatted.contains("mark \"triaged\" after j"));
33946        assert_eq!(
33947            format_program(&formatted).formatted.expect("reformats"),
33948            formatted
33949        );
33950    }
33951
33952    #[test]
33953    fn coerce_judge_explicit_arguments_parse_lower_and_validate() {
33954        let program = |judge_line: &str| {
33955            format!(
33956                r##"
33957@service
33958workflow Improve
33959
33960output result R
33961class R {{ v string }}
33962class Ticket {{ title string }}
33963signal go.now {{ x string }}
33964
33965coerce Assess(title string, priority string) -> R {{
33966  prompt """markdown
33967  Judge {{{{ title }}}} at {{{{ priority }}}}.
33968
33969  {{{{ ctx.output_format }}}}
33970  """
33971}}
33972
33973gauge quality {{
33974  {judge_line}
33975}}
33976
33977rule j
33978  when go.now as g
33979=> {{
33980  complete result {{
33981    v "ok"
33982  }}
33983}}
33984"##
33985            )
33986        };
33987        // Explicit paths parse, lower in order, and roundtrip through fmt.
33988        let source =
33989            program("judge via coerce Assess(input.ticket.title, facts.Assessment.priority)");
33990        let compiled = compile_program(&source);
33991        assert_eq!(compiled.diagnostics, Vec::new());
33992        let ir = compiled.ir.expect("compiles");
33993        assert_eq!(
33994            ir.gauges[0].judge_args,
33995            vec!["input.ticket.title", "facts.Assessment.priority"]
33996        );
33997        let formatted = format_program(&source).formatted.expect("formats");
33998        assert!(
33999            formatted
34000                .contains("judge via coerce Assess(input.ticket.title, facts.Assessment.priority)"),
34001            "fmt keeps the binding: {formatted}"
34002        );
34003        // Arity is checked: a drifted signature is a check error, never a
34004        // silently rebound judge.
34005        let compiled = compile_program(&program("judge via coerce Assess(input.ticket.title)"));
34006        assert!(
34007            compiled
34008                .diagnostics
34009                .iter()
34010                .any(|diagnostic| diagnostic.message.contains("passes 1 argument")),
34011            "{:?}",
34012            compiled.diagnostics
34013        );
34014        // Paths must be record paths.
34015        let compiled = compile_program(&program(
34016            "judge via coerce Assess(whatever.title, facts.Assessment.priority)",
34017        ));
34018        assert!(
34019            compiled
34020                .diagnostics
34021                .iter()
34022                .any(|diagnostic| diagnostic.message.contains("not a record path")),
34023            "{:?}",
34024            compiled.diagnostics
34025        );
34026        // The reserved `(record)` form needs a single-parameter coerce.
34027        let compiled = compile_program(&program("judge via coerce Assess(record)"));
34028        assert!(
34029            compiled
34030                .diagnostics
34031                .iter()
34032                .any(|diagnostic| diagnostic.message.contains("single-parameter")),
34033            "{:?}",
34034            compiled.diagnostics
34035        );
34036        // Bare (no arguments) still parses — declared, honestly unscoreable.
34037        let compiled = compile_program(&program("judge via coerce Assess"));
34038        assert_eq!(compiled.diagnostics, Vec::new());
34039        assert!(compiled.ir.expect("compiles").gauges[0]
34040            .judge_args
34041            .is_empty());
34042    }
34043
34044    #[test]
34045    fn gauge_and_campaign_cross_reference_validation() {
34046        let source = r##"
34047@service
34048workflow Improve
34049
34050output result R
34051class R { v string }
34052signal go.now { x string }
34053
34054gauge broken_judge {
34055  judge via coerce MissingJudge
34056}
34057
34058gauge broken_inputs {
34059  judge via prompt "score this"
34060  inputs nowhere
34061}
34062
34063campaign confused {
34064  ascend broken_judge
34065  sacrifice broken_judge
34066}
34067
34068campaign unknown_ref {
34069  ascend nowhere_else
34070}
34071
34072rule j
34073  when go.now as g
34074=> {
34075  complete result {
34076    v "ok"
34077  }
34078}
34079"##;
34080        let compiled = compile_program(source);
34081        let messages: Vec<String> = compiled
34082            .diagnostics
34083            .iter()
34084            .map(|diagnostic| diagnostic.message.clone())
34085            .collect();
34086        assert!(messages
34087            .iter()
34088            .any(|m| m.contains("judges via undeclared coerce `MissingJudge`")));
34089        assert!(messages
34090            .iter()
34091            .any(|m| m.contains("derived gauge `broken_inputs` must judge via exec")));
34092        assert!(messages
34093            .iter()
34094            .any(|m| m.contains("unknown gauge `nowhere`")));
34095        assert!(messages
34096            .iter()
34097            .any(|m| m.contains("unknown gauge `nowhere_else`")));
34098        assert!(messages
34099            .iter()
34100            .any(|m| m.contains("names gauge `broken_judge` as both ascend and sacrifice")));
34101    }
34102
34103    #[test]
34104    fn campaign_naming_nothing_is_rejected_at_parse() {
34105        let source = r##"
34106@service
34107workflow Improve
34108
34109output result R
34110class R { v string }
34111signal go.now { x string }
34112
34113campaign nothing_named {
34114  guard std.spend within 5 percent
34115}
34116
34117rule j
34118  when go.now as g
34119=> {
34120  complete result {
34121    v "ok"
34122  }
34123}
34124"##;
34125        let compiled = compile_program(source);
34126        assert!(compiled.diagnostics.iter().any(|d| d
34127            .message
34128            .contains("campaign `nothing_named` names nothing to improve")));
34129    }
34130
34131    #[test]
34132    fn gauge_bar_operator_gets_word_form_diagnostic() {
34133        let source = r##"
34134@service
34135workflow Improve
34136
34137output result R
34138class R { v string }
34139signal go.now { x string }
34140
34141gauge extract_quality {
34142  judge via exec "./judge.py"
34143  expect P(ok) >= 0.9
34144}
34145
34146rule j
34147  when go.now as g
34148=> {
34149  complete result {
34150    v "ok"
34151  }
34152}
34153"##;
34154        let compiled = compile_program(source);
34155        assert!(compiled.diagnostics.iter().any(|diagnostic| {
34156            diagnostic
34157                .suggestion
34158                .as_deref()
34159                .is_some_and(|s| s.contains("write `at least`"))
34160        }));
34161    }
34162
34163    #[test]
34164    fn formats_gauge_and_campaign_declarations() {
34165        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";
34166        let formatted = format_program(source);
34167        assert_eq!(formatted.diagnostics, Vec::new());
34168        let once = formatted.formatted.expect("formats");
34169        assert!(once.contains("gauge extract_quality on j.result {"));
34170        assert!(once.contains("  judge via exec \"./judge.py\""));
34171        assert!(once.contains("  expect P(ok) at least 0.9"));
34172        assert!(once.contains("campaign release_tuning {"));
34173        assert!(once.contains("  reach std.latency at most 800ms"));
34174        assert!(once.contains("  guard std.tokens within 2 percent"));
34175        assert!(once.contains("  proposer redacted"));
34176        let twice = format_program(&once).formatted.expect("reformats");
34177        assert_eq!(once, twice, "gauge/campaign formatting is idempotent");
34178    }
34179
34180    #[test]
34181    fn channel_declaration_parses_and_lowers() {
34182        let source = r##"
34183@service
34184workflow ChannelDecl
34185
34186use std.messaging
34187
34188channel release_room {
34189  provider fixture
34190  workspace ops
34191  destination "#release"
34192}
34193
34194output result R
34195class R { v string }
34196signal go.now { x string }
34197
34198rule j
34199  when go.now as g
34200=> {
34201  complete result {
34202    v "ok"
34203  }
34204}
34205"##;
34206        let compiled = compile_program(source);
34207        assert_eq!(compiled.diagnostics, Vec::new());
34208        let ir = compiled.ir.expect("program compiles");
34209        assert_eq!(ir.channels.len(), 1);
34210        let channel = &ir.channels[0];
34211        assert_eq!(channel.name, "release_room");
34212        assert_eq!(channel.provider, "fixture");
34213        assert_eq!(channel.workspace.as_deref(), Some("ops"));
34214        assert_eq!(channel.destination.as_deref(), Some("#release"));
34215        // The channel construct auto-registers std.messaging in the contract
34216        // registry (like leases -> std.coord), and `use std.messaging` parses as
34217        // a dotted package name.
34218        let registry = ir.contract_registry();
34219        assert!(registry
34220            .libraries
34221            .iter()
34222            .any(|library| library.id == "std.messaging"));
34223        // The generic `Message` envelope is a built-in referenceable schema.
34224        assert!(SchemaIndex::with_builtins().class_exists("Message"));
34225        // The `send` construct's typed receipt (the messaging.send contract's
34226        // output schema) is a built-in class so `send … as r` binds a typed r.
34227        assert!(SchemaIndex::with_builtins().class_exists("MessageSendReceipt"));
34228    }
34229
34230    #[test]
34231    fn single_line_multi_field_terminal_payload_collects_every_field() {
34232        // R5 papercut: `complete result { first "a" second "b" }` on ONE line
34233        // must satisfy the required-field check for both fields (token-level
34234        // splitting, not line-based).
34235        let source = r#"
34236workflow OneLine
34237
34238output result Done
34239
34240class Done {
34241  first string
34242  second string
34243}
34244
34245rule r
34246  when started
34247=> {
34248  complete result { first "a" second "b" }
34249}
34250"#;
34251        let compiled = compile_program(source);
34252        assert_eq!(compiled.diagnostics, Vec::new());
34253        assert!(compiled.ir.is_some());
34254    }
34255
34256    #[test]
34257    fn file_store_is_read_only_by_default() {
34258        // S4: a store with no `allow write` policy rejects writes at check
34259        // time; reads need no clause; `allow write` permits and bounds writes.
34260        let program = |allow: &str| {
34261            format!(
34262                r#"
34263use std.files
34264
34265workflow Posture
34266
34267output result Done
34268
34269class Done {{
34270  note string
34271}}
34272
34273file store docs {{
34274  root "./docs"
34275{allow}}}
34276
34277rule r
34278  when started
34279=> {{
34280  write text to docs at "out.txt" {{
34281    body "x"
34282    mode create
34283  }} as out
34284
34285  after out completes {{
34286    complete result {{ note "done" }}
34287  }}
34288}}
34289"#
34290            )
34291        };
34292        let denied = compile_program(&program(""));
34293        assert!(
34294            denied
34295                .diagnostics
34296                .iter()
34297                .any(|d| d.message.contains("permits no writes")),
34298            "{:?}",
34299            denied.diagnostics
34300        );
34301        let allowed = compile_program(&program("  allow write [\"**\"]\n"));
34302        assert_eq!(allowed.diagnostics, Vec::new());
34303
34304        // Reads against a bare store stay clean.
34305        let read_only = compile_program(
34306            r#"
34307use std.files
34308
34309workflow ReadOnly
34310
34311output result Done
34312
34313class Done {
34314  note string
34315}
34316
34317file store docs {
34318  root "./docs"
34319}
34320
34321rule r
34322  when started
34323=> {
34324  read text from docs at "in.txt" as doc
34325
34326  after doc completes {
34327    complete result { note "done" }
34328  }
34329}
34330"#,
34331        );
34332        assert_eq!(read_only.diagnostics, Vec::new());
34333    }
34334
34335    #[test]
34336    fn tracker_bare_defaults_provider_to_builtin() {
34337        // S1 (surface-defaults batch): `tracker <name>` bare — no block —
34338        // defaults the provider to `builtin`.
34339        let source = r#"
34340@service
34341workflow TrackerBare
34342
34343tracker backlog
34344
34345class Item { id string }
34346signal go.now { x string }
34347rule j
34348  when go.now as g
34349=> {
34350  file issue into backlog {
34351    title g.x
34352  }
34353}
34354"#;
34355        let compiled = compile_program(source);
34356        let ir = compiled.ir.expect("compiles");
34357        assert_eq!(ir.trackers.len(), 1);
34358        assert_eq!(ir.trackers[0].provider, "builtin");
34359    }
34360
34361    #[test]
34362    fn emit_signal_from_projects_bounded_fields() {
34363        // S6: `emit signal <name> to <target> from <binding>` parses (block
34364        // optional; an override block allows shorthand), mirroring
34365        // `record … from`.
34366        let source = r#"
34367use std.ingress
34368
34369@service
34370workflow EmitFrom
34371
34372signal deploy.finished {
34373  service string
34374  peer string
34375}
34376
34377signal deploy.acknowledged {
34378  service string
34379}
34380
34381rule relay
34382  when deploy.finished as deployed
34383=> {
34384  emit signal deploy.acknowledged to deployed.peer from deployed as sent
34385}
34386"#;
34387        let compiled = compile_program(source);
34388        assert_eq!(compiled.diagnostics, Vec::new());
34389        assert!(compiled.ir.is_some());
34390    }
34391
34392    #[test]
34393    fn inline_contract_payload_synthesizes_anonymous_class() {
34394        // S7: `output result { message string }` synthesizes the hygienic
34395        // `output.result` class (the `decide` precedent); the terminal payload
34396        // validates against it.
34397        let source = r#"
34398workflow Inline
34399
34400output result {
34401  message string
34402}
34403
34404failure error {
34405  reason string
34406}
34407
34408rule r
34409  when started
34410=> {
34411  complete result {
34412    message "hello"
34413  }
34414}
34415"#;
34416        let compiled = compile_program(source);
34417        assert_eq!(compiled.diagnostics, Vec::new());
34418        let ir = compiled.ir.expect("compiles");
34419        assert!(ir.schemas.iter().any(|schema| matches!(
34420            schema,
34421            IrSchema::Class(class) if class.name == "output.result"
34422        )));
34423        assert!(ir.schemas.iter().any(|schema| matches!(
34424            schema,
34425            IrSchema::Class(class) if class.name == "failure.error"
34426        )));
34427
34428        // A bad payload field is still a check error against the synthesized class.
34429        let bad = compile_program(
34430            r#"
34431workflow InlineBad
34432
34433output result {
34434  message string
34435}
34436
34437rule r
34438  when started
34439=> {
34440  complete result {
34441    wrong "hello"
34442  }
34443}
34444"#,
34445        );
34446        assert!(
34447            !bad.diagnostics.is_empty(),
34448            "unknown field on the synthesized class must be rejected"
34449        );
34450    }
34451
34452    #[test]
34453    fn channel_defaults_provider_to_local() {
34454        // S2 (surface-defaults batch): a channel without a `provider` clause —
34455        // block or bare — defaults to `local`.
34456        let source = r#"
34457use std.messaging
34458use std.ingress
34459
34460@service
34461workflow ChannelDefault
34462
34463channel orphan {
34464  workspace ops
34465}
34466
34467channel bare
34468
34469output result R
34470class R { v string }
34471signal go.now { x string }
34472rule j
34473  when go.now as g
34474=> { complete result { v "ok" } }
34475"#;
34476        let compiled = compile_program(source);
34477        assert_eq!(compiled.diagnostics, Vec::new());
34478        let ir = compiled.ir.expect("compiles");
34479        assert!(
34480            ir.channels
34481                .iter()
34482                .all(|channel| channel.provider == "local"),
34483            "{:?}",
34484            ir.channels
34485        );
34486        assert_eq!(ir.channels.len(), 2);
34487    }
34488
34489    #[test]
34490    fn duplicate_channel_is_rejected() {
34491        let source = r#"
34492@service
34493workflow DupChannel
34494
34495channel room {
34496  provider fixture
34497}
34498channel room {
34499  provider discord
34500}
34501
34502output result R
34503class R { v string }
34504signal go.now { x string }
34505rule j
34506  when go.now as g
34507=> { complete result { v "ok" } }
34508"#;
34509        let compiled = compile_program(source);
34510        let dup = compiled
34511            .diagnostics
34512            .iter()
34513            .find(|d| d.message.contains("declared more than once"))
34514            .expect("expected duplicate-channel diagnostic");
34515        // The diagnostic carries related-information pointing at the first
34516        // declaration (spec/error-handling.md "Spans And Labels").
34517        assert_eq!(dup.related.len(), 1, "expected one related-info entry");
34518        assert_eq!(dup.related[0].message, "first declared here");
34519        assert!(dup.related[0].span.start < dup.span.start);
34520    }
34521
34522    #[test]
34523    fn when_message_from_binds_message_and_validates_channel() {
34524        // `when message from <channel> as msg` binds the built-in `Message`
34525        // envelope and the channel must be declared (spec/messaging.md).
34526        let ok = compile_program(
34527            r#"
34528@service
34529workflow Inbound
34530
34531channel release_room {
34532  provider fixture
34533}
34534
34535output result Decision
34536class Decision { note string }
34537
34538rule react
34539  when message from release_room as msg
34540=> {
34541  complete result { note msg.text }
34542}
34543"#,
34544        );
34545        assert!(
34546            ok.diagnostics.is_empty(),
34547            "expected clean compile, got {:?}",
34548            ok.diagnostics
34549        );
34550        // `msg.text` resolving against the Message schema proves the binding typed.
34551
34552        let bad = compile_program(
34553            r#"
34554@service
34555workflow Inbound
34556
34557channel release_room {
34558  provider fixture
34559}
34560
34561output result Decision
34562class Decision { note string }
34563
34564rule react
34565  when message from typo_room as msg
34566=> {
34567  complete result { note msg.text }
34568}
34569"#,
34570        );
34571        assert!(
34572            bad.diagnostics.iter().any(|d| d
34573                .message
34574                .contains("`when message from typo_room` names an unknown channel")),
34575            "expected unknown-channel diagnostic, got {:?}",
34576            bad.diagnostics
34577        );
34578    }
34579
34580    #[test]
34581    fn unknown_channel_provider_is_a_check_error() {
34582        // spec/std-messaging.md "Static checks": a channel `provider`
34583        // identifier must resolve to a v1 provider capability report; a
34584        // free-form name (`slack` shipped in old demos) is a check error.
34585        let compiled = compile_program(
34586            r##"
34587@service
34588workflow UnknownProvider
34589
34590channel ops_room {
34591  provider slack
34592  destination "#ops"
34593}
34594
34595output result R
34596class R { v string }
34597signal go.now { x string }
34598rule j
34599  when go.now as g
34600=> { complete result { v "ok" } }
34601"##,
34602        );
34603        let unknown = compiled
34604            .diagnostics
34605            .iter()
34606            .find(|d| {
34607                d.message
34608                    .contains("channel `ops_room` names unknown messaging provider `slack`")
34609            })
34610            .expect("expected unknown-provider diagnostic");
34611        assert!(
34612            unknown
34613                .suggestion
34614                .as_deref()
34615                .is_some_and(|s| s.contains("fixture") && s.contains("desktop")),
34616            "suggestion lists the v1 providers: {:?}",
34617            unknown.suggestion
34618        );
34619    }
34620
34621    #[test]
34622    fn desktop_channel_is_outbound_only_at_check_time() {
34623        // The v1 acceptance test (spec/std-messaging.md "Static checks"):
34624        // send/receive-capable providers are DISTINGUISHABLE. `send via` a
34625        // desktop channel passes; `when message from` the same channel is a
34626        // check error conditioned on the provider's capability report.
34627        let send_ok = compile_program(
34628            r#"
34629@service
34630workflow DesktopSend
34631
34632use std.messaging
34633
34634channel alerts {
34635  provider desktop
34636}
34637
34638output result R
34639class R { v string }
34640signal go.now { x string }
34641
34642rule j
34643  when go.now as g
34644=> {
34645  send via alerts {
34646    text "ping"
34647  } as sent
34648
34649  after sent succeeds {
34650    complete result { v "ok" }
34651  }
34652}
34653"#,
34654        );
34655        assert!(
34656            send_ok.diagnostics.is_empty(),
34657            "outbound send over desktop passes: {:?}",
34658            send_ok.diagnostics
34659        );
34660
34661        let inbound_bad = compile_program(
34662            r#"
34663@service
34664workflow DesktopInbound
34665
34666channel alerts {
34667  provider desktop
34668}
34669
34670output result R
34671class R { v string }
34672
34673rule react
34674  when message from alerts as msg
34675=> { complete result { v msg.text } }
34676"#,
34677        );
34678        assert!(
34679            inbound_bad.diagnostics.iter().any(|d| d.message.contains(
34680                "`when message from alerts` observes a channel whose provider `desktop` is outbound-only"
34681            )),
34682            "expected outbound-only diagnostic, got {:?}",
34683            inbound_bad.diagnostics
34684        );
34685
34686        // A bidirectional provider (`local`) admits both directions.
34687        let bidirectional = compile_program(
34688            r#"
34689@service
34690workflow LocalInbound
34691
34692channel alerts {
34693  provider local
34694}
34695
34696output result R
34697class R { v string }
34698
34699rule react
34700  when message from alerts as msg
34701=> { complete result { v msg.text } }
34702"#,
34703        );
34704        assert!(
34705            bidirectional.diagnostics.is_empty(),
34706            "bidirectional provider admits inbound observation: {:?}",
34707            bidirectional.diagnostics
34708        );
34709    }
34710
34711    #[test]
34712    fn channel_provider_reports_cover_the_v1_matrix() {
34713        // Report data is load-bearing for the conditioned checks: pin the v1
34714        // provider set, direction axis, and short-name/provider-id resolution.
34715        let shorts: Vec<&str> = CHANNEL_PROVIDER_REPORTS
34716            .iter()
34717            .map(|r| r.short_name)
34718            .collect();
34719        assert_eq!(shorts, ["fixture", "local", "desktop", "stdio"]);
34720        for report in CHANNEL_PROVIDER_REPORTS {
34721            assert!(
34722                matches!(
34723                    report.direction,
34724                    "outbound_only" | "inbound_only" | "bidirectional"
34725                ),
34726                "direction vocabulary: {}",
34727                report.direction
34728            );
34729            assert!(
34730                matches!(report.identity, "anonymous" | "claimed_actor"),
34731                "identity ladder is v1-narrowed (no verified_actor): {}",
34732                report.identity
34733            );
34734            assert_eq!(report.delivery_receipts, &["accepted", "failed"]);
34735            assert_eq!(
34736                channel_provider_report(report.short_name),
34737                Some(report),
34738                "short name resolves"
34739            );
34740            assert_eq!(
34741                channel_provider_report(report.provider_id),
34742                Some(report),
34743                "provider id resolves"
34744            );
34745        }
34746        assert_eq!(channel_provider_report("slack"), None);
34747        assert_eq!(
34748            channel_provider_report("desktop").map(|r| r.direction),
34749            Some("outbound_only")
34750        );
34751    }
34752
34753    #[test]
34754    fn duplicate_schema_diagnostic_points_at_first_declaration() {
34755        let source = r#"
34756@service
34757workflow DupSchema
34758
34759class Thing { v string }
34760class Thing { w string }
34761
34762output result R
34763class R { v string }
34764signal go.now { x string }
34765rule j
34766  when go.now as g
34767=> { complete result { v "ok" } }
34768"#;
34769        let compiled = compile_program(source);
34770        let dup = compiled
34771            .diagnostics
34772            .iter()
34773            .find(|d| {
34774                d.message
34775                    .contains("schema `Thing` is declared more than once")
34776            })
34777            .expect("expected duplicate-schema diagnostic");
34778        assert_eq!(dup.related.len(), 1);
34779        assert_eq!(dup.related[0].message, "first declared here");
34780        assert!(dup.related[0].span.start < dup.span.start);
34781    }
34782
34783    #[test]
34784    fn interval_clock_source_parses_duration() {
34785        let source = r#"
34786workflow Interval
34787
34788signal tick.beat {
34789  at_time time
34790}
34791
34792source clock as heartbeat {
34793  every 5m
34794  missed skip
34795
34796  observe as tick
34797  emit tick.beat {
34798    at_time tick.scheduled_at
34799  }
34800}
34801"#;
34802        let compiled = compile_program(source);
34803        assert_eq!(compiled.diagnostics, Vec::new());
34804        let ir = compiled.ir.expect("program compiles");
34805        match &ir.sources[0].recurrence {
34806            Some(Recurrence::EveryDuration { seconds, .. }) => assert_eq!(*seconds, 300),
34807            other => panic!("expected duration recurrence, got {other:?}"),
34808        }
34809        assert_eq!(ir.sources[0].missed, Some(MissedPolicy::Skip));
34810    }
34811
34812    #[test]
34813    fn fails_binding_types_to_effecterror_base() {
34814        // DR-0032: `after <effect> fails as f` types `f` to the EffectError base
34815        // (TerminalFailed: reason, summary, effect_id, run_id, kind). The base
34816        // fields read cleanly.
34817        let source = r#"
34818workflow W {
34819  input task T
34820  output result R
34821  failure error E
34822  class T { x string }
34823  class R { y string }
34824  class E { reason string detail string }
34825
34826  rule go when T as task => {
34827    exec "true" as e
34828    after e fails as f {
34829      fail error { reason f.reason detail f.kind }
34830    }
34831    after e succeeds {
34832      complete result { y task.x }
34833    }
34834  }
34835}
34836"#;
34837        let compiled = compile_program(source);
34838        assert!(
34839            !compiled
34840                .diagnostics
34841                .iter()
34842                .any(|d| d.message.contains("invalid field path")),
34843            "base fields should type-check: {:?}",
34844            compiled.diagnostics
34845        );
34846    }
34847
34848    #[test]
34849    fn fails_binding_rejects_non_base_field() {
34850        // The teeth of DR-0032 typing: a field outside the binding's per-kind
34851        // failure schema is a check error. An exec binding narrows to
34852        // TerminalFailedExec (exit_code is legal), but stderr is NOT exposed
34853        // (Decision 4 — redaction), and a coerce binding may not read exec's
34854        // extras (extras are reachable only under the matching kind).
34855        let exec_source = r#"
34856workflow W {
34857  input task T
34858  output result R
34859  failure error E
34860  class T { x string }
34861  class R { y string }
34862  class E { reason string }
34863
34864  rule go when T as task => {
34865    exec "true" as e
34866    after e fails as f {
34867      fail error { reason f.stderr }
34868    }
34869    after e succeeds {
34870      complete result { y task.x }
34871    }
34872  }
34873}
34874"#;
34875        let compiled = compile_program(exec_source);
34876        assert!(
34877            compiled
34878                .diagnostics
34879                .iter()
34880                .any(|d| d.message.contains("invalid field path `f.stderr`")),
34881            "{:?}",
34882            compiled.diagnostics
34883        );
34884
34885        let cross_kind = r#"
34886workflow W {
34887  input task T
34888  output result R
34889  failure error E
34890  class T { x string }
34891  class R { y string }
34892  class E { reason string }
34893  class V { note string }
34894
34895  coerce judge(x string) -> V {
34896    prompt "Classify {{ x }}"
34897  }
34898
34899  rule go when T as task => {
34900    coerce judge(task.x) as c
34901    after c fails as f {
34902      fail error { reason f.exit_code }
34903    }
34904    after c succeeds {
34905      complete result { y task.x }
34906    }
34907  }
34908}
34909"#;
34910        let compiled = compile_program(cross_kind);
34911        assert!(
34912            compiled
34913                .diagnostics
34914                .iter()
34915                .any(|d| d.message.contains("invalid field path `f.exit_code`")
34916                    && d.message.contains("TerminalFailedCoerce")),
34917            "a coerce binding must not read exec extras: {:?}",
34918            compiled.diagnostics
34919        );
34920    }
34921
34922    #[test]
34923    fn fails_binding_narrows_to_per_kind_failure_extras() {
34924        // DR-0032 P3 (DQ-2 static narrowing): the v1 per-kind extras are legal
34925        // exactly when the binding's effect kind matches — exec `exit_code`,
34926        // coerce `error_class` (+ optional `http_status`), tell `error_class`.
34927        let source = r#"
34928workflow W {
34929  input task T
34930  output result R
34931  failure error E
34932  class T { x string }
34933  class R { y string }
34934  class E { reason string code int klass string }
34935  class V { note string }
34936
34937  agent worker {
34938    provider fixture
34939    profile "repo-reader"
34940    capacity 1
34941  }
34942
34943  coerce judge(x string) -> V {
34944    prompt "Classify {{ x }}"
34945  }
34946
34947  rule go when T as task => {
34948    exec "true" as e
34949    coerce judge(task.x) as c
34950    tell worker as turn "go"
34951
34952    after e fails as fe {
34953      fail error { reason fe.reason code fe.exit_code klass "x" }
34954    }
34955    after c fails as fc {
34956      fail error { reason fc.reason code 0 klass fc.error_class }
34957    }
34958    after turn fails as ft {
34959      fail error { reason ft.reason code 0 klass ft.error_class }
34960    }
34961    after e succeeds {
34962      complete result { y task.x }
34963    }
34964  }
34965}
34966"#;
34967        let compiled = compile_program(source);
34968        assert!(
34969            !compiled
34970                .diagnostics
34971                .iter()
34972                .any(|d| d.message.contains("invalid field path")),
34973            "per-kind extras must type-check under the matching kind: {:?}",
34974            compiled.diagnostics
34975        );
34976    }
34977
34978    #[test]
34979    fn milestone_reaches_rejects_undeclared_milestone() {
34980        // Family C terminal-only observation invariant: a parent cannot observe a
34981        // milestone the invoked child never declares.
34982        let source = r#"
34983workflow Parent {
34984  input task Task
34985  class Task { title string }
34986  class Saw { note string }
34987
34988  rule dispatch when Task as task => {
34989    invoke Child { task { title task.title } } as child
34990    after child reaches "never_declared" as m {
34991      record Saw { note m.note }
34992    }
34993  }
34994}
34995
34996workflow Child {
34997  input task Task
34998  output result R
34999  class Task { title string }
35000  class R { title string }
35001  class P { note string }
35002
35003  rule go when Task as task => {
35004    emit milestone "actually_declared" of P { note task.title }
35005    complete result { title task.title }
35006  }
35007}
35008"#;
35009        let compiled = compile_program_with_root(source, Some("Parent"));
35010        assert!(
35011            compiled.diagnostics.iter().any(|d| d.message.contains(
35012                "reaches milestone `never_declared` that workflow `Child` does not declare"
35013            )),
35014            "{:?}",
35015            compiled.diagnostics
35016        );
35017    }
35018
35019    #[test]
35020    fn emit_milestone_rejects_unknown_payload_class() {
35021        let source = r#"
35022workflow Child {
35023  input task Task
35024  output result R
35025  class Task { title string }
35026  class R { title string }
35027
35028  rule go when Task as task => {
35029    emit milestone "m1" of Nonexistent { note task.title }
35030    complete result { title task.title }
35031  }
35032}
35033"#;
35034        let compiled = compile_program(source);
35035        assert!(
35036            compiled.diagnostics.iter().any(|d| d
35037                .message
35038                .contains("emits milestone `m1` with unknown payload class `Nonexistent`")),
35039            "{:?}",
35040            compiled.diagnostics
35041        );
35042    }
35043
35044    #[test]
35045    fn milestone_reaches_accepts_declared_milestone() {
35046        // The positive control: a declared milestone is accepted (no
35047        // reject-undeclared / unknown-class diagnostics).
35048        let source = r#"
35049workflow Parent {
35050  input task Task
35051  class Task { title string }
35052  class Saw { note string }
35053
35054  rule dispatch when Task as task => {
35055    invoke Child { task { title task.title } } as child
35056    after child reaches "halfway" as m {
35057      record Saw { note m.note }
35058    }
35059  }
35060}
35061
35062workflow Child {
35063  input task Task
35064  output result R
35065  class Task { title string }
35066  class R { title string }
35067  class P { note string }
35068
35069  rule go when Task as task => {
35070    emit milestone "halfway" of P { note task.title }
35071    complete result { title task.title }
35072  }
35073}
35074"#;
35075        let compiled = compile_program_with_root(source, Some("Parent"));
35076        assert!(
35077            !compiled
35078                .diagnostics
35079                .iter()
35080                .any(|d| d.message.contains("reaches milestone")
35081                    || d.message.contains("unknown payload class")),
35082            "{:?}",
35083            compiled.diagnostics
35084        );
35085    }
35086
35087    #[test]
35088    fn recurring_clock_source_requires_missed() {
35089        let source = r#"
35090workflow NeedsMissed
35091
35092signal triage.tick {
35093  scheduled_at time
35094}
35095
35096source clock as daily {
35097  every weekday at 09:00
35098  timezone "UTC"
35099
35100  observe as tick
35101  emit triage.tick {
35102    scheduled_at tick.scheduled_at
35103  }
35104}
35105"#;
35106        let compiled = compile_program(source);
35107        assert!(
35108            compiled.diagnostics.iter().any(|diagnostic| diagnostic
35109                .message
35110                .contains("must declare a `missed` policy")),
35111            "{:?}",
35112            compiled.diagnostics
35113        );
35114    }
35115
35116    #[test]
35117    fn calendar_clock_source_requires_timezone() {
35118        let source = r#"
35119workflow NeedsTimezone
35120
35121signal triage.tick {
35122  scheduled_at time
35123}
35124
35125source clock as daily {
35126  every weekday at 09:00
35127  missed skip
35128
35129  observe as tick
35130  emit triage.tick {
35131    scheduled_at tick.scheduled_at
35132  }
35133}
35134"#;
35135        let compiled = compile_program(source);
35136        assert!(
35137            compiled
35138                .diagnostics
35139                .iter()
35140                .any(|diagnostic| diagnostic.message.contains("should declare a `timezone`")),
35141            "{:?}",
35142            compiled.diagnostics
35143        );
35144    }
35145
35146    #[test]
35147    fn generic_source_block_lowers_to_signal_source() {
35148        let source = r#"
35149workflow Ingress
35150
35151signal deploy.finished {
35152  service string
35153}
35154
35155source webhook as deploys {
35156  observe as obs
35157  emit deploy.finished {
35158    service obs.service
35159  }
35160}
35161"#;
35162        let compiled = compile_program(source);
35163        assert_eq!(compiled.diagnostics, Vec::new());
35164        let ir = compiled.ir.expect("program compiles");
35165        assert_eq!(ir.sources.len(), 1);
35166        let decl = &ir.sources[0];
35167        assert!(!decl.is_clock);
35168        assert_eq!(decl.provider, "webhook");
35169        assert!(decl.recurrence.is_none());
35170        assert_eq!(decl.emit_signal, "deploy.finished");
35171    }
35172
35173    #[test]
35174    fn complete_field_reads_are_collected_per_field() {
35175        // The per-field flow-signature metadata (DR-0030 X2 v2) keeps each result
35176        // field's referenced roots SEPARATE (where `egress_payload_reads` joins them):
35177        // `id` references only `a`, `note` references only `b`.
35178        let source = r#"
35179@tool
35180workflow Producer {
35181  input request Req
35182  output result R
35183  class Req { id string }
35184  class A { x string }
35185  class B { y string }
35186  class R { id string  note string }
35187
35188  rule combine
35189    when A as a
35190    when B as b
35191  => {
35192    complete result {
35193      id a.x
35194      note b.y
35195    }
35196  }
35197}
35198"#;
35199        let compiled = compile_program(source);
35200        let ir = compiled.ir.expect("program compiles");
35201        let rule = ir
35202            .rules
35203            .iter()
35204            .find(|r| r.name == "combine")
35205            .expect("combine rule");
35206        let per_field = rule
35207            .metadata
35208            .complete_field_reads
35209            .get("result")
35210            .expect("result has per-field reads");
35211        assert_eq!(
35212            per_field.get("id"),
35213            Some(&BTreeSet::from(["a".to_owned()])),
35214            "id references only a: {per_field:?}"
35215        );
35216        assert_eq!(
35217            per_field.get("note"),
35218            Some(&BTreeSet::from(["b".to_owned()])),
35219            "note references only b: {per_field:?}"
35220        );
35221    }
35222
35223    #[test]
35224    fn milestone_field_reads_are_collected_per_field() {
35225        // D3′: milestone payload fields get the same per-field root metadata as
35226        // `complete result`, so a parent can audit each milestone field separately.
35227        let source = r#"
35228workflow Child {
35229  input request Req
35230  output result R
35231  class Req { id string }
35232  class A { x string }
35233  class B { y string }
35234  class R { ok bool }
35235  class Progress { hot string  cold string }
35236
35237  rule report
35238    when A as a
35239    when B as b
35240  => {
35241    emit milestone "halfway" of Progress {
35242      hot a.x
35243      cold b.y
35244    }
35245    complete result { ok true }
35246  }
35247}
35248"#;
35249        let compiled = compile_program(source);
35250        let ir = compiled.ir.expect("program compiles");
35251        let rule = ir
35252            .rules
35253            .iter()
35254            .find(|r| r.name == "report")
35255            .expect("report rule");
35256        let per_field = rule
35257            .metadata
35258            .milestone_field_reads
35259            .get("halfway")
35260            .expect("milestone has per-field reads");
35261        assert_eq!(
35262            per_field.get("hot"),
35263            Some(&BTreeSet::from(["a".to_owned()])),
35264            "hot references only a: {per_field:?}"
35265        );
35266        assert_eq!(
35267            per_field.get("cold"),
35268            Some(&BTreeSet::from(["b".to_owned()])),
35269            "cold references only b: {per_field:?}"
35270        );
35271    }
35272}