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;
8mod canonical;
9pub use canonical::{canonical_declarations, canonical_program_hash, DeclCanon};
10pub mod body;
11mod body_print;
12mod format;
13mod lowering;
14use format::*;
15pub use format::{format_program, format_program_preserving_comments, FormatOutput};
16use lowering::*;
17mod syntax;
18// The lexer/parser front end moved out whole; these three were public API
19// before the move and are re-exported so the crate's surface is unchanged.
20use syntax::*;
21pub use syntax::{lex_comments, parse_program, parser_stage, string_and_comment_spans};
22mod then_expand;
23
24use std::{
25    collections::{BTreeMap, BTreeSet, VecDeque},
26    fmt,
27};
28use whipplescript_core::{
29    ContractRegistry, EffectContract, LibraryRegistration, TypedOutputValidation,
30};
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct SourceSpan {
34    pub start: usize,
35    pub end: usize,
36}
37
38impl SourceSpan {
39    fn join(self, other: Self) -> Self {
40        Self {
41            start: self.start,
42            end: other.end,
43        }
44    }
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct Diagnostic {
49    pub span: SourceSpan,
50    pub message: String,
51    pub suggestion: Option<String>,
52    /// Secondary spans carrying supporting context (spec/error-handling.md "Spans
53    /// And Labels"): a `note`-style related-information label pointing at a
54    /// definition, prior claim, or other related site. Empty for most
55    /// diagnostics; surfaced in CLI text, JSON reports, and LSP
56    /// `relatedInformation`.
57    pub related: Vec<RelatedInfo>,
58}
59
60/// A secondary span + short label attached to a [`Diagnostic`] as related
61/// information (never a top-level diagnostic of its own).
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct RelatedInfo {
64    pub span: SourceSpan,
65    pub message: String,
66}
67
68impl Diagnostic {
69    /// Attaches a related-information label at `span` (builder style, so the
70    /// common no-related case stays a plain struct literal that only needs the
71    /// new field defaulted).
72    pub fn with_related(mut self, span: SourceSpan, message: impl Into<String>) -> Self {
73        self.related.push(RelatedInfo {
74            span,
75            message: message.into(),
76        });
77        self
78    }
79}
80
81/// The marker that introduced a comment, preserved so a formatter can re-emit it
82/// faithfully.
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub enum CommentMarker {
85    /// `# …`
86    Hash,
87    /// `// …`
88    Slash,
89}
90
91/// A source comment captured by the lexer. Comments are kept out of the token
92/// stream (so the parser is unaffected) but retained here so tooling — `whip fmt`,
93/// the LSP — can preserve them. `text` is the trimmed content after the marker;
94/// `span` covers the marker through end of line (exclusive of the newline).
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct Comment {
97    pub marker: CommentMarker,
98    pub text: String,
99    pub span: SourceSpan,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct Ident {
104    pub name: String,
105    pub span: SourceSpan,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct StringLiteral {
110    pub value: String,
111    pub span: SourceSpan,
112}
113
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct Program {
116    pub workflow: Option<Ident>,
117    pub workflow_tags: Vec<TagDecl>,
118    pub workflow_description: Option<StringLiteral>,
119    pub explicit_workflow_body: bool,
120    pub workflows: Vec<WorkflowDecl>,
121    pub patterns: Vec<PatternDecl>,
122    pub items: Vec<Item>,
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct WorkflowDecl {
127    pub name: Ident,
128    pub tags: Vec<TagDecl>,
129    pub description: Option<StringLiteral>,
130    pub items: Vec<Item>,
131    pub span: SourceSpan,
132}
133
134#[derive(Clone, Debug, Eq, PartialEq)]
135pub enum Item {
136    Include(IncludeDecl),
137    Use(UseDecl),
138    Pattern(PatternDecl),
139    Apply(ApplyDecl),
140    WorkflowContract(WorkflowContractDecl),
141    Harness(HarnessDecl),
142    Tracker(TrackerDecl),
143    Channel(ChannelDecl),
144    Credential(CredentialDecl),
145    Stream(StreamDecl),
146    Gauge(GaugeDecl),
147    Mark(MarkDecl),
148    Campaign(CampaignDecl),
149    FileStore(FileStoreDecl),
150    MemoryPool(MemoryPoolDecl),
151    Action(ActionDecl),
152    Agent(AgentDecl),
153    Enum(EnumDecl),
154    Event(EventDecl),
155    // Boxed: SourceDecl carries the ingress path/url/emit surface and is by far
156    // the largest Item variant; boxing keeps the enum small (clippy large_enum_variant).
157    Source(Box<SourceDecl>),
158    Test(TestDecl),
159    Lease(LeaseDecl),
160    Ledger(LedgerDecl),
161    Counter(CounterDecl),
162    Class(ClassDecl),
163    Table(TableDecl),
164    Coerce(CoerceDecl),
165    Assert(AssertDecl),
166    Rule(RuleDecl),
167}
168
169impl Item {
170    /// Source span of this top-level item, used to interleave preserved comments.
171    fn span(&self) -> SourceSpan {
172        match self {
173            Self::Include(decl) => decl.path.span,
174            Self::Use(decl) => decl.name.span,
175            Self::Pattern(decl) => decl.span,
176            Self::Apply(decl) => decl.span,
177            Self::WorkflowContract(decl) => decl.span,
178            Self::Harness(decl) => decl.span,
179            Self::Tracker(decl) => decl.span,
180            Self::Channel(decl) => decl.span,
181            Self::Credential(decl) => decl.span,
182            Self::Stream(decl) => decl.span,
183            Self::Gauge(decl) => decl.span,
184            Self::Mark(decl) => decl.span,
185            Self::Campaign(decl) => decl.span,
186            Self::FileStore(decl) => decl.span,
187            Self::MemoryPool(decl) => decl.span,
188            Self::Action(decl) => decl.span,
189            Self::Agent(decl) => decl.span,
190            Self::Enum(decl) => decl.span,
191            Self::Event(decl) => decl.span,
192            Self::Source(decl) => decl.span,
193            Self::Test(decl) => decl.span,
194            Self::Lease(decl) => decl.span,
195            Self::Ledger(decl) => decl.span,
196            Self::Counter(decl) => decl.span,
197            Self::Class(decl) => decl.span,
198            Self::Table(decl) => decl.span,
199            Self::Coerce(decl) => decl.span,
200            Self::Assert(decl) => decl.span,
201            Self::Rule(decl) => decl.span,
202        }
203    }
204}
205
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct PatternDecl {
208    pub name: Ident,
209    pub type_params: Vec<Ident>,
210    pub items: Vec<Item>,
211    pub span: SourceSpan,
212}
213
214#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct ApplyDecl {
216    pub pattern: Ident,
217    pub type_args: Vec<TypeSyntax>,
218    pub alias: Ident,
219    pub body: BlockSource,
220    pub span: SourceSpan,
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct IncludeDecl {
225    pub path: StringLiteral,
226}
227
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct WorkflowContractDecl {
230    pub kind: WorkflowContractKind,
231    pub name: Ident,
232    pub ty: TypeSyntax,
233    pub span: SourceSpan,
234}
235
236#[derive(Clone, Debug, Eq, PartialEq)]
237pub enum WorkflowContractKind {
238    Input,
239    Output,
240    Failure,
241}
242
243impl WorkflowContractKind {
244    fn as_str(&self) -> &'static str {
245        match self {
246            Self::Input => "input",
247            Self::Output => "output",
248            Self::Failure => "failure",
249        }
250    }
251}
252
253#[derive(Clone, Debug, Eq, PartialEq)]
254pub struct AssertDecl {
255    pub tags: Vec<TagDecl>,
256    pub description: Option<StringLiteral>,
257    pub expr: String,
258    pub span: SourceSpan,
259}
260
261#[derive(Clone, Debug, Eq, PartialEq)]
262pub struct TagDecl {
263    pub name: String,
264    pub span: SourceSpan,
265}
266
267#[derive(Clone, Debug, Eq, PartialEq)]
268pub struct UseDecl {
269    pub name: StringLiteral,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
273pub struct HarnessDecl {
274    pub name: Ident,
275    pub kind: Ident,
276    pub span: SourceSpan,
277}
278
279#[derive(Clone, Debug, Eq, PartialEq)]
280pub struct TrackerDecl {
281    pub name: Ident,
282    pub provider: Ident,
283    pub span: SourceSpan,
284}
285
286/// `channel <name> { provider <p> [workspace <w>] [destination "<d>"] }`
287/// (std.messaging): a named communication route through a provider. The bare
288/// `channel` construct shape is reserved by the platform for `std.messaging`
289/// (spec/messaging.md), so third-party packages cannot author channel-like
290/// semantics with weaker guarantees. Lowers to a `metadata_only` declaration
291/// (like `queue`); the runtime messaging provider is later-stage work.
292#[derive(Clone, Debug, Eq, PartialEq)]
293pub struct ChannelDecl {
294    pub name: Ident,
295    pub provider: Ident,
296    pub workspace: Option<Ident>,
297    pub destination: Option<StringLiteral>,
298    pub span: SourceSpan,
299}
300
301/// `credential <name> { kind <kind> }` (std.custody; DR-0053 §5): a bare
302/// handle naming a custodian entry — governance supplies reality via
303/// `grant credential … -> credential:<addr>`, and material never appears in
304/// source. `kind` exists so the checker can statically reject an operation
305/// the credential cannot perform (`sign … with` a `bearer`); the custodian's
306/// registered kind is authoritative and mismatch is a check error. Kinds are
307/// spelled with underscores in source (`hmac_sha256`) and normalize to the
308/// protocol's kebab-case.
309#[derive(Clone, Debug, Eq, PartialEq)]
310pub struct CredentialDecl {
311    pub name: Ident,
312    pub kind: Ident,
313    pub span: SourceSpan,
314}
315
316/// `stream <name> { members [<agent>, ...] [staleness <duration>] }`
317/// (std.vcs; DR-0052 Decision 5): a declared collaboration — a named
318/// shared line whose member agents' session lines home to it, syncing
319/// greedily in-stream and promoting to mainline through one gated
320/// boundary. Members are agent declarations (every session of that
321/// agent homes here); `staleness` is the §7.1 bound. Metadata-only
322/// lowering, like `queue`/`channel`; the runtime workstream tier is the
323/// enforcement seam.
324#[derive(Clone, Debug, Eq, PartialEq)]
325pub struct StreamDecl {
326    pub name: Ident,
327    pub members: Vec<Ident>,
328    pub staleness_seconds: Option<u64>,
329    pub span: SourceSpan,
330}
331
332/// `mark "<name>" after <site>` (experimentation subsystem §4.2): a named
333/// cut point. The runtime stamps a `mark.reached` event when the named
334/// site commits on any run, so every run's meaningful moments are
335/// addressable — `whip pin <run> at <mark>` freezes the prefix as a
336/// scenario, and regeneration replays that prefix and re-executes only
337/// the suffix. Names are stable across edits (event offsets shift, marks
338/// don't). Deliberately a separate declaration from `milestone`
339/// (child→parent lifecycle signaling vs. event-log position).
340#[derive(Clone, Debug, Eq, PartialEq)]
341pub struct MarkDecl {
342    pub name: StringLiteral,
343    /// The committing site the cut rides: a rule name (dotted for
344    /// flow-generated segments).
345    pub site: String,
346    pub site_span: SourceSpan,
347    pub span: SourceSpan,
348}
349
350/// `gauge <name> [on <site>] { judge via ... [expect ...] [inputs ...] }`
351/// (experimentation subsystem §4.2): a named quality dimension — a site, a
352/// judge, optionally a bar. The sibling of `test`: deterministic expectation
353/// vs. stochastic expectation, one family. Core grammar (hand-parsed): the
354/// `judge via` tagged union and the bar form are outside the declaration
355/// family's shape. Bars use the word forms `at least` / `at most` because
356/// the declaration tokenizer deliberately steps over `>=`/`<=` (the same
357/// reason field presence conditions use `is`).
358#[derive(Clone, Debug, Eq, PartialEq)]
359pub struct GaugeDecl {
360    pub name: Ident,
361    /// Optional `on <dotted.site>` designation. v1 records it (identity and
362    /// forward-compat with site-scoped judging); ambient scoring judges the
363    /// run's terminal view.
364    pub site: Option<String>,
365    pub site_span: Option<SourceSpan>,
366    pub judge: GaugeJudge,
367    pub expect: Option<GaugeBar>,
368    /// Derived gauges: other gauges whose scores feed this gauge's exec
369    /// judge (`inputs a, b`). Deterministic composition — the settled cure
370    /// for composite objectives (no weights feature, ever).
371    pub inputs: Vec<GaugeRef>,
372    pub span: SourceSpan,
373}
374
375/// The generalized judge slot: `judge via coerce <Name>(<args>) |
376/// prompt "<t>" | exec "<cmd>" | labels "<source>"`. Coerce judges carry
377/// EXPLICIT argument paths (settled 2026-07-14): each names the record
378/// value feeding the parameter (`input.ticket.title`,
379/// `facts.Assessment.priority`), or the single reserved `record` passes
380/// the whole judge-input record — the binding is written down and
381/// versioned, never inferred.
382#[derive(Clone, Debug, Eq, PartialEq)]
383pub enum GaugeJudge {
384    Coerce(Ident, Vec<String>),
385    Prompt(StringLiteral),
386    Exec(StringLiteral),
387    Labels(StringLiteral),
388}
389
390/// An optional bar: the default decision bar for settle/campaign gates.
391/// `expect P(<field>) at least 0.9` (chance-shaped) or
392/// `expect p10 at least 0.7` / `expect mean at most 800` (stat-shaped).
393/// Thresholds keep their exact source text (`Eq`-safe, format-exact);
394/// consumers parse.
395#[derive(Clone, Debug, Eq, PartialEq)]
396pub struct GaugeBar {
397    pub subject: GaugeBarSubject,
398    /// `true` = `at least`, `false` = `at most`.
399    pub at_least: bool,
400    pub threshold: String,
401    pub span: SourceSpan,
402}
403
404#[derive(Clone, Debug, Eq, PartialEq)]
405pub enum GaugeBarSubject {
406    /// `P(<field>)`: probability the judge's boolean output field holds.
407    Chance { field: Ident },
408    /// A named statistic of the score distribution: `mean`, `p10`, `p90`, …
409    Stat { stat: Ident },
410}
411
412/// A (possibly dotted) gauge reference: user gauges are bare idents, the
413/// built-in resource gauges are namespaced (`std.spend` / `std.latency` /
414/// `std.tokens`).
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct GaugeRef {
417    pub name: String,
418    pub span: SourceSpan,
419}
420
421/// `campaign <name> { ascend … [reach …] [guard …] [sacrifice …] }`
422/// (improve design note §3): versioned, diffable objective intent at higher
423/// ceremony than a CLI invocation — the partition of the gauge vector.
424/// Unnamed gauges are guarded by default; `guard` widens a band, `sacrifice`
425/// releases a gauge, `reach` sets a target that becomes a hard bound.
426#[derive(Clone, Debug, Eq, PartialEq)]
427pub struct CampaignDecl {
428    pub name: Ident,
429    pub ascend: Vec<GaugeRef>,
430    pub reach: Vec<CampaignReach>,
431    pub guard: Vec<CampaignGuard>,
432    pub sacrifice: Vec<GaugeRef>,
433    /// `proposer redacted`: campaign-attached stratified reflection — the
434    /// proposer sees aggregates only, never scenario contents (leakage
435    /// policy, improve note §7; settled 2026-07-11).
436    pub proposer_redacted: bool,
437    pub span: SourceSpan,
438}
439
440/// `reach <gauge> at least 0.9` / `reach std.latency at most 800ms`.
441#[derive(Clone, Debug, Eq, PartialEq)]
442pub struct CampaignReach {
443    pub gauge: GaugeRef,
444    pub at_least: bool,
445    pub threshold: String,
446    /// Optional trailing unit ident (`ms`, `s`); recorded verbatim.
447    pub unit: Option<String>,
448    pub span: SourceSpan,
449}
450
451/// `guard <gauge> within 2 percent`: an indifference-band override.
452#[derive(Clone, Debug, Eq, PartialEq)]
453pub struct CampaignGuard {
454    pub gauge: GaugeRef,
455    pub band_percent: String,
456    pub span: SourceSpan,
457}
458
459/// `file store <name> { root "<dir>" }` (std.files): a capability-scoped file
460/// store identity with a literal root directory. v0 is a local storage boundary.
461#[derive(Clone, Debug, Eq, PartialEq)]
462pub struct FileStoreDecl {
463    pub name: Ident,
464    pub root: String,
465    pub read_globs: Vec<String>,
466    pub write_globs: Vec<String>,
467    /// Optional `provider <name>` clause (std.files v1, spec/std-files.md
468    /// "Surface"): the store's backing provider, defaulting to `local` when
469    /// absent. Unknown providers are rejected at check time.
470    pub provider: Option<Ident>,
471    /// Source spans of each clause keyword (`root` / the `allow` of read / write),
472    /// so `whip fmt` can interleave own-line and trailing body comments by position
473    /// (the body otherwise rebuilds from the AST, dropping comments).
474    pub root_span: Option<SourceSpan>,
475    pub read_span: Option<SourceSpan>,
476    pub write_span: Option<SourceSpan>,
477    pub provider_span: Option<SourceSpan>,
478    pub span: SourceSpan,
479}
480
481/// `memory pool <name> { context limit <n> }` (std.memory, MEM-1): a named
482/// durable memory place. Mirrors `file store` as a `declaration_block` /
483/// `metadata_only` construct providing `Resource<MemoryPool>`. v1 pools are
484/// provider-less; `context limit <n>` (optional, non-negative) is the recall
485/// packing budget. Unknown clauses are rejected (file-store precedent).
486#[derive(Clone, Debug, Eq, PartialEq)]
487pub struct MemoryPoolDecl {
488    pub name: Ident,
489    pub context_limit: Option<u64>,
490    /// Source span of the `context` clause keyword, so `whip fmt` can interleave
491    /// body comments by position (file-store precedent).
492    pub context_limit_span: Option<SourceSpan>,
493    pub span: SourceSpan,
494}
495
496/// One typed parameter of an `action` template (DR-0023).
497#[derive(Clone, Debug, Eq, PartialEq)]
498pub struct ActionParam {
499    pub name: Ident,
500    pub ty: TypeSyntax,
501    pub span: SourceSpan,
502}
503
504/// `action <name>(<param: type>, …) { <effect chain> }` (DR-0023): a static,
505/// hygienic, inline-expanded template over rule-body effect chains. Consumed by
506/// `expand_action_calls` before lowering; never a runtime construct.
507#[derive(Clone, Debug, Eq, PartialEq)]
508pub struct ActionDecl {
509    pub name: Ident,
510    pub params: Vec<ActionParam>,
511    pub body: BlockSource,
512    pub span: SourceSpan,
513}
514
515#[derive(Clone, Debug, Eq, PartialEq)]
516pub struct AgentDecl {
517    pub name: Ident,
518    pub harness: Option<Ident>,
519    /// `agent Foo delegated to <provider>` (DR-0034 Decision 2): the surface
520    /// spelling of a Delegated agent. Names the foreign provider kind directly;
521    /// `agent Foo { … }` without it is Managed by default.
522    pub delegated_to: Option<Ident>,
523    pub fields: Vec<AgentField>,
524    pub span: SourceSpan,
525}
526
527#[derive(Clone, Debug, Eq, PartialEq)]
528pub enum AgentField {
529    Provider(Ident),
530    Profile(StringLiteral),
531    Capacity(u32, SourceSpan),
532    Skills(Vec<StringLiteral>, SourceSpan),
533    Capabilities(Vec<StringLiteral>, SourceSpan),
534    /// `requires [session.resume, turn.cancel]`: portable feature-class
535    /// requirements (DR-0015 taxonomy; spec/std-agent.md slice 6). Entries are
536    /// dotted feature-class names, validated for taxonomy membership at
537    /// lowering and against the provider's feature report by the CLI.
538    Requires(Vec<Ident>, SourceSpan),
539    /// `tools [Foo, Bar]`: the workflows this agent may invoke as typed tools
540    /// (DR-0025). Entries are workflow names resolved against the program/packages.
541    Tools(Vec<Ident>, SourceSpan),
542    /// `compaction <strategy>`: the owned-harness conversation-compaction strategy
543    /// (context-assembly Phase 5). One of `summarize`, `hard_reset`, `tool_results`,
544    /// `none`.
545    Compaction(Ident),
546    /// `thread <mode>`: owned-harness conversation continuation across tells
547    /// (the chat-shaped instance v1). `continue` seeds each
548    /// new tell from the agent's latest completed-turn transcript in this
549    /// instance; `fresh` (the default) starts every tell from scratch.
550    Thread(Ident),
551    /// `settings <sources>`: which ambient-config sources a Delegated harness may
552    /// read when assembling its own context (DR-0034 Decision 4). One of `project`,
553    /// `user`, `none`. Unset means the provider's own default.
554    Settings(Ident),
555    Unknown {
556        name: Ident,
557        span: SourceSpan,
558    },
559}
560
561#[derive(Clone, Debug, Eq, PartialEq)]
562pub struct EnumDecl {
563    pub name: Ident,
564    pub variants: Vec<EnumVariantDecl>,
565    pub span: SourceSpan,
566}
567
568/// One enum variant: bare (`Accept`) or data-carrying with a brace body that
569/// reuses the class field grammar (sum types, spec/sum-types.md).
570#[derive(Clone, Debug, Eq, PartialEq)]
571pub struct EnumVariantDecl {
572    pub name: Ident,
573    pub fields: Vec<ClassField>,
574    pub span: SourceSpan,
575}
576
577#[derive(Clone, Debug, Eq, PartialEq)]
578pub struct ClassDecl {
579    pub name: Ident,
580    pub fields: Vec<ClassField>,
581    pub span: SourceSpan,
582}
583
584/// Coordination resources (spec/coordination.md): a closed family of shared,
585/// workspace-scoped resources with typed keys, atomic branchable operations,
586/// and mandatory bounds (`ttl`/`retain`/`cap`+`reset`).
587#[derive(Clone, Debug, Eq, PartialEq)]
588pub struct LeaseDecl {
589    pub name: Ident,
590    pub key_type: Ident,
591    pub slots: u32,
592    pub ttl_seconds: u64,
593    pub shared: bool,
594    pub span: SourceSpan,
595}
596
597#[derive(Clone, Debug, Eq, PartialEq)]
598pub struct LedgerDecl {
599    pub name: Ident,
600    pub entry_schema: Ident,
601    pub partition_field: Ident,
602    pub retain_seconds: u64,
603    pub shared: bool,
604    pub span: SourceSpan,
605}
606
607#[derive(Clone, Debug, Eq, PartialEq)]
608pub struct CounterDecl {
609    pub name: Ident,
610    pub key_type: Ident,
611    pub cap: i64,
612    pub reset: String,
613    /// IANA timezone anchoring the reset-period boundary (std.coord slice 3);
614    /// `None` anchors to UTC and draws a default-UTC warning.
615    pub timezone: Option<String>,
616    pub shared: bool,
617    pub span: SourceSpan,
618}
619
620/// A typed external-signal declaration (`signal deploy.finished { ... }`):
621/// the ingress manifest naming a dotted event and its payload schema
622/// (spec/event-ingress.md).
623#[derive(Clone, Debug, Eq, PartialEq)]
624pub struct EventDecl {
625    /// Dotted lowercase signal name (`deploy.finished`).
626    pub name: String,
627    pub name_span: SourceSpan,
628    pub fields: Vec<ClassField>,
629    pub span: SourceSpan,
630}
631
632#[derive(Clone, Debug, Eq, PartialEq)]
633pub struct ClassField {
634    pub name: Ident,
635    pub ty: TypeSyntax,
636    /// `@key`: this field is the class's natural key (used for import per-row
637    /// idempotency, spec/std-library/files.md). At most one per class in v0.
638    pub is_key: bool,
639    /// Family B (discriminant-string schemas): `<field> <Type> when <disc> == "<lit>"`
640    /// — this field is present only when the literal-union discriminant field `disc`
641    /// equals `lit`. `(discriminant field name, required literal)`.
642    pub presence_condition: Option<(String, String)>,
643    pub span: SourceSpan,
644}
645
646/// A top-level source declaration: `source <provider> as <name> { ... }` or
647/// `source clock as <name> { ... }`. Lowers through the `source_declaration`
648/// construct family to a `signal_source` (generic provider) or `clock_source`
649/// (the `clock` provider) admission template (spec/std-time.md,
650/// spec/construct-grammar.md). A source admits a durable signal fact; it never
651/// fires a rule directly.
652#[derive(Clone, Debug, Eq, PartialEq)]
653pub struct SourceDecl {
654    /// `as <name>` — the source instance name.
655    pub name: Ident,
656    /// The provider keyword (`clock`) or a generic provider identifier.
657    pub provider: Ident,
658    /// Recurrence/timezone/missed policy; `Some` only for the `clock` provider.
659    pub clock: Option<ClockPolicy>,
660    /// `path "<file>"` — `file` provider, line mode (exactly one of
661    /// `path`/`watch`); rejected elsewhere. The file is read line-by-line; each
662    /// non-empty line is admitted once as a durable signal fact
663    /// (spec/std-time.md admission semantics, append-only).
664    pub path: Option<StringLiteral>,
665    /// `watch "<glob>"` — `file` provider, occurrence mode (exactly one of
666    /// `path`/`watch`); rejected elsewhere (spec/std-ingress.md I2a). Each
667    /// matched file is admitted once per new (path, content-hash) occurrence:
668    /// a dropped file admits once, an unchanged file never re-admits, a
669    /// content change re-admits. Content READING stays std.files.
670    pub watch: Option<StringLiteral>,
671    /// `url "<url>"` — required for the `http` provider, rejected elsewhere. The
672    /// URL is GET'd and its JSON-array body admitted one element per signal,
673    /// keyed by (source, element index) so re-polls are idempotent (append-only).
674    pub url: Option<StringLiteral>,
675    /// `dedup <observe>.<field>` — optional provider delivery-id source for
676    /// `file` (line mode) and `http` sources (spec/std-ingress.md I2a): the
677    /// named observation field becomes the admission key instead of the
678    /// positional ordinal, so a re-ordered or head-inserted feed still admits
679    /// each delivery exactly once.
680    pub dedup: Option<SourceValue>,
681    /// `observe as <binding>` — binds the provider observation schema.
682    pub observe_binding: Ident,
683    /// `emit <signal> { <field> <value> ... }` — maps the observation into the
684    /// declared signal payload.
685    pub emit: SourceEmit,
686    pub span: SourceSpan,
687}
688
689#[derive(Clone, Debug, Eq, PartialEq)]
690pub struct ClockPolicy {
691    pub recurrence: Recurrence,
692    pub timezone: Option<StringLiteral>,
693    pub missed: Option<MissedPolicy>,
694    pub span: SourceSpan,
695}
696
697/// Recurrence forms from spec/std-time.md (conservative first surface).
698#[derive(Clone, Debug, Eq, PartialEq)]
699pub enum Recurrence {
700    /// `at <hh:mm>` — a single scheduled occurrence.
701    At { time: TimeOfDay, span: SourceSpan },
702    /// `every <duration>` — interval occurrences.
703    EveryDuration {
704        seconds: u64,
705        source: String,
706        span: SourceSpan,
707    },
708    /// `every <calendar-pattern> at <hh:mm>` — calendar occurrences.
709    EveryCalendar {
710        pattern: CalendarPattern,
711        time: TimeOfDay,
712        span: SourceSpan,
713    },
714}
715
716#[derive(Clone, Copy, Debug, Eq, PartialEq)]
717pub enum CalendarPattern {
718    Day,
719    Weekday,
720    Weekly(Weekday),
721}
722
723#[derive(Clone, Copy, Debug, Eq, PartialEq)]
724pub enum Weekday {
725    Monday,
726    Tuesday,
727    Wednesday,
728    Thursday,
729    Friday,
730    Saturday,
731    Sunday,
732}
733
734#[derive(Clone, Copy, Debug, Eq, PartialEq)]
735pub struct TimeOfDay {
736    pub hour: u8,
737    pub minute: u8,
738    pub span: SourceSpan,
739}
740
741/// Missed-occurrence policy from spec/std-time.md. No silent default: a recurring
742/// source must declare one (enforced by the checker).
743#[derive(Clone, Copy, Debug, Eq, PartialEq)]
744pub enum MissedPolicy {
745    Skip,
746    Coalesce,
747    CatchUp { limit: u32 },
748}
749
750#[derive(Clone, Debug, Eq, PartialEq)]
751pub struct SourceEmit {
752    /// Dotted lowercase signal name materialized by this source.
753    pub signal: String,
754    pub signal_span: SourceSpan,
755    /// S6: `emit <signal> from <binding> [{ overrides }]` — copy the
756    /// observation's same-named fields, bounded to the signal's declared
757    /// fields, with the block overriding (the `record … from` semantics).
758    pub from: Option<Ident>,
759    pub fields: Vec<SourceEmitField>,
760    pub span: SourceSpan,
761}
762
763#[derive(Clone, Debug, Eq, PartialEq)]
764pub struct SourceEmitField {
765    pub name: Ident,
766    pub value: SourceValue,
767    pub span: SourceSpan,
768}
769
770/// A value mapped into an emitted signal field: an observation path
771/// (`tick.scheduled_at`) or a literal.
772#[derive(Clone, Debug, Eq, PartialEq)]
773pub enum SourceValue {
774    Path {
775        binding: Ident,
776        segments: Vec<Ident>,
777        span: SourceSpan,
778    },
779    String(StringLiteral),
780    Number(String, SourceSpan),
781}
782
783/// A deterministic test scenario (spec/workflow-testing.md). Validated by
784/// `whip check`; excluded from compile/run IR; executed by `whip test`.
785#[derive(Clone, Debug, Eq, PartialEq)]
786pub struct TestDecl {
787    pub name: StringLiteral,
788    /// Optional `workflow <Name>` header binding the scenario to one workflow
789    /// in a multi-workflow bundle (spec/workflow-testing.md). Single-workflow
790    /// files may omit it and bind implicitly.
791    pub workflow: Option<Ident>,
792    pub clauses: Vec<TestClause>,
793    pub span: SourceSpan,
794}
795
796#[derive(Clone, Debug, Eq, PartialEq)]
797pub enum TestClause {
798    Given(GivenClause),
799    Stub(StubClause),
800    Run(RunClause),
801    Expect(ExpectClause),
802}
803
804/// A `<field> <expr>` mapping inside a `given` record body. `value` is the source
805/// text of the expression (parsed via `parse_expression` when validated), matching
806/// how guards and assertions capture expressions.
807#[derive(Clone, Debug, Eq, PartialEq)]
808pub struct TestField {
809    pub name: Ident,
810    pub value: String,
811    pub span: SourceSpan,
812}
813
814#[derive(Clone, Debug, Eq, PartialEq)]
815pub enum GivenClause {
816    Input {
817        fields: Vec<TestField>,
818        span: SourceSpan,
819    },
820    Fact {
821        ty: Ident,
822        fields: Vec<TestField>,
823        span: SourceSpan,
824    },
825    Signal {
826        name: String,
827        fields: Vec<TestField>,
828        span: SourceSpan,
829    },
830    Clock {
831        at: StringLiteral,
832        span: SourceSpan,
833    },
834    Tracker {
835        tracker: String,
836        fields: Vec<TestField>,
837        span: SourceSpan,
838    },
839    /// `given file <store> at <path> "<content>"` seeds a fixture file in the
840    /// named `file store` so a `read` during `whip test` resolves deterministic
841    /// content (the harness redirects the store root to a temp dir).
842    File {
843        store: String,
844        path: StringLiteral,
845        content: StringLiteral,
846        span: SourceSpan,
847    },
848}
849
850/// `stub <surface…> <outcome> [record | string]`. The surface path and outcome
851/// are kept as tokens; provider-specific validation happens in the harness.
852#[derive(Clone, Debug, Eq, PartialEq)]
853pub struct StubClause {
854    /// Surface path segments (each may be dotted, e.g. `script.run`); the trailing
855    /// segment is the outcome.
856    pub surface: Vec<String>,
857    pub outcome: String,
858    pub payload: Option<StubPayload>,
859    pub span: SourceSpan,
860}
861
862#[derive(Clone, Debug, Eq, PartialEq)]
863pub enum StubPayload {
864    Record(Vec<TestField>),
865    Message(StringLiteral),
866}
867
868#[derive(Clone, Debug, Eq, PartialEq)]
869pub struct RunClause {
870    pub kind: RunKind,
871    pub span: SourceSpan,
872}
873
874#[derive(Clone, Debug, Eq, PartialEq)]
875pub enum RunKind {
876    UntilIdle,
877    UntilWorkflowCompleted,
878    UntilWorkflowFailed,
879    ForSteps(u32),
880}
881
882#[derive(Clone, Debug, Eq, PartialEq)]
883pub struct ExpectClause {
884    pub target: ExpectTarget,
885    pub span: SourceSpan,
886}
887
888#[derive(Clone, Debug, Eq, PartialEq)]
889pub enum ExpectTarget {
890    WorkflowCompleted,
891    WorkflowFailed { failure: Option<Ident> },
892    Rule { name: Ident, status: RuleStatus },
893    Effect { name: String, status: EffectStatus },
894    Diagnostic { code: String },
895    NoEffect { name: String },
896    Projection(ProjQuery),
897}
898
899#[derive(Clone, Debug, Eq, PartialEq)]
900pub enum RuleStatus {
901    Fired,
902    FiredTimes(u32),
903    DidNotFire,
904}
905
906#[derive(Clone, Debug, Eq, PartialEq)]
907pub enum EffectStatus {
908    Requested,
909    Completed,
910    Failed,
911}
912
913/// A projection query: `<noun> exists | count <predicate> is <N> | where <predicate>`.
914/// The predicate reuses the guard expression kernel, restricted to projection
915/// fields. The noun is a dotted fact name, so a scenario can assert over runtime
916/// facts such as `agent.turn.completed` as well as single-identifier user facts.
917#[derive(Clone, Debug, Eq, PartialEq)]
918pub struct ProjQuery {
919    pub noun: String,
920    pub kind: ProjQueryKind,
921    pub span: SourceSpan,
922}
923
924#[derive(Clone, Debug, Eq, PartialEq)]
925pub enum ProjQueryKind {
926    Exists,
927    Count { predicate: String, count: u32 },
928    Where { predicate: String },
929}
930
931#[derive(Clone, Debug, Eq, PartialEq)]
932pub struct TableDecl {
933    pub name: Ident,
934    pub tags: Vec<TagDecl>,
935    pub description: Option<StringLiteral>,
936    pub schema: Ident,
937    pub rows: Vec<TableRow>,
938    pub span: SourceSpan,
939}
940
941#[derive(Clone, Debug, Eq, PartialEq)]
942pub struct TableRow {
943    pub body: BlockSource,
944    pub span: SourceSpan,
945}
946
947#[derive(Clone, Debug, Eq, PartialEq)]
948pub struct CoerceDecl {
949    pub name: Ident,
950    pub params: Vec<ParamDecl>,
951    pub output: TypeSyntax,
952    pub body: BlockSource,
953    pub span: SourceSpan,
954}
955
956#[derive(Clone, Debug, Eq, PartialEq)]
957pub struct ParamDecl {
958    pub name: Ident,
959    pub ty: TypeSyntax,
960    pub span: SourceSpan,
961}
962
963#[derive(Clone, Debug, Eq, PartialEq)]
964pub enum TypeSyntax {
965    Primitive {
966        name: String,
967        span: SourceSpan,
968    },
969    LiteralString {
970        value: String,
971        span: SourceSpan,
972    },
973    Ref {
974        name: Ident,
975    },
976    AgentRef {
977        agents: Vec<Ident>,
978        span: SourceSpan,
979    },
980    Optional {
981        inner: Box<TypeSyntax>,
982        span: SourceSpan,
983    },
984    Array {
985        inner: Box<TypeSyntax>,
986        span: SourceSpan,
987    },
988    Map {
989        inner: Box<TypeSyntax>,
990        span: SourceSpan,
991    },
992    Union {
993        variants: Vec<TypeSyntax>,
994        span: SourceSpan,
995    },
996}
997
998impl TypeSyntax {
999    fn span(&self) -> SourceSpan {
1000        match self {
1001            Self::Primitive { span, .. }
1002            | Self::LiteralString { span, .. }
1003            | Self::Optional { span, .. }
1004            | Self::Array { span, .. }
1005            | Self::Map { span, .. }
1006            | Self::Union { span, .. }
1007            | Self::AgentRef { span, .. } => *span,
1008            Self::Ref { name } => name.span,
1009        }
1010    }
1011}
1012
1013#[derive(Clone, Debug, Eq, PartialEq)]
1014pub struct RuleDecl {
1015    pub name: Ident,
1016    pub tags: Vec<TagDecl>,
1017    pub description: Option<StringLiteral>,
1018    pub whens: Vec<WhenClause>,
1019    pub body: BlockSource,
1020    pub span: SourceSpan,
1021}
1022
1023#[derive(Clone, Debug, Eq, PartialEq)]
1024pub struct WhenClause {
1025    pub text: String,
1026    pub span: SourceSpan,
1027}
1028
1029#[derive(Clone, Debug, Eq, PartialEq)]
1030pub struct BlockSource {
1031    pub text: String,
1032    pub span: SourceSpan,
1033}
1034
1035#[derive(Clone, Debug, Eq, PartialEq)]
1036pub struct ParseOutput {
1037    pub program: Program,
1038    pub diagnostics: Vec<Diagnostic>,
1039}
1040
1041#[derive(Clone, Debug, Eq, PartialEq)]
1042pub struct CompileOutput {
1043    pub ir: Option<IrProgram>,
1044    pub diagnostics: Vec<Diagnostic>,
1045    /// Non-fatal diagnostics (deprecations, style); never block compilation.
1046    pub warnings: Vec<Diagnostic>,
1047}
1048
1049#[derive(Clone, Debug, Eq, PartialEq)]
1050pub struct IrProgram {
1051    pub workflow: String,
1052    pub source_tags: Vec<IrSourceTag>,
1053    pub source_descriptions: Vec<IrSourceDescription>,
1054    pub includes: Vec<IrInclude>,
1055    pub pattern_applications: Vec<IrPatternApplication>,
1056    pub workflow_contracts: Vec<IrWorkflowContract>,
1057    pub uses: Vec<IrUse>,
1058    pub harnesses: Vec<IrHarness>,
1059    pub trackers: Vec<IrTracker>,
1060    pub streams: Vec<IrStream>,
1061    pub channels: Vec<IrChannel>,
1062    pub credentials: Vec<IrCredential>,
1063    pub gauges: Vec<IrGauge>,
1064    pub marks: Vec<IrMark>,
1065    pub campaigns: Vec<IrCampaign>,
1066    pub file_stores: Vec<IrFileStore>,
1067    pub memory_pools: Vec<IrMemoryPool>,
1068    pub events: Vec<IrEvent>,
1069    pub sources: Vec<IrSource>,
1070    pub tests: Vec<IrTest>,
1071    pub leases: Vec<IrLease>,
1072    pub ledgers: Vec<IrLedger>,
1073    pub counters: Vec<IrCounter>,
1074    pub shared_coordination_usage: Vec<IrSharedCoordinationUsage>,
1075    pub schemas: Vec<IrSchema>,
1076    pub agents: Vec<IrAgent>,
1077    pub coerces: Vec<IrCoerce>,
1078    pub assertions: Vec<IrAssertion>,
1079    pub rules: Vec<IrRule>,
1080    pub rule_dependencies: Vec<IrRuleDependency>,
1081}
1082
1083#[derive(Clone, Debug, Eq, PartialEq)]
1084pub struct IrSharedCoordinationUsage {
1085    pub resource: String,
1086    pub workflow_principals: Vec<String>,
1087}
1088
1089#[derive(Clone, Debug, Eq, PartialEq)]
1090pub struct IrSourceTag {
1091    pub name: String,
1092    pub target_kind: String,
1093    pub target: String,
1094    pub span: SourceSpan,
1095}
1096
1097#[derive(Clone, Debug, Eq, PartialEq)]
1098pub struct IrSourceDescription {
1099    pub value: String,
1100    pub target_kind: String,
1101    pub target: String,
1102    pub span: SourceSpan,
1103}
1104
1105#[derive(Clone, Debug, Eq, PartialEq)]
1106pub struct IrPatternApplication {
1107    pub pattern: String,
1108    pub alias: String,
1109    pub type_args: Vec<IrType>,
1110    pub value_args: Vec<IrPatternArgument>,
1111    pub generated: Vec<String>,
1112    /// Source span of the `pattern <Name> { ... }` DEFINITION this application
1113    /// expanded, so provenance can point back at where the reused shape lives.
1114    pub definition_span: SourceSpan,
1115    /// Source span of the `apply <Name> as <alias> { ... }` APPLICATION site.
1116    pub application_span: SourceSpan,
1117}
1118
1119#[derive(Clone, Debug, Eq, PartialEq)]
1120pub struct IrPatternArgument {
1121    pub name: String,
1122    pub value: String,
1123}
1124
1125#[derive(Clone, Debug, Eq, PartialEq)]
1126pub struct IrWorkflowContract {
1127    pub kind: IrWorkflowContractKind,
1128    pub name: String,
1129    pub ty: IrType,
1130    pub span: SourceSpan,
1131}
1132
1133#[derive(Clone, Debug, Eq, PartialEq)]
1134pub enum IrWorkflowContractKind {
1135    Input,
1136    Output,
1137    Failure,
1138}
1139
1140impl IrWorkflowContractKind {
1141    fn as_str(&self) -> &'static str {
1142        match self {
1143            Self::Input => "input",
1144            Self::Output => "output",
1145            Self::Failure => "failure",
1146        }
1147    }
1148}
1149
1150#[derive(Clone, Debug, Eq, PartialEq)]
1151pub struct IrInclude {
1152    pub path: String,
1153    pub source_hash: Option<String>,
1154}
1155
1156#[derive(Clone, Debug, Eq, PartialEq)]
1157pub struct IrAssertion {
1158    pub expr: IrExpression,
1159    pub projection_reads: Vec<IrProjectionRead>,
1160}
1161
1162#[derive(Clone, Debug, Eq, PartialEq)]
1163pub struct IrExpression {
1164    pub source: String,
1165    pub expr: Expr,
1166    pub span: SourceSpan,
1167}
1168
1169#[derive(Clone, Debug, Eq, PartialEq)]
1170pub struct IrUse {
1171    pub kind: IrUseKind,
1172    pub name: String,
1173}
1174
1175#[derive(Clone, Debug, Eq, PartialEq)]
1176pub enum IrUseKind {
1177    Package,
1178}
1179
1180/// One lowered `stream` declaration (std.vcs): the workstream tier's
1181/// declared membership + staleness bound. Runtime homing reads this.
1182#[derive(Clone, Debug, Eq, PartialEq)]
1183pub struct IrStream {
1184    pub name: String,
1185    pub members: Vec<String>,
1186    pub member_spans: Vec<SourceSpan>,
1187    pub staleness_seconds: Option<u64>,
1188    pub span: SourceSpan,
1189}
1190
1191#[derive(Clone, Debug, Eq, PartialEq)]
1192pub struct IrTracker {
1193    pub name: String,
1194    pub provider: String,
1195    pub span: SourceSpan,
1196}
1197
1198/// A lowered `channel` declaration (std.messaging): the channel identity, its
1199/// provider, and optional workspace/destination config. Lowering class is
1200/// `metadata_only`; the runtime messaging provider consumes it later.
1201#[derive(Clone, Debug, Eq, PartialEq)]
1202pub struct IrChannel {
1203    pub name: String,
1204    pub provider: String,
1205    pub workspace: Option<String>,
1206    pub destination: Option<String>,
1207    pub span: SourceSpan,
1208}
1209
1210/// A lowered `credential` declaration (DR-0053 §5): a handle plus its
1211/// declared kind, normalized to the custody protocol's kebab-case. Metadata
1212/// only — reality (material, sealing rung, grants) lives with the custodian
1213/// and governance, never in the program.
1214#[derive(Clone, Debug, Eq, PartialEq)]
1215pub struct IrCredential {
1216    pub name: String,
1217    /// Kebab-case credential kind (`bearer`, `hmac-sha256`, …).
1218    pub kind: String,
1219    pub span: SourceSpan,
1220}
1221
1222/// A lowered `mark` declaration: a named cut point riding a committing
1223/// site. `metadata_only`; the runtime stamps `mark.reached` events, the
1224/// improve store pins scenarios at them.
1225#[derive(Clone, Debug, Eq, PartialEq)]
1226pub struct IrMark {
1227    pub name: String,
1228    pub site: String,
1229    pub span: SourceSpan,
1230}
1231
1232/// A lowered `gauge` declaration (experimentation subsystem): the binding of
1233/// a judge to a quality dimension, versioning with the program. Lowering
1234/// class is `metadata_only`; the evidence engine (`whip evidence` /
1235/// `whip improve`) consumes it at runtime.
1236#[derive(Clone, Debug, Eq, PartialEq)]
1237pub struct IrGauge {
1238    pub name: String,
1239    pub site: Option<String>,
1240    /// `coerce` | `prompt` | `exec` | `labels`.
1241    pub judge_kind: String,
1242    /// The judge target: coerce name, prompt template, exec command, or
1243    /// labels source path.
1244    pub judge_target: String,
1245    /// Coerce judges only: the explicit record paths feeding the coerce
1246    /// function's parameters, in declaration order (`input.…`,
1247    /// `facts.<Class>.<field>`, or the single reserved `record`). Empty =
1248    /// declared without arguments (parses, but is not scoreable).
1249    pub judge_args: Vec<String>,
1250    pub expect: Option<IrGaugeBar>,
1251    pub inputs: Vec<String>,
1252    pub span: SourceSpan,
1253}
1254
1255/// A lowered gauge bar. `form` is `chance` (`P(field)`) or `stat`
1256/// (`p10`/`mean`/…); `op` is `>=` (`at least`) or `<=` (`at most`);
1257/// `threshold` keeps its exact source text — consumers parse.
1258#[derive(Clone, Debug, Eq, PartialEq)]
1259pub struct IrGaugeBar {
1260    pub form: String,
1261    pub subject: String,
1262    pub op: String,
1263    pub threshold: String,
1264}
1265
1266/// A lowered `campaign` declaration (improve design note §3): the named,
1267/// versioned partition of the gauge vector. `metadata_only`; consumed by
1268/// `whip improve <name>`.
1269#[derive(Clone, Debug, Eq, PartialEq)]
1270pub struct IrCampaign {
1271    pub name: String,
1272    pub ascend: Vec<String>,
1273    pub reach: Vec<IrCampaignReach>,
1274    pub guard: Vec<IrCampaignGuard>,
1275    pub sacrifice: Vec<String>,
1276    /// Campaign-attached stratified reflection (`proposer redacted`).
1277    pub proposer_redacted: bool,
1278    pub span: SourceSpan,
1279}
1280
1281#[derive(Clone, Debug, Eq, PartialEq)]
1282pub struct IrCampaignReach {
1283    pub gauge: String,
1284    pub op: String,
1285    pub threshold: String,
1286    pub unit: Option<String>,
1287}
1288
1289#[derive(Clone, Debug, Eq, PartialEq)]
1290pub struct IrCampaignGuard {
1291    pub gauge: String,
1292    pub band_percent: String,
1293}
1294
1295/// A lowered `file store` declaration (std.files): the store identity + its
1296/// literal local root directory, consumed by the runtime file provider.
1297#[derive(Clone, Debug, Eq, PartialEq)]
1298pub struct IrFileStore {
1299    pub name: String,
1300    pub root: String,
1301    /// Path globs (relative to `root`) a `read` may touch; empty = any path
1302    /// inside the root (mounting the root is the read consent). Enforced at
1303    /// runtime in addition to root-containment.
1304    pub read_globs: Vec<String>,
1305    /// Path globs a `write` may touch. S4: stores are READ-ONLY by default —
1306    /// empty means writes are DENIED (checked at compile time and enforced
1307    /// fail-closed at runtime); declaring `allow write [...]` permits and
1308    /// bounds them.
1309    pub write_globs: Vec<String>,
1310    /// Declared `provider <name>` clause; `None` = the default `local`
1311    /// provider (spec/std-files.md "Providers"). Serialized to the snapshot
1312    /// only when declared, so provider-less stores keep their prior `.ir`.
1313    pub provider: Option<String>,
1314}
1315
1316/// A lowered `memory pool` declaration (std.memory, MEM-1): the pool identity +
1317/// its optional recall context-limit budget. `metadata_only` — provides
1318/// `Resource<MemoryPool>`; providers read `context_limit` from the effect input.
1319#[derive(Clone, Debug, Eq, PartialEq)]
1320pub struct IrMemoryPool {
1321    pub name: String,
1322    /// Optional recall packing budget (`context limit <n>`); providers read it
1323    /// from the `capability.call` effect input like any other argument.
1324    pub context_limit: Option<u64>,
1325}
1326
1327#[derive(Clone, Debug, Eq, PartialEq)]
1328pub struct IrHarness {
1329    pub name: String,
1330    pub kind: String,
1331    pub span: SourceSpan,
1332}
1333
1334#[derive(Clone, Debug, Eq, PartialEq)]
1335pub enum IrSchema {
1336    Enum(IrEnum),
1337    Class(IrClass),
1338}
1339
1340#[derive(Clone, Debug, Eq, PartialEq)]
1341pub struct IrEnum {
1342    pub name: String,
1343    pub variants: Vec<String>,
1344    pub span: SourceSpan,
1345}
1346
1347#[derive(Clone, Debug, Eq, PartialEq)]
1348pub struct IrClass {
1349    pub name: String,
1350    pub fields: Vec<IrClassField>,
1351    pub span: SourceSpan,
1352}
1353
1354/// A declared external event: the typed ingress manifest
1355/// (spec/event-ingress.md). Dotted name, class-shaped payload.
1356#[derive(Clone, Debug, Eq, PartialEq)]
1357pub struct IrEvent {
1358    pub name: String,
1359    pub fields: Vec<IrClassField>,
1360    pub span: SourceSpan,
1361}
1362
1363/// A lowered source declaration (spec/std-time.md). `is_clock` selects the
1364/// `clock_source` lowering; otherwise `signal_source`. Both lower through the
1365/// `source_declaration` construct family and admit a durable signal fact.
1366#[derive(Clone, Debug, Eq, PartialEq)]
1367pub struct IrSource {
1368    pub name: String,
1369    pub provider: String,
1370    pub is_clock: bool,
1371    /// The `file` provider: reads `path` line-by-line and admits one signal per
1372    /// non-empty line, keyed by (source, line index) so re-reads are idempotent.
1373    pub is_file: bool,
1374    /// The `http` provider: GETs `url`, parses a JSON array, and admits one
1375    /// signal per element, keyed by (source, element index) so re-polls are
1376    /// idempotent.
1377    pub is_http: bool,
1378    pub recurrence: Option<Recurrence>,
1379    pub timezone: Option<String>,
1380    pub missed: Option<MissedPolicy>,
1381    /// `path "<file>"` — the file read line-by-line by a `file` source in line
1382    /// mode (`None` otherwise; exactly one of `path`/`watch`).
1383    pub path: Option<String>,
1384    /// `watch "<glob>"` — the glob a `file` source polls in occurrence mode
1385    /// (`None` otherwise): one signal per new (path, content-hash) occurrence.
1386    pub watch: Option<String>,
1387    /// `url "<url>"` — the endpoint GET'd by an `http` source (`None` otherwise).
1388    pub url: Option<String>,
1389    /// `dedup <observe>.<field>` — the observation field carrying the provider
1390    /// delivery id for `file` (line mode) / `http` sources; replaces the
1391    /// positional-ordinal admission key when declared.
1392    pub dedup_field: Option<String>,
1393    pub observe_binding: String,
1394    pub emit_signal: String,
1395    /// S6 `emit … from` — the projection source binding; when set, the
1396    /// signal's declared fields not overridden in `emit_fields` are expanded
1397    /// to copies off this binding after all declarations lower.
1398    pub emit_from: Option<String>,
1399    pub emit_fields: Vec<IrSourceEmitField>,
1400    pub span: SourceSpan,
1401}
1402
1403#[derive(Clone, Debug, Eq, PartialEq)]
1404pub struct IrSourceEmitField {
1405    pub name: String,
1406    pub value: SourceValue,
1407    pub span: SourceSpan,
1408}
1409
1410/// A lowered test scenario (spec/workflow-testing.md). Tests are excluded from
1411/// the executable IR (`compile`/`run` ignore them); `whip check` validates them
1412/// and `whip test` runs them. The clause detail is retained for the harness.
1413#[derive(Clone, Debug, Eq, PartialEq)]
1414pub struct IrTest {
1415    pub name: String,
1416    pub workflow: Option<String>,
1417    pub clauses: Vec<TestClause>,
1418    pub span: SourceSpan,
1419}
1420
1421/// Coordination resources (spec/coordination.md), lowered.
1422#[derive(Clone, Debug, Eq, PartialEq)]
1423pub struct IrLease {
1424    pub name: String,
1425    pub key_type: String,
1426    pub slots: u32,
1427    pub ttl_seconds: u64,
1428    pub shared: bool,
1429    pub span: SourceSpan,
1430}
1431
1432#[derive(Clone, Debug, Eq, PartialEq)]
1433pub struct IrLedger {
1434    pub name: String,
1435    pub entry_schema: String,
1436    pub partition_field: String,
1437    pub retain_seconds: u64,
1438    pub shared: bool,
1439    pub span: SourceSpan,
1440}
1441
1442#[derive(Clone, Debug, Eq, PartialEq)]
1443pub struct IrCounter {
1444    pub name: String,
1445    pub key_type: String,
1446    pub cap: i64,
1447    pub reset: String,
1448    /// IANA timezone anchoring the reset-period boundary; `None` = UTC.
1449    pub timezone: Option<String>,
1450    pub shared: bool,
1451    pub span: SourceSpan,
1452}
1453
1454#[derive(Clone, Debug, Eq, PartialEq)]
1455pub struct IrClassField {
1456    pub name: String,
1457    pub ty: IrType,
1458    /// `@key`: this field is the class's natural key (import per-row idempotency).
1459    pub is_key: bool,
1460    /// Family B presence condition: `(discriminant field name, required literal)`.
1461    /// When set, the field is present only when the discriminant equals the literal
1462    /// (spec/decision-records/discriminated-families-design.md §5.7).
1463    pub presence_condition: Option<(String, String)>,
1464    pub span: SourceSpan,
1465}
1466
1467#[derive(Clone, Debug, Eq, PartialEq)]
1468pub enum IrType {
1469    Primitive(IrPrimitiveType),
1470    LiteralString(String),
1471    Ref(String),
1472    AgentRef(Vec<String>),
1473    Object(Vec<IrClassField>),
1474    Optional(Box<IrType>),
1475    Array(Box<IrType>),
1476    Map(Box<IrType>),
1477    Union(Vec<IrType>),
1478}
1479
1480#[derive(Clone, Debug, Eq, PartialEq)]
1481pub enum IrPrimitiveType {
1482    String,
1483    Int,
1484    Float,
1485    Bool,
1486    Null,
1487    Duration,
1488    Time,
1489    Image,
1490    Audio,
1491    Pdf,
1492    Video,
1493    /// DR-0053 §5: the carrier of credential custody. A `secret` value can be
1494    /// bound, passed, stored in a field, and placed in an effect position —
1495    /// and no operation anywhere in the language or runtime yields its
1496    /// material (`models/maude/credential-no-eliminator.maude`).
1497    Secret,
1498}
1499
1500#[derive(Clone, Debug, Eq, PartialEq)]
1501pub struct IrAgent {
1502    pub name: String,
1503    /// Where the declaration sits, so a diagnostic about this agent's PROVIDER
1504    /// can point at the line that binds it rather than only at the `tell` that
1505    /// tripped over it (DR-0062). The binding is per-agent and the fix is almost
1506    /// always here, not at the call site.
1507    ///
1508    /// Not part of the `.ir` snapshot: analysis-facing metadata, like
1509    /// `IrEffectNode::agent`.
1510    pub span: SourceSpan,
1511    pub harness: Option<String>,
1512    pub provider: Option<String>,
1513    pub profile: Option<String>,
1514    pub capacity: Option<u32>,
1515    pub skills: Vec<String>,
1516    pub capabilities: Vec<String>,
1517    /// Portable feature requirements (`requires [<feature.class>]`, DR-0015 /
1518    /// spec/std-agent.md slice 6): taxonomy classes the selected provider's
1519    /// accepted feature report must state as supported.
1520    pub requires: Vec<String>,
1521    /// Workflows this agent may invoke as typed tools (DR-0025 `tools [...]`).
1522    pub tools: Vec<String>,
1523    /// Owned-harness conversation-compaction strategy (context-assembly Phase 5):
1524    /// `summarize` (default), `hard_reset`, `tool_results`, or `none`. `None` uses
1525    /// the harness default.
1526    pub compaction: Option<String>,
1527    /// Owned-harness thread continuation across tells:
1528    /// `continue` or `fresh`. `None` = `fresh` (every tell starts from scratch).
1529    pub thread: Option<String>,
1530    /// Ambient-config sources a Delegated harness may read (DR-0034 Decision 4):
1531    /// `project`, `user`, or `none`. `None` means the provider's own default —
1532    /// deliberately NOT the crippled empty set.
1533    pub settings: Option<String>,
1534    /// The harness class (DR-0034): `Managed` (WhippleScript is the runtime) vs
1535    /// `Delegated` (a foreign runtime that assembles its own context). Derived from
1536    /// the resolved provider/harness kind at lowering.
1537    pub harness_class: HarnessClass,
1538}
1539
1540#[derive(Clone, Debug, Eq, PartialEq)]
1541pub struct IrCoerce {
1542    pub name: String,
1543    /// Where the declaration sits, so a diagnostic about the endpoint this
1544    /// coerce reaches can point at its `provider` clause (DR-0062), the same way
1545    /// an agent's does. Not part of the `.ir` snapshot.
1546    pub span: SourceSpan,
1547    pub params: Vec<IrParam>,
1548    pub output: IrType,
1549    pub body: String,
1550    /// The backend named by the declaration's `provider <name>` clause, surfaced
1551    /// so information-flow analysis can treat THIS endpoint as the principal a
1552    /// `coerce` egresses to (DR-0062). `None` when the declaration names none —
1553    /// the backend is then whatever the selection ladder resolves at runtime, so
1554    /// there is no static endpoint identity to govern by.
1555    ///
1556    /// Not part of the `.ir` snapshot: like `IrEffectNode::agent`, this is
1557    /// analysis-facing metadata, not lowered program shape.
1558    pub provider: Option<String>,
1559}
1560
1561#[derive(Clone, Debug, Eq, PartialEq)]
1562pub struct IrParam {
1563    pub name: String,
1564    pub ty: IrType,
1565}
1566
1567#[derive(Clone, Debug, Eq, PartialEq)]
1568pub struct IrRule {
1569    pub name: String,
1570    pub whens: Vec<IrWhen>,
1571    pub body: String,
1572    pub metadata: IrRuleMetadata,
1573}
1574
1575#[derive(Clone, Debug, Eq, PartialEq)]
1576pub struct IrWhen {
1577    pub source: String,
1578    pub pattern: String,
1579    pub guard: Option<IrExpression>,
1580    pub span: SourceSpan,
1581}
1582
1583#[derive(Clone, Debug, Eq, PartialEq)]
1584pub struct IrRuleDependency {
1585    pub producer: String,
1586    pub consumer: String,
1587    pub fact: String,
1588}
1589
1590/// DR-0043 Decision 5: one effect the region contains, with the level-1
1591/// `after` scope the kernel keys its effect id under.
1592#[derive(Clone, Debug, Eq, PartialEq)]
1593pub struct IrRegionEffect {
1594    pub binding: String,
1595    pub scope: Option<(String, String)>,
1596}
1597
1598/// DR-0043 Decision 5: a rule's `during`/`until` region, pre-rendered as the
1599/// three body variants the kernel lowers against. `IrRule.body` itself is the
1600/// condition-HOLDS variant (region spliced inline), so every existing text
1601/// scanner and effect-id derivation is untouched; the kernel swaps in
1602/// `body_removed` (region gone -- post-lapse suppression) or `body_lapsed`
1603/// (region replaced by its arm) per the region's durable state. NOT rendered
1604/// into the .ir snapshot (derived, deterministic).
1605#[derive(Clone, Debug, Eq, PartialEq)]
1606pub struct IrRegion {
1607    pub until: bool,
1608    /// Guard-grammar condition text; the kernel parses and evaluates it
1609    /// atomically inside each advancing commit.
1610    pub condition: String,
1611    pub lapse_binding: Option<String>,
1612    pub effects: Vec<IrRegionEffect>,
1613    pub body_removed: String,
1614    pub body_lapsed: String,
1615    /// The `on lapse` arm's own text, without the ambient statements that
1616    /// `body_lapsed` splices around it. The arm is the only part of the rule no
1617    /// other pass sees (the canonical body is the HOLDS variant), so it is
1618    /// validated separately and must not re-report the ambient lines.
1619    pub arm_content: String,
1620    /// The `(scrutinee, pattern)` chain of the `case` arms enclosing the region,
1621    /// outermost first. Family B narrowing of the lapse arm starts from the
1622    /// allowances those arms grant, not from the rule top.
1623    pub arm_case_arms: Vec<(String, String)>,
1624}
1625
1626#[derive(Clone, Debug, Default, Eq, PartialEq)]
1627pub struct IrRuleMetadata {
1628    pub fact_reads: Vec<String>,
1629    pub projection_reads: Vec<IrProjectionRead>,
1630    pub fact_writes: Vec<String>,
1631    pub record_sources: Vec<IrRecordSource>,
1632    pub fact_consumes: Vec<String>,
1633    pub effects: Vec<IrEffectNode>,
1634    pub dependencies: Vec<IrEffectDependency>,
1635    /// DR-0043: the rule's `during`/`until` region (at most one in v1).
1636    pub region: Option<IrRegion>,
1637    pub case_branches: Vec<IrRuleCaseBranch>,
1638    pub terminal_outputs: Vec<IrTerminalOutput>,
1639    pub terminal_branches: Vec<IrTerminalCaseBranch>,
1640    /// The output bindings this rule `complete`s (the `name` of each `complete
1641    /// <binding> {…}` in the body, recursing into after/case/branch/handler blocks).
1642    /// Surfaced for the information-flow checker: a `complete result` returns a value
1643    /// to the workflow's invoker, an egress sink at the invoker boundary (DR-0030 X2).
1644    /// IFC-only — deliberately NOT rendered in the `.ir` snapshot, so it adds no
1645    /// golden/hash churn.
1646    pub terminal_completes: Vec<String>,
1647    /// The `redact <source> keep [..] as <out>` projections in this rule body
1648    /// (recursing into after/case/branch/handler blocks). Surfaced for the
1649    /// information-flow value-flow engine: a redaction is the explicit crossing at
1650    /// which the rule-level opaque join box is refined — the projected binding
1651    /// carries only the kept fields' labels (DR-0027, proven in
1652    /// models/lean/Whipple/Redaction.lean). IFC-only — NOT rendered in the `.ir`
1653    /// snapshot, so it adds no golden/hash churn.
1654    pub redactions: Vec<IrRedaction>,
1655    /// Per egress sink, the set of binding roots its payload references (union
1656    /// across branches), keyed by the sink string the IFC engine uses: a `complete
1657    /// <binding>` by its binding, a `record <Schema>` by `fact:<Schema>`, a `send via
1658    /// <channel>` by the channel. IFC-only (NOT in the `.ir` snapshot). The engine
1659    /// uses this to recognize a FULLY-REDACTED egress — one whose payload references
1660    /// only redaction outputs — and govern its leak check by the projection's
1661    /// per-field label rather than the rule's whole read set (DR-0027 redact, the
1662    /// static refinement).
1663    pub egress_payload_reads: BTreeMap<String, BTreeSet<String>>,
1664    /// The output roots of `coerce … declassified` crossings in this rule: the
1665    /// coerce's binding plus its `after <binding> succeeds|completes as <alias>`
1666    /// aliases (the names an egress payload actually references). The IFC engine
1667    /// waives the read×sink leak check for an egress carried ENTIRELY by these
1668    /// roots when a matching `grant declassify` covers the sink (DR-0027
1669    /// I-IFC3 — grants authorize marked crossings only). IFC-only (NOT in the
1670    /// `.ir` snapshot).
1671    pub declassified_roots: BTreeSet<String>,
1672    /// The `endorsed` dual of `declassified_roots`: output roots of `coerce …
1673    /// endorsed` crossings, and (DR-0051 §2) the claimed *item* of `claim …
1674    /// endorsed` crossings. Consulted by the inject check. IFC-only (NOT in the
1675    /// `.ir` snapshot).
1676    pub endorsed_roots: BTreeSet<String>,
1677    /// DR-0051 §3: the *item* bindings of `claim … endorsed` effects — the
1678    /// names a `when <tracker> has ready issue as <binding>` trigger bound, not
1679    /// the claim's own `as` binding.
1680    ///
1681    /// Carried separately from `endorsed_roots` because the two answer different
1682    /// questions. `endorsed_roots` says which values crossed; this says which
1683    /// queue the crossing drew its authority from, so the checker can refuse a
1684    /// marker whose tracker nobody vouched. IFC-only (NOT in the `.ir`
1685    /// snapshot).
1686    pub endorsed_claim_items: BTreeSet<String>,
1687    /// DR-0051 §4: per-field binding roots for each `record <Schema> { … }`
1688    /// egress — the same shape as `complete_field_reads`, keyed by
1689    /// `fact:<Schema>` and then by field name.
1690    ///
1691    /// `egress_payload_reads` collapses a record's roots to one set, which is
1692    /// enough to decide whether a *sink* is carried by a marked crossing but not
1693    /// which *field* it shaped. §4 needs the finer grain: a verdict field shaped
1694    /// by an endorsement must be schema-closed, while a sibling field holding a
1695    /// constant is nobody's business. IFC-only (NOT in the `.ir` snapshot).
1696    pub record_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1697    /// Per-coerce argument roots: for EVERY `coerce f(args…) as <binding>` in
1698    /// the rule (marked or not), the binding roots its argument expressions
1699    /// reference. The IFC engine resolves these to governed sources for
1700    /// input-side provenance narrowing at marked crossings — including chaining
1701    /// through unmarked coerces (a model call is a total mixing point: its
1702    /// output carries the join of all its inputs). IFC-only (NOT in the `.ir`
1703    /// snapshot).
1704    pub coerce_input_roots: BTreeMap<String, BTreeSet<String>>,
1705    /// `after <effect-binding> succeeds|completes as <alias>` → the effect
1706    /// binding, so the IFC engine can resolve payload and argument roots
1707    /// through the aliases bodies actually reference. IFC-only (NOT in the
1708    /// `.ir` snapshot).
1709    pub after_aliases: BTreeMap<String, String>,
1710    /// Per egress sink, the binding roots of every enclosing `case` scrutinee
1711    /// (DR-0046): a sink inside a `case` arm is INFLUENCED by the scrutinee —
1712    /// branching on model output and recording per-arm constants is the
1713    /// classic implicit channel. Covers record/complete/milestone/send/write
1714    /// uniformly. IFC-only (NOT in the `.ir` snapshot).
1715    pub egress_case_influence: BTreeMap<String, BTreeSet<String>>,
1716    /// Per `complete <binding>` egress, the binding roots each RESULT FIELD
1717    /// references — a two-level map `binding -> field -> {roots}`. Where
1718    /// `egress_payload_reads` joins all of a sink's fields into one set (enough for the
1719    /// fully-redacted recognizer), this keeps them SEPARATE so the IFC engine can
1720    /// compute a PER-FIELD flow signature (DR-0030 X2 v2): the reads reaching each
1721    /// result field, refined at fact granularity. IFC-only (NOT in the `.ir`
1722    /// snapshot). Union across branches; a `Shorthand` field resolves to the
1723    /// terminal's `from` binding.
1724    pub complete_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1725    /// Per `emit milestone "<name>"` egress, the binding roots each MILESTONE FIELD
1726    /// references — same shape and purpose as `complete_field_reads`, but keyed by
1727    /// milestone name. Milestone payloads are child-to-parent egresses, so IFC needs
1728    /// their per-field flow signature too (D3′). IFC-only (NOT in the `.ir`
1729    /// snapshot). Union across branches.
1730    pub milestone_field_reads: BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1731    /// Bounded-type projection egresses (`record <T> from <src>`): each is governed
1732    /// by the kept fields' per-field label join, like an explicit `redact`. IFC-only
1733    /// (NOT in the `.ir` snapshot). DR-0027 auto-redaction, the bounded-type reading.
1734    pub bounded_egresses: Vec<IrBoundedEgress>,
1735    /// Maximum nesting depth of `after` blocks in the rule body (0 = no `after`,
1736    /// 1 = a top-level `after`, 2 = an `after` inside an `after`, …). Surfaced for the
1737    /// `lint.deep_after_nesting` maintainability check.
1738    pub max_after_depth: usize,
1739}
1740
1741/// A bounded-type projection egress (`record <T> from <src>`): the recorded fact
1742/// keeps exactly `T`'s fields, copied from `src`, so the egress carries only the
1743/// kept fields' per-field labels — the "bounded-type" auto-redaction reading
1744/// (DR-0027). The bound is the declared target type `T`; the labels are the
1745/// SOURCE schema's (a target field mislabelled public is still caught against the
1746/// source's label). The IFC engine governs it exactly like an explicit `redact`.
1747#[derive(Clone, Debug, Eq, PartialEq)]
1748pub struct IrBoundedEgress {
1749    /// The engine's sink string (`fact:<T>` for a record).
1750    pub sink: String,
1751    /// The schema of the `from` source binding, whose per-field labels bound the
1752    /// projection.
1753    pub source_schema: String,
1754    /// The kept field names (the target type `T`'s fields).
1755    pub keep: Vec<String>,
1756}
1757
1758/// A `redact <source> keep [..] as <binding>` projection, surfaced for the
1759/// information-flow value-flow engine (DR-0027). `source` is the binding being
1760/// projected, `keep` the kept field names, `binding` the projected output.
1761#[derive(Clone, Debug, Eq, PartialEq)]
1762pub struct IrRedaction {
1763    pub source: String,
1764    pub keep: Vec<String>,
1765    pub binding: String,
1766    /// The schema of the source binding, when resolvable (a matched class, a
1767    /// coerce/decide/exec result, an `after … as` alias, or an earlier redaction's
1768    /// output). The information-flow engine derives the projection's confidentiality
1769    /// from the kept fields of this schema (`<schema>.<field>` labels), so a redacted
1770    /// egress needs only the kept fields' clearance, not the whole record's. `None`
1771    /// when the source type is not statically known (the engine then stays
1772    /// conservative for that redaction).
1773    pub source_schema: Option<String>,
1774}
1775
1776#[derive(Clone, Debug, Eq, PartialEq)]
1777pub struct IrRecordSource {
1778    pub schema: String,
1779    pub construct: String,
1780    pub span: SourceSpan,
1781}
1782
1783#[derive(Clone, Debug, Eq, PartialEq)]
1784pub struct IrProjectionRead {
1785    pub kind: QueryKind,
1786    pub head: String,
1787    pub guard: Option<String>,
1788}
1789
1790impl IrProjectionRead {
1791    fn to_snapshot(&self) -> String {
1792        let prefix = match self.kind {
1793            QueryKind::Fact => format!("fact:{}", self.head),
1794            QueryKind::Effect => format!("effect:{}", self.head),
1795        };
1796        match &self.guard {
1797            Some(guard) => format!("{prefix} where {guard}"),
1798            None => prefix,
1799        }
1800    }
1801}
1802
1803#[derive(Clone, Debug, Eq, PartialEq)]
1804pub struct IrEffectNode {
1805    pub id: String,
1806    pub kind: IrEffectKind,
1807    pub binding: Option<String>,
1808    pub required_capabilities: Vec<String>,
1809    pub construct_use: Option<IrConstructUse>,
1810    pub idempotency_key: String,
1811    pub span: SourceSpan,
1812    /// Creation-anchored deadline from a `timeout <duration>` clause.
1813    pub timeout_seconds: Option<u64>,
1814    /// Turn-access grants (`with access to …`) lowered onto an `agent.tell` effect as
1815    /// authority-narrowing metadata (Proposal A). Empty for non-grant effects.
1816    pub access_grants: Vec<IrAccessGrant>,
1817    /// Turn-scoped skills (`with skills [...]`) pinned onto an `agent.tell` effect as
1818    /// provenance (context-assembly Phase 7). Recorded, not enforced — the owned
1819    /// catalogue stays discover-all. Empty for effects without a skill pin.
1820    pub turn_skills: Vec<String>,
1821    /// `on stream <name>` (std.vcs): the tell's per-turn homing exception.
1822    /// `None` = the agent's declared membership decides.
1823    pub on_stream: Option<String>,
1824    /// The raw selection-slot source of an `undo`/`transport` effect
1825    /// (std.vcs R4). A string LITERAL validates statically against the
1826    /// selection grammar; a dynamic expression validates at execution.
1827    pub selection_source: Option<String>,
1828    /// The `onto <target>` of a `transport` effect: `mainline` or a
1829    /// declared stream, validated post-lowering.
1830    pub transport_onto: Option<String>,
1831    /// The named resource (file store / channel) a direct effect touches, if any —
1832    /// e.g. the store of a `read`/`write`. Surfaced so information-flow analysis can
1833    /// see rule-body data flows, not just turn-access grants. `None` for effects
1834    /// that touch no named resource. Not part of the `.ir` snapshot.
1835    pub resource: Option<String>,
1836    /// The agent a `tell` addresses (its `target`), surfaced so information-flow
1837    /// analysis can model the turn's egress to that agent's provider. `None` for
1838    /// non-`tell` effects. Not part of the `.ir` snapshot.
1839    pub agent: Option<String>,
1840    /// The `coerce` declaration this effect invokes, surfaced for the same reason
1841    /// `agent` is: it is how the analysis reaches the declaration's `provider`
1842    /// clause and so the endpoint this egress actually reaches. `None` for
1843    /// non-coerce effects AND for an inline `decide`, which names no declaration
1844    /// and therefore no backend. Not part of the `.ir` snapshot.
1845    pub coerce_target: Option<String>,
1846    /// The workflow an `invoke` addresses, surfaced so information-flow analysis can
1847    /// enumerate and govern invoke membrane ports. `None` for non-`invoke` effects.
1848    /// Not part of the `.ir` snapshot.
1849    pub workflow_target: Option<String>,
1850    /// The `endorsed` source marker (DR-0027 I-IFC3): the author declared this effect
1851    /// (a `coerce`) an integrity-raising crossing. Surfaced so the trusted surface is
1852    /// visible at the source crossing point. Not part of the `.ir` snapshot.
1853    pub endorsed: bool,
1854    /// The `declassified` source marker (DR-0027 I-IFC3): the author declared this
1855    /// `coerce` a confidentiality-lowering crossing (its output schema bounds the
1856    /// leak). Surfaced for audit. Not part of the `.ir` snapshot.
1857    pub declassified: bool,
1858    /// The innermost `case <scrutinee> { <pattern> => … }` arm this effect sits in,
1859    /// as `(scrutinee, pattern)` — the discriminated-families *selector*. Lets the
1860    /// IFC checker apply NMIF-on-the-selector: a crossing (`endorsed`/`declassified`)
1861    /// selected by a low-integrity discriminant is rejected (DR §5.6 / §7.4). `None`
1862    /// for effects outside any `case`. Not part of the `.ir` snapshot.
1863    pub selected_by: Option<(String, String)>,
1864    /// The `exec` surface form — raw command string vs manifest capability
1865    /// (spec/std-script.md "Static checks" item 2) — surfaced so check-time
1866    /// gates (hosted-raw demotion, manifest resolution) classify the effect
1867    /// from the AST instead of re-scanning rule-body text. `None` for
1868    /// non-`exec` effects. Not part of the `.ir` snapshot.
1869    pub exec_target: Option<IrExecTarget>,
1870}
1871
1872/// The two `exec` source forms (spec/std-script.md): a raw command string
1873/// (`exec "cmd"`, dev-profile only) or an operator-manifest capability
1874/// (`exec <name> with <record>`).
1875#[derive(Clone, Debug, Eq, PartialEq)]
1876pub enum IrExecTarget {
1877    Raw,
1878    Capability { name: String },
1879}
1880
1881/// A lowered turn-access grant: the granted operations narrow the turn's effective
1882/// authority on `resource` (modeled in `models/maude/turn-access-grant.maude`).
1883#[derive(Clone, Debug, Eq, PartialEq)]
1884pub struct IrAccessGrant {
1885    pub resource: String,
1886    pub operations: Vec<IrAccessGrantOp>,
1887}
1888
1889#[derive(Clone, Debug, Eq, PartialEq)]
1890pub struct IrAccessGrantOp {
1891    pub operation: String,
1892    pub target: Option<String>,
1893    pub globs: Vec<String>,
1894}
1895
1896#[derive(Clone, Debug, Eq, PartialEq)]
1897pub struct IrConstructUse {
1898    pub keyword: String,
1899    pub scope: String,
1900    pub construct_family: String,
1901    pub lowering_target: String,
1902    pub target_capability: String,
1903}
1904
1905#[derive(Clone, Debug, Eq, PartialEq)]
1906pub enum IrEffectKind {
1907    AgentTell,
1908    SchemaCoerce,
1909    CapabilityCall,
1910    EventEmit,
1911    WorkflowInvoke,
1912    TimerWait,
1913    ExecCommand,
1914    TrackerFile,
1915    TrackerClaim,
1916    TrackerRenew,
1917    TrackerRelease,
1918    TrackerFinish,
1919    LeaseAcquire,
1920    LeaseRenew,
1921    LedgerAppend,
1922    CounterConsume,
1923    SignalEmit,
1924    FileRead,
1925    FileWrite,
1926    FileImport,
1927    FileExport,
1928}
1929
1930#[derive(Clone, Debug, Eq, PartialEq)]
1931pub struct IrEffectDependency {
1932    pub upstream: String,
1933    pub predicate: DependencyPredicate,
1934    pub downstream: String,
1935}
1936
1937#[derive(Clone, Debug, Eq, PartialEq)]
1938pub struct IrRuleCaseBranch {
1939    pub scrutinee: String,
1940    pub scrutinee_type: IrType,
1941    pub pattern: IrCasePattern,
1942    pub guard: Option<IrExpression>,
1943    pub body_hash: String,
1944    pub pattern_span: SourceSpan,
1945}
1946
1947#[derive(Clone, Debug, Eq, PartialEq)]
1948pub enum IrCasePattern {
1949    EnumVariant(String),
1950    LiteralString(String),
1951    Agent(String),
1952    OptionalSome { binding: String },
1953    OptionalNone,
1954    Wildcard,
1955}
1956
1957impl IrCasePattern {
1958    fn to_snapshot(&self) -> String {
1959        match self {
1960            IrCasePattern::EnumVariant(value) => format!("enum:{value}"),
1961            IrCasePattern::LiteralString(value) => format!("literal:\"{value}\""),
1962            IrCasePattern::Agent(value) => format!("agent:{value}"),
1963            IrCasePattern::OptionalSome { binding } => format!("some:{binding}"),
1964            IrCasePattern::OptionalNone => "none".to_owned(),
1965            IrCasePattern::Wildcard => "_".to_owned(),
1966        }
1967    }
1968}
1969
1970#[derive(Clone, Debug, Eq, PartialEq)]
1971pub struct IrTerminalOutput {
1972    pub binding: String,
1973    pub alternatives: Vec<IrTerminalAlternative>,
1974    pub span: SourceSpan,
1975}
1976
1977#[derive(Clone, Debug, Eq, PartialEq)]
1978pub struct IrTerminalAlternative {
1979    pub tag: String,
1980    pub payload_type: IrType,
1981    pub source_span: SourceSpan,
1982}
1983
1984#[derive(Clone, Debug, Eq, PartialEq)]
1985pub struct IrTerminalCaseBranch {
1986    pub scrutinee: String,
1987    pub tag: Option<String>,
1988    pub binding: Option<String>,
1989    pub guard: Option<IrExpression>,
1990    pub body_hash: String,
1991    pub pattern_span: SourceSpan,
1992}
1993
1994#[derive(Clone, Debug, Eq, PartialEq)]
1995pub enum DependencyPredicate {
1996    Succeeds,
1997    Fails,
1998    TimedOut,
1999    Cancelled,
2000    Completes,
2001}
2002
2003#[derive(Clone, Debug)]
2004struct SemanticContext {
2005    workflow: Option<String>,
2006    schemas: SchemaIndex,
2007    agents: BTreeSet<String>,
2008    agent_capabilities: BTreeMap<String, BTreeSet<String>>,
2009    coerce_outputs: BTreeMap<String, TypeSyntax>,
2010    coerce_params: BTreeMap<String, Vec<ParamDecl>>,
2011    workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
2012    /// Declared coordination resources (spec/coordination.md).
2013    leases: BTreeSet<String>,
2014    ledgers: BTreeSet<String>,
2015    counters: BTreeSet<String>,
2016    /// Declared `channel` names (std.messaging); `send via <channel>` must name one.
2017    channels: BTreeSet<String>,
2018    /// Declared channel providers by channel name (std.messaging): the
2019    /// capability-report-conditioned checks (send requires outbound-capable,
2020    /// `when message from` requires inbound-capable) resolve the report
2021    /// through this map.
2022    channel_providers: BTreeMap<String, String>,
2023    /// Declared `credential` kinds by name (std.custody; DR-0053 §5): the
2024    /// kind-conditioned static checks (`sign … with` needs a signing kind,
2025    /// presentation forms need a presentable kind) resolve through this map.
2026    /// Kinds are stored kebab-case, matching the custody protocol.
2027    /// Consumed once `call`/`verify` land; the declaration ships first
2028    /// (custody-first sequencing).
2029    #[allow(dead_code)]
2030    credentials: BTreeMap<String, String>,
2031    /// Declared `memory pool` names (std.memory); `recall`/`learn`/`curate`
2032    /// must name one (MEM-1 check 1).
2033    memory_pools: BTreeSet<String>,
2034    /// DR-0043 regions by rule name, stashed by `extract_rule_regions` before the
2035    /// rule body was rewritten to its condition-HOLDS variant. The `on lapse` arm
2036    /// is spliced out of that body, so it reaches no other pass; analysis reads it
2037    /// back from here to type the arm (Decision 7 obligation 2).
2038    regions: BTreeMap<String, IrRegion>,
2039}
2040
2041#[derive(Clone, Debug, Default)]
2042struct WorkflowInputSurface {
2043    inputs: BTreeMap<String, TypeSyntax>,
2044    /// The workflow's `output` contract types by name. A parent's
2045    /// `after <invoke-binding> succeeds as r` binds `r` to this contract so
2046    /// `r.<field>` type-checks against the child's declared output (the runtime
2047    /// already carries the child's terminal payload into that binding).
2048    outputs: BTreeMap<String, TypeSyntax>,
2049    /// The workflow's `failure` contract types by name. A parent's
2050    /// `after <invoke-binding> fails as f` binds `f` to this contract (when it is
2051    /// a shared top-level class) so `f.<field>` type-checks against the child's
2052    /// declared failure shape, instead of the generic DR-0032 `TerminalFailed`
2053    /// base. Falls back to the base when the failure class is child-local or the
2054    /// child declares zero/several failures.
2055    failures: BTreeMap<String, TypeSyntax>,
2056    schemas: SchemaIndex,
2057    /// Milestones the workflow may project (Family C): name -> payload class
2058    /// (empty string for a bare, payload-less milestone). Derived by scanning the
2059    /// workflow's rule bodies for `emit milestone "<name>" [of <Class>]`. This is
2060    /// the `declared(S)` set a parent's `after p reaches "<name>"` validates
2061    /// against (reject-undeclared) and the source of the observing binding's type.
2062    milestones: BTreeMap<String, String>,
2063}
2064
2065#[derive(Clone, Debug, Default)]
2066struct SchemaIndex {
2067    classes: BTreeMap<String, BTreeMap<String, TypeSyntax>>,
2068    enums: BTreeMap<String, BTreeSet<String>>,
2069    /// Declared external signals (spec/event-ingress.md); their payload
2070    /// schemas live in `classes` keyed by the dotted signal name.
2071    events: BTreeSet<String>,
2072    /// Family B: per-schema field presence conditions, `schema -> field ->
2073    /// (discriminant field, required literal)`. A conditioned field is readable
2074    /// only inside a matching `case <root>.<disc>` arm.
2075    presence: BTreeMap<String, BTreeMap<String, (String, String)>>,
2076}
2077
2078#[derive(Clone, Debug, Eq, PartialEq)]
2079enum BlockFrame {
2080    After {
2081        binding: String,
2082        predicate: DependencyPredicate,
2083    },
2084}
2085
2086#[derive(Clone, Debug, Eq, PartialEq)]
2087enum LiteralExpr<'a> {
2088    String(&'a str),
2089    Number(&'a str),
2090    Bool,
2091    Null,
2092    Ident(&'a str),
2093}
2094
2095#[derive(Clone, Debug, Eq, PartialEq)]
2096enum ExprType {
2097    Bool,
2098    Int,
2099    Float,
2100    String,
2101    Duration,
2102    Time,
2103    /// DR-0053: distinct from `String` so no operator, comparison, or
2104    /// interpolation accepts a secret where prose is expected — the
2105    /// expression-level face of the no-eliminator property. Deliberately NOT
2106    /// `Unknown`, which type-checks everywhere.
2107    Secret,
2108    Null,
2109    Object,
2110    Optional(Box<ExprType>),
2111    Array(Box<ExprType>),
2112    Map(Box<ExprType>),
2113    Finite {
2114        label: String,
2115        values: Vec<String>,
2116    },
2117    Collection,
2118    Unknown,
2119}
2120
2121#[derive(Clone, Debug, Eq, PartialEq)]
2122pub enum Expr {
2123    Literal(ExprLiteral),
2124    Path(Vec<String>),
2125    Index {
2126        target: Box<Expr>,
2127        key: Box<Expr>,
2128    },
2129    Array(Vec<Expr>),
2130    Object(Vec<ExprObjectField>),
2131    Unary {
2132        op: UnaryOp,
2133        expr: Box<Expr>,
2134    },
2135    Binary {
2136        op: BinaryOp,
2137        left: Box<Expr>,
2138        right: Box<Expr>,
2139    },
2140    Call {
2141        name: String,
2142        args: Vec<Expr>,
2143    },
2144    Query {
2145        kind: QueryKind,
2146        head: String,
2147        guard: Option<Box<Expr>>,
2148    },
2149}
2150
2151#[derive(Clone, Debug, Eq, PartialEq)]
2152pub struct ExprObjectField {
2153    pub key: String,
2154    pub value: Expr,
2155}
2156
2157#[derive(Clone, Debug, Eq, PartialEq)]
2158pub enum ExprLiteral {
2159    String(String),
2160    Number(String),
2161    Bool(bool),
2162    Null,
2163    Ident(String),
2164}
2165
2166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2167pub enum UnaryOp {
2168    Not,
2169}
2170
2171#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2172pub enum BinaryOp {
2173    Or,
2174    And,
2175    Eq,
2176    Ne,
2177    Lt,
2178    Le,
2179    Gt,
2180    Ge,
2181    In,
2182    NotIn,
2183    Add,
2184    Sub,
2185    Mul,
2186    Div,
2187}
2188
2189#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2190pub enum QueryKind {
2191    Fact,
2192    Effect,
2193}
2194
2195/// Parses a deterministic expression used by guards, assertions, and branch guards.
2196pub fn parse_expression(expr: &str) -> Result<Expr, String> {
2197    ExprParser::new(expr).parse()
2198}
2199
2200impl Expr {
2201    pub fn to_snapshot(&self) -> String {
2202        match self {
2203            Self::Literal(literal) => literal.to_snapshot(),
2204            Self::Path(path) => path.join("."),
2205            Self::Index { target, key } => {
2206                format!(
2207                    "{}[{}]",
2208                    target.to_snapshot_with_parentheses(),
2209                    key.to_snapshot()
2210                )
2211            }
2212            Self::Array(items) => {
2213                let items = items
2214                    .iter()
2215                    .map(Self::to_snapshot)
2216                    .collect::<Vec<_>>()
2217                    .join(", ");
2218                format!("[{items}]")
2219            }
2220            Self::Object(fields) => {
2221                let fields = fields
2222                    .iter()
2223                    .map(|field| format!("{} {}", field.key, field.value.to_snapshot()))
2224                    .collect::<Vec<_>>()
2225                    .join(", ");
2226                format!("{{{fields}}}")
2227            }
2228            Self::Unary { op, expr } => match op {
2229                UnaryOp::Not => format!("!{}", expr.to_snapshot_with_parentheses()),
2230            },
2231            Self::Binary { op, left, right } => format!(
2232                "{} {} {}",
2233                left.to_snapshot_with_parentheses(),
2234                op.to_snapshot(),
2235                right.to_snapshot_with_parentheses()
2236            ),
2237            Self::Call { name, args } => {
2238                let args = args
2239                    .iter()
2240                    .map(Self::to_snapshot)
2241                    .collect::<Vec<_>>()
2242                    .join(", ");
2243                format!("{name}({args})")
2244            }
2245            Self::Query { kind, head, guard } => {
2246                let prefix = match kind {
2247                    QueryKind::Fact => head.clone(),
2248                    QueryKind::Effect => format!("effect {head}"),
2249                };
2250                match guard {
2251                    Some(guard) => format!("{prefix} where {}", guard.to_snapshot()),
2252                    None => prefix,
2253                }
2254            }
2255        }
2256    }
2257
2258    fn to_snapshot_with_parentheses(&self) -> String {
2259        match self {
2260            Self::Binary { .. } => format!("({})", self.to_snapshot()),
2261            _ => self.to_snapshot(),
2262        }
2263    }
2264}
2265
2266impl ExprLiteral {
2267    fn to_snapshot(&self) -> String {
2268        match self {
2269            Self::String(value) => format!("{value:?}"),
2270            Self::Number(value) | Self::Ident(value) => value.clone(),
2271            Self::Bool(value) => value.to_string(),
2272            Self::Null => "null".to_owned(),
2273        }
2274    }
2275}
2276
2277impl BinaryOp {
2278    fn to_snapshot(self) -> &'static str {
2279        match self {
2280            Self::Or => "||",
2281            Self::And => "&&",
2282            Self::Eq => "==",
2283            Self::Ne => "!=",
2284            Self::Lt => "<",
2285            Self::Le => "<=",
2286            Self::Gt => ">",
2287            Self::Ge => ">=",
2288            Self::In => "in",
2289            Self::NotIn => "not in",
2290            Self::Add => "+",
2291            Self::Sub => "-",
2292            Self::Mul => "*",
2293            Self::Div => "/",
2294        }
2295    }
2296}
2297
2298/// Parses and lowers a source file into deterministic typed IR.
2299pub fn compile_program(source: &str) -> CompileOutput {
2300    compile_program_with_root(source, None)
2301}
2302
2303/// Parses and lowers a source bundle into deterministic typed IR with an
2304/// optional explicit root workflow selection.
2305pub fn compile_program_with_root(source: &str, root: Option<&str>) -> CompileOutput {
2306    let parsed = parse_program(source);
2307    if !parsed.diagnostics.is_empty() {
2308        return CompileOutput {
2309            ir: None,
2310            diagnostics: parsed.diagnostics,
2311            warnings: Vec::new(),
2312        };
2313    }
2314
2315    // Program-level static check over ALL workflows (before root selection):
2316    // transitive runtime invocation cycles have no compile-time convergence proof
2317    // and are rejected (RESOLVED 2026-07-01). Direct self-invocation is caught
2318    // per-rule during lowering.
2319    let mut invoke_recursion_diagnostics = Vec::new();
2320    detect_workflow_invoke_recursion(&parsed.program, &mut invoke_recursion_diagnostics);
2321    detect_private_workflow_invocations(&parsed.program, &mut invoke_recursion_diagnostics);
2322    if !invoke_recursion_diagnostics.is_empty() {
2323        return CompileOutput {
2324            ir: None,
2325            diagnostics: invoke_recursion_diagnostics,
2326            warnings: Vec::new(),
2327        };
2328    }
2329
2330    let workflow_inputs = collect_workflow_input_surfaces(&parsed.program);
2331    let shared_coordination_usage = collect_shared_coordination_usage(&parsed.program);
2332
2333    // Whole-program validation (RESOLVED 2026-07-01): when a program declares
2334    // more than one explicit `workflow`, validate EVERY workflow — not only the
2335    // selected root — so a broken sibling is caught in a single compile
2336    // regardless of which `--root` is chosen. Each workflow is lowered against
2337    // its own scope (top-level globals + that workflow's local block items),
2338    // which is exactly the scoped program `select_root_workflow` builds for that
2339    // name. Root selection below still produces the single entry IR for
2340    // `dev`/`deploy`; this pass only adds validation coverage and never changes
2341    // the emitted IR (when it finds no errors it returns nothing, so the root is
2342    // lowered once more, cleanly, below). See models/maude/workflow-scoping.maude.
2343    if parsed.program.workflows.len() > 1 {
2344        // Names declared at the top level are global (shared across every
2345        // workflow); names declared inside a `workflow { ... }` block are private
2346        // to it. Map each workflow-local name to its owning workflow(s) so that
2347        // when a workflow references a name that is really a sibling's local, the
2348        // resulting unknown-name error can point the author at where it lives —
2349        // the "names do not leak into sibling workflows" guarantee, surfaced.
2350        let global_names: BTreeSet<String> = parsed
2351            .program
2352            .items
2353            .iter()
2354            .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2355            .collect();
2356        let mut sibling_locals: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
2357        for workflow in &parsed.program.workflows {
2358            for item in &workflow.items {
2359                if let Some((name, span)) = referenced_decl_name(item) {
2360                    sibling_locals
2361                        .entry(name)
2362                        .or_default()
2363                        .push((workflow.name.name.clone(), span));
2364                }
2365            }
2366        }
2367
2368        let mut aggregated = Vec::new();
2369        for workflow in &parsed.program.workflows {
2370            let name = workflow.name.name.clone();
2371            let own_locals: BTreeSet<String> = workflow
2372                .items
2373                .iter()
2374                .filter_map(|item| referenced_decl_name(item).map(|(name, _)| name))
2375                .collect();
2376            let mut diagnostics = match select_root_workflow(parsed.program.clone(), Some(&name)) {
2377                Ok(scoped) => {
2378                    lower_program(
2379                        scoped,
2380                        workflow_inputs.clone(),
2381                        shared_coordination_usage.clone(),
2382                    )
2383                    .diagnostics
2384                }
2385                Err(diagnostics) => diagnostics,
2386            };
2387            for diagnostic in &mut diagnostics {
2388                annotate_cross_workflow_leak(
2389                    diagnostic,
2390                    &name,
2391                    &own_locals,
2392                    &global_names,
2393                    &sibling_locals,
2394                );
2395            }
2396            aggregated.extend(diagnostics);
2397        }
2398        if !aggregated.is_empty() {
2399            return CompileOutput {
2400                ir: None,
2401                diagnostics: aggregated,
2402                warnings: Vec::new(),
2403            };
2404        }
2405    }
2406
2407    match select_root_workflow(parsed.program, root) {
2408        Ok(program) => lower_program(program, workflow_inputs, shared_coordination_usage),
2409        Err(diagnostics) => CompileOutput {
2410            ir: None,
2411            diagnostics,
2412            warnings: Vec::new(),
2413        },
2414    }
2415}
2416
2417/// One top-level declaration for an editor outline (`whip lsp`'s
2418/// `textDocument/documentSymbol`): its name, a coarse kind tag, and source span.
2419#[derive(Clone, Debug, Eq, PartialEq)]
2420pub struct DeclSymbol {
2421    pub name: String,
2422    pub kind: &'static str,
2423    pub span: SourceSpan,
2424}
2425
2426/// Top-level declarations of `source` in source order, for an editor outline. On a
2427/// parse error it returns whatever declarations parsed (best-effort outline).
2428pub fn document_symbols(source: &str) -> Vec<DeclSymbol> {
2429    let program = parse_program(source).program;
2430    let mut symbols = Vec::new();
2431    if let Some(workflow) = &program.workflow {
2432        symbols.push(DeclSymbol {
2433            name: workflow.name.clone(),
2434            kind: "workflow",
2435            span: workflow.span,
2436        });
2437    }
2438    for workflow in &program.workflows {
2439        symbols.push(DeclSymbol {
2440            name: workflow.name.name.clone(),
2441            kind: "workflow",
2442            span: workflow.span,
2443        });
2444    }
2445    for pattern in &program.patterns {
2446        symbols.push(DeclSymbol {
2447            name: pattern.name.name.clone(),
2448            kind: "pattern",
2449            span: pattern.span,
2450        });
2451    }
2452    for item in &program.items {
2453        let symbol = match item {
2454            Item::Class(decl) => ("class", decl.name.name.clone(), decl.span),
2455            Item::Enum(decl) => ("enum", decl.name.name.clone(), decl.span),
2456            Item::Agent(decl) => ("agent", decl.name.name.clone(), decl.span),
2457            Item::Rule(decl) => ("rule", decl.name.name.clone(), decl.span),
2458            Item::Coerce(decl) => ("coerce", decl.name.name.clone(), decl.span),
2459            Item::Action(decl) => ("action", decl.name.name.clone(), decl.span),
2460            Item::Lease(decl) => ("lease", decl.name.name.clone(), decl.span),
2461            Item::Ledger(decl) => ("ledger", decl.name.name.clone(), decl.span),
2462            Item::Counter(decl) => ("counter", decl.name.name.clone(), decl.span),
2463            Item::Tracker(decl) => ("tracker", decl.name.name.clone(), decl.span),
2464            Item::Channel(decl) => ("channel", decl.name.name.clone(), decl.span),
2465            Item::Credential(decl) => ("credential", decl.name.name.clone(), decl.span),
2466            Item::FileStore(decl) => ("file store", decl.name.name.clone(), decl.span),
2467            Item::MemoryPool(decl) => ("memory pool", decl.name.name.clone(), decl.span),
2468            Item::Event(decl) => ("signal", decl.name.clone(), decl.span),
2469            Item::Table(decl) => ("table", decl.name.name.clone(), decl.span),
2470            Item::Gauge(decl) => ("gauge", decl.name.name.clone(), decl.span),
2471            Item::Campaign(decl) => ("campaign", decl.name.name.clone(), decl.span),
2472            Item::Mark(decl) => ("mark", decl.name.value.clone(), decl.span),
2473            _ => continue,
2474        };
2475        symbols.push(DeclSymbol {
2476            name: symbol.1,
2477            kind: symbol.0,
2478            span: symbol.2,
2479        });
2480    }
2481    symbols
2482}
2483
2484/// Zero-based source line of a byte offset.
2485fn line_index(source: &str, offset: usize) -> usize {
2486    source.as_bytes()[..offset]
2487        .iter()
2488        .filter(|&&byte| byte == b'\n')
2489        .count()
2490}
2491
2492/// Classify the comments inside `body` (a field-list declaration's brace region)
2493/// against its `members` (each member's span + already-formatted lines, in source
2494/// order). Returns the own-line comments to interleave between members, plus a
2495/// per-member optional trailing comment (appended to that member's last line).
2496/// Returns `None` when a comment cannot be placed safely — a comment inside a
2497/// *multi-line* member's own body (a deeper level this pass does not place), or a
2498/// trailing comment with no single-line member on its line — so the caller refuses
2499/// the file rather than misplace it. `comments` must be sorted by `span.start`.
2500fn classify_body_comments<'a>(
2501    source: &str,
2502    body: SourceSpan,
2503    members: &[(SourceSpan, Vec<String>)],
2504    comments: &'a [Comment],
2505) -> Option<(Vec<&'a Comment>, Vec<Option<&'a Comment>>)> {
2506    let mut own_line: Vec<&Comment> = Vec::new();
2507    let mut trailing: Vec<Option<&Comment>> = vec![None; members.len()];
2508    for comment in comments {
2509        if comment.span.start <= body.start || comment.span.start >= body.end {
2510            continue;
2511        }
2512        // A comment inside a multi-line member's own braces is a deeper level we do
2513        // not place here (e.g. a data-carrying `enum` variant's nested field).
2514        if members.iter().any(|(span, lines)| {
2515            lines.len() > 1 && span.start < comment.span.start && comment.span.start < span.end
2516        }) {
2517            return None;
2518        }
2519        let line_start = source[..comment.span.start]
2520            .rfind('\n')
2521            .map(|index| index + 1)
2522            .unwrap_or(0);
2523        if source[line_start..comment.span.start].trim().is_empty() {
2524            own_line.push(comment);
2525            continue;
2526        }
2527        // Trailing: attach to a single-line member sharing the comment's line.
2528        let comment_line = line_index(source, comment.span.start);
2529        let mut placed = false;
2530        for (index, (span, lines)) in members.iter().enumerate() {
2531            if lines.len() == 1 && line_index(source, span.start) == comment_line {
2532                if trailing[index].is_some() {
2533                    return None;
2534                }
2535                trailing[index] = Some(comment);
2536                placed = true;
2537                break;
2538            }
2539        }
2540        if !placed {
2541            return None;
2542        }
2543    }
2544    Some((own_line, trailing))
2545}
2546
2547/// Emit each member's lines, interleaving `own_line` comments by source position
2548/// (at `indent`) and appending each member's `trailing` comment to its last line.
2549/// `members` and `own_line` must be in ascending `span.start` order; `trailing`
2550/// is parallel to `members`.
2551fn emit_members_with_comments(
2552    members: &[(SourceSpan, Vec<String>)],
2553    own_line: &[&Comment],
2554    trailing: &[Option<&Comment>],
2555    indent: &str,
2556    formatted: &mut String,
2557) {
2558    let mut next = 0;
2559    for (index, (span, lines)) in members.iter().enumerate() {
2560        while next < own_line.len() && own_line[next].span.start < span.start {
2561            push_line(
2562                formatted,
2563                format!("{indent}{}", format_comment(own_line[next])),
2564            );
2565            next += 1;
2566        }
2567        let last = lines.len().saturating_sub(1);
2568        for (offset, line) in lines.iter().enumerate() {
2569            match trailing[index] {
2570                Some(comment) if offset == last => {
2571                    push_line(formatted, format!("{line}  {}", format_comment(comment)));
2572                }
2573                _ => push_line(formatted, line.clone()),
2574            }
2575        }
2576    }
2577    while next < own_line.len() {
2578        push_line(
2579            formatted,
2580            format!("{indent}{}", format_comment(own_line[next])),
2581        );
2582        next += 1;
2583    }
2584}
2585
2586/// Format a `class` body with its own-line and trailing comments preserved.
2587/// Returns `false` (caller refuses the file) when a body comment cannot be placed
2588/// safely.
2589fn try_format_class_with_comments(
2590    class_decl: &ClassDecl,
2591    source: &str,
2592    comments: &[Comment],
2593    formatted: &mut String,
2594) -> bool {
2595    let members: Vec<(SourceSpan, Vec<String>)> = class_decl
2596        .fields
2597        .iter()
2598        .map(|field| {
2599            let key = if field.is_key { " @key" } else { "" };
2600            (
2601                field.span,
2602                vec![format!(
2603                    "  {} {}{key}",
2604                    field.name.name,
2605                    field.ty.to_source()
2606                )],
2607            )
2608        })
2609        .collect();
2610    let Some((own_line, trailing)) =
2611        classify_body_comments(source, class_decl.span, &members, comments)
2612    else {
2613        return false;
2614    };
2615    push_line(formatted, format!("class {} {{", class_decl.name.name));
2616    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2617    push_line(formatted, "}");
2618    true
2619}
2620
2621/// Format a `queue` body (its single `tracker` member) with own-line and trailing
2622/// comments preserved. Returns `false` (caller refuses the file) when a body
2623/// comment cannot be placed safely.
2624fn try_format_tracker_with_comments(
2625    queue: &TrackerDecl,
2626    source: &str,
2627    comments: &[Comment],
2628    formatted: &mut String,
2629) -> bool {
2630    let members: Vec<(SourceSpan, Vec<String>)> = vec![(
2631        queue.provider.span,
2632        vec![format!("  provider {}", queue.provider.name)],
2633    )];
2634    let Some((own_line, trailing)) = classify_body_comments(source, queue.span, &members, comments)
2635    else {
2636        return false;
2637    };
2638    push_line(formatted, format!("tracker {} {{", queue.name.name));
2639    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2640    push_line(formatted, "}");
2641    true
2642}
2643
2644/// Format a `file store` body (its `root` and optional `allow read`/`allow write`
2645/// clauses) with own-line and trailing comments preserved, interleaved by the
2646/// clause spans captured during parsing. Returns `false` (caller refuses the file)
2647/// when a body comment cannot be placed safely.
2648fn try_format_filestore_with_comments(
2649    file_store: &FileStoreDecl,
2650    source: &str,
2651    comments: &[Comment],
2652    formatted: &mut String,
2653) -> bool {
2654    let render = |globs: &[String]| {
2655        globs
2656            .iter()
2657            .map(|glob| format!("{glob:?}"))
2658            .collect::<Vec<_>>()
2659            .join(", ")
2660    };
2661    let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::new();
2662    if let Some(span) = file_store.root_span {
2663        members.push((span, vec![format!("  root {:?}", file_store.root)]));
2664    }
2665    if !file_store.read_globs.is_empty() {
2666        if let Some(span) = file_store.read_span {
2667            members.push((
2668                span,
2669                vec![format!("  allow read [{}]", render(&file_store.read_globs))],
2670            ));
2671        }
2672    }
2673    if !file_store.write_globs.is_empty() {
2674        if let Some(span) = file_store.write_span {
2675            members.push((
2676                span,
2677                vec![format!(
2678                    "  allow write [{}]",
2679                    render(&file_store.write_globs)
2680                )],
2681            ));
2682        }
2683    }
2684    if let Some(provider) = &file_store.provider {
2685        if let Some(span) = file_store.provider_span {
2686            members.push((span, vec![format!("  provider {}", provider.name)]));
2687        }
2688    }
2689    members.sort_by_key(|(span, _)| span.start);
2690    let Some((own_line, trailing)) =
2691        classify_body_comments(source, file_store.span, &members, comments)
2692    else {
2693        return false;
2694    };
2695    push_line(formatted, format!("file store {} {{", file_store.name.name));
2696    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2697    push_line(formatted, "}");
2698    true
2699}
2700
2701/// Format a `signal` body (a typed payload schema of `ClassField`s, like a class)
2702/// with its own-line and trailing comments preserved. Returns `false` (caller
2703/// refuses the file) when a body comment cannot be placed safely.
2704fn try_format_event_with_comments(
2705    event: &EventDecl,
2706    source: &str,
2707    comments: &[Comment],
2708    formatted: &mut String,
2709) -> bool {
2710    let members: Vec<(SourceSpan, Vec<String>)> = event
2711        .fields
2712        .iter()
2713        .map(|field| {
2714            (
2715                field.span,
2716                vec![format!("  {} {}", field.name.name, field.ty.to_source())],
2717            )
2718        })
2719        .collect();
2720    let Some((own_line, trailing)) = classify_body_comments(source, event.span, &members, comments)
2721    else {
2722        return false;
2723    };
2724    push_line(formatted, format!("signal {} {{", event.name));
2725    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2726    push_line(formatted, "}");
2727    true
2728}
2729
2730fn agent_field_span(field: &AgentField) -> SourceSpan {
2731    match field {
2732        AgentField::Provider(ident) => ident.span,
2733        AgentField::Profile(profile) => profile.span,
2734        AgentField::Capacity(_, span)
2735        | AgentField::Skills(_, span)
2736        | AgentField::Capabilities(_, span)
2737        | AgentField::Requires(_, span)
2738        | AgentField::Tools(_, span) => *span,
2739        AgentField::Compaction(strategy) => strategy.span,
2740        AgentField::Thread(mode) => mode.span,
2741        AgentField::Settings(sources) => sources.span,
2742        AgentField::Unknown { span, .. } => *span,
2743    }
2744}
2745
2746fn agent_field_line(field: &AgentField) -> String {
2747    match field {
2748        AgentField::Provider(provider) => format!("  provider {}", provider.name),
2749        AgentField::Profile(profile) => format!("  profile {:?}", profile.value),
2750        AgentField::Capacity(capacity, _) => format!("  capacity {capacity}"),
2751        AgentField::Skills(skills, _) => {
2752            let skills = skills
2753                .iter()
2754                .map(|skill| format!("{:?}", skill.value))
2755                .collect::<Vec<_>>()
2756                .join(", ");
2757            format!("  skills [{skills}]")
2758        }
2759        AgentField::Capabilities(capabilities, _) => {
2760            let capabilities = capabilities
2761                .iter()
2762                .map(|capability| format!("{:?}", capability.value))
2763                .collect::<Vec<_>>()
2764                .join(", ");
2765            format!("  capabilities [{capabilities}]")
2766        }
2767        AgentField::Requires(classes, _) => {
2768            let classes = classes
2769                .iter()
2770                .map(|class| class.name.as_str())
2771                .collect::<Vec<_>>()
2772                .join(", ");
2773            format!("  requires [{classes}]")
2774        }
2775        AgentField::Tools(tools, _) => {
2776            let tools = tools
2777                .iter()
2778                .map(|tool| tool.name.as_str())
2779                .collect::<Vec<_>>()
2780                .join(", ");
2781            format!("  tools [{tools}]")
2782        }
2783        AgentField::Compaction(strategy) => format!("  compaction {}", strategy.name),
2784        AgentField::Thread(mode) => format!("  thread {}", mode.name),
2785        AgentField::Settings(sources) => format!("  settings {}", sources.name),
2786        AgentField::Unknown { name, .. } => format!("  {}", name.name),
2787    }
2788}
2789
2790/// Format an `agent` body with its own-line and trailing comments preserved.
2791/// Returns `false` (caller refuses the file) when a body comment cannot be placed
2792/// safely.
2793fn try_format_agent_with_comments(
2794    agent: &AgentDecl,
2795    source: &str,
2796    comments: &[Comment],
2797    formatted: &mut String,
2798) -> bool {
2799    let members: Vec<(SourceSpan, Vec<String>)> = agent
2800        .fields
2801        .iter()
2802        .map(|field| (agent_field_span(field), vec![agent_field_line(field)]))
2803        .collect();
2804    let Some((own_line, trailing)) = classify_body_comments(source, agent.span, &members, comments)
2805    else {
2806        return false;
2807    };
2808    let harness = agent
2809        .harness
2810        .as_ref()
2811        .map(|harness| format!(" using {}", harness.name))
2812        .or_else(|| {
2813            agent
2814                .delegated_to
2815                .as_ref()
2816                .map(|delegate| format!(" delegated to {}", delegate.name))
2817        })
2818        .unwrap_or_default();
2819    push_line(
2820        formatted,
2821        format!("agent {}{} {{", agent.name.name, harness),
2822    );
2823    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2824    push_line(formatted, "}");
2825    true
2826}
2827
2828/// Lines for one enum variant, with comments inside a data-carrying variant's
2829/// nested field block preserved (own-line interleaved, trailing appended) — the
2830/// block is a field list in braces, so it reuses the same classify/emit one level
2831/// deeper. Returns `None` when a nested comment cannot be placed safely.
2832fn enum_variant_lines_with_comments(
2833    variant: &EnumVariantDecl,
2834    source: &str,
2835    comments: &[Comment],
2836) -> Option<Vec<String>> {
2837    if variant.fields.is_empty() {
2838        return Some(vec![format!("  {}", variant.name.name)]);
2839    }
2840    let members: Vec<(SourceSpan, Vec<String>)> = variant
2841        .fields
2842        .iter()
2843        .map(|field| {
2844            (
2845                field.span,
2846                vec![format!("    {} {}", field.name.name, field.ty.to_source())],
2847            )
2848        })
2849        .collect();
2850    // `comments` is filtered to this variant's span by classify (via variant.span).
2851    let (own_line, trailing) = classify_body_comments(source, variant.span, &members, comments)?;
2852    let mut block = String::new();
2853    emit_members_with_comments(&members, &own_line, &trailing, "    ", &mut block);
2854    let mut lines = vec![format!("  {} {{", variant.name.name)];
2855    lines.extend(block.lines().map(str::to_owned));
2856    lines.push("  }".to_owned());
2857    Some(lines)
2858}
2859
2860/// Format an `enum` body with its comments preserved at both levels: between
2861/// variants (own-line interleaved, trailing appended to a bare variant's line) and
2862/// inside a data-carrying variant's nested field block. Each brace-body filters
2863/// comments by its own span, so the two levels never double-count. Returns `false`
2864/// (caller refuses the file) when a comment cannot be placed safely.
2865fn try_format_enum_with_comments(
2866    enum_decl: &EnumDecl,
2867    source: &str,
2868    comments: &[Comment],
2869    formatted: &mut String,
2870) -> bool {
2871    let mut members: Vec<(SourceSpan, Vec<String>)> = Vec::with_capacity(enum_decl.variants.len());
2872    for variant in &enum_decl.variants {
2873        let Some(lines) = enum_variant_lines_with_comments(variant, source, comments) else {
2874            return false;
2875        };
2876        members.push((variant.span, lines));
2877    }
2878    // Enum-body-level comments are those NOT inside a data variant's nested block
2879    // (those are placed by `enum_variant_lines_with_comments`); pass only those to
2880    // the body-level classify so the nested ones are not counted twice.
2881    let body_level: Vec<Comment> = comments
2882        .iter()
2883        .filter(|comment| {
2884            !enum_decl.variants.iter().any(|variant| {
2885                !variant.fields.is_empty()
2886                    && variant.span.start < comment.span.start
2887                    && comment.span.start < variant.span.end
2888            })
2889        })
2890        .cloned()
2891        .collect();
2892    let Some((own_line, trailing)) =
2893        classify_body_comments(source, enum_decl.span, &members, &body_level)
2894    else {
2895        return false;
2896    };
2897    push_line(formatted, format!("enum {} {{", enum_decl.name.name));
2898    emit_members_with_comments(&members, &own_line, &trailing, "  ", formatted);
2899    push_line(formatted, "}");
2900    true
2901}
2902
2903/// The name a top-level named declaration introduces, paired with its span, when
2904/// it is a kind that another workflow can reference by name (schemas, agents,
2905/// coordination resources, signals). Rules/tests/asserts/apply/contracts/patterns
2906/// introduce no such cross-referenced name here. Mirrors `document_symbols`'
2907/// named-decl set. Used to attach a "declared in workflow B" note when a
2908/// workflow references a name that is really private to a sibling.
2909fn referenced_decl_name(item: &Item) -> Option<(String, SourceSpan)> {
2910    match item {
2911        Item::Class(decl) => Some((decl.name.name.clone(), decl.span)),
2912        Item::Enum(decl) => Some((decl.name.name.clone(), decl.span)),
2913        Item::Agent(decl) => Some((decl.name.name.clone(), decl.span)),
2914        Item::Coerce(decl) => Some((decl.name.name.clone(), decl.span)),
2915        Item::Lease(decl) => Some((decl.name.name.clone(), decl.span)),
2916        Item::Ledger(decl) => Some((decl.name.name.clone(), decl.span)),
2917        Item::Counter(decl) => Some((decl.name.name.clone(), decl.span)),
2918        Item::Tracker(decl) => Some((decl.name.name.clone(), decl.span)),
2919        Item::Channel(decl) => Some((decl.name.name.clone(), decl.span)),
2920        Item::FileStore(decl) => Some((decl.name.name.clone(), decl.span)),
2921        Item::MemoryPool(decl) => Some((decl.name.name.clone(), decl.span)),
2922        Item::Event(decl) => Some((decl.name.clone(), decl.span)),
2923        Item::Table(decl) => Some((decl.name.name.clone(), decl.span)),
2924        Item::Gauge(decl) => Some((decl.name.name.clone(), decl.span)),
2925        Item::Campaign(decl) => Some((decl.name.name.clone(), decl.span)),
2926        Item::Mark(decl) => Some((decl.name.value.clone(), decl.span)),
2927        _ => None,
2928    }
2929}
2930
2931/// If `diagnostic` (produced while validating workflow `current`) reports an
2932/// unknown name that is actually declared *private to a sibling workflow*, attach
2933/// a related note pointing at that sibling's declaration. This turns a bare
2934/// "unknown class `X`" into an actionable "…and `X` lives in workflow `B`; move
2935/// it to the top level to share it." A name that is global or one of `current`'s
2936/// own locals is legitimately in scope and never annotated.
2937fn annotate_cross_workflow_leak(
2938    diagnostic: &mut Diagnostic,
2939    current: &str,
2940    own_locals: &BTreeSet<String>,
2941    global_names: &BTreeSet<String>,
2942    sibling_locals: &BTreeMap<String, Vec<(String, SourceSpan)>>,
2943) {
2944    for (name, owners) in sibling_locals {
2945        if global_names.contains(name) || own_locals.contains(name) {
2946            continue;
2947        }
2948        // Only names actually referenced (as `` `name` ``) in this diagnostic, and
2949        // owned by some workflow other than the one being validated.
2950        if !diagnostic.message.contains(&format!("`{name}`")) {
2951            continue;
2952        }
2953        let Some((owner, span)) = owners.iter().find(|(owner, _)| owner != current) else {
2954            continue;
2955        };
2956        diagnostic.related.push(RelatedInfo {
2957            span: *span,
2958            message: format!(
2959                "`{name}` is declared inside workflow `{owner}`, which makes it \
2960                 private to that workflow; move it to a top-level declaration to \
2961                 share it across workflows"
2962            ),
2963        });
2964        return;
2965    }
2966}
2967
2968fn select_root_workflow(
2969    mut program: Program,
2970    root: Option<&str>,
2971) -> Result<Program, Vec<Diagnostic>> {
2972    // A runnable program requires at least one explicit `workflow`. The implicit
2973    // compatibility root is removed (RESOLVED 2026-07-01): a source that declares
2974    // no `workflow` at all (neither the header form nor a `workflow Name { ... }`
2975    // block) is a library fragment, not a program, and is rejected here rather
2976    // than silently compiled as an anonymous root.
2977    if program.workflow.is_none() && program.workflows.is_empty() {
2978        return Err(vec![Diagnostic {
2979            related: Vec::new(),
2980            span: SourceSpan { start: 0, end: 0 },
2981            message: "program declares no `workflow`".to_owned(),
2982            suggestion: Some(
2983                "add an explicit `workflow Name { ... }` declaration; a runnable \
2984                 program requires at least one workflow (files that only declare \
2985                 shared types or patterns are libraries, meant to be `include`d)"
2986                    .to_owned(),
2987            ),
2988        }]);
2989    }
2990
2991    if program.workflows.is_empty() {
2992        if let Some(root) = root {
2993            match program.workflow.as_ref() {
2994                Some(workflow) if workflow.name == root => {}
2995                Some(workflow) => {
2996                    return Err(vec![Diagnostic {
2997                        related: Vec::new(),
2998                        span: workflow.span,
2999                        message: format!("root workflow `{root}` was not found"),
3000                        suggestion: Some(format!("available workflow: `{}`", workflow.name)),
3001                    }]);
3002                }
3003                None => {
3004                    return Err(vec![Diagnostic {
3005                        related: Vec::new(),
3006                        span: SourceSpan { start: 0, end: 0 },
3007                        message: format!("root workflow `{root}` was not found"),
3008                        suggestion: Some(
3009                            "add an explicit `workflow Name { ... }` declaration".to_owned(),
3010                        ),
3011                    }]);
3012                }
3013            }
3014        }
3015        return Ok(program);
3016    }
3017
3018    let selected_index = match root {
3019        Some(root) => match program
3020            .workflows
3021            .iter()
3022            .position(|workflow| workflow.name.name == root)
3023        {
3024            Some(index) => index,
3025            None => {
3026                let names = program
3027                    .workflows
3028                    .iter()
3029                    .map(|workflow| format!("`{}`", workflow.name.name))
3030                    .collect::<Vec<_>>()
3031                    .join(", ");
3032                return Err(vec![Diagnostic {
3033                    related: Vec::new(),
3034                    span: SourceSpan { start: 0, end: 0 },
3035                    message: format!("root workflow `{root}` was not found"),
3036                    suggestion: Some(format!("available workflows: {names}")),
3037                }]);
3038            }
3039        },
3040        None if program.workflows.len() == 1 => 0,
3041        None => {
3042            let names = program
3043                .workflows
3044                .iter()
3045                .map(|workflow| format!("`{}`", workflow.name.name))
3046                .collect::<Vec<_>>()
3047                .join(", ");
3048            return Err(vec![Diagnostic {
3049                related: Vec::new(),
3050                span: SourceSpan { start: 0, end: 0 },
3051                message: "multiple workflow declarations require an explicit root".to_owned(),
3052                suggestion: Some(format!(
3053                    "pass `--root <name>`; available workflows: {names}"
3054                )),
3055            }]);
3056        }
3057    };
3058
3059    let selected = program.workflows.remove(selected_index);
3060    let mut items = program.items;
3061    let workflow_tags = selected.tags;
3062    let workflow_description = selected.description;
3063    items.extend(selected.items);
3064    Ok(Program {
3065        workflow: Some(selected.name),
3066        workflow_tags,
3067        workflow_description,
3068        explicit_workflow_body: true,
3069        workflows: Vec::new(),
3070        patterns: program.patterns,
3071        items,
3072    })
3073}
3074
3075impl IrProgram {
3076    pub fn construct_uses(&self) -> Vec<&IrConstructUse> {
3077        self.rules
3078            .iter()
3079            .flat_map(|rule| rule.metadata.effects.iter())
3080            .filter_map(|effect| effect.construct_use.as_ref())
3081            .collect()
3082    }
3083
3084    pub fn contract_registry(&self) -> ContractRegistry {
3085        let mut libraries = BTreeMap::<String, LibraryRegistration>::new();
3086        let mut contracts = BTreeMap::<(String, String), EffectContract>::new();
3087
3088        for use_decl in &self.uses {
3089            libraries
3090                .entry(use_decl.name.clone())
3091                .or_insert_with(|| LibraryRegistration {
3092                    id: use_decl.name.clone(),
3093                    version: "unlocked".to_owned(),
3094                    standard: false,
3095                });
3096        }
3097
3098        if !self.harnesses.is_empty() || !self.agents.is_empty() {
3099            register_standard_library(&mut libraries, "std.agent");
3100        }
3101        if !self.trackers.is_empty() {
3102            register_standard_library(&mut libraries, "std.tracker");
3103        }
3104        if !self.events.is_empty() {
3105            register_standard_library(&mut libraries, "std.ingress");
3106        }
3107        if !self.leases.is_empty() || !self.ledgers.is_empty() || !self.counters.is_empty() {
3108            register_standard_library(&mut libraries, "std.coord");
3109        }
3110        if !self.channels.is_empty() {
3111            register_standard_library(&mut libraries, "std.messaging");
3112        }
3113        if !self.credentials.is_empty() {
3114            register_standard_library(&mut libraries, "std.custody");
3115        }
3116        // A bare `file store` declaration registers the owning library even
3117        // before any rule uses a file effect (spec/std-files.md "Manifest":
3118        // the declaration alone previously registered nothing).
3119        if !self.file_stores.is_empty() {
3120            register_standard_library(&mut libraries, "std.files");
3121        }
3122        if self.sources.iter().any(|source| source.is_clock) {
3123            register_standard_library(&mut libraries, "std.time");
3124        }
3125        if !self.coerces.is_empty() {
3126            register_standard_library(&mut libraries, "std.coercion");
3127            register_effect_contract(
3128                &mut libraries,
3129                &mut contracts,
3130                IrEffectKind::SchemaCoerce,
3131                Vec::new(),
3132            );
3133        }
3134
3135        for rule in &self.rules {
3136            for effect in &rule.metadata.effects {
3137                register_effect_contract(
3138                    &mut libraries,
3139                    &mut contracts,
3140                    effect.kind.clone(),
3141                    effect.required_capabilities.clone(),
3142                );
3143            }
3144        }
3145
3146        // Package-owned construct registrations (e.g. `send`, `recall`) are NOT
3147        // registered here: they come from a package manifest — embedded std
3148        // manifests included — merged in by the CLI when the owning package is
3149        // imported (`use std.messaging`). Modeled in
3150        // `models/maude/std-construct-authorization.maude`.
3151        ContractRegistry {
3152            libraries: libraries.into_values().collect(),
3153            constructs: Vec::new(),
3154            effect_contracts: contracts.into_values().collect(),
3155        }
3156    }
3157
3158    pub fn to_snapshot(&self) -> String {
3159        let mut snapshot = String::new();
3160        push_line(&mut snapshot, format!("workflow {}", self.workflow));
3161
3162        if !self.source_tags.is_empty() {
3163            push_line(&mut snapshot, "source_tags");
3164            for tag in &self.source_tags {
3165                push_line(
3166                    &mut snapshot,
3167                    format!("@{} {} {}", tag.name, tag.target_kind, tag.target),
3168                );
3169            }
3170        }
3171
3172        if !self.source_descriptions.is_empty() {
3173            push_line(&mut snapshot, "source_descriptions");
3174            for description in &self.source_descriptions {
3175                push_line(
3176                    &mut snapshot,
3177                    format!(
3178                        "{:?} {} {}",
3179                        description.value, description.target_kind, description.target
3180                    ),
3181                );
3182            }
3183        }
3184
3185        if !self.shared_coordination_usage.is_empty() {
3186            push_line(&mut snapshot, "shared_coordination_usage");
3187            for usage in &self.shared_coordination_usage {
3188                push_line(
3189                    &mut snapshot,
3190                    format!(
3191                        "{} <- {}",
3192                        usage.resource,
3193                        usage.workflow_principals.join(",")
3194                    ),
3195                );
3196            }
3197        }
3198
3199        if !self.includes.is_empty() {
3200            push_line(&mut snapshot, "includes");
3201            for include in &self.includes {
3202                match &include.source_hash {
3203                    Some(source_hash) => {
3204                        push_line(
3205                            &mut snapshot,
3206                            format!("  {} hash {}", include.path, source_hash),
3207                        );
3208                    }
3209                    None => push_line(&mut snapshot, format!("  {}", include.path)),
3210                }
3211            }
3212        }
3213
3214        if !self.pattern_applications.is_empty() {
3215            push_line(&mut snapshot, "pattern_applications");
3216            for application in &self.pattern_applications {
3217                let type_args = application
3218                    .type_args
3219                    .iter()
3220                    .map(IrType::to_snapshot)
3221                    .collect::<Vec<_>>()
3222                    .join(", ");
3223                push_line(
3224                    &mut snapshot,
3225                    format!(
3226                        "  {} as {}<{}>",
3227                        application.pattern, application.alias, type_args
3228                    ),
3229                );
3230                push_line(
3231                    &mut snapshot,
3232                    format!(
3233                        "    defined-at {}..{}",
3234                        application.definition_span.start, application.definition_span.end
3235                    ),
3236                );
3237                push_line(
3238                    &mut snapshot,
3239                    format!(
3240                        "    applied-at {}..{}",
3241                        application.application_span.start, application.application_span.end
3242                    ),
3243                );
3244                for argument in &application.value_args {
3245                    push_line(
3246                        &mut snapshot,
3247                        format!("    arg {} {}", argument.name, argument.value),
3248                    );
3249                }
3250                for generated in &application.generated {
3251                    push_line(&mut snapshot, format!("    generated {generated}"));
3252                }
3253            }
3254        }
3255
3256        if !self.workflow_contracts.is_empty() {
3257            push_line(&mut snapshot, "workflow_contracts");
3258            for contract in &self.workflow_contracts {
3259                push_line(
3260                    &mut snapshot,
3261                    format!(
3262                        "  {} {} {}",
3263                        contract.kind.as_str(),
3264                        contract.name,
3265                        contract.ty.to_snapshot()
3266                    ),
3267                );
3268            }
3269        }
3270
3271        if !self.uses.is_empty() {
3272            push_line(&mut snapshot, "uses");
3273            for use_decl in &self.uses {
3274                push_line(
3275                    &mut snapshot,
3276                    format!("  {} {}", use_decl.kind.as_str(), use_decl.name),
3277                );
3278            }
3279        }
3280
3281        if !self.schemas.is_empty() {
3282            push_line(&mut snapshot, "schemas");
3283            for schema in &self.schemas {
3284                match schema {
3285                    IrSchema::Enum(enum_decl) => {
3286                        push_line(
3287                            &mut snapshot,
3288                            format!(
3289                                "  enum {} {{ {} }}",
3290                                enum_decl.name,
3291                                enum_decl.variants.join(", ")
3292                            ),
3293                        );
3294                    }
3295                    IrSchema::Class(class_decl) => {
3296                        push_line(&mut snapshot, format!("  class {}", class_decl.name));
3297                        for field in &class_decl.fields {
3298                            // `@key` is serialized only when set, so non-keyed
3299                            // classes keep their prior snapshot (no ripple).
3300                            let key = if field.is_key { " @key" } else { "" };
3301                            push_line(
3302                                &mut snapshot,
3303                                format!("    {} {}{key}", field.name, field.ty.to_snapshot()),
3304                            );
3305                        }
3306                    }
3307                }
3308            }
3309        }
3310
3311        if !self.harnesses.is_empty() {
3312            push_line(&mut snapshot, "harnesses");
3313            for harness in &self.harnesses {
3314                push_line(
3315                    &mut snapshot,
3316                    format!("  harness {} kind={}", harness.name, harness.kind),
3317                );
3318            }
3319        }
3320        if !self.trackers.is_empty() {
3321            push_line(&mut snapshot, "trackers");
3322            for queue in &self.trackers {
3323                push_line(
3324                    &mut snapshot,
3325                    format!("  tracker {} provider={}", queue.name, queue.provider),
3326                );
3327            }
3328        }
3329        if !self.streams.is_empty() {
3330            push_line(&mut snapshot, "streams");
3331            for stream in &self.streams {
3332                push_line(
3333                    &mut snapshot,
3334                    format!(
3335                        "  stream {} members=[{}]{}",
3336                        stream.name,
3337                        stream.members.join(","),
3338                        stream
3339                            .staleness_seconds
3340                            .map(|seconds| format!(" staleness={seconds}s"))
3341                            .unwrap_or_default()
3342                    ),
3343                );
3344            }
3345        }
3346
3347        if !self.channels.is_empty() {
3348            push_line(&mut snapshot, "channels");
3349            for channel in &self.channels {
3350                let mut line = format!("  channel {} provider={}", channel.name, channel.provider);
3351                if let Some(workspace) = &channel.workspace {
3352                    line.push_str(&format!(" workspace={workspace}"));
3353                }
3354                if let Some(destination) = &channel.destination {
3355                    line.push_str(&format!(" destination={destination:?}"));
3356                }
3357                push_line(&mut snapshot, line);
3358            }
3359        }
3360
3361        if !self.credentials.is_empty() {
3362            push_line(&mut snapshot, "credentials");
3363            for credential in &self.credentials {
3364                push_line(
3365                    &mut snapshot,
3366                    format!("  credential {} kind={}", credential.name, credential.kind),
3367                );
3368            }
3369        }
3370
3371        if !self.gauges.is_empty() {
3372            push_line(&mut snapshot, "gauges");
3373            for gauge in &self.gauges {
3374                let mut line = format!(
3375                    "  gauge {} judge={}:{}",
3376                    gauge.name, gauge.judge_kind, gauge.judge_target
3377                );
3378                if !gauge.judge_args.is_empty() {
3379                    line.push_str(&format!(" args=({})", gauge.judge_args.join(",")));
3380                }
3381                if let Some(site) = &gauge.site {
3382                    line.push_str(&format!(" site={site}"));
3383                }
3384                if let Some(bar) = &gauge.expect {
3385                    line.push_str(&format!(
3386                        " expect={}:{}{}{}",
3387                        bar.form, bar.subject, bar.op, bar.threshold
3388                    ));
3389                }
3390                if !gauge.inputs.is_empty() {
3391                    line.push_str(&format!(" inputs={}", gauge.inputs.join(",")));
3392                }
3393                push_line(&mut snapshot, line);
3394            }
3395        }
3396
3397        if !self.marks.is_empty() {
3398            push_line(&mut snapshot, "marks");
3399            for mark in &self.marks {
3400                push_line(
3401                    &mut snapshot,
3402                    format!("  mark {:?} after {}", mark.name, mark.site),
3403                );
3404            }
3405        }
3406
3407        if !self.campaigns.is_empty() {
3408            push_line(&mut snapshot, "campaigns");
3409            for campaign in &self.campaigns {
3410                let mut line = format!("  campaign {}", campaign.name);
3411                if !campaign.ascend.is_empty() {
3412                    line.push_str(&format!(" ascend={}", campaign.ascend.join(",")));
3413                }
3414                for reach in &campaign.reach {
3415                    line.push_str(&format!(
3416                        " reach={}{}{}{}",
3417                        reach.gauge,
3418                        reach.op,
3419                        reach.threshold,
3420                        reach.unit.as_deref().unwrap_or("")
3421                    ));
3422                }
3423                for guard in &campaign.guard {
3424                    line.push_str(&format!(
3425                        " guard={}:within:{}%",
3426                        guard.gauge, guard.band_percent
3427                    ));
3428                }
3429                if !campaign.sacrifice.is_empty() {
3430                    line.push_str(&format!(" sacrifice={}", campaign.sacrifice.join(",")));
3431                }
3432                if campaign.proposer_redacted {
3433                    line.push_str(" proposer=redacted");
3434                }
3435                push_line(&mut snapshot, line);
3436            }
3437        }
3438
3439        if !self.file_stores.is_empty() {
3440            push_line(&mut snapshot, "file_stores");
3441            for file_store in &self.file_stores {
3442                push_line(
3443                    &mut snapshot,
3444                    format!(
3445                        "  file store {} root={:?}",
3446                        file_store.name, file_store.root
3447                    ),
3448                );
3449                // Globs are serialized only when present, so stores without an
3450                // `allow` clause keep their prior snapshot (no ripple).
3451                if !file_store.read_globs.is_empty() {
3452                    push_line(
3453                        &mut snapshot,
3454                        format!("    allow read {:?}", file_store.read_globs),
3455                    );
3456                }
3457                if !file_store.write_globs.is_empty() {
3458                    push_line(
3459                        &mut snapshot,
3460                        format!("    allow write {:?}", file_store.write_globs),
3461                    );
3462                }
3463                // The provider likewise serializes only when declared (unset =
3464                // the `local` default), so provider-less stores keep their
3465                // prior `.ir` byte-identically (slice F5 zero-churn gate).
3466                if let Some(provider) = &file_store.provider {
3467                    push_line(&mut snapshot, format!("    provider {provider}"));
3468                }
3469            }
3470        }
3471
3472        if !self.memory_pools.is_empty() {
3473            push_line(&mut snapshot, "memory_pools");
3474            for pool in &self.memory_pools {
3475                push_line(&mut snapshot, format!("  memory pool {}", pool.name));
3476                // The context limit is serialized only when present, so pools
3477                // without it keep a minimal snapshot (no ripple).
3478                if let Some(limit) = pool.context_limit {
3479                    push_line(&mut snapshot, format!("    context limit {limit}"));
3480                }
3481            }
3482        }
3483
3484        if !self.agents.is_empty() {
3485            push_line(&mut snapshot, "agents");
3486            for agent in &self.agents {
3487                let profile = agent.profile.as_deref().unwrap_or("<missing>");
3488                let harness = agent.harness.as_deref().unwrap_or("<fallback>");
3489                let provider = agent.provider.as_deref().unwrap_or("<fallback>");
3490                let capacity = agent
3491                    .capacity
3492                    .map(|capacity| capacity.to_string())
3493                    .unwrap_or_else(|| "<missing>".to_owned());
3494                let skills = if agent.skills.is_empty() {
3495                    "[]".to_owned()
3496                } else {
3497                    format!("[{}]", agent.skills.join(", "))
3498                };
3499                let capabilities = if agent.capabilities.is_empty() {
3500                    "[]".to_owned()
3501                } else {
3502                    format!("[{}]", agent.capabilities.join(", "))
3503                };
3504                let tools = if agent.tools.is_empty() {
3505                    "[]".to_owned()
3506                } else {
3507                    format!("[{}]", agent.tools.join(", "))
3508                };
3509                // Feature requirements append only when declared, so agents
3510                // without `requires` keep an unchanged .ir snapshot (no ripple).
3511                let requires = if agent.requires.is_empty() {
3512                    String::new()
3513                } else {
3514                    format!(" requires=[{}]", agent.requires.join(", "))
3515                };
3516                // Compaction strategy appends only when set, so agents that take the
3517                // harness default keep an unchanged .ir snapshot (no ripple).
3518                let compaction = agent
3519                    .compaction
3520                    .as_deref()
3521                    .map(|strategy| format!(" compaction={strategy}"))
3522                    .unwrap_or_default();
3523                // Settings likewise appends only when set (unset = provider default).
3524                let settings = agent
3525                    .settings
3526                    .as_deref()
3527                    .map(|sources| format!(" settings={sources}"))
3528                    .unwrap_or_default();
3529                // Thread mode likewise appends only when set (unset = fresh).
3530                let thread = agent
3531                    .thread
3532                    .as_deref()
3533                    .map(|mode| format!(" thread={mode}"))
3534                    .unwrap_or_default();
3535                // Harness class (DR-0034): Managed is the default/substrate, so only
3536                // Delegated agents emit a class token — Managed agents' .ir is unchanged.
3537                let class = match agent.harness_class {
3538                    HarnessClass::Delegated => " class=delegated",
3539                    HarnessClass::Managed => "",
3540                };
3541                push_line(
3542                    &mut snapshot,
3543                    format!(
3544                        "  agent {} harness={} provider={} profile={} capacity={} skills={} capabilities={} tools={}{}{}{}{}{}",
3545                        agent.name, harness, provider, profile, capacity, skills, capabilities, tools, requires, compaction, settings, thread, class
3546                    ),
3547                );
3548            }
3549        }
3550
3551        if !self.coerces.is_empty() {
3552            push_line(&mut snapshot, "coerces");
3553            for coerce in &self.coerces {
3554                let params = coerce
3555                    .params
3556                    .iter()
3557                    .map(|param| format!("{} {}", param.name, param.ty.to_snapshot()))
3558                    .collect::<Vec<_>>()
3559                    .join(", ");
3560                push_line(
3561                    &mut snapshot,
3562                    format!(
3563                        "  coerce {}({}) -> {}",
3564                        coerce.name,
3565                        params,
3566                        coerce.output.to_snapshot()
3567                    ),
3568                );
3569            }
3570        }
3571
3572        if !self.assertions.is_empty() {
3573            push_line(&mut snapshot, "assertions");
3574            for assertion in &self.assertions {
3575                push_line(
3576                    &mut snapshot,
3577                    format!("  assert {}", assertion.expr.expr.to_snapshot()),
3578                );
3579                if !assertion.projection_reads.is_empty() {
3580                    push_line(&mut snapshot, "    reads");
3581                    for read in &assertion.projection_reads {
3582                        push_line(&mut snapshot, format!("      {}", read.to_snapshot()));
3583                    }
3584                }
3585            }
3586        }
3587
3588        if !self.rules.is_empty() {
3589            push_line(&mut snapshot, "rules");
3590            for rule in &self.rules {
3591                push_line(&mut snapshot, format!("  rule {}", rule.name));
3592                for when in &rule.whens {
3593                    match &when.guard {
3594                        Some(guard) => push_line(
3595                            &mut snapshot,
3596                            format!(
3597                                "    when {} where {}",
3598                                when.pattern,
3599                                guard.expr.to_snapshot()
3600                            ),
3601                        ),
3602                        None => push_line(&mut snapshot, format!("    when {}", when.pattern)),
3603                    }
3604                }
3605                if !rule.metadata.fact_reads.is_empty() {
3606                    push_line(&mut snapshot, "    reads");
3607                    for read in &rule.metadata.fact_reads {
3608                        push_line(&mut snapshot, format!("      {}", read));
3609                    }
3610                }
3611                if !rule.metadata.projection_reads.is_empty() {
3612                    push_line(&mut snapshot, "    projection_reads");
3613                    for read in &rule.metadata.projection_reads {
3614                        push_line(&mut snapshot, format!("      {}", read.to_snapshot()));
3615                    }
3616                }
3617                if !rule.metadata.fact_writes.is_empty() {
3618                    push_line(&mut snapshot, "    writes");
3619                    for write in &rule.metadata.fact_writes {
3620                        push_line(&mut snapshot, format!("      {}", write));
3621                    }
3622                }
3623                if !rule.metadata.record_sources.is_empty() {
3624                    push_line(&mut snapshot, "    record_sources");
3625                    for source in &rule.metadata.record_sources {
3626                        push_line(
3627                            &mut snapshot,
3628                            format!(
3629                                "      schema:{} construct={} span={}..{}",
3630                                source.schema, source.construct, source.span.start, source.span.end
3631                            ),
3632                        );
3633                    }
3634                }
3635                if !rule.metadata.fact_consumes.is_empty() {
3636                    push_line(&mut snapshot, "    consumes");
3637                    for consumed in &rule.metadata.fact_consumes {
3638                        push_line(&mut snapshot, format!("      {}", consumed));
3639                    }
3640                }
3641                if !rule.metadata.effects.is_empty() {
3642                    push_line(&mut snapshot, "    effects");
3643                    for effect in &rule.metadata.effects {
3644                        let binding = effect.binding.as_deref().unwrap_or("-");
3645                        let construct = effect
3646                            .construct_use
3647                            .as_ref()
3648                            .map(|form| {
3649                                format!(" construct={}->{}", form.keyword, form.target_capability)
3650                            })
3651                            .unwrap_or_default();
3652                        // Turn-access grants are appended only when present, so
3653                        // grant-free effects keep their existing snapshot shape.
3654                        let grants = if effect.access_grants.is_empty() {
3655                            String::new()
3656                        } else {
3657                            let rendered = effect
3658                                .access_grants
3659                                .iter()
3660                                .map(|grant| {
3661                                    let ops = grant
3662                                        .operations
3663                                        .iter()
3664                                        .map(|op| op.operation.as_str())
3665                                        .collect::<Vec<_>>()
3666                                        .join(",");
3667                                    format!("{}[{ops}]", grant.resource)
3668                                })
3669                                .collect::<Vec<_>>()
3670                                .join(";");
3671                            format!(" grants={rendered}")
3672                        };
3673                        // Turn-scoped skill pins (Phase 7) append only when present,
3674                        // so pin-free effects keep their existing snapshot shape.
3675                        let homing = effect
3676                            .on_stream
3677                            .as_ref()
3678                            .map(|stream| format!(" on_stream={stream}"))
3679                            .unwrap_or_default();
3680                        let skills = if effect.turn_skills.is_empty() {
3681                            String::new()
3682                        } else {
3683                            format!(" skills={}", effect.turn_skills.join(","))
3684                        };
3685                        push_line(
3686                            &mut snapshot,
3687                            format!(
3688                                "      {} kind={} binding={}{} key={}{}{}{}",
3689                                effect.id,
3690                                effect.kind.as_str(),
3691                                binding,
3692                                construct,
3693                                effect.idempotency_key,
3694                                grants,
3695                                skills,
3696                                homing
3697                            ),
3698                        );
3699                    }
3700                }
3701                if !rule.metadata.dependencies.is_empty() {
3702                    push_line(&mut snapshot, "    dependencies");
3703                    for dependency in &rule.metadata.dependencies {
3704                        push_line(
3705                            &mut snapshot,
3706                            format!(
3707                                "      {} --{}--> {}",
3708                                dependency.upstream,
3709                                dependency.predicate.as_str(),
3710                                dependency.downstream
3711                            ),
3712                        );
3713                    }
3714                }
3715                if !rule.metadata.case_branches.is_empty() {
3716                    push_line(&mut snapshot, "    case_branches");
3717                    for branch in &rule.metadata.case_branches {
3718                        let guard = branch
3719                            .guard
3720                            .as_ref()
3721                            .map(|guard| guard.expr.to_snapshot())
3722                            .unwrap_or_else(|| "-".to_owned());
3723                        push_line(
3724                            &mut snapshot,
3725                            format!(
3726                                "      case {} type={} pattern={} guard={} body_hash={} span={}..{}",
3727                                branch.scrutinee,
3728                                branch.scrutinee_type.to_snapshot(),
3729                                branch.pattern.to_snapshot(),
3730                                guard,
3731                                branch.body_hash,
3732                                branch.pattern_span.start,
3733                                branch.pattern_span.end
3734                            ),
3735                        );
3736                    }
3737                }
3738                if !rule.metadata.terminal_outputs.is_empty() {
3739                    push_line(&mut snapshot, "    terminal_outputs");
3740                    for output in &rule.metadata.terminal_outputs {
3741                        push_line(
3742                            &mut snapshot,
3743                            format!(
3744                                "      {} span={}..{}",
3745                                output.binding, output.span.start, output.span.end
3746                            ),
3747                        );
3748                        for alternative in &output.alternatives {
3749                            push_line(
3750                                &mut snapshot,
3751                                format!(
3752                                    "        {} payload={} span={}..{}",
3753                                    alternative.tag,
3754                                    alternative.payload_type.to_snapshot(),
3755                                    alternative.source_span.start,
3756                                    alternative.source_span.end
3757                                ),
3758                            );
3759                        }
3760                    }
3761                }
3762                if !rule.metadata.terminal_branches.is_empty() {
3763                    push_line(&mut snapshot, "    terminal_branches");
3764                    for branch in &rule.metadata.terminal_branches {
3765                        let tag = branch.tag.as_deref().unwrap_or("_");
3766                        let binding = branch.binding.as_deref().unwrap_or("-");
3767                        let guard = branch
3768                            .guard
3769                            .as_ref()
3770                            .map(|guard| guard.expr.to_snapshot())
3771                            .unwrap_or_else(|| "-".to_owned());
3772                        push_line(
3773                            &mut snapshot,
3774                            format!(
3775                                "      case {} {} binding={} guard={} body_hash={} span={}..{}",
3776                                branch.scrutinee,
3777                                tag,
3778                                binding,
3779                                guard,
3780                                branch.body_hash,
3781                                branch.pattern_span.start,
3782                                branch.pattern_span.end
3783                            ),
3784                        );
3785                    }
3786                }
3787                push_line(
3788                    &mut snapshot,
3789                    format!("    body_hash {}", stable_hash(&rule.body)),
3790                );
3791            }
3792        }
3793
3794        if !self.rule_dependencies.is_empty() {
3795            push_line(&mut snapshot, "rule_dependencies");
3796            for dependency in &self.rule_dependencies {
3797                push_line(
3798                    &mut snapshot,
3799                    format!(
3800                        "  {} --{}--> {}",
3801                        dependency.producer, dependency.fact, dependency.consumer
3802                    ),
3803                );
3804            }
3805        }
3806
3807        snapshot
3808    }
3809}
3810
3811fn register_standard_library(libraries: &mut BTreeMap<String, LibraryRegistration>, id: &str) {
3812    libraries
3813        .entry(id.to_owned())
3814        .or_insert_with(|| LibraryRegistration {
3815            id: id.to_owned(),
3816            version: "0.1.0".to_owned(),
3817            standard: true,
3818        });
3819}
3820
3821fn register_effect_contract(
3822    libraries: &mut BTreeMap<String, LibraryRegistration>,
3823    contracts: &mut BTreeMap<(String, String), EffectContract>,
3824    kind: IrEffectKind,
3825    required_capabilities: Vec<String>,
3826) {
3827    let contract = effect_contract_for_kind(kind, required_capabilities);
3828    register_standard_library(libraries, contract.library_id.as_str());
3829    contracts
3830        .entry((contract.id.clone(), contract.version.clone()))
3831        .and_modify(|existing| {
3832            merge_unique(
3833                &mut existing.required_capabilities,
3834                &contract.required_capabilities,
3835            );
3836            merge_unique(&mut existing.provider_kinds, &contract.provider_kinds);
3837            merge_unique(&mut existing.source_forms, &contract.source_forms);
3838            merge_unique(&mut existing.projected_facts, &contract.projected_facts);
3839        })
3840        .or_insert(contract);
3841}
3842
3843fn merge_unique(target: &mut Vec<String>, values: &[String]) {
3844    for value in values {
3845        if !target.contains(value) {
3846            target.push(value.clone());
3847        }
3848    }
3849    target.sort();
3850}
3851
3852fn strings(values: &[&str]) -> Vec<String> {
3853    values.iter().map(|value| (*value).to_owned()).collect()
3854}
3855
3856fn effect_contract_for_kind(
3857    kind: IrEffectKind,
3858    required_capabilities: Vec<String>,
3859) -> EffectContract {
3860    let mut required_capabilities = required_capabilities;
3861    required_capabilities.sort();
3862    required_capabilities.dedup();
3863    let effect_kind = kind.as_str().to_owned();
3864
3865    let (
3866        library_id,
3867        source_forms,
3868        input_schema,
3869        output_schema,
3870        default_capabilities,
3871        provider_kinds,
3872        projected_facts,
3873        validation,
3874    ) = match kind {
3875        IrEffectKind::AgentTell => (
3876            "std.agent",
3877            strings(&["tell"]),
3878            Some("agent.turn.request"),
3879            Some("AgentTurn"),
3880            strings(&["agent.turn"]),
3881            strings(&["agent"]),
3882            strings(&["effect.output"]),
3883            TypedOutputValidation::RuntimeBoundary,
3884        ),
3885        IrEffectKind::SchemaCoerce => (
3886            "std.coercion",
3887            strings(&["coerce", "decide", "prompt"]),
3888            Some("schema.coerce.input"),
3889            Some("typed-provider-output"),
3890            // Capability id == effect kind (spec/std-coercion.md "Static
3891            // checks" 1: the never-enforced `model.invoke` died with the S2
3892            // rename), and the provider kind is the kernel's
3893            // `provider::PROVIDER_SCHEMA_COERCE` ("schema_coercer") string — a
3894            // schema coercer, not a generic model row.
3895            strings(&["schema.coerce"]),
3896            strings(&["schema_coercer"]),
3897            strings(&["effect.output"]),
3898            TypedOutputValidation::RuntimeBoundary,
3899        ),
3900        IrEffectKind::CapabilityCall => (
3901            "std.script",
3902            strings(&["call"]),
3903            Some("capability.call.input"),
3904            Some("capability.call.output"),
3905            Vec::new(),
3906            strings(&["capability"]),
3907            strings(&["effect.output"]),
3908            TypedOutputValidation::RuntimeBoundary,
3909        ),
3910        IrEffectKind::EventEmit => (
3911            "std.ingress",
3912            strings(&["emit"]),
3913            Some("event.emit.input"),
3914            None,
3915            Vec::new(),
3916            Vec::new(),
3917            Vec::new(),
3918            TypedOutputValidation::None,
3919        ),
3920        IrEffectKind::WorkflowInvoke => (
3921            "std.workflow",
3922            strings(&["invoke"]),
3923            Some("workflow.invoke.input"),
3924            Some("workflow.terminal"),
3925            Vec::new(),
3926            Vec::new(),
3927            strings(&["effect.output"]),
3928            TypedOutputValidation::RuntimeBoundary,
3929        ),
3930        IrEffectKind::TimerWait => (
3931            "std.time",
3932            strings(&["timer"]),
3933            Some("timer.wait.input"),
3934            Some("TimerElapsed"),
3935            Vec::new(),
3936            Vec::new(),
3937            strings(&["effect.output"]),
3938            TypedOutputValidation::None,
3939        ),
3940        IrEffectKind::ExecCommand => (
3941            "std.script",
3942            strings(&["exec"]),
3943            Some("exec.command.input"),
3944            Some("exec.command.output"),
3945            strings(&["exec.run"]),
3946            strings(&["script", "command"]),
3947            strings(&["effect.output"]),
3948            TypedOutputValidation::RuntimeBoundary,
3949        ),
3950        IrEffectKind::TrackerFile => (
3951            "std.tracker",
3952            strings(&["file"]),
3953            Some("tracker.file.input"),
3954            None,
3955            strings(&["tracker.file"]),
3956            Vec::new(),
3957            Vec::new(),
3958            TypedOutputValidation::None,
3959        ),
3960        IrEffectKind::TrackerClaim => (
3961            "std.tracker",
3962            strings(&["claim"]),
3963            Some("tracker.claim.input"),
3964            Some("TrackerClaim"),
3965            strings(&["tracker.claim"]),
3966            Vec::new(),
3967            strings(&["effect.output"]),
3968            TypedOutputValidation::None,
3969        ),
3970        // T3: holder-only renew of a claimed issue. No typed output schema (the
3971        // renewed/not_held outcome is a completed/failed terminal, mirroring
3972        // tracker.release), so the manifest contract row folds cleanly against
3973        // this compiled one.
3974        IrEffectKind::TrackerRenew => (
3975            "std.tracker",
3976            strings(&["renew"]),
3977            Some("tracker.renew.input"),
3978            None,
3979            strings(&["tracker.renew"]),
3980            Vec::new(),
3981            Vec::new(),
3982            TypedOutputValidation::None,
3983        ),
3984        IrEffectKind::TrackerRelease => (
3985            "std.tracker",
3986            strings(&["release"]),
3987            Some("tracker.release.input"),
3988            None,
3989            strings(&["tracker.release"]),
3990            Vec::new(),
3991            Vec::new(),
3992            TypedOutputValidation::None,
3993        ),
3994        IrEffectKind::TrackerFinish => (
3995            "std.tracker",
3996            strings(&["finish"]),
3997            Some("tracker.finish.input"),
3998            None,
3999            strings(&["tracker.finish"]),
4000            Vec::new(),
4001            Vec::new(),
4002            TypedOutputValidation::None,
4003        ),
4004        IrEffectKind::LeaseAcquire => (
4005            "std.coord",
4006            strings(&["acquire"]),
4007            Some("lease.acquire.input"),
4008            Some("LeaseAcquireOutcome"),
4009            Vec::new(),
4010            Vec::new(),
4011            strings(&["effect.output"]),
4012            TypedOutputValidation::None,
4013        ),
4014        IrEffectKind::LeaseRenew => (
4015            "std.coord",
4016            strings(&["renew"]),
4017            Some("lease.renew.input"),
4018            Some("LeaseRenewOutcome"),
4019            Vec::new(),
4020            Vec::new(),
4021            strings(&["effect.output"]),
4022            TypedOutputValidation::None,
4023        ),
4024        IrEffectKind::LedgerAppend => (
4025            "std.coord",
4026            strings(&["append"]),
4027            Some("ledger.append.input"),
4028            None,
4029            Vec::new(),
4030            Vec::new(),
4031            Vec::new(),
4032            TypedOutputValidation::None,
4033        ),
4034        IrEffectKind::CounterConsume => (
4035            "std.coord",
4036            strings(&["consume"]),
4037            Some("counter.consume.input"),
4038            Some("CounterConsumeOutcome"),
4039            Vec::new(),
4040            Vec::new(),
4041            strings(&["effect.output"]),
4042            TypedOutputValidation::None,
4043        ),
4044        IrEffectKind::SignalEmit => (
4045            "std.ingress",
4046            strings(&["emit", "signal"]),
4047            Some("signal.emit.input"),
4048            None,
4049            Vec::new(),
4050            Vec::new(),
4051            Vec::new(),
4052            TypedOutputValidation::None,
4053        ),
4054        // std.files capability ids EQUAL effect kinds (spec/std-files.md, M3
4055        // id==kind): each contract requires exactly its own kind string, which
4056        // the store's default-required-capability rule already derives for an
4057        // empty list — declaring it here makes the registry honest about it.
4058        IrEffectKind::FileRead => (
4059            "std.files",
4060            strings(&["read"]),
4061            Some("file.read.input"),
4062            Some("FileReadResult"),
4063            strings(&["file.read"]),
4064            Vec::new(),
4065            strings(&["effect.output"]),
4066            TypedOutputValidation::RuntimeBoundary,
4067        ),
4068        IrEffectKind::FileWrite => (
4069            "std.files",
4070            strings(&["write"]),
4071            Some("file.write.input"),
4072            Some("FileWriteResult"),
4073            strings(&["file.write"]),
4074            Vec::new(),
4075            strings(&["effect.output"]),
4076            TypedOutputValidation::RuntimeBoundary,
4077        ),
4078        IrEffectKind::FileImport => (
4079            "std.files",
4080            strings(&["import"]),
4081            Some("file.import.input"),
4082            Some("FileImportResult"),
4083            strings(&["file.import"]),
4084            Vec::new(),
4085            strings(&["effect.output"]),
4086            TypedOutputValidation::RuntimeBoundary,
4087        ),
4088        IrEffectKind::FileExport => (
4089            "std.files",
4090            strings(&["export"]),
4091            Some("file.export.input"),
4092            Some("FileExportResult"),
4093            strings(&["file.export"]),
4094            Vec::new(),
4095            strings(&["effect.output"]),
4096            TypedOutputValidation::RuntimeBoundary,
4097        ),
4098    };
4099
4100    merge_unique(&mut required_capabilities, &default_capabilities);
4101
4102    EffectContract {
4103        id: effect_kind.clone(),
4104        library_id: library_id.to_owned(),
4105        version: "0.1.0".to_owned(),
4106        effect_kind,
4107        source_forms,
4108        input_schema: input_schema.map(str::to_owned),
4109        output_schema: output_schema.map(str::to_owned),
4110        required_capabilities,
4111        provider_kinds,
4112        projected_facts,
4113        validation,
4114    }
4115}
4116
4117impl IrEffectKind {
4118    /// The single canonical `IrEffectKind` → effect-kind string map. Kernel and
4119    /// CLI delegate here (S0 dedup) so a rename touches exactly one match.
4120    pub fn as_str(&self) -> &'static str {
4121        match self {
4122            Self::AgentTell => "agent.tell",
4123            Self::SchemaCoerce => "schema.coerce",
4124            Self::CapabilityCall => "capability.call",
4125            Self::EventEmit => "event.emit",
4126            Self::WorkflowInvoke => "workflow.invoke",
4127            Self::TimerWait => "timer.wait",
4128            Self::ExecCommand => "exec.command",
4129            Self::TrackerFile => "tracker.file",
4130            Self::TrackerClaim => "tracker.claim",
4131            Self::TrackerRenew => "tracker.renew",
4132            Self::TrackerRelease => "tracker.release",
4133            Self::TrackerFinish => "tracker.finish",
4134            Self::LeaseAcquire => "lease.acquire",
4135            Self::LeaseRenew => "lease.renew",
4136            Self::LedgerAppend => "ledger.append",
4137            Self::CounterConsume => "counter.consume",
4138            Self::SignalEmit => "signal.emit",
4139            Self::FileRead => "file.read",
4140            Self::FileWrite => "file.write",
4141            Self::FileImport => "file.import",
4142            Self::FileExport => "file.export",
4143        }
4144    }
4145}
4146
4147impl DependencyPredicate {
4148    fn as_str(&self) -> &'static str {
4149        match self {
4150            Self::Succeeds => "succeeds",
4151            Self::Fails => "fails",
4152            Self::TimedOut => "timed_out",
4153            Self::Cancelled => "cancelled",
4154            Self::Completes => "completes",
4155        }
4156    }
4157}
4158
4159impl IrUseKind {
4160    fn as_str(&self) -> &'static str {
4161        match self {
4162            Self::Package => "package",
4163        }
4164    }
4165}
4166
4167impl IrType {
4168    /// A human-readable label for this type (e.g. `ref<TicketRequest>`), for use in
4169    /// diagnostics such as workflow-input errors.
4170    pub fn display_label(&self) -> String {
4171        self.to_snapshot()
4172    }
4173
4174    fn to_snapshot(&self) -> String {
4175        match self {
4176            Self::Primitive(primitive) => primitive.as_str().to_owned(),
4177            Self::LiteralString(value) => format!("literal<{value:?}>"),
4178            Self::Ref(name) => format!("ref<{name}>"),
4179            Self::AgentRef(agents) => format!("agentref<{}>", agents.join(" | ")),
4180            Self::Object(fields) => {
4181                let fields = fields
4182                    .iter()
4183                    .map(|field| format!("{} {}", field.name, field.ty.to_snapshot()))
4184                    .collect::<Vec<_>>()
4185                    .join(", ");
4186                format!("object<{{{fields}}}>")
4187            }
4188            Self::Optional(inner) => format!("optional<{}>", inner.to_snapshot()),
4189            Self::Array(inner) => format!("array<{}>", inner.to_snapshot()),
4190            Self::Map(inner) => format!("map<{}>", inner.to_snapshot()),
4191            Self::Union(variants) => {
4192                let variants = variants
4193                    .iter()
4194                    .map(Self::to_snapshot)
4195                    .collect::<Vec<_>>()
4196                    .join(" | ");
4197                format!("union<{variants}>")
4198            }
4199        }
4200    }
4201}
4202
4203impl IrPrimitiveType {
4204    fn as_str(&self) -> &'static str {
4205        match self {
4206            Self::String => "string",
4207            Self::Int => "int",
4208            Self::Float => "float",
4209            Self::Bool => "bool",
4210            Self::Null => "null",
4211            Self::Duration => "duration",
4212            Self::Time => "time",
4213            Self::Image => "image",
4214            Self::Audio => "audio",
4215            Self::Pdf => "pdf",
4216            Self::Video => "video",
4217            Self::Secret => "secret",
4218        }
4219    }
4220}
4221
4222/// Post-lowering check: a turn-access grant whose resource is a declared `file store`
4223/// may only grant file operations (`read`/`write`/`import`/`export`). Runs after the
4224/// whole program is lowered so every file-store declaration is visible regardless of
4225/// source order. Grants whose resource is NOT a declared file store are left alone —
4226/// they may be package-provided resources whose operation vocabulary lives in the
4227/// capability registry (validated at the construct-graph layer), so this stays
4228/// zero-false-positive.
4229fn validate_turn_access_grant_file_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4230    const FILE_OPERATIONS: [&str; 4] = ["read", "write", "import", "export"];
4231    let file_stores: BTreeSet<&str> = ir
4232        .file_stores
4233        .iter()
4234        .map(|store| store.name.as_str())
4235        .collect();
4236    for rule in &ir.rules {
4237        for effect in &rule.metadata.effects {
4238            for grant in &effect.access_grants {
4239                if !file_stores.contains(grant.resource.as_str()) {
4240                    continue;
4241                }
4242                for op in &grant.operations {
4243                    if !FILE_OPERATIONS.contains(&op.operation.as_str()) {
4244                        diagnostics.push(Diagnostic { related: Vec::new(),
4245                            span: effect.span,
4246                            message: format!(
4247                                "rule `{}` grants `{}` on file store `{}`, which is not a file operation",
4248                                rule.name, op.operation, grant.resource
4249                            ),
4250                            suggestion: Some(
4251                                "file-store grants allow `read`, `write`, `import`, or `export`"
4252                                    .to_owned(),
4253                            ),
4254                        });
4255                    }
4256                }
4257            }
4258        }
4259    }
4260}
4261
4262/// Post-lowering check: a turn-access grant whose resource is a declared `memory
4263/// pool` (std.memory, MEM-1) may only grant memory operations
4264/// (`recall`/`learn`/`curate`). Runs after the whole program is lowered so every
4265/// pool declaration is visible regardless of source order. Grants whose resource
4266/// is NOT a declared memory pool are left alone — they may be file stores or
4267/// package-provided resources whose operation vocabulary lives elsewhere, so this
4268/// stays zero-false-positive. This closes the deliberate memory-grant-validation
4269/// deferral (there was no declared-pool list to key it off before MEM-1).
4270fn validate_turn_access_grant_memory_operations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4271    const MEMORY_OPERATIONS: [&str; 3] = ["recall", "learn", "curate"];
4272    let memory_pools: BTreeSet<&str> = ir
4273        .memory_pools
4274        .iter()
4275        .map(|pool| pool.name.as_str())
4276        .collect();
4277    for rule in &ir.rules {
4278        for effect in &rule.metadata.effects {
4279            for grant in &effect.access_grants {
4280                if !memory_pools.contains(grant.resource.as_str()) {
4281                    continue;
4282                }
4283                for op in &grant.operations {
4284                    if !MEMORY_OPERATIONS.contains(&op.operation.as_str()) {
4285                        diagnostics.push(Diagnostic {
4286                            related: Vec::new(),
4287                            span: effect.span,
4288                            message: format!(
4289                                "rule `{}` grants `{}` on memory pool `{}`, which is not a memory operation",
4290                                rule.name, op.operation, grant.resource
4291                            ),
4292                            suggestion: Some(
4293                                "memory-pool grants allow `recall`, `learn`, or `curate`".to_owned(),
4294                            ),
4295                        });
4296                    }
4297                }
4298            }
4299        }
4300    }
4301}
4302
4303/// std.coord slice 3: a counter without a declared `timezone` anchors its
4304/// reset-period boundary to UTC — legal, but a daily/weekly/monthly quota
4305/// silently rolling over at an operator-surprising hour is worth a warning.
4306/// S4 (file-store default posture): a store is READ-ONLY by default — a
4307/// `write`/`export` against a store with no `allow write [...]` policy will
4308/// fail closed at runtime, so surface it as a check error here ("catch before
4309/// deployment"). Reads/imports need no clause (mounting the root is the read
4310/// consent); `allow read [...]` narrows them.
4311fn validate_file_store_write_policy(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4312    let read_only: BTreeSet<&str> = ir
4313        .file_stores
4314        .iter()
4315        .filter(|store| store.write_globs.is_empty())
4316        .map(|store| store.name.as_str())
4317        .collect();
4318    if read_only.is_empty() {
4319        return;
4320    }
4321    fn walk(
4322        statements: &[body::BodyStmt],
4323        rule_name: &str,
4324        read_only: &BTreeSet<&str>,
4325        diagnostics: &mut Vec<Diagnostic>,
4326    ) {
4327        for statement in statements {
4328            match statement {
4329                body::BodyStmt::Effect(effect) => {
4330                    let store = match &effect.kind {
4331                        body::BodyEffectKind::FileWrite { store, .. }
4332                        | body::BodyEffectKind::FileExport { store, .. } => Some(store),
4333                        _ => None,
4334                    };
4335                    if let Some(store) = store {
4336                        if read_only.contains(store.as_str()) {
4337                            diagnostics.push(Diagnostic {
4338                                related: Vec::new(),
4339                                span: effect.span,
4340                                message: format!(
4341                                    "rule `{rule_name}` writes to store `{store}`, which permits \
4342                                     no writes — stores are read-only by default"
4343                                ),
4344                                suggestion: Some(format!(
4345                                    "declare `allow write [\"<glob>\", …]` on `file store {store}` \
4346                                     to permit (and bound) writes"
4347                                )),
4348                            });
4349                        }
4350                    }
4351                }
4352                body::BodyStmt::After(after) => {
4353                    walk(&after.body, rule_name, read_only, diagnostics)
4354                }
4355                body::BodyStmt::Case(case) => {
4356                    for branch in &case.branches {
4357                        walk(&branch.body, rule_name, read_only, diagnostics);
4358                    }
4359                }
4360                _ => {}
4361            }
4362        }
4363    }
4364    for rule in &ir.rules {
4365        let (ast, _) = body::parse_rule_body(&rule.body, 0);
4366        walk(&ast.statements, &rule.name, &read_only, diagnostics);
4367    }
4368}
4369
4370/// S6 `emit <signal> from <binding>` (source declarations): expand the
4371/// projection into concrete emit fields once every declaration has lowered
4372/// (the signal may be declared after the source). Each of the signal's
4373/// declared fields not overridden by the block becomes a copy off the `from`
4374/// binding — the `record … from` semantics. The `from` binding must be the
4375/// source's `observe` binding: it is the only binding in scope.
4376fn expand_source_emit_from(ir: &mut IrProgram, diagnostics: &mut Vec<Diagnostic>) {
4377    let events: BTreeMap<String, Vec<String>> = ir
4378        .events
4379        .iter()
4380        .map(|event| {
4381            (
4382                event.name.clone(),
4383                event
4384                    .fields
4385                    .iter()
4386                    .map(|field| field.name.clone())
4387                    .collect(),
4388            )
4389        })
4390        .collect();
4391    for source in &mut ir.sources {
4392        let Some(from) = source.emit_from.clone() else {
4393            continue;
4394        };
4395        if from != source.observe_binding {
4396            diagnostics.push(Diagnostic {
4397                related: Vec::new(),
4398                span: source.span,
4399                message: format!(
4400                    "source `{}` emits `from {from}`, but the only binding in scope is the observe binding `{}`",
4401                    source.name, source.observe_binding
4402                ),
4403                suggestion: Some(format!("write `emit {} from {}`", source.emit_signal, source.observe_binding)),
4404            });
4405            continue;
4406        }
4407        let Some(signal_fields) = events.get(&source.emit_signal) else {
4408            // The undeclared-signal diagnostic is reported by the emit checks.
4409            continue;
4410        };
4411        for field in signal_fields {
4412            if source
4413                .emit_fields
4414                .iter()
4415                .any(|existing| &existing.name == field)
4416            {
4417                continue;
4418            }
4419            source.emit_fields.push(IrSourceEmitField {
4420                name: field.clone(),
4421                value: SourceValue::Path {
4422                    binding: Ident {
4423                        name: from.clone(),
4424                        span: source.span,
4425                    },
4426                    segments: vec![Ident {
4427                        name: field.clone(),
4428                        span: source.span,
4429                    }],
4430                    span: source.span,
4431                },
4432                span: source.span,
4433            });
4434        }
4435    }
4436}
4437
4438/// Auto-fail R1a — partiality made visible: an effect whose failure has no
4439/// observing `after` block in its rule will auto-fail the instance at runtime
4440/// (the rule-level net). That is SAFE, but the "handles only `succeeds`" signal
4441/// is load-bearing enough to surface prominently at check time — a warning, not
4442/// a buried lint advisory. `@service` workflows are exempt (they record a
4443/// durable diagnostic and keep running, so the auto-fail framing would be
4444/// wrong), timers are exempt (they cannot fail), and the compile-time observer
4445/// set is deliberately WIDER than the runtime net's: coordination outcome
4446/// predicates (`held`/`contended`/`ok`/`over`) count as observers here so
4447/// ordinary coordination code stays quiet, while the runtime net still catches
4448/// a genuine op failure underneath them.
4449fn warn_unhandled_effect_failures(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4450    let service = ir
4451        .source_tags
4452        .iter()
4453        .any(|tag| tag.target_kind == "workflow" && tag.name == "service");
4454    if service {
4455        return;
4456    }
4457    for rule in &ir.rules {
4458        for effect in &rule.metadata.effects {
4459            let Some(binding) = effect.binding.as_deref() else {
4460                continue;
4461            };
4462            if effect.kind == IrEffectKind::TimerWait {
4463                continue;
4464            }
4465            // A `then`-chained effect (synthetic `__then_*` handle) is an
4466            // explicit opt-in to auto-fail on failure (R2) — never a warning.
4467            if binding.starts_with(then_expand::THEN_BINDING_PREFIX) {
4468                continue;
4469            }
4470            let observed = rule.body.lines().any(|line| {
4471                let Some(rest) = line.trim().strip_prefix("after ") else {
4472                    return false;
4473                };
4474                let mut parts = rest.split_whitespace();
4475                if parts.next() != Some(binding) {
4476                    return false;
4477                }
4478                // `times` only occurs as the two-token predicate `times out`.
4479                matches!(
4480                    parts.next().map(|token| token.trim_end_matches('{')),
4481                    Some(
4482                        "fails"
4483                            | "times"
4484                            | "completes"
4485                            | "held"
4486                            | "contended"
4487                            | "ok"
4488                            | "over"
4489                            | "promoted"
4490                            | "conflicted"
4491                    )
4492                )
4493            });
4494            if observed {
4495                continue;
4496            }
4497            warnings.push(Diagnostic {
4498                related: Vec::new(),
4499                span: effect.span,
4500                message: format!(
4501                    "effect `{binding}`'s failure is unhandled in rule `{}`; if it fails or \
4502                     times out, the instance will auto-fail with a generic reason",
4503                    rule.name
4504                ),
4505                suggestion: Some(format!(
4506                    "handle it with `after {binding} fails {{ … }}` (typed failure or recovery) \
4507                     or observe every outcome with `after {binding} completes`"
4508                )),
4509            });
4510        }
4511    }
4512}
4513
4514fn warn_counter_without_timezone(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4515    for counter in &ir.counters {
4516        if counter.timezone.is_none() {
4517            warnings.push(Diagnostic {
4518                related: Vec::new(),
4519                span: counter.span,
4520                message: format!(
4521                    "counter `{}` declares no `timezone`; its `{}` reset boundary anchors to UTC",
4522                    counter.name, counter.reset
4523                ),
4524                suggestion: Some(
4525                    "declare `timezone \"<IANA zone>\"` (e.g. `timezone \"America/New_York\"`) to anchor the period locally"
4526                        .to_owned(),
4527                ),
4528            });
4529        }
4530    }
4531}
4532
4533/// MEM-5 static check 4: a memory-pool grant on a `tell` whose agent runs a
4534/// NATIVE adapter (codex/claude/command) is inert — only the owned harness
4535/// exposes the granted memory tools. Warn instead of silently dropping the
4536/// author's intent (the inert-grant honesty the design eliminates).
4537fn warn_inert_memory_grant_on_native_adapter(ir: &IrProgram, warnings: &mut Vec<Diagnostic>) {
4538    let memory_pools: BTreeSet<&str> = ir
4539        .memory_pools
4540        .iter()
4541        .map(|pool| pool.name.as_str())
4542        .collect();
4543    if memory_pools.is_empty() {
4544        return;
4545    }
4546    let harness_kind_of: BTreeMap<&str, &str> = ir
4547        .harnesses
4548        .iter()
4549        .map(|harness| (harness.name.as_str(), harness.kind.as_str()))
4550        .collect();
4551    let agent_harness_kind: BTreeMap<&str, &str> = ir
4552        .agents
4553        .iter()
4554        .filter_map(|agent| {
4555            let harness = agent.harness.as_deref()?;
4556            Some((agent.name.as_str(), *harness_kind_of.get(harness)?))
4557        })
4558        .collect();
4559    for rule in &ir.rules {
4560        for effect in &rule.metadata.effects {
4561            let Some(agent) = effect.agent.as_deref() else {
4562                continue;
4563            };
4564            let Some(kind) = agent_harness_kind.get(agent) else {
4565                continue;
4566            };
4567            if !matches!(*kind, "codex" | "claude" | "command") {
4568                continue;
4569            }
4570            for grant in &effect.access_grants {
4571                if memory_pools.contains(grant.resource.as_str()) {
4572                    warnings.push(Diagnostic {
4573                        related: Vec::new(),
4574                        span: effect.span,
4575                        message: format!(
4576                            "rule `{}` grants memory pool `{}` on a tell to `{agent}`, whose \
4577                             harness kind `{kind}` is a native adapter — memory grants only \
4578                             take effect on the owned harness, so this grant is inert",
4579                            rule.name, grant.resource
4580                        ),
4581                        suggestion: Some(
4582                            "target an owned-harness agent, or drop the memory grant".to_owned(),
4583                        ),
4584                    });
4585                }
4586            }
4587        }
4588    }
4589}
4590
4591/// Detect recursive pattern application over the pattern-declaration graph and
4592/// emit `graph.unbounded_pattern_recursion` (severity error) for each expansion
4593/// cycle, naming the cycle. Returns the set of patterns that participate in a
4594/// cycle so the caller can suppress the generic "nested apply" message for them.
4595///
4596/// A pattern's body that `apply`s another pattern is an edge; a pattern that can
4597/// reach itself (directly via a self-apply, or transitively) cannot elaborate into
4598/// a finite first-order program, so v0 rejects it (spec/static-analysis.md). The
4599/// reachability closure mirrors `models/maude/pattern-recursion.maude`.
4600fn detect_pattern_recursion(
4601    patterns: &BTreeMap<String, PatternDecl>,
4602    diagnostics: &mut Vec<Diagnostic>,
4603) -> BTreeSet<String> {
4604    // Application edges: pattern name -> the patterns its body applies, with spans.
4605    let mut edges: BTreeMap<&str, Vec<(&str, SourceSpan)>> = BTreeMap::new();
4606    for pattern in patterns.values() {
4607        let mut applied = Vec::new();
4608        for item in &pattern.items {
4609            if let Item::Apply(apply) = item {
4610                applied.push((apply.pattern.name.as_str(), apply.span));
4611            }
4612        }
4613        edges.insert(pattern.name.name.as_str(), applied);
4614    }
4615
4616    // A pattern is recursive iff it can reach itself. Find a shortest cycle path
4617    // back to `start` via breadth-first search, tracking each node's predecessor.
4618    let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
4619        let mut queue: VecDeque<&str> = VecDeque::new();
4620        // predecessor[node] = (came_from, span_of_edge) used to first reach `node`.
4621        let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
4622        for &(target, span) in edges.get(start).into_iter().flatten() {
4623            if target == start {
4624                // Direct self-application.
4625                return Some((vec![start.to_owned(), start.to_owned()], span));
4626            }
4627            if predecessor.insert(target, (start, span)).is_none() {
4628                queue.push_back(target);
4629            }
4630        }
4631        while let Some(node) = queue.pop_front() {
4632            for &(target, span) in edges.get(node).into_iter().flatten() {
4633                if target == start {
4634                    // Reconstruct start -> ... -> node, then close back to start.
4635                    let mut path = vec![node.to_owned()];
4636                    let mut cursor = node;
4637                    while cursor != start {
4638                        let (from, _) = predecessor[cursor];
4639                        path.push(from.to_owned());
4640                        cursor = from;
4641                    }
4642                    path.reverse();
4643                    path.push(start.to_owned());
4644                    // Report at the first apply edge of `start` that enters the cycle.
4645                    let first = &path[1];
4646                    let entry_span = edges
4647                        .get(start)
4648                        .into_iter()
4649                        .flatten()
4650                        .find(|(target, _)| target == first)
4651                        .map(|(_, span)| *span)
4652                        .unwrap_or(span);
4653                    return Some((path, entry_span));
4654                }
4655                if predecessor.insert(target, (node, span)).is_none() {
4656                    queue.push_back(target);
4657                }
4658            }
4659        }
4660        None
4661    };
4662
4663    let mut recursive = BTreeSet::new();
4664    // Iterate patterns in declaration-name order for deterministic diagnostics, and
4665    // report each cycle once by skipping members already covered by a prior cycle.
4666    for name in patterns.keys() {
4667        if recursive.contains(name) {
4668            continue;
4669        }
4670        if let Some((cycle, span)) = find_cycle(name) {
4671            for member in &cycle {
4672                recursive.insert(member.clone());
4673            }
4674            diagnostics.push(Diagnostic { related: Vec::new(),
4675                span,
4676                message: format!(
4677                    "recursive pattern application is not allowed (graph.unbounded_pattern_recursion): expansion cycle {}",
4678                    cycle.join(" -> ")
4679                ),
4680                suggestion: Some(
4681                    "break the cycle: pattern expansion must elaborate into a finite program"
4682                        .to_owned(),
4683                ),
4684            });
4685        }
4686    }
4687    recursive
4688}
4689
4690/// Reject a *transitive* runtime workflow-invocation cycle (A invokes B invokes A,
4691/// or longer). RESOLVED 2026-07-01: the invoke-recursion policy is "as permissive
4692/// as provable convergence at compile time allows"; whipplescript has no
4693/// convergence proof for runtime `invoke` recursion (termination is data-dependent
4694/// and there is no decreasing-measure mechanism yet), so — exactly parallel to
4695/// `detect_pattern_recursion` — any cycle is rejected as
4696/// `graph.unbounded_workflow_invocation_recursion`. Direct self-invocation (a cycle
4697/// of length 1) is already rejected per-rule in `validate_workflow_invocations`, so
4698/// self-edges are excluded here and this catches only length >= 2 cycles. Modeled
4699/// as invoke-graph non-convergence in `models/maude/subworkflow-convergence.maude`.
4700fn detect_workflow_invoke_recursion(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
4701    // Invoke edges: workflow name -> the workflows its rules invoke, with the span
4702    // of the invoking rule body. Built over the raw AST (all workflows), so it is
4703    // independent of root selection. Self-edges are excluded (owned by the direct
4704    // per-rule recursion check).
4705    let mut edges: BTreeMap<String, Vec<(String, SourceSpan)>> = BTreeMap::new();
4706    let record_invokes =
4707        |name: &str, items: &[Item], edges: &mut BTreeMap<String, Vec<(String, SourceSpan)>>| {
4708            let entry = edges.entry(name.to_owned()).or_default();
4709            for item in items {
4710                let Item::Rule(rule) = item else {
4711                    continue;
4712                };
4713                for statement in workflow_invoke_statements(&rule.body.text) {
4714                    if let Some((target, _)) = invoke_statement_parts(&statement) {
4715                        if target != name {
4716                            entry.push((target.to_owned(), rule.body.span));
4717                        }
4718                    }
4719                }
4720            }
4721        };
4722    if let Some(root) = &program.workflow {
4723        record_invokes(&root.name, &program.items, &mut edges);
4724    }
4725    for workflow in &program.workflows {
4726        record_invokes(&workflow.name.name, &workflow.items, &mut edges);
4727    }
4728
4729    // A workflow is in a cycle iff it can reach itself over invoke edges. BFS for a
4730    // shortest path back to `start` (mirrors `detect_pattern_recursion`).
4731    let find_cycle = |start: &str| -> Option<(Vec<String>, SourceSpan)> {
4732        let mut queue: VecDeque<&str> = VecDeque::new();
4733        let mut predecessor: BTreeMap<&str, (&str, SourceSpan)> = BTreeMap::new();
4734        for (target, span) in edges.get(start).into_iter().flatten() {
4735            if predecessor
4736                .insert(target.as_str(), (start, *span))
4737                .is_none()
4738            {
4739                queue.push_back(target.as_str());
4740            }
4741        }
4742        while let Some(node) = queue.pop_front() {
4743            for (target, span) in edges.get(node).into_iter().flatten() {
4744                if target == start {
4745                    let mut path = vec![node.to_owned()];
4746                    let mut cursor = node;
4747                    while cursor != start {
4748                        let (from, _) = predecessor[cursor];
4749                        path.push(from.to_owned());
4750                        cursor = from;
4751                    }
4752                    path.reverse();
4753                    path.push(start.to_owned());
4754                    let first = &path[1];
4755                    let entry_span = edges
4756                        .get(start)
4757                        .into_iter()
4758                        .flatten()
4759                        .find(|(target, _)| target == first)
4760                        .map(|(_, span)| *span)
4761                        .unwrap_or(*span);
4762                    return Some((path, entry_span));
4763                }
4764                if predecessor.insert(target.as_str(), (node, *span)).is_none() {
4765                    queue.push_back(target.as_str());
4766                }
4767            }
4768        }
4769        None
4770    };
4771
4772    let mut flagged: BTreeSet<String> = BTreeSet::new();
4773    for name in edges.keys() {
4774        if flagged.contains(name) {
4775            continue;
4776        }
4777        if let Some((cycle, span)) = find_cycle(name) {
4778            for member in &cycle {
4779                flagged.insert(member.clone());
4780            }
4781            diagnostics.push(Diagnostic {
4782                related: Vec::new(),
4783                span,
4784                message: format!(
4785                    "recursive workflow invocation is not allowed (graph.unbounded_workflow_invocation_recursion): invocation cycle {}",
4786                    cycle.join(" -> ")
4787                ),
4788                suggestion: Some(
4789                    "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"
4790                        .to_owned(),
4791                ),
4792            });
4793        }
4794    }
4795}
4796
4797fn detect_private_workflow_invocations(program: &Program, diagnostics: &mut Vec<Diagnostic>) {
4798    let private_workflows = program
4799        .workflows
4800        .iter()
4801        .filter(|workflow| workflow.tags.iter().any(|tag| tag.name == "private"))
4802        .map(|workflow| workflow.name.name.as_str())
4803        .collect::<BTreeSet<_>>();
4804    if private_workflows.is_empty() {
4805        return;
4806    }
4807
4808    let mut record_private_invokes = |caller: &str, items: &[Item]| {
4809        for item in items {
4810            let Item::Rule(rule) = item else {
4811                continue;
4812            };
4813            for statement in workflow_invoke_statements(&rule.body.text) {
4814                let Some((target, _)) = invoke_statement_parts(&statement) else {
4815                    continue;
4816                };
4817                if caller == target || !private_workflows.contains(target) {
4818                    continue;
4819                }
4820                diagnostics.push(Diagnostic {
4821                    related: Vec::new(),
4822                    span: rule.body.span,
4823                    message: format!(
4824                        "rule `{}` invokes private workflow `{target}`",
4825                        rule.name.name
4826                    ),
4827                    suggestion: Some(
4828                        "remove `@private` from the target workflow or expose a public wrapper workflow"
4829                            .to_owned(),
4830                    ),
4831                });
4832            }
4833        }
4834    };
4835
4836    if let Some(root) = &program.workflow {
4837        record_private_invokes(&root.name, &program.items);
4838    }
4839    for workflow in &program.workflows {
4840        record_private_invokes(&workflow.name.name, &workflow.items);
4841    }
4842}
4843
4844fn expand_pattern_applications(
4845    mut program: Program,
4846    diagnostics: &mut Vec<Diagnostic>,
4847) -> (Program, Vec<IrPatternApplication>) {
4848    let mut patterns = BTreeMap::new();
4849    for pattern in &program.patterns {
4850        if patterns
4851            .insert(pattern.name.name.clone(), pattern.clone())
4852            .is_some()
4853        {
4854            diagnostics.push(Diagnostic {
4855                related: Vec::new(),
4856                span: pattern.name.span,
4857                message: format!("pattern `{}` is declared more than once", pattern.name.name),
4858                suggestion: Some("rename one pattern declaration".to_owned()),
4859            });
4860        }
4861    }
4862
4863    // v0 forbids recursive pattern application (spec/static-analysis.md,
4864    // graph.unbounded_pattern_recursion): an `apply` that reaches, directly or
4865    // transitively, a pattern already on the active expansion stack can never
4866    // elaborate into a finite first-order program. Detect cycles up front so the
4867    // precise diagnostic is emitted and the generic "nested apply not supported
4868    // yet" message is suppressed for the recursive case.
4869    let recursive_patterns = detect_pattern_recursion(&patterns, diagnostics);
4870
4871    let mut expanded_items = Vec::new();
4872    let mut applications = Vec::new();
4873    for item in program.items {
4874        let Item::Apply(apply) = item else {
4875            expanded_items.push(item);
4876            continue;
4877        };
4878        let Some(pattern) = patterns.get(&apply.pattern.name) else {
4879            diagnostics.push(Diagnostic {
4880                related: Vec::new(),
4881                span: apply.pattern.span,
4882                message: format!("pattern `{}` was not found", apply.pattern.name),
4883                suggestion: Some("declare the pattern before applying it".to_owned()),
4884            });
4885            continue;
4886        };
4887        if pattern.type_params.len() != apply.type_args.len() {
4888            diagnostics.push(Diagnostic {
4889                related: Vec::new(),
4890                span: apply.span,
4891                message: format!(
4892                    "pattern `{}` expects {} type arguments but got {}",
4893                    pattern.name.name,
4894                    pattern.type_params.len(),
4895                    apply.type_args.len()
4896                ),
4897                suggestion: Some("match the pattern type parameter list".to_owned()),
4898            });
4899            continue;
4900        }
4901        let type_substitutions = pattern
4902            .type_params
4903            .iter()
4904            .map(|param| param.name.clone())
4905            .zip(apply.type_args.iter().cloned())
4906            .collect::<BTreeMap<_, _>>();
4907        let value_substitutions = parse_pattern_value_arguments(&apply, diagnostics);
4908        let local_names = pattern_local_names(pattern, &apply.alias.name);
4909        let definition_span = pattern.span;
4910        let application_span = apply.span;
4911        let mut generated = Vec::new();
4912        for pattern_item in pattern.items.iter().cloned() {
4913            // Enforce the pattern-body allow-list before expanding: a pattern
4914            // is a compile-time reuse fragment, not a workflow, so forbidden
4915            // constructs are rejected with a clear diagnostic and dropped.
4916            if let Some(diagnostic) = pattern_body_admission(&pattern_item, &recursive_patterns) {
4917                diagnostics.push(diagnostic);
4918                continue;
4919            }
4920            if let Some((generated_name, item)) = expand_pattern_item(
4921                pattern_item,
4922                &apply.alias.name,
4923                &type_substitutions,
4924                &value_substitutions,
4925                &local_names,
4926            ) {
4927                generated.push(generated_name);
4928                expanded_items.push(item);
4929            }
4930        }
4931        applications.push(IrPatternApplication {
4932            pattern: pattern.name.name.clone(),
4933            alias: apply.alias.name,
4934            type_args: apply.type_args.into_iter().map(lower_type).collect(),
4935            value_args: value_substitutions
4936                .into_iter()
4937                .map(|(name, value)| IrPatternArgument { name, value })
4938                .collect(),
4939            generated,
4940            definition_span,
4941            application_span,
4942        });
4943    }
4944    program.items = expanded_items;
4945    (program, applications)
4946}
4947
4948fn pattern_local_names(pattern: &PatternDecl, alias: &str) -> BTreeMap<String, String> {
4949    let mut names = BTreeMap::new();
4950    for item in &pattern.items {
4951        match item {
4952            Item::Harness(harness) => {
4953                names.insert(
4954                    harness.name.name.clone(),
4955                    generated_pattern_name(alias, &harness.name.name),
4956                );
4957            }
4958            Item::Agent(agent) => {
4959                names.insert(
4960                    agent.name.name.clone(),
4961                    generated_pattern_name(alias, &agent.name.name),
4962                );
4963            }
4964            Item::Enum(enum_decl) => {
4965                names.insert(
4966                    enum_decl.name.name.clone(),
4967                    generated_pattern_name(alias, &enum_decl.name.name),
4968                );
4969            }
4970            Item::Class(class_decl) => {
4971                names.insert(
4972                    class_decl.name.name.clone(),
4973                    generated_pattern_name(alias, &class_decl.name.name),
4974                );
4975            }
4976            Item::Coerce(coerce) => {
4977                names.insert(
4978                    coerce.name.name.clone(),
4979                    generated_pattern_name(alias, &coerce.name.name),
4980                );
4981            }
4982            Item::Rule(rule) => {
4983                names.insert(
4984                    rule.name.name.clone(),
4985                    generated_pattern_name(alias, &rule.name.name),
4986                );
4987            }
4988            _ => {}
4989        }
4990    }
4991    names
4992}
4993
4994fn generated_pattern_name(alias: &str, name: &str) -> String {
4995    format!("{alias}_{name}")
4996}
4997
4998fn parse_pattern_value_arguments(
4999    apply: &ApplyDecl,
5000    diagnostics: &mut Vec<Diagnostic>,
5001) -> BTreeMap<String, String> {
5002    let mut args = BTreeMap::new();
5003    for line in apply
5004        .body
5005        .text
5006        .lines()
5007        .map(str::trim)
5008        .filter(|line| !line.is_empty())
5009    {
5010        let mut parts = line.splitn(2, char::is_whitespace);
5011        let Some(name) = parts.next().filter(|name| is_identifier(name)) else {
5012            diagnostics.push(Diagnostic {
5013                related: Vec::new(),
5014                span: apply.body.span,
5015                message: format!(
5016                    "pattern application `{}` has malformed argument `{line}`",
5017                    apply.alias.name
5018                ),
5019                suggestion: Some("write pattern arguments as `name value`".to_owned()),
5020            });
5021            continue;
5022        };
5023        let Some(value) = parts
5024            .next()
5025            .map(str::trim)
5026            .filter(|value| !value.is_empty())
5027        else {
5028            diagnostics.push(Diagnostic {
5029                related: Vec::new(),
5030                span: apply.body.span,
5031                message: format!(
5032                    "pattern application `{}` argument `{name}` is missing a value",
5033                    apply.alias.name
5034                ),
5035                suggestion: Some("write pattern arguments as `name value`".to_owned()),
5036            });
5037            continue;
5038        };
5039        if args.insert(name.to_owned(), value.to_owned()).is_some() {
5040            diagnostics.push(Diagnostic {
5041                related: Vec::new(),
5042                span: apply.body.span,
5043                message: format!(
5044                    "pattern application `{}` passes argument `{name}` more than once",
5045                    apply.alias.name
5046                ),
5047                suggestion: Some("remove the duplicate pattern argument".to_owned()),
5048            });
5049        }
5050    }
5051    args
5052}
5053
5054/// The explicit allow-list gate for a `pattern { ... }` body.
5055///
5056/// A pattern is a compile-time reuse fragment, not a workflow. Its body MAY
5057/// declare the building blocks of a workflow -- rules, effects (`coerce`),
5058/// records (via a rule's `record`), local schemas (`class`/`enum`), tables,
5059/// agents/harnesses, and coordination resources -- but it MUST NOT contain:
5060///   * workflow contracts (`input`/`output`/`failure`) -- workflow-level shape,
5061///   * nested `pattern` declarations,
5062///   * nested `apply` (pattern applications inside pattern bodies), or
5063///   * rules that reach a workflow terminal (`complete`/`fail`): a reusable
5064///     fragment must not hard-code the enclosing workflow's terminal outcome.
5065///
5066/// Returns `Some(diagnostic)` for a forbidden construct; `None` when the item is
5067/// on the allow-list.
5068fn pattern_body_admission(
5069    item: &Item,
5070    recursive_patterns: &BTreeSet<String>,
5071) -> Option<Diagnostic> {
5072    match item {
5073        Item::WorkflowContract(contract) => Some(Diagnostic {
5074            related: Vec::new(),
5075            span: contract.span,
5076            message: "workflow contracts are not allowed in pattern bodies".to_owned(),
5077            suggestion: Some(
5078                "declare workflow inputs, outputs, and failures on the workflow".to_owned(),
5079            ),
5080        }),
5081        Item::Pattern(pattern) => Some(Diagnostic {
5082            related: Vec::new(),
5083            span: pattern.span,
5084            message: "nested pattern declarations are not supported in pattern bodies".to_owned(),
5085            suggestion: Some("declare reusable patterns at source top level".to_owned()),
5086        }),
5087        // A recursive nested apply was already rejected with the precise
5088        // graph.unbounded_pattern_recursion diagnostic by detect_pattern_recursion;
5089        // don't also emit the generic "not supported yet" message for it.
5090        Item::Apply(apply) if !recursive_patterns.contains(&apply.pattern.name) => Some(Diagnostic {
5091            related: Vec::new(),
5092            span: apply.span,
5093            message: "pattern applications inside pattern bodies are not supported yet".to_owned(),
5094            suggestion: Some(
5095                "apply patterns from workflow bodies only in this implementation slice".to_owned(),
5096            ),
5097        }),
5098        // Objective intent is top-level: a gauge binds a judge to this
5099        // program's sites and a campaign partitions this program's gauge
5100        // vector — neither is a reusable template fragment.
5101        Item::Gauge(gauge) => Some(Diagnostic {
5102            related: Vec::new(),
5103            span: gauge.span,
5104            message: "gauge declarations are not allowed in pattern bodies".to_owned(),
5105            suggestion: Some("declare gauges at source top level".to_owned()),
5106        }),
5107        Item::Campaign(campaign) => Some(Diagnostic {
5108            related: Vec::new(),
5109            span: campaign.span,
5110            message: "campaign declarations are not allowed in pattern bodies".to_owned(),
5111            suggestion: Some("declare campaigns at source top level".to_owned()),
5112        }),
5113        Item::Mark(mark) => Some(Diagnostic {
5114            related: Vec::new(),
5115            span: mark.span,
5116            message: "mark declarations are not allowed in pattern bodies".to_owned(),
5117            suggestion: Some("declare marks at source top level".to_owned()),
5118        }),
5119        Item::Rule(rule) => pattern_rule_terminal_span(rule).map(|span| Diagnostic {
5120            related: Vec::new(),
5121            span,
5122            message: format!(
5123                "rule `{}` in a pattern body cannot reach a workflow terminal (`complete`/`fail`)",
5124                rule.name.name
5125            ),
5126            suggestion: Some(
5127                "record a fact in the pattern rule and let a workflow rule decide the terminal outcome"
5128                    .to_owned(),
5129            ),
5130        }),
5131        _ => None,
5132    }
5133}
5134
5135/// Locate the first workflow-terminal statement (`complete`/`fail`) in a
5136/// pattern rule body, returning its source span for diagnostics.
5137fn pattern_rule_terminal_span(rule: &RuleDecl) -> Option<SourceSpan> {
5138    let mut offset = 0usize;
5139    for line in rule.body.text.split_inclusive('\n') {
5140        let trimmed_start = line.trim_start();
5141        let leading = line.len() - trimmed_start.len();
5142        let statement = trimmed_start.trim_end();
5143        if is_pattern_terminal_statement(statement) {
5144            let start = rule.body.span.start + offset + leading;
5145            return Some(SourceSpan {
5146                start,
5147                end: start + statement.len(),
5148            });
5149        }
5150        offset += line.len();
5151    }
5152    None
5153}
5154
5155/// A trimmed body line begins a workflow terminal iff it starts with the
5156/// `complete` or `fail` keyword followed by whitespace, `{`, or end of line.
5157fn is_pattern_terminal_statement(line: &str) -> bool {
5158    for keyword in ["complete", "fail"] {
5159        if let Some(rest) = line.strip_prefix(keyword) {
5160            if rest.is_empty() || rest.starts_with('{') || rest.starts_with(char::is_whitespace) {
5161                return true;
5162            }
5163        }
5164    }
5165    false
5166}
5167
5168fn expand_pattern_item(
5169    item: Item,
5170    alias: &str,
5171    type_substitutions: &BTreeMap<String, TypeSyntax>,
5172    value_substitutions: &BTreeMap<String, String>,
5173    local_names: &BTreeMap<String, String>,
5174) -> Option<(String, Item)> {
5175    match item {
5176        Item::Include(include) => Some((
5177            format!("include:{}", include.path.value),
5178            Item::Include(include),
5179        )),
5180        Item::Use(use_decl) => Some((format!("use:{}", use_decl.name.value), Item::Use(use_decl))),
5181        Item::Tracker(queue) => {
5182            Some((format!("tracker:{}", queue.name.name), Item::Tracker(queue)))
5183        }
5184        Item::Stream(stream) => {
5185            Some((format!("stream:{}", stream.name.name), Item::Stream(stream)))
5186        }
5187        Item::Channel(channel) => Some((
5188            format!("channel:{}", channel.name.name),
5189            Item::Channel(channel),
5190        )),
5191        Item::Credential(credential) => Some((
5192            format!("credential:{}", credential.name.name),
5193            Item::Credential(credential),
5194        )),
5195        // Gauges, campaigns, and marks are rejected from pattern bodies by
5196        // `pattern_body_admission` (objective intent and cut points are
5197        // top-level); there is deliberately no expansion path for them.
5198        Item::Gauge(_) | Item::Campaign(_) | Item::Mark(_) => None,
5199        Item::FileStore(file_store) => Some((
5200            format!("file-store:{}", file_store.name.name),
5201            Item::FileStore(file_store),
5202        )),
5203        Item::MemoryPool(pool) => Some((
5204            format!("memory-pool:{}", pool.name.name),
5205            Item::MemoryPool(pool),
5206        )),
5207        Item::Event(event) => Some((format!("event:{}", event.name), Item::Event(event))),
5208        Item::Source(source) => {
5209            Some((format!("source:{}", source.name.name), Item::Source(source)))
5210        }
5211        Item::Test(test) => Some((format!("test:{}", test.name.value), Item::Test(test))),
5212        Item::Lease(lease) => Some((format!("lease:{}", lease.name.name), Item::Lease(lease))),
5213        Item::Ledger(ledger) => {
5214            Some((format!("ledger:{}", ledger.name.name), Item::Ledger(ledger)))
5215        }
5216        Item::Counter(counter) => Some((
5217            format!("counter:{}", counter.name.name),
5218            Item::Counter(counter),
5219        )),
5220        Item::Action(action) => {
5221            Some((format!("action:{}", action.name.name), Item::Action(action)))
5222        }
5223        Item::Harness(mut harness) => {
5224            let name = rename_ident(harness.name, alias, local_names);
5225            let generated = format!("harness:{}", name.name);
5226            harness.name = name;
5227            Some((generated, Item::Harness(harness)))
5228        }
5229        // Forbidden constructs are rejected up front by `pattern_body_admission`
5230        // (the explicit allow-list gate), so these arms are unreachable in
5231        // practice; they stay defensive and simply drop the item.
5232        Item::WorkflowContract(_) | Item::Pattern(_) | Item::Apply(_) => None,
5233        Item::Agent(mut agent) => {
5234            let name = rename_ident(agent.name, alias, local_names);
5235            let generated = format!("agent:{}", name.name);
5236            agent.name = name;
5237            if let Some(harness) = agent.harness {
5238                agent.harness = Some(Ident {
5239                    name: local_names
5240                        .get(&harness.name)
5241                        .cloned()
5242                        .unwrap_or(harness.name),
5243                    span: harness.span,
5244                });
5245            }
5246            Some((generated, Item::Agent(agent)))
5247        }
5248        Item::Enum(mut enum_decl) => {
5249            let name = rename_ident(enum_decl.name, alias, local_names);
5250            let generated = format!("enum:{}", name.name);
5251            enum_decl.name = name;
5252            Some((generated, Item::Enum(enum_decl)))
5253        }
5254        Item::Class(mut class_decl) => {
5255            let name = rename_ident(class_decl.name, alias, local_names);
5256            let generated = format!("class:{}", name.name);
5257            class_decl.name = name;
5258            for field in &mut class_decl.fields {
5259                field.ty =
5260                    substitute_pattern_type(field.ty.clone(), type_substitutions, local_names);
5261            }
5262            Some((generated, Item::Class(class_decl)))
5263        }
5264        Item::Table(mut table) => {
5265            let name = rename_ident(table.name, alias, local_names);
5266            let generated = format!("table:{}", name.name);
5267            table.name = name;
5268            for row in &mut table.rows {
5269                row.body.text = substitute_pattern_text(
5270                    &row.body.text,
5271                    type_substitutions,
5272                    value_substitutions,
5273                    local_names,
5274                );
5275            }
5276            Some((generated, Item::Table(table)))
5277        }
5278        Item::Coerce(mut coerce) => {
5279            let name = rename_ident(coerce.name, alias, local_names);
5280            let generated = format!("coerce:{}", name.name);
5281            coerce.name = name;
5282            for param in &mut coerce.params {
5283                param.ty =
5284                    substitute_pattern_type(param.ty.clone(), type_substitutions, local_names);
5285            }
5286            coerce.output =
5287                substitute_pattern_type(coerce.output.clone(), type_substitutions, local_names);
5288            coerce.body.text = substitute_pattern_text(
5289                &coerce.body.text,
5290                type_substitutions,
5291                value_substitutions,
5292                local_names,
5293            );
5294            Some((generated, Item::Coerce(coerce)))
5295        }
5296        Item::Assert(mut assertion) => {
5297            assertion.expr = substitute_pattern_text(
5298                &assertion.expr,
5299                type_substitutions,
5300                value_substitutions,
5301                local_names,
5302            );
5303            Some((format!("assert:{alias}"), Item::Assert(assertion)))
5304        }
5305        Item::Rule(mut rule) => {
5306            let name = rename_ident(rule.name, alias, local_names);
5307            let generated = format!("rule:{}", name.name);
5308            rule.name = name;
5309            for when in &mut rule.whens {
5310                when.text = substitute_pattern_text(
5311                    &when.text,
5312                    type_substitutions,
5313                    value_substitutions,
5314                    local_names,
5315                );
5316            }
5317            rule.body.text = substitute_pattern_text(
5318                &rule.body.text,
5319                type_substitutions,
5320                value_substitutions,
5321                local_names,
5322            );
5323            Some((generated, Item::Rule(rule)))
5324        }
5325    }
5326}
5327
5328fn rename_ident(ident: Ident, alias: &str, local_names: &BTreeMap<String, String>) -> Ident {
5329    Ident {
5330        name: local_names
5331            .get(&ident.name)
5332            .cloned()
5333            .unwrap_or_else(|| generated_pattern_name(alias, &ident.name)),
5334        span: ident.span,
5335    }
5336}
5337
5338fn substitute_pattern_type(
5339    ty: TypeSyntax,
5340    type_substitutions: &BTreeMap<String, TypeSyntax>,
5341    local_names: &BTreeMap<String, String>,
5342) -> TypeSyntax {
5343    match ty {
5344        TypeSyntax::Ref { name } => {
5345            if let Some(replacement) = type_substitutions.get(&name.name) {
5346                return replacement.clone();
5347            }
5348            TypeSyntax::Ref {
5349                name: Ident {
5350                    name: local_names.get(&name.name).cloned().unwrap_or(name.name),
5351                    span: name.span,
5352                },
5353            }
5354        }
5355        TypeSyntax::AgentRef { agents, span } => TypeSyntax::AgentRef {
5356            agents: agents
5357                .into_iter()
5358                .map(|agent| Ident {
5359                    name: local_names.get(&agent.name).cloned().unwrap_or(agent.name),
5360                    span: agent.span,
5361                })
5362                .collect(),
5363            span,
5364        },
5365        TypeSyntax::Optional { inner, span } => TypeSyntax::Optional {
5366            inner: Box::new(substitute_pattern_type(
5367                *inner,
5368                type_substitutions,
5369                local_names,
5370            )),
5371            span,
5372        },
5373        TypeSyntax::Array { inner, span } => TypeSyntax::Array {
5374            inner: Box::new(substitute_pattern_type(
5375                *inner,
5376                type_substitutions,
5377                local_names,
5378            )),
5379            span,
5380        },
5381        TypeSyntax::Map { inner, span } => TypeSyntax::Map {
5382            inner: Box::new(substitute_pattern_type(
5383                *inner,
5384                type_substitutions,
5385                local_names,
5386            )),
5387            span,
5388        },
5389        TypeSyntax::Union { variants, span } => TypeSyntax::Union {
5390            variants: variants
5391                .into_iter()
5392                .map(|variant| substitute_pattern_type(variant, type_substitutions, local_names))
5393                .collect(),
5394            span,
5395        },
5396        other => other,
5397    }
5398}
5399
5400/// Substitute a pattern's type parameters, hygienic local names, and value
5401/// arguments through an item's source text in ONE pass.
5402///
5403/// Single-pass is what makes the substitution hygienic. Running one
5404/// whole-string replacement per map over a shared accumulator — as this did —
5405/// let each pass rescan text the previous passes had inserted. A type argument
5406/// was therefore capturable by a pattern-local declaration: `apply Review<Task>`
5407/// against a pattern declaring its own `Task` had the argument rewritten to the
5408/// pattern's gensym, so the rule matched the pattern's local class instead of
5409/// the caller's type. Value-argument keys are unvalidated identifiers taken
5410/// from the apply body and ran last, so they could rewrite whatever the type
5411/// pass had just produced. Both failed closed on downstream name resolution,
5412/// but only after handing the author a diagnostic naming an identifier they
5413/// never wrote.
5414///
5415/// Each identifier token is now resolved exactly once, against the maps in the
5416/// priority order the multi-pass form implied, and its replacement is emitted
5417/// without being re-examined. Token boundaries are unchanged: a maximal run of
5418/// identifier characters is precisely what the old boundary test accepted.
5419fn substitute_pattern_text(
5420    text: &str,
5421    type_substitutions: &BTreeMap<String, TypeSyntax>,
5422    value_substitutions: &BTreeMap<String, String>,
5423    local_names: &BTreeMap<String, String>,
5424) -> String {
5425    let mut output = String::with_capacity(text.len());
5426    let mut rest = text;
5427    while let Some(start) = rest.find(is_identifier_char) {
5428        output.push_str(&rest[..start]);
5429        let after = &rest[start..];
5430        let end = after
5431            .find(|ch| !is_identifier_char(ch))
5432            .unwrap_or(after.len());
5433        let token = &after[..end];
5434        match resolve_pattern_token(token, type_substitutions, value_substitutions, local_names) {
5435            Some(replacement) => output.push_str(&replacement),
5436            None => output.push_str(token),
5437        }
5438        rest = &after[end..];
5439    }
5440    output.push_str(rest);
5441    output
5442}
5443
5444/// Resolve one identifier token against the substitution maps, in the priority
5445/// order the multi-pass form implied: a type parameter first, then a
5446/// pattern-local declaration, then a value argument. A token naming none of
5447/// them is not substituted.
5448fn resolve_pattern_token(
5449    token: &str,
5450    type_substitutions: &BTreeMap<String, TypeSyntax>,
5451    value_substitutions: &BTreeMap<String, String>,
5452    local_names: &BTreeMap<String, String>,
5453) -> Option<String> {
5454    if let Some(ty) = type_substitutions.get(token) {
5455        return Some(ty.to_source());
5456    }
5457    if let Some(local) = local_names.get(token) {
5458        return Some(local.clone());
5459    }
5460    value_substitutions.get(token).cloned()
5461}
5462
5463fn is_identifier_char(ch: char) -> bool {
5464    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
5465}
5466
5467fn collect_projection_reads(expr: &Expr) -> Vec<IrProjectionRead> {
5468    let mut reads = Vec::new();
5469    collect_projection_reads_into(expr, &mut reads);
5470    reads
5471}
5472
5473fn collect_projection_reads_into(expr: &Expr, reads: &mut Vec<IrProjectionRead>) {
5474    match expr {
5475        Expr::Literal(_) | Expr::Path(_) => {}
5476        Expr::Index { target, key } => {
5477            collect_projection_reads_into(target, reads);
5478            collect_projection_reads_into(key, reads);
5479        }
5480        Expr::Array(items) => {
5481            for item in items {
5482                collect_projection_reads_into(item, reads);
5483            }
5484        }
5485        Expr::Object(fields) => {
5486            for field in fields {
5487                collect_projection_reads_into(&field.value, reads);
5488            }
5489        }
5490        Expr::Unary { expr, .. } => collect_projection_reads_into(expr, reads),
5491        Expr::Binary { left, right, .. } => {
5492            collect_projection_reads_into(left, reads);
5493            collect_projection_reads_into(right, reads);
5494        }
5495        Expr::Call { args, .. } => {
5496            for arg in args {
5497                collect_projection_reads_into(arg, reads);
5498            }
5499        }
5500        Expr::Query { kind, head, guard } => {
5501            reads.push(IrProjectionRead {
5502                kind: *kind,
5503                head: head.clone(),
5504                guard: guard.as_ref().map(|guard| guard.to_snapshot()),
5505            });
5506            if let Some(guard) = guard {
5507                collect_projection_reads_into(guard, reads);
5508            }
5509        }
5510    }
5511}
5512
5513fn sort_projection_reads(reads: &mut Vec<IrProjectionRead>) {
5514    reads.sort_by_key(IrProjectionRead::to_snapshot);
5515    reads.dedup();
5516}
5517
5518fn collect_schema_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
5519    let mut names = BTreeSet::new();
5520    // Track the first declaration span per name so a duplicate can point back to
5521    // it as related information ("first declared here").
5522    let mut first_spans: BTreeMap<String, SourceSpan> = BTreeMap::new();
5523    for item in &program.items {
5524        let name = match item {
5525            Item::Enum(enum_decl) => &enum_decl.name,
5526            Item::Class(class_decl) => &class_decl.name,
5527            _ => continue,
5528        };
5529
5530        if !names.insert(name.name.clone()) {
5531            let mut diagnostic = Diagnostic {
5532                related: Vec::new(),
5533                span: name.span,
5534                message: format!("schema `{}` is declared more than once", name.name),
5535                suggestion: Some("rename one declaration or merge the schemas".to_owned()),
5536            };
5537            if let Some(first) = first_spans.get(&name.name) {
5538                diagnostic = diagnostic.with_related(*first, "first declared here");
5539            }
5540            diagnostics.push(diagnostic);
5541        } else {
5542            first_spans.insert(name.name.clone(), name.span);
5543        }
5544    }
5545
5546    names
5547}
5548
5549fn collect_harness_kinds(
5550    program: &Program,
5551    diagnostics: &mut Vec<Diagnostic>,
5552) -> BTreeMap<String, String> {
5553    let mut kinds: BTreeMap<String, String> = BTreeMap::new();
5554    for item in &program.items {
5555        let Item::Harness(harness) = item else {
5556            continue;
5557        };
5558        if kinds
5559            .insert(harness.name.name.clone(), harness.kind.name.clone())
5560            .is_some()
5561        {
5562            diagnostics.push(Diagnostic {
5563                related: Vec::new(),
5564                span: harness.name.span,
5565                message: format!("harness `{}` is declared more than once", harness.name.name),
5566                suggestion: Some(
5567                    "rename one harness declaration or merge the harness settings".to_owned(),
5568                ),
5569            });
5570        }
5571    }
5572    kinds
5573}
5574
5575fn collect_agent_names(program: &Program, diagnostics: &mut Vec<Diagnostic>) -> BTreeSet<String> {
5576    let mut names = BTreeSet::new();
5577    for item in &program.items {
5578        let Item::Agent(agent) = item else {
5579            continue;
5580        };
5581        if !names.insert(agent.name.name.clone()) {
5582            diagnostics.push(Diagnostic {
5583                related: Vec::new(),
5584                span: agent.name.span,
5585                message: format!("agent `{}` is declared more than once", agent.name.name),
5586                suggestion: Some("rename one agent declaration or merge the settings".to_owned()),
5587            });
5588        }
5589    }
5590    names
5591}
5592
5593#[derive(Clone, Debug, Default, Eq, PartialEq)]
5594struct WorkflowContractNames {
5595    inputs: BTreeMap<String, TypeSyntax>,
5596    outputs: BTreeMap<String, TypeSyntax>,
5597    failures: BTreeMap<String, TypeSyntax>,
5598}
5599
5600fn collect_workflow_contract_names(
5601    program: &Program,
5602    diagnostics: &mut Vec<Diagnostic>,
5603) -> WorkflowContractNames {
5604    let mut names = WorkflowContractNames::default();
5605    for item in &program.items {
5606        let Item::WorkflowContract(contract) = item else {
5607            continue;
5608        };
5609        let set = match contract.kind {
5610            WorkflowContractKind::Input => &mut names.inputs,
5611            WorkflowContractKind::Output => &mut names.outputs,
5612            WorkflowContractKind::Failure => &mut names.failures,
5613        };
5614        if set
5615            .insert(contract.name.name.clone(), contract.ty.clone())
5616            .is_some()
5617        {
5618            diagnostics.push(Diagnostic {
5619                related: Vec::new(),
5620                span: contract.name.span,
5621                message: format!(
5622                    "workflow declares {} `{}` more than once",
5623                    contract.kind.as_str(),
5624                    contract.name.name
5625                ),
5626                suggestion: Some("remove the duplicate workflow contract".to_owned()),
5627            });
5628        }
5629    }
5630    names
5631}
5632
5633impl SemanticContext {
5634    fn from_program(
5635        program: &Program,
5636        workflow_inputs: BTreeMap<String, WorkflowInputSurface>,
5637    ) -> Self {
5638        let mut schemas = SchemaIndex::with_builtins();
5639        let mut agents = BTreeSet::new();
5640        let mut agent_capabilities = BTreeMap::new();
5641        let mut coerce_outputs = BTreeMap::new();
5642        let mut coerce_params = BTreeMap::new();
5643        let mut leases = BTreeSet::new();
5644        let mut ledgers = BTreeSet::new();
5645        let mut counters = BTreeSet::new();
5646        let mut channels = BTreeSet::new();
5647        let mut channel_providers = BTreeMap::new();
5648        let mut credentials = BTreeMap::new();
5649        let mut memory_pools = BTreeSet::new();
5650
5651        for item in &program.items {
5652            schemas.insert_item(item);
5653            match item {
5654                Item::Agent(agent) => {
5655                    agents.insert(agent.name.name.clone());
5656                    let capabilities = agent
5657                        .fields
5658                        .iter()
5659                        .find_map(|field| match field {
5660                            AgentField::Capabilities(capabilities, _) => Some(
5661                                capabilities
5662                                    .iter()
5663                                    .map(|capability| capability.value.clone())
5664                                    .collect::<BTreeSet<_>>(),
5665                            ),
5666                            _ => None,
5667                        })
5668                        .unwrap_or_default();
5669                    agent_capabilities.insert(agent.name.name.clone(), capabilities);
5670                }
5671                Item::Coerce(coerce) => {
5672                    coerce_outputs.insert(coerce.name.name.clone(), coerce.output.clone());
5673                    coerce_params.insert(coerce.name.name.clone(), coerce.params.clone());
5674                }
5675                Item::Lease(lease) => {
5676                    leases.insert(lease.name.name.clone());
5677                }
5678                Item::Ledger(ledger) => {
5679                    ledgers.insert(ledger.name.name.clone());
5680                }
5681                Item::Counter(counter) => {
5682                    counters.insert(counter.name.name.clone());
5683                }
5684                Item::Channel(channel) => {
5685                    channels.insert(channel.name.name.clone());
5686                    channel_providers
5687                        .insert(channel.name.name.clone(), channel.provider.name.clone());
5688                }
5689                Item::Credential(credential) => {
5690                    credentials.insert(
5691                        credential.name.name.clone(),
5692                        credential.kind.name.replace('_', "-"),
5693                    );
5694                }
5695                Item::MemoryPool(pool) => {
5696                    memory_pools.insert(pool.name.name.clone());
5697                }
5698                _ => {}
5699            }
5700        }
5701
5702        Self {
5703            workflow: program
5704                .workflow
5705                .as_ref()
5706                .map(|workflow| workflow.name.clone()),
5707            schemas,
5708            agents,
5709            agent_capabilities,
5710            coerce_outputs,
5711            coerce_params,
5712            workflow_inputs,
5713            leases,
5714            ledgers,
5715            counters,
5716            channels,
5717            channel_providers,
5718            credentials,
5719            memory_pools,
5720            regions: BTreeMap::new(),
5721        }
5722    }
5723}
5724
5725fn collect_workflow_input_surfaces(program: &Program) -> BTreeMap<String, WorkflowInputSurface> {
5726    let mut surfaces = BTreeMap::new();
5727    let top_level_schemas = schema_index_for_items(&program.items);
5728
5729    if let Some(workflow) = &program.workflow {
5730        let inputs = workflow_inputs_for_items(&program.items);
5731        surfaces.insert(
5732            workflow.name.clone(),
5733            WorkflowInputSurface {
5734                inputs,
5735                outputs: workflow_outputs_for_items(&program.items),
5736                failures: workflow_failures_for_items(&program.items),
5737                schemas: top_level_schemas.clone(),
5738                milestones: collect_milestone_declarations(&program.items),
5739            },
5740        );
5741    }
5742
5743    for workflow in &program.workflows {
5744        let mut schemas = top_level_schemas.clone();
5745        schemas.merge(schema_index_for_items(&workflow.items));
5746        surfaces.insert(
5747            workflow.name.name.clone(),
5748            WorkflowInputSurface {
5749                inputs: workflow_inputs_for_items(&workflow.items),
5750                outputs: workflow_outputs_for_items(&workflow.items),
5751                failures: workflow_failures_for_items(&workflow.items),
5752                schemas,
5753                milestones: collect_milestone_declarations(&workflow.items),
5754            },
5755        );
5756    }
5757
5758    surfaces
5759}
5760
5761fn collect_shared_coordination_usage(program: &Program) -> Vec<IrSharedCoordinationUsage> {
5762    let global_shared = shared_coordination_declarations(&program.items);
5763    let mut usage: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
5764
5765    let mut record_workflow = |workflow_name: &str, local_items: &[Item]| {
5766        let mut shared = global_shared.clone();
5767        shared.extend(shared_coordination_declarations(local_items));
5768        if shared.is_empty() {
5769            return;
5770        }
5771        let principal = format!("workflow:local/{workflow_name}");
5772        for resource in coordination_resources_used_by_items(&program.items)
5773            .into_iter()
5774            .chain(coordination_resources_used_by_items(local_items))
5775        {
5776            if shared.contains(&resource) {
5777                usage.entry(resource).or_default().insert(principal.clone());
5778            }
5779        }
5780    };
5781
5782    if let Some(workflow) = &program.workflow {
5783        record_workflow(&workflow.name, &[]);
5784    }
5785    for workflow in &program.workflows {
5786        record_workflow(&workflow.name.name, &workflow.items);
5787    }
5788
5789    usage
5790        .into_iter()
5791        .map(|(resource, principals)| IrSharedCoordinationUsage {
5792            resource: format!("resource:{resource}"),
5793            workflow_principals: principals.into_iter().collect(),
5794        })
5795        .collect()
5796}
5797
5798fn shared_coordination_declarations(items: &[Item]) -> BTreeSet<String> {
5799    items
5800        .iter()
5801        .filter_map(|item| match item {
5802            Item::Lease(lease) if lease.shared => Some(lease.name.name.clone()),
5803            Item::Ledger(ledger) if ledger.shared => Some(ledger.name.name.clone()),
5804            Item::Counter(counter) if counter.shared => Some(counter.name.name.clone()),
5805            _ => None,
5806        })
5807        .collect()
5808}
5809
5810fn coordination_resources_used_by_items(items: &[Item]) -> BTreeSet<String> {
5811    let mut resources = BTreeSet::new();
5812    for item in items {
5813        let Item::Rule(rule) = item else {
5814            continue;
5815        };
5816        let (body, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
5817        collect_coordination_resources_from_statements(&body.statements, &mut resources);
5818    }
5819    resources
5820}
5821
5822fn collect_coordination_resources_from_statements(
5823    statements: &[body::BodyStmt],
5824    resources: &mut BTreeSet<String>,
5825) {
5826    for statement in statements {
5827        match statement {
5828            body::BodyStmt::Effect(effect) => match &effect.kind {
5829                body::BodyEffectKind::LeaseAcquire { resource, .. } => {
5830                    resources.insert(resource.clone());
5831                }
5832                body::BodyEffectKind::LedgerAppend { ledger, .. } => {
5833                    resources.insert(ledger.clone());
5834                }
5835                body::BodyEffectKind::CounterConsume { counter, .. } => {
5836                    resources.insert(counter.clone());
5837                }
5838                _ => {}
5839            },
5840            body::BodyStmt::After(after) => {
5841                collect_coordination_resources_from_statements(&after.body, resources);
5842            }
5843            body::BodyStmt::Region(region) => {
5844                collect_coordination_resources_from_statements(&region.body, resources);
5845                collect_coordination_resources_from_statements(&region.lapse_body, resources);
5846            }
5847            body::BodyStmt::Case(case_stmt) => {
5848                for branch in &case_stmt.branches {
5849                    collect_coordination_resources_from_statements(&branch.body, resources);
5850                }
5851            }
5852            body::BodyStmt::Record(_)
5853            | body::BodyStmt::Done { .. }
5854            | body::BodyStmt::Terminal(_)
5855            | body::BodyStmt::Cancel { .. }
5856            | body::BodyStmt::Milestone { .. }
5857            | body::BodyStmt::Redact { .. } => {}
5858        }
5859    }
5860}
5861
5862/// Scans a workflow's rule bodies for `emit milestone "<name>" [of <Class>]`
5863/// projections (Family C) and returns the name -> payload-class map (empty class
5864/// string for a bare milestone). The emit statement IS the declaration — the
5865/// declared milestone set is exactly what the workflow's rules can project, which
5866/// is what a parent's `after p reaches "<name>"` is validated against.
5867fn collect_milestone_declarations(items: &[Item]) -> BTreeMap<String, String> {
5868    let mut milestones = BTreeMap::new();
5869    for item in items {
5870        let Item::Rule(rule) = item else {
5871            continue;
5872        };
5873        for (name, class) in milestone_emissions_in_body(&rule.body.text) {
5874            milestones.entry(name).or_insert(class);
5875        }
5876    }
5877    milestones
5878}
5879
5880/// Validates Family C milestone statements in a rule (spec/decision-records/
5881/// discriminated-families-design.md sections 6.4 / 7.3):
5882///   - child `emit milestone "<name>" of <Class>` — `<Class>` must be a declared
5883///     class (the payload the observing parent narrows into scope);
5884///   - parent `after <p> reaches "<name>"` — `<p>` must be a workflow-invoke
5885///     binding in this rule, and `<name>` must be a milestone that the invoked
5886///     child workflow actually declares (the reject-undeclared / terminal-only
5887///     observation invariant: a parent cannot observe a state the child never
5888///     projects).
5889fn validate_milestone_statements(
5890    rule: &RuleDecl,
5891    semantic: &SemanticContext,
5892    diagnostics: &mut Vec<Diagnostic>,
5893) {
5894    // Child side: every `emit milestone "<name>" of <Class>` payload class must
5895    // exist.
5896    for (name, class) in milestone_emissions_in_body(&rule.body.text) {
5897        if !class.is_empty() && !semantic.schemas.class_exists(&class) {
5898            diagnostics.push(Diagnostic {
5899                related: Vec::new(),
5900                span: rule.body.span,
5901                message: format!(
5902                    "rule `{}` emits milestone `{name}` with unknown payload class `{class}`",
5903                    rule.name.name
5904                ),
5905                suggestion: Some(format!("declare `class {class}` before projecting it")),
5906            });
5907        }
5908    }
5909
5910    // Parent side: every `after <p> reaches "<name>"` must name a milestone the
5911    // invoked child declares.
5912    for (binding, milestone) in milestone_reaches_in_body(&rule.body.text) {
5913        let Some(workflow) = invoke_binding_workflow(rule, &binding) else {
5914            diagnostics.push(Diagnostic {
5915                related: Vec::new(),
5916                span: rule.body.span,
5917                message: format!(
5918                    "rule `{}` has `after {binding} reaches \"{milestone}\"` for `{binding}`, which is not a workflow-invoke binding in this rule",
5919                    rule.name.name
5920                ),
5921                suggestion: Some(
5922                    "`reaches` observes a child workflow milestone; bind the child with `invoke W { ... } as <binding>` first"
5923                        .to_owned(),
5924                ),
5925            });
5926            continue;
5927        };
5928        let declared = semantic
5929            .workflow_inputs
5930            .get(&workflow)
5931            .map(|surface| surface.milestones.contains_key(&milestone))
5932            .unwrap_or(false);
5933        if !declared {
5934            let available = semantic
5935                .workflow_inputs
5936                .get(&workflow)
5937                .map(|surface| {
5938                    surface
5939                        .milestones
5940                        .keys()
5941                        .map(|name| format!("\"{name}\""))
5942                        .collect::<Vec<_>>()
5943                        .join(", ")
5944                })
5945                .unwrap_or_default();
5946            let suggestion = if available.is_empty() {
5947                format!("workflow `{workflow}` declares no milestones; add `emit milestone \"{milestone}\" ...` to it")
5948            } else {
5949                format!("workflow `{workflow}` declares: {available}")
5950            };
5951            diagnostics.push(Diagnostic {
5952                related: Vec::new(),
5953                span: rule.body.span,
5954                message: format!(
5955                    "rule `{}` reaches milestone `{milestone}` that workflow `{workflow}` does not declare",
5956                    rule.name.name
5957                ),
5958                suggestion: Some(suggestion),
5959            });
5960        }
5961    }
5962}
5963
5964/// Parses `after <binding> reaches "<name>"` headers out of a rule body's text,
5965/// returning (binding, milestone-name) pairs. Mirrors `milestone_emissions_in_body`.
5966fn milestone_reaches_in_body(body: &str) -> Vec<(String, String)> {
5967    let mut out = Vec::new();
5968    for raw in body.lines() {
5969        let trimmed = raw.trim();
5970        let Some(rest) = trimmed.strip_prefix("after ") else {
5971            continue;
5972        };
5973        let mut words = rest.split_whitespace();
5974        let Some(binding) = words.next() else {
5975            continue;
5976        };
5977        if words.next() != Some("reaches") {
5978            continue;
5979        }
5980        let Some(quoted) = words.next() else {
5981            continue;
5982        };
5983        if !(quoted.starts_with('"') && quoted.ends_with('"') && quoted.len() >= 2) {
5984            continue;
5985        }
5986        out.push((binding.to_owned(), quoted.trim_matches('"').to_owned()));
5987    }
5988    out
5989}
5990
5991/// Maps an `invoke <Workflow> { ... } as <binding>` binding to the invoked
5992/// workflow name within a single rule, so a sibling `after <binding> reaches`
5993/// can find the child workflow whose milestones it observes.
5994fn invoke_binding_workflow(rule: &RuleDecl, binding: &str) -> Option<String> {
5995    for statement in workflow_invoke_statements(&rule.body.text) {
5996        let (target, _) = invoke_statement_parts(&statement)?;
5997        if let Some(as_binding) = binding_after_as(&statement) {
5998            if as_binding == binding {
5999                return Some(target.to_owned());
6000            }
6001        }
6002    }
6003    None
6004}
6005
6006/// Resolves the payload class of a child milestone for `after <binding> reaches
6007/// "<milestone>"`: follow `binding` to its invoked workflow, then look up the
6008/// milestone in that workflow's declared set. Returns the owning workflow with
6009/// the class, because the class may be declared inside that child and so
6010/// resolves in the child's index, not this workflow's (`SchemaScopes`).
6011/// `Some((_, ""))` means the milestone is declared but payload-less; `None`
6012/// means undeclared (reject) or unresolvable.
6013fn milestone_payload_class(
6014    rule: &RuleDecl,
6015    binding: &str,
6016    milestone: &str,
6017    semantic: &SemanticContext,
6018) -> Option<(String, String)> {
6019    let workflow = invoke_binding_workflow(rule, binding)?;
6020    let surface = semantic.workflow_inputs.get(&workflow)?;
6021    let class = surface.milestones.get(milestone).cloned()?;
6022    Some((workflow, class))
6023}
6024
6025/// Resolves the OUTPUT-contract class of the child workflow a `succeeds`/`completes`
6026/// invoke binding observes, so `after <binding> succeeds as r` can type `r` and
6027/// check `r.<field>`. `None` (leave the binding opaque, unchanged) when: the binding
6028/// is not an invoke; the child declares zero or several outputs (which output the
6029/// child completes is not statically known); or the sole output is a scalar (no
6030/// fields).
6031///
6032/// The class is resolved in the CHILD's index and returned with its owning
6033/// workflow. A child that declares its output class workflow-locally — the
6034/// ordinary, encapsulated spelling — used to fall through this function and leave
6035/// `r.<field>` unchecked; the class still never becomes nameable in the parent,
6036/// only resolvable for reads off this binding (`SchemaScopes`).
6037fn invoke_output_class(
6038    rule: &RuleDecl,
6039    binding: &str,
6040    semantic: &SemanticContext,
6041) -> Option<(String, String)> {
6042    let workflow = invoke_binding_workflow(rule, binding)?;
6043    let surface = semantic.workflow_inputs.get(&workflow)?;
6044    if surface.outputs.len() != 1 {
6045        return None;
6046    }
6047    match surface.outputs.values().next()? {
6048        TypeSyntax::Ref { name } if surface.schemas.class_exists(&name.name) => {
6049            Some((workflow, name.name.clone()))
6050        }
6051        _ => None,
6052    }
6053}
6054
6055/// Resolves the FAILURE-contract class of the child workflow a `fails` invoke
6056/// binding observes, so `after <binding> fails as f` can type `f` to the child's
6057/// declared failure shape (and check `f.<field>`) instead of the generic DR-0032
6058/// `TerminalFailed` base. `None` (fall back to the base) when: the binding is not
6059/// an invoke; the child declares zero or several failures (which failure the child
6060/// raised is not statically known); or the sole failure is a scalar (no fields).
6061/// Anything else keeps the base, which every failure structurally satisfies.
6062///
6063/// As with `invoke_output_class`, the class is resolved in the CHILD's index and
6064/// returned with its owning workflow, so a child that declares its failure class
6065/// workflow-locally is typed rather than silently left on the base.
6066fn invoke_failure_class(
6067    rule: &RuleDecl,
6068    binding: &str,
6069    semantic: &SemanticContext,
6070) -> Option<(String, String)> {
6071    let workflow = invoke_binding_workflow(rule, binding)?;
6072    let surface = semantic.workflow_inputs.get(&workflow)?;
6073    if surface.failures.len() != 1 {
6074        return None;
6075    }
6076    match surface.failures.values().next()? {
6077        TypeSyntax::Ref { name } if surface.schemas.class_exists(&name.name) => {
6078            Some((workflow, name.name.clone()))
6079        }
6080        _ => None,
6081    }
6082}
6083
6084/// Parses `emit milestone "<name>" [of <Class>]` headers out of a rule body's
6085/// text, returning (name, class) pairs (class is empty for a bare milestone).
6086/// Text-based to mirror the other body scanners (`workflow_invoke_statements`)
6087/// and stay independent of flow-vs-rule body provenance.
6088fn milestone_emissions_in_body(body: &str) -> Vec<(String, String)> {
6089    let mut out = Vec::new();
6090    for raw in body.lines() {
6091        let trimmed = raw.trim();
6092        let Some(rest) = trimmed.strip_prefix("emit milestone ") else {
6093            continue;
6094        };
6095        // The name is a quoted string literal; take the text between the first
6096        // pair of quotes.
6097        let rest = rest.trim_start();
6098        if !rest.starts_with('"') {
6099            continue;
6100        }
6101        let Some(close) = rest[1..].find('"') else {
6102            continue;
6103        };
6104        let name = rest[1..=close].to_owned();
6105        let after_name = rest[close + 2..].trim_start();
6106        let class = after_name
6107            .strip_prefix("of ")
6108            .map(|tail| {
6109                tail.trim_start()
6110                    .split(|c: char| c.is_whitespace() || c == '{')
6111                    .next()
6112                    .unwrap_or("")
6113                    .to_owned()
6114            })
6115            .unwrap_or_default();
6116        out.push((name, class));
6117    }
6118    out
6119}
6120
6121fn schema_index_for_items(items: &[Item]) -> SchemaIndex {
6122    let mut schemas = SchemaIndex::with_builtins();
6123    for item in items {
6124        schemas.insert_item(item);
6125    }
6126    schemas
6127}
6128
6129fn workflow_inputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6130    items
6131        .iter()
6132        .filter_map(|item| match item {
6133            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Input => {
6134                Some((contract.name.name.clone(), contract.ty.clone()))
6135            }
6136            _ => None,
6137        })
6138        .collect()
6139}
6140
6141fn workflow_outputs_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6142    items
6143        .iter()
6144        .filter_map(|item| match item {
6145            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Output => {
6146                Some((contract.name.name.clone(), contract.ty.clone()))
6147            }
6148            _ => None,
6149        })
6150        .collect()
6151}
6152
6153fn workflow_failures_for_items(items: &[Item]) -> BTreeMap<String, TypeSyntax> {
6154    items
6155        .iter()
6156        .filter_map(|item| match item {
6157            Item::WorkflowContract(contract) if contract.kind == WorkflowContractKind::Failure => {
6158                Some((contract.name.name.clone(), contract.ty.clone()))
6159            }
6160            _ => None,
6161        })
6162        .collect()
6163}
6164
6165impl SchemaIndex {
6166    fn with_builtins() -> Self {
6167        let mut index = Self::default();
6168        index.insert_class(
6169            "AgentTurn",
6170            [
6171                ("id", string_ty()),
6172                ("summary", string_ty()),
6173                ("agent", string_ty()),
6174                ("provider", string_ty()),
6175                ("status", string_ty()),
6176                ("run_id", string_ty()),
6177                ("effect_id", string_ty()),
6178            ],
6179        );
6180        index.insert_class(
6181            "WorkItem",
6182            [
6183                ("id", string_ty()),
6184                ("title", string_ty()),
6185                ("body", string_ty()),
6186                ("queue", string_ty()),
6187                ("status", string_ty()),
6188                ("labels", array_ty(string_ty())),
6189            ],
6190        );
6191        // std.vcs observer schemas (DR-0052 grammar pass): the readiness
6192        // sugar's typed bindings. Observer-origin — the mediator emits
6193        // them; user rules eliminate, never construct.
6194        index.insert_class(
6195            "VcsChange",
6196            [
6197                ("branch", string_ty()),
6198                ("cut", string_ty()),
6199                ("path", string_ty()),
6200                ("origin", string_ty()),
6201                ("by", string_ty()),
6202                ("intent", string_ty()),
6203                ("at", string_ty()),
6204            ],
6205        );
6206        index.insert_class(
6207            "VcsContention",
6208            [
6209                ("branch", string_ty()),
6210                ("with", string_ty()),
6211                ("stream", string_ty()),
6212                ("slice", array_ty(string_ty())),
6213                ("at", string_ty()),
6214            ],
6215        );
6216        index.insert_class(
6217            "VcsPromotion",
6218            [
6219                ("branch", string_ty()),
6220                ("stream", string_ty()),
6221                ("cut", string_ty()),
6222                ("at", string_ty()),
6223            ],
6224        );
6225        index.insert_class(
6226            "VcsStall",
6227            [
6228                ("branch", string_ty()),
6229                ("stream", string_ty()),
6230                ("boundary", string_ty()),
6231                ("paths", array_ty(string_ty())),
6232                ("at", string_ty()),
6233            ],
6234        );
6235        index.insert_class(
6236            "Evidence",
6237            [
6238                ("title", string_ty()),
6239                ("path", string_ty()),
6240                ("summary", string_ty()),
6241            ],
6242        );
6243        index.insert_class(
6244            "TerminalFailed",
6245            [
6246                ("reason", string_ty()),
6247                ("summary", string_ty()),
6248                ("effect_id", string_ty()),
6249                ("run_id", string_ty()),
6250                // DR-0032: `kind` names the failing effect — the `EffectError` base
6251                // field that lets a future runtime union dispatch and that
6252                // telemetry reads. Static narrowing does not require it.
6253                ("kind", string_ty()),
6254            ],
6255        );
6256        // DR-0032 P3 (per-kind failure extras, DQ-2 static narrowing): each
6257        // kind's failure schema extends the base with the ruled v1 extras —
6258        // exec `exit_code`; schema.coerce `error_class` + optional
6259        // `http_status`; agent.tell `error_class`. The extras are reachable
6260        // ONLY when the binding's effect kind matches (the `fails`-arm
6261        // narrowing below), so each addition is additive by construction.
6262        index.insert_class(
6263            "TerminalFailedExec",
6264            [
6265                ("reason", string_ty()),
6266                ("summary", string_ty()),
6267                ("effect_id", string_ty()),
6268                ("run_id", string_ty()),
6269                ("kind", string_ty()),
6270                // Absent when the process could not be spawned (the emitters
6271                // only set it for a run that actually started) — the docs
6272                // said so; the type now agrees.
6273                ("exit_code", optional_ty(int_ty())),
6274            ],
6275        );
6276        index.insert_class(
6277            "TerminalFailedCoerce",
6278            [
6279                ("reason", string_ty()),
6280                ("summary", string_ty()),
6281                ("effect_id", string_ty()),
6282                ("run_id", string_ty()),
6283                ("kind", string_ty()),
6284                ("error_class", string_ty()),
6285                ("http_status", optional_ty(int_ty())),
6286            ],
6287        );
6288        index.insert_class(
6289            "TerminalFailedTell",
6290            [
6291                ("reason", string_ty()),
6292                ("summary", string_ty()),
6293                ("effect_id", string_ty()),
6294                ("run_id", string_ty()),
6295                ("kind", string_ty()),
6296                ("error_class", string_ty()),
6297            ],
6298        );
6299        index.insert_class(
6300            "TerminalTimedOut",
6301            [
6302                ("summary", string_ty()),
6303                ("effect_id", string_ty()),
6304                ("run_id", string_ty()),
6305            ],
6306        );
6307        index.insert_class(
6308            "TerminalCancelled",
6309            [
6310                ("summary", string_ty()),
6311                ("effect_id", string_ty()),
6312                ("run_id", string_ty()),
6313            ],
6314        );
6315        // The `after x completes as o` envelope: the runtime delivers the
6316        // terminal UNION {tag, status, summary, effect_id, run_id} (plus the
6317        // dynamically-shaped value/error read via `case o { Completed as v =>
6318        // … }`). Typing the alias as the effect's SUCCESS schema — the old
6319        // behavior — approved field reads that were null at runtime on every
6320        // non-success terminal.
6321        index.insert_class(
6322            "TerminalOutcome",
6323            [
6324                ("tag", string_ty()),
6325                ("status", string_ty()),
6326                ("summary", string_ty()),
6327                ("effect_id", string_ty()),
6328                ("run_id", string_ty()),
6329            ],
6330        );
6331        // The generic inbound messaging envelope (spec/messaging.md): a
6332        // `when message from <channel> as msg` binding sees a `Message`, never a
6333        // domain type. Structured sub-payloads (sender_claims, interaction,
6334        // correlation) are JSON-serialized strings here; provider-specific
6335        // payloads live in bounded evidence / `raw_ref`, not as untyped facts.
6336        index.insert_class(
6337            "Message",
6338            [
6339                ("message_id", string_ty()),
6340                ("channel", string_ty()),
6341                ("provider", string_ty()),
6342                ("received_at", string_ty()),
6343                ("sender", string_ty()),
6344                ("sender_claims", string_ty()),
6345                ("thread_id", string_ty()),
6346                ("text", string_ty()),
6347                ("markdown", string_ty()),
6348                ("attachments", array_ty(string_ty())),
6349                ("interaction", string_ty()),
6350                ("raw_ref", string_ty()),
6351                ("correlation", string_ty()),
6352            ],
6353        );
6354        // The typed receipt a `send via <channel> { ... } as r` binding sees
6355        // (std.messaging; the `messaging.send` contract's output schema —
6356        // spec/std-messaging.md "MessageSendReceipt"). Every provider returns
6357        // the full shape; correlation fields the provider cannot report
6358        // (`provider_message_id`, `thread_id`, `destination`) are empty
6359        // strings, and `accepted_at` is the provider-acknowledged instant.
6360        // Failure is NOT a receipt: it settles `capability.call.failed` with
6361        // the DR-0032 EffectError base and routes to `fails as`. `status` is
6362        // `accepted` in v1 (`delivered` is reserved for providers whose report
6363        // includes it; none exists yet).
6364        index.insert_class(
6365            "MessageSendReceipt",
6366            [
6367                ("message_id", string_ty()),
6368                ("channel", string_ty()),
6369                ("provider", string_ty()),
6370                ("status", string_ty()),
6371                ("provider_message_id", string_ty()),
6372                ("thread_id", string_ty()),
6373                ("destination", string_ty()),
6374                ("accepted_at", string_ty()),
6375            ],
6376        );
6377        index
6378    }
6379
6380    fn insert_class<const N: usize>(&mut self, name: &str, fields: [(&str, TypeSyntax); N]) {
6381        self.classes.insert(
6382            name.to_owned(),
6383            fields
6384                .into_iter()
6385                .map(|(field, ty)| (field.to_owned(), ty))
6386                .collect(),
6387        );
6388    }
6389
6390    fn insert_item(&mut self, item: &Item) {
6391        match item {
6392            Item::Enum(enum_decl) => {
6393                self.enums.insert(
6394                    enum_decl.name.name.clone(),
6395                    enum_decl
6396                        .variants
6397                        .iter()
6398                        .map(|variant| variant.name.name.clone())
6399                        .collect(),
6400                );
6401                // Data-carrying variants are visible as generated
6402                // `<Enum>.<Variant>` classes (spec/sum-types.md), so case
6403                // bindings type-check field access against them.
6404                for variant in &enum_decl.variants {
6405                    if variant.fields.is_empty() {
6406                        continue;
6407                    }
6408                    let mut fields = BTreeMap::new();
6409                    fields.insert(
6410                        "variant".to_owned(),
6411                        TypeSyntax::LiteralString {
6412                            value: variant.name.name.clone(),
6413                            span: variant.name.span,
6414                        },
6415                    );
6416                    for field in &variant.fields {
6417                        fields.insert(field.name.name.clone(), field.ty.clone());
6418                    }
6419                    self.classes.insert(
6420                        format!("{}.{}", enum_decl.name.name, variant.name.name),
6421                        fields,
6422                    );
6423                }
6424            }
6425            Item::Class(class_decl) => {
6426                self.classes.insert(
6427                    class_decl.name.name.clone(),
6428                    class_decl
6429                        .fields
6430                        .iter()
6431                        .map(|field| (field.name.name.clone(), field.ty.clone()))
6432                        .collect(),
6433                );
6434                self.insert_presence(&class_decl.name.name, &class_decl.fields);
6435            }
6436            Item::Event(event) => {
6437                self.events.insert(event.name.clone());
6438                // The payload schema is indexed under the dotted signal name,
6439                // unreachable from user class declarations, so bare `when
6440                // <signal> as x` bindings type-check field access.
6441                self.classes.insert(
6442                    event.name.clone(),
6443                    event
6444                        .fields
6445                        .iter()
6446                        .map(|field| (field.name.name.clone(), field.ty.clone()))
6447                        .collect(),
6448                );
6449                self.insert_presence(&event.name, &event.fields);
6450            }
6451            _ => {}
6452        }
6453    }
6454
6455    /// Record Family B presence conditions for a schema's fields (if any).
6456    fn insert_presence(&mut self, schema: &str, fields: &[ClassField]) {
6457        let conditions: BTreeMap<String, (String, String)> = fields
6458            .iter()
6459            .filter_map(|field| {
6460                field
6461                    .presence_condition
6462                    .clone()
6463                    .map(|condition| (field.name.name.clone(), condition))
6464            })
6465            .collect();
6466        if !conditions.is_empty() {
6467            self.presence.insert(schema.to_owned(), conditions);
6468        }
6469    }
6470
6471    /// The presence condition `(discriminant, literal)` for a schema field, if any.
6472    fn field_presence(&self, schema: &str, field: &str) -> Option<&(String, String)> {
6473        self.presence
6474            .get(schema)
6475            .and_then(|fields| fields.get(field))
6476    }
6477
6478    fn merge(&mut self, other: SchemaIndex) {
6479        self.classes.extend(other.classes);
6480        self.enums.extend(other.enums);
6481        self.presence.extend(other.presence);
6482    }
6483
6484    fn class_exists(&self, name: &str) -> bool {
6485        self.classes.contains_key(name)
6486    }
6487
6488    fn resolve_field_path(&self, root_schema: &str, path: &[String]) -> Result<TypeSyntax, String> {
6489        // Dotted runtime fact names (general `when fact <name>` matches) are
6490        // untyped — unless a declared `event` (or generated `<Enum>.<Variant>`
6491        // class) indexes a payload schema under the dotted name, in which
6492        // case field paths are statically validated against it.
6493        if root_schema.contains('.') && !self.classes.contains_key(root_schema) {
6494            return Ok(TypeSyntax::Ref {
6495                name: Ident {
6496                    name: root_schema.to_owned(),
6497                    span: zero_span(),
6498                },
6499            });
6500        }
6501        let mut schema = root_schema.to_owned();
6502        let mut current = TypeSyntax::Ref {
6503            name: Ident {
6504                name: schema.clone(),
6505                span: zero_span(),
6506            },
6507        };
6508
6509        for field in path {
6510            let Some(fields) = self.classes.get(&schema) else {
6511                return Err(format!("schema `{schema}` has no declared fields"));
6512            };
6513            let Some(field_ty) = fields.get(field) else {
6514                return Err(format!("schema `{schema}` has no field `{field}`"));
6515            };
6516
6517            current = field_ty.clone();
6518            match schema_name_for_path(&current) {
6519                Some(next_schema) => schema = next_schema,
6520                None if field != path.last().expect("path is non-empty") => {
6521                    return Err(format!("field `{field}` is not a schema value"));
6522                }
6523                None => {}
6524            }
6525        }
6526
6527        Ok(current)
6528    }
6529}
6530
6531fn zero_span() -> SourceSpan {
6532    SourceSpan { start: 0, end: 0 }
6533}
6534
6535fn string_ty() -> TypeSyntax {
6536    TypeSyntax::Primitive {
6537        name: "string".to_owned(),
6538        span: zero_span(),
6539    }
6540}
6541
6542fn int_ty() -> TypeSyntax {
6543    TypeSyntax::Primitive {
6544        name: "int".to_owned(),
6545        span: zero_span(),
6546    }
6547}
6548
6549fn optional_ty(inner: TypeSyntax) -> TypeSyntax {
6550    TypeSyntax::Optional {
6551        inner: Box::new(inner),
6552        span: zero_span(),
6553    }
6554}
6555
6556fn array_ty(inner: TypeSyntax) -> TypeSyntax {
6557    TypeSyntax::Array {
6558        inner: Box::new(inner),
6559        span: zero_span(),
6560    }
6561}
6562
6563fn schema_name_for_path(ty: &TypeSyntax) -> Option<String> {
6564    match ty {
6565        TypeSyntax::Ref { name } => Some(name.name.clone()),
6566        TypeSyntax::Optional { inner, .. } => schema_name_for_path(inner),
6567        _ => None,
6568    }
6569}
6570
6571/// The complete standard-package universe (the 13 std packages of the
6572/// standard-package campaign). `use std.<name>` outside this list is a check
6573/// error: std resolution is a built-in registry, so an unknown name can never
6574/// resolve later — a typo'd `use std.coercon` would otherwise silently import
6575/// nothing (and downstream missing-import bite is advisory only).
6576pub const STD_PACKAGE_IDS: &[&str] = &[
6577    "std.agent",
6578    "std.vcs",
6579    "std.coercion",
6580    "std.coord",
6581    "std.files",
6582    "std.human",
6583    "std.ingress",
6584    "std.memory",
6585    "std.messaging",
6586    "std.script",
6587    "std.telemetry",
6588    "std.time",
6589    "std.tracker",
6590    "std.workflow",
6591];
6592
6593/// Cross-declaration stream checks (DR-0052 Decision 5, run after all
6594/// items lower so agent order does not matter): every member names a
6595/// declared agent, and membership is single-valued — one stream per
6596/// agent, so the sync topology stays a tree.
6597fn validate_streams(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
6598    let mut memberships: Vec<(&str, &str)> = Vec::new(); // (agent, stream)
6599    for stream in &ir.streams {
6600        for (member, span) in stream.members.iter().zip(&stream.member_spans) {
6601            if !ir.agents.iter().any(|agent| agent.name == *member) {
6602                diagnostics.push(Diagnostic {
6603                    related: Vec::new(),
6604                    span: *span,
6605                    message: format!(
6606                        "stream `{}` member `{}` is not a declared agent",
6607                        stream.name, member
6608                    ),
6609                    suggestion: Some(
6610                        "stream members are agent declarations; declare the agent \
6611                         or remove it from the stream"
6612                            .to_owned(),
6613                    ),
6614                });
6615                continue;
6616            }
6617            if let Some((_, holder)) = memberships.iter().find(|(agent, _)| agent == member) {
6618                diagnostics.push(Diagnostic {
6619                    related: Vec::new(),
6620                    span: *span,
6621                    message: format!("agent `{member}` is already a member of stream `{holder}`",),
6622                    suggestion: Some(
6623                        "membership is single-valued (the sync topology stays a \
6624                         tree): an agent homes to exactly one stream"
6625                            .to_owned(),
6626                    ),
6627                });
6628                continue;
6629            }
6630            memberships.push((member, &stream.name));
6631        }
6632    }
6633    // `on stream <name>` on a tell must name a declared stream — the
6634    // per-turn exception cannot invent topology.
6635    for rule in &ir.rules {
6636        for effect in &rule.metadata.effects {
6637            if let Some(target) = &effect.on_stream {
6638                if !ir.streams.iter().any(|stream| stream.name == *target) {
6639                    diagnostics.push(Diagnostic {
6640                        related: Vec::new(),
6641                        span: effect.span,
6642                        message: format!("`on stream {target}` names an undeclared stream"),
6643                        suggestion: Some(
6644                            "declare the stream at top level: `stream <name> { members [...] }`"
6645                                .to_owned(),
6646                        ),
6647                    });
6648                }
6649            }
6650            // A literal selection validates against the ONE selection
6651            // grammar at check time (DR-0052 R4.2 — the grammar lives in
6652            // whipplescript-core, the same parser the runtime uses;
6653            // dynamic expressions validate at execution instead).
6654            if let Some(source) = &effect.selection_source {
6655                let trimmed = source.trim();
6656                if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
6657                    let literal = &trimmed[1..trimmed.len() - 1];
6658                    if let Err(error) = whipplescript_core::selection::parse(literal) {
6659                        diagnostics.push(Diagnostic {
6660                            related: Vec::new(),
6661                            span: effect.span,
6662                            message: format!("the selection does not parse: {error}"),
6663                            suggestion: Some(
6664                                "selections compose atoms like `path(<glob>)`, `by(<prefix>)`, \
6665                                 `intent(<prefix>)`, `cut(<id>)` with `|`, `&`, `~`, and \
6666                                 `dependents-of(...)`"
6667                                    .to_owned(),
6668                            ),
6669                        });
6670                    }
6671                }
6672            }
6673            // `with access to vcs { repair for <binding> }` (DR-0052
6674            // R3): the vcs resource grants exactly one operation, on an
6675            // invoke only (a tell would hand a MODEL repair authority),
6676            // and its target must be a fact this rule bound — the grant's
6677            // extent IS the bound incident fact.
6678            for grant in &effect.access_grants {
6679                if grant.resource != "vcs" {
6680                    continue;
6681                }
6682                if effect.kind != IrEffectKind::WorkflowInvoke {
6683                    diagnostics.push(Diagnostic {
6684                        related: Vec::new(),
6685                        span: effect.span,
6686                        message: "a `vcs` access grant rides an `invoke` only".to_owned(),
6687                        suggestion: Some(
6688                            "repair authority is orchestration: grant it to a repair \
6689                             workflow via `invoke ... with access to vcs { repair for \
6690                             <binding> }`; agents never receive it"
6691                                .to_owned(),
6692                        ),
6693                    });
6694                    continue;
6695                }
6696                for op in &grant.operations {
6697                    if op.operation != "repair" {
6698                        diagnostics.push(Diagnostic {
6699                            related: Vec::new(),
6700                            span: effect.span,
6701                            message: format!("unknown `vcs` grant operation `{}`", op.operation),
6702                            suggestion: Some(
6703                                "the vcs resource grants `repair for <binding>`".to_owned(),
6704                            ),
6705                        });
6706                        continue;
6707                    }
6708                    let Some(target) = &op.target else {
6709                        diagnostics.push(Diagnostic {
6710                            related: Vec::new(),
6711                            span: effect.span,
6712                            message: "`repair` names no binding".to_owned(),
6713                            suggestion: Some(
6714                                "write `repair for <binding>` where the binding is a \
6715                                 vcs arming fact this rule matched (e.g. `when reconcile \
6716                                 stalled as r`)"
6717                                    .to_owned(),
6718                            ),
6719                        });
6720                        continue;
6721                    };
6722                    let bound = rule.whens.iter().any(|when| {
6723                        binding_after_as(when.pattern.as_str()).as_deref() == Some(target)
6724                    });
6725                    if !bound {
6726                        diagnostics.push(Diagnostic {
6727                            related: Vec::new(),
6728                            span: effect.span,
6729                            message: format!("`repair for {target}` names no binding of this rule"),
6730                            suggestion: Some(
6731                                "bind the arming fact first: `when reconcile stalled as \
6732                                 <binding>` (or the dotted `when fact vcs.* as <binding>` \
6733                                 form)"
6734                                    .to_owned(),
6735                            ),
6736                        });
6737                    }
6738                }
6739            }
6740            // `transport ... onto <target>`: the target is a nameable
6741            // tier — `mainline`, or a declared stream (its line).
6742            if let Some(target) = &effect.transport_onto {
6743                if target != "mainline" && !ir.streams.iter().any(|stream| stream.name == *target) {
6744                    diagnostics.push(Diagnostic {
6745                        related: Vec::new(),
6746                        span: effect.span,
6747                        message: format!(
6748                            "`onto {target}` names neither `mainline` nor a declared stream"
6749                        ),
6750                        suggestion: Some(
6751                            "transport targets are the nameable tiers: `onto mainline`, or \
6752                             `onto <stream>` for a declared stream's line"
6753                                .to_owned(),
6754                        ),
6755                    });
6756                }
6757            }
6758        }
6759    }
6760}
6761
6762/// std.messaging v1 provider capability report (spec/std-messaging.md
6763/// "Capability reports + conditioned checks"). Reports are DATA, never code
6764/// (M8): these compiled constants are mirrored by the embedded std.messaging
6765/// manifest's `bindings[].config.report` rows, and the conditioned static
6766/// checks below admit syntax only when the selected provider's report
6767/// supports it. Report axes are messaging.md "Provider Capability Report"
6768/// narrowed for v1: `delivery_receipts` ⊆ {accepted, failed}; `identity` ⊆
6769/// {anonymous, claimed_actor} (no verified_actor provider exists, so any
6770/// check demanding verified identity fails closed); `content` ⊆
6771/// {text, markdown}.
6772#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6773pub struct ChannelProviderReport {
6774    /// The short channel-declaration identifier (`provider <short_name>`).
6775    pub short_name: &'static str,
6776    /// The binding-row provider id the short name resolves to.
6777    pub provider_id: &'static str,
6778    /// `outbound_only` | `inbound_only` | `bidirectional`.
6779    pub direction: &'static str,
6780    /// `anonymous` | `claimed_actor`.
6781    pub identity: &'static str,
6782    /// Interaction families the provider can deliver callbacks for.
6783    pub interactions: &'static [&'static str],
6784    /// Payload content kinds the provider accepts.
6785    pub content: &'static [&'static str],
6786    /// Receipt statuses the provider can report.
6787    pub delivery_receipts: &'static [&'static str],
6788}
6789
6790/// The four v1 std.messaging providers (spec/std-messaging.md "Providers").
6791pub const CHANNEL_PROVIDER_REPORTS: &[ChannelProviderReport] = &[
6792    ChannelProviderReport {
6793        short_name: "fixture",
6794        provider_id: "fixture",
6795        direction: "bidirectional",
6796        identity: "claimed_actor",
6797        interactions: &["buttons", "reactions"],
6798        content: &["text", "markdown"],
6799        delivery_receipts: &["accepted", "failed"],
6800    },
6801    ChannelProviderReport {
6802        short_name: "local",
6803        provider_id: "std.messaging.local",
6804        direction: "bidirectional",
6805        identity: "claimed_actor",
6806        interactions: &["buttons", "reactions"],
6807        content: &["text", "markdown"],
6808        delivery_receipts: &["accepted", "failed"],
6809    },
6810    ChannelProviderReport {
6811        short_name: "desktop",
6812        provider_id: "std.messaging.desktop",
6813        direction: "outbound_only",
6814        identity: "anonymous",
6815        interactions: &[],
6816        content: &["text"],
6817        delivery_receipts: &["accepted", "failed"],
6818    },
6819    ChannelProviderReport {
6820        short_name: "stdio",
6821        provider_id: "std.messaging.stdio",
6822        direction: "bidirectional",
6823        identity: "claimed_actor",
6824        interactions: &["buttons"],
6825        content: &["text", "markdown"],
6826        delivery_receipts: &["accepted", "failed"],
6827    },
6828];
6829
6830/// Resolve a channel's declared `provider <p>` identifier against the v1
6831/// provider reports: the short name (`local`) or the full binding provider id
6832/// (`std.messaging.local`) both resolve. `None` = unknown identifier, a check
6833/// error (spec/std-messaging.md open question 2 resolved: short names resolved
6834/// against contributed provider kinds, unknown = check error).
6835pub fn channel_provider_report(provider: &str) -> Option<&'static ChannelProviderReport> {
6836    CHANNEL_PROVIDER_REPORTS
6837        .iter()
6838        .find(|report| report.short_name == provider || report.provider_id == provider)
6839}
6840
6841/// The built-in resource gauges: deterministic observables already in the
6842/// effect ledger, present without declaration (improve design note §3).
6843/// `std.cache_hit` is the provider prompt-cache hit rate (cache-read tokens /
6844/// total input-side tokens) — present only when the provider reports cache
6845/// usage (spec/inference-cache-note.md G2).
6846pub const BUILTIN_GAUGES: &[&str] = &["std.spend", "std.latency", "std.tokens", "std.cache_hit"];
6847
6848/// The v1 std.files store providers (spec/std-files.md "Providers"): `local`
6849/// is the FileStore host-projection seam (native + DO) and the default when a
6850/// `file store` declares no `provider` clause. Non-filesystem providers
6851/// (S3/GitHub/Drive) are deferred with cause; an unknown identifier is a
6852/// check error at the declaration.
6853pub const FILE_STORE_PROVIDERS: &[&str] = &["local"];
6854
6855/// Cross-reference validation for the improve surface, run after the item
6856/// loop so declaration order never matters: judge `coerce` targets must
6857/// resolve, derived-gauge inputs and campaign gauge references must name a
6858/// declared gauge or a built-in resource gauge, and a campaign's partition
6859/// must be disjoint (a gauge cannot be both ascended and sacrificed).
6860fn validate_improve_declarations(ir: &IrProgram, diagnostics: &mut Vec<Diagnostic>) {
6861    // A mark rides a committing site: its `after` target must be a rule
6862    // (flow segments have lowered to `flow.<name>.segN` rules by now).
6863    for mark in &ir.marks {
6864        if !ir.rules.iter().any(|rule| rule.name == mark.site) {
6865            diagnostics.push(Diagnostic {
6866                related: Vec::new(),
6867                span: mark.span,
6868                message: format!("mark `{}` rides unknown site `{}`", mark.name, mark.site),
6869                suggestion: Some(format!(
6870                    "declared rules: {}",
6871                    ir.rules
6872                        .iter()
6873                        .map(|rule| rule.name.as_str())
6874                        .collect::<Vec<_>>()
6875                        .join(", ")
6876                )),
6877            });
6878        }
6879    }
6880    let gauge_names: BTreeSet<&str> = ir.gauges.iter().map(|gauge| gauge.name.as_str()).collect();
6881    let resolves = |name: &str| gauge_names.contains(name) || BUILTIN_GAUGES.contains(&name);
6882    let unknown = |name: &str, span: SourceSpan, diagnostics: &mut Vec<Diagnostic>| {
6883        diagnostics.push(Diagnostic {
6884            related: Vec::new(),
6885            span,
6886            message: format!("unknown gauge `{name}`"),
6887            suggestion: Some(format!(
6888                "declare `gauge {name} {{ ... }}` or use a built-in gauge ({})",
6889                BUILTIN_GAUGES.join(", ")
6890            )),
6891        });
6892    };
6893    for gauge in &ir.gauges {
6894        if gauge.judge_kind == "coerce" {
6895            match ir
6896                .coerces
6897                .iter()
6898                .find(|coerce| coerce.name == gauge.judge_target)
6899            {
6900                None => {
6901                    diagnostics.push(Diagnostic {
6902                        related: Vec::new(),
6903                        span: gauge.span,
6904                        message: format!(
6905                            "gauge `{}` judges via undeclared coerce `{}`",
6906                            gauge.name, gauge.judge_target
6907                        ),
6908                        suggestion: Some("declare the coerce this gauge judges with".to_owned()),
6909                    });
6910                }
6911                // Explicit-argument binding (settled 2026-07-14): the
6912                // judge's data diet is written down, never inferred. The
6913                // single reserved `record` passes the whole judge-input
6914                // record to a one-parameter coerce; otherwise each path
6915                // (`input.…` / `facts.<Class>.<field>`) feeds the
6916                // parameter at its position, arity-checked here so a
6917                // drifted signature is a check error, not a silently
6918                // rebound judge.
6919                Some(coerce) if !gauge.judge_args.is_empty() => {
6920                    if gauge.judge_args.len() == 1 && gauge.judge_args[0] == "record" {
6921                        if coerce.params.len() != 1 {
6922                            diagnostics.push(Diagnostic {
6923                                related: Vec::new(),
6924                                span: gauge.span,
6925                                message: format!(
6926                                    "gauge `{}`: the reserved `(record)` form needs a \
6927                                     single-parameter coerce; `{}` takes {}",
6928                                    gauge.name,
6929                                    gauge.judge_target,
6930                                    coerce.params.len()
6931                                ),
6932                                suggestion: Some(
6933                                    "give the coerce one record-shaped parameter, or bind each \
6934                                     parameter to an explicit path"
6935                                        .to_owned(),
6936                                ),
6937                            });
6938                        }
6939                    } else {
6940                        for arg in &gauge.judge_args {
6941                            let head = arg.split('.').next().unwrap_or_default();
6942                            let valid = match head {
6943                                "record" => false, // reserved: only alone
6944                                "input" => true,
6945                                "facts" => arg.splitn(3, '.').count() == 3,
6946                                _ => false,
6947                            };
6948                            if !valid {
6949                                diagnostics.push(Diagnostic {
6950                                    related: Vec::new(),
6951                                    span: gauge.span,
6952                                    message: format!(
6953                                        "gauge `{}`: judge argument `{arg}` is not a record \
6954                                         path",
6955                                        gauge.name
6956                                    ),
6957                                    suggestion: Some(
6958                                        "arguments are `input.<path>`, \
6959                                         `facts.<Class>.<field...>`, or the single reserved \
6960                                         `record`"
6961                                            .to_owned(),
6962                                    ),
6963                                });
6964                            }
6965                        }
6966                        if gauge.judge_args.len() != coerce.params.len() {
6967                            diagnostics.push(Diagnostic {
6968                                related: Vec::new(),
6969                                span: gauge.span,
6970                                message: format!(
6971                                    "gauge `{}`: judge passes {} argument{} but coerce `{}` \
6972                                     takes {}",
6973                                    gauge.name,
6974                                    gauge.judge_args.len(),
6975                                    if gauge.judge_args.len() == 1 { "" } else { "s" },
6976                                    gauge.judge_target,
6977                                    coerce.params.len()
6978                                ),
6979                                suggestion: Some(
6980                                    "bind one path per coerce parameter, in order".to_owned(),
6981                                ),
6982                            });
6983                        }
6984                    }
6985                }
6986                Some(_) => {}
6987            }
6988        }
6989        if !gauge.inputs.is_empty() && gauge.judge_kind != "exec" {
6990            diagnostics.push(Diagnostic {
6991                related: Vec::new(),
6992                span: gauge.span,
6993                message: format!(
6994                    "derived gauge `{}` must judge via exec (its judge receives the input score vector)",
6995                    gauge.name
6996                ),
6997                suggestion: Some("use `judge via exec \"<validator>\"`".to_owned()),
6998            });
6999        }
7000        for input in &gauge.inputs {
7001            if input == &gauge.name {
7002                diagnostics.push(Diagnostic {
7003                    related: Vec::new(),
7004                    span: gauge.span,
7005                    message: format!("derived gauge `{}` cannot input itself", gauge.name),
7006                    suggestion: None,
7007                });
7008            } else if !resolves(input) {
7009                unknown(input, gauge.span, diagnostics);
7010            }
7011        }
7012    }
7013    for campaign in &ir.campaigns {
7014        let mut named: Vec<(&str, &'static str)> = Vec::new();
7015        for name in &campaign.ascend {
7016            named.push((name, "ascend"));
7017        }
7018        for reach in &campaign.reach {
7019            named.push((&reach.gauge, "reach"));
7020        }
7021        for guard in &campaign.guard {
7022            named.push((&guard.gauge, "guard"));
7023        }
7024        for name in &campaign.sacrifice {
7025            named.push((name, "sacrifice"));
7026        }
7027        let mut seen: BTreeMap<&str, &'static str> = BTreeMap::new();
7028        for (name, role) in named {
7029            if !resolves(name) {
7030                unknown(name, campaign.span, diagnostics);
7031            }
7032            if let Some(previous) = seen.insert(name, role) {
7033                let message = if previous == role {
7034                    format!(
7035                        "campaign `{}` names gauge `{name}` twice in {role}",
7036                        campaign.name
7037                    )
7038                } else {
7039                    format!(
7040                        "campaign `{}` names gauge `{name}` as both {previous} and {role}",
7041                        campaign.name
7042                    )
7043                };
7044                diagnostics.push(Diagnostic {
7045                    related: Vec::new(),
7046                    span: campaign.span,
7047                    message,
7048                    suggestion: Some("name each gauge once, in at most one clause".to_owned()),
7049                });
7050            }
7051        }
7052    }
7053}
7054
7055/// The harness class (DR-0034). `Managed` = WhippleScript is the agent runtime
7056/// (owned; hermetic context, full provenance, reproducible). `Delegated` = a foreign
7057/// runtime WhippleScript invokes, which assembles its own context. The guarantee is
7058/// two-valued, so the class is too.
7059#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7060pub enum HarnessClass {
7061    Managed,
7062    Delegated,
7063}
7064
7065impl HarnessClass {
7066    pub fn as_str(self) -> &'static str {
7067        match self {
7068            HarnessClass::Managed => "managed",
7069            HarnessClass::Delegated => "delegated",
7070        }
7071    }
7072}
7073
7074/// Classify a harness kind (DR-0034 Decision 6). Total over the supported kinds:
7075/// `owned` and the credential-free `fixture` model client are Managed; every other
7076/// kind (codex/claude sidecars, the `native-fixture` delegated adapter, `command`)
7077/// is Delegated. An unrecognized kind — validated registry-side by the CLI
7078/// (spec/std-agent.md "Open provider registry") — defaults to Delegated, never
7079/// granting the Managed guarantee to something unknown.
7080pub fn harness_class(kind: &str) -> HarnessClass {
7081    match kind {
7082        "owned" | "fixture" => HarnessClass::Managed,
7083        _ => HarnessClass::Delegated,
7084    }
7085}
7086
7087fn validate_test_expr_source(
7088    label: &str,
7089    source: &str,
7090    span: SourceSpan,
7091    diagnostics: &mut Vec<Diagnostic>,
7092) {
7093    if source.trim().is_empty() {
7094        diagnostics.push(Diagnostic {
7095            related: Vec::new(),
7096            span,
7097            message: format!("{label} is empty"),
7098            suggestion: Some("provide an expression".to_owned()),
7099        });
7100        return;
7101    }
7102    if let Err(error) = parse_expression(source) {
7103        diagnostics.push(Diagnostic {
7104            related: Vec::new(),
7105            span,
7106            message: format!("{label} is not a valid expression: {error}"),
7107            suggestion: None,
7108        });
7109    }
7110}
7111
7112/// The string-literal values of a literal-union (or single-literal) type, or `None`
7113/// if the type is not a pure string-literal union. Used to validate Family B
7114/// discriminants.
7115fn literal_union_values(ty: &TypeSyntax) -> Option<Vec<String>> {
7116    match ty {
7117        TypeSyntax::LiteralString { value, .. } => Some(vec![value.clone()]),
7118        TypeSyntax::Union { variants, .. } => {
7119            let values = variants
7120                .iter()
7121                .filter_map(|variant| match variant {
7122                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
7123                    _ => None,
7124                })
7125                .collect::<Vec<_>>();
7126            (!values.is_empty() && values.len() == variants.len()).then_some(values)
7127        }
7128        _ => None,
7129    }
7130}
7131
7132/// Family B validation (spec/decision-records/discriminated-families-design.md §6.3):
7133/// every `<field> <T> when <disc> is "<lit>"` must name a same-schema discriminant
7134/// that is a string-literal union, and `<lit>` must be one of its values.
7135fn validate_presence_conditions(
7136    container: &str,
7137    fields: &[ClassField],
7138    diagnostics: &mut Vec<Diagnostic>,
7139) {
7140    for field in fields {
7141        let Some((disc, literal)) = &field.presence_condition else {
7142            continue;
7143        };
7144        let Some(disc_field) = fields.iter().find(|candidate| &candidate.name.name == disc) else {
7145            diagnostics.push(Diagnostic {
7146                related: Vec::new(),
7147                span: field.span,
7148                message: format!(
7149                    "`{container}` field `{}` is conditioned on unknown discriminant `{disc}`",
7150                    field.name.name
7151                ),
7152                suggestion: Some(
7153                    "`when <field> is \"...\"` must name a literal-union field of the same schema"
7154                        .to_owned(),
7155                ),
7156            });
7157            continue;
7158        };
7159        match literal_union_values(&disc_field.ty) {
7160            Some(values) if values.iter().any(|value| value == literal) => {}
7161            Some(values) => diagnostics.push(Diagnostic {
7162                related: Vec::new(),
7163                span: field.span,
7164                message: format!(
7165                    "`{container}` field `{}` is conditioned on `{disc} is \"{literal}\"`, which is not a value of `{disc}`",
7166                    field.name.name
7167                ),
7168                suggestion: Some(format!("use one of: {}", values.join(", "))),
7169            }),
7170            None => diagnostics.push(Diagnostic {
7171                related: Vec::new(),
7172                span: field.span,
7173                message: format!(
7174                    "`{container}` field `{}` is conditioned on `{disc}`, which is not a string-literal discriminant",
7175                    field.name.name
7176                ),
7177                suggestion: Some(
7178                    "the discriminant must be a string-literal union, e.g. `kind \"a\" | \"b\"`"
7179                        .to_owned(),
7180                ),
7181            }),
7182        }
7183    }
7184}
7185
7186/// The coerce body is a clause list — `prompt` (single or multi-line) and
7187/// `provider <name>` — not free text. Reject anything else so a typo'd
7188/// `promt` (which would otherwise silently produce a coercion with NO prompt)
7189/// or a stray field fails at `check`, matching the agent-block posture.
7190/// The backend named by a coerce declaration's `provider <name>` clause.
7191///
7192/// Mirrors `validate_coerce_body_fields`'s prompt tracking exactly: a `provider`
7193/// line inside a `"""` prompt is prose the model reads, not a clause, and
7194/// reading it as one would let a prompt rename the endpoint its own egress is
7195/// judged against.
7196fn coerce_declared_provider(body: &str) -> Option<String> {
7197    let mut in_prompt = false;
7198    let mut awaiting_opener = false;
7199    for line in body.lines() {
7200        let trimmed = line.trim();
7201        if in_prompt {
7202            if trimmed.matches('"').count() >= 3 && trimmed.matches("\"\"\"").count() % 2 == 1 {
7203                in_prompt = false;
7204            }
7205            continue;
7206        }
7207        if awaiting_opener {
7208            if trimmed.is_empty() {
7209                continue;
7210            }
7211            awaiting_opener = false;
7212            if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
7213                if after_opener.matches("\"\"\"").count() % 2 == 0 {
7214                    in_prompt = true;
7215                }
7216                continue;
7217            }
7218        }
7219        if trimmed.is_empty() || trimmed.starts_with('#') {
7220            continue;
7221        }
7222        if trimmed == "prompt" {
7223            awaiting_opener = true;
7224            continue;
7225        }
7226        if let Some(rest) = trimmed.strip_prefix("prompt ") {
7227            if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
7228                if after_opener.matches("\"\"\"").count() % 2 == 0 {
7229                    in_prompt = true;
7230                }
7231            }
7232            continue;
7233        }
7234        if let Some(rest) = trimmed.strip_prefix("provider ") {
7235            let mut tokens = rest.split_whitespace();
7236            if let (Some(name), None) = (tokens.next(), tokens.next()) {
7237                return Some(name.to_owned());
7238            }
7239        }
7240    }
7241    None
7242}
7243
7244#[cfg(test)]
7245#[path = "lib_tests/coerce_provider.rs"]
7246mod coerce_provider_tests;
7247
7248fn validate_coerce_body_fields(coerce: &CoerceDecl, diagnostics: &mut Vec<Diagnostic>) {
7249    let mut in_prompt = false;
7250    let mut awaiting_opener = false;
7251    for line in coerce.body.text.lines() {
7252        let trimmed = line.trim();
7253        if in_prompt {
7254            // A line with an odd number of `"""` markers closes the prompt.
7255            if trimmed.matches("\"\"\"").count() % 2 == 1 {
7256                in_prompt = false;
7257            }
7258            continue;
7259        }
7260        if awaiting_opener {
7261            // Bare `prompt` on its own line: the opener is the next
7262            // non-empty line.
7263            if trimmed.is_empty() {
7264                continue;
7265            }
7266            awaiting_opener = false;
7267            if let Some(after_opener) = trimmed.strip_prefix("\"\"\"") {
7268                if after_opener.matches("\"\"\"").count() % 2 == 0 {
7269                    in_prompt = true;
7270                }
7271                continue;
7272            }
7273            // fall through: not an opener — validate as a clause line
7274        }
7275        if trimmed.is_empty() || trimmed.starts_with('#') {
7276            continue;
7277        }
7278        if trimmed == "prompt" {
7279            awaiting_opener = true;
7280            continue;
7281        }
7282        if let Some(rest) = trimmed.strip_prefix("prompt ") {
7283            let rest = rest.trim_start();
7284            if let Some(after_opener) = rest.strip_prefix("\"\"\"") {
7285                // `prompt """…` (optionally annotated): multi-line unless the
7286                // triple quote closes on the same line.
7287                if after_opener.matches("\"\"\"").count() % 2 == 0 {
7288                    in_prompt = true;
7289                }
7290            }
7291            // Single-quoted one-line prompts close on their own line.
7292            continue;
7293        }
7294        if let Some(rest) = trimmed.strip_prefix("provider ") {
7295            if rest.split_whitespace().count() != 1 {
7296                diagnostics.push(Diagnostic {
7297                    related: Vec::new(),
7298                    span: coerce.name.span,
7299                    message: format!(
7300                        "coerce `{}` has a malformed `provider` clause: `{trimmed}`",
7301                        coerce.name.name
7302                    ),
7303                    suggestion: Some("write `provider <name>`".to_owned()),
7304                });
7305            }
7306            continue;
7307        }
7308        let field = trimmed.split_whitespace().next().unwrap_or(trimmed);
7309        diagnostics.push(Diagnostic {
7310            related: Vec::new(),
7311            span: coerce.name.span,
7312            message: format!(
7313                "unknown coerce field `{field}` on coerce `{}`",
7314                coerce.name.name
7315            ),
7316            suggestion: Some("supported coerce fields are `prompt` and `provider`".to_owned()),
7317        });
7318    }
7319}
7320
7321fn validate_type_refs(
7322    ty: &TypeSyntax,
7323    schema_names: &BTreeSet<String>,
7324    agent_names: &BTreeSet<String>,
7325    diagnostics: &mut Vec<Diagnostic>,
7326) {
7327    match ty {
7328        TypeSyntax::Primitive { .. } | TypeSyntax::LiteralString { .. } => {}
7329        TypeSyntax::Ref { name } => {
7330            if !schema_names.contains(&name.name) && !is_builtin_schema_ref(&name.name) {
7331                diagnostics.push(Diagnostic {
7332                    related: Vec::new(),
7333                    span: name.span,
7334                    message: format!("unknown schema reference `{}`", name.name),
7335                    suggestion: Some(format!(
7336                        "declare `class {}` or `enum {}` before using it",
7337                        name.name, name.name
7338                    )),
7339                });
7340            }
7341        }
7342        TypeSyntax::AgentRef { agents, .. } => {
7343            let mut seen = BTreeSet::new();
7344            for agent in agents {
7345                if !seen.insert(agent.name.clone()) {
7346                    diagnostics.push(Diagnostic {
7347                        related: Vec::new(),
7348                        span: agent.span,
7349                        message: format!("AgentRef lists agent `{}` more than once", agent.name),
7350                        suggestion: Some(
7351                            "remove the duplicate agent from the AgentRef domain".to_owned(),
7352                        ),
7353                    });
7354                }
7355                if !agent_names.contains(&agent.name) {
7356                    diagnostics.push(Diagnostic {
7357                        related: Vec::new(),
7358                        span: agent.span,
7359                        message: format!("AgentRef references unknown agent `{}`", agent.name),
7360                        suggestion: Some(format!(
7361                            "declare `agent {}` before using it in AgentRef",
7362                            agent.name
7363                        )),
7364                    });
7365                }
7366            }
7367        }
7368        TypeSyntax::Optional { inner, .. }
7369        | TypeSyntax::Array { inner, .. }
7370        | TypeSyntax::Map { inner, .. } => {
7371            validate_type_refs(inner, schema_names, agent_names, diagnostics)
7372        }
7373        TypeSyntax::Union { variants, .. } => {
7374            for variant in variants {
7375                validate_type_refs(variant, schema_names, agent_names, diagnostics);
7376            }
7377        }
7378    }
7379}
7380
7381fn is_builtin_schema_ref(name: &str) -> bool {
7382    matches!(
7383        name,
7384        "AgentTurn"
7385            | "WorkItem"
7386            | "Evidence"
7387            | "VcsChange"
7388            | "VcsContention"
7389            | "VcsPromotion"
7390            | "VcsStall"
7391            | "TerminalFailed"
7392            | "TerminalTimedOut"
7393            | "TerminalCancelled"
7394            | "TerminalOutcome"
7395    )
7396}
7397
7398/// The terminal-family schemas are `origin = observer` (discriminated-families
7399/// design §5.4): the kernel projects them when it observes an effect or child
7400/// terminal, and user rules may only *eliminate* them (`after … fails/times
7401/// out/cancels as f`), never *construct* them. A rule that `record`s one would
7402/// forge a terminal outcome the kernel never produced, misleading the
7403/// `after`/terminal-case reaction machinery. Rejected at check time.
7404fn is_observer_only_schema(name: &str) -> bool {
7405    matches!(
7406        name,
7407        "TerminalFailed"
7408            | "TerminalTimedOut"
7409            | "TerminalCancelled"
7410            | "TerminalOutcome"
7411            // std.vcs observer schemas: the mediator emits them; a rule
7412            // that `record`s one forges a workspace observation.
7413            | "VcsChange"
7414            | "VcsContention"
7415            | "VcsPromotion"
7416            | "VcsStall"
7417    )
7418}
7419
7420fn validate_canonical_rule_body_syntax(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
7421    for line in rule.body.text.lines().map(str::trim) {
7422        if line.starts_with("then ") {
7423            diagnostics.push(Diagnostic {
7424                related: Vec::new(),
7425                span: rule.body.span,
7426                message: format!(
7427                    "rule `{}` uses unsupported `then` sequencing",
7428                    rule.name.name
7429                ),
7430                suggestion: Some(
7431                    "use `after <effect> succeeds { ... }` blocks for effect sequencing".to_owned(),
7432                ),
7433            });
7434        }
7435        if line.starts_with("after ") && line.contains("=>") {
7436            diagnostics.push(Diagnostic {
7437                related: Vec::new(),
7438                span: rule.body.span,
7439                message: format!(
7440                    "rule `{}` uses unsupported `after ... =>` sequencing",
7441                    rule.name.name
7442                ),
7443                suggestion: Some("write `after <effect> succeeds { ... }`".to_owned()),
7444            });
7445        }
7446    }
7447}
7448
7449fn build_rule_dependencies(rules: &[IrRule]) -> Vec<IrRuleDependency> {
7450    let mut dependencies = Vec::new();
7451    for producer in rules {
7452        for produced_fact in &producer.metadata.fact_writes {
7453            for consumer in rules {
7454                if consumer.metadata.fact_reads.contains(produced_fact) {
7455                    dependencies.push(IrRuleDependency {
7456                        producer: producer.name.clone(),
7457                        consumer: consumer.name.clone(),
7458                        fact: produced_fact.clone(),
7459                    });
7460                }
7461            }
7462        }
7463    }
7464    dependencies.sort_by(|left, right| {
7465        (&left.producer, &left.consumer, &left.fact).cmp(&(
7466            &right.producer,
7467            &right.consumer,
7468            &right.fact,
7469        ))
7470    });
7471    dependencies
7472}
7473
7474/// `send via <channel>` (std.messaging) must name a declared `channel`. The
7475/// channel name is carried as the construct's `channel` field; an unknown channel
7476/// would lower to a `messaging.send` effect that no provider can route, so it is
7477/// rejected at compile time (mirrors `acquire`/`consume` resource-existence checks).
7478/// `when message from <channel> as msg` (spec/messaging.md) must name a declared
7479/// channel, mirroring the outbound `send via <channel>` check.
7480fn validate_message_from_channels(
7481    rule: &RuleDecl,
7482    semantic: &SemanticContext,
7483    diagnostics: &mut Vec<Diagnostic>,
7484) {
7485    for when in &rule.whens {
7486        let (pattern, _) = split_when_guard(&when.text);
7487        let Some(rest) = pattern.trim_start().strip_prefix("message from ") else {
7488            continue;
7489        };
7490        let Some(channel) = rest.split_whitespace().next() else {
7491            continue;
7492        };
7493        if !semantic.channels.iter().any(|c| c.as_str() == channel) {
7494            diagnostics.push(Diagnostic {
7495                related: Vec::new(),
7496                span: when.span,
7497                message: format!("`when message from {channel}` names an unknown channel"),
7498                suggestion: Some(
7499                    "declare it with `channel <name> { provider … }`, or correct the channel name"
7500                        .to_owned(),
7501                ),
7502            });
7503            continue;
7504        }
7505        // Capability-report-conditioned check (spec/std-messaging.md "Static
7506        // checks"): inbound observation requires the channel provider's report
7507        // `direction` ∈ {inbound_only, bidirectional}. Desktop channels are a
7508        // check error here — send/receive-capable are distinguishable (the v1
7509        // acceptance test). Unknown providers already errored at the channel
7510        // declaration, so they are not re-flagged here.
7511        if let Some(report) = semantic
7512            .channel_providers
7513            .get(channel)
7514            .and_then(|provider| channel_provider_report(provider))
7515        {
7516            if report.direction == "outbound_only" {
7517                diagnostics.push(Diagnostic {
7518                    related: Vec::new(),
7519                    span: when.span,
7520                    message: format!(
7521                        "`when message from {channel}` observes a channel whose provider `{}` is outbound-only (its capability report cannot deliver inbound messages)",
7522                        report.short_name
7523                    ),
7524                    suggestion: Some(
7525                        "route inbound observation through an inbound-capable provider (`local`, `stdio`, `fixture`)"
7526                            .to_owned(),
7527                    ),
7528                });
7529            }
7530        }
7531    }
7532}
7533
7534fn validate_send_channels(
7535    rule: &RuleDecl,
7536    semantic: &SemanticContext,
7537    diagnostics: &mut Vec<Diagnostic>,
7538) {
7539    let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
7540    fn walk(
7541        statements: &[body::BodyStmt],
7542        semantic: &SemanticContext,
7543        diagnostics: &mut Vec<Diagnostic>,
7544    ) {
7545        for statement in statements {
7546            match statement {
7547                body::BodyStmt::Effect(effect) => {
7548                    if let body::BodyEffectKind::ConstructCapabilityCall {
7549                        keyword, fields, ..
7550                    } = &effect.kind
7551                    {
7552                        if keyword == "send" {
7553                            if let Some(channel) =
7554                                fields.iter().find(|field| field.name == "channel")
7555                            {
7556                                if !semantic.channels.contains(&channel.source) {
7557                                    diagnostics.push(Diagnostic {
7558                                        related: Vec::new(),
7559                                        span: effect.span,
7560                                        message: format!(
7561                                            "`send via {}` names an unknown channel",
7562                                            channel.source
7563                                        ),
7564                                        suggestion: Some(
7565                                            "declare it with `channel <name> { provider … }`, or correct the channel name"
7566                                                .to_owned(),
7567                                        ),
7568                                    });
7569                                } else if let Some(report) = semantic
7570                                    .channel_providers
7571                                    .get(&channel.source)
7572                                    .and_then(|provider| channel_provider_report(provider))
7573                                {
7574                                    // Capability-report-conditioned check
7575                                    // (spec/std-messaging.md "Static checks"):
7576                                    // outbound `send via` requires the provider
7577                                    // report `direction` ∈ {outbound_only,
7578                                    // bidirectional}. No v1 provider is
7579                                    // inbound-only, so this arm has no
7580                                    // reachable negative today; it exists so a
7581                                    // future inbound-only provider fails
7582                                    // closed at check time, not at dispatch.
7583                                    if report.direction == "inbound_only" {
7584                                        diagnostics.push(Diagnostic {
7585                                            related: Vec::new(),
7586                                            span: effect.span,
7587                                            message: format!(
7588                                                "`send via {}` targets a channel whose provider `{}` is inbound-only (its capability report cannot accept outbound sends)",
7589                                                channel.source, report.short_name
7590                                            ),
7591                                            suggestion: Some(
7592                                                "send through an outbound-capable provider (`local`, `desktop`, `stdio`, `fixture`)"
7593                                                    .to_owned(),
7594                                            ),
7595                                        });
7596                                    }
7597                                }
7598                            }
7599                        }
7600                        // MEM-1 check 1: the memory operations must name a
7601                        // DECLARED pool — the twin of the send-channel check.
7602                        if matches!(keyword.as_str(), "recall" | "learn" | "curate") {
7603                            if let Some(pool) = fields.iter().find(|field| field.name == "pool") {
7604                                if !semantic.memory_pools.contains(&pool.source) {
7605                                    diagnostics.push(Diagnostic {
7606                                        related: Vec::new(),
7607                                        span: effect.span,
7608                                        message: format!(
7609                                            "`{keyword}` names unknown memory pool `{}`",
7610                                            pool.source
7611                                        ),
7612                                        suggestion: Some(
7613                                            "declare it with `memory pool <name> { … }`, or correct the pool name"
7614                                                .to_owned(),
7615                                        ),
7616                                    });
7617                                }
7618                            }
7619                        }
7620                    }
7621                }
7622                body::BodyStmt::After(after) => walk(&after.body, semantic, diagnostics),
7623                body::BodyStmt::Case(case) => {
7624                    for branch in &case.branches {
7625                        walk(&branch.body, semantic, diagnostics);
7626                    }
7627                }
7628                _ => {}
7629            }
7630        }
7631    }
7632    walk(&ast.statements, semantic, diagnostics);
7633}
7634
7635/// In-turn agent observations — `agent.turn.streamed` (streamed progress),
7636/// `agent.turn.tool_requested` (in-turn tool call), and `agent.turn.artifact_captured`
7637/// (captured artifact/diff) — are recorded as EVIDENCE, never as rule-matchable facts
7638/// (spec/agent-harness.md). The rule-matchable lifecycle facts are
7639/// `agent.turn.started/completed/failed/timed_out/cancelled`. A `when` that matches an
7640/// evidence-only fact can never fire, so it is a compile-time error.
7641const EVIDENCE_ONLY_TURN_FACTS: [&str; 3] = [
7642    "agent.turn.streamed",
7643    "agent.turn.tool_requested",
7644    "agent.turn.artifact_captured",
7645];
7646
7647/// Structural well-formedness of access grants (`with access to <resource> { … }`):
7648/// a grant must grant at least one operation, and a single effect must not list the
7649/// same resource twice (merge them). The deeper "required
7650/// Resource/Operation/Capability ports" validation against the capability registry
7651/// is a separate construct-graph-layer concern, so this stays registry-independent
7652/// and zero-false-positive.
7653fn validate_turn_access_grants(
7654    rule: &RuleDecl,
7655    metadata: &IrRuleMetadata,
7656    diagnostics: &mut Vec<Diagnostic>,
7657) {
7658    for effect in &metadata.effects {
7659        if effect.access_grants.is_empty() {
7660            continue;
7661        }
7662        let mut seen = BTreeSet::new();
7663        for grant in &effect.access_grants {
7664            if grant.operations.is_empty() {
7665                diagnostics.push(Diagnostic {
7666                    related: Vec::new(),
7667                    span: effect.span,
7668                    message: format!(
7669                        "rule `{}` has a `with access to {}` grant that grants no operations",
7670                        rule.name.name, grant.resource
7671                    ),
7672                    suggestion: Some(
7673                        "list at least one operation in the grant block, or drop the grant"
7674                            .to_owned(),
7675                    ),
7676                });
7677            }
7678            if !seen.insert(grant.resource.clone()) {
7679                diagnostics.push(Diagnostic {
7680                    related: Vec::new(),
7681                    span: effect.span,
7682                    message: format!(
7683                        "rule `{}` lists access resource `{}` more than once on one effect",
7684                        rule.name.name, grant.resource
7685                    ),
7686                    suggestion: Some(
7687                        "merge the grant clauses for a resource into a single block".to_owned(),
7688                    ),
7689                });
7690            }
7691        }
7692    }
7693}
7694
7695fn validate_evidence_fact_not_matched(rule: &RuleDecl, diagnostics: &mut Vec<Diagnostic>) {
7696    for when in &rule.whens {
7697        let (pattern, _) = split_when_guard(&when.text);
7698        let Some(name) = runtime_fact_name_for_pattern(pattern) else {
7699            continue;
7700        };
7701        if EVIDENCE_ONLY_TURN_FACTS.contains(&name.as_str()) {
7702            diagnostics.push(Diagnostic { related: Vec::new(),
7703                span: when.span,
7704                message: format!(
7705                    "rule `{}` matches evidence-only fact `{name}`: in-turn observations are evidence, not rule-matchable facts",
7706                    rule.name.name
7707                ),
7708                suggestion: Some(
7709                    "match a lifecycle fact (`agent.turn.completed`/`failed`/`timed_out`/`cancelled`) and read in-turn detail from its evidence".to_owned(),
7710                ),
7711            });
7712        }
7713    }
7714}
7715
7716/// DR-0043 Decision 5: extracts each rule's `during`/`until` region (at most
7717/// one per rule in v1), REWRITES the rule body to the condition-HOLDS variant
7718/// (region content spliced inline — every downstream scanner and effect-id
7719/// derivation sees ordinary text), and returns the pre-rendered region
7720/// metadata (removed / lapsed variants, region effect scopes) to attach onto
7721/// the lowered `IrRule`s.
7722fn extract_rule_regions(
7723    items: &mut [Item],
7724    diagnostics: &mut Vec<Diagnostic>,
7725) -> BTreeMap<String, IrRegion> {
7726    let mut pending = BTreeMap::new();
7727    for item in items.iter_mut() {
7728        let Item::Rule(rule) = item else {
7729            continue;
7730        };
7731        let (ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
7732        let mut regions = Vec::new();
7733        collect_region_blocks(&ast.statements, &[], &mut regions);
7734        if regions.is_empty() {
7735            continue;
7736        }
7737        if regions.len() > 1 {
7738            diagnostics.push(Diagnostic {
7739                related: Vec::new(),
7740                span: regions[1].0.span,
7741                message: format!(
7742                    "rule `{}` declares more than one `during`/`until` region",
7743                    rule.name.name
7744                ),
7745                suggestion: Some(
7746                    "v1 supports one region per rule (including nested regions); split the \
7747                     rule or merge the conditions"
7748                        .to_owned(),
7749                ),
7750            });
7751            continue;
7752        }
7753        let (region, region_case_arms) = regions[0].clone();
7754        if count_effect_statements(&region.body) == 0 {
7755            diagnostics.push(Diagnostic {
7756                related: Vec::new(),
7757                span: region.span,
7758                message: format!(
7759                    "the `{}` region in rule `{}` contains no progression",
7760                    if region.until { "until" } else { "during" },
7761                    rule.name.name
7762                ),
7763                suggestion: Some(
7764                    "a region around purely-atomic actions commits with admission and can \
7765                     never lapse between steps; it needs at least one effect with a \
7766                     continuation"
7767                        .to_owned(),
7768                ),
7769            });
7770            continue;
7771        }
7772        // Lapse-arm binding scope: the arm may run at ANY point inside the
7773        // region, so it may only reference bindings guaranteed at region
7774        // entry — never a binding the region itself introduces (the optional
7775        // progress view is the sanctioned window into those).
7776        let mut region_bindings = BTreeSet::new();
7777        collect_all_binding_names(&region.body, &mut region_bindings);
7778        if let Some(view) = &region.lapse_binding {
7779            region_bindings.remove(view);
7780        }
7781        let mut arm_roots = BTreeSet::new();
7782        collect_statement_roots(&region.lapse_body, &mut arm_roots);
7783        for root in &arm_roots {
7784            if region_bindings.contains(root) {
7785                diagnostics.push(Diagnostic {
7786                    related: Vec::new(),
7787                    span: region.span,
7788                    message: format!(
7789                        "the `on lapse` arm of rule `{}` references `{root}`, a binding the \
7790                         region introduces — it may not exist when the arm runs",
7791                        rule.name.name
7792                    ),
7793                    suggestion: Some(
7794                        "reference only bindings from before the region, or bind the \
7795                         progress view (`on lapse as got`) and read `got.<binding>` — its \
7796                         fields are present exactly if that step settled"
7797                            .to_owned(),
7798                    ),
7799                });
7800            }
7801        }
7802        // Variant surgery. All spans are absolute; rebase onto the body text.
7803        let base = rule.body.span.start;
7804        let text = rule.body.text.clone();
7805        let clamp = |offset: usize| offset.saturating_sub(base).min(text.len());
7806        let (r_start, r_end) = (clamp(region.span.start), clamp(region.span.end));
7807        let (b_start, b_end) = (clamp(region.body_span.start), clamp(region.body_span.end));
7808        let (l_start, l_end) = (clamp(region.lapse_span.start), clamp(region.lapse_span.end));
7809        if !(r_start <= b_start
7810            && b_start <= b_end
7811            && b_end <= l_start
7812            && l_start <= l_end
7813            && l_end <= r_end)
7814        {
7815            diagnostics.push(Diagnostic {
7816                related: Vec::new(),
7817                span: region.span,
7818                message: format!(
7819                    "internal: region span reconstruction failed for rule `{}`",
7820                    rule.name.name
7821                ),
7822                suggestion: None,
7823            });
7824            continue;
7825        }
7826        let body_content = &text[b_start..b_end];
7827        let arm_content = &text[l_start..l_end];
7828        let variant_holds = format!("{}{}{}", &text[..r_start], body_content, &text[r_end..]);
7829        let variant_removed = format!("{}{}", &text[..r_start], &text[r_end..]);
7830        let variant_lapsed = format!("{}{}{}", &text[..r_start], arm_content, &text[r_end..]);
7831        // Region effect scopes, computed on the HOLDS variant (the canonical
7832        // kernel body): each region-owned effect binding's LEVEL-1 `after`
7833        // ancestor is the scope the kernel keys its effect id under.
7834        let mut effect_bindings = BTreeSet::new();
7835        collect_effect_binding_names(&region.body, &mut effect_bindings);
7836        let (holds_ast, _) = body::parse_rule_body(&variant_holds, 0);
7837        let mut region_effects = Vec::new();
7838        assign_region_effect_scopes(
7839            &holds_ast.statements,
7840            None,
7841            &effect_bindings,
7842            &mut region_effects,
7843        );
7844        pending.insert(
7845            rule.name.name.clone(),
7846            IrRegion {
7847                until: region.until,
7848                condition: region.condition.clone(),
7849                lapse_binding: region.lapse_binding.clone(),
7850                effects: region_effects,
7851                body_removed: variant_removed,
7852                body_lapsed: variant_lapsed,
7853                arm_content: arm_content.to_owned(),
7854                arm_case_arms: region_case_arms,
7855            },
7856        );
7857        rule.body.text = variant_holds;
7858    }
7859    pending
7860}
7861
7862/// Every region in the body, each paired with the `(scrutinee, pattern)` chain of
7863/// the `case` arms that enclose it. The chain is what lets the lapse arm be
7864/// read-narrowed at the position the region actually sits in: an arm inside
7865/// `case e.kind { "deploy" => … }` inherits that arm's Family B allowances, so
7866/// checking it against the rule-top (empty) allowed set would reject a legal read.
7867fn collect_region_blocks(
7868    statements: &[body::BodyStmt],
7869    case_arms: &[(String, String)],
7870    out: &mut Vec<(body::RegionBlock, Vec<(String, String)>)>,
7871) {
7872    for statement in statements {
7873        match statement {
7874            body::BodyStmt::Region(region) => {
7875                out.push((region.clone(), case_arms.to_vec()));
7876                collect_region_blocks(&region.body, case_arms, out);
7877                collect_region_blocks(&region.lapse_body, case_arms, out);
7878            }
7879            body::BodyStmt::After(after) => collect_region_blocks(&after.body, case_arms, out),
7880            body::BodyStmt::Case(case) => {
7881                for branch in &case.branches {
7882                    let mut nested = case_arms.to_vec();
7883                    nested.push((case.scrutinee.clone(), branch.pattern.clone()));
7884                    collect_region_blocks(&branch.body, &nested, out);
7885                }
7886            }
7887            _ => {}
7888        }
7889    }
7890}
7891
7892fn count_effect_statements(statements: &[body::BodyStmt]) -> usize {
7893    let mut count = 0;
7894    for statement in statements {
7895        match statement {
7896            body::BodyStmt::Effect(_) => count += 1,
7897            body::BodyStmt::After(after) => count += count_effect_statements(&after.body),
7898            body::BodyStmt::Case(case) => {
7899                for branch in &case.branches {
7900                    count += count_effect_statements(&branch.body);
7901                }
7902            }
7903            body::BodyStmt::Region(region) => {
7904                count += count_effect_statements(&region.body);
7905            }
7906            _ => {}
7907        }
7908    }
7909    count
7910}
7911
7912fn collect_effect_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
7913    for statement in statements {
7914        match statement {
7915            body::BodyStmt::Effect(effect) => {
7916                if let Some(binding) = &effect.binding {
7917                    out.insert(binding.clone());
7918                }
7919            }
7920            body::BodyStmt::After(after) => collect_effect_binding_names(&after.body, out),
7921            body::BodyStmt::Case(case) => {
7922                for branch in &case.branches {
7923                    collect_effect_binding_names(&branch.body, out);
7924                }
7925            }
7926            body::BodyStmt::Region(region) => {
7927                collect_effect_binding_names(&region.body, out);
7928            }
7929            _ => {}
7930        }
7931    }
7932}
7933
7934/// Walks the HOLDS-variant AST assigning each region-owned effect its LEVEL-1
7935/// `after` scope (the kernel's effect-id key component). `level1` is fixed at
7936/// the first `after` ancestor and inherited by everything deeper.
7937fn assign_region_effect_scopes(
7938    statements: &[body::BodyStmt],
7939    level1: Option<&(String, String)>,
7940    region_bindings: &BTreeSet<String>,
7941    out: &mut Vec<IrRegionEffect>,
7942) {
7943    for statement in statements {
7944        match statement {
7945            body::BodyStmt::Effect(effect) => {
7946                if let Some(binding) = &effect.binding {
7947                    if region_bindings.contains(binding)
7948                        && !out.iter().any(|known| &known.binding == binding)
7949                    {
7950                        out.push(IrRegionEffect {
7951                            binding: binding.clone(),
7952                            scope: level1.cloned(),
7953                        });
7954                    }
7955                }
7956            }
7957            body::BodyStmt::After(after) => {
7958                let own = (
7959                    after.binding.clone(),
7960                    after.predicate.kernel_str().to_owned(),
7961                );
7962                let next = level1.cloned().unwrap_or(own);
7963                assign_region_effect_scopes(&after.body, Some(&next), region_bindings, out);
7964            }
7965            body::BodyStmt::Case(case) => {
7966                for branch in &case.branches {
7967                    assign_region_effect_scopes(&branch.body, level1, region_bindings, out);
7968                }
7969            }
7970            body::BodyStmt::Region(region) => {
7971                assign_region_effect_scopes(&region.body, level1, region_bindings, out);
7972            }
7973            _ => {}
7974        }
7975    }
7976}
7977
7978/// Root identifiers referenced by a statement list's value positions: record
7979/// fields, terminal fields, done/cancel bindings, effect arguments, and
7980/// `{{ … }}` prompt interpolations. Used for the lapse-arm scope check.
7981fn collect_statement_roots(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
7982    fn roots_in_expr(source: &str, out: &mut BTreeSet<String>) {
7983        let bytes = source.as_bytes();
7984        let mut i = 0;
7985        let mut in_string = false;
7986        while i < bytes.len() {
7987            let c = bytes[i] as char;
7988            if c == '"' {
7989                in_string = !in_string;
7990                i += 1;
7991                continue;
7992            }
7993            if in_string {
7994                i += 1;
7995                continue;
7996            }
7997            if c.is_ascii_alphabetic() || c == '_' {
7998                let start = i;
7999                while i < bytes.len() {
8000                    let cj = bytes[i] as char;
8001                    if cj.is_ascii_alphanumeric() || cj == '_' {
8002                        i += 1;
8003                    } else {
8004                        break;
8005                    }
8006                }
8007                let preceded_by_dot = start > 0 && bytes[start - 1] as char == '.';
8008                if !preceded_by_dot {
8009                    out.insert(source[start..i].to_owned());
8010                }
8011                continue;
8012            }
8013            i += 1;
8014        }
8015    }
8016    fn roots_in_fields(fields: &[body::FieldAssign], out: &mut BTreeSet<String>) {
8017        for field in fields {
8018            match &field.value {
8019                body::FieldValue::Expr { source, .. } => roots_in_expr(source, out),
8020                body::FieldValue::Nested { fields, .. } => roots_in_fields(fields, out),
8021                body::FieldValue::Shorthand => {
8022                    out.insert(field.name.clone());
8023                }
8024            }
8025        }
8026    }
8027    fn roots_in_prompt(text: &str, out: &mut BTreeSet<String>) {
8028        let mut rest = text;
8029        while let Some(open) = rest.find("{{") {
8030            let tail = &rest[open + 2..];
8031            let Some(close) = tail.find("}}") else {
8032                break;
8033            };
8034            roots_in_expr(&tail[..close], out);
8035            rest = &tail[close + 2..];
8036        }
8037    }
8038    for statement in statements {
8039        match statement {
8040            body::BodyStmt::Record(record) => roots_in_fields(&record.fields, out),
8041            body::BodyStmt::Done {
8042                binding,
8043                replacement,
8044                ..
8045            } => {
8046                out.insert(binding.clone());
8047                if let Some(record) = replacement {
8048                    roots_in_fields(&record.fields, out);
8049                }
8050            }
8051            body::BodyStmt::Cancel { binding, .. } => {
8052                out.insert(binding.clone());
8053            }
8054            body::BodyStmt::Effect(effect) => {
8055                if let Some(prompt) = &effect.prompt {
8056                    roots_in_prompt(&prompt.text, out);
8057                }
8058                match &effect.kind {
8059                    body::BodyEffectKind::Coerce { args, .. } => {
8060                        for arg in args {
8061                            roots_in_expr(arg, out);
8062                        }
8063                    }
8064                    body::BodyEffectKind::TrackerFinish { item, fields } => {
8065                        out.insert(item.clone());
8066                        roots_in_fields(fields, out);
8067                    }
8068                    body::BodyEffectKind::TrackerRelease { item } => {
8069                        out.insert(item.clone());
8070                    }
8071                    _ => {}
8072                }
8073            }
8074            body::BodyStmt::Terminal(terminal) => {
8075                roots_in_fields(&terminal.fields, out);
8076                if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
8077                    roots_in_expr(source, out);
8078                }
8079            }
8080            body::BodyStmt::Milestone { fields, .. } => roots_in_fields(fields, out),
8081            body::BodyStmt::After(after) => collect_statement_roots(&after.body, out),
8082            body::BodyStmt::Case(case) => {
8083                roots_in_expr(&case.scrutinee, out);
8084                for branch in &case.branches {
8085                    collect_statement_roots(&branch.body, out);
8086                }
8087            }
8088            body::BodyStmt::Region(region) => {
8089                collect_statement_roots(&region.body, out);
8090                collect_statement_roots(&region.lapse_body, out);
8091            }
8092            body::BodyStmt::Redact { source, .. } => {
8093                out.insert(source.clone());
8094            }
8095        }
8096    }
8097}
8098
8099fn validate_effectful_self_trigger(
8100    rule: &RuleDecl,
8101    metadata: &IrRuleMetadata,
8102    diagnostics: &mut Vec<Diagnostic>,
8103) {
8104    if metadata.effects.is_empty() {
8105        return;
8106    }
8107
8108    for written_fact in &metadata.fact_writes {
8109        if metadata.fact_reads.contains(written_fact)
8110            && !metadata.fact_consumes.contains(written_fact)
8111        {
8112            diagnostics.push(Diagnostic { related: Vec::new(),
8113                span: rule.body.span,
8114                message: format!(
8115                    "effectful rule `{}` preserves trigger fact `{written_fact}`",
8116                    rule.name.name
8117                ),
8118                suggestion: Some(
8119                    "consume or advance the triggering fact, or move the next effect behind an external completion event"
8120                        .to_owned(),
8121                ),
8122            });
8123        }
8124    }
8125}
8126
8127fn binding_types_for_rule(rule: &RuleDecl) -> BTreeMap<String, String> {
8128    let mut binding_types = BTreeMap::new();
8129    for when in &rule.whens {
8130        if let Some((binding, schema)) = binding_from_when(&when.text) {
8131            binding_types.insert(binding, schema);
8132        }
8133    }
8134    binding_types
8135}
8136
8137fn validate_workflow_terminal_actions(
8138    rule: &RuleDecl,
8139    semantic: &SemanticContext,
8140    binding_types: &BTreeMap<String, String>,
8141    known_roots: &BTreeSet<String>,
8142    contracts: &WorkflowContractNames,
8143    diagnostics: &mut Vec<Diagnostic>,
8144) {
8145    for line in rule.body.text.lines().map(str::trim) {
8146        let terminal = line
8147            .strip_prefix("complete ")
8148            .map(|rest| ("complete", rest, &contracts.outputs))
8149            .or_else(|| {
8150                line.strip_prefix("fail ")
8151                    .map(|rest| ("fail", rest, &contracts.failures))
8152            });
8153        let Some((action, rest, declared)) = terminal else {
8154            continue;
8155        };
8156        // Scalar terminal form: `complete result 0.9` / `fail error "msg"` — a bare
8157        // value after the name, with no `{ }` block and no `from` projection.
8158        // Validated against a scalar (primitive) contract; class contracts still
8159        // require a field block (checked by the block path below).
8160        if !rest.contains('{') {
8161            let tokens: Vec<&str> = rest.split_whitespace().collect();
8162            let is_from = matches!(tokens.as_slice(), [_, "from", ..]) && action == "complete";
8163            if tokens.len() >= 2 && !is_from {
8164                let name = tokens[0];
8165                let value = rest.trim().get(name.len()..).unwrap_or("").trim();
8166                if !declared.contains_key(name) {
8167                    diagnostics.push(Diagnostic {
8168                        related: Vec::new(),
8169                        span: rule.body.span,
8170                        message: format!(
8171                            "rule `{}` {action}s unknown workflow terminal `{name}`",
8172                            rule.name.name
8173                        ),
8174                        suggestion: Some(format!(
8175                            "declare `{kind} {name} Type` on the workflow first",
8176                            kind = if action == "complete" {
8177                                "output"
8178                            } else {
8179                                "failure"
8180                            }
8181                        )),
8182                    });
8183                    continue;
8184                }
8185                if let Some(contract_ty) = declared.get(name) {
8186                    validate_scalar_terminal_payload(
8187                        rule,
8188                        action,
8189                        name,
8190                        value,
8191                        contract_ty,
8192                        semantic,
8193                        binding_types,
8194                        known_roots,
8195                        diagnostics,
8196                    );
8197                }
8198                continue;
8199            }
8200        }
8201        // Header is `<name>` or (for `complete`) `<name> from <binding>` — the
8202        // bounded-type projection form (DR-0027), whose payload copies the binding.
8203        let Some(name) = rest.split('{').next().and_then(|header| {
8204            let mut parts = header.split_whitespace();
8205            match (parts.next(), parts.next(), parts.next()) {
8206                (Some(name), None, _) => Some(name),
8207                (Some(name), Some("from"), Some(binding))
8208                    if action == "complete" && is_identifier(binding) =>
8209                {
8210                    Some(name)
8211                }
8212                _ => None,
8213            }
8214        }) else {
8215            diagnostics.push(Diagnostic {
8216                related: Vec::new(),
8217                span: rule.body.span,
8218                message: format!("rule `{}` has malformed `{action}` action", rule.name.name),
8219                suggestion: Some(format!(
8220                    "{action} a declared workflow terminal with a payload block"
8221                )),
8222            });
8223            continue;
8224        };
8225        if !declared.contains_key(name) {
8226            diagnostics.push(Diagnostic {
8227                related: Vec::new(),
8228                span: rule.body.span,
8229                message: format!(
8230                    "rule `{}` {action}s unknown workflow terminal `{name}`",
8231                    rule.name.name
8232                ),
8233                suggestion: Some(format!(
8234                    "declare `{kind} {name} Type` on the workflow first",
8235                    kind = if action == "complete" {
8236                        "output"
8237                    } else {
8238                        "failure"
8239                    }
8240                )),
8241            });
8242            continue;
8243        }
8244        let Some(contract_ty) = declared.get(name) else {
8245            continue;
8246        };
8247        validate_workflow_terminal_payload(
8248            rule,
8249            action,
8250            name,
8251            contract_ty,
8252            semantic,
8253            binding_types,
8254            known_roots,
8255            diagnostics,
8256        );
8257    }
8258}
8259
8260#[allow(clippy::too_many_arguments)]
8261fn validate_workflow_terminal_payload(
8262    rule: &RuleDecl,
8263    action: &str,
8264    terminal_name: &str,
8265    contract_ty: &TypeSyntax,
8266    semantic: &SemanticContext,
8267    binding_types: &BTreeMap<String, String>,
8268    known_roots: &BTreeSet<String>,
8269    diagnostics: &mut Vec<Diagnostic>,
8270) {
8271    let Some((_, _, body)) = workflow_terminal_blocks(&rule.body.text).into_iter().find(
8272        |(candidate_action, candidate_name, _)| {
8273            candidate_action == action && candidate_name == terminal_name
8274        },
8275    ) else {
8276        return;
8277    };
8278    let schema = match contract_ty {
8279        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => &name.name,
8280        TypeSyntax::Primitive { .. }
8281        | TypeSyntax::LiteralString { .. }
8282        | TypeSyntax::Union { .. } => {
8283            // A scalar (primitive/literal/union) contract takes a bare value, not
8284            // a field block.
8285            diagnostics.push(Diagnostic {
8286                related: Vec::new(),
8287                span: rule.body.span,
8288                message: format!(
8289                    "workflow terminal `{terminal_name}` has a scalar payload contract but is given a field block"
8290                ),
8291                suggestion: Some(format!(
8292                    "write a bare scalar value: `{action} {terminal_name} <value>`"
8293                )),
8294            });
8295            return;
8296        }
8297        _ => {
8298            diagnostics.push(Diagnostic { related: Vec::new(),
8299                span: rule.body.span,
8300                message: format!(
8301                    "workflow terminal `{terminal_name}` uses an unsupported payload contract type"
8302                ),
8303                suggestion: Some(
8304                    "declare the terminal payload as a class (field block) or a scalar type (number/string/bool)"
8305                        .to_owned(),
8306                ),
8307            });
8308            return;
8309        }
8310    };
8311    for assignment in collect_field_assignments(&body) {
8312        let (field, value) = match assignment {
8313            RecordFieldAssignment::Value { field, value } => (field, value),
8314            RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
8315        };
8316        let line = format!("{field} {value}");
8317        validate_record_field(
8318            rule,
8319            &line,
8320            schema,
8321            semantic,
8322            binding_types,
8323            known_roots,
8324            diagnostics,
8325        );
8326    }
8327    validate_required_terminal_fields(rule, schema, terminal_name, &body, semantic, diagnostics);
8328}
8329
8330/// Validates a bare-scalar terminal payload (`complete result 0.9` /
8331/// `fail error "reason"`) against a scalar output/failure contract. A class
8332/// contract is rejected (it needs a field block); a literal value is typechecked
8333/// against the primitive/enum/union contract, and a binding-expression value has
8334/// its roots and field path validated.
8335#[allow(clippy::too_many_arguments)]
8336fn validate_scalar_terminal_payload(
8337    rule: &RuleDecl,
8338    action: &str,
8339    terminal_name: &str,
8340    value: &str,
8341    contract_ty: &TypeSyntax,
8342    semantic: &SemanticContext,
8343    binding_types: &BTreeMap<String, String>,
8344    known_roots: &BTreeSet<String>,
8345    diagnostics: &mut Vec<Diagnostic>,
8346) {
8347    if let TypeSyntax::Ref { name } = contract_ty {
8348        if semantic.schemas.class_exists(&name.name) {
8349            diagnostics.push(Diagnostic {
8350                related: Vec::new(),
8351                span: rule.body.span,
8352                message: format!(
8353                    "workflow terminal `{terminal_name}` has a class payload contract `{}` but is given a bare scalar value",
8354                    name.name
8355                ),
8356                suggestion: Some(format!("write a field block: `{action} {terminal_name} {{ … }}`")),
8357            });
8358            return;
8359        }
8360    }
8361    if value.is_empty() {
8362        diagnostics.push(Diagnostic {
8363            related: Vec::new(),
8364            span: rule.body.span,
8365            message: format!("workflow terminal `{terminal_name}` is missing its scalar value"),
8366            suggestion: Some(format!("write `{action} {terminal_name} <value>`")),
8367        });
8368        return;
8369    }
8370    // A literal value is typechecked against the scalar contract; a binding
8371    // expression has its roots (and any field path) validated.
8372    validate_literal_assignment(
8373        rule,
8374        terminal_name,
8375        "value",
8376        contract_ty,
8377        value,
8378        semantic,
8379        diagnostics,
8380    );
8381    if let Some(root) = dangling_value_root(value, known_roots) {
8382        diagnostics.push(Diagnostic {
8383            related: Vec::new(),
8384            span: rule.body.span,
8385            message: format!(
8386                "rule `{}` has unknown binding `{root}` in `{action} {terminal_name}` value",
8387                rule.name.name
8388            ),
8389            suggestion: Some(
8390                "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
8391                    .to_owned(),
8392            ),
8393        });
8394    } else if let Some((root, path)) = expression_path(value) {
8395        // Local scopes only. A terminal payload line is also walked by the
8396        // scoped field-path pass in `analyze_rule`, which is where an
8397        // invoke-derived binding resolves; making this one scope-aware too would
8398        // report the same read twice.
8399        check_field_path(
8400            rule,
8401            &root,
8402            &path,
8403            rule.body.span,
8404            SchemaScopes::local(&semantic.schemas),
8405            binding_types,
8406            diagnostics,
8407        );
8408    }
8409}
8410
8411fn validate_required_terminal_fields(
8412    rule: &RuleDecl,
8413    schema: &str,
8414    terminal_name: &str,
8415    body: &str,
8416    semantic: &SemanticContext,
8417    diagnostics: &mut Vec<Diagnostic>,
8418) {
8419    let Some(schema_fields) = semantic.schemas.classes.get(schema) else {
8420        return;
8421    };
8422    let seen = collect_field_assignments(body)
8423        .into_iter()
8424        .map(|assignment| match assignment {
8425            RecordFieldAssignment::Value { field, .. }
8426            | RecordFieldAssignment::Shorthand { field } => field,
8427        })
8428        .collect::<BTreeSet<_>>();
8429    for (required, ty) in schema_fields {
8430        if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
8431            continue;
8432        }
8433        diagnostics.push(Diagnostic { related: Vec::new(),
8434            span: rule.body.span,
8435            message: format!(
8436                "workflow terminal `{terminal_name}` is missing required field `{schema}.{required}`"
8437            ),
8438            suggestion: Some(format!("add `{required}` to the `{terminal_name}` payload")),
8439        });
8440    }
8441}
8442
8443/// Maximum nesting depth of `after` blocks across `statements` (an `after` inside an
8444/// `after` is depth 2, …). Other nesting (`case`/`when`/handlers) is descended into so
8445/// an `after` buried inside them still counts, but only `after` increments the depth —
8446/// it is `after`-chaining specifically that `lint.deep_after_nesting` advises moving to
8447/// a `flow`. Computed from the body AST so prompt braces never confuse it.
8448fn max_after_depth(statements: &[body::BodyStmt]) -> usize {
8449    use body::BodyStmt;
8450    statements
8451        .iter()
8452        .map(|statement| match statement {
8453            BodyStmt::After(after) => 1 + max_after_depth(&after.body),
8454            BodyStmt::Case(case) => case
8455                .branches
8456                .iter()
8457                .map(|branch| max_after_depth(&branch.body))
8458                .max()
8459                .unwrap_or(0),
8460            _ => 0,
8461        })
8462        .max()
8463        .unwrap_or(0)
8464}
8465
8466fn analyze_rule(
8467    rule: &RuleDecl,
8468    semantic: &SemanticContext,
8469    diagnostics: &mut Vec<Diagnostic>,
8470) -> IrRuleMetadata {
8471    // Statement-form gate: every body must parse into the body AST. Unknown
8472    // statements, malformed modifiers, and unclosed blocks are spanned
8473    // errors here rather than silent no-ops at lowering time.
8474    let (body_ast, body_diagnostics) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
8475    diagnostics.extend(body_diagnostics);
8476    let mut metadata = IrRuleMetadata {
8477        fact_reads: rule
8478            .whens
8479            .iter()
8480            .map(|when| fact_read_from_when(&when.text))
8481            .collect(),
8482        max_after_depth: max_after_depth(&body_ast.statements),
8483        ..IrRuleMetadata::default()
8484    };
8485    let mut seen_bindings = BTreeSet::new();
8486    let mut binding_types = BTreeMap::new();
8487    // Bindings whose schema is declared inside a CHILD workflow (`after <invoke>
8488    // succeeds/fails/reaches as x`). Their field paths resolve in that child's
8489    // index, not this one — see `SchemaScopes`.
8490    let mut foreign_schemas: BTreeMap<String, String> = BTreeMap::new();
8491    for when in &rule.whens {
8492        // A pattern that binds (`... as x`) but maps to no known readiness
8493        // form would otherwise be a silently-dead rule.
8494        let (pattern_text, _) = split_when_guard(&when.text);
8495        if binding_after_as(pattern_text).is_some()
8496            && binding_from_when(&when.text).is_none()
8497            && !pattern_text.ends_with(" is available")
8498        {
8499            diagnostics.push(Diagnostic {
8500                related: Vec::new(),
8501                span: when.span,
8502                message: format!(
8503                    "rule `{}` has unknown readiness pattern `{pattern_text}`",
8504                    rule.name.name
8505                ),
8506                suggestion: Some(
8507                    "match a class (`when Class as x`) or a runtime fact (`when fact <name> as x`)"
8508                        .to_owned(),
8509                ),
8510            });
8511        }
8512        if let Some((binding, schema)) = binding_from_when(&when.text) {
8513            validate_binding_name(rule, &binding, when.span, diagnostics);
8514            if !schema.contains('.') && !semantic.schemas.class_exists(&schema) {
8515                let suggestion = match closest_name(&schema, semantic.schemas.classes.keys()) {
8516                    Some(candidate) => {
8517                        format!("did you mean `{candidate}`? otherwise declare `class {schema}`")
8518                    }
8519                    None => format!("declare `class {schema}` before matching it"),
8520                };
8521                diagnostics.push(Diagnostic {
8522                    related: Vec::new(),
8523                    span: when.span,
8524                    message: format!("rule `{}` matches unknown class `{schema}`", rule.name.name),
8525                    suggestion: Some(suggestion),
8526                });
8527            }
8528            // The bare dotted form is the typed signal reaction
8529            // (spec/event-ingress.md): it requires a declared `signal`;
8530            // undeclared dotted facts keep the untyped `when fact` form.
8531            if schema.contains('.')
8532                && !pattern_text.trim_start().starts_with("fact ")
8533                && !semantic.schemas.events.contains(&schema)
8534            {
8535                diagnostics.push(Diagnostic { related: Vec::new(),
8536                    span: when.span,
8537                    message: format!(
8538                        "rule `{}` reacts to undeclared signal `{schema}`",
8539                        rule.name.name
8540                    ),
8541                    suggestion: Some(format!(
8542                        "declare `signal {schema} {{ ... }}` for a typed reaction, or use `when fact {schema} as ...` for an untyped one"
8543                    )),
8544                });
8545            }
8546            binding_types.insert(binding, schema);
8547        }
8548    }
8549    let mut effect_payload_types = collect_effect_payload_types(rule, semantic, diagnostics);
8550    // `exec ... -> Schema as binding` is parsed from the AST (the command text
8551    // can itself contain `->`/` as `, so a text scan is unsafe), giving its
8552    // result the same after-binding type flow a named `coerce -> Schema` gets.
8553    collect_exec_payload_types(&body_ast.statements, semantic, &mut effect_payload_types);
8554    // Inline `decide … as <binding>` carries the synthesized
8555    // `decide.<rule>.<binding>` class (see `collect_inline_decide_schemas`), so
8556    // its result is `case`able / field-accessible like a named coerce result.
8557    collect_decide_payload_types(
8558        &body_ast.statements,
8559        &rule.name.name,
8560        &mut effect_payload_types,
8561    );
8562    collect_prompt_payload_types(&body_ast.statements, &mut effect_payload_types);
8563    // `redact … as <binding>` result carries the synthesized `redact.<rule>.<binding>`
8564    // projected class (see `collect_redact_schemas`), so access through it resolves
8565    // against the kept-only fields.
8566    collect_redact_payload_types(
8567        &body_ast.statements,
8568        &rule.name.name,
8569        &mut effect_payload_types,
8570    );
8571    for (binding, payload_type) in &effect_payload_types {
8572        if let IrType::Ref(schema) = payload_type {
8573            binding_types.insert(binding.clone(), schema.clone());
8574        }
8575    }
8576    // Effect-kind map for the `fails`-arm static narrowing (DR-0032 P3): the
8577    // failing effect's kind is always statically known at the read site. Scan
8578    // raw lines first (every single-line effect form), then the balanced
8579    // multi-line statements (a `coerce` whose arguments span lines carries its
8580    // binding on the closing line).
8581    // Bindings born from the std.vcs completion-valued verbs: the
8582    // succeeds-refusals below need the construct, not just the generic
8583    // CapabilityCall kind. Maps binding -> (verb, negative variant).
8584    let vcs_verb_bindings: BTreeMap<String, (&'static str, &'static str)> = rule
8585        .body
8586        .text
8587        .lines()
8588        .filter_map(|line| {
8589            let line = line.trim();
8590            let (verb, negative) = if line.starts_with("promote ") {
8591                ("promote", "Conflicted")
8592            } else if line.starts_with("undo ") {
8593                ("undo", "Stranded")
8594            } else if line.starts_with("transport ") {
8595                ("transport", "Conflicted")
8596            } else {
8597                return None;
8598            };
8599            Some((binding_after_as(line)?, (verb, negative)))
8600        })
8601        .collect();
8602    let mut effect_binding_kinds: BTreeMap<String, IrEffectKind> = rule
8603        .body
8604        .text
8605        .lines()
8606        .filter_map(|line| {
8607            let line = line.trim();
8608            // `exec` is not in parse_effect_line (whose other callers feed the
8609            // rule-metadata effect count); match it here for kind narrowing.
8610            if line.starts_with("exec ") {
8611                return Some((binding_after_as(line)?, IrEffectKind::ExecCommand));
8612            }
8613            let (kind, binding) = parse_effect_line(line)?;
8614            Some((binding?, kind))
8615        })
8616        .collect();
8617    for statement in effect_payload_statements(&rule.body.text) {
8618        if let Some((kind, Some(binding))) = parse_effect_line(statement.trim()) {
8619            effect_binding_kinds.insert(binding, kind);
8620        }
8621    }
8622    // `after <binding> <predicate> as <alias>`: the alias carries the
8623    // effect's completed payload type, so case dispatch and field access
8624    // through it type-check.
8625    for line in rule.body.text.lines() {
8626        let Some(rest) = line.trim().strip_prefix("after ") else {
8627            continue;
8628        };
8629        let mut words = rest.split_whitespace();
8630        let Some(binding) = words.next() else {
8631            continue;
8632        };
8633        let Some(predicate) = words.next() else {
8634            continue;
8635        };
8636        // Coordination ops are completion-valued: an `acquire` COMPLETES with
8637        // variant Held|Contended (counter `consume` with Ok|Over), so the
8638        // generic `succeeds` arm would fire on the negative outcome too — a
8639        // workflow proceeding "as if holding" on Contended. Reject `succeeds`
8640        // and force the variant vocabulary; `fails` (infra failures) and
8641        // `completes` (deliberate catch-all) stay legal.
8642        if predicate == "succeeds" {
8643            if let Some((verb, negative)) = vcs_verb_bindings.get(binding) {
8644                // std.vcs verbs are completion-valued: the generic
8645                // `succeeds` arm would fire on the refusal variant too — a
8646                // workflow proceeding as if the act landed. Same posture
8647                // as acquire; outcome-variant predicates are only ever
8648                // added alongside this refusal (DR-0052 Decision 0).
8649                let positive = if *verb == "promote" {
8650                    "promoted"
8651                } else {
8652                    "applied"
8653                };
8654                let negative_arm = negative.to_lowercase();
8655                diagnostics.push(Diagnostic {
8656                    related: Vec::new(),
8657                    span: rule.body.span,
8658                    message: format!(
8659                        "rule `{}` observes {verb} `{binding}` with `succeeds`, which also \
8660                         matches a {negative} outcome (the op completes either way)",
8661                        rule.name.name
8662                    ),
8663                    suggestion: Some(format!(
8664                        "use `after {binding} {positive}` / `after {binding} {negative_arm}` \
8665                         for the outcome variants, or `after {binding} completes` for any \
8666                         settled outcome"
8667                    )),
8668                });
8669            }
8670        }
8671        if predicate == "succeeds" {
8672            match effect_binding_kinds.get(binding) {
8673                Some(IrEffectKind::LeaseAcquire) => {
8674                    diagnostics.push(Diagnostic {
8675                        related: Vec::new(),
8676                        span: rule.body.span,
8677                        message: format!(
8678                            "rule `{}` observes acquire `{binding}` with `succeeds`, which also \
8679                             matches a Contended outcome (the acquire op completes either way)",
8680                            rule.name.name
8681                        ),
8682                        suggestion: Some(format!(
8683                            "use `after {binding} held` / `after {binding} contended` for the \
8684                             outcome variants, or `after {binding} completes` for any settled \
8685                             outcome"
8686                        )),
8687                    });
8688                }
8689                Some(IrEffectKind::CounterConsume) => {
8690                    diagnostics.push(Diagnostic {
8691                        related: Vec::new(),
8692                        span: rule.body.span,
8693                        message: format!(
8694                            "rule `{}` observes counter consume `{binding}` with `succeeds`, \
8695                             which also matches an Over outcome (the consume op completes \
8696                             either way)",
8697                            rule.name.name
8698                        ),
8699                        suggestion: Some(format!(
8700                            "use `after {binding} ok` / `after {binding} over` for the outcome \
8701                             variants, or `after {binding} completes` for any settled outcome"
8702                        )),
8703                    });
8704                }
8705                _ => {}
8706            }
8707        }
8708        // `after p reaches "<name>" as m` (Family C): the milestone name sits
8709        // between the predicate and `as`, so the alias lands one token later.
8710        // Type `m` to the child's declared milestone payload class.
8711        if predicate == "reaches" {
8712            let Some(quoted) = words.next() else {
8713                continue;
8714            };
8715            let milestone = quoted.trim_matches('"');
8716            let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
8717                continue;
8718            };
8719            let alias = alias.trim_end_matches('{').trim();
8720            if alias.is_empty() {
8721                continue;
8722            }
8723            if let Some((workflow, class)) =
8724                milestone_payload_class(rule, binding, milestone, semantic)
8725            {
8726                if !class.is_empty() {
8727                    binding_types.insert(alias.to_owned(), class);
8728                    foreign_schemas.insert(alias.to_owned(), workflow);
8729                }
8730            }
8731            continue;
8732        }
8733        // `times out` is the only two-token predicate; skip its second word so
8734        // the `as <alias>` clause lines up.
8735        if predicate == "times" && words.next() != Some("out") {
8736            continue;
8737        }
8738        let (Some(keyword), Some(alias)) = (words.next(), words.next()) else {
8739            continue;
8740        };
8741        if keyword != "as" {
8742            continue;
8743        }
8744        let alias = alias.trim_end_matches('{').trim();
8745        if alias.is_empty() {
8746            continue;
8747        }
8748        // Bind the alias to the terminal payload schema that matches the
8749        // predicate, consistent with the case-tag payload schemas
8750        // (terminal_payload_schema_for_tag): `times out` -> `TerminalTimedOut`,
8751        // `cancelled` -> `TerminalCancelled`. Other predicates carry the
8752        // effect's completed payload schema.
8753        match predicate {
8754            "times" => {
8755                binding_types.insert(alias.to_owned(), "TerminalTimedOut".to_owned());
8756            }
8757            "cancelled" => {
8758                binding_types.insert(alias.to_owned(), "TerminalCancelled".to_owned());
8759            }
8760            // `completes` binds the terminal-union ENVELOPE, not the success
8761            // schema: the runtime delivers {tag, status, summary, …} for ANY
8762            // settled outcome, and the payload is read via `case o {
8763            // Completed as v => … }`. The old success-schema typing approved
8764            // reads that were null on every non-success terminal.
8765            "completes" => {
8766                binding_types.insert(alias.to_owned(), "TerminalOutcome".to_owned());
8767            }
8768            // DR-0032: the `fails` branch binds the EffectError family — the
8769            // base `{reason, summary, effect_id, run_id, kind}` plus per-kind
8770            // extras narrowed STATICALLY by the binding's effect kind (P3 /
8771            // DQ-2): exec adds `exit_code`; schema.coerce adds `error_class` +
8772            // optional `http_status`; agent.tell adds `error_class`. Every
8773            // other kind stays on the plain base.
8774            //
8775            // Exception (typed invoke failure): when this is an invoke binding
8776            // whose child declares a SOLE, shared top-level FAILURE contract class,
8777            // bind the alias to THAT class so `f.<field>` type-checks against the
8778            // child's declared failure shape (the runtime merges the child payload
8779            // under the base). Invoke bindings with a child-local/unresolvable
8780            // failure class keep the `TerminalFailed` base.
8781            "fails" => {
8782                if let Some((workflow, class)) = invoke_failure_class(rule, binding, semantic) {
8783                    binding_types.insert(alias.to_owned(), class);
8784                    foreign_schemas.insert(alias.to_owned(), workflow);
8785                } else {
8786                    let schema = match effect_binding_kinds.get(binding) {
8787                        Some(IrEffectKind::ExecCommand) => "TerminalFailedExec",
8788                        Some(IrEffectKind::SchemaCoerce) => "TerminalFailedCoerce",
8789                        Some(IrEffectKind::AgentTell) => "TerminalFailedTell",
8790                        _ => "TerminalFailed",
8791                    };
8792                    binding_types.insert(alias.to_owned(), schema.to_owned());
8793                }
8794            }
8795            _ => {
8796                if let Some(IrType::Ref(schema)) = effect_payload_types.get(binding) {
8797                    binding_types.insert(alias.to_owned(), schema.clone());
8798                } else if let Some((workflow, class)) = invoke_output_class(rule, binding, semantic)
8799                {
8800                    // Typed invoke result: `after <child> succeeds/completes as r`
8801                    // binds r to the child workflow's OUTPUT contract class, so
8802                    // `r.<field>` type-checks. (The runtime already carries the
8803                    // child's terminal payload into this binding.) The `fails` arm
8804                    // above keeps the DR-0032 failure base.
8805                    binding_types.insert(alias.to_owned(), class);
8806                    foreign_schemas.insert(alias.to_owned(), workflow);
8807                }
8808            }
8809        }
8810    }
8811    for when in &rule.whens {
8812        if let (_, Some(guard)) = split_when_guard(&when.text) {
8813            validate_expression(rule, guard, semantic, &binding_types, "guard", diagnostics);
8814            validate_known_field_paths(rule, guard, semantic, &binding_types, diagnostics);
8815            if let Some(expr) = lower_expression(guard, when.span) {
8816                metadata
8817                    .projection_reads
8818                    .extend(collect_projection_reads(&expr.expr));
8819            }
8820        }
8821        validate_availability_when(rule, &when.text, semantic, &binding_types, diagnostics);
8822    }
8823    validate_case_blocks(rule, semantic, &binding_types, diagnostics);
8824    metadata.case_branches =
8825        collect_rule_case_metadata(rule, semantic, &binding_types, diagnostics);
8826    let terminal_metadata = collect_terminal_case_metadata(
8827        rule,
8828        semantic,
8829        &binding_types,
8830        &effect_payload_types,
8831        diagnostics,
8832    );
8833    // Complete value-position root set: typed bindings plus every binding NAME
8834    // the body introduces (AST-collected, so multi-line-prompt `tell`/`exec`
8835    // results and `case` payloads are covered, which `binding_types` omits).
8836    let mut known_roots: BTreeSet<String> = binding_types.keys().cloned().collect();
8837    collect_all_binding_names(&body_ast.statements, &mut known_roots);
8838    validate_record_blocks(rule, semantic, &binding_types, &known_roots, diagnostics);
8839    validate_effect_payloads(rule, semantic, &binding_types, &known_roots, diagnostics);
8840    validate_effect_field_roots(rule, &body_ast.statements, &known_roots, diagnostics);
8841    validate_emit_signal_declarations(
8842        rule,
8843        &body_ast.statements,
8844        &semantic.schemas.events,
8845        diagnostics,
8846    );
8847    validate_workflow_invocations(rule, semantic, &binding_types, &known_roots, diagnostics);
8848    validate_milestone_statements(rule, semantic, diagnostics);
8849    let mut block_stack: Vec<BlockFrame> = Vec::new();
8850    let mut misplaced_effect_bindings = BTreeSet::new();
8851    seed_ast_only_effect_bindings(&body_ast.statements, &mut seen_bindings, &mut binding_types);
8852    validate_body_effect_operands(
8853        rule,
8854        &body_ast.statements,
8855        semantic,
8856        &binding_types,
8857        diagnostics,
8858    );
8859    validate_coordination_discipline(rule, &body_ast.statements, diagnostics);
8860    // `redact <source> keep [..] as <out>`: the source must resolve to a known
8861    // schema and every kept field must exist on it (fail-closed).
8862    validate_redactions(
8863        rule,
8864        &body_ast.statements,
8865        semantic,
8866        &binding_types,
8867        diagnostics,
8868    );
8869    // Family B read-narrowing: a presence-conditioned field is readable only inside a
8870    // matching `case <root>.<disc>` arm (starts with nothing allowed at the rule top).
8871    validate_conditioned_field_reads(
8872        rule,
8873        &body_ast.statements,
8874        semantic,
8875        &binding_types,
8876        &BTreeSet::new(),
8877        diagnostics,
8878    );
8879    let mut anonymous_effects = 0usize;
8880    let mut record_depth = 0i32;
8881
8882    for raw_line in rule.body.text.lines() {
8883        let line = raw_line.trim();
8884        if line.is_empty() {
8885            continue;
8886        }
8887
8888        if record_depth > 0 {
8889            record_depth += brace_delta(line);
8890            continue;
8891        }
8892
8893        if let Some(binding) = binding_after_multiline_string_end(line) {
8894            misplaced_effect_bindings.insert(binding.clone());
8895            diagnostics.push(Diagnostic { related: Vec::new(),
8896                span: rule.body.span,
8897                message: format!(
8898                    "rule `{}` places effect binding `{binding}` after a multiline string delimiter",
8899                    rule.name.name
8900                ),
8901                suggestion: Some(format!(
8902                    "move `as {binding}` onto the effect line, before the multiline string body"
8903                )),
8904            });
8905            continue;
8906        }
8907        validate_rule_prompt_content_type_annotation(rule, line, diagnostics);
8908
8909        if line.starts_with('}') {
8910            block_stack.pop();
8911            continue;
8912        }
8913
8914        if line.starts_with("case ") || (!line.starts_with("after ") && is_case_branch_start(line))
8915        {
8916            validate_known_field_paths_scoped(
8917                rule,
8918                line,
8919                semantic,
8920                &binding_types,
8921                &foreign_schemas,
8922                diagnostics,
8923            );
8924            continue;
8925        }
8926
8927        let active_afters = after_scopes(&block_stack);
8928        validate_binding_uses(rule, line, &seen_bindings, &active_afters, diagnostics);
8929        validate_known_field_paths_scoped(
8930            rule,
8931            line,
8932            semantic,
8933            &binding_types,
8934            &foreign_schemas,
8935            diagnostics,
8936        );
8937
8938        if let Some(binding) = parse_consume_line(line) {
8939            match binding_types.get(&binding) {
8940                Some(schema) => metadata.fact_consumes.push(format!("schema:{schema}")),
8941                None => diagnostics.push(Diagnostic {
8942                    related: Vec::new(),
8943                    span: rule.body.span,
8944                    message: format!(
8945                        "rule `{}` consumes unknown fact binding `{binding}`",
8946                        rule.name.name
8947                    ),
8948                    suggestion: Some(
8949                        "consume a binding introduced by a `when Class as binding` clause"
8950                            .to_owned(),
8951                    ),
8952                }),
8953            }
8954            if !line.contains("->") {
8955                continue;
8956            }
8957        }
8958
8959        if line.starts_with("after ") {
8960            if let Some(alias) = binding_after_as(line) {
8961                validate_binding_name(rule, &alias, rule.body.span, diagnostics);
8962            }
8963            match parse_after_line(line) {
8964                Some((binding, predicate)) => {
8965                    if !seen_bindings.contains(&binding) {
8966                        let suggestion = if misplaced_effect_bindings.contains(&binding) {
8967                            format!(
8968                                "move `as {binding}` onto the effect line before the multiline string"
8969                            )
8970                        } else {
8971                            format!("create an effect with `as {binding}` before the `after` block")
8972                        };
8973                        diagnostics.push(Diagnostic { related: Vec::new(),
8974                            span: rule.body.span,
8975                            message: format!(
8976                                "rule `{}` has `after` block for unknown effect binding `{binding}`",
8977                                rule.name.name
8978                            ),
8979                            suggestion: Some(suggestion),
8980                        });
8981                    }
8982                    block_stack.push(BlockFrame::After { binding, predicate });
8983                }
8984                None => {
8985                    diagnostics.push(Diagnostic { related: Vec::new(),
8986                        span: rule.body.span,
8987                        message: format!(
8988                            "rule `{}` has unsupported `after` dependency predicate",
8989                            rule.name.name
8990                        ),
8991                        suggestion: Some(
8992                            "use `after name succeeds`, `after name fails`, `after name completes`, `after name times out`, or `after name cancelled`"
8993                                .to_owned(),
8994                        ),
8995                    });
8996                }
8997            }
8998            continue;
8999        }
9000
9001        if let Some((schema, _)) = parse_record_start(line) {
9002            if is_observer_only_schema(&schema) {
9003                diagnostics.push(Diagnostic {
9004                    related: Vec::new(),
9005                    span: rule.body.span,
9006                    message: format!(
9007                        "rule `{}` cannot record kernel-owned terminal schema `{schema}`",
9008                        rule.name.name
9009                    ),
9010                    suggestion: Some(
9011                        "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`"
9012                            .to_owned(),
9013                    ),
9014                });
9015            } else if !semantic.schemas.class_exists(&schema) {
9016                diagnostics.push(Diagnostic {
9017                    related: Vec::new(),
9018                    span: rule.body.span,
9019                    message: format!("rule `{}` records unknown class `{schema}`", rule.name.name),
9020                    suggestion: Some(format!("declare `class {schema}` before recording it")),
9021                });
9022            }
9023            metadata.fact_writes.push(format!("schema:{schema}"));
9024            record_depth = brace_delta(line).max(1);
9025            continue;
9026        }
9027
9028        if let Some((kind, binding)) = parse_effect_line(line) {
9029            validate_agent_tell_target(
9030                rule,
9031                line,
9032                &kind,
9033                semantic,
9034                &binding_types,
9035                &known_roots,
9036                diagnostics,
9037            );
9038            anonymous_effects += 1;
9039            let id = binding
9040                .clone()
9041                .unwrap_or_else(|| format!("effect{anonymous_effects}"));
9042            if let Some(binding) = &binding {
9043                validate_binding_name(rule, binding, rule.body.span, diagnostics);
9044                seen_bindings.insert(binding.clone());
9045                if let Some(schema) = effect_binding_schema(line, &kind, semantic) {
9046                    binding_types.insert(binding.clone(), schema);
9047                }
9048            }
9049            for (upstream, predicate) in after_scopes(&block_stack) {
9050                metadata.dependencies.push(IrEffectDependency {
9051                    upstream,
9052                    predicate,
9053                    downstream: id.clone(),
9054                });
9055            }
9056            let idempotency_key = effect_idempotency_key(&rule.name.name, &id, &kind, &binding);
9057            metadata.effects.push(IrEffectNode {
9058                id,
9059                kind,
9060                binding,
9061                required_capabilities: parse_required_capabilities(line),
9062                construct_use: None,
9063                idempotency_key,
9064                span: rule.body.span,
9065                timeout_seconds: None,
9066                // The line-scanner result is overwritten by collect_effects_from_ast
9067                // below (which carries the real grants); empty here is fine.
9068                access_grants: Vec::new(),
9069                turn_skills: Vec::new(),
9070                on_stream: None,
9071                selection_source: None,
9072                transport_onto: None,
9073                resource: None,
9074                agent: None,
9075                coerce_target: None,
9076                workflow_target: None,
9077                endorsed: false,
9078                declassified: false,
9079                selected_by: None,
9080                exec_target: None,
9081            });
9082        }
9083    }
9084
9085    let (ast_effects, ast_dependencies) =
9086        collect_effects_from_ast(&body_ast.statements, &rule.name.name);
9087    metadata.effects = ast_effects;
9088    metadata.dependencies = ast_dependencies;
9089
9090    // `exec ... -> each Schema` produces one `Schema` fact per stream element
9091    // (spec/json-ingestion.md) — a fact write for liveness and effect-graph
9092    // analysis, like `record`.
9093    push_ingest_fact_writes(&body_ast.statements, &mut metadata.fact_writes);
9094
9095    metadata.fact_reads.sort();
9096    metadata.fact_reads.dedup();
9097    sort_projection_reads(&mut metadata.projection_reads);
9098    metadata.fact_writes.sort();
9099    metadata.fact_writes.dedup();
9100    metadata.fact_consumes.sort();
9101    metadata.fact_consumes.dedup();
9102    metadata.terminal_outputs = terminal_metadata.outputs;
9103    metadata.terminal_branches = terminal_metadata.branches;
9104    // DR-0044 Q5 / P1: an after-arm `case … where <guard>` guard query observes
9105    // live fact state at continuation time — the same firing-decision implicit
9106    // flow as a `when`-guard query (the IFC checker reads `projection_reads` to
9107    // taint guard-gated egresses). Fold both case families' arm guards into
9108    // `projection_reads` so the analysis sees them; the `when`-guard queries were
9109    // added above.
9110    for branch in &metadata.case_branches {
9111        if let Some(guard) = &branch.guard {
9112            metadata
9113                .projection_reads
9114                .extend(collect_projection_reads(&guard.expr));
9115        }
9116    }
9117    for branch in &metadata.terminal_branches {
9118        if let Some(guard) = &branch.guard {
9119            metadata
9120                .projection_reads
9121                .extend(collect_projection_reads(&guard.expr));
9122        }
9123    }
9124    sort_projection_reads(&mut metadata.projection_reads);
9125    // DR-0043 Decision 7 obligation 2: the lapse arm is not in `rule.body.text`
9126    // (that is the HOLDS variant), so it is checked here, once, with the binding
9127    // environment the body loop just built.
9128    if let Some(region) = semantic.regions.get(&rule.name.name) {
9129        validate_lapse_arm(
9130            rule,
9131            region,
9132            semantic,
9133            &binding_types,
9134            &foreign_schemas,
9135            &effect_payload_types,
9136            diagnostics,
9137        );
9138    }
9139    collect_terminal_complete_bindings(&body_ast.statements, &mut metadata.terminal_completes);
9140    metadata.terminal_completes.sort();
9141    metadata.terminal_completes.dedup();
9142    collect_redaction_metadata(
9143        &body_ast.statements,
9144        &binding_types,
9145        &mut metadata.redactions,
9146    );
9147    collect_bounded_egresses(
9148        &body_ast.statements,
9149        &binding_types,
9150        &mut metadata.bounded_egresses,
9151    );
9152    let mut egress_reads = Vec::new();
9153    collect_egress_payload_reads(&body_ast.statements, &mut egress_reads);
9154    for (sink, roots) in egress_reads {
9155        metadata
9156            .egress_payload_reads
9157            .entry(sink)
9158            .or_default()
9159            .extend(roots);
9160    }
9161    collect_complete_field_reads(&body_ast.statements, &mut metadata.complete_field_reads);
9162    collect_record_field_reads(&body_ast.statements, &mut metadata.record_field_reads);
9163    collect_milestone_field_reads(&body_ast.statements, &mut metadata.milestone_field_reads);
9164    collect_crossing_roots(
9165        &body_ast.statements,
9166        &mut metadata.declassified_roots,
9167        &mut metadata.endorsed_roots,
9168        &mut metadata.endorsed_claim_items,
9169    );
9170    collect_provenance_metadata(
9171        &body_ast.statements,
9172        &mut metadata.coerce_input_roots,
9173        &mut metadata.after_aliases,
9174    );
9175    collect_egress_case_influence(
9176        &body_ast.statements,
9177        &mut Vec::new(),
9178        &mut metadata.egress_case_influence,
9179    );
9180    // Redaction closure over marked roots (redact ∘ marked-crossing): a
9181    // `redact <marked-output> keep […] as out` projection is still the
9182    // crossing's carrier — a redaction can only NARROW what the marked
9183    // coercion released, and the kept fields are additionally held to their
9184    // per-field schema labels by the redact refinement. Fixpoint so
9185    // redactions of redactions chain.
9186    loop {
9187        let mut changed = false;
9188        for redaction in &metadata.redactions {
9189            if metadata.declassified_roots.contains(&redaction.source)
9190                && metadata
9191                    .declassified_roots
9192                    .insert(redaction.binding.clone())
9193            {
9194                changed = true;
9195            }
9196            if metadata.endorsed_roots.contains(&redaction.source)
9197                && metadata.endorsed_roots.insert(redaction.binding.clone())
9198            {
9199                changed = true;
9200            }
9201        }
9202        if !changed {
9203            break;
9204        }
9205    }
9206    metadata
9207}
9208
9209/// Collect, per egress sink, the binding roots of every enclosing `case`
9210/// scrutinee (DR-0046 selector influence). The active-scrutinee stack is
9211/// threaded through nesting; each egress statement records the union of the
9212/// stack at its position, keyed exactly like `collect_egress_payload_reads`.
9213fn collect_egress_case_influence(
9214    statements: &[body::BodyStmt],
9215    active: &mut Vec<BTreeSet<String>>,
9216    out: &mut BTreeMap<String, BTreeSet<String>>,
9217) {
9218    let record_sink = |sink: String,
9219                       active: &[BTreeSet<String>],
9220                       out: &mut BTreeMap<String, BTreeSet<String>>| {
9221        if active.is_empty() {
9222            return;
9223        }
9224        let entry = out.entry(sink).or_default();
9225        for roots in active {
9226            entry.extend(roots.iter().cloned());
9227        }
9228    };
9229    for statement in statements {
9230        match statement {
9231            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
9232                record_sink(terminal.name.clone(), active, out);
9233            }
9234            body::BodyStmt::Record(record) => {
9235                record_sink(format!("fact:{}", record.schema), active, out);
9236            }
9237            body::BodyStmt::Done {
9238                replacement: Some(record),
9239                ..
9240            } => {
9241                record_sink(format!("fact:{}", record.schema), active, out);
9242            }
9243            body::BodyStmt::Milestone { name, .. } => {
9244                record_sink(format!("milestone:{name}"), active, out);
9245            }
9246            body::BodyStmt::Effect(effect) => match &effect.kind {
9247                body::BodyEffectKind::ConstructCapabilityCall {
9248                    keyword, fields, ..
9249                } if keyword == "send" => {
9250                    if let Some(channel) = fields
9251                        .iter()
9252                        .find(|field| field.name == "channel")
9253                        .map(|field| field.source.clone())
9254                    {
9255                        record_sink(channel, active, out);
9256                    }
9257                }
9258                body::BodyEffectKind::FileWrite { store, .. } => {
9259                    record_sink(store.clone(), active, out);
9260                }
9261                _ => {}
9262            },
9263            body::BodyStmt::After(after) => {
9264                collect_egress_case_influence(&after.body, active, out);
9265            }
9266            body::BodyStmt::Case(case) => {
9267                let mut roots = BTreeSet::new();
9268                if let Ok(expr) = parse_expression(&case.scrutinee) {
9269                    collect_expr_binding_roots(&expr, &mut roots);
9270                } else {
9271                    collect_template_binding_roots(&case.scrutinee, &mut roots);
9272                }
9273                active.push(roots);
9274                for branch in &case.branches {
9275                    collect_egress_case_influence(&branch.body, active, out);
9276                }
9277                active.pop();
9278            }
9279            _ => {}
9280        }
9281    }
9282}
9283
9284/// Collect the raw structure input-side provenance narrowing resolves over:
9285/// each coerce's argument-expression binding roots (template-scan fallback for
9286/// unparseable sources, same discipline as `send_payload_reads`), and the
9287/// `after … succeeds|completes as` alias map.
9288fn collect_provenance_metadata(
9289    statements: &[body::BodyStmt],
9290    coerce_input_roots: &mut BTreeMap<String, BTreeSet<String>>,
9291    after_aliases: &mut BTreeMap<String, String>,
9292) {
9293    for statement in statements {
9294        match statement {
9295            body::BodyStmt::Effect(effect) => {
9296                if let body::BodyEffectKind::Coerce { args, .. } = &effect.kind {
9297                    if let Some(binding) = &effect.binding {
9298                        let mut roots = BTreeSet::new();
9299                        for arg in args {
9300                            if let Ok(expr) = parse_expression(arg) {
9301                                collect_expr_binding_roots(&expr, &mut roots);
9302                            } else {
9303                                collect_template_binding_roots(arg, &mut roots);
9304                            }
9305                        }
9306                        coerce_input_roots
9307                            .entry(binding.clone())
9308                            .or_default()
9309                            .extend(roots);
9310                    }
9311                }
9312            }
9313            body::BodyStmt::After(after) => {
9314                if matches!(
9315                    after.predicate,
9316                    body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
9317                ) {
9318                    if let Some(alias) = &after.alias {
9319                        after_aliases.insert(alias.clone(), after.binding.clone());
9320                    }
9321                }
9322                collect_provenance_metadata(&after.body, coerce_input_roots, after_aliases);
9323            }
9324            body::BodyStmt::Case(case) => {
9325                for branch in &case.branches {
9326                    collect_provenance_metadata(&branch.body, coerce_input_roots, after_aliases);
9327                }
9328            }
9329            _ => {}
9330        }
9331    }
9332}
9333
9334/// Collect the output roots of marked crossings (`coerce … declassified` /
9335/// `coerce … endorsed`, DR-0027 I-IFC3): each marked coerce's binding, plus the
9336/// aliases its `after <binding> succeeds|completes as <alias>` branches bind —
9337/// the names an egress payload actually references. Two passes so an `after`
9338/// textually preceding nothing is impossible to miss; aliases of aliases cannot
9339/// occur (an `after` subject is always an effect binding).
9340fn collect_crossing_roots(
9341    statements: &[body::BodyStmt],
9342    declassified: &mut BTreeSet<String>,
9343    endorsed: &mut BTreeSet<String>,
9344    claim_items: &mut BTreeSet<String>,
9345) {
9346    fn collect_marked(
9347        statements: &[body::BodyStmt],
9348        declassified: &mut BTreeSet<String>,
9349        endorsed: &mut BTreeSet<String>,
9350        claim_items: &mut BTreeSet<String>,
9351    ) {
9352        for statement in statements {
9353            match statement {
9354                body::BodyStmt::Effect(effect) => {
9355                    if let body::BodyEffectKind::Coerce {
9356                        declassified: is_declassified,
9357                        endorsed: is_endorsed,
9358                        ..
9359                    } = &effect.kind
9360                    {
9361                        if let Some(binding) = &effect.binding {
9362                            if *is_declassified {
9363                                declassified.insert(binding.clone());
9364                            }
9365                            if *is_endorsed {
9366                                endorsed.insert(binding.clone());
9367                            }
9368                        }
9369                    }
9370                    // DR-0051 §2: an endorsed claim is a marked crossing of the
9371                    // same kind, so its output binding joins `endorsed_roots`
9372                    // and every downstream check — the narrowing, the grant
9373                    // consultation, NMIF-on-the-selector — applies unchanged.
9374                    if let body::BodyEffectKind::TrackerClaim {
9375                        endorsed: is_endorsed,
9376                        item,
9377                        ..
9378                    } = &effect.kind
9379                    {
9380                        if *is_endorsed {
9381                            // The crossed value is the *claimed item*, not the
9382                            // claim's `as` binding: `claim v as hold` binds a
9383                            // lease in `hold`, while the decision the program
9384                            // goes on to read lives in `v`. Marking the lease
9385                            // would mark a handle nothing reads.
9386                            endorsed.insert(item.clone());
9387                            claim_items.insert(item.clone());
9388                        }
9389                    }
9390                }
9391                body::BodyStmt::After(after) => {
9392                    collect_marked(&after.body, declassified, endorsed, claim_items)
9393                }
9394                body::BodyStmt::Case(case) => {
9395                    for branch in &case.branches {
9396                        collect_marked(&branch.body, declassified, endorsed, claim_items);
9397                    }
9398                }
9399                _ => {}
9400            }
9401        }
9402    }
9403    fn collect_aliases(
9404        statements: &[body::BodyStmt],
9405        declassified: &mut BTreeSet<String>,
9406        endorsed: &mut BTreeSet<String>,
9407    ) {
9408        for statement in statements {
9409            match statement {
9410                body::BodyStmt::After(after) => {
9411                    if matches!(
9412                        after.predicate,
9413                        body::AfterPredicate::Succeeds | body::AfterPredicate::Completes
9414                    ) {
9415                        if let Some(alias) = &after.alias {
9416                            if declassified.contains(&after.binding) {
9417                                declassified.insert(alias.clone());
9418                            }
9419                            if endorsed.contains(&after.binding) {
9420                                endorsed.insert(alias.clone());
9421                            }
9422                        }
9423                    }
9424                    collect_aliases(&after.body, declassified, endorsed);
9425                }
9426                body::BodyStmt::Case(case) => {
9427                    for branch in &case.branches {
9428                        collect_aliases(&branch.body, declassified, endorsed);
9429                    }
9430                }
9431                _ => {}
9432            }
9433        }
9434    }
9435    collect_marked(statements, declassified, endorsed, claim_items);
9436    // Aliases can nest under other afters, so run the alias pass to a fixpoint
9437    // over the (small) statement tree: one extra pass suffices in practice, but
9438    // loop until stable so deep nesting cannot order-skip an alias.
9439    loop {
9440        let before = (declassified.len(), endorsed.len());
9441        collect_aliases(statements, declassified, endorsed);
9442        if (declassified.len(), endorsed.len()) == before {
9443            break;
9444        }
9445    }
9446}
9447
9448/// For each `complete <binding> { field: <expr>, … }` egress in a rule body
9449/// (recursing into nested blocks), the binding roots EACH result field references,
9450/// as `binding -> field -> {roots}`. A `Shorthand` field (`complete result from src
9451/// { f }`) resolves to the terminal's `from` binding. Unlike
9452/// `collect_egress_payload_reads` (which joins a sink's fields), this keeps fields
9453/// separate so the IFC engine can compute a per-field flow signature (DR-0030 X2
9454/// v2). Union across branches (a field completed in two arms references the union).
9455fn collect_complete_field_reads(
9456    statements: &[body::BodyStmt],
9457    out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
9458) {
9459    for statement in statements {
9460        match statement {
9461            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
9462                let per_field = out.entry(terminal.name.clone()).or_default();
9463                for field in &terminal.fields {
9464                    let mut roots = BTreeSet::new();
9465                    match &field.value {
9466                        body::FieldValue::Shorthand => {
9467                            if let Some(root) = &terminal.from {
9468                                roots.insert(root.clone());
9469                            }
9470                        }
9471                        body::FieldValue::Expr { expr, .. } => {
9472                            collect_expr_binding_roots(expr, &mut roots)
9473                        }
9474                        body::FieldValue::Nested { fields, .. } => collect_payload_field_roots(
9475                            fields,
9476                            terminal.from.as_deref(),
9477                            &mut roots,
9478                        ),
9479                    }
9480                    per_field
9481                        .entry(field.name.clone())
9482                        .or_default()
9483                        .extend(roots);
9484                }
9485            }
9486            body::BodyStmt::After(after) => collect_complete_field_reads(&after.body, out),
9487            body::BodyStmt::Case(case) => {
9488                for branch in &case.branches {
9489                    collect_complete_field_reads(&branch.body, out);
9490                }
9491            }
9492            _ => {}
9493        }
9494    }
9495}
9496
9497/// For each `emit milestone "<name>" { field: <expr>, … }` egress in a rule body
9498/// (recursing into nested blocks), collect the binding roots EACH milestone field
9499/// references. This mirrors `collect_complete_field_reads`: the IFC checker uses it
9500/// to expose and gate a child-to-parent milestone payload with a per-field flow
9501/// signature (D3′).
9502fn collect_milestone_field_reads(
9503    statements: &[body::BodyStmt],
9504    out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
9505) {
9506    for statement in statements {
9507        match statement {
9508            body::BodyStmt::Milestone { name, fields, .. } => {
9509                let per_field = out.entry(name.clone()).or_default();
9510                for field in fields {
9511                    let mut roots = BTreeSet::new();
9512                    match &field.value {
9513                        body::FieldValue::Shorthand => {}
9514                        body::FieldValue::Expr { expr, .. } => {
9515                            collect_expr_binding_roots(expr, &mut roots)
9516                        }
9517                        body::FieldValue::Nested { fields, .. } => {
9518                            collect_payload_field_roots(fields, None, &mut roots)
9519                        }
9520                    }
9521                    per_field
9522                        .entry(field.name.clone())
9523                        .or_default()
9524                        .extend(roots);
9525                }
9526            }
9527            body::BodyStmt::After(after) => collect_milestone_field_reads(&after.body, out),
9528            body::BodyStmt::Case(case) => {
9529                for branch in &case.branches {
9530                    collect_milestone_field_reads(&branch.body, out);
9531                }
9532            }
9533            _ => {}
9534        }
9535    }
9536}
9537
9538/// Collects the `redact <source> keep [..] as <out>` projections of a rule body
9539/// (recursing into nested blocks) as IFC value-flow metadata, preserving body
9540/// order so a chained redaction's source resolves against the earlier projection.
9541/// `binding_types` (the rule's fully-resolved binding -> schema map, including
9542/// redaction outputs via their synthetic class) supplies each source's schema so
9543/// the IFC engine can derive the projection's per-field label.
9544fn collect_redaction_metadata(
9545    statements: &[body::BodyStmt],
9546    binding_types: &BTreeMap<String, String>,
9547    out: &mut Vec<IrRedaction>,
9548) {
9549    let mut redacts = Vec::new();
9550    collect_redact_effects(statements, &mut redacts);
9551    for (source, keep, binding, _span) in redacts {
9552        out.push(IrRedaction {
9553            source: source.to_owned(),
9554            keep: keep.to_vec(),
9555            binding: binding.to_owned(),
9556            source_schema: binding_types.get(source).cloned(),
9557        });
9558    }
9559}
9560
9561/// Collect the bounded-type projection egresses (`record <T> from <src>`) of a rule
9562/// body (recursing into nested blocks). A `record T from src` keeps exactly `T`'s
9563/// declared fields, copied from `src`, so the IFC engine can govern it by the kept
9564/// fields' per-field labels (sourced from `src`'s schema) — the "bounded-type"
9565/// auto-redaction reading. Only recorded when the source schema resolves and the
9566/// target type is declared; otherwise the egress stays conservative.
9567/// Records a bounded-type projection egress for a PURE `from` projection — a
9568/// `from <src>` egress every field of which is a shorthand copy of `src.<name>`.
9569/// The runtime materializes exactly these fields, so the kept set is their names,
9570/// governed by `src`'s schema per-field labels. `None` source schema, no `from`, or
9571/// any explicit value field → not a clean projection, so it stays conservative
9572/// (handled by the whole-read join). `sink` is the engine sink string
9573/// (`fact:<Schema>` for a record, the completed binding for a `complete`).
9574fn push_bounded_projection(
9575    from: Option<&str>,
9576    fields: &[body::FieldAssign],
9577    sink: String,
9578    binding_types: &BTreeMap<String, String>,
9579    out: &mut Vec<IrBoundedEgress>,
9580) {
9581    let Some(source_schema) = from.and_then(|src| binding_types.get(src)) else {
9582        return;
9583    };
9584    if fields.is_empty()
9585        || !fields
9586            .iter()
9587            .all(|field| matches!(field.value, body::FieldValue::Shorthand))
9588    {
9589        return;
9590    }
9591    out.push(IrBoundedEgress {
9592        sink,
9593        source_schema: source_schema.clone(),
9594        keep: fields.iter().map(|field| field.name.clone()).collect(),
9595    });
9596}
9597
9598fn push_bounded_record(
9599    record: &body::RecordStmt,
9600    binding_types: &BTreeMap<String, String>,
9601    out: &mut Vec<IrBoundedEgress>,
9602) {
9603    push_bounded_projection(
9604        record.from.as_deref(),
9605        &record.fields,
9606        format!("fact:{}", record.schema),
9607        binding_types,
9608        out,
9609    );
9610}
9611
9612fn collect_bounded_egresses(
9613    statements: &[body::BodyStmt],
9614    binding_types: &BTreeMap<String, String>,
9615    out: &mut Vec<IrBoundedEgress>,
9616) {
9617    for statement in statements {
9618        match statement {
9619            body::BodyStmt::Record(record) => push_bounded_record(record, binding_types, out),
9620            body::BodyStmt::Done {
9621                replacement: Some(record),
9622                ..
9623            } => push_bounded_record(record, binding_types, out),
9624            // `complete <T> from <src> { … }`: bounded-type projection to the invoker.
9625            // The engine sink for a complete is the completed binding (its name).
9626            body::BodyStmt::Terminal(terminal)
9627                if terminal.kind == body::TerminalKind::Complete && terminal.from.is_some() =>
9628            {
9629                push_bounded_projection(
9630                    terminal.from.as_deref(),
9631                    &terminal.fields,
9632                    terminal.name.clone(),
9633                    binding_types,
9634                    out,
9635                );
9636            }
9637            body::BodyStmt::After(after) => {
9638                collect_bounded_egresses(&after.body, binding_types, out)
9639            }
9640            body::BodyStmt::Case(case) => {
9641                for branch in &case.branches {
9642                    collect_bounded_egresses(&branch.body, binding_types, out);
9643                }
9644            }
9645            _ => {}
9646        }
9647    }
9648}
9649
9650/// Collect EVERY binding root referenced by an expression, for the information-flow
9651/// value-flow engine. SOUNDNESS: a missed reference under-approximates a payload's
9652/// sources — so this over-collects (an over-collected name that is not a relevant
9653/// binding contributes nothing downstream). It walks every `Expr` variant and, for
9654/// string literals, extracts `{{ … }}` interpolation roots (those refs live as raw
9655/// text inside the literal, not as structured nodes). A bare identifier parses as
9656/// `Literal(Ident)`, a dotted ref as `Path` — both are roots.
9657fn collect_expr_binding_roots(expr: &Expr, out: &mut BTreeSet<String>) {
9658    match expr {
9659        Expr::Literal(ExprLiteral::String(text)) => collect_template_binding_roots(text, out),
9660        Expr::Literal(ExprLiteral::Ident(name)) => {
9661            out.insert(name.clone());
9662        }
9663        Expr::Literal(ExprLiteral::Number(_) | ExprLiteral::Bool(_) | ExprLiteral::Null) => {}
9664        Expr::Path(segments) => {
9665            if let Some(root) = segments.first() {
9666                out.insert(root.clone());
9667            }
9668        }
9669        Expr::Index { target, key } => {
9670            collect_expr_binding_roots(target, out);
9671            collect_expr_binding_roots(key, out);
9672        }
9673        Expr::Array(items) => {
9674            for item in items {
9675                collect_expr_binding_roots(item, out);
9676            }
9677        }
9678        Expr::Object(fields) => {
9679            for field in fields {
9680                collect_expr_binding_roots(&field.value, out);
9681            }
9682        }
9683        Expr::Unary { expr, .. } => collect_expr_binding_roots(expr, out),
9684        Expr::Binary { left, right, .. } => {
9685            collect_expr_binding_roots(left, out);
9686            collect_expr_binding_roots(right, out);
9687        }
9688        Expr::Call { args, .. } => {
9689            for arg in args {
9690                collect_expr_binding_roots(arg, out);
9691            }
9692        }
9693        Expr::Query { head, guard, .. } => {
9694            out.insert(head.clone());
9695            if let Some(guard) = guard {
9696                collect_expr_binding_roots(guard, out);
9697            }
9698        }
9699    }
9700}
9701
9702/// Collect every binding root inside `{{ … }}` interpolations of a string. Unlike
9703/// `interpolation_roots` (first root per interpolation), value-flow needs EVERY
9704/// root, so `{{ a.b + c.d }}` yields both `a` and `c`. Each interpolation body is
9705/// parsed and walked; an unparseable body falls back to a conservative identifier
9706/// scan (over-collection is sound).
9707fn collect_template_binding_roots(text: &str, out: &mut BTreeSet<String>) {
9708    let mut rest = text;
9709    while let Some(open) = rest.find("{{") {
9710        let after_open = &rest[open + 2..];
9711        let Some(close) = after_open.find("}}") else {
9712            break;
9713        };
9714        let body = after_open[..close].trim();
9715        if let Ok(expr) = parse_expression(body) {
9716            collect_expr_binding_roots(&expr, out);
9717        } else {
9718            for token in body.split(|ch: char| !ch.is_alphanumeric() && ch != '_') {
9719                if token
9720                    .as_bytes()
9721                    .first()
9722                    .is_some_and(|byte| is_ident_start(*byte))
9723                {
9724                    out.insert(token.to_owned());
9725                }
9726            }
9727        }
9728        rest = &after_open[close + 2..];
9729    }
9730}
9731
9732/// Collect the binding roots a payload field list references, threading the
9733/// enclosing `from <binding>` source so a `Shorthand` field resolves to it.
9734fn collect_payload_field_roots(
9735    fields: &[body::FieldAssign],
9736    from_binding: Option<&str>,
9737    out: &mut BTreeSet<String>,
9738) {
9739    for field in fields {
9740        match &field.value {
9741            body::FieldValue::Shorthand => {
9742                if let Some(root) = from_binding {
9743                    out.insert(root.to_owned());
9744                }
9745            }
9746            body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, out),
9747            body::FieldValue::Nested { fields, .. } => {
9748                collect_payload_field_roots(fields, from_binding, out)
9749            }
9750        }
9751    }
9752}
9753
9754/// For each egress sink in a rule body (recursing into nested blocks), the set of
9755/// binding roots its payload references, keyed by the sink string the IFC engine
9756/// uses: a `complete <binding>` by its binding, a `record <Schema>` by
9757/// `fact:<Schema>`. Surfaced so the engine can recognize a FULLY-REDACTED egress —
9758/// one whose payload references only redaction outputs (and constants) — and
9759/// govern it by the projection's per-field label instead of the rule's whole read
9760/// set. A `record <Schema> from <binding>` references that `from` binding too (its
9761/// fields are copied). A sink with no recorded entry references nothing resolvable.
9762fn collect_egress_payload_reads(
9763    statements: &[body::BodyStmt],
9764    out: &mut Vec<(String, BTreeSet<String>)>,
9765) {
9766    for statement in statements {
9767        match statement {
9768            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
9769                let mut roots = BTreeSet::new();
9770                collect_payload_field_roots(&terminal.fields, None, &mut roots);
9771                // A bare scalar payload's value expression is the whole egress
9772                // value; its binding roots must join the sink's label (fail-closed
9773                // — otherwise `complete result secret.value` would under-report).
9774                if let Some(body::FieldValue::Expr { expr, .. }) = &terminal.scalar {
9775                    collect_expr_binding_roots(expr, &mut roots);
9776                }
9777                out.push((terminal.name.clone(), roots));
9778            }
9779            body::BodyStmt::Record(record) => out.push(record_payload_reads(record)),
9780            // `done <b> -> record <Schema> { … }` is also a record egress.
9781            body::BodyStmt::Done {
9782                replacement: Some(record),
9783                ..
9784            } => out.push(record_payload_reads(record)),
9785            body::BodyStmt::Milestone { name, fields, .. } => {
9786                let mut roots = BTreeSet::new();
9787                collect_payload_field_roots(fields, None, &mut roots);
9788                out.push((format!("milestone:{name}"), roots));
9789            }
9790            // `send via <channel> { text … }` egresses to the channel; its payload
9791            // fields (text/markdown/thread_id) are construct-use source text. Keyed by
9792            // the channel (the engine's send sink, per `resource_for_body`). A
9793            // `write … to <store>` is likewise an egress to the store: its body
9794            // AND path expressions are the payload (a path can encode data too),
9795            // keyed by the store handle.
9796            body::BodyStmt::Effect(effect) => match &effect.kind {
9797                body::BodyEffectKind::ConstructCapabilityCall {
9798                    keyword, fields, ..
9799                } if keyword == "send" => {
9800                    if let Some(reads) = send_payload_reads(fields) {
9801                        out.push(reads);
9802                    }
9803                }
9804                body::BodyEffectKind::FileWrite {
9805                    store, path, body, ..
9806                } => {
9807                    let mut roots = BTreeSet::new();
9808                    for source in [path, body] {
9809                        if let Ok(expr) = parse_expression(source) {
9810                            collect_expr_binding_roots(&expr, &mut roots);
9811                        } else {
9812                            collect_template_binding_roots(source, &mut roots);
9813                        }
9814                    }
9815                    out.push((store.clone(), roots));
9816                }
9817                _ => {}
9818            },
9819            body::BodyStmt::After(after) => collect_egress_payload_reads(&after.body, out),
9820            body::BodyStmt::Case(case) => {
9821                for branch in &case.branches {
9822                    collect_egress_payload_reads(&branch.body, out);
9823                }
9824            }
9825            _ => {}
9826        }
9827    }
9828}
9829
9830/// The channel sink key and the binding roots a `send` payload references. The
9831/// payload fields (`text`/`markdown`/`thread_id`) carry expression SOURCE TEXT, so
9832/// each is parsed and walked (a string literal's `{{ … }}` interpolations count);
9833/// the `channel` field names the sink. `None` if no channel is present.
9834fn send_payload_reads(fields: &[body::ConstructUseField]) -> Option<(String, BTreeSet<String>)> {
9835    let channel = fields
9836        .iter()
9837        .find(|field| field.name == "channel")
9838        .map(|field| field.source.clone())?;
9839    let mut roots = BTreeSet::new();
9840    for field in fields.iter().filter(|field| field.name != "channel") {
9841        if let Ok(expr) = parse_expression(&field.source) {
9842            collect_expr_binding_roots(&expr, &mut roots);
9843        } else {
9844            // Unparseable source: scan its interpolations conservatively.
9845            collect_template_binding_roots(&field.source, &mut roots);
9846        }
9847    }
9848    Some((channel, roots))
9849}
9850
9851/// The `fact:<Schema>` sink key and the binding roots a `record` payload
9852/// references — its explicit field values plus, for `record <S> from <b>`, the
9853/// copied-from binding `b`.
9854/// DR-0051 §4: per-field binding roots for every `record <Schema> { … }` in a
9855/// rule body, recursing into nested blocks. Mirrors
9856/// `collect_complete_field_reads`; see `record_field_reads`.
9857fn collect_record_field_reads(
9858    statements: &[body::BodyStmt],
9859    out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
9860) {
9861    fn record_fields(
9862        record: &body::RecordStmt,
9863        out: &mut BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
9864    ) {
9865        let per_field = out.entry(format!("fact:{}", record.schema)).or_default();
9866        for field in &record.fields {
9867            let mut roots = BTreeSet::new();
9868            match &field.value {
9869                body::FieldValue::Shorthand => {
9870                    if let Some(root) = &record.from {
9871                        roots.insert(root.clone());
9872                    }
9873                }
9874                body::FieldValue::Expr { expr, .. } => collect_expr_binding_roots(expr, &mut roots),
9875                body::FieldValue::Nested { fields, .. } => {
9876                    collect_payload_field_roots(fields, record.from.as_deref(), &mut roots)
9877                }
9878            }
9879            per_field
9880                .entry(field.name.clone())
9881                .or_default()
9882                .extend(roots);
9883        }
9884    }
9885    for statement in statements {
9886        match statement {
9887            body::BodyStmt::Record(record) => record_fields(record, out),
9888            body::BodyStmt::Done {
9889                replacement: Some(record),
9890                ..
9891            } => record_fields(record, out),
9892            body::BodyStmt::After(after) => collect_record_field_reads(&after.body, out),
9893            body::BodyStmt::Case(case) => {
9894                for branch in &case.branches {
9895                    collect_record_field_reads(&branch.body, out);
9896                }
9897            }
9898            _ => {}
9899        }
9900    }
9901}
9902
9903fn record_payload_reads(record: &body::RecordStmt) -> (String, BTreeSet<String>) {
9904    let mut roots = BTreeSet::new();
9905    if let Some(from) = &record.from {
9906        roots.insert(from.clone());
9907    }
9908    collect_payload_field_roots(&record.fields, record.from.as_deref(), &mut roots);
9909    (format!("fact:{}", record.schema), roots)
9910}
9911
9912#[derive(Clone, Debug, Default)]
9913struct TerminalMetadata {
9914    outputs: Vec<IrTerminalOutput>,
9915    branches: Vec<IrTerminalCaseBranch>,
9916}
9917
9918#[derive(Clone, Debug)]
9919struct TerminalBranchSource {
9920    scrutinee: String,
9921    pattern: String,
9922    guard: Option<String>,
9923    body: String,
9924    pattern_span: SourceSpan,
9925}
9926
9927#[derive(Clone, Debug)]
9928struct RuleCaseBranchSource {
9929    scrutinee: String,
9930    scrutinee_type: TypeSyntax,
9931    pattern: String,
9932    guard: Option<String>,
9933    body: String,
9934    pattern_span: SourceSpan,
9935}
9936
9937fn collect_effect_payload_types(
9938    rule: &RuleDecl,
9939    semantic: &SemanticContext,
9940    diagnostics: &mut Vec<Diagnostic>,
9941) -> BTreeMap<String, IrType> {
9942    let mut payloads = BTreeMap::new();
9943    for statement in effect_payload_statements(&rule.body.text) {
9944        let line = statement.trim();
9945        let Some((kind, Some(binding))) = parse_effect_line(line) else {
9946            continue;
9947        };
9948        let payload = terminal_completed_payload_type(line, &kind, semantic);
9949        // A binding name keys the per-rule payload map, so reusing it for two effects
9950        // with DIFFERENT result types makes `after <binding> …` ambiguous (§5.5).
9951        // Same-type reuse (and mutually-exclusive `case` arms, which never both run)
9952        // is harmless and left alone.
9953        match payloads.get(&binding) {
9954            Some(existing) if existing != &payload => {
9955                diagnostics.push(Diagnostic {
9956                    related: Vec::new(),
9957                    span: rule.body.span,
9958                    message: format!(
9959                        "rule `{}` reuses effect binding `{binding}` for effects with conflicting result types",
9960                        rule.name.name
9961                    ),
9962                    suggestion: Some(format!(
9963                        "give each effect a distinct binding — `as {binding}` is reused with a different result type, so `after {binding} …` is ambiguous"
9964                    )),
9965                });
9966            }
9967            Some(_) => {}
9968            None => {
9969                payloads.insert(binding, payload);
9970            }
9971        }
9972    }
9973
9974    payloads
9975}
9976
9977fn terminal_completed_payload_type(
9978    line: &str,
9979    kind: &IrEffectKind,
9980    semantic: &SemanticContext,
9981) -> IrType {
9982    match kind {
9983        IrEffectKind::SchemaCoerce if line.starts_with("prompt ") => {
9984            IrType::Primitive(IrPrimitiveType::String)
9985        }
9986        IrEffectKind::SchemaCoerce => parse_coerce_call_name(line)
9987            .and_then(|name| semantic.coerce_outputs.get(name))
9988            .cloned()
9989            .map(lower_type)
9990            .unwrap_or_else(terminal_unknown_payload_type),
9991        IrEffectKind::AgentTell => IrType::Ref("AgentTurn".to_owned()),
9992        IrEffectKind::CapabilityCall
9993        | IrEffectKind::EventEmit
9994        | IrEffectKind::WorkflowInvoke
9995        | IrEffectKind::TimerWait
9996        | IrEffectKind::ExecCommand
9997        | IrEffectKind::TrackerFile
9998        | IrEffectKind::TrackerClaim
9999        | IrEffectKind::TrackerRenew
10000        | IrEffectKind::TrackerRelease
10001        | IrEffectKind::TrackerFinish
10002        | IrEffectKind::LeaseAcquire
10003        | IrEffectKind::LeaseRenew
10004        | IrEffectKind::LedgerAppend
10005        | IrEffectKind::CounterConsume
10006        | IrEffectKind::SignalEmit
10007        | IrEffectKind::FileRead
10008        | IrEffectKind::FileWrite
10009        | IrEffectKind::FileImport
10010        | IrEffectKind::FileExport => terminal_unknown_payload_type(),
10011    }
10012}
10013
10014fn collect_rule_case_metadata(
10015    rule: &RuleDecl,
10016    semantic: &SemanticContext,
10017    binding_types: &BTreeMap<String, String>,
10018    diagnostics: &mut Vec<Diagnostic>,
10019) -> Vec<IrRuleCaseBranch> {
10020    let mut branches = Vec::new();
10021    for branch in rule_case_branch_sources(rule, semantic, binding_types) {
10022        let mut branch_scope = binding_types.clone();
10023        if let Some((binding, schema)) =
10024            case_branch_payload_binding(&branch.pattern, &branch.scrutinee_type, semantic)
10025        {
10026            branch_scope.insert(binding, schema);
10027        }
10028        if let Some(guard) = &branch.guard {
10029            validate_expression(
10030                rule,
10031                guard,
10032                semantic,
10033                &branch_scope,
10034                "case guard",
10035                diagnostics,
10036            );
10037            validate_known_field_paths_at_span(
10038                rule,
10039                guard,
10040                branch.pattern_span,
10041                semantic,
10042                &branch_scope,
10043                diagnostics,
10044            );
10045        }
10046        validate_known_field_paths_at_span(
10047            rule,
10048            &branch.body,
10049            branch.pattern_span,
10050            semantic,
10051            &branch_scope,
10052            diagnostics,
10053        );
10054        if let Some(pattern) = lower_case_pattern(&branch.pattern, &branch.scrutinee_type, semantic)
10055        {
10056            branches.push(IrRuleCaseBranch {
10057                scrutinee: branch.scrutinee,
10058                scrutinee_type: lower_type(branch.scrutinee_type),
10059                pattern,
10060                guard: branch.guard.as_ref().and_then(|guard| {
10061                    lower_expression(
10062                        guard,
10063                        SourceSpan {
10064                            start: branch.pattern_span.start,
10065                            end: branch.pattern_span.end,
10066                        },
10067                    )
10068                }),
10069                body_hash: stable_hash(&branch.body),
10070                pattern_span: branch.pattern_span,
10071            });
10072        }
10073    }
10074    branches.sort_by(|left, right| {
10075        (left.scrutinee.as_str(), left.pattern_span.start)
10076            .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
10077    });
10078    branches
10079}
10080
10081fn rule_case_branch_sources(
10082    rule: &RuleDecl,
10083    semantic: &SemanticContext,
10084    binding_types: &BTreeMap<String, String>,
10085) -> Vec<RuleCaseBranchSource> {
10086    let lines = rule
10087        .body
10088        .text
10089        .lines()
10090        .scan(0usize, |offset, line| {
10091            let current = *offset;
10092            *offset += line.len() + 1;
10093            Some((line, current))
10094        })
10095        .collect::<Vec<_>>();
10096    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
10097    let mut branches = Vec::new();
10098    let mut index = 0usize;
10099    while index < lines.len() {
10100        let (line, _) = lines[index];
10101        let trimmed = line.trim();
10102        let Some(scrutinee) = case_scrutinee(trimmed) else {
10103            index += 1;
10104            continue;
10105        };
10106        if active_completes_binding_for_case(&text_lines, index, scrutinee) {
10107            index += 1;
10108            continue;
10109        }
10110        let Some(scrutinee_type) = expression_type(scrutinee, semantic, binding_types) else {
10111            index += 1;
10112            continue;
10113        };
10114        let mut depth = brace_delta(trimmed).max(1);
10115        index += 1;
10116        while index < lines.len() && depth > 0 {
10117            let (branch_line, branch_line_offset) = lines[index];
10118            let branch_trimmed = branch_line.trim();
10119            if depth == 1 {
10120                if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
10121                    let pattern_column = case_pattern_column(branch_line, pattern);
10122                    let pattern_span = SourceSpan {
10123                        start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
10124                        end: rule_body_text_start(rule)
10125                            + branch_line_offset
10126                            + pattern_column
10127                            + pattern.len(),
10128                    };
10129                    let mut body_lines = Vec::new();
10130                    let mut branch_depth = brace_delta(body_start).max(1);
10131                    index += 1;
10132                    while index < lines.len() && branch_depth > 0 {
10133                        let body_line = lines[index].0;
10134                        let next_depth = branch_depth + brace_delta(body_line);
10135                        if next_depth >= 1 {
10136                            body_lines.push(body_line.to_owned());
10137                        }
10138                        branch_depth = next_depth;
10139                        index += 1;
10140                    }
10141                    branches.push(RuleCaseBranchSource {
10142                        scrutinee: scrutinee.to_owned(),
10143                        scrutinee_type: scrutinee_type.clone(),
10144                        pattern: pattern.to_owned(),
10145                        guard,
10146                        body: body_lines.join("\n"),
10147                        pattern_span,
10148                    });
10149                    continue;
10150                }
10151            }
10152            depth += brace_delta(branch_trimmed);
10153            index += 1;
10154        }
10155    }
10156    branches
10157}
10158
10159fn case_branch_payload_binding(
10160    pattern: &str,
10161    scrutinee_type: &TypeSyntax,
10162    semantic: &SemanticContext,
10163) -> Option<(String, String)> {
10164    // Sum types: `Variant as b` binds the payload typed as the generated
10165    // `<Enum>.<Variant>` class (spec/sum-types.md).
10166    if let TypeSyntax::Ref { name } = scrutinee_type {
10167        if semantic.schemas.enums.contains_key(&name.name) {
10168            let (variant, binding) = sum_case_pattern_parts(pattern);
10169            let binding = binding?;
10170            let generated = format!("{}.{variant}", name.name);
10171            if binding.is_empty() || !semantic.schemas.class_exists(&generated) {
10172                return None;
10173            }
10174            return Some((binding.to_owned(), generated));
10175        }
10176    }
10177    let binding = pattern.strip_prefix("Some ").map(str::trim)?;
10178    if binding.is_empty() {
10179        return None;
10180    }
10181    let TypeSyntax::Optional { inner, .. } = scrutinee_type else {
10182        return None;
10183    };
10184    let schema = match inner.as_ref() {
10185        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
10186            Some(name.name.clone())
10187        }
10188        _ => None,
10189    }?;
10190    Some((binding.to_owned(), schema))
10191}
10192
10193fn collect_terminal_case_metadata(
10194    rule: &RuleDecl,
10195    semantic: &SemanticContext,
10196    binding_types: &BTreeMap<String, String>,
10197    effect_payload_types: &BTreeMap<String, IrType>,
10198    diagnostics: &mut Vec<Diagnostic>,
10199) -> TerminalMetadata {
10200    let mut metadata = TerminalMetadata::default();
10201    let mut output_bindings = BTreeSet::new();
10202
10203    for branch in terminal_case_branch_sources(rule) {
10204        if output_bindings.insert(branch.scrutinee.clone()) {
10205            let completed_payload = effect_payload_types
10206                .get(&branch.scrutinee)
10207                .cloned()
10208                .unwrap_or_else(terminal_unknown_payload_type);
10209            metadata.outputs.push(IrTerminalOutput {
10210                binding: branch.scrutinee.clone(),
10211                alternatives: terminal_alternatives(completed_payload, branch.pattern_span),
10212                span: branch.pattern_span,
10213            });
10214        }
10215
10216        let (tag, binding) = parse_terminal_pattern_parts(&branch.pattern);
10217        let mut branch_scope = binding_types.clone();
10218        if let (Some(tag), Some(binding)) = (&tag, &binding) {
10219            if let Some(schema) =
10220                terminal_payload_schema_for_tag(tag, &branch.scrutinee, effect_payload_types)
10221            {
10222                branch_scope.insert(binding.clone(), schema);
10223            }
10224        }
10225        if let Some(guard) = &branch.guard {
10226            validate_expression(
10227                rule,
10228                guard,
10229                semantic,
10230                &branch_scope,
10231                "case guard",
10232                diagnostics,
10233            );
10234            validate_known_field_paths(rule, guard, semantic, &branch_scope, diagnostics);
10235        }
10236        validate_known_field_paths(rule, &branch.body, semantic, &branch_scope, diagnostics);
10237        metadata.branches.push(IrTerminalCaseBranch {
10238            scrutinee: branch.scrutinee,
10239            tag,
10240            binding,
10241            guard: branch.guard.as_ref().and_then(|guard| {
10242                lower_expression(
10243                    guard,
10244                    SourceSpan {
10245                        start: branch.pattern_span.start,
10246                        end: branch.pattern_span.end,
10247                    },
10248                )
10249            }),
10250            body_hash: stable_hash(&branch.body),
10251            pattern_span: branch.pattern_span,
10252        });
10253    }
10254
10255    metadata
10256        .outputs
10257        .sort_by(|left, right| left.binding.cmp(&right.binding));
10258    metadata.branches.sort_by(|left, right| {
10259        (left.scrutinee.as_str(), left.pattern_span.start)
10260            .cmp(&(right.scrutinee.as_str(), right.pattern_span.start))
10261    });
10262    metadata
10263}
10264
10265fn terminal_case_branch_sources(rule: &RuleDecl) -> Vec<TerminalBranchSource> {
10266    let lines = rule
10267        .body
10268        .text
10269        .lines()
10270        .scan(0usize, |offset, line| {
10271            let current = *offset;
10272            *offset += line.len() + 1;
10273            Some((line, current))
10274        })
10275        .collect::<Vec<_>>();
10276    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
10277    let mut branches = Vec::new();
10278    let mut index = 0usize;
10279    while index < lines.len() {
10280        let (line, line_offset) = lines[index];
10281        let trimmed = line.trim();
10282        let Some(scrutinee) = case_scrutinee(trimmed) else {
10283            index += 1;
10284            continue;
10285        };
10286        if !active_completes_binding_for_case(&text_lines, index, scrutinee) {
10287            index += 1;
10288            continue;
10289        }
10290        let mut depth = brace_delta(trimmed).max(1);
10291        index += 1;
10292        while index < lines.len() && depth > 0 {
10293            let (branch_line, branch_line_offset) = lines[index];
10294            let branch_trimmed = branch_line.trim();
10295            if depth == 1 {
10296                if let Some((pattern, guard, body_start)) = terminal_branch_header(branch_trimmed) {
10297                    let pattern_column = case_pattern_column(branch_line, pattern);
10298                    let pattern_span = SourceSpan {
10299                        start: rule_body_text_start(rule) + branch_line_offset + pattern_column,
10300                        end: rule_body_text_start(rule)
10301                            + branch_line_offset
10302                            + pattern_column
10303                            + pattern.len(),
10304                    };
10305                    let mut body_lines = Vec::new();
10306                    let mut branch_depth = brace_delta(body_start).max(1);
10307                    index += 1;
10308                    while index < lines.len() && branch_depth > 0 {
10309                        let body_line = lines[index].0;
10310                        let next_depth = branch_depth + brace_delta(body_line);
10311                        if next_depth >= 1 {
10312                            body_lines.push(body_line.to_owned());
10313                        }
10314                        branch_depth = next_depth;
10315                        index += 1;
10316                    }
10317                    branches.push(TerminalBranchSource {
10318                        scrutinee: scrutinee.to_owned(),
10319                        pattern: pattern.to_owned(),
10320                        guard,
10321                        body: body_lines.join("\n"),
10322                        pattern_span,
10323                    });
10324                    continue;
10325                }
10326            }
10327            depth += brace_delta(branch_trimmed);
10328            index += 1;
10329        }
10330        let _ = line_offset;
10331    }
10332    branches
10333}
10334
10335fn rule_body_text_start(rule: &RuleDecl) -> usize {
10336    rule.body.span.end.saturating_sub(2 + rule.body.text.len())
10337}
10338
10339fn terminal_branch_header(line: &str) -> Option<(&str, Option<String>, &str)> {
10340    let (head, body_start) = line.split_once("=>")?;
10341    let body_start = body_start.trim();
10342    if !body_start.starts_with('{') {
10343        return None;
10344    }
10345    let head = head.trim();
10346    let (pattern, guard) = match head.split_once(" where ") {
10347        Some((pattern, guard)) => (pattern.trim(), Some(guard.trim().to_owned())),
10348        None => (head, None),
10349    };
10350    Some((pattern, guard, body_start))
10351}
10352
10353fn case_pattern_column(line: &str, pattern: &str) -> usize {
10354    line.find(pattern).unwrap_or_else(|| {
10355        let indent = line.len().saturating_sub(line.trim_start().len());
10356        indent + line.trim_start().find(pattern).unwrap_or(0)
10357    })
10358}
10359
10360fn parse_terminal_pattern_parts(pattern: &str) -> (Option<String>, Option<String>) {
10361    if is_fallback_pattern(pattern) {
10362        return (None, None);
10363    }
10364    let mut parts = pattern.split_whitespace();
10365    let tag = parts.next().map(str::to_owned);
10366    // Binding is `Tag as binding` (Stage 1b: the space form `Tag binding` is gone).
10367    let second = parts.next();
10368    let binding = match second {
10369        Some("as") => parts.next().map(str::to_owned),
10370        Some(_) => return (tag, None),
10371        None => None,
10372    };
10373    if parts.next().is_some() {
10374        return (tag, None);
10375    }
10376    (tag, binding)
10377}
10378
10379fn terminal_payload_schema_for_tag(
10380    tag: &str,
10381    scrutinee: &str,
10382    effect_payload_types: &BTreeMap<String, IrType>,
10383) -> Option<String> {
10384    match tag {
10385        "Completed" => match effect_payload_types.get(scrutinee) {
10386            Some(IrType::Ref(schema)) => Some(schema.clone()),
10387            _ => None,
10388        },
10389        "Failed" => Some("TerminalFailed".to_owned()),
10390        "TimedOut" => Some("TerminalTimedOut".to_owned()),
10391        "Cancelled" => Some("TerminalCancelled".to_owned()),
10392        _ => None,
10393    }
10394}
10395
10396fn terminal_alternatives(
10397    completed_payload: IrType,
10398    span: SourceSpan,
10399) -> Vec<IrTerminalAlternative> {
10400    [
10401        ("Completed", completed_payload),
10402        ("Failed", terminal_failure_payload_type()),
10403        ("TimedOut", terminal_timeout_payload_type()),
10404        ("Cancelled", terminal_cancelled_payload_type()),
10405    ]
10406    .into_iter()
10407    .map(|(tag, payload_type)| IrTerminalAlternative {
10408        tag: tag.to_owned(),
10409        payload_type,
10410        source_span: span,
10411    })
10412    .collect()
10413}
10414
10415fn terminal_failure_payload_type() -> IrType {
10416    IrType::Object(vec![
10417        ir_field("reason", IrType::Primitive(IrPrimitiveType::String)),
10418        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
10419        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
10420        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
10421    ])
10422}
10423
10424fn terminal_timeout_payload_type() -> IrType {
10425    IrType::Object(vec![
10426        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
10427        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
10428        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
10429    ])
10430}
10431
10432fn terminal_cancelled_payload_type() -> IrType {
10433    IrType::Object(vec![
10434        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
10435        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
10436        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
10437    ])
10438}
10439
10440fn terminal_unknown_payload_type() -> IrType {
10441    IrType::Object(vec![
10442        ir_field("summary", IrType::Primitive(IrPrimitiveType::String)),
10443        ir_field("effect_id", IrType::Primitive(IrPrimitiveType::String)),
10444        ir_field("run_id", IrType::Primitive(IrPrimitiveType::String)),
10445    ])
10446}
10447
10448fn ir_field(name: &str, ty: IrType) -> IrClassField {
10449    IrClassField {
10450        name: name.to_owned(),
10451        ty,
10452        is_key: false,
10453        presence_condition: None,
10454        span: SourceSpan { start: 0, end: 0 },
10455    }
10456}
10457
10458/// Lower parsed access grants to IR for effects that carry authority-narrowing
10459/// metadata (`tell` turns and `invoke` start grants).
10460fn ir_access_grants_for_body(kind: &body::BodyEffectKind) -> Vec<IrAccessGrant> {
10461    match kind {
10462        body::BodyEffectKind::Tell { access_grants, .. }
10463        | body::BodyEffectKind::Invoke { access_grants, .. } => access_grants
10464            .iter()
10465            .map(|grant| IrAccessGrant {
10466                resource: grant.resource.clone(),
10467                operations: grant
10468                    .operations
10469                    .iter()
10470                    .map(|op| IrAccessGrantOp {
10471                        operation: op.operation.clone(),
10472                        target: op.target.clone(),
10473                        globs: op.globs.clone(),
10474                    })
10475                    .collect(),
10476            })
10477            .collect(),
10478        _ => Vec::new(),
10479    }
10480}
10481
10482fn ir_effect_kind_for_body(kind: &body::BodyEffectKind) -> IrEffectKind {
10483    match kind {
10484        body::BodyEffectKind::Tell { .. } => IrEffectKind::AgentTell,
10485        body::BodyEffectKind::Coerce { .. }
10486        | body::BodyEffectKind::Prompt { .. }
10487        | body::BodyEffectKind::Decide { .. } => IrEffectKind::SchemaCoerce,
10488        body::BodyEffectKind::Call { .. }
10489        | body::BodyEffectKind::ConstructCapabilityCall { .. } => IrEffectKind::CapabilityCall,
10490        body::BodyEffectKind::Invoke { .. } => IrEffectKind::WorkflowInvoke,
10491        body::BodyEffectKind::Timer { .. } => IrEffectKind::TimerWait,
10492        body::BodyEffectKind::Exec { .. } => IrEffectKind::ExecCommand,
10493        body::BodyEffectKind::TrackerFile { .. } => IrEffectKind::TrackerFile,
10494        body::BodyEffectKind::TrackerClaim { .. } => IrEffectKind::TrackerClaim,
10495        body::BodyEffectKind::TrackerRelease { .. } => IrEffectKind::TrackerRelease,
10496        body::BodyEffectKind::TrackerFinish { .. } => IrEffectKind::TrackerFinish,
10497        body::BodyEffectKind::LeaseAcquire { .. } => IrEffectKind::LeaseAcquire,
10498        body::BodyEffectKind::LeaseRenew { .. } => IrEffectKind::LeaseRenew,
10499        body::BodyEffectKind::LedgerAppend { .. } => IrEffectKind::LedgerAppend,
10500        body::BodyEffectKind::CounterConsume { .. } => IrEffectKind::CounterConsume,
10501        body::BodyEffectKind::Notify { .. } => IrEffectKind::SignalEmit,
10502        body::BodyEffectKind::FileRead { .. } => IrEffectKind::FileRead,
10503        body::BodyEffectKind::FileWrite { .. } => IrEffectKind::FileWrite,
10504        body::BodyEffectKind::FileImport { .. } => IrEffectKind::FileImport,
10505        body::BodyEffectKind::FileExport { .. } => IrEffectKind::FileExport,
10506    }
10507}
10508
10509/// The agent a `tell` addresses, surfaced for information-flow analysis of the
10510/// turn's egress to the agent's provider. `None` for non-`tell` effects.
10511fn agent_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10512    match kind {
10513        body::BodyEffectKind::Tell { target, .. } => Some(target.clone()),
10514        _ => None,
10515    }
10516}
10517
10518/// The `coerce` declaration a coerce effect invokes (DR-0062). An inline
10519/// `decide` names no declaration, so it yields `None` and falls back to the
10520/// un-named-backend principal.
10521fn coerce_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10522    match kind {
10523        body::BodyEffectKind::Coerce { name, .. } => Some(name.clone()),
10524        _ => None,
10525    }
10526}
10527
10528/// Turn-scoped `with skills [...]` pinned onto an `agent.tell` effect (Phase 7).
10529fn turn_skills_for_body(kind: &body::BodyEffectKind) -> Vec<String> {
10530    match kind {
10531        body::BodyEffectKind::Tell { skills, .. } => skills.clone(),
10532        _ => Vec::new(),
10533    }
10534}
10535
10536/// `on stream <name>` (std.vcs) carried on an `agent.tell` effect.
10537fn on_stream_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10538    match kind {
10539        body::BodyEffectKind::Tell { on_stream, .. } => on_stream.clone(),
10540        _ => None,
10541    }
10542}
10543
10544/// The std.vcs selective verbs' statically-checkable pieces (DR-0052
10545/// R4): the raw selection-slot source, and transport's `onto` target.
10546fn vcs_selective_for_body(kind: &body::BodyEffectKind) -> (Option<String>, Option<String>) {
10547    let body::BodyEffectKind::ConstructCapabilityCall {
10548        keyword, fields, ..
10549    } = kind
10550    else {
10551        return (None, None);
10552    };
10553    if keyword != "undo" && keyword != "transport" {
10554        return (None, None);
10555    }
10556    let field = |name: &str| {
10557        fields
10558            .iter()
10559            .find(|field| field.name == name)
10560            .map(|field| field.source.clone())
10561    };
10562    (field("selection"), field("onto"))
10563}
10564
10565/// The workflow an `invoke` targets, surfaced for IFC membrane-door enumeration.
10566fn workflow_target_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10567    match kind {
10568        body::BodyEffectKind::Invoke { workflow, .. } => Some(workflow.clone()),
10569        _ => None,
10570    }
10571}
10572
10573/// The `exec` surface form (raw command vs manifest capability), surfaced so
10574/// check-time gates classify exec effects without re-scanning rule-body text.
10575fn exec_target_for_body(kind: &body::BodyEffectKind) -> Option<IrExecTarget> {
10576    match kind {
10577        body::BodyEffectKind::Exec { target, .. } => Some(match target {
10578            body::ExecTarget::RawCommand(_) => IrExecTarget::Raw,
10579            body::ExecTarget::Capability { name, .. } => {
10580                IrExecTarget::Capability { name: name.clone() }
10581            }
10582        }),
10583        _ => None,
10584    }
10585}
10586
10587/// Whether an effect carries the `endorsed` source marker (I-IFC3) — a `coerce` the
10588/// author declared an integrity-raising crossing.
10589fn endorsed_for_body(kind: &body::BodyEffectKind) -> bool {
10590    matches!(kind, body::BodyEffectKind::Coerce { endorsed: true, .. })
10591}
10592
10593/// Whether an effect carries the `declassified` source marker (I-IFC3) — a `coerce`
10594/// the author declared a confidentiality-lowering crossing.
10595fn declassified_for_body(kind: &body::BodyEffectKind) -> bool {
10596    matches!(
10597        kind,
10598        body::BodyEffectKind::Coerce {
10599            declassified: true,
10600            ..
10601        }
10602    )
10603}
10604
10605/// The named resource a direct file/channel effect touches, surfaced for
10606/// information-flow analysis. `None` for effects with no named resource.
10607fn resource_for_body(kind: &body::BodyEffectKind) -> Option<String> {
10608    match kind {
10609        body::BodyEffectKind::FileRead { store, .. }
10610        | body::BodyEffectKind::FileWrite { store, .. }
10611        | body::BodyEffectKind::FileImport { store, .. }
10612        | body::BodyEffectKind::FileExport { store, .. } => Some(store.clone()),
10613        // `send via <channel>` carries the channel as a construct field.
10614        body::BodyEffectKind::ConstructCapabilityCall {
10615            keyword, fields, ..
10616        } if keyword == "send" => fields
10617            .iter()
10618            .find(|field| field.name == "channel")
10619            .map(|field| field.source.clone()),
10620        // `emit signal <name> to <peer>` touches the signal port `signal:<name>` (the
10621        // emit-port door, DR-0027 E6/H8); surfaced so the IFC checker can carry the
10622        // emitter's label to the receiver and enumerate the port in the surface.
10623        body::BodyEffectKind::Notify { event, .. } => Some(format!("signal:{event}")),
10624        // Coordination is governed as a resource label under E-COORD. The IFC
10625        // checker decides whether this declaration is partitioned or `shared`.
10626        body::BodyEffectKind::LeaseAcquire { resource, .. } => Some(format!("resource:{resource}")),
10627        body::BodyEffectKind::LedgerAppend { ledger, .. } => Some(format!("resource:{ledger}")),
10628        body::BodyEffectKind::CounterConsume { counter, .. } => Some(format!("resource:{counter}")),
10629        _ => None,
10630    }
10631}
10632
10633fn construct_use_for_body(kind: &body::BodyEffectKind) -> Option<IrConstructUse> {
10634    match kind {
10635        body::BodyEffectKind::ConstructCapabilityCall {
10636            keyword,
10637            target_capability,
10638            ..
10639        } => Some(IrConstructUse {
10640            keyword: keyword.clone(),
10641            scope: "rule_body".to_owned(),
10642            construct_family: "effect_operation".to_owned(),
10643            lowering_target: "capability_call".to_owned(),
10644            target_capability: target_capability.clone(),
10645        }),
10646        _ => None,
10647    }
10648}
10649
10650fn is_ast_only_effect_kind(kind: &body::BodyEffectKind) -> bool {
10651    // `send via <channel> { … } as x` closes its `as` on the block line (unlike
10652    // `recall`, whose `as` is inline), so the line scanner cannot see the binding;
10653    // seed it from the AST. Other `ConstructCapabilityCall`s (e.g. `recall`) are
10654    // line-visible and must NOT be treated as AST-only.
10655    if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = kind {
10656        return keyword == "send";
10657    }
10658    matches!(
10659        kind,
10660        body::BodyEffectKind::Prompt { .. }
10661            | body::BodyEffectKind::Timer { .. }
10662            | body::BodyEffectKind::Exec { .. }
10663            | body::BodyEffectKind::Decide { .. }
10664            | body::BodyEffectKind::TrackerFile { .. }
10665            | body::BodyEffectKind::TrackerClaim { .. }
10666            | body::BodyEffectKind::TrackerRelease { .. }
10667            | body::BodyEffectKind::TrackerFinish { .. }
10668            | body::BodyEffectKind::LeaseAcquire { .. }
10669            | body::BodyEffectKind::LeaseRenew { .. }
10670            | body::BodyEffectKind::LedgerAppend { .. }
10671            | body::BodyEffectKind::CounterConsume { .. }
10672            | body::BodyEffectKind::Notify { .. }
10673            // `invoke` payload blocks put post-payload modifiers on the closing line
10674            // in flow-generated rules, so the line scanner may miss `as <binding>`.
10675            | body::BodyEffectKind::Invoke { .. }
10676            // `write`/`export` put their `as <binding>` on the block's closing
10677            // line, so the line-based scanner cannot see it; seed it from the AST
10678            // so `after <binding>` blocks and sequence checks resolve.
10679            | body::BodyEffectKind::FileWrite { .. }
10680            | body::BodyEffectKind::FileExport { .. }
10681    )
10682}
10683
10684/// Bindings introduced by AST-only effect kinds are unknown to the
10685/// line-based scanner; seed them so sequence checks and `after` blocks see
10686/// them. Binding types for typed outputs are registered where known.
10687fn seed_ast_only_effect_bindings(
10688    statements: &[body::BodyStmt],
10689    seen_bindings: &mut BTreeSet<String>,
10690    binding_types: &mut BTreeMap<String, String>,
10691) {
10692    for statement in statements {
10693        match statement {
10694            body::BodyStmt::Effect(effect) if is_ast_only_effect_kind(&effect.kind) => {
10695                if let Some(binding) = &effect.binding {
10696                    seen_bindings.insert(binding.clone());
10697                    let _ = binding_types;
10698                }
10699            }
10700            body::BodyStmt::After(after) => {
10701                seed_ast_only_effect_bindings(&after.body, seen_bindings, binding_types)
10702            }
10703            body::BodyStmt::Case(case) => {
10704                for branch in &case.branches {
10705                    seed_ast_only_effect_bindings(&branch.body, seen_bindings, binding_types);
10706                }
10707            }
10708            _ => {}
10709        }
10710    }
10711}
10712
10713/// Derives effect nodes and dependency edges from the body AST, in document
10714/// order, with ids and idempotency keys identical to the historical
10715/// line-scanner derivation.
10716/// Collect the output bindings a rule `complete`s, recursing through the body's
10717/// nested blocks (after / case / branch / handler). A `complete <binding> {…}` is the
10718/// workflow's output to its invoker; the IFC checker treats it as an egress sink at
10719/// the invoker boundary (DR-0030 X2). `fail` terminals are NOT collected —
10720/// they carry an error to the runtime, not a value to the invoker.
10721fn collect_terminal_complete_bindings(statements: &[body::BodyStmt], out: &mut Vec<String>) {
10722    for statement in statements {
10723        match statement {
10724            body::BodyStmt::Terminal(terminal) if terminal.kind == body::TerminalKind::Complete => {
10725                out.push(terminal.name.clone());
10726            }
10727            body::BodyStmt::After(after) => collect_terminal_complete_bindings(&after.body, out),
10728            body::BodyStmt::Case(case) => {
10729                for branch in &case.branches {
10730                    collect_terminal_complete_bindings(&branch.body, out);
10731                }
10732            }
10733            _ => {}
10734        }
10735    }
10736}
10737
10738fn collect_effects_from_ast(
10739    statements: &[body::BodyStmt],
10740    rule_name: &str,
10741) -> (Vec<IrEffectNode>, Vec<IrEffectDependency>) {
10742    let mut effects = Vec::new();
10743    let mut dependencies = Vec::new();
10744    let mut counter = 0usize;
10745    let mut after_stack: Vec<(String, DependencyPredicate)> = Vec::new();
10746    let mut case_stack: Vec<(String, String)> = Vec::new();
10747    // Renew disambiguation (T3, mirroring the shipped `release` split): a
10748    // `renew <binding>` whose binding names a same-rule `claim <issue> as
10749    // <binding>` is a tracker claim-renew (`tracker.renew`); one naming an
10750    // `acquire ... as <binding>` stays a lease renew (`lease.renew`). Collect
10751    // the claim `as` bindings up front so the walk can re-classify.
10752    let claim_bindings = collect_claim_bindings(statements);
10753    walk_effects(
10754        statements,
10755        rule_name,
10756        &claim_bindings,
10757        &mut counter,
10758        &mut after_stack,
10759        &mut case_stack,
10760        &mut effects,
10761        &mut dependencies,
10762    );
10763    (effects, dependencies)
10764}
10765
10766/// The `as` bindings of every `claim <issue> as <binding>` in a rule body — the
10767/// referent set the renew disambiguation flips `lease.renew` to `tracker.renew`
10768/// against (the claim result binding, whose output fact carries the issue id).
10769fn collect_claim_bindings(statements: &[body::BodyStmt]) -> BTreeSet<String> {
10770    let mut bindings = BTreeSet::new();
10771    for_each_body(statements, &mut |stmt| {
10772        if let body::BodyStmt::Effect(effect) = stmt {
10773            if matches!(effect.kind, body::BodyEffectKind::TrackerClaim { .. }) {
10774                if let Some(binding) = &effect.binding {
10775                    bindings.insert(binding.clone());
10776                }
10777            }
10778        }
10779    });
10780    bindings
10781}
10782
10783#[allow(clippy::too_many_arguments)]
10784fn walk_effects(
10785    statements: &[body::BodyStmt],
10786    rule_name: &str,
10787    claim_bindings: &BTreeSet<String>,
10788    counter: &mut usize,
10789    after_stack: &mut Vec<(String, DependencyPredicate)>,
10790    case_stack: &mut Vec<(String, String)>,
10791    effects: &mut Vec<IrEffectNode>,
10792    dependencies: &mut Vec<IrEffectDependency>,
10793) {
10794    for statement in statements {
10795        match statement {
10796            body::BodyStmt::Effect(effect) => {
10797                *counter += 1;
10798                let id = effect
10799                    .binding
10800                    .clone()
10801                    .unwrap_or_else(|| format!("effect{counter}"));
10802                // A `renew` naming a claim binding lowers to `tracker.renew`;
10803                // otherwise it stays the coord `lease.renew` its parser produced.
10804                let kind = match &effect.kind {
10805                    body::BodyEffectKind::LeaseRenew {
10806                        acquire_binding, ..
10807                    } if claim_bindings.contains(acquire_binding) => IrEffectKind::TrackerRenew,
10808                    other => ir_effect_kind_for_body(other),
10809                };
10810                for (upstream, predicate) in after_stack.iter() {
10811                    dependencies.push(IrEffectDependency {
10812                        upstream: upstream.clone(),
10813                        predicate: predicate.clone(),
10814                        downstream: id.clone(),
10815                    });
10816                }
10817                let idempotency_key =
10818                    effect_idempotency_key(rule_name, &id, &kind, &effect.binding);
10819                let mut required_capabilities = effect.requires.clone();
10820                match &effect.kind {
10821                    body::BodyEffectKind::Call { capability, .. } => {
10822                        required_capabilities.push(capability.clone());
10823                    }
10824                    body::BodyEffectKind::ConstructCapabilityCall {
10825                        target_capability, ..
10826                    } => {
10827                        required_capabilities.push(target_capability.clone());
10828                    }
10829                    _ => {}
10830                }
10831                required_capabilities.sort();
10832                required_capabilities.dedup();
10833                let construct_use = construct_use_for_body(&effect.kind);
10834                let access_grants = ir_access_grants_for_body(&effect.kind);
10835                let turn_skills = turn_skills_for_body(&effect.kind);
10836                let on_stream = on_stream_for_body(&effect.kind);
10837                let (selection_source, transport_onto) = vcs_selective_for_body(&effect.kind);
10838                let resource = resource_for_body(&effect.kind);
10839                let agent = agent_for_body(&effect.kind);
10840                let coerce_target = coerce_target_for_body(&effect.kind);
10841                let workflow_target = workflow_target_for_body(&effect.kind);
10842                let endorsed = endorsed_for_body(&effect.kind);
10843                let declassified = declassified_for_body(&effect.kind);
10844                let exec_target = exec_target_for_body(&effect.kind);
10845                effects.push(IrEffectNode {
10846                    id,
10847                    kind,
10848                    binding: effect.binding.clone(),
10849                    required_capabilities,
10850                    construct_use,
10851                    idempotency_key,
10852                    span: effect.span,
10853                    timeout_seconds: effect.timeout_seconds,
10854                    access_grants,
10855                    turn_skills,
10856                    on_stream,
10857                    selection_source,
10858                    transport_onto,
10859                    resource,
10860                    agent,
10861                    coerce_target,
10862                    workflow_target,
10863                    endorsed,
10864                    declassified,
10865                    selected_by: case_stack.last().cloned(),
10866                    exec_target,
10867                });
10868            }
10869            body::BodyStmt::After(after) => {
10870                let predicate = match after.predicate {
10871                    body::AfterPredicate::Succeeds => DependencyPredicate::Succeeds,
10872                    body::AfterPredicate::Fails => DependencyPredicate::Fails,
10873                    // `times out` / `cancelled` are distinct non-success terminal
10874                    // statuses, so the downstream effect releases only on that
10875                    // specific status (mirroring succeeds/fails), not on any
10876                    // terminal.
10877                    body::AfterPredicate::TimedOut => DependencyPredicate::TimedOut,
10878                    body::AfterPredicate::Cancelled => DependencyPredicate::Cancelled,
10879                    // Coordination outcomes are completion-valued: the downstream
10880                    // depends on the op reaching a terminal state; the outcome
10881                    // variant selects the arm at lowering.
10882                    body::AfterPredicate::Completes
10883                    | body::AfterPredicate::Held
10884                    | body::AfterPredicate::Contended
10885                    | body::AfterPredicate::Ok
10886                    | body::AfterPredicate::Over
10887                    | body::AfterPredicate::Promoted
10888                    | body::AfterPredicate::Conflicted
10889                    | body::AfterPredicate::Applied
10890                    | body::AfterPredicate::Stranded => DependencyPredicate::Completes,
10891                    // `reaches "<name>"` (Family C) is completion-shaped for the
10892                    // construct-graph provenance edge; the milestone-specific
10893                    // gating happens at runtime against the
10894                    // `workflow.invoke.reached:<name>` fact (text-keyed, see
10895                    // `fact_matches_after_predicate`), so this IR predicate is
10896                    // metadata only.
10897                    body::AfterPredicate::Reaches => DependencyPredicate::Completes,
10898                };
10899                after_stack.push((after.binding.clone(), predicate));
10900                walk_effects(
10901                    &after.body,
10902                    rule_name,
10903                    claim_bindings,
10904                    counter,
10905                    after_stack,
10906                    case_stack,
10907                    effects,
10908                    dependencies,
10909                );
10910                after_stack.pop();
10911            }
10912            body::BodyStmt::Case(case) => {
10913                for branch in &case.branches {
10914                    // Record the selector: an effect in this arm is gated by
10915                    // `case <scrutinee> { <pattern> => … }` (DR §7.4).
10916                    case_stack.push((case.scrutinee.clone(), branch.pattern.clone()));
10917                    walk_effects(
10918                        &branch.body,
10919                        rule_name,
10920                        claim_bindings,
10921                        counter,
10922                        after_stack,
10923                        case_stack,
10924                        effects,
10925                        dependencies,
10926                    );
10927                    case_stack.pop();
10928                }
10929            }
10930            _ => {}
10931        }
10932    }
10933}
10934
10935fn effect_idempotency_key(
10936    rule_name: &str,
10937    effect_id: &str,
10938    kind: &IrEffectKind,
10939    binding: &Option<String>,
10940) -> String {
10941    stable_hash(&format!(
10942        "rule={rule_name};effect={effect_id};kind={};binding={}",
10943        kind.as_str(),
10944        binding.as_deref().unwrap_or("-")
10945    ))
10946}
10947
10948fn validate_coerce_call(
10949    rule: &RuleDecl,
10950    line: &str,
10951    semantic: &SemanticContext,
10952    binding_types: &BTreeMap<String, String>,
10953    known_roots: &BTreeSet<String>,
10954    diagnostics: &mut Vec<Diagnostic>,
10955) {
10956    let Some((function_name, args)) = parse_coerce_call(line) else {
10957        diagnostics.push(Diagnostic {
10958            related: Vec::new(),
10959            span: rule.body.span,
10960            message: format!("rule `{}` has malformed coerce call", rule.name.name),
10961            suggestion: Some("write `coerce functionName(arg, ...) as name`".to_owned()),
10962        });
10963        return;
10964    };
10965    let Some(params) = semantic.coerce_params.get(function_name) else {
10966        diagnostics.push(Diagnostic {
10967            related: Vec::new(),
10968            span: rule.body.span,
10969            message: format!(
10970                "rule `{}` calls unknown coerce function `{function_name}`",
10971                rule.name.name
10972            ),
10973            suggestion: Some(format!(
10974                "declare `coerce {function_name}(...) -> Output {{ ... }}` before using it"
10975            )),
10976        });
10977        return;
10978    };
10979    if args.len() != params.len() {
10980        diagnostics.push(Diagnostic {
10981            related: Vec::new(),
10982            span: rule.body.span,
10983            message: format!(
10984                "rule `{}` calls coerce `{function_name}` with {} argument(s), expected {}",
10985                rule.name.name,
10986                args.len(),
10987                params.len()
10988            ),
10989            suggestion: Some("pass one argument for each declared coerce parameter".to_owned()),
10990        });
10991        return;
10992    }
10993    let scope = ExprScope::from_bindings(binding_types);
10994    for (arg, param) in args.iter().zip(params) {
10995        // Dangling-root check (mirrors record/terminal value validation): an arg
10996        // whose root is not a known binding is a typo/unbound reference, which the
10997        // type-checker below accepts leniently.
10998        if let Some(root) = dangling_value_root(arg, known_roots) {
10999            diagnostics.push(Diagnostic { related: Vec::new(),
11000                span: rule.body.span,
11001                message: format!(
11002                    "rule `{}` has unknown binding `{root}` in coerce `{function_name}` argument",
11003                    rule.name.name
11004                ),
11005                suggestion: Some(
11006                    "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
11007                        .to_owned(),
11008                ),
11009            });
11010        }
11011        validate_expr_source_against_type(
11012            rule,
11013            &format!("coerce `{function_name}`"),
11014            &param.name.name,
11015            &param.ty,
11016            arg,
11017            semantic,
11018            &scope,
11019            diagnostics,
11020        );
11021    }
11022}
11023
11024fn validate_effect_payloads(
11025    rule: &RuleDecl,
11026    semantic: &SemanticContext,
11027    binding_types: &BTreeMap<String, String>,
11028    known_roots: &BTreeSet<String>,
11029    diagnostics: &mut Vec<Diagnostic>,
11030) {
11031    for statement in effect_payload_statements(&rule.body.text) {
11032        let trimmed = statement.trim();
11033        if trimmed.starts_with("coerce ") {
11034            validate_coerce_call(
11035                rule,
11036                trimmed,
11037                semantic,
11038                binding_types,
11039                known_roots,
11040                diagnostics,
11041            );
11042        }
11043    }
11044}
11045
11046fn validate_workflow_invocations(
11047    rule: &RuleDecl,
11048    semantic: &SemanticContext,
11049    binding_types: &BTreeMap<String, String>,
11050    known_roots: &BTreeSet<String>,
11051    diagnostics: &mut Vec<Diagnostic>,
11052) {
11053    for statement in workflow_invoke_statements(&rule.body.text) {
11054        let Some((target, body)) = invoke_statement_parts(&statement) else {
11055            diagnostics.push(Diagnostic {
11056                related: Vec::new(),
11057                span: rule.body.span,
11058                message: format!(
11059                    "rule `{}` has malformed workflow invocation",
11060                    rule.name.name
11061                ),
11062                suggestion: Some("write `invoke Workflow { input value } as binding`".to_owned()),
11063            });
11064            continue;
11065        };
11066        if semantic.workflow.as_deref() == Some(target) {
11067            diagnostics.push(Diagnostic {
11068                related: Vec::new(),
11069                span: rule.body.span,
11070                message: format!(
11071                    "rule `{}` recursively invokes workflow `{target}`",
11072                    rule.name.name
11073                ),
11074                suggestion: Some(
11075                    "split recursive orchestration into an explicit bounded scheduler workflow"
11076                        .to_owned(),
11077                ),
11078            });
11079            continue;
11080        }
11081        let Some(surface) = semantic.workflow_inputs.get(target) else {
11082            diagnostics.push(Diagnostic {
11083                related: Vec::new(),
11084                span: rule.body.span,
11085                message: format!(
11086                    "rule `{}` invokes unknown workflow `{target}`",
11087                    rule.name.name
11088                ),
11089                suggestion: Some("invoke a workflow declared in this source bundle".to_owned()),
11090            });
11091            continue;
11092        };
11093
11094        let mut invocation_semantic = semantic.clone();
11095        invocation_semantic.schemas.merge(surface.schemas.clone());
11096        let assignments = collect_field_assignments(body);
11097        let mut seen = BTreeSet::new();
11098        for assignment in assignments {
11099            let (field, value) = match assignment {
11100                RecordFieldAssignment::Value { field, value } => (field, value),
11101                RecordFieldAssignment::Shorthand { field } => (field.clone(), field),
11102            };
11103            if !seen.insert(field.clone()) {
11104                diagnostics.push(Diagnostic {
11105                    related: Vec::new(),
11106                    span: rule.body.span,
11107                    message: format!("workflow invocation `{target}` repeats input `{field}`"),
11108                    suggestion: Some("remove the duplicate invocation input".to_owned()),
11109                });
11110                continue;
11111            }
11112            let Some(input_ty) = surface.inputs.get(&field) else {
11113                let known = surface
11114                    .inputs
11115                    .keys()
11116                    .map(|input| format!("`{input}`"))
11117                    .collect::<Vec<_>>()
11118                    .join(", ");
11119                diagnostics.push(Diagnostic {
11120                    related: Vec::new(),
11121                    span: rule.body.span,
11122                    message: format!("workflow `{target}` has no input `{field}`"),
11123                    suggestion: Some(if known.is_empty() {
11124                        "remove the invocation payload; the target declares no inputs".to_owned()
11125                    } else {
11126                        format!("pass one of: {known}")
11127                    }),
11128                });
11129                continue;
11130            };
11131            if let Some(root) = dangling_value_root(&value, known_roots) {
11132                diagnostics.push(Diagnostic { related: Vec::new(),
11133                    span: rule.body.span,
11134                    message: format!(
11135                        "rule `{}` has unknown binding `{root}` in `invoke {target}` input `{field}`",
11136                        rule.name.name
11137                    ),
11138                    suggestion: Some(
11139                        "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
11140                            .to_owned(),
11141                    ),
11142                });
11143            }
11144            validate_expr_source_against_type(
11145                rule,
11146                target,
11147                &field,
11148                input_ty,
11149                &value,
11150                &invocation_semantic,
11151                &ExprScope::from_bindings(binding_types),
11152                diagnostics,
11153            );
11154        }
11155        for input in surface.inputs.keys() {
11156            if seen.contains(input) {
11157                continue;
11158            }
11159            diagnostics.push(Diagnostic {
11160                related: Vec::new(),
11161                span: rule.body.span,
11162                message: format!("workflow invocation `{target}` is missing input `{input}`"),
11163                suggestion: Some(format!(
11164                    "add `{input}` to the `{target}` invocation payload"
11165                )),
11166            });
11167        }
11168    }
11169}
11170
11171fn validate_agent_tell_target(
11172    rule: &RuleDecl,
11173    line: &str,
11174    kind: &IrEffectKind,
11175    semantic: &SemanticContext,
11176    binding_types: &BTreeMap<String, String>,
11177    known_roots: &BTreeSet<String>,
11178    diagnostics: &mut Vec<Diagnostic>,
11179) {
11180    if kind != &IrEffectKind::AgentTell {
11181        return;
11182    }
11183    let Some(target) = parse_tell_target(line) else {
11184        diagnostics.push(Diagnostic {
11185            related: Vec::new(),
11186            span: rule.body.span,
11187            message: format!("rule `{}` has malformed tell target", rule.name.name),
11188            suggestion: Some("write `tell agentName ...` or `tell task.agentRef ...`".to_owned()),
11189        });
11190        return;
11191    };
11192    if target.starts_with('"') {
11193        diagnostics.push(Diagnostic {
11194            related: Vec::new(),
11195            span: rule.body.span,
11196            message: format!(
11197                "rule `{}` uses a string literal as a tell target",
11198                rule.name.name
11199            ),
11200            suggestion: Some("use a declared agent name or an AgentRef field".to_owned()),
11201        });
11202        return;
11203    }
11204    let required_capabilities = parse_required_capabilities(line);
11205    if target.contains('.') {
11206        let Some(ty) = expression_type(target, semantic, binding_types) else {
11207            // Unknown type can mean a dangling root (the path's binding does not
11208            // exist) — caught here since the type lookup returns None silently. A
11209            // known root with a bad path is left to other validation.
11210            if let Some(root) = dangling_value_root(target, known_roots) {
11211                diagnostics.push(Diagnostic { related: Vec::new(),
11212                    span: rule.body.span,
11213                    message: format!(
11214                        "rule `{}` has unknown binding `{root}` in tell target `{target}`",
11215                        rule.name.name
11216                    ),
11217                    suggestion: Some(
11218                        "reference a binding from a `when ... as name` clause or an effect `as` binding"
11219                            .to_owned(),
11220                    ),
11221                });
11222            }
11223            return;
11224        };
11225        if let TypeSyntax::AgentRef { agents, .. } = ty {
11226            for agent in agents {
11227                validate_agent_capabilities(
11228                    rule,
11229                    &agent.name,
11230                    &required_capabilities,
11231                    semantic,
11232                    diagnostics,
11233                );
11234            }
11235        } else {
11236            diagnostics.push(Diagnostic {
11237                related: Vec::new(),
11238                span: rule.body.span,
11239                message: format!(
11240                    "rule `{}` uses non-AgentRef dynamic tell target `{target}`",
11241                    rule.name.name
11242                ),
11243                suggestion: Some(
11244                    "declare the field as `AgentRef<...>` before using it as a tell target"
11245                        .to_owned(),
11246                ),
11247            });
11248        }
11249        return;
11250    }
11251    if !semantic.agents.contains(target) {
11252        diagnostics.push(Diagnostic {
11253            related: Vec::new(),
11254            span: rule.body.span,
11255            message: format!("rule `{}` tells unknown agent `{target}`", rule.name.name),
11256            suggestion: Some("declare the target agent before telling it".to_owned()),
11257        });
11258        return;
11259    }
11260    validate_agent_capabilities(rule, target, &required_capabilities, semantic, diagnostics);
11261}
11262
11263fn validate_agent_capabilities(
11264    rule: &RuleDecl,
11265    agent: &str,
11266    required_capabilities: &[String],
11267    semantic: &SemanticContext,
11268    diagnostics: &mut Vec<Diagnostic>,
11269) {
11270    if required_capabilities.is_empty() {
11271        return;
11272    }
11273    let declared = semantic
11274        .agent_capabilities
11275        .get(agent)
11276        .cloned()
11277        .unwrap_or_default();
11278    for capability in required_capabilities {
11279        if !declared.contains(capability) {
11280            diagnostics.push(Diagnostic { related: Vec::new(),
11281                span: rule.body.span,
11282                message: format!(
11283                    "rule `{}` tells agent `{agent}` requiring undeclared capability `{capability}`",
11284                    rule.name.name
11285                ),
11286                suggestion: Some(format!(
11287                    "add `{capability}` to agent `{agent}` capabilities or choose another AgentRef target"
11288                )),
11289            });
11290        }
11291    }
11292}
11293
11294fn validate_availability_when(
11295    rule: &RuleDecl,
11296    when: &str,
11297    semantic: &SemanticContext,
11298    binding_types: &BTreeMap<String, String>,
11299    diagnostics: &mut Vec<Diagnostic>,
11300) {
11301    let (pattern, _) = split_when_guard(when);
11302    let Some(target) = pattern.strip_suffix(" is available").map(str::trim) else {
11303        return;
11304    };
11305    if target.contains('.') {
11306        let Some(ty) = expression_type(target, semantic, binding_types) else {
11307            return;
11308        };
11309        if !matches!(ty, TypeSyntax::AgentRef { .. }) {
11310            diagnostics.push(Diagnostic {
11311                related: Vec::new(),
11312                span: rule.body.span,
11313                message: format!(
11314                    "rule `{}` checks availability for non-AgentRef `{target}`",
11315                    rule.name.name
11316                ),
11317                suggestion: Some(
11318                    "availability checks must name a declared agent or an AgentRef field"
11319                        .to_owned(),
11320                ),
11321            });
11322        }
11323        return;
11324    }
11325    if !semantic.agents.contains(target) {
11326        diagnostics.push(Diagnostic {
11327            related: Vec::new(),
11328            span: rule.body.span,
11329            message: format!("rule `{}` checks unknown agent `{target}`", rule.name.name),
11330            suggestion: Some("declare the target agent before checking availability".to_owned()),
11331        });
11332    }
11333}
11334
11335#[derive(Clone, Debug, Default)]
11336struct ExprScope {
11337    binding_types: BTreeMap<String, String>,
11338    implicit_schema: Option<String>,
11339}
11340
11341impl ExprScope {
11342    fn from_bindings(binding_types: &BTreeMap<String, String>) -> Self {
11343        Self {
11344            binding_types: binding_types.clone(),
11345            implicit_schema: None,
11346        }
11347    }
11348
11349    fn with_implicit_schema(&self, schema: String) -> Self {
11350        let mut scope = self.clone();
11351        scope.implicit_schema = Some(schema);
11352        scope
11353    }
11354}
11355
11356#[derive(Clone, Debug)]
11357struct ExprValidationContext {
11358    subject: String,
11359    span: SourceSpan,
11360}
11361
11362impl ExprValidationContext {
11363    fn rule(rule: &RuleDecl) -> Self {
11364        Self {
11365            subject: format!("rule `{}`", rule.name.name),
11366            span: rule.body.span,
11367        }
11368    }
11369
11370    fn assertion(span: SourceSpan) -> Self {
11371        Self {
11372            subject: "assertion".to_owned(),
11373            span,
11374        }
11375    }
11376}
11377
11378fn validate_expression(
11379    rule: &RuleDecl,
11380    expr: &str,
11381    semantic: &SemanticContext,
11382    binding_types: &BTreeMap<String, String>,
11383    label: &str,
11384    diagnostics: &mut Vec<Diagnostic>,
11385) {
11386    match parse_expression(expr) {
11387        Ok(expr) => {
11388            validate_parsed_expression(
11389                &expr,
11390                semantic,
11391                &ExprScope::from_bindings(binding_types),
11392                &ExprValidationContext::rule(rule),
11393                label,
11394                diagnostics,
11395            );
11396        }
11397        Err(message) => diagnostics.push(Diagnostic { related: Vec::new(),
11398            span: rule.body.span,
11399            message: format!("rule `{}` has invalid {label} expression: {message}", rule.name.name),
11400            suggestion: Some("use deterministic field paths, literals, boolean operators, comparisons, membership, count, or exists".to_owned()),
11401        }),
11402    }
11403}
11404
11405fn validate_parsed_expression(
11406    expr: &Expr,
11407    semantic: &SemanticContext,
11408    scope: &ExprScope,
11409    context: &ExprValidationContext,
11410    label: &str,
11411    diagnostics: &mut Vec<Diagnostic>,
11412) {
11413    let presence_proofs = BTreeSet::new();
11414    validate_expr_node(
11415        expr,
11416        semantic,
11417        scope,
11418        context,
11419        &presence_proofs,
11420        diagnostics,
11421    );
11422    let ty = infer_expr_type(expr, semantic, scope, context, diagnostics);
11423    if ty != ExprType::Bool && ty != ExprType::Unknown {
11424        diagnostics.push(Diagnostic {
11425            related: Vec::new(),
11426            span: context.span,
11427            message: format!("{} has non-boolean {label} expression", context.subject),
11428            suggestion: Some(format!("{label} expressions must evaluate to bool")),
11429        });
11430    }
11431}
11432
11433fn validate_expr_node(
11434    expr: &Expr,
11435    semantic: &SemanticContext,
11436    scope: &ExprScope,
11437    context: &ExprValidationContext,
11438    presence_proofs: &BTreeSet<String>,
11439    diagnostics: &mut Vec<Diagnostic>,
11440) {
11441    match expr {
11442        Expr::Path(path) => {
11443            if path.len() < 2 {
11444                return;
11445            }
11446            let root = &path[0];
11447            let Some(schema) = scope.binding_types.get(root) else {
11448                if let Some(schema) = &scope.implicit_schema {
11449                    if let Err(message) =
11450                        validate_optional_path_access(schema, path, semantic, presence_proofs)
11451                    {
11452                        diagnostics.push(Diagnostic {
11453                            related: Vec::new(),
11454                            span: context.span,
11455                            message: format!(
11456                                "{} has unsafe optional path `{}`: {message}",
11457                                context.subject,
11458                                path.join(".")
11459                            ),
11460                            suggestion: Some(
11461                                "prove the optional value is present before reading through it"
11462                                    .to_owned(),
11463                            ),
11464                        });
11465                        return;
11466                    }
11467                    if let Err(message) = semantic.schemas.resolve_field_path(schema, path) {
11468                        diagnostics.push(Diagnostic {
11469                            related: Vec::new(),
11470                            span: context.span,
11471                            message: format!(
11472                                "{} has invalid expression path `{}`: {message}",
11473                                context.subject,
11474                                path.join(".")
11475                            ),
11476                            suggestion: Some(
11477                                "use a field declared on the queried schema".to_owned(),
11478                            ),
11479                        });
11480                    }
11481                    return;
11482                }
11483                diagnostics.push(Diagnostic {
11484                    related: Vec::new(),
11485                    span: context.span,
11486                    message: format!("{} has unknown expression root `{root}`", context.subject),
11487                    suggestion: Some(
11488                        "use a binding introduced by a `when ... as name` clause".to_owned(),
11489                    ),
11490                });
11491                return;
11492            };
11493            if let Err(message) =
11494                validate_optional_path_access(schema, &path[1..], semantic, presence_proofs)
11495            {
11496                diagnostics.push(Diagnostic {
11497                    related: Vec::new(),
11498                    span: context.span,
11499                    message: format!(
11500                        "{} has unsafe optional path `{}`: {message}",
11501                        context.subject,
11502                        path.join(".")
11503                    ),
11504                    suggestion: Some(
11505                        "prove the optional value is present before reading through it".to_owned(),
11506                    ),
11507                });
11508                return;
11509            }
11510            if let Err(message) = semantic.schemas.resolve_field_path(schema, &path[1..]) {
11511                diagnostics.push(Diagnostic {
11512                    related: Vec::new(),
11513                    span: context.span,
11514                    message: format!(
11515                        "{} has invalid expression path `{}`: {message}",
11516                        context.subject,
11517                        path.join(".")
11518                    ),
11519                    suggestion: Some("use a field declared on the bound schema".to_owned()),
11520                });
11521            }
11522        }
11523        Expr::Index { target, key } => {
11524            validate_expr_node(
11525                target,
11526                semantic,
11527                scope,
11528                context,
11529                presence_proofs,
11530                diagnostics,
11531            );
11532            validate_expr_node(key, semantic, scope, context, presence_proofs, diagnostics);
11533            let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
11534            if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
11535                diagnostics.push(Diagnostic {
11536                    related: Vec::new(),
11537                    span: context.span,
11538                    message: format!("{} indexes a map with a non-string key", context.subject),
11539                    suggestion: Some(
11540                        "use a string literal or string expression as the map key".to_owned(),
11541                    ),
11542                });
11543            }
11544        }
11545        Expr::Array(items) => {
11546            for item in items {
11547                validate_expr_node(item, semantic, scope, context, presence_proofs, diagnostics);
11548            }
11549        }
11550        Expr::Object(fields) => {
11551            diagnostics.push(Diagnostic {
11552                related: Vec::new(),
11553                span: context.span,
11554                message: format!(
11555                    "{} uses an object literal without an expected object or map type",
11556                    context.subject
11557                ),
11558                suggestion: Some(
11559                    "use object literals only in typed record fields or typed effect arguments"
11560                        .to_owned(),
11561                ),
11562            });
11563            for field in fields {
11564                validate_expr_node(
11565                    &field.value,
11566                    semantic,
11567                    scope,
11568                    context,
11569                    presence_proofs,
11570                    diagnostics,
11571                );
11572            }
11573        }
11574        Expr::Unary { expr, .. } => {
11575            validate_expr_node(expr, semantic, scope, context, presence_proofs, diagnostics)
11576        }
11577        Expr::Binary {
11578            op: BinaryOp::And,
11579            left,
11580            right,
11581        } => {
11582            validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
11583            let mut right_proofs = presence_proofs.clone();
11584            collect_presence_proofs(left, &mut right_proofs);
11585            validate_expr_node(right, semantic, scope, context, &right_proofs, diagnostics);
11586        }
11587        Expr::Binary { op, left, right } => {
11588            validate_expr_node(left, semantic, scope, context, presence_proofs, diagnostics);
11589            validate_expr_node(
11590                right,
11591                semantic,
11592                scope,
11593                context,
11594                presence_proofs,
11595                diagnostics,
11596            );
11597            validate_unknown_implicit_idents(
11598                *op,
11599                left,
11600                right,
11601                semantic,
11602                scope,
11603                context,
11604                diagnostics,
11605            );
11606            validate_finite_domain_expr(*op, left, right, semantic, scope, context, diagnostics);
11607        }
11608        Expr::Call { name, args } => {
11609            validate_function_call(name, args, semantic, scope, context, diagnostics);
11610            for arg in args {
11611                validate_expr_node(arg, semantic, scope, context, presence_proofs, diagnostics);
11612            }
11613        }
11614        Expr::Query { guard, .. } => {
11615            validate_query_expr(expr, semantic, scope, context, diagnostics);
11616            if let Some(guard) = guard {
11617                let guard_scope = query_guard_scope(expr, semantic, scope);
11618                validate_expr_node(
11619                    guard,
11620                    semantic,
11621                    &guard_scope,
11622                    context,
11623                    presence_proofs,
11624                    diagnostics,
11625                );
11626            }
11627        }
11628        Expr::Literal(_) => {}
11629    }
11630}
11631
11632fn validate_unknown_implicit_idents(
11633    op: BinaryOp,
11634    left: &Expr,
11635    right: &Expr,
11636    semantic: &SemanticContext,
11637    scope: &ExprScope,
11638    context: &ExprValidationContext,
11639    diagnostics: &mut Vec<Diagnostic>,
11640) {
11641    if !matches!(
11642        op,
11643        BinaryOp::Eq | BinaryOp::Ne | BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge
11644    ) {
11645        return;
11646    }
11647    validate_unknown_implicit_ident(left, right, semantic, scope, context, diagnostics);
11648    validate_unknown_implicit_ident(right, left, semantic, scope, context, diagnostics);
11649}
11650
11651fn validate_unknown_implicit_ident(
11652    expr: &Expr,
11653    other: &Expr,
11654    semantic: &SemanticContext,
11655    scope: &ExprScope,
11656    context: &ExprValidationContext,
11657    diagnostics: &mut Vec<Diagnostic>,
11658) {
11659    let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
11660        return;
11661    };
11662    let Some(schema) = &scope.implicit_schema else {
11663        return;
11664    };
11665    let field_exists = semantic
11666        .schemas
11667        .classes
11668        .get(schema)
11669        .is_some_and(|fields| fields.contains_key(name));
11670    if field_exists
11671        || expr_domain(other, semantic, scope).is_some()
11672        || implicit_ident_field_exists(other, semantic, scope)
11673    {
11674        return;
11675    }
11676    diagnostics.push(Diagnostic {
11677        related: Vec::new(),
11678        span: context.span,
11679        message: format!(
11680            "{} fact query `{schema}` has unknown field `{name}`",
11681            context.subject
11682        ),
11683        suggestion: Some(format!(
11684            "use a field declared on `{schema}` inside the query `where` expression"
11685        )),
11686    });
11687}
11688
11689fn implicit_ident_field_exists(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> bool {
11690    let Expr::Literal(ExprLiteral::Ident(name)) = expr else {
11691        return false;
11692    };
11693    let Some(schema) = &scope.implicit_schema else {
11694        return false;
11695    };
11696    semantic
11697        .schemas
11698        .classes
11699        .get(schema)
11700        .is_some_and(|fields| fields.contains_key(name))
11701}
11702
11703fn validate_function_call(
11704    name: &str,
11705    args: &[Expr],
11706    semantic: &SemanticContext,
11707    scope: &ExprScope,
11708    context: &ExprValidationContext,
11709    diagnostics: &mut Vec<Diagnostic>,
11710) {
11711    match name {
11712        "count" => {
11713            if args.len() != 1 {
11714                diagnostics.push(Diagnostic { related: Vec::new(),
11715                    span: context.span,
11716                    message: format!(
11717                        "{} calls `count` with {} arguments, expected 1",
11718                        context.subject,
11719                        args.len()
11720                    ),
11721                    suggestion: Some(
11722                        "call `count` with exactly one array, map, fact query, or effect query argument"
11723                            .to_owned(),
11724                    ),
11725                });
11726                return;
11727            }
11728            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
11729            if !is_countable_type(&ty) {
11730                diagnostics.push(Diagnostic {
11731                    related: Vec::new(),
11732                    span: context.span,
11733                    message: format!(
11734                        "{} calls `count` with unsupported argument type `{}`",
11735                        context.subject,
11736                        expr_type_label(&ty)
11737                    ),
11738                    suggestion: Some(
11739                        "use `count` only with arrays, maps, fact queries, or effect queries"
11740                            .to_owned(),
11741                    ),
11742                });
11743            }
11744        }
11745        "exists" => {
11746            if args.len() != 1 {
11747                diagnostics.push(Diagnostic {
11748                    related: Vec::new(),
11749                    span: context.span,
11750                    message: format!(
11751                        "{} calls `exists` with {} arguments, expected 1",
11752                        context.subject,
11753                        args.len()
11754                    ),
11755                    suggestion: Some("call `exists` with exactly one argument".to_owned()),
11756                });
11757                return;
11758            }
11759            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
11760            if !matches!(args[0], Expr::Index { .. }) && !is_exists_type(&ty) {
11761                diagnostics.push(Diagnostic { related: Vec::new(),
11762                    span: context.span,
11763                    message: format!(
11764                        "{} calls `exists` with unsupported argument type `{}`",
11765                        context.subject,
11766                        expr_type_label(&ty)
11767                    ),
11768                    suggestion: Some(
11769                        "use `exists path` for optional/map presence checks or pass an array, map, fact query, or effect query"
11770                            .to_owned(),
11771                    ),
11772                });
11773            }
11774        }
11775        "empty" => {
11776            if args.len() != 1 {
11777                diagnostics.push(Diagnostic {
11778                    related: Vec::new(),
11779                    span: context.span,
11780                    message: format!(
11781                        "{} calls `empty` with {} arguments, expected 1",
11782                        context.subject,
11783                        args.len()
11784                    ),
11785                    suggestion: Some(
11786                        "call `empty` with exactly one array, map, string, fact query, or effect query argument"
11787                            .to_owned(),
11788                    ),
11789                });
11790                return;
11791            }
11792            let ty = infer_expr_type(&args[0], semantic, scope, context, diagnostics);
11793            if !is_emptiable_type(&ty) {
11794                // An optional gets its own message: the inner type is what
11795                // makes it unsupported (spec: `empty(Optional<T>)` is defined
11796                // only when `empty(T)` is).
11797                let optional = matches!(ty, ExprType::Optional(_));
11798                diagnostics.push(Diagnostic {
11799                    related: Vec::new(),
11800                    span: context.span,
11801                    message: format!(
11802                        "{} calls `empty` with unsupported {}argument type `{}`",
11803                        context.subject,
11804                        if optional { "optional " } else { "" },
11805                        expr_type_label(&ty)
11806                    ),
11807                    suggestion: Some(
11808                        "use `empty` only with arrays, maps, strings, fact queries, effect queries, null, or supported optional values"
11809                            .to_owned(),
11810                    ),
11811                });
11812            }
11813        }
11814        _ => {}
11815    }
11816}
11817
11818fn validate_query_expr(
11819    expr: &Expr,
11820    semantic: &SemanticContext,
11821    scope: &ExprScope,
11822    context: &ExprValidationContext,
11823    diagnostics: &mut Vec<Diagnostic>,
11824) {
11825    let Expr::Query { kind, head, guard } = expr else {
11826        return;
11827    };
11828    if *kind == QueryKind::Fact {
11829        let Some(schema) = query_head_schema(head, semantic) else {
11830            diagnostics.push(Diagnostic {
11831                related: Vec::new(),
11832                span: context.span,
11833                message: format!(
11834                    "{} queries unknown fact schema `{}`",
11835                    context.subject,
11836                    head.trim()
11837                ),
11838                suggestion: Some("use a declared class name in fact queries".to_owned()),
11839            });
11840            return;
11841        };
11842        if let Some(guard) = guard {
11843            let guard_scope = scope.with_implicit_schema(schema);
11844            let ty = infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
11845            if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
11846                diagnostics.push(Diagnostic {
11847                    related: Vec::new(),
11848                    span: context.span,
11849                    message: format!(
11850                        "{} fact query `{}` has non-boolean `where` expression",
11851                        context.subject,
11852                        head.trim()
11853                    ),
11854                    suggestion: Some("query `where` expressions must evaluate to bool".to_owned()),
11855                });
11856            }
11857        }
11858    }
11859}
11860
11861fn validate_optional_path_access(
11862    root_schema: &str,
11863    path: &[String],
11864    semantic: &SemanticContext,
11865    presence_proofs: &BTreeSet<String>,
11866) -> Result<(), String> {
11867    let mut schema = root_schema.to_owned();
11868    let mut prefix = Vec::new();
11869    for (index, field) in path.iter().enumerate() {
11870        let Some(fields) = semantic.schemas.classes.get(&schema) else {
11871            return Ok(());
11872        };
11873        let Some(field_ty) = fields.get(field) else {
11874            return Ok(());
11875        };
11876        prefix.push(field.clone());
11877        if let TypeSyntax::Optional { inner, .. } = field_ty {
11878            if index + 1 < path.len() && !presence_proofs.contains(&prefix.join(".")) {
11879                return Err(format!(
11880                    "`{}` must be proven present before accessing `{}`",
11881                    prefix.join("."),
11882                    path[index + 1..].join(".")
11883                ));
11884            }
11885            if let Some(next_schema) = schema_name_for_path(inner) {
11886                schema = next_schema;
11887            }
11888            continue;
11889        }
11890        if let Some(next_schema) = schema_name_for_path(field_ty) {
11891            schema = next_schema;
11892        }
11893    }
11894    Ok(())
11895}
11896
11897fn collect_presence_proofs(expr: &Expr, proofs: &mut BTreeSet<String>) {
11898    match expr {
11899        Expr::Binary {
11900            op: BinaryOp::Ne,
11901            left,
11902            right,
11903        } => {
11904            if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
11905                if let Some(path) = expr_path_key(left) {
11906                    proofs.insert(path);
11907                }
11908            }
11909            if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
11910                if let Some(path) = expr_path_key(right) {
11911                    proofs.insert(path);
11912                }
11913            }
11914        }
11915        Expr::Unary {
11916            op: UnaryOp::Not,
11917            expr,
11918        } => {
11919            if let Expr::Binary {
11920                op: BinaryOp::Eq,
11921                left,
11922                right,
11923            } = expr.as_ref()
11924            {
11925                if matches!(**right, Expr::Literal(ExprLiteral::Null)) {
11926                    if let Some(path) = expr_path_key(left) {
11927                        proofs.insert(path);
11928                    }
11929                }
11930                if matches!(**left, Expr::Literal(ExprLiteral::Null)) {
11931                    if let Some(path) = expr_path_key(right) {
11932                        proofs.insert(path);
11933                    }
11934                }
11935            }
11936        }
11937        Expr::Call { name, args } if name == "exists" && args.len() == 1 => {
11938            if let Some(path) = expr_path_key(&args[0]) {
11939                proofs.insert(path);
11940            }
11941        }
11942        Expr::Binary {
11943            op: BinaryOp::And,
11944            left,
11945            right,
11946        } => {
11947            collect_presence_proofs(left, proofs);
11948            collect_presence_proofs(right, proofs);
11949        }
11950        _ => {}
11951    }
11952}
11953
11954fn expr_path_key(expr: &Expr) -> Option<String> {
11955    match expr {
11956        Expr::Literal(ExprLiteral::Ident(name)) => Some(name.clone()),
11957        Expr::Path(path) if path.len() >= 2 => Some(path[1..].join(".")),
11958        Expr::Index { target, key } => {
11959            let target = expr_path_key(target)?;
11960            let key = match key.as_ref() {
11961                Expr::Literal(ExprLiteral::String(value) | ExprLiteral::Ident(value)) => value,
11962                _ => return None,
11963            };
11964            Some(format!("{target}[{key:?}]"))
11965        }
11966        _ => None,
11967    }
11968}
11969
11970fn query_guard_scope(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> ExprScope {
11971    let Expr::Query {
11972        kind: QueryKind::Fact,
11973        head,
11974        ..
11975    } = expr
11976    else {
11977        return scope.clone();
11978    };
11979    query_head_schema(head, semantic)
11980        .map(|schema| scope.with_implicit_schema(schema))
11981        .unwrap_or_else(|| scope.clone())
11982}
11983
11984fn query_head_schema(head: &str, semantic: &SemanticContext) -> Option<String> {
11985    let mut parts = head.split_whitespace();
11986    let schema = parts.next()?;
11987    if parts.next().is_some() {
11988        return None;
11989    }
11990    semantic
11991        .schemas
11992        .class_exists(schema)
11993        .then(|| schema.to_owned())
11994}
11995
11996fn implicit_field_type(
11997    name: &str,
11998    semantic: &SemanticContext,
11999    scope: &ExprScope,
12000) -> Option<TypeSyntax> {
12001    let schema = scope.implicit_schema.as_ref()?;
12002    semantic
12003        .schemas
12004        .resolve_field_path(schema, &[name.to_owned()])
12005        .ok()
12006}
12007
12008fn infer_expr_type(
12009    expr: &Expr,
12010    semantic: &SemanticContext,
12011    scope: &ExprScope,
12012    context: &ExprValidationContext,
12013    diagnostics: &mut Vec<Diagnostic>,
12014) -> ExprType {
12015    match expr {
12016        Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)
12017            .map(|ty| expr_type_from_type_syntax(&ty, semantic))
12018            .unwrap_or_else(|| expr_literal_type(&ExprLiteral::Ident(name.clone()))),
12019        Expr::Literal(literal) => expr_literal_type(literal),
12020        Expr::Path(path) => expr_path_type(path, semantic, scope).unwrap_or(ExprType::Unknown),
12021        Expr::Index { target, key } => {
12022            let target_ty = infer_expr_type(target, semantic, scope, context, diagnostics);
12023            let key_ty = infer_expr_type(key, semantic, scope, context, diagnostics);
12024            if !matches!(key_ty, ExprType::String | ExprType::Unknown) {
12025                diagnostics.push(Diagnostic {
12026                    related: Vec::new(),
12027                    span: context.span,
12028                    message: format!("{} indexes a map with a non-string key", context.subject),
12029                    suggestion: Some(
12030                        "use a string literal or string expression as the map key".to_owned(),
12031                    ),
12032                });
12033            }
12034            match target_ty {
12035                ExprType::Map(inner) => *inner,
12036                ExprType::Unknown => ExprType::Unknown,
12037                _ => {
12038                    diagnostics.push(Diagnostic {
12039                        related: Vec::new(),
12040                        span: context.span,
12041                        message: format!("{} indexes a non-map expression", context.subject),
12042                        suggestion: Some("use indexing only on map values".to_owned()),
12043                    });
12044                    ExprType::Unknown
12045                }
12046            }
12047        }
12048        Expr::Array(items) => infer_array_type(items, semantic, scope, context, diagnostics),
12049        Expr::Object(fields) => {
12050            for field in fields {
12051                infer_expr_type(&field.value, semantic, scope, context, diagnostics);
12052            }
12053            ExprType::Object
12054        }
12055        Expr::Unary {
12056            op: UnaryOp::Not,
12057            expr,
12058        } => {
12059            let inner = infer_expr_type(expr, semantic, scope, context, diagnostics);
12060            if !matches!(inner, ExprType::Bool | ExprType::Unknown) {
12061                diagnostics.push(Diagnostic {
12062                    related: Vec::new(),
12063                    span: context.span,
12064                    message: format!(
12065                        "{} applies `!` to a non-boolean expression",
12066                        context.subject
12067                    ),
12068                    suggestion: Some("use `!` only with boolean expressions".to_owned()),
12069                });
12070            }
12071            ExprType::Bool
12072        }
12073        Expr::Binary { op, left, right } => {
12074            infer_binary_type(*op, left, right, semantic, scope, context, diagnostics)
12075        }
12076        Expr::Call { name, args } => match name.as_str() {
12077            "count" => ExprType::Int,
12078            "exists" => ExprType::Bool,
12079            "empty" => ExprType::Bool,
12080            _ => {
12081                diagnostics.push(Diagnostic {
12082                    related: Vec::new(),
12083                    span: context.span,
12084                    message: format!(
12085                        "{} calls unsupported expression function `{name}`",
12086                        context.subject
12087                    ),
12088                    suggestion: Some("use `count`, `exists`, or `empty`".to_owned()),
12089                });
12090                for arg in args {
12091                    infer_expr_type(arg, semantic, scope, context, diagnostics);
12092                }
12093                ExprType::Unknown
12094            }
12095        },
12096        Expr::Query { guard, .. } => {
12097            if let Some(guard) = guard {
12098                let guard_scope = query_guard_scope(expr, semantic, scope);
12099                infer_expr_type(guard, semantic, &guard_scope, context, diagnostics);
12100            }
12101            ExprType::Collection
12102        }
12103    }
12104}
12105
12106fn infer_binary_type(
12107    op: BinaryOp,
12108    left: &Expr,
12109    right: &Expr,
12110    semantic: &SemanticContext,
12111    scope: &ExprScope,
12112    context: &ExprValidationContext,
12113    diagnostics: &mut Vec<Diagnostic>,
12114) -> ExprType {
12115    let left_ty = infer_expr_type(left, semantic, scope, context, diagnostics);
12116    let right_ty = infer_expr_type(right, semantic, scope, context, diagnostics);
12117    match op {
12118        BinaryOp::And | BinaryOp::Or => {
12119            for ty in [&left_ty, &right_ty] {
12120                if !matches!(ty, ExprType::Bool | ExprType::Unknown) {
12121                    diagnostics.push(Diagnostic {
12122                        related: Vec::new(),
12123                        span: context.span,
12124                        message: format!(
12125                            "{} uses boolean operator with non-boolean operand",
12126                            context.subject
12127                        ),
12128                        suggestion: Some(
12129                            "use `&&` and `||` only with boolean expressions".to_owned(),
12130                        ),
12131                    });
12132                    break;
12133                }
12134            }
12135            ExprType::Bool
12136        }
12137        BinaryOp::Eq | BinaryOp::Ne => {
12138            if !types_comparable(&left_ty, &right_ty) {
12139                diagnostics.push(Diagnostic {
12140                    related: Vec::new(),
12141                    span: context.span,
12142                    message: format!("{} compares incompatible expression types", context.subject),
12143                    suggestion: Some(
12144                        "compare values with compatible scalar or finite-domain types".to_owned(),
12145                    ),
12146                });
12147            }
12148            ExprType::Bool
12149        }
12150        BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => {
12151            if !is_orderable_pair(&left_ty, &right_ty) {
12152                diagnostics.push(Diagnostic {
12153                    related: Vec::new(),
12154                    span: context.span,
12155                    message: format!("{} orders non-orderable expression values", context.subject),
12156                    suggestion: Some(
12157                        "use ordering only with int, float, duration, or time values".to_owned(),
12158                    ),
12159                });
12160            }
12161            ExprType::Bool
12162        }
12163        BinaryOp::In | BinaryOp::NotIn => {
12164            match &right_ty {
12165                ExprType::Array(item_ty) => {
12166                    if !types_comparable(&left_ty, item_ty) {
12167                        diagnostics.push(Diagnostic {
12168                            related: Vec::new(),
12169                            span: context.span,
12170                            message: format!(
12171                                "{} uses membership with incompatible item type",
12172                                context.subject
12173                            ),
12174                            suggestion: Some(
12175                                "make the left value compatible with the array item type"
12176                                    .to_owned(),
12177                            ),
12178                        });
12179                    }
12180                }
12181                ExprType::Map(_) => {
12182                    if !is_string_like_key_type(&left_ty) {
12183                        diagnostics.push(Diagnostic {
12184                            related: Vec::new(),
12185                            span: context.span,
12186                            message: format!(
12187                                "{} uses map membership with a non-string key",
12188                                context.subject
12189                            ),
12190                            suggestion: Some(
12191                                "use a string value on the left side of map membership".to_owned(),
12192                            ),
12193                        });
12194                    }
12195                }
12196                ExprType::Unknown => {}
12197                _ => diagnostics.push(Diagnostic {
12198                    related: Vec::new(),
12199                    span: context.span,
12200                    message: format!(
12201                        "{} uses membership against a non-array/non-map expression",
12202                        context.subject
12203                    ),
12204                    suggestion: Some(
12205                        "use `in` with an array literal, array value, or map value".to_owned(),
12206                    ),
12207                }),
12208            }
12209            ExprType::Bool
12210        }
12211        BinaryOp::Add | BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div => {
12212            for ty in [&left_ty, &right_ty] {
12213                if !matches!(ty, ExprType::Int | ExprType::Float | ExprType::Unknown) {
12214                    diagnostics.push(Diagnostic {
12215                        related: Vec::new(),
12216                        span: context.span,
12217                        message: format!(
12218                            "{} uses arithmetic with a non-numeric operand",
12219                            context.subject
12220                        ),
12221                        suggestion: Some("use `+ - * /` only with int or float values".to_owned()),
12222                    });
12223                    break;
12224                }
12225            }
12226            if matches!(left_ty, ExprType::Float) || matches!(right_ty, ExprType::Float) {
12227                ExprType::Float
12228            } else if matches!(left_ty, ExprType::Int) && matches!(right_ty, ExprType::Int) {
12229                ExprType::Int
12230            } else {
12231                ExprType::Unknown
12232            }
12233        }
12234    }
12235}
12236
12237fn infer_array_type(
12238    items: &[Expr],
12239    semantic: &SemanticContext,
12240    scope: &ExprScope,
12241    context: &ExprValidationContext,
12242    diagnostics: &mut Vec<Diagnostic>,
12243) -> ExprType {
12244    let mut item_ty: Option<ExprType> = None;
12245    for item in items {
12246        let ty = infer_expr_type(item, semantic, scope, context, diagnostics);
12247        if matches!(ty, ExprType::Unknown) {
12248            continue;
12249        }
12250        match &item_ty {
12251            None => item_ty = Some(ty),
12252            Some(existing) if types_comparable(existing, &ty) => {}
12253            Some(_) => {
12254                diagnostics.push(Diagnostic {
12255                    related: Vec::new(),
12256                    span: context.span,
12257                    message: format!("{} has mixed-type array literal", context.subject),
12258                    suggestion: Some("use array literals whose elements share one type".to_owned()),
12259                });
12260                return ExprType::Array(Box::new(ExprType::Unknown));
12261            }
12262        }
12263    }
12264    ExprType::Array(Box::new(item_ty.unwrap_or(ExprType::Unknown)))
12265}
12266
12267fn expr_path_type(
12268    path: &[String],
12269    semantic: &SemanticContext,
12270    scope: &ExprScope,
12271) -> Option<ExprType> {
12272    if path.len() < 2 {
12273        return None;
12274    }
12275    if let Some(schema) = scope.binding_types.get(&path[0]) {
12276        if schema.contains('.') {
12277            // Untyped runtime fact binding (general `when fact <name>`).
12278            return Some(ExprType::Unknown);
12279        }
12280        return semantic
12281            .schemas
12282            .resolve_field_path(schema, &path[1..])
12283            .ok()
12284            .map(|ty| expr_type_from_type_syntax(&ty, semantic));
12285    }
12286    let schema = scope.implicit_schema.as_ref()?;
12287    if schema.contains('.') {
12288        return Some(ExprType::Unknown);
12289    }
12290    semantic
12291        .schemas
12292        .resolve_field_path(schema, path)
12293        .ok()
12294        .map(|ty| expr_type_from_type_syntax(&ty, semantic))
12295}
12296
12297fn expr_type_from_type_syntax(ty: &TypeSyntax, semantic: &SemanticContext) -> ExprType {
12298    match ty {
12299        TypeSyntax::Primitive { name, .. } => match name.as_str() {
12300            "bool" => ExprType::Bool,
12301            "int" => ExprType::Int,
12302            "float" => ExprType::Float,
12303            "string" => ExprType::String,
12304            "duration" => ExprType::Duration,
12305            "time" => ExprType::Time,
12306            "secret" => ExprType::Secret,
12307            _ => ExprType::Unknown,
12308        },
12309        TypeSyntax::LiteralString { value, .. } => ExprType::Finite {
12310            label: "literal".to_owned(),
12311            values: vec![value.clone()],
12312        },
12313        TypeSyntax::AgentRef { agents, .. } => ExprType::Finite {
12314            label: "AgentRef".to_owned(),
12315            values: agents.iter().map(|agent| agent.name.clone()).collect(),
12316        },
12317        TypeSyntax::Ref { name } => semantic
12318            .schemas
12319            .enums
12320            .get(&name.name)
12321            .map(|variants| ExprType::Finite {
12322                label: format!("enum `{}`", name.name),
12323                values: variants.iter().cloned().collect(),
12324            })
12325            .unwrap_or(ExprType::Object),
12326        TypeSyntax::Optional { inner, .. } => {
12327            ExprType::Optional(Box::new(expr_type_from_type_syntax(inner, semantic)))
12328        }
12329        TypeSyntax::Array { inner, .. } => {
12330            ExprType::Array(Box::new(expr_type_from_type_syntax(inner, semantic)))
12331        }
12332        TypeSyntax::Map { inner, .. } => {
12333            ExprType::Map(Box::new(expr_type_from_type_syntax(inner, semantic)))
12334        }
12335        TypeSyntax::Union { variants, .. } => {
12336            let values = variants
12337                .iter()
12338                .filter_map(|variant| match variant {
12339                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
12340                    _ => None,
12341                })
12342                .collect::<Vec<_>>();
12343            if values.len() == variants.len() && !values.is_empty() {
12344                ExprType::Finite {
12345                    label: "literal union".to_owned(),
12346                    values,
12347                }
12348            } else {
12349                ExprType::Unknown
12350            }
12351        }
12352    }
12353}
12354
12355fn expr_literal_type(literal: &ExprLiteral) -> ExprType {
12356    match literal {
12357        ExprLiteral::String(_) | ExprLiteral::Ident(_) => ExprType::String,
12358        ExprLiteral::Number(value) if value.contains('.') => ExprType::Float,
12359        ExprLiteral::Number(_) => ExprType::Int,
12360        ExprLiteral::Bool(_) => ExprType::Bool,
12361        ExprLiteral::Null => ExprType::Null,
12362    }
12363}
12364
12365fn types_comparable(left: &ExprType, right: &ExprType) -> bool {
12366    if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
12367        return true;
12368    }
12369    if matches!(left, ExprType::Null) || matches!(right, ExprType::Null) {
12370        return true;
12371    }
12372    if is_numeric_type(left) && is_numeric_type(right) {
12373        return true;
12374    }
12375    match (left, right) {
12376        (ExprType::Optional(left), right) | (right, ExprType::Optional(left)) => {
12377            types_comparable(left, right)
12378        }
12379        (ExprType::Finite { .. }, ExprType::String)
12380        | (ExprType::String, ExprType::Finite { .. })
12381        | (ExprType::Finite { .. }, ExprType::Finite { .. }) => true,
12382        _ => left == right,
12383    }
12384}
12385
12386fn is_numeric_type(ty: &ExprType) -> bool {
12387    matches!(ty, ExprType::Int | ExprType::Float)
12388}
12389
12390fn is_string_like_key_type(ty: &ExprType) -> bool {
12391    match ty {
12392        ExprType::String | ExprType::Unknown | ExprType::Finite { .. } => true,
12393        ExprType::Optional(inner) => is_string_like_key_type(inner),
12394        _ => false,
12395    }
12396}
12397
12398fn is_orderable_pair(left: &ExprType, right: &ExprType) -> bool {
12399    if matches!(left, ExprType::Unknown) || matches!(right, ExprType::Unknown) {
12400        return true;
12401    }
12402    (is_numeric_type(left) && is_numeric_type(right))
12403        || matches!(
12404            (left, right),
12405            (ExprType::Duration, ExprType::Duration)
12406                | (ExprType::Time, ExprType::Time)
12407                // A quoted ISO-8601 string in a time-typed comparison is a
12408                // time literal (spec/scheduled-time.md).
12409                | (ExprType::Time, ExprType::String)
12410                | (ExprType::String, ExprType::Time)
12411        )
12412}
12413
12414fn is_countable_type(ty: &ExprType) -> bool {
12415    matches!(
12416        ty,
12417        ExprType::Array(_) | ExprType::Map(_) | ExprType::Collection | ExprType::Unknown
12418    )
12419}
12420
12421fn is_exists_type(ty: &ExprType) -> bool {
12422    matches!(
12423        ty,
12424        ExprType::Array(_)
12425            | ExprType::Map(_)
12426            | ExprType::Collection
12427            | ExprType::Optional(_)
12428            | ExprType::Unknown
12429    )
12430}
12431
12432/// Spec "Count And Empty": `empty` is a structural emptiness test for arrays,
12433/// maps, strings, fact/effect queries, and null; `empty(Optional<T>)` is
12434/// defined only when `empty(T)` is (so `empty(string?)` works, `empty(int?)`
12435/// does not). It never coerces scalars, objects, enum variants, or agent refs.
12436fn is_emptiable_type(ty: &ExprType) -> bool {
12437    match ty {
12438        ExprType::Array(_)
12439        | ExprType::Map(_)
12440        | ExprType::String
12441        | ExprType::Collection
12442        | ExprType::Null
12443        | ExprType::Unknown => true,
12444        ExprType::Optional(inner) => is_emptiable_type(inner),
12445        _ => false,
12446    }
12447}
12448
12449fn expr_type_label(ty: &ExprType) -> String {
12450    match ty {
12451        ExprType::Bool => "bool".to_owned(),
12452        ExprType::Int => "int".to_owned(),
12453        ExprType::Float => "float".to_owned(),
12454        ExprType::String => "string".to_owned(),
12455        ExprType::Finite { label, values } => format!("{label}<{}>", values.join(" | ")),
12456        ExprType::Duration => "duration".to_owned(),
12457        ExprType::Time => "time".to_owned(),
12458        ExprType::Secret => "secret".to_owned(),
12459        ExprType::Null => "null".to_owned(),
12460        ExprType::Object => "object".to_owned(),
12461        ExprType::Array(inner) => format!("{}[]", expr_type_label(inner)),
12462        ExprType::Map(inner) => format!("map<{}>", expr_type_label(inner)),
12463        ExprType::Optional(inner) => format!("{}?", expr_type_label(inner)),
12464        ExprType::Collection => "query".to_owned(),
12465        ExprType::Unknown => "unknown".to_owned(),
12466    }
12467}
12468
12469fn validate_finite_domain_expr(
12470    op: BinaryOp,
12471    left: &Expr,
12472    right: &Expr,
12473    semantic: &SemanticContext,
12474    scope: &ExprScope,
12475    context: &ExprValidationContext,
12476    diagnostics: &mut Vec<Diagnostic>,
12477) {
12478    if !matches!(
12479        op,
12480        BinaryOp::Eq | BinaryOp::Ne | BinaryOp::In | BinaryOp::NotIn
12481    ) {
12482        return;
12483    }
12484    let Some((domain, literals)) = finite_domain_comparison(left, right, semantic, scope)
12485        .or_else(|| finite_domain_comparison(right, left, semantic, scope))
12486    else {
12487        validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
12488        return;
12489    };
12490    for literal in literals.into_iter().flatten() {
12491        if !domain.iter().any(|value| value == &literal) {
12492            diagnostics.push(Diagnostic {
12493                related: Vec::new(),
12494                span: context.span,
12495                message: format!(
12496                    "{} compares finite-domain value to unknown `{literal}`",
12497                    context.subject
12498                ),
12499                suggestion: Some(format!("use one of: {}", domain.join(", "))),
12500            });
12501        }
12502    }
12503    validate_finite_domain_relation(op, left, right, semantic, scope, context, diagnostics);
12504}
12505
12506fn validate_finite_domain_relation(
12507    op: BinaryOp,
12508    left: &Expr,
12509    right: &Expr,
12510    semantic: &SemanticContext,
12511    scope: &ExprScope,
12512    context: &ExprValidationContext,
12513    diagnostics: &mut Vec<Diagnostic>,
12514) {
12515    match op {
12516        BinaryOp::Eq => {
12517            let Some(left_domain) = expr_domain(left, semantic, scope) else {
12518                return;
12519            };
12520            let Some(right_domain) = expr_domain(right, semantic, scope) else {
12521                return;
12522            };
12523            if left_domain
12524                .iter()
12525                .all(|value| !right_domain.iter().any(|right| right == value))
12526            {
12527                diagnostics.push(Diagnostic {
12528                    related: Vec::new(),
12529                    span: context.span,
12530                    message: format!(
12531                        "{} has statically unsatisfiable finite-domain equality",
12532                        context.subject
12533                    ),
12534                    suggestion: Some(format!(
12535                        "compare domains with at least one shared value; left: {}, right: {}",
12536                        left_domain.join(", "),
12537                        right_domain.join(", ")
12538                    )),
12539                });
12540            }
12541        }
12542        BinaryOp::In => {
12543            let Some(domain) = expr_domain(left, semantic, scope) else {
12544                return;
12545            };
12546            let Some(literals) = literal_array_values(right) else {
12547                return;
12548            };
12549            if literals
12550                .iter()
12551                .all(|literal| !domain.iter().any(|value| value == literal))
12552            {
12553                diagnostics.push(Diagnostic {
12554                    related: Vec::new(),
12555                    span: context.span,
12556                    message: format!(
12557                        "{} has statically unsatisfiable finite-domain membership",
12558                        context.subject
12559                    ),
12560                    suggestion: Some(format!("use one of: {}", domain.join(", "))),
12561                });
12562            }
12563        }
12564        BinaryOp::NotIn => {
12565            let Some(domain) = expr_domain(left, semantic, scope) else {
12566                return;
12567            };
12568            let Some(literals) = literal_array_values(right) else {
12569                return;
12570            };
12571            if !domain.is_empty()
12572                && domain
12573                    .iter()
12574                    .all(|value| literals.iter().any(|literal| literal == value))
12575            {
12576                diagnostics.push(Diagnostic {
12577                    related: Vec::new(),
12578                    span: context.span,
12579                    message: format!(
12580                        "{} has statically unsatisfiable finite-domain exclusion",
12581                        context.subject
12582                    ),
12583                    suggestion: Some(
12584                        "leave at least one domain value outside the exclusion set".to_owned(),
12585                    ),
12586                });
12587            }
12588        }
12589        _ => {}
12590    }
12591}
12592
12593fn finite_domain_comparison(
12594    domain_expr: &Expr,
12595    literal_expr: &Expr,
12596    semantic: &SemanticContext,
12597    scope: &ExprScope,
12598) -> Option<(Vec<String>, Vec<Option<String>>)> {
12599    let domain = expr_domain(domain_expr, semantic, scope)?;
12600    let literals = match literal_expr {
12601        Expr::Literal(literal) => vec![expr_literal_name(literal)],
12602        Expr::Array(items) => items
12603            .iter()
12604            .filter_map(|item| match item {
12605                Expr::Literal(literal) => Some(expr_literal_name(literal)),
12606                _ => None,
12607            })
12608            .collect(),
12609        _ => Vec::new(),
12610    };
12611    Some((domain, literals))
12612}
12613
12614fn expr_domain(expr: &Expr, semantic: &SemanticContext, scope: &ExprScope) -> Option<Vec<String>> {
12615    let ty = match expr {
12616        Expr::Path(path) => {
12617            let root = path.first()?;
12618            if let Some(schema) = scope.binding_types.get(root) {
12619                semantic
12620                    .schemas
12621                    .resolve_field_path(schema, path.get(1..)?)
12622                    .ok()?
12623            } else {
12624                let schema = scope.implicit_schema.as_ref()?;
12625                semantic.schemas.resolve_field_path(schema, path).ok()?
12626            }
12627        }
12628        Expr::Literal(ExprLiteral::Ident(name)) => implicit_field_type(name, semantic, scope)?,
12629        _ => return None,
12630    };
12631    finite_expr_domain(&ty, semantic)
12632}
12633
12634fn finite_expr_domain(ty: &TypeSyntax, semantic: &SemanticContext) -> Option<Vec<String>> {
12635    match ty {
12636        TypeSyntax::Ref { name } => semantic
12637            .schemas
12638            .enums
12639            .get(&name.name)
12640            .map(|variants| variants.iter().cloned().collect()),
12641        TypeSyntax::Union { variants, .. } => {
12642            let values = variants
12643                .iter()
12644                .filter_map(|variant| match variant {
12645                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
12646                    _ => None,
12647                })
12648                .collect::<Vec<_>>();
12649            (!values.is_empty()).then_some(values)
12650        }
12651        TypeSyntax::AgentRef { agents, .. } => {
12652            Some(agents.iter().map(|agent| agent.name.clone()).collect())
12653        }
12654        _ => None,
12655    }
12656}
12657
12658fn expr_literal_name(literal: &ExprLiteral) -> Option<String> {
12659    match literal {
12660        ExprLiteral::String(value) | ExprLiteral::Ident(value) => Some(value.clone()),
12661        _ => None,
12662    }
12663}
12664
12665fn literal_array_values(expr: &Expr) -> Option<Vec<String>> {
12666    let Expr::Array(items) = expr else {
12667        return None;
12668    };
12669    items
12670        .iter()
12671        .map(|item| match item {
12672            Expr::Literal(literal) => expr_literal_name(literal),
12673            _ => None,
12674        })
12675        .collect()
12676}
12677
12678fn parse_tell_target(line: &str) -> Option<&str> {
12679    line.strip_prefix("tell ")?
12680        .split_whitespace()
12681        .next()
12682        .filter(|target| !target.is_empty())
12683}
12684
12685fn parse_required_capabilities(line: &str) -> Vec<String> {
12686    let Some(rest) = line.split_once(" requires ") else {
12687        return Vec::new();
12688    };
12689    let Some(list) = rest.1.trim_start().strip_prefix('[') else {
12690        return Vec::new();
12691    };
12692    let Some((items, _)) = list.split_once(']') else {
12693        return Vec::new();
12694    };
12695    let mut capabilities = items
12696        .split(',')
12697        .filter_map(|item| {
12698            let value = item.trim().trim_matches('"');
12699            (!value.is_empty()).then(|| value.to_owned())
12700        })
12701        .collect::<Vec<_>>();
12702    capabilities.sort();
12703    capabilities.dedup();
12704    capabilities
12705}
12706
12707fn validate_case_blocks(
12708    rule: &RuleDecl,
12709    semantic: &SemanticContext,
12710    binding_types: &BTreeMap<String, String>,
12711    diagnostics: &mut Vec<Diagnostic>,
12712) {
12713    let lines = rule
12714        .body
12715        .text
12716        .lines()
12717        .scan(0usize, |offset, line| {
12718            let current = *offset;
12719            *offset += line.len() + 1;
12720            Some((line, current))
12721        })
12722        .collect::<Vec<_>>();
12723    let text_lines = lines.iter().map(|(line, _)| *line).collect::<Vec<_>>();
12724    let mut index = 0usize;
12725    while index < lines.len() {
12726        let trimmed = lines[index].0.trim();
12727        let Some(scrutinee) = case_scrutinee(trimmed) else {
12728            index += 1;
12729            continue;
12730        };
12731        let scrutinee_ty = expression_type(scrutinee, semantic, binding_types);
12732        let terminal_case = scrutinee_ty.is_none()
12733            && active_completes_binding_for_case(&text_lines, index, scrutinee);
12734        if scrutinee_ty.is_none() && !terminal_case {
12735            diagnostics.push(Diagnostic {
12736                related: Vec::new(),
12737                span: rule.body.span,
12738                message: format!(
12739                    "rule `{}` has case scrutinee `{scrutinee}` that is not a typed path",
12740                    rule.name.name
12741                ),
12742                suggestion: Some("match on a bound field such as `task.provider`".to_owned()),
12743            });
12744        }
12745        let mut depth = brace_delta(trimmed).max(1);
12746        let mut case_index = index + 1;
12747        let mut branches = Vec::new();
12748        while case_index < lines.len() && depth > 0 {
12749            let (raw_line, line_offset) = lines[case_index];
12750            let line = raw_line.trim();
12751            if depth == 1 {
12752                if let Some(branch) = parse_case_branch_head(line) {
12753                    let pattern_column = case_pattern_column(raw_line, branch.pattern);
12754                    let branch = SpanCaseBranchHead {
12755                        pattern: branch.pattern,
12756                        guard: branch.guard,
12757                        pattern_span: SourceSpan {
12758                            start: rule_body_text_start(rule) + line_offset + pattern_column,
12759                            end: rule_body_text_start(rule)
12760                                + line_offset
12761                                + pattern_column
12762                                + branch.pattern.len(),
12763                        },
12764                    };
12765                    branches.push(branch);
12766                    if terminal_case {
12767                        validate_terminal_case_pattern(
12768                            rule,
12769                            branch.pattern,
12770                            branch.pattern_span,
12771                            diagnostics,
12772                        );
12773                    } else {
12774                        validate_case_pattern(
12775                            rule,
12776                            branch.pattern,
12777                            scrutinee_ty.as_ref(),
12778                            branch.pattern_span,
12779                            semantic,
12780                            diagnostics,
12781                        );
12782                    }
12783                    // Terminal-case guards are validated by
12784                    // `collect_terminal_case_metadata`, which is the only path
12785                    // with `effect_payload_types` and so the only one that can
12786                    // bind the tag-refined payload (`Completed as result where
12787                    // result.x ...`) into the guard scope. Validating them here
12788                    // too would reject that binding as an unknown root.
12789                    if let Some(guard) = branch.guard.filter(|_| !terminal_case) {
12790                        let mut branch_scope = binding_types.clone();
12791                        if let Some(scrutinee_ty) = scrutinee_ty.as_ref() {
12792                            if let Some((binding, schema)) =
12793                                case_branch_payload_binding(branch.pattern, scrutinee_ty, semantic)
12794                            {
12795                                branch_scope.insert(binding, schema);
12796                            }
12797                        }
12798                        validate_expression(
12799                            rule,
12800                            guard,
12801                            semantic,
12802                            &branch_scope,
12803                            "case guard",
12804                            diagnostics,
12805                        );
12806                        validate_known_field_paths_at_span(
12807                            rule,
12808                            guard,
12809                            branch.pattern_span,
12810                            semantic,
12811                            &branch_scope,
12812                            diagnostics,
12813                        );
12814                    }
12815                }
12816            }
12817            depth += brace_delta(line);
12818            case_index += 1;
12819        }
12820        if terminal_case {
12821            validate_terminal_case_coverage(rule, &branches, diagnostics);
12822        } else {
12823            validate_case_coverage(
12824                rule,
12825                scrutinee_ty.as_ref(),
12826                &branches,
12827                semantic,
12828                diagnostics,
12829            );
12830        }
12831        index += 1;
12832    }
12833}
12834
12835fn active_completes_binding_for_case(lines: &[&str], case_index: usize, scrutinee: &str) -> bool {
12836    let mut scopes: Vec<(String, DependencyPredicate, i32)> = Vec::new();
12837    for line in lines.iter().take(case_index) {
12838        let trimmed = line.trim();
12839        if let Some((binding, predicate)) = parse_after_line(trimmed) {
12840            scopes.push((binding, predicate, brace_delta(trimmed).max(1)));
12841        } else {
12842            let delta = brace_delta(trimmed);
12843            for (_, _, depth) in &mut scopes {
12844                *depth += delta;
12845            }
12846            scopes.retain(|(_, _, depth)| *depth > 0);
12847        }
12848    }
12849    scopes.iter().any(|(binding, predicate, _)| {
12850        binding == scrutinee && predicate == &DependencyPredicate::Completes
12851    })
12852}
12853
12854fn brace_delta(line: &str) -> i32 {
12855    line.chars().fold(0, |depth, ch| match ch {
12856        '{' => depth + 1,
12857        '}' => depth - 1,
12858        _ => depth,
12859    })
12860}
12861
12862fn case_scrutinee(line: &str) -> Option<&str> {
12863    let rest = line.strip_prefix("case ")?;
12864    let expr = rest.strip_suffix('{').unwrap_or(rest).trim();
12865    (!expr.is_empty()).then_some(expr)
12866}
12867
12868fn is_case_branch_start(line: &str) -> bool {
12869    line.contains("=>")
12870}
12871
12872#[derive(Clone, Copy)]
12873struct CaseBranchHead<'a> {
12874    pattern: &'a str,
12875    guard: Option<&'a str>,
12876}
12877
12878#[derive(Clone, Copy)]
12879struct SpanCaseBranchHead<'a> {
12880    pattern: &'a str,
12881    guard: Option<&'a str>,
12882    pattern_span: SourceSpan,
12883}
12884
12885fn parse_case_branch_head(line: &str) -> Option<CaseBranchHead<'_>> {
12886    let (pattern, _) = line.split_once("=>")?;
12887    let pattern = pattern.trim();
12888    if pattern.is_empty() {
12889        return None;
12890    }
12891    match pattern.split_once(" where ") {
12892        Some((pattern, guard)) => Some(CaseBranchHead {
12893            pattern: pattern.trim(),
12894            guard: Some(guard.trim()),
12895        }),
12896        None => Some(CaseBranchHead {
12897            pattern,
12898            guard: None,
12899        }),
12900    }
12901}
12902
12903fn expression_type(
12904    expr: &str,
12905    semantic: &SemanticContext,
12906    binding_types: &BTreeMap<String, String>,
12907) -> Option<TypeSyntax> {
12908    // A bare enum-typed binding is a valid scrutinee: `case o` dispatches a
12909    // sum-type payload (spec/sum-types.md). Class-typed bare bindings stay
12910    // untyped here so the "match on a bound field" guidance still fires.
12911    let is_bare_ident = !expr.is_empty()
12912        && expr.chars().all(|ch| ch.is_alphanumeric() || ch == '_')
12913        && expr.chars().next().is_some_and(char::is_alphabetic);
12914    if is_bare_ident {
12915        let schema = binding_types.get(expr)?;
12916        if semantic.schemas.enums.contains_key(schema) {
12917            return Some(TypeSyntax::Ref {
12918                name: Ident {
12919                    name: schema.clone(),
12920                    span: zero_span(),
12921                },
12922            });
12923        }
12924        return None;
12925    }
12926    let (root, path) = expression_path(expr)?;
12927    let schema = binding_types.get(&root)?;
12928    semantic.schemas.resolve_field_path(schema, &path).ok()
12929}
12930
12931fn validate_case_pattern(
12932    rule: &RuleDecl,
12933    pattern: &str,
12934    scrutinee_ty: Option<&TypeSyntax>,
12935    span: SourceSpan,
12936    semantic: &SemanticContext,
12937    diagnostics: &mut Vec<Diagnostic>,
12938) {
12939    if matches!(pattern, "_" | "default") {
12940        return;
12941    }
12942    if pattern == "None" {
12943        if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
12944            diagnostics.push(Diagnostic {
12945                related: Vec::new(),
12946                span,
12947                message: format!(
12948                    "rule `{}` uses `None` for a non-optional case",
12949                    rule.name.name
12950                ),
12951                suggestion: Some("use `None` only when matching an optional field".to_owned()),
12952            });
12953        }
12954        return;
12955    }
12956    if pattern.starts_with("Some ") {
12957        if !matches!(scrutinee_ty, Some(TypeSyntax::Optional { .. })) {
12958            diagnostics.push(Diagnostic {
12959                related: Vec::new(),
12960                span,
12961                message: format!(
12962                    "rule `{}` uses `Some` for a non-optional case",
12963                    rule.name.name
12964                ),
12965                suggestion: Some("use `Some name` only when matching an optional field".to_owned()),
12966            });
12967        }
12968        return;
12969    }
12970    let Some(scrutinee_ty) = scrutinee_ty else {
12971        return;
12972    };
12973    match scrutinee_ty {
12974        TypeSyntax::Ref { name } => {
12975            let Some(variants) = semantic.schemas.enums.get(&name.name) else {
12976                return;
12977            };
12978            let (variant, binding) = sum_case_pattern_parts(pattern);
12979            if !variants.contains(variant) {
12980                diagnostics.push(Diagnostic {
12981                    related: Vec::new(),
12982                    span,
12983                    message: format!("enum `{}` has no variant `{variant}`", name.name),
12984                    suggestion: Some(format!(
12985                        "use one of: {}",
12986                        variants.iter().cloned().collect::<Vec<_>>().join(", ")
12987                    )),
12988                });
12989                return;
12990            }
12991            // `as` binds a data-carrying variant's payload (spec/sum-types.md);
12992            // a bare variant has no payload to bind.
12993            if binding.is_some()
12994                && !semantic
12995                    .schemas
12996                    .class_exists(&format!("{}.{variant}", name.name))
12997            {
12998                diagnostics.push(Diagnostic {
12999                    related: Vec::new(),
13000                    span,
13001                    message: format!(
13002                        "variant `{variant}` of enum `{}` carries no payload to bind",
13003                        name.name
13004                    ),
13005                    suggestion: Some(format!("write `{variant} => {{ ... }}` without `as`")),
13006                });
13007            }
13008        }
13009        TypeSyntax::Union { variants, .. } => {
13010            let Some(literal) = parse_literal_expr(pattern) else {
13011                diagnostics.push(Diagnostic {
13012                    related: Vec::new(),
13013                    span,
13014                    message: format!(
13015                        "rule `{}` has unsupported case pattern `{pattern}`",
13016                        rule.name.name
13017                    ),
13018                    suggestion: Some("use a literal branch value or `_`".to_owned()),
13019                });
13020                return;
13021            };
13022            validate_union_case_pattern(rule, variants, &literal, span, diagnostics);
13023        }
13024        TypeSyntax::AgentRef { agents, .. } => {
13025            let Some(literal) = parse_literal_expr(pattern) else {
13026                diagnostics.push(Diagnostic {
13027                    related: Vec::new(),
13028                    span,
13029                    message: format!(
13030                        "rule `{}` has unsupported AgentRef case pattern `{pattern}`",
13031                        rule.name.name
13032                    ),
13033                    suggestion: Some(
13034                        "use a declared agent name, a string literal, or `_`".to_owned(),
13035                    ),
13036                });
13037                return;
13038            };
13039            validate_agent_ref_case_pattern(rule, agents, &literal, span, diagnostics);
13040        }
13041        TypeSyntax::Optional { inner, .. } => {
13042            validate_case_pattern(rule, pattern, Some(inner), span, semantic, diagnostics);
13043        }
13044        // `case` over a `bool` field: only the two literals `true`/`false` (plus
13045        // the `_`/`default` fallbacks already handled above) are valid patterns.
13046        TypeSyntax::Primitive { name, .. } if name == "bool" => {
13047            if !matches!(pattern, "true" | "false") {
13048                diagnostics.push(Diagnostic {
13049                    related: Vec::new(),
13050                    span,
13051                    message: format!(
13052                        "rule `{}` has case pattern `{pattern}` that is not a `bool` value",
13053                        rule.name.name
13054                    ),
13055                    suggestion: Some("match `true`, `false`, or `_`".to_owned()),
13056                });
13057            }
13058        }
13059        _ => {
13060            diagnostics.push(Diagnostic {
13061                related: Vec::new(),
13062                span,
13063                message: format!(
13064                    "rule `{}` cannot pattern-match this scrutinee type",
13065                    rule.name.name
13066                ),
13067                suggestion: Some(
13068                    "match an enum, literal union, optional, or tagged output union".to_owned(),
13069                ),
13070            });
13071        }
13072    }
13073}
13074
13075fn terminal_case_tags() -> [&'static str; 4] {
13076    ["Completed", "Failed", "TimedOut", "Cancelled"]
13077}
13078
13079fn validate_terminal_case_pattern(
13080    rule: &RuleDecl,
13081    pattern: &str,
13082    span: SourceSpan,
13083    diagnostics: &mut Vec<Diagnostic>,
13084) {
13085    if is_fallback_pattern(pattern) {
13086        return;
13087    }
13088    let mut parts = pattern.split_whitespace();
13089    let Some(tag) = parts.next() else {
13090        return;
13091    };
13092    // Binding is `Tag as binding` (Stage 1b: the legacy space form `Tag binding` is
13093    // no longer accepted — it aligns terminal cases with enum-variant `as` binding).
13094    let second = parts.next();
13095    let binding = match second {
13096        Some("as") => parts.next(),
13097        other => other,
13098    };
13099    let uses_as = matches!(second, Some("as"));
13100    if parts.next().is_some() || binding.is_none() || !uses_as {
13101        diagnostics.push(Diagnostic { related: Vec::new(),
13102            span,
13103            message: format!(
13104                "rule `{}` has malformed terminal-output case pattern `{pattern}`",
13105                rule.name.name
13106            ),
13107            suggestion: Some("write `Completed as result`, `Failed as failure`, `TimedOut as timeout`, or `Cancelled as cancel` (the `as` is required)".to_owned()),
13108        });
13109        return;
13110    }
13111    let tags = terminal_case_tags();
13112    if !tags.contains(&tag) {
13113        diagnostics.push(Diagnostic {
13114            related: Vec::new(),
13115            span,
13116            message: format!(
13117                "rule `{}` terminal-output case pattern cannot be `{tag}`",
13118                rule.name.name
13119            ),
13120            suggestion: Some(format!("use one of: {}", tags.join(", "))),
13121        });
13122    }
13123}
13124
13125fn validate_terminal_case_coverage(
13126    rule: &RuleDecl,
13127    branches: &[SpanCaseBranchHead<'_>],
13128    diagnostics: &mut Vec<Diagnostic>,
13129) {
13130    validate_unreachable_after_fallback(rule, branches, diagnostics);
13131    if branches.is_empty()
13132        || branches
13133            .iter()
13134            .any(|branch| is_fallback_pattern(branch.pattern))
13135    {
13136        validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
13137        return;
13138    }
13139    validate_duplicate_terminal_case_patterns(rule, branches, diagnostics);
13140    let covered = branches
13141        .iter()
13142        .filter(|branch| branch.guard.is_none())
13143        .filter_map(|branch| normalized_terminal_case_pattern(branch.pattern))
13144        .collect::<BTreeSet<_>>();
13145    let missing = terminal_case_tags()
13146        .iter()
13147        .filter(|tag| !covered.contains(**tag))
13148        .copied()
13149        .collect::<Vec<_>>();
13150    if !missing.is_empty() {
13151        diagnostics.push(Diagnostic {
13152            related: Vec::new(),
13153            span: rule.body.span,
13154            message: format!(
13155                "rule `{}` has non-exhaustive terminal-output case; missing {}",
13156                rule.name.name,
13157                missing.join(", ")
13158            ),
13159            suggestion: Some(
13160                "add terminal branches for every value or add `_ => { ... }`".to_owned(),
13161            ),
13162        });
13163    }
13164}
13165
13166fn validate_duplicate_terminal_case_patterns(
13167    rule: &RuleDecl,
13168    branches: &[SpanCaseBranchHead<'_>],
13169    diagnostics: &mut Vec<Diagnostic>,
13170) {
13171    let mut seen = BTreeSet::new();
13172    for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
13173        let Some(pattern) = normalized_terminal_case_pattern(branch.pattern) else {
13174            continue;
13175        };
13176        if !seen.insert(pattern.to_owned()) {
13177            diagnostics.push(Diagnostic {
13178                related: Vec::new(),
13179                span: branch.pattern_span,
13180                message: format!(
13181                    "rule `{}` has duplicate unguarded terminal-output case pattern `{pattern}`",
13182                    rule.name.name
13183                ),
13184                suggestion: Some(
13185                    "remove the duplicate branch or add mutually exclusive `where` guards"
13186                        .to_owned(),
13187                ),
13188            });
13189        }
13190    }
13191}
13192
13193fn validate_case_coverage(
13194    rule: &RuleDecl,
13195    scrutinee_ty: Option<&TypeSyntax>,
13196    branches: &[SpanCaseBranchHead<'_>],
13197    semantic: &SemanticContext,
13198    diagnostics: &mut Vec<Diagnostic>,
13199) {
13200    validate_unreachable_after_fallback(rule, branches, diagnostics);
13201    if branches.is_empty()
13202        || branches
13203            .iter()
13204            .any(|branch| is_fallback_pattern(branch.pattern))
13205    {
13206        validate_duplicate_case_patterns(rule, branches, diagnostics);
13207        return;
13208    }
13209    validate_duplicate_case_patterns(rule, branches, diagnostics);
13210
13211    let Some(domain) = finite_case_domain(scrutinee_ty, semantic) else {
13212        return;
13213    };
13214    let covered = branches
13215        .iter()
13216        .filter(|branch| branch.guard.is_none())
13217        .filter_map(|branch| normalized_case_pattern(branch.pattern))
13218        .collect::<BTreeSet<_>>();
13219    let missing = domain
13220        .iter()
13221        .filter(|value| !covered.contains(value.as_str()))
13222        .cloned()
13223        .collect::<Vec<_>>();
13224    if !missing.is_empty() {
13225        diagnostics.push(Diagnostic {
13226            related: Vec::new(),
13227            span: rule.body.span,
13228            message: format!(
13229                "rule `{}` has non-exhaustive case; missing {}",
13230                rule.name.name,
13231                missing.join(", ")
13232            ),
13233            suggestion: Some("add branches for every value or add `_ => { ... }`".to_owned()),
13234        });
13235    }
13236}
13237
13238fn validate_duplicate_case_patterns(
13239    rule: &RuleDecl,
13240    branches: &[SpanCaseBranchHead<'_>],
13241    diagnostics: &mut Vec<Diagnostic>,
13242) {
13243    let mut seen = BTreeSet::new();
13244    for branch in branches.iter().filter(|branch| branch.guard.is_none()) {
13245        let Some(pattern) = normalized_case_pattern(branch.pattern) else {
13246            continue;
13247        };
13248        if !seen.insert(pattern.to_owned()) {
13249            diagnostics.push(Diagnostic {
13250                related: Vec::new(),
13251                span: branch.pattern_span,
13252                message: format!(
13253                    "rule `{}` has duplicate unguarded case pattern `{pattern}`",
13254                    rule.name.name
13255                ),
13256                suggestion: Some(
13257                    "remove the duplicate branch or add mutually exclusive `where` guards"
13258                        .to_owned(),
13259                ),
13260            });
13261        }
13262    }
13263}
13264
13265/// Flags case branches that can never be reached because an earlier *unguarded*
13266/// wildcard (`_`/`default`) already matches everything. Shared by rule cases and
13267/// terminal-output cases. Mirrors case-family.maude inv c (redundant-postwild): any
13268/// arm after the wildcard is redundant. A *guarded* fallback (`_ where g`) does not
13269/// shadow, since its guard can fail at runtime.
13270fn validate_unreachable_after_fallback(
13271    rule: &RuleDecl,
13272    branches: &[SpanCaseBranchHead<'_>],
13273    diagnostics: &mut Vec<Diagnostic>,
13274) {
13275    let mut ordered: Vec<&SpanCaseBranchHead<'_>> = branches.iter().collect();
13276    ordered.sort_by_key(|branch| branch.pattern_span.start);
13277    let mut fallback_span: Option<SourceSpan> = None;
13278    for branch in ordered {
13279        if let Some(prior) = fallback_span {
13280            diagnostics.push(
13281                Diagnostic {
13282                    related: Vec::new(),
13283                    span: branch.pattern_span,
13284                    message: format!(
13285                        "rule `{}` has an unreachable case branch after the `_` wildcard",
13286                        rule.name.name
13287                    ),
13288                    suggestion: Some(
13289                        "move this branch before the wildcard, or remove it".to_owned(),
13290                    ),
13291                }
13292                .with_related(
13293                    prior,
13294                    "this unguarded wildcard already matches every remaining value",
13295                ),
13296            );
13297        } else if branch.guard.is_none() && is_fallback_pattern(branch.pattern) {
13298            fallback_span = Some(branch.pattern_span);
13299        }
13300    }
13301}
13302
13303fn finite_case_domain(
13304    scrutinee_ty: Option<&TypeSyntax>,
13305    semantic: &SemanticContext,
13306) -> Option<Vec<String>> {
13307    match scrutinee_ty? {
13308        TypeSyntax::Ref { name } => semantic
13309            .schemas
13310            .enums
13311            .get(&name.name)
13312            .map(|variants| variants.iter().cloned().collect()),
13313        TypeSyntax::Union { variants, .. } => {
13314            let values = variants
13315                .iter()
13316                .filter_map(|variant| match variant {
13317                    TypeSyntax::LiteralString { value, .. } => Some(value.clone()),
13318                    _ => None,
13319                })
13320                .collect::<Vec<_>>();
13321            (!values.is_empty()).then_some(values)
13322        }
13323        TypeSyntax::Optional { .. } => Some(vec!["Some".to_owned(), "None".to_owned()]),
13324        TypeSyntax::AgentRef { agents, .. } => {
13325            Some(agents.iter().map(|agent| agent.name.clone()).collect())
13326        }
13327        // `bool` is a finite two-value domain: an exhaustive `case` over it must
13328        // cover both `true` and `false` (or carry a `_`).
13329        TypeSyntax::Primitive { name, .. } if name == "bool" => {
13330            Some(vec!["true".to_owned(), "false".to_owned()])
13331        }
13332        _ => None,
13333    }
13334}
13335
13336/// Splits a sum-type case pattern `Variant as binding` into variant and
13337/// binding (spec/sum-types.md); a plain pattern returns no binding.
13338fn sum_case_pattern_parts(pattern: &str) -> (&str, Option<&str>) {
13339    match pattern.split_once(" as ") {
13340        Some((variant, binding)) => (variant.trim(), Some(binding.trim())),
13341        None => (pattern.trim(), None),
13342    }
13343}
13344
13345fn normalized_case_pattern(pattern: &str) -> Option<&str> {
13346    if is_fallback_pattern(pattern) {
13347        return None;
13348    }
13349    if pattern.starts_with("Some ") {
13350        return Some("Some");
13351    }
13352    if pattern == "None" {
13353        return Some("None");
13354    }
13355    // Coverage counts the variant, not its payload binding.
13356    let (pattern, _) = sum_case_pattern_parts(pattern);
13357    // `bool` literals parse to the value-less `LiteralExpr::Bool`; return them
13358    // verbatim so they count toward `true`/`false` coverage.
13359    if matches!(pattern, "true" | "false") {
13360        return Some(pattern);
13361    }
13362    parse_literal_expr(pattern).and_then(|literal| match literal {
13363        LiteralExpr::String(value) | LiteralExpr::Ident(value) => Some(value),
13364        _ => None,
13365    })
13366}
13367
13368fn normalized_terminal_case_pattern(pattern: &str) -> Option<&str> {
13369    if is_fallback_pattern(pattern) {
13370        return None;
13371    }
13372    pattern.split_whitespace().next()
13373}
13374
13375fn is_fallback_pattern(pattern: &str) -> bool {
13376    matches!(pattern, "_" | "default")
13377}
13378
13379fn validate_union_case_pattern(
13380    rule: &RuleDecl,
13381    variants: &[TypeSyntax],
13382    literal: &LiteralExpr<'_>,
13383    span: SourceSpan,
13384    diagnostics: &mut Vec<Diagnostic>,
13385) {
13386    let allowed = variants
13387        .iter()
13388        .filter_map(|variant| match variant {
13389            TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
13390            _ => None,
13391        })
13392        .collect::<Vec<_>>();
13393    if allowed.is_empty() {
13394        return;
13395    }
13396    let LiteralExpr::String(value) = literal else {
13397        diagnostics.push(Diagnostic {
13398            related: Vec::new(),
13399            span,
13400            message: format!(
13401                "rule `{}` case pattern must be one of its literal variants",
13402                rule.name.name
13403            ),
13404            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
13405        });
13406        return;
13407    };
13408    if !allowed.contains(value) {
13409        diagnostics.push(Diagnostic {
13410            related: Vec::new(),
13411            span,
13412            message: format!("rule `{}` case pattern cannot be `{value}`", rule.name.name),
13413            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
13414        });
13415    }
13416}
13417
13418fn validate_agent_ref_case_pattern(
13419    rule: &RuleDecl,
13420    agents: &[Ident],
13421    literal: &LiteralExpr<'_>,
13422    span: SourceSpan,
13423    diagnostics: &mut Vec<Diagnostic>,
13424) {
13425    let allowed = agents
13426        .iter()
13427        .map(|agent| agent.name.as_str())
13428        .collect::<Vec<_>>();
13429    let (LiteralExpr::String(value) | LiteralExpr::Ident(value)) = literal else {
13430        diagnostics.push(Diagnostic {
13431            related: Vec::new(),
13432            span,
13433            message: format!("rule `{}` has non-agent case pattern", rule.name.name),
13434            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
13435        });
13436        return;
13437    };
13438    if !allowed.contains(value) {
13439        diagnostics.push(Diagnostic {
13440            related: Vec::new(),
13441            span,
13442            message: format!("AgentRef has no agent `{value}`"),
13443            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
13444        });
13445    }
13446}
13447
13448fn validate_binding_uses(
13449    rule: &RuleDecl,
13450    line: &str,
13451    seen_bindings: &BTreeSet<String>,
13452    scope_stack: &[(String, DependencyPredicate)],
13453    diagnostics: &mut Vec<Diagnostic>,
13454) {
13455    for root in interpolation_roots(line) {
13456        if !seen_bindings.contains(&root) {
13457            continue;
13458        }
13459        if scope_stack.iter().any(|(binding, _)| binding == &root) {
13460            continue;
13461        }
13462
13463        diagnostics.push(Diagnostic { related: Vec::new(),
13464            span: rule.body.span,
13465            message: format!(
13466                "rule `{}` uses effect output `{root}` outside a matching `after {root} ...` block",
13467                rule.name.name
13468            ),
13469            suggestion: Some(format!(
13470                "move this use into `after {root} succeeds {{ ... }}` or another matching terminal branch"
13471            )),
13472        });
13473    }
13474}
13475
13476fn after_scopes(block_stack: &[BlockFrame]) -> Vec<(String, DependencyPredicate)> {
13477    block_stack
13478        .iter()
13479        .map(|frame| match frame {
13480            BlockFrame::After { binding, predicate } => (binding.clone(), predicate.clone()),
13481        })
13482        .collect()
13483}
13484
13485/// The single lowering table for readiness sugar: maps a `when` pattern to
13486/// the runtime fact name it matches. The general form is
13487/// `when fact <name> as x`; the English phrases are documented abbreviations
13488/// of it.
13489pub fn runtime_fact_name_for_pattern(pattern: &str) -> Option<String> {
13490    let pattern = pattern.trim();
13491    if let Some(rest) = pattern.strip_prefix("fact ") {
13492        let name = rest.split_whitespace().next()?;
13493        return Some(name.to_owned());
13494    }
13495    // Inbound messaging (spec/messaging.md): `message from <channel>` matches the
13496    // channel-specific `message.<channel>` fact ingested by `whip message`.
13497    if let Some(rest) = pattern.strip_prefix("message from ") {
13498        if let Some(channel) = rest.split_whitespace().next() {
13499            return Some(format!("message.{channel}"));
13500        }
13501    }
13502    // std.vcs readiness sugar (DR-0052 grammar pass): each phrase is a
13503    // defined lowering onto a generated-only `vcs.*` fact; the leading
13504    // word of the stream forms is the stream guard's subject.
13505    if pattern == "line changed" || pattern == "line changed by others" {
13506        return Some("vcs.cut.recorded".to_owned());
13507    }
13508    if pattern == "reconcile stalled" {
13509        return Some("vcs.reconcile.stalled".to_owned());
13510    }
13511    {
13512        let words: Vec<&str> = pattern.split_whitespace().collect();
13513        match words.as_slice() {
13514            [_, "has", "contention"] => {
13515                return Some("vcs.contention.predicted".to_owned());
13516            }
13517            [_, "promoted"] => {
13518                return Some("vcs.stream.promoted".to_owned());
13519            }
13520            [_, "is", "quiescent"] => {
13521                return Some("vcs.stream.quiescent".to_owned());
13522            }
13523            _ => {}
13524        }
13525    }
13526    let mut words = pattern.split_whitespace();
13527    let first = words.next()?;
13528    if words.next() == Some("completed") && words.next() == Some("turn") {
13529        let _ = first;
13530        return Some("agent.turn.completed".to_owned());
13531    }
13532    {
13533        let mut words = pattern.split_whitespace();
13534        let _tracker = words.next();
13535        if words.next() == Some("has")
13536            && words.next() == Some("ready")
13537            && words.next() == Some("issue")
13538        {
13539            return Some("tracker.issue.ready".to_owned());
13540        }
13541    }
13542    if first.chars().next().is_some_and(char::is_uppercase) {
13543        return Some(first.to_owned());
13544    }
13545    None
13546}
13547
13548/// The schema used to type-check fields on the pattern's binding. Dotted
13549/// runtime fact names are untyped (no class declares them); the sugar forms
13550/// map to their builtin schemas.
13551fn binding_from_when(when: &str) -> Option<(String, String)> {
13552    let (pattern, _) = split_when_guard(when);
13553    let binding = binding_after_as(pattern)?;
13554    let first = pattern.split_whitespace().next()?;
13555    let completed_turn = {
13556        let mut words = pattern.split_whitespace();
13557        words.next();
13558        words.next() == Some("completed") && words.next() == Some("turn")
13559    };
13560    let has_ready_issue = {
13561        let mut words = pattern.split_whitespace();
13562        words.next();
13563        words.next() == Some("has")
13564            && words.next() == Some("ready")
13565            && words.next() == Some("issue")
13566    };
13567    let schema = if let Some(rest) = pattern.strip_prefix("fact ") {
13568        rest.split_whitespace().next()?.to_owned()
13569    } else if first.chars().next().is_some_and(char::is_uppercase) {
13570        first.to_owned()
13571    } else if first.contains('.') {
13572        // Bare dotted reaction `when deploy.finished as d` — typed against a
13573        // declared `event` (validated at the call site,
13574        // spec/event-ingress.md).
13575        first.to_owned()
13576    } else if completed_turn {
13577        "AgentTurn".to_owned()
13578    } else if has_ready_issue {
13579        "WorkItem".to_owned()
13580    } else if pattern.starts_with("message from ") {
13581        // Inbound messaging (spec/messaging.md): `when message from <channel> as
13582        // msg` binds the generic `Message` envelope, never a domain type.
13583        "Message".to_owned()
13584    } else {
13585        let schema = vcs_sugar_schema(pattern.split(" as ").next().unwrap_or(pattern).trim())?;
13586        // std.vcs readiness sugar (DR-0052): each phrase binds its
13587        // builtin observer schema, like `completed turn` -> AgentTurn.
13588        schema.to_owned()
13589    };
13590
13591    Some((binding, schema))
13592}
13593
13594/// The builtin observer schema each std.vcs sugar phrase binds.
13595fn vcs_sugar_schema(phrase: &str) -> Option<&'static str> {
13596    if phrase == "line changed" || phrase == "line changed by others" {
13597        return Some("VcsChange");
13598    }
13599    if phrase == "reconcile stalled" {
13600        return Some("VcsStall");
13601    }
13602    let words: Vec<&str> = phrase.split_whitespace().collect();
13603    match words.as_slice() {
13604        [_, "has", "contention"] => Some("VcsContention"),
13605        [_, "promoted"] => Some("VcsPromotion"),
13606        _ => None,
13607    }
13608}
13609
13610pub(crate) fn split_when_guard(when: &str) -> (&str, Option<&str>) {
13611    match when.split_once(" where ") {
13612        Some((pattern, guard)) => (pattern.trim(), Some(guard.trim())),
13613        None => (when.trim(), None),
13614    }
13615}
13616
13617fn effect_binding_schema(
13618    line: &str,
13619    kind: &IrEffectKind,
13620    semantic: &SemanticContext,
13621) -> Option<String> {
13622    match kind {
13623        IrEffectKind::SchemaCoerce => parse_coerce_call_name(line).and_then(|name| {
13624            semantic
13625                .coerce_outputs
13626                .get(name)
13627                .and_then(schema_name_for_path)
13628        }),
13629        IrEffectKind::AgentTell
13630        | IrEffectKind::CapabilityCall
13631        | IrEffectKind::EventEmit
13632        | IrEffectKind::WorkflowInvoke
13633        | IrEffectKind::TimerWait
13634        | IrEffectKind::ExecCommand
13635        | IrEffectKind::TrackerFile
13636        | IrEffectKind::TrackerClaim
13637        | IrEffectKind::TrackerRenew
13638        | IrEffectKind::TrackerRelease
13639        | IrEffectKind::TrackerFinish
13640        | IrEffectKind::LeaseAcquire
13641        | IrEffectKind::LeaseRenew
13642        | IrEffectKind::LedgerAppend
13643        | IrEffectKind::CounterConsume
13644        | IrEffectKind::SignalEmit
13645        | IrEffectKind::FileRead
13646        | IrEffectKind::FileWrite
13647        | IrEffectKind::FileImport
13648        | IrEffectKind::FileExport => None,
13649    }
13650}
13651
13652fn parse_coerce_call_name(line: &str) -> Option<&str> {
13653    let rest = line.strip_prefix("coerce ")?;
13654    rest.split_once('(').map(|(name, _)| name.trim())
13655}
13656
13657fn parse_coerce_call(line: &str) -> Option<(&str, Vec<&str>)> {
13658    let rest = line.strip_prefix("coerce ")?;
13659    let call = rest.split(" as ").next().unwrap_or(rest).trim();
13660    let (name, tail) = call.split_once('(')?;
13661    let (args, _) = tail.rsplit_once(')')?;
13662    Some((name.trim(), split_expression_args(args)))
13663}
13664
13665fn split_expression_args(args: &str) -> Vec<&str> {
13666    let mut values = Vec::new();
13667    let mut start = 0usize;
13668    let mut depth = 0i32;
13669    let mut in_string = false;
13670    let mut previous = '\0';
13671    for (index, ch) in args.char_indices() {
13672        if ch == '"' && previous != '\\' {
13673            in_string = !in_string;
13674        } else if !in_string {
13675            match ch {
13676                '(' | '[' | '{' => depth += 1,
13677                ')' | ']' | '}' => depth -= 1,
13678                ',' if depth == 0 => {
13679                    let value = args[start..index].trim();
13680                    if !value.is_empty() {
13681                        values.push(value);
13682                    }
13683                    start = index + ch.len_utf8();
13684                }
13685                _ => {}
13686            }
13687        }
13688        previous = ch;
13689    }
13690    let value = args[start..].trim();
13691    if !value.is_empty() {
13692        values.push(value);
13693    }
13694    values
13695}
13696
13697fn effect_payload_statements(body: &str) -> Vec<String> {
13698    collect_body_statements(body, effect_payload_statement_balance)
13699}
13700
13701fn workflow_invoke_statements(body: &str) -> Vec<String> {
13702    collect_body_statements(body, workflow_invoke_statement_balance)
13703}
13704
13705#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13706enum StatementBalance {
13707    None,
13708    Parens,
13709    Braces,
13710}
13711
13712fn collect_body_statements(
13713    body: &str,
13714    statement_balance: fn(&str) -> Option<StatementBalance>,
13715) -> Vec<String> {
13716    let lines = body.lines().collect::<Vec<_>>();
13717    let mut statements = Vec::new();
13718    let mut index = 0usize;
13719    let mut record_depth = 0i32;
13720    let mut multiline_string = false;
13721    while index < lines.len() {
13722        let trimmed = lines[index].trim();
13723        if trimmed.is_empty() {
13724            index += 1;
13725            continue;
13726        }
13727        if multiline_string {
13728            if trimmed.contains("\"\"\"") {
13729                multiline_string = false;
13730            }
13731            index += 1;
13732            continue;
13733        }
13734        if record_depth > 0 {
13735            record_depth += brace_delta(trimmed);
13736            index += 1;
13737            continue;
13738        }
13739        if parse_record_start(trimmed).is_some() {
13740            record_depth = brace_delta(trimmed).max(1);
13741            index += 1;
13742            continue;
13743        }
13744        if trimmed.contains("\"\"\"") {
13745            multiline_string = trimmed.matches("\"\"\"").count() % 2 == 1;
13746            index += 1;
13747            continue;
13748        }
13749        if let Some(balance) = statement_balance(trimmed) {
13750            match balance {
13751                StatementBalance::None => statements.push(trimmed.to_owned()),
13752                StatementBalance::Parens => {
13753                    let (statement, next_index) =
13754                        statement_until_balanced(&lines, index, trimmed, paren_delta);
13755                    statements.push(statement);
13756                    index = next_index + 1;
13757                    continue;
13758                }
13759                StatementBalance::Braces => {
13760                    let (statement, next_index) =
13761                        statement_until_balanced(&lines, index, trimmed, brace_delta);
13762                    statements.push(statement);
13763                    index = next_index + 1;
13764                    continue;
13765                }
13766            }
13767        }
13768        index += 1;
13769    }
13770    statements
13771}
13772
13773fn effect_payload_statement_balance(trimmed: &str) -> Option<StatementBalance> {
13774    if trimmed.starts_with("coerce ") {
13775        Some(StatementBalance::Parens)
13776    } else if trimmed.starts_with("claim ") {
13777        Some(StatementBalance::None)
13778    } else {
13779        None
13780    }
13781}
13782
13783fn workflow_invoke_statement_balance(trimmed: &str) -> Option<StatementBalance> {
13784    trimmed
13785        .starts_with("invoke ")
13786        .then_some(StatementBalance::Braces)
13787}
13788
13789fn invoke_statement_parts(statement: &str) -> Option<(&str, &str)> {
13790    let rest = statement.trim().strip_prefix("invoke ")?;
13791    let target = rest
13792        .split_whitespace()
13793        .next()
13794        .unwrap_or("")
13795        .trim_end_matches('{');
13796    if target.is_empty() {
13797        return None;
13798    }
13799    let open = statement.find('{')?;
13800    let mut depth = 0i32;
13801    let mut close = None;
13802    for (offset, ch) in statement[open..].char_indices() {
13803        match ch {
13804            '{' => depth += 1,
13805            '}' => {
13806                depth -= 1;
13807                if depth == 0 {
13808                    close = Some(open + offset);
13809                    break;
13810                }
13811            }
13812            _ => {}
13813        }
13814    }
13815    let close = close?;
13816    (close > open).then_some((target, statement[open + 1..close].trim()))
13817}
13818
13819fn statement_until_balanced(
13820    lines: &[&str],
13821    index: usize,
13822    trimmed: &str,
13823    delta: fn(&str) -> i32,
13824) -> (String, usize) {
13825    let mut statement = trimmed.to_owned();
13826    let mut depth = delta(trimmed);
13827    let mut cursor = index;
13828    while depth > 0 && cursor + 1 < lines.len() {
13829        cursor += 1;
13830        let next = lines[cursor].trim();
13831        statement.push(' ');
13832        statement.push_str(next);
13833        depth += delta(next);
13834    }
13835    (statement, cursor)
13836}
13837
13838fn paren_delta(line: &str) -> i32 {
13839    line.chars().fold(0, |depth, ch| match ch {
13840        '(' => depth + 1,
13841        ')' => depth - 1,
13842        _ => depth,
13843    })
13844}
13845
13846/// The hygienic class name synthesized for an inline `decide -> { … } as
13847/// <binding>`. Dots are illegal in user class names (like the `flow.<name>.seg*`
13848/// rule convention), so `decide.<rule>.<binding>` can never collide with a
13849/// declared schema. The lowering pass, the type checker, and the runtime fixture
13850/// all derive the same name, so the anonymous result shape flows exactly like a
13851/// named `coerce -> Schema`: `after <binding> succeeds as r` resolves `r`'s
13852/// fields for `case` dispatch and field access.
13853pub fn inline_decide_schema_name(rule: &str, binding: &str) -> String {
13854    format!("decide.{rule}.{binding}")
13855}
13856
13857/// A single-identifier `decide` field type is either a primitive keyword
13858/// (`bool`, `string`, …) or a reference to a declared class/enum. The `decide`
13859/// grammar only admits single identifiers, so no compound parsing is needed.
13860fn decide_field_type_syntax(ty: &str, span: SourceSpan) -> TypeSyntax {
13861    if is_primitive_type(ty) {
13862        TypeSyntax::Primitive {
13863            name: ty.to_owned(),
13864            span,
13865        }
13866    } else {
13867        TypeSyntax::Ref {
13868            name: Ident {
13869                name: ty.to_owned(),
13870                span,
13871            },
13872        }
13873    }
13874}
13875
13876/// Collects every inline `decide … as <binding>` in a rule body — recursing
13877/// through nested after/case/branch/handler blocks — yielding
13878/// `(binding, result_fields, span)` for synthesis and type registration.
13879#[allow(clippy::type_complexity)]
13880fn collect_decide_effects<'a>(
13881    statements: &'a [body::BodyStmt],
13882    out: &mut Vec<(&'a str, &'a [(String, String)], SourceSpan)>,
13883) {
13884    for statement in statements {
13885        match statement {
13886            body::BodyStmt::Effect(effect) => {
13887                if let body::BodyEffectKind::Decide { result_fields } = &effect.kind {
13888                    if let Some(binding) = &effect.binding {
13889                        out.push((binding.as_str(), result_fields.as_slice(), effect.span));
13890                    }
13891                }
13892            }
13893            body::BodyStmt::After(after) => collect_decide_effects(&after.body, out),
13894            body::BodyStmt::Case(case) => {
13895                for branch in &case.branches {
13896                    collect_decide_effects(&branch.body, out);
13897                }
13898            }
13899            _ => {}
13900        }
13901    }
13902}
13903
13904/// Registers each inline `decide … as <binding>` result as `Ref(decide.<rule>.<binding>)`
13905/// so the after-binding type flow resolves the anonymous shape's fields, exactly
13906/// like a named `coerce -> Schema`. The synthesized class is injected into both
13907/// the semantic schema index and the IR by [`collect_inline_decide_schemas`].
13908fn collect_decide_payload_types(
13909    statements: &[body::BodyStmt],
13910    rule_name: &str,
13911    payloads: &mut BTreeMap<String, IrType>,
13912) {
13913    let mut decides = Vec::new();
13914    collect_decide_effects(statements, &mut decides);
13915    for (binding, _fields, _span) in decides {
13916        payloads.insert(
13917            binding.to_owned(),
13918            IrType::Ref(inline_decide_schema_name(rule_name, binding)),
13919        );
13920    }
13921}
13922
13923fn collect_prompt_payload_types(
13924    statements: &[body::BodyStmt],
13925    payloads: &mut BTreeMap<String, IrType>,
13926) {
13927    for statement in statements {
13928        match statement {
13929            body::BodyStmt::Effect(effect) => {
13930                if matches!(&effect.kind, body::BodyEffectKind::Prompt { .. }) {
13931                    if let Some(binding) = &effect.binding {
13932                        payloads
13933                            .insert(binding.clone(), IrType::Primitive(IrPrimitiveType::String));
13934                    }
13935                }
13936            }
13937            body::BodyStmt::After(after) => collect_prompt_payload_types(&after.body, payloads),
13938            body::BodyStmt::Case(case) => {
13939                for branch in &case.branches {
13940                    collect_prompt_payload_types(&branch.body, payloads);
13941                }
13942            }
13943            _ => {}
13944        }
13945    }
13946}
13947
13948/// Synthesizes a hygienic `decide.<rule>.<binding>` class for every inline
13949/// `decide -> { … } as <binding>`, injecting it into both the semantic schema
13950/// index (so field access / `case` type-check) and the IR (so the runtime
13951/// fixture can generate the anonymous shape). Mirrors the generated
13952/// `<Enum>.<Variant>` class synthesis for data-carrying sum-type variants.
13953fn collect_inline_decide_schemas(
13954    items: &[Item],
13955    semantic: &mut SemanticContext,
13956    ir: &mut IrProgram,
13957) {
13958    for item in items {
13959        let Item::Rule(rule) = item else {
13960            continue;
13961        };
13962        let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
13963        let mut decides = Vec::new();
13964        collect_decide_effects(&body_ast.statements, &mut decides);
13965        for (binding, fields, span) in decides {
13966            let name = inline_decide_schema_name(&rule.name.name, binding);
13967            // Build the field shape once as `TypeSyntax` (the schema-index form),
13968            // then lower it for the IR so both representations stay in lockstep.
13969            let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
13970            let mut ir_fields = Vec::new();
13971            for (field_name, field_ty) in fields {
13972                let ty = decide_field_type_syntax(field_ty, span);
13973                ir_fields.push(IrClassField {
13974                    name: field_name.clone(),
13975                    ty: lower_type(ty.clone()),
13976                    is_key: false,
13977                    presence_condition: None,
13978                    span,
13979                });
13980                syntax_fields.insert(field_name.clone(), ty);
13981            }
13982            semantic.schemas.classes.insert(name.clone(), syntax_fields);
13983            ir.schemas.push(IrSchema::Class(IrClass {
13984                name,
13985                fields: ir_fields,
13986                span,
13987            }));
13988        }
13989    }
13990}
13991
13992/// The hygienic synthetic class name for a `redact … as <binding>` projection:
13993/// `redact.<rule>.<binding>`, holding only the kept fields of the source schema.
13994pub fn redact_schema_name(rule: &str, binding: &str) -> String {
13995    format!("redact.{rule}.{binding}")
13996}
13997
13998/// Collects every `redact <source> keep [..] as <binding>` in a rule body —
13999/// recursing through nested after/case/branch/handler blocks — for projected-type
14000/// synthesis, type registration, and IFC value-flow.
14001#[allow(clippy::type_complexity)]
14002fn collect_redact_effects<'a>(
14003    statements: &'a [body::BodyStmt],
14004    out: &mut Vec<(&'a str, &'a [String], &'a str, SourceSpan)>,
14005) {
14006    for statement in statements {
14007        match statement {
14008            body::BodyStmt::Redact {
14009                source,
14010                keep,
14011                binding,
14012                span,
14013            } => out.push((source.as_str(), keep.as_slice(), binding.as_str(), *span)),
14014            body::BodyStmt::After(after) => collect_redact_effects(&after.body, out),
14015            body::BodyStmt::Case(case) => {
14016                for branch in &case.branches {
14017                    collect_redact_effects(&branch.body, out);
14018                }
14019            }
14020            _ => {}
14021        }
14022    }
14023}
14024
14025/// Resolves binding -> schema name for a rule's redact SOURCES: `when Class as x`
14026/// matches, plus coerce/decide/exec result bindings. Used only to find the schema
14027/// a `redact` projects from, so the synthetic projected class copies the kept
14028/// fields' types. (`after`-alias sources are a documented follow-up; an
14029/// unresolved source surfaces as an empty projection + a `validate_redactions`
14030/// diagnostic.) Diagnostics from the reused collector are discarded — the real
14031/// pass re-emits them.
14032fn rule_binding_schemas(rule: &RuleDecl, semantic: &SemanticContext) -> BTreeMap<String, String> {
14033    let mut schemas = binding_types_for_rule(rule);
14034    let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
14035    let mut payloads = collect_effect_payload_types(rule, semantic, &mut Vec::new());
14036    collect_exec_payload_types(&body_ast.statements, semantic, &mut payloads);
14037    collect_decide_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
14038    collect_redact_payload_types(&body_ast.statements, &rule.name.name, &mut payloads);
14039    // `after <binding> <predicate> as <alias>` aliases the effect's completed
14040    // payload schema, so a `coerce … as c` then `after c succeeds as cust` then
14041    // `redact cust …` resolves (the primary read-then-redact flow). Only
14042    // payload-carrying predicates are mapped here; terminal predicates
14043    // (`times out`/`fails`) bind synthetic terminal schemas not usefully redacted.
14044    for line in rule.body.text.lines() {
14045        let Some(rest) = line.trim().strip_prefix("after ") else {
14046            continue;
14047        };
14048        let mut words = rest.split_whitespace();
14049        let Some(binding) = words.next() else {
14050            continue;
14051        };
14052        let Some(predicate) = words.next() else {
14053            continue;
14054        };
14055        if predicate == "times" && words.next() != Some("out") {
14056            continue;
14057        }
14058        let (Some("as"), Some(alias)) = (words.next(), words.next()) else {
14059            continue;
14060        };
14061        let alias = alias.trim_end_matches('{').trim();
14062        if alias.is_empty() {
14063            continue;
14064        }
14065        if let Some(IrType::Ref(schema)) = payloads.get(binding) {
14066            schemas.insert(alias.to_owned(), schema.clone());
14067        }
14068    }
14069    for (binding, ty) in payloads {
14070        if let IrType::Ref(schema) = ty {
14071            schemas.insert(binding, schema);
14072        }
14073    }
14074    schemas
14075}
14076
14077/// Synthesizes a hygienic `redact.<rule>.<binding>` class for every
14078/// `redact <source> keep [..] as <binding>`, holding ONLY the kept fields of the
14079/// source schema (with their source types). This is what makes a redaction sound:
14080/// the projected binding cannot expose a dropped field (accessing one is a
14081/// type error, since it is absent from the synthetic class), so the lowered IFC
14082/// label the checker assigns the projection is honoured by the type system too.
14083/// Mirrors [`collect_inline_decide_schemas`]; run before the rule loop so
14084/// `analyze_rule` sees the class. A redact chained off an earlier redact's output
14085/// resolves via the local map built as the pass proceeds.
14086fn collect_redact_schemas(items: &[Item], semantic: &mut SemanticContext, ir: &mut IrProgram) {
14087    for item in items {
14088        let Item::Rule(rule) = item else {
14089            continue;
14090        };
14091        let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
14092        let mut redacts = Vec::new();
14093        collect_redact_effects(&body_ast.statements, &mut redacts);
14094        if redacts.is_empty() {
14095            continue;
14096        }
14097        let binding_schemas = rule_binding_schemas(rule, semantic);
14098        let mut local: BTreeMap<String, String> = BTreeMap::new();
14099        for (source, keep, binding, span) in redacts {
14100            let name = redact_schema_name(&rule.name.name, binding);
14101            let source_schema = binding_schemas
14102                .get(source)
14103                .cloned()
14104                .or_else(|| local.get(source).cloned());
14105            // Clone the kept fields' types out of the source schema first, so the
14106            // immutable borrow ends before we insert the new class.
14107            let projected: Vec<(String, TypeSyntax)> = source_schema
14108                .as_ref()
14109                .and_then(|schema| semantic.schemas.classes.get(schema))
14110                .map(|src_fields| {
14111                    keep.iter()
14112                        .filter_map(|field| {
14113                            src_fields.get(field).map(|ty| (field.clone(), ty.clone()))
14114                        })
14115                        .collect()
14116                })
14117                .unwrap_or_default();
14118            let mut syntax_fields: BTreeMap<String, TypeSyntax> = BTreeMap::new();
14119            let mut ir_fields = Vec::new();
14120            for (field_name, ty) in &projected {
14121                syntax_fields.insert(field_name.clone(), ty.clone());
14122                ir_fields.push(IrClassField {
14123                    name: field_name.clone(),
14124                    ty: lower_type(ty.clone()),
14125                    is_key: false,
14126                    presence_condition: None,
14127                    span,
14128                });
14129            }
14130            semantic.schemas.classes.insert(name.clone(), syntax_fields);
14131            ir.schemas.push(IrSchema::Class(IrClass {
14132                name: name.clone(),
14133                fields: ir_fields,
14134                span,
14135            }));
14136            local.insert(binding.to_owned(), name);
14137        }
14138    }
14139}
14140
14141/// Registers each `redact … as <binding>` result as `Ref(redact.<rule>.<binding>)`
14142/// so field access / `case` through the projection resolves against the kept-only
14143/// synthetic class (a dropped field is an unknown-field error). Mirrors
14144/// [`collect_decide_payload_types`].
14145fn collect_redact_payload_types(
14146    statements: &[body::BodyStmt],
14147    rule_name: &str,
14148    payloads: &mut BTreeMap<String, IrType>,
14149) {
14150    let mut redacts = Vec::new();
14151    collect_redact_effects(statements, &mut redacts);
14152    for (_source, _keep, binding, _span) in redacts {
14153        payloads.insert(
14154            binding.to_owned(),
14155            IrType::Ref(redact_schema_name(rule_name, binding)),
14156        );
14157    }
14158}
14159
14160/// Validates each `redact <source> keep [..] as <out>`: the source must resolve to
14161/// a known schema, and every kept field must exist on it. Fail-closed — an
14162/// unresolvable source or unknown kept field is a hard error, so a redaction can
14163/// never silently project nothing (which would carry no data and mask a mistake).
14164fn validate_redactions(
14165    rule: &RuleDecl,
14166    statements: &[body::BodyStmt],
14167    semantic: &SemanticContext,
14168    binding_schemas: &BTreeMap<String, String>,
14169    diagnostics: &mut Vec<Diagnostic>,
14170) {
14171    let mut redacts = Vec::new();
14172    collect_redact_effects(statements, &mut redacts);
14173    let mut local: BTreeMap<String, String> = BTreeMap::new();
14174    for (source, keep, binding, span) in redacts {
14175        let source_schema = binding_schemas
14176            .get(source)
14177            .cloned()
14178            .or_else(|| local.get(source).cloned());
14179        local.insert(
14180            binding.to_owned(),
14181            redact_schema_name(&rule.name.name, binding),
14182        );
14183        let Some(schema) = source_schema else {
14184            diagnostics.push(Diagnostic {
14185                related: Vec::new(),
14186                span,
14187                message: format!(
14188                    "rule `{}` redacts `{source}`, which has no known schema",
14189                    rule.name.name
14190                ),
14191                suggestion: Some(
14192                    "redact a binding with a known record type — a matched `when Class as x`, or a \
14193                     coerce/decide/exec result"
14194                        .to_owned(),
14195                ),
14196            });
14197            continue;
14198        };
14199        let Some(src_fields) = semantic.schemas.classes.get(&schema) else {
14200            continue;
14201        };
14202        for field in keep {
14203            if !src_fields.contains_key(field) {
14204                diagnostics.push(Diagnostic {
14205                    related: Vec::new(),
14206                    span,
14207                    message: format!(
14208                        "rule `{}` redacts `{source}` keeping unknown field `{field}` of `{schema}`",
14209                        rule.name.name
14210                    ),
14211                    suggestion: Some(format!("keep a field declared on `{schema}`")),
14212                });
14213            }
14214        }
14215    }
14216}
14217
14218/// Registers the typed result of the single `exec "..." -> Schema as binding`
14219/// form so `after <binding> succeeds as r` resolves `r`'s fields — the same
14220/// after-binding type flow a named `coerce -> Schema` already gets. The
14221/// streaming `-> each Schema` form records one fact per element (not a single
14222/// bound value), so it is skipped here.
14223fn collect_exec_payload_types(
14224    statements: &[body::BodyStmt],
14225    semantic: &SemanticContext,
14226    payloads: &mut BTreeMap<String, IrType>,
14227) {
14228    for statement in statements {
14229        match statement {
14230            body::BodyStmt::Effect(effect) => {
14231                if let body::BodyEffectKind::Exec {
14232                    parse_target: Some(parse),
14233                    ..
14234                } = &effect.kind
14235                {
14236                    if !parse.each {
14237                        if let Some(binding) = &effect.binding {
14238                            if semantic.schemas.class_exists(&parse.schema) {
14239                                payloads.insert(binding.clone(), IrType::Ref(parse.schema.clone()));
14240                            }
14241                        }
14242                    }
14243                }
14244            }
14245            body::BodyStmt::After(after) => {
14246                collect_exec_payload_types(&after.body, semantic, payloads)
14247            }
14248            body::BodyStmt::Case(case) => {
14249                for branch in &case.branches {
14250                    collect_exec_payload_types(&branch.body, semantic, payloads);
14251                }
14252            }
14253            _ => {}
14254        }
14255    }
14256}
14257
14258/// Collects the schemas an `exec ... -> each` stream records as facts.
14259fn push_ingest_fact_writes(statements: &[body::BodyStmt], fact_writes: &mut Vec<String>) {
14260    for statement in statements {
14261        match statement {
14262            body::BodyStmt::Effect(effect) => {
14263                match &effect.kind {
14264                    body::BodyEffectKind::Exec {
14265                        parse_target: Some(parse),
14266                        ..
14267                    } if parse.each => {
14268                        fact_writes.push(format!("schema:{}", parse.schema));
14269                    }
14270                    // `import <fmt> <Schema>` admits one `<Schema>` fact per row
14271                    // (spec/std-library/files.md), so a `when <Schema>` rule has a
14272                    // producer for liveness/effect-graph analysis.
14273                    body::BodyEffectKind::FileImport { schema, .. } => {
14274                        fact_writes.push(format!("schema:{schema}"));
14275                    }
14276                    _ => {}
14277                }
14278            }
14279            body::BodyStmt::After(after) => push_ingest_fact_writes(&after.body, fact_writes),
14280            body::BodyStmt::Case(case) => {
14281                for branch in &case.branches {
14282                    push_ingest_fact_writes(&branch.body, fact_writes);
14283                }
14284            }
14285            _ => {}
14286        }
14287    }
14288}
14289
14290/// Body-effect operand checks that need schema knowledge:
14291/// - `timer until <operand>`: a non-literal operand must be a dotted path
14292///   resolving to a `time`-typed field (spec/scheduled-time.md). Literals were
14293///   format-validated by the body parser, so anything that still looks like an
14294///   instant here is a valid literal and passes.
14295/// - `exec ... -> Schema` / `-> each Schema`: the parse target must name a
14296///   declared class (spec/json-ingestion.md).
14297///
14298/// The coordination safety model (spec/coordination.md): at most one held
14299/// lease per progression (hard default), exhaustive outcome handling, and
14300/// the linear must-release discipline (instance terminals auto-release, so
14301/// a path that ends in `complete`/`fail` is safe without an explicit
14302/// `release`).
14303fn validate_coordination_discipline(
14304    rule: &RuleDecl,
14305    statements: &[body::BodyStmt],
14306    diagnostics: &mut Vec<Diagnostic>,
14307) {
14308    let mut acquires = Vec::new();
14309    let mut consumes = Vec::new();
14310    let mut claims = Vec::new();
14311    collect_coordination_effects(statements, &mut acquires, &mut consumes, &mut claims);
14312
14313    // std.vcs completion-valued verbs (DR-0052): promote, undo, and
14314    // transport are exhaustive exactly like acquire — an unwritten
14315    // refusal arm is a workflow with no policy at the refusal.
14316    let mut vcs_verbs: Vec<(&'static str, [&'static str; 2], String, SourceSpan)> = Vec::new();
14317    for_each_body(statements, &mut |stmt| {
14318        if let body::BodyStmt::Effect(effect) = stmt {
14319            if let body::BodyEffectKind::ConstructCapabilityCall { keyword, .. } = &effect.kind {
14320                let arms: Option<(&'static str, [&'static str; 2])> = match keyword.as_str() {
14321                    "promote" => Some(("promote", ["promoted", "conflicted"])),
14322                    "undo" => Some(("undo", ["applied", "stranded"])),
14323                    "transport" => Some(("transport", ["applied", "conflicted"])),
14324                    _ => None,
14325                };
14326                if let (Some((verb, required)), Some(binding)) = (arms, &effect.binding) {
14327                    vcs_verbs.push((verb, required, binding.clone(), effect.span));
14328                }
14329            }
14330        }
14331    });
14332    for (verb, required_arms, binding, span) in &vcs_verbs {
14333        let mut predicates = BTreeSet::new();
14334        collect_after_predicates(statements, binding, &mut predicates);
14335        for required in required_arms {
14336            if !predicates.contains(*required) {
14337                diagnostics.push(Diagnostic {
14338                    related: Vec::new(),
14339                    span: *span,
14340                    message: format!(
14341                        "rule `{}` does not handle the `{required}` outcome of {verb} `{binding}`",
14342                        rule.name.name
14343                    ),
14344                    suggestion: Some(format!(
14345                        "{verb} outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
14346                    )),
14347                });
14348            }
14349        }
14350    }
14351
14352    // A `renew <binding>` names ONE OF TWO legitimate referents (T3, mirroring
14353    // the `release` disambiguation below):
14354    //   (1) an `acquire ... as <binding>` LEASE binding acquired in this rule —
14355    //       lowers to `lease.renew` (std.coord); resolves the acquire's recorded
14356    //       resource/key at runtime;
14357    //   (2) a `claim <issue> as <binding>` CLAIM binding claimed in this rule —
14358    //       lowers to `tracker.renew` (std.tracker); resolves the claimed issue
14359    //       id from the claim's output fact.
14360    // A `renew <binding>` naming NEITHER renews nothing, so catch the typo at
14361    // `whip check`. (Scoped to `renew`, which is new.)
14362    let claim_bindings = collect_claim_bindings(statements);
14363    let renewable: BTreeSet<&str> = acquires
14364        .iter()
14365        .map(|(b, _, _)| b.as_str())
14366        .chain(claim_bindings.iter().map(String::as_str))
14367        .collect();
14368    for_each_body(statements, &mut |stmt| {
14369        if let body::BodyStmt::Effect(effect) = stmt {
14370            if let body::BodyEffectKind::LeaseRenew {
14371                acquire_binding, ..
14372            } = &effect.kind
14373            {
14374                if !renewable.contains(acquire_binding.as_str()) {
14375                    diagnostics.push(Diagnostic {
14376                        related: Vec::new(),
14377                        span: effect.span,
14378                        message: format!(
14379                            "rule `{}` renews unbound coordination binding `{}`",
14380                            rule.name.name, acquire_binding
14381                        ),
14382                        suggestion: Some(format!(
14383                            "`renew {acquire_binding}` must name a lease acquired here (`acquire ... as {acquire_binding}`) or an issue claimed here (`claim ... as {acquire_binding}`)"
14384                        )),
14385                    });
14386                }
14387            }
14388        }
14389    });
14390
14391    // A `release <x>` names ONE OF THREE legitimate referents (spec/coordination.md,
14392    // verified against the corpus + the rule-body matrix test):
14393    //   (1) an `acquire ... as <x>` LEASE binding acquired in this rule;
14394    //   (2) a `claim <x> as ...` — the *item* being claimed (the `TrackerClaim.item`,
14395    //       NOT the claim's `as` binding);
14396    //   (3) a `when <queue> has ready <x> as <x>` WORK-ITEM binding, released
14397    //       without a same-rule claim.
14398    // A naive `acquire ∪ claim-binding` model false-positives forms (2) and (3);
14399    // admit exactly these three and flag a `release <x>` that matches none as a
14400    // genuinely-unbound release. Scoped per-rule like the `renew` check above.
14401    let work_items: Vec<String> = rule
14402        .whens
14403        .iter()
14404        .filter_map(|when| when_has_ready_binding(&when.text))
14405        .collect();
14406    let releasable: BTreeSet<&str> = acquires
14407        .iter()
14408        .map(|(b, _, _)| b.as_str())
14409        .chain(claims.iter().map(|(item, _)| item.as_str()))
14410        .chain(work_items.iter().map(String::as_str))
14411        .collect();
14412    for_each_body(statements, &mut |stmt| {
14413        if let body::BodyStmt::Effect(effect) = stmt {
14414            if let body::BodyEffectKind::TrackerRelease { item } = &effect.kind {
14415                if !releasable.contains(item.as_str()) {
14416                    diagnostics.push(Diagnostic {
14417                        related: Vec::new(),
14418                        span: effect.span,
14419                        message: format!(
14420                            "rule `{}` releases unbound coordination item `{}`",
14421                            rule.name.name, item
14422                        ),
14423                        suggestion: Some(format!(
14424                            "`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"
14425                        )),
14426                    });
14427                }
14428            }
14429        }
14430    });
14431
14432    if acquires.len() > 1 {
14433        diagnostics.push(Diagnostic { related: Vec::new(),
14434            span: acquires[1].2,
14435            message: format!(
14436                "rule `{}` acquires more than one lease in a single progression",
14437                rule.name.name
14438            ),
14439            suggestion: Some(
14440                "the hard default is at most one held lease per progression (it breaks hold-and-wait); restructure into separate rules"
14441                    .to_owned(),
14442            ),
14443        });
14444    }
14445    for (binding, until_ttl, span) in &acquires {
14446        if *until_ttl {
14447            continue;
14448        }
14449        let mut predicates = BTreeSet::new();
14450        collect_after_predicates(statements, binding, &mut predicates);
14451        for required in ["held", "contended"] {
14452            if !predicates.contains(required) {
14453                diagnostics.push(Diagnostic { related: Vec::new(),
14454                    span: *span,
14455                    message: format!(
14456                        "rule `{}` does not handle the `{required}` outcome of lease `{binding}`",
14457                        rule.name.name
14458                    ),
14459                    suggestion: Some(format!(
14460                        "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
14461                    )),
14462                });
14463            }
14464        }
14465        if let Some(held_body) = find_after_body(statements, binding, body::AfterPredicate::Held) {
14466            if !releases_or_terminates(held_body, binding) {
14467                diagnostics.push(Diagnostic { related: Vec::new(),
14468                    span: *span,
14469                    message: format!(
14470                        "rule `{}` can hold lease `{binding}` forever: the `held` branch neither releases it nor reaches a workflow terminal",
14471                        rule.name.name
14472                    ),
14473                    suggestion: Some(format!(
14474                        "add `release {binding}` on every non-terminal path, or use `acquire ... until ttl` for fire-and-forget"
14475                    )),
14476                });
14477            }
14478        }
14479    }
14480    for (binding, span) in &consumes {
14481        let mut predicates = BTreeSet::new();
14482        collect_after_predicates(statements, binding, &mut predicates);
14483        for required in ["ok", "over"] {
14484            if !predicates.contains(required) {
14485                diagnostics.push(Diagnostic { related: Vec::new(),
14486                    span: *span,
14487                    message: format!(
14488                        "rule `{}` does not handle the `{required}` outcome of counter consume `{binding}`",
14489                        rule.name.name
14490                    ),
14491                    suggestion: Some(format!(
14492                        "coordination outcomes are exhaustive: add `after {binding} {required} {{ ... }}`"
14493                    )),
14494                });
14495            }
14496        }
14497    }
14498}
14499
14500fn collect_coordination_effects(
14501    statements: &[body::BodyStmt],
14502    acquires: &mut Vec<(String, bool, SourceSpan)>,
14503    consumes: &mut Vec<(String, SourceSpan)>,
14504    claims: &mut Vec<(String, SourceSpan)>,
14505) {
14506    for_each_body(statements, &mut |stmt| {
14507        if let body::BodyStmt::Effect(effect) = stmt {
14508            match &effect.kind {
14509                body::BodyEffectKind::LeaseAcquire { until_ttl, .. } => {
14510                    if let Some(binding) = &effect.binding {
14511                        acquires.push((binding.clone(), *until_ttl, effect.span));
14512                    }
14513                }
14514                body::BodyEffectKind::CounterConsume { .. } => {
14515                    if let Some(binding) = &effect.binding {
14516                        consumes.push((binding.clone(), effect.span));
14517                    }
14518                }
14519                // A `claim <item> as <lease>` makes `<item>` releasable: the
14520                // releasable referent is the *item* being claimed (the
14521                // `TrackerClaim.item`), not the claim's `as` binding.
14522                body::BodyEffectKind::TrackerClaim { item, .. } => {
14523                    claims.push((item.clone(), effect.span));
14524                }
14525                _ => {}
14526            }
14527        }
14528    });
14529}
14530
14531/// The work-item binding of a `when <queue> has ready <item> as <binding>`
14532/// reaction: the `as <binding>` names a claimable/releasable work item pulled
14533/// off the queue (spec/coordination.md). Returns `None` for every other `when`
14534/// pattern (plain fact binds are not releasable). Guards (`where ...`) are
14535/// stripped first so the pattern words line up.
14536fn when_has_ready_binding(when: &str) -> Option<String> {
14537    let (pattern, _) = split_when_guard(when);
14538    let mut words = pattern.split_whitespace();
14539    let _queue = words.next()?;
14540    if words.next() == Some("has") && words.next() == Some("ready") {
14541        return binding_after_as(pattern);
14542    }
14543    None
14544}
14545
14546fn collect_after_predicates(
14547    statements: &[body::BodyStmt],
14548    binding: &str,
14549    predicates: &mut BTreeSet<&'static str>,
14550) {
14551    for_each_body(statements, &mut |stmt| {
14552        if let body::BodyStmt::After(after) = stmt {
14553            if after.binding == binding {
14554                predicates.insert(after.predicate.as_str());
14555            }
14556        }
14557    });
14558}
14559
14560fn find_after_body<'a>(
14561    statements: &'a [body::BodyStmt],
14562    binding: &str,
14563    predicate: body::AfterPredicate,
14564) -> Option<&'a [body::BodyStmt]> {
14565    for statement in statements {
14566        match statement {
14567            body::BodyStmt::After(after) => {
14568                if after.binding == binding && after.predicate == predicate {
14569                    return Some(&after.body);
14570                }
14571                if let Some(found) = find_after_body(&after.body, binding, predicate) {
14572                    return Some(found);
14573                }
14574            }
14575            body::BodyStmt::Case(case) => {
14576                for branch in &case.branches {
14577                    if let Some(found) = find_after_body(&branch.body, binding, predicate) {
14578                        return Some(found);
14579                    }
14580                }
14581            }
14582            _ => {}
14583        }
14584    }
14585    None
14586}
14587
14588/// Linear must-release, prototype form: a statement list is safe if some
14589/// statement guarantees release — an explicit `release <binding>`, a
14590/// workflow terminal (instance-terminal auto-release), a nested after-block
14591/// that is safe, or a branching construct ALL of whose branches are safe.
14592fn releases_or_terminates(statements: &[body::BodyStmt], binding: &str) -> bool {
14593    statements.iter().any(|statement| match statement {
14594        body::BodyStmt::Effect(effect) => matches!(
14595            &effect.kind,
14596            body::BodyEffectKind::TrackerRelease { item } if item == binding
14597        ),
14598        body::BodyStmt::Terminal(_) => true,
14599        body::BodyStmt::After(after) => releases_or_terminates(&after.body, binding),
14600        body::BodyStmt::Case(case) => {
14601            !case.branches.is_empty()
14602                && case
14603                    .branches
14604                    .iter()
14605                    .all(|branch| releases_or_terminates(&branch.body, binding))
14606        }
14607        _ => false,
14608    })
14609}
14610
14611fn for_each_body(statements: &[body::BodyStmt], visit: &mut impl FnMut(&body::BodyStmt)) {
14612    for statement in statements {
14613        visit(statement);
14614        match statement {
14615            body::BodyStmt::After(after) => for_each_body(&after.body, visit),
14616            body::BodyStmt::Case(case) => {
14617                for branch in &case.branches {
14618                    for_each_body(&branch.body, visit);
14619                }
14620            }
14621            _ => {}
14622        }
14623    }
14624}
14625
14626/// Family B: the `(root, field)` pairs a `case <root>.<disc> { "<lit>" => ... }` arm
14627/// makes readable — the fields conditioned on `<disc> is "<lit>"`. Empty unless the
14628/// scrutinee is a single-level `<root>.<disc>` path bound to a schema and the arm
14629/// pattern is the matching string literal.
14630fn family_b_arm_allowed(
14631    scrutinee: &str,
14632    pattern: &str,
14633    binding_types: &BTreeMap<String, String>,
14634    semantic: &SemanticContext,
14635) -> BTreeSet<(String, String)> {
14636    let mut allowed = BTreeSet::new();
14637    let Some((root, disc)) = scrutinee.split_once('.') else {
14638        return allowed;
14639    };
14640    if disc.contains('.') {
14641        return allowed;
14642    }
14643    let trimmed = pattern.trim();
14644    if trimmed == "_" || trimmed == "default" {
14645        return allowed;
14646    }
14647    let literal = trimmed.trim_matches('"');
14648    if literal.is_empty() {
14649        return allowed;
14650    }
14651    let Some(schema) = binding_types.get(root) else {
14652        return allowed;
14653    };
14654    if let Some(conditions) = semantic.schemas.presence.get(schema) {
14655        for (field, (cond_disc, cond_literal)) in conditions {
14656            if cond_disc == disc && cond_literal == literal {
14657                allowed.insert((root.to_owned(), field.clone()));
14658            }
14659        }
14660    }
14661    allowed
14662}
14663
14664/// Reject ONE read of `<root>.<field>` when that field is Family B
14665/// presence-conditioned and `allowed` (the conditioned fields this scope's `case`
14666/// arm makes present) does not carry it. The single place the diagnostic is
14667/// worded, so a written read, a `from` shorthand copy, and an implicit `from`
14668/// copy all report identically.
14669#[allow(clippy::too_many_arguments)]
14670fn check_conditioned_read(
14671    rule: &RuleDecl,
14672    root: &str,
14673    field: &str,
14674    span: SourceSpan,
14675    semantic: &SemanticContext,
14676    binding_types: &BTreeMap<String, String>,
14677    allowed: &BTreeSet<(String, String)>,
14678    diagnostics: &mut Vec<Diagnostic>,
14679) {
14680    let Some(schema) = binding_types.get(root) else {
14681        return;
14682    };
14683    let Some((disc, _literal)) = semantic.schemas.field_presence(schema, field) else {
14684        return;
14685    };
14686    if allowed.contains(&(root.to_owned(), field.to_owned())) {
14687        return;
14688    }
14689    diagnostics.push(Diagnostic {
14690        related: Vec::new(),
14691        span,
14692        message: format!(
14693            "rule `{}` reads conditional field `{root}.{field}` outside a matching `case {root}.{disc}` arm",
14694            rule.name.name
14695        ),
14696        suggestion: Some(format!(
14697            "read `{root}.{field}` inside `case {root}.{disc} {{ \"...\" => ... }}` — it is present only for a specific `{disc}`"
14698        )),
14699    });
14700}
14701
14702/// Reject reads of a Family B presence-conditioned field in `text` that are not
14703/// permitted by `allowed` (the conditioned fields this scope's `case` arm makes
14704/// present). `text` is any source fragment that may contain dotted field paths.
14705fn check_conditioned_reads_in_text(
14706    rule: &RuleDecl,
14707    text: &str,
14708    span: SourceSpan,
14709    semantic: &SemanticContext,
14710    binding_types: &BTreeMap<String, String>,
14711    allowed: &BTreeSet<(String, String)>,
14712    diagnostics: &mut Vec<Diagnostic>,
14713) {
14714    for (root, path) in dotted_paths(text) {
14715        let Some(first) = path.first() else {
14716            continue;
14717        };
14718        check_conditioned_read(
14719            rule,
14720            &root,
14721            first,
14722            span,
14723            semantic,
14724            binding_types,
14725            allowed,
14726            diagnostics,
14727        );
14728    }
14729}
14730
14731/// The dotted paths inside a free-text fragment's `{{ … }}` interpolations, which
14732/// are the only place a prompt or a command string reads a binding. Prose outside
14733/// the braces is NOT a read — scanning it whole would turn an `e.g.` in an English
14734/// sentence into a read of a binding named `e`.
14735fn interpolation_paths(text: &str) -> Vec<(String, Vec<String>)> {
14736    let mut paths = Vec::new();
14737    let mut rest = text;
14738    while let Some(open) = rest.find("{{") {
14739        let after_open = &rest[open + 2..];
14740        let Some(close) = after_open.find("}}") else {
14741            break;
14742        };
14743        paths.extend(dotted_paths(&after_open[..close]));
14744        rest = &after_open[close + 2..];
14745    }
14746    paths
14747}
14748
14749/// Reject conditioned reads in a FREE-TEXT operand — a prompt body, an `exec`
14750/// command line. Only `{{ … }}` interpolations are scanned (see
14751/// `interpolation_paths`); the surrounding prose is not source.
14752fn check_conditioned_reads_in_interpolations(
14753    rule: &RuleDecl,
14754    text: &str,
14755    span: SourceSpan,
14756    semantic: &SemanticContext,
14757    binding_types: &BTreeMap<String, String>,
14758    allowed: &BTreeSet<(String, String)>,
14759    diagnostics: &mut Vec<Diagnostic>,
14760) {
14761    for (root, path) in interpolation_paths(text) {
14762        let Some(first) = path.first() else {
14763            continue;
14764        };
14765        check_conditioned_read(
14766            rule,
14767            &root,
14768            first,
14769            span,
14770            semantic,
14771            binding_types,
14772            allowed,
14773            diagnostics,
14774        );
14775    }
14776}
14777
14778/// `from_binding` is the enclosing statement's `from <binding>` source (`None`
14779/// when the statement has none). A bare `Shorthand` field copies the same-named
14780/// field OFF that source, so it is a read of `<from_binding>.<field>` and narrows
14781/// exactly like a written-out `<from_binding>.<field>` expression. A nested block
14782/// keeps the same source (nesting introduces no new `from`).
14783fn check_conditioned_reads_in_fields(
14784    rule: &RuleDecl,
14785    fields: &[body::FieldAssign],
14786    from_binding: Option<&str>,
14787    semantic: &SemanticContext,
14788    binding_types: &BTreeMap<String, String>,
14789    allowed: &BTreeSet<(String, String)>,
14790    diagnostics: &mut Vec<Diagnostic>,
14791) {
14792    for field in fields {
14793        match &field.value {
14794            body::FieldValue::Expr { source, .. } => check_conditioned_reads_in_text(
14795                rule,
14796                source,
14797                field.span,
14798                semantic,
14799                binding_types,
14800                allowed,
14801                diagnostics,
14802            ),
14803            body::FieldValue::Nested { fields, .. } => check_conditioned_reads_in_fields(
14804                rule,
14805                fields,
14806                from_binding,
14807                semantic,
14808                binding_types,
14809                allowed,
14810                diagnostics,
14811            ),
14812            body::FieldValue::Shorthand => {
14813                if let Some(root) = from_binding {
14814                    check_conditioned_read(
14815                        rule,
14816                        root,
14817                        &field.name,
14818                        field.span,
14819                        semantic,
14820                        binding_types,
14821                        allowed,
14822                        diagnostics,
14823                    );
14824                }
14825            }
14826        }
14827    }
14828}
14829
14830/// The copies a `from <binding>` block makes that nobody wrote down. A `from`
14831/// projection copies EVERY same-named field of the target shape off the source
14832/// binding, the written block only overriding (`parse_record_fields_with_from` in
14833/// the kernel is the runtime authority) — so omitting a field name copies it just
14834/// the same, and a presence-conditioned one is read whether or not it is spelled.
14835/// `target_fields` is the destination's declared field set, which bounds the copy;
14836/// fields the block assigns explicitly are not copied and are checked as their own
14837/// expressions.
14838#[allow(clippy::too_many_arguments)]
14839fn check_conditioned_implicit_copies(
14840    rule: &RuleDecl,
14841    from_binding: Option<&str>,
14842    target_fields: Option<&BTreeMap<String, TypeSyntax>>,
14843    fields: &[body::FieldAssign],
14844    span: SourceSpan,
14845    semantic: &SemanticContext,
14846    binding_types: &BTreeMap<String, String>,
14847    allowed: &BTreeSet<(String, String)>,
14848    diagnostics: &mut Vec<Diagnostic>,
14849) {
14850    let (Some(root), Some(target_fields)) = (from_binding, target_fields) else {
14851        return;
14852    };
14853    let written: BTreeSet<&str> = fields.iter().map(|field| field.name.as_str()).collect();
14854    for name in target_fields.keys() {
14855        if written.contains(name.as_str()) {
14856            continue;
14857        }
14858        check_conditioned_read(
14859            rule,
14860            root,
14861            name,
14862            span,
14863            semantic,
14864            binding_types,
14865            allowed,
14866            diagnostics,
14867        );
14868    }
14869}
14870
14871/// A `record <Class> [from <binding>] { … }` in either of its two statement
14872/// positions (a plain `record`, or the `done … -> record` replacement).
14873fn check_conditioned_record_reads(
14874    rule: &RuleDecl,
14875    record: &body::RecordStmt,
14876    semantic: &SemanticContext,
14877    binding_types: &BTreeMap<String, String>,
14878    allowed: &BTreeSet<(String, String)>,
14879    diagnostics: &mut Vec<Diagnostic>,
14880) {
14881    check_conditioned_reads_in_fields(
14882        rule,
14883        &record.fields,
14884        record.from.as_deref(),
14885        semantic,
14886        binding_types,
14887        allowed,
14888        diagnostics,
14889    );
14890    check_conditioned_implicit_copies(
14891        rule,
14892        record.from.as_deref(),
14893        semantic.schemas.classes.get(&record.schema),
14894        &record.fields,
14895        record.span,
14896        semantic,
14897        binding_types,
14898        allowed,
14899        diagnostics,
14900    );
14901}
14902
14903/// Every read position of one effect statement. An effect is an EGRESS as much as a
14904/// terminal is — a prompt, a command line, an invoke payload, a coordination key all
14905/// carry the field's value out of the rule — so a presence-conditioned field is
14906/// narrowed here exactly as it is in a record or terminal value.
14907///
14908/// The `kind` match is wildcard-free on purpose: a new `BodyEffectKind` must state
14909/// which of its operands are reads rather than inherit silence from a `_` arm. Three
14910/// operand shapes:
14911///
14912///   * EXPRESSION text (`dotted_paths`) — an operand written as an expression:
14913///     coerce arguments, coordination keys, a timer's `until` path, file paths and
14914///     bodies, an export predicate, a signal's target instance.
14915///   * FREE text (`interpolation_paths`, `{{ … }}` only) — a model prompt or an
14916///     `exec` command line, where the surrounding prose is not source.
14917///   * FIELD BLOCKS (`check_conditioned_reads_in_fields`) — an invoke payload, a
14918///     tracker file/finish payload, a ledger row, a signal's override block, which
14919///     narrow through the same walk record and terminal payloads use.
14920///
14921/// Named identifiers are not reads: an agent, capability, workflow, queue, ledger,
14922/// counter, lease, file store, format, mode, or schema NAME references a declaration,
14923/// and a bare binding operand (`claim <item>`, `release <item>`, `renew <lease>`,
14924/// `call … for <binding>`, `exec <capability> with <binding>`) reads the whole
14925/// binding rather than a conditioned field of it.
14926fn check_conditioned_effect_reads(
14927    rule: &RuleDecl,
14928    effect: &body::EffectStmt,
14929    semantic: &SemanticContext,
14930    binding_types: &BTreeMap<String, String>,
14931    allowed: &BTreeSet<(String, String)>,
14932    diagnostics: &mut Vec<Diagnostic>,
14933) {
14934    let span = effect.span;
14935    let expression = |text: &str, diagnostics: &mut Vec<Diagnostic>| {
14936        check_conditioned_reads_in_text(
14937            rule,
14938            text,
14939            span,
14940            semantic,
14941            binding_types,
14942            allowed,
14943            diagnostics,
14944        );
14945    };
14946
14947    // Every effect kind may carry a prompt (`tell`/`prompt`/`decide`/`coerce`
14948    // bodies), and a prompt is free text: only its interpolations are reads.
14949    if let Some(prompt) = &effect.prompt {
14950        check_conditioned_reads_in_interpolations(
14951            rule,
14952            &prompt.text,
14953            span,
14954            semantic,
14955            binding_types,
14956            allowed,
14957            diagnostics,
14958        );
14959    }
14960
14961    match &effect.kind {
14962        body::BodyEffectKind::Coerce { args, .. } => {
14963            for arg in args {
14964                expression(arg, diagnostics);
14965            }
14966        }
14967        body::BodyEffectKind::ConstructCapabilityCall { fields, .. } => {
14968            for field in fields {
14969                expression(&field.source, diagnostics);
14970            }
14971        }
14972        body::BodyEffectKind::Invoke { payload, .. } => check_conditioned_reads_in_fields(
14973            rule,
14974            payload,
14975            // An invoke payload block takes no `from` projection.
14976            None,
14977            semantic,
14978            binding_types,
14979            allowed,
14980            diagnostics,
14981        ),
14982        body::BodyEffectKind::Timer { until, .. } => {
14983            if let Some(until) = until {
14984                expression(until, diagnostics);
14985            }
14986        }
14987        body::BodyEffectKind::Exec {
14988            target,
14989            parse_target: _,
14990        } => match target {
14991            // A raw command is a string literal: prose plus interpolations.
14992            body::ExecTarget::RawCommand(command) => {
14993                check_conditioned_reads_in_interpolations(
14994                    rule,
14995                    command,
14996                    span,
14997                    semantic,
14998                    binding_types,
14999                    allowed,
15000                    diagnostics,
15001                );
15002            }
15003            // `with <binding>` pipes the whole binding to stdin.
15004            body::ExecTarget::Capability { .. } => {}
15005        },
15006        body::BodyEffectKind::TrackerFile { fields, .. }
15007        | body::BodyEffectKind::TrackerFinish { fields, .. }
15008        | body::BodyEffectKind::LedgerAppend { fields, .. } => check_conditioned_reads_in_fields(
15009            rule,
15010            fields,
15011            None,
15012            semantic,
15013            binding_types,
15014            allowed,
15015            diagnostics,
15016        ),
15017        body::BodyEffectKind::LeaseAcquire { key_expr, .. } => expression(key_expr, diagnostics),
15018        body::BodyEffectKind::CounterConsume {
15019            key_expr,
15020            amount_expr,
15021            ..
15022        } => {
15023            expression(key_expr, diagnostics);
15024            expression(amount_expr, diagnostics);
15025        }
15026        // `emit signal <name> to <target> from <binding> { overrides }` is both an
15027        // operand position (the target instance) and the third COPY position (the
15028        // `record … from` precedent, S6): the block overrides, the projection copies
15029        // every same-named field the signal declares.
15030        body::BodyEffectKind::Notify {
15031            target_expr,
15032            event,
15033            from,
15034            fields,
15035        } => {
15036            expression(target_expr, diagnostics);
15037            check_conditioned_reads_in_fields(
15038                rule,
15039                fields,
15040                from.as_deref(),
15041                semantic,
15042                binding_types,
15043                allowed,
15044                diagnostics,
15045            );
15046            check_conditioned_implicit_copies(
15047                rule,
15048                from.as_deref(),
15049                semantic.schemas.classes.get(event),
15050                fields,
15051                span,
15052                semantic,
15053                binding_types,
15054                allowed,
15055                diagnostics,
15056            );
15057        }
15058        body::BodyEffectKind::FileRead { path, .. }
15059        | body::BodyEffectKind::FileImport { path, .. } => expression(path, diagnostics),
15060        body::BodyEffectKind::FileWrite { path, body, .. } => {
15061            expression(path, diagnostics);
15062            expression(body, diagnostics);
15063        }
15064        body::BodyEffectKind::FileExport {
15065            path, predicate, ..
15066        } => {
15067            expression(path, diagnostics);
15068            if let Some(predicate) = predicate {
15069                expression(predicate, diagnostics);
15070            }
15071        }
15072        // Prompt-only or name-only kinds: every operand is a declaration name, a
15073        // bare binding, or the prompt already scanned above.
15074        body::BodyEffectKind::Tell { .. }
15075        | body::BodyEffectKind::Prompt { .. }
15076        | body::BodyEffectKind::Decide { .. }
15077        | body::BodyEffectKind::Call { .. }
15078        | body::BodyEffectKind::TrackerClaim { .. }
15079        | body::BodyEffectKind::TrackerRelease { .. }
15080        | body::BodyEffectKind::LeaseRenew { .. } => {}
15081    }
15082}
15083
15084/// The declared field set of the workflow output contract a `complete <name> from
15085/// <binding>` projects onto — the bound on what that projection copies. `None`
15086/// when the contract is scalar, inline-typed to something other than a class, or
15087/// not resolvable in this workflow's scope (nothing is claimed about the copy).
15088fn terminal_output_fields<'a>(
15089    terminal: &body::TerminalStmt,
15090    semantic: &'a SemanticContext,
15091) -> Option<&'a BTreeMap<String, TypeSyntax>> {
15092    if terminal.kind != body::TerminalKind::Complete {
15093        return None;
15094    }
15095    let workflow = semantic.workflow.as_ref()?;
15096    let surface = semantic.workflow_inputs.get(workflow)?;
15097    match surface.outputs.get(&terminal.name)? {
15098        TypeSyntax::Ref { name } => semantic.schemas.classes.get(&name.name),
15099        _ => None,
15100    }
15101}
15102
15103/// Family B read-narrowing (discriminated-families-design.md §5.6/§5.7): walk the
15104/// rule body and reject a read of a presence-conditioned field that is not inside a
15105/// matching `case <root>.<disc>` arm. Each `case` arm extends `allowed` with the
15106/// fields its discriminant=literal makes present. Coverage is every read position a
15107/// rule body has: record/terminal/done/milestone values, branch conditions, case
15108/// guards, and effect operands (`check_conditioned_effect_reads` — prompts, command
15109/// lines, payloads, coordination keys). Every `from`-carrying statement passes its
15110/// source binding down, so both spellings of a copy — the written `Shorthand` field
15111/// and the field the projection copies implicitly — narrow like the
15112/// `<binding>.<field>` read each one is.
15113fn validate_conditioned_field_reads(
15114    rule: &RuleDecl,
15115    statements: &[body::BodyStmt],
15116    semantic: &SemanticContext,
15117    binding_types: &BTreeMap<String, String>,
15118    allowed: &BTreeSet<(String, String)>,
15119    diagnostics: &mut Vec<Diagnostic>,
15120) {
15121    for statement in statements {
15122        match statement {
15123            body::BodyStmt::Record(record) => {
15124                check_conditioned_record_reads(
15125                    rule,
15126                    record,
15127                    semantic,
15128                    binding_types,
15129                    allowed,
15130                    diagnostics,
15131                );
15132            }
15133            body::BodyStmt::Terminal(terminal) => {
15134                check_conditioned_reads_in_fields(
15135                    rule,
15136                    &terminal.fields,
15137                    terminal.from.as_deref(),
15138                    semantic,
15139                    binding_types,
15140                    allowed,
15141                    diagnostics,
15142                );
15143                check_conditioned_implicit_copies(
15144                    rule,
15145                    terminal.from.as_deref(),
15146                    terminal_output_fields(terminal, semantic),
15147                    &terminal.fields,
15148                    terminal.span,
15149                    semantic,
15150                    binding_types,
15151                    allowed,
15152                    diagnostics,
15153                );
15154                // A bare scalar payload value is also an egress read.
15155                if let Some(body::FieldValue::Expr { source, .. }) = &terminal.scalar {
15156                    check_conditioned_reads_in_text(
15157                        rule,
15158                        source,
15159                        terminal.span,
15160                        semantic,
15161                        binding_types,
15162                        allowed,
15163                        diagnostics,
15164                    );
15165                }
15166            }
15167            body::BodyStmt::Done {
15168                replacement: Some(record),
15169                ..
15170            } => check_conditioned_record_reads(
15171                rule,
15172                record,
15173                semantic,
15174                binding_types,
15175                allowed,
15176                diagnostics,
15177            ),
15178            body::BodyStmt::Milestone { fields, .. } => check_conditioned_reads_in_fields(
15179                rule,
15180                fields,
15181                // `emit milestone` carries no `from` projection.
15182                None,
15183                semantic,
15184                binding_types,
15185                allowed,
15186                diagnostics,
15187            ),
15188            body::BodyStmt::Effect(effect) => check_conditioned_effect_reads(
15189                rule,
15190                effect,
15191                semantic,
15192                binding_types,
15193                allowed,
15194                diagnostics,
15195            ),
15196            body::BodyStmt::Done { .. }
15197            | body::BodyStmt::Cancel { .. }
15198            | body::BodyStmt::Redact { .. } => {}
15199            body::BodyStmt::After(after) => validate_conditioned_field_reads(
15200                rule,
15201                &after.body,
15202                semantic,
15203                binding_types,
15204                allowed,
15205                diagnostics,
15206            ),
15207            body::BodyStmt::Region(region) => {
15208                validate_conditioned_field_reads(
15209                    rule,
15210                    &region.body,
15211                    semantic,
15212                    binding_types,
15213                    allowed,
15214                    diagnostics,
15215                );
15216                validate_conditioned_field_reads(
15217                    rule,
15218                    &region.lapse_body,
15219                    semantic,
15220                    binding_types,
15221                    allowed,
15222                    diagnostics,
15223                );
15224            }
15225            body::BodyStmt::Case(case) => {
15226                for arm in &case.branches {
15227                    let mut arm_allowed = allowed.clone();
15228                    arm_allowed.extend(family_b_arm_allowed(
15229                        &case.scrutinee,
15230                        &arm.pattern,
15231                        binding_types,
15232                        semantic,
15233                    ));
15234                    if let Some(guard) = &arm.guard {
15235                        check_conditioned_reads_in_text(
15236                            rule,
15237                            guard,
15238                            arm.span,
15239                            semantic,
15240                            binding_types,
15241                            &arm_allowed,
15242                            diagnostics,
15243                        );
15244                    }
15245                    validate_conditioned_field_reads(
15246                        rule,
15247                        &arm.body,
15248                        semantic,
15249                        binding_types,
15250                        &arm_allowed,
15251                        diagnostics,
15252                    );
15253                }
15254            }
15255        }
15256    }
15257}
15258
15259fn validate_body_effect_operands(
15260    rule: &RuleDecl,
15261    statements: &[body::BodyStmt],
15262    semantic: &SemanticContext,
15263    binding_types: &BTreeMap<String, String>,
15264    diagnostics: &mut Vec<Diagnostic>,
15265) {
15266    for statement in statements {
15267        match statement {
15268            body::BodyStmt::Effect(effect) => {
15269                match &effect.kind {
15270                    body::BodyEffectKind::LeaseAcquire { resource, .. }
15271                        if !semantic.leases.contains(resource) =>
15272                    {
15273                        diagnostics.push(Diagnostic { related: Vec::new(),
15274                            span: effect.span,
15275                            message: format!(
15276                                "rule `{}` acquires undeclared lease `{resource}`",
15277                                rule.name.name
15278                            ),
15279                            suggestion: Some(format!(
15280                                "declare `lease {resource} {{ key <Type>  slots <N>  ttl <duration> }}`"
15281                            )),
15282                        });
15283                    }
15284                    body::BodyEffectKind::LedgerAppend { ledger, schema, .. } => {
15285                        if !semantic.ledgers.contains(ledger) {
15286                            diagnostics.push(Diagnostic { related: Vec::new(),
15287                                span: effect.span,
15288                                message: format!(
15289                                    "rule `{}` appends to undeclared ledger `{ledger}`",
15290                                    rule.name.name
15291                                ),
15292                                suggestion: Some(format!(
15293                                    "declare `ledger {ledger} {{ entry <Type>  partition by <field>  retain <duration> }}`"
15294                                )),
15295                            });
15296                        }
15297                        if !semantic.schemas.class_exists(schema) {
15298                            diagnostics.push(Diagnostic {
15299                                related: Vec::new(),
15300                                span: effect.span,
15301                                message: format!(
15302                                    "rule `{}` appends unknown entry class `{schema}`",
15303                                    rule.name.name
15304                                ),
15305                                suggestion: Some(format!("declare `class {schema}` first")),
15306                            });
15307                        }
15308                    }
15309                    body::BodyEffectKind::CounterConsume { counter, .. }
15310                        if !semantic.counters.contains(counter) =>
15311                    {
15312                        diagnostics.push(Diagnostic { related: Vec::new(),
15313                            span: effect.span,
15314                            message: format!(
15315                                "rule `{}` consumes undeclared counter `{counter}`",
15316                                rule.name.name
15317                            ),
15318                            suggestion: Some(format!(
15319                                "declare `counter {counter} {{ key <Type>  cap <N>  reset <period> }}`"
15320                            )),
15321                        });
15322                    }
15323                    _ => {}
15324                }
15325                // `exec <name> with <binding>` requires a typed record binding
15326                // (spec/std-script.md "Static checks" item 4): the binding is
15327                // serialized to the script's stdin as a typed record, so an
15328                // unknown or untyped binding cannot cross.
15329                if let body::BodyEffectKind::Exec {
15330                    target:
15331                        body::ExecTarget::Capability {
15332                            name,
15333                            stdin_binding,
15334                        },
15335                    ..
15336                } = &effect.kind
15337                {
15338                    match binding_types.get(stdin_binding) {
15339                        None => {
15340                            diagnostics.push(Diagnostic {
15341                                related: Vec::new(),
15342                                span: effect.span,
15343                                message: format!(
15344                                    "rule `{}` uses unknown binding `{stdin_binding}` in `exec {name} with {stdin_binding}` — `with` requires a typed record binding",
15345                                    rule.name.name
15346                                ),
15347                                suggestion: Some(format!(
15348                                    "bind a typed record first (e.g. `when <Class> as {stdin_binding}` or `coerce ... -> <Class> as {stdin_binding}`) and pass that binding to `with`"
15349                                )),
15350                            });
15351                        }
15352                        // A dotted binding without an indexed payload class is an
15353                        // untyped runtime fact (`when fact <name> as x`) — no
15354                        // static record shape crosses to stdin. Non-dotted
15355                        // unknown classes are already reported at their binding
15356                        // site (`matches unknown class` / unknown parse schema).
15357                        Some(schema)
15358                            if schema.contains('.') && !semantic.schemas.class_exists(schema) =>
15359                        {
15360                            diagnostics.push(Diagnostic {
15361                                related: Vec::new(),
15362                                span: effect.span,
15363                                message: format!(
15364                                    "rule `{}` passes untyped fact binding `{stdin_binding}` to `exec {name} with` — `with` requires a typed record binding",
15365                                    rule.name.name
15366                                ),
15367                                suggestion: Some(format!(
15368                                    "declare `signal {schema} {{ ... }}` for a typed reaction, or bind a declared class and pass that to `with`"
15369                                )),
15370                            });
15371                        }
15372                        Some(_) => {}
15373                    }
15374                }
15375                if let body::BodyEffectKind::Exec {
15376                    parse_target: Some(parse),
15377                    ..
15378                } = &effect.kind
15379                {
15380                    if !semantic.schemas.class_exists(&parse.schema) {
15381                        let suggestion =
15382                            match closest_name(&parse.schema, semantic.schemas.classes.keys()) {
15383                                Some(candidate) => format!(
15384                                    "did you mean `{candidate}`? otherwise declare `class {}`",
15385                                    parse.schema
15386                                ),
15387                                None => format!(
15388                                    "declare `class {}` before parsing into it",
15389                                    parse.schema
15390                                ),
15391                            };
15392                        diagnostics.push(Diagnostic {
15393                            related: Vec::new(),
15394                            span: effect.span,
15395                            message: format!(
15396                                "rule `{}` parses exec output into unknown schema `{}`",
15397                                rule.name.name, parse.schema
15398                            ),
15399                            suggestion: Some(suggestion),
15400                        });
15401                    }
15402                }
15403                let body::BodyEffectKind::Timer {
15404                    until: Some(until), ..
15405                } = &effect.kind
15406                else {
15407                    continue;
15408                };
15409                if body::is_iso8601_instant(until) {
15410                    continue;
15411                }
15412                let mut segments = until.split('.');
15413                let root = segments.next().unwrap_or_default();
15414                let path = segments.map(str::to_owned).collect::<Vec<_>>();
15415                let Some(schema) = binding_types.get(root) else {
15416                    diagnostics.push(Diagnostic { related: Vec::new(),
15417                        span: effect.span,
15418                        message: format!(
15419                            "rule `{}` uses unknown binding `{root}` in `timer until {until}`",
15420                            rule.name.name
15421                        ),
15422                        suggestion: Some(
15423                            "bind a fact in `when` and reference a `time` field on it, or use an ISO-8601 literal"
15424                                .to_owned(),
15425                        ),
15426                    });
15427                    continue;
15428                };
15429                // Dotted runtime fact bindings are untyped; their fields
15430                // cannot be statically checked.
15431                if schema.contains('.') {
15432                    continue;
15433                }
15434                let resolved = if path.is_empty() {
15435                    Err(format!(
15436                        "`{root}` is a `{schema}` record, not a `time` value"
15437                    ))
15438                } else {
15439                    semantic.schemas.resolve_field_path(schema, &path)
15440                };
15441                match resolved {
15442                    Ok(TypeSyntax::Primitive { ref name, .. }) if name == "time" => {}
15443                    Ok(_) => {
15444                        diagnostics.push(Diagnostic { related: Vec::new(),
15445                            span: effect.span,
15446                            message: format!(
15447                                "rule `{}` uses non-time operand `{until}` in `timer until`",
15448                                rule.name.name
15449                            ),
15450                            suggestion: Some(format!(
15451                                "declare the field as `time` on `{schema}` or use an ISO-8601 literal"
15452                            )),
15453                        });
15454                    }
15455                    Err(message) => {
15456                        diagnostics.push(Diagnostic { related: Vec::new(),
15457                            span: effect.span,
15458                            message: format!(
15459                                "rule `{}` has invalid `timer until` operand `{until}`: {message}",
15460                                rule.name.name
15461                            ),
15462                            suggestion: Some(
15463                                "reference a `time`-typed field on a bound fact, or use an ISO-8601 literal"
15464                                    .to_owned(),
15465                            ),
15466                        });
15467                    }
15468                }
15469            }
15470            body::BodyStmt::After(after) => {
15471                validate_body_effect_operands(
15472                    rule,
15473                    &after.body,
15474                    semantic,
15475                    binding_types,
15476                    diagnostics,
15477                );
15478            }
15479            body::BodyStmt::Case(case) => {
15480                for branch in &case.branches {
15481                    validate_body_effect_operands(
15482                        rule,
15483                        &branch.body,
15484                        semantic,
15485                        binding_types,
15486                        diagnostics,
15487                    );
15488                }
15489            }
15490            _ => {}
15491        }
15492    }
15493}
15494
15495/// The reserved namespace the synthesized progress-view classes live under. A `.`
15496/// cannot appear in a declared class name, so these can never collide with an
15497/// author's class — the same guarantee the `<Enum>.<Variant>` sum-type lowering
15498/// relies on.
15499const PROGRESS_VIEW_NAMESPACE: &str = "region";
15500
15501/// DR-0043 Decision 7 obligation 2 — type the lapse arm.
15502///
15503/// The `on lapse` arm is spliced out of the canonical (condition-HOLDS) rule body,
15504/// so *nothing* validated it: not the progress view, and not ordinary bindings
15505/// either — `fail error { reason task.bogus }` in an arm was accepted in full.
15506/// This walks the arm text with the rule's binding environment extended by the
15507/// progress view, against a schema index extended with two synthesized classes:
15508///
15509///   `region.<rule>.Progress`  one optional field per step (the step's own settled
15510///                             payload) plus `steps`
15511///   `region.<rule>.Steps`     one `string` field per step (its status)
15512///
15513/// The DR's four rules then fall out of the ordinary path resolver: a field that is
15514/// neither a step nor `steps` has no field on Progress; an unknown step has no field
15515/// on Steps; a path *through* a status hits "is not a schema value"; and a deeper
15516/// path under a step resolves against that step's own schema because
15517/// `schema_name_for_path` sees through the optional.
15518///
15519/// The same splice hid the arm from Family B read-narrowing, so the arm is walked
15520/// with that pass too: an arm is an egress position like any other, and the region
15521/// it belongs to may itself sit inside a `case` arm whose allowances the arm keeps.
15522fn validate_lapse_arm(
15523    rule: &RuleDecl,
15524    region: &IrRegion,
15525    semantic: &SemanticContext,
15526    binding_types: &BTreeMap<String, String>,
15527    foreign_schemas: &BTreeMap<String, String>,
15528    effect_payload_types: &BTreeMap<String, IrType>,
15529    diagnostics: &mut Vec<Diagnostic>,
15530) {
15531    let mut schemas = semantic.schemas.clone();
15532    let mut arm_bindings = binding_types.clone();
15533
15534    if let Some(view) = &region.lapse_binding {
15535        let progress = format!("{PROGRESS_VIEW_NAMESPACE}.{}.Progress", rule.name.name);
15536        let steps = format!("{PROGRESS_VIEW_NAMESPACE}.{}.Steps", rule.name.name);
15537
15538        let mut progress_fields = BTreeMap::new();
15539        let mut steps_fields = BTreeMap::new();
15540        // Derive the step set from `region.effects` — the same list the kernel
15541        // walks when it pins the view (rule_pass.rs), including the `__then_`
15542        // strip, so the checker's field set and the runtime's key set cannot
15543        // drift apart.
15544        for effect in &region.effects {
15545            let step = effect
15546                .binding
15547                .strip_prefix(then_expand::THEN_BINDING_PREFIX)
15548                .unwrap_or(&effect.binding)
15549                .to_owned();
15550            // A step's status is always a string; its settled value is optional
15551            // because the arm can run before that step settled — or at all.
15552            steps_fields.insert(step.clone(), string_ty());
15553            let settled = match effect_payload_types.get(&effect.binding) {
15554                Some(IrType::Ref(name)) if semantic.schemas.class_exists(name) => TypeSyntax::Ref {
15555                    name: Ident {
15556                        name: name.clone(),
15557                        span: zero_span(),
15558                    },
15559                },
15560                // No resolvable payload schema: the step reads as a settled scalar,
15561                // so a bare read is fine and a deeper path correctly errors.
15562                _ => string_ty(),
15563            };
15564            progress_fields.insert(step, optional_ty(settled));
15565        }
15566        progress_fields.insert(
15567            "steps".to_owned(),
15568            TypeSyntax::Ref {
15569                name: Ident {
15570                    name: steps.clone(),
15571                    span: zero_span(),
15572                },
15573            },
15574        );
15575
15576        schemas.classes.insert(steps, steps_fields);
15577        schemas.classes.insert(progress.clone(), progress_fields);
15578        arm_bindings.insert(view.clone(), progress);
15579    }
15580
15581    for line in region.arm_content.lines() {
15582        let line = line.trim();
15583        if line.is_empty() {
15584            continue;
15585        }
15586        validate_known_field_paths_in_index(
15587            rule,
15588            line,
15589            rule.body.span,
15590            // The synthesized view classes are local to this check; an ambient
15591            // binding the arm inherits may still be invoke-derived and resolve in
15592            // a child's index.
15593            SchemaScopes {
15594                local: &schemas,
15595                foreign: foreign_schemas,
15596                workflows: &semantic.workflow_inputs,
15597            },
15598            &arm_bindings,
15599            diagnostics,
15600        );
15601    }
15602
15603    // Family B read-narrowing. The arm is an egress like any other body position —
15604    // `coerce fa(e.region)` in an arm carries the conditioned value out of the
15605    // instance — and the splice is the only reason the rule-body pass never saw it.
15606    // The allowed set starts from the `case` arms the region sits inside, so an arm
15607    // under `case e.kind { "deploy" => … }` keeps that arm's allowances.
15608    let (arm_ast, _) = body::parse_rule_body(&region.arm_content, 0);
15609    let mut allowed = BTreeSet::new();
15610    for (scrutinee, pattern) in &region.arm_case_arms {
15611        allowed.extend(family_b_arm_allowed(
15612            scrutinee,
15613            pattern,
15614            &arm_bindings,
15615            semantic,
15616        ));
15617    }
15618    let mut arm_diagnostics = Vec::new();
15619    validate_conditioned_field_reads(
15620        rule,
15621        &arm_ast.statements,
15622        semantic,
15623        &arm_bindings,
15624        &allowed,
15625        &mut arm_diagnostics,
15626    );
15627    // `arm_content` is cut from the then-expanded body text, so offsets into it are
15628    // not source positions. Every arm diagnostic is reported at the body span, the
15629    // same span the field-path walk above uses.
15630    for mut diagnostic in arm_diagnostics {
15631        diagnostic.span = rule.body.span;
15632        diagnostics.push(diagnostic);
15633    }
15634}
15635
15636/// No binding in this scope carries a foreign (child-workflow) schema.
15637static NO_FOREIGN_SCHEMAS: BTreeMap<String, String> = BTreeMap::new();
15638static NO_WORKFLOW_SURFACES: BTreeMap<String, WorkflowInputSurface> = BTreeMap::new();
15639
15640/// Which schema index a binding's field paths resolve in.
15641///
15642/// A parent that observes a child workflow's result, failure, or milestone
15643/// payload holds a value whose class may be declared *inside that child*. The
15644/// class is not nameable in the parent — and must not become nameable, or a
15645/// child's private types would leak into the parent's declaration space — but
15646/// its fields are exactly the contract the parent was handed, so the parent's
15647/// reads resolve in the child's own index. That is structural typing across the
15648/// workflow boundary, not an import.
15649///
15650/// A binding with no `foreign` entry resolves locally, which is every ordinary
15651/// binding.
15652#[derive(Clone, Copy)]
15653struct SchemaScopes<'a> {
15654    local: &'a SchemaIndex,
15655    /// Binding -> the child workflow whose index types it.
15656    foreign: &'a BTreeMap<String, String>,
15657    workflows: &'a BTreeMap<String, WorkflowInputSurface>,
15658}
15659
15660impl<'a> SchemaScopes<'a> {
15661    fn local(local: &'a SchemaIndex) -> Self {
15662        Self {
15663            local,
15664            foreign: &NO_FOREIGN_SCHEMAS,
15665            workflows: &NO_WORKFLOW_SURFACES,
15666        }
15667    }
15668
15669    fn index_for(&self, binding: &str) -> &'a SchemaIndex {
15670        self.foreign
15671            .get(binding)
15672            .and_then(|workflow| self.workflows.get(workflow))
15673            .map_or(self.local, |surface| &surface.schemas)
15674    }
15675}
15676
15677fn validate_known_field_paths(
15678    rule: &RuleDecl,
15679    line: &str,
15680    semantic: &SemanticContext,
15681    binding_types: &BTreeMap<String, String>,
15682    diagnostics: &mut Vec<Diagnostic>,
15683) {
15684    validate_known_field_paths_at_span(
15685        rule,
15686        line,
15687        rule.body.span,
15688        semantic,
15689        binding_types,
15690        diagnostics,
15691    );
15692}
15693
15694/// `validate_known_field_paths` for a scope that can hold invoke-derived
15695/// bindings, whose payload classes may live in the child workflow's index.
15696fn validate_known_field_paths_scoped(
15697    rule: &RuleDecl,
15698    line: &str,
15699    semantic: &SemanticContext,
15700    binding_types: &BTreeMap<String, String>,
15701    foreign: &BTreeMap<String, String>,
15702    diagnostics: &mut Vec<Diagnostic>,
15703) {
15704    validate_known_field_paths_in_index(
15705        rule,
15706        line,
15707        rule.body.span,
15708        SchemaScopes {
15709            local: &semantic.schemas,
15710            foreign,
15711            workflows: &semantic.workflow_inputs,
15712        },
15713        binding_types,
15714        diagnostics,
15715    );
15716}
15717
15718fn validate_known_field_paths_at_span(
15719    rule: &RuleDecl,
15720    line: &str,
15721    span: SourceSpan,
15722    semantic: &SemanticContext,
15723    binding_types: &BTreeMap<String, String>,
15724    diagnostics: &mut Vec<Diagnostic>,
15725) {
15726    validate_known_field_paths_in_index(
15727        rule,
15728        line,
15729        span,
15730        SchemaScopes::local(&semantic.schemas),
15731        binding_types,
15732        diagnostics,
15733    );
15734}
15735
15736/// `validate_known_field_paths_at_span` against explicit schema scopes rather
15737/// than the program's index alone. The lapse arm resolves its progress view
15738/// against an index extended with that region's synthesized view classes, which
15739/// exist only for the duration of the check; an invoke-derived binding resolves
15740/// against the child workflow that produced it.
15741/// What resolving one `<root>.<path>` read established. Callers that only want
15742/// the diagnostic ignore this; the record-field walk acts on it, because a read
15743/// it cannot resolve suppresses the literal and expected-value checks that
15744/// follow it.
15745#[derive(Clone, Copy, Eq, PartialEq)]
15746enum FieldPathCheck {
15747    /// `root` is not a typed binding here. Nothing was checked or reported —
15748    /// the caller decides whether that means a dangling reference.
15749    Unbound,
15750    /// `root` is typed, but its schema is absent from the index consulted. A
15751    /// child workflow's private class reads this way in a scope that was not
15752    /// given the child's index. Nothing was checked or reported.
15753    ///
15754    /// Distinct from `Resolved` for one caller only: `validate_record_field`
15755    /// stops the field here rather than running the literal and expected-value
15756    /// checks against a schema it cannot see. That is observable in a narrow
15757    /// shape — `expression_path` accepts any expression holding exactly ONE
15758    /// dotted path, so a brace- or bracket-valued field carrying one reaches
15759    /// `validate_expected_assignment`, which acts on it. For a bare path both of
15760    /// those validators return immediately and the distinction does not show.
15761    SchemaNotIndexed,
15762    /// The path was resolved. A diagnostic was pushed if it did not.
15763    Resolved,
15764}
15765
15766/// Resolve one `<root>.<path>` read against the index that types `root`, and
15767/// report an unresolvable one.
15768///
15769/// This is the single implementation. It used to exist three times — here, in
15770/// `validate_scalar_terminal_payload`, and in `validate_record_field` — with the
15771/// same message and suggestion but three different control flows, so a change to
15772/// one (scope awareness, say) silently left the other two behind.
15773fn check_field_path(
15774    rule: &RuleDecl,
15775    root: &str,
15776    path: &[String],
15777    span: SourceSpan,
15778    scopes: SchemaScopes,
15779    binding_types: &BTreeMap<String, String>,
15780    diagnostics: &mut Vec<Diagnostic>,
15781) -> FieldPathCheck {
15782    let Some(schema) = binding_types.get(root) else {
15783        return FieldPathCheck::Unbound;
15784    };
15785    let schemas = scopes.index_for(root);
15786    if !schemas.class_exists(schema) {
15787        return FieldPathCheck::SchemaNotIndexed;
15788    }
15789    if let Err(message) = schemas.resolve_field_path(schema, path) {
15790        diagnostics.push(Diagnostic {
15791            related: Vec::new(),
15792            span,
15793            message: format!(
15794                "rule `{}` has invalid field path `{root}.{}`: {message}",
15795                rule.name.name,
15796                path.join(".")
15797            ),
15798            suggestion: Some(
15799                "use a field declared on the bound schema or add it to the class declaration"
15800                    .to_owned(),
15801            ),
15802        });
15803    }
15804    FieldPathCheck::Resolved
15805}
15806
15807fn validate_known_field_paths_in_index(
15808    rule: &RuleDecl,
15809    line: &str,
15810    span: SourceSpan,
15811    scopes: SchemaScopes,
15812    binding_types: &BTreeMap<String, String>,
15813    diagnostics: &mut Vec<Diagnostic>,
15814) {
15815    for (root, path) in dotted_paths(line) {
15816        check_field_path(rule, &root, &path, span, scopes, binding_types, diagnostics);
15817    }
15818}
15819
15820fn dotted_paths(line: &str) -> Vec<(String, Vec<String>)> {
15821    let bytes = line.as_bytes();
15822    let mut paths = Vec::new();
15823    let mut index = 0;
15824
15825    while index < bytes.len() {
15826        if !is_ident_start(bytes[index]) {
15827            index += 1;
15828            continue;
15829        }
15830
15831        let root_start = index;
15832        index += 1;
15833        while index < bytes.len() && is_ident_continue(bytes[index]) {
15834            index += 1;
15835        }
15836        let root = &line[root_start..index];
15837        let mut fields = Vec::new();
15838
15839        while bytes.get(index) == Some(&b'.')
15840            && bytes
15841                .get(index + 1)
15842                .is_some_and(|byte| is_ident_start(*byte))
15843        {
15844            index += 1;
15845            let field_start = index;
15846            index += 1;
15847            while index < bytes.len() && is_ident_continue(bytes[index]) {
15848                index += 1;
15849            }
15850            fields.push(line[field_start..index].to_owned());
15851        }
15852
15853        if !fields.is_empty() {
15854            paths.push((root.to_owned(), fields));
15855        }
15856    }
15857
15858    paths
15859}
15860
15861fn interpolation_roots(line: &str) -> Vec<String> {
15862    let mut roots = Vec::new();
15863    let mut rest = line;
15864
15865    while let Some(open) = rest.find("{{") {
15866        let after_open = &rest[open + 2..];
15867        let Some(close) = after_open.find("}}") else {
15868            break;
15869        };
15870        let expr = after_open[..close].trim();
15871        if let Some(root) = expr
15872            .split(|ch: char| !ch.is_alphanumeric() && ch != '_')
15873            .find(|part| !part.is_empty())
15874        {
15875            roots.push(root.to_owned());
15876        }
15877        rest = &after_open[close + 2..];
15878    }
15879
15880    roots
15881}
15882
15883// `claim` stays bindable: `claim item as claim` is an established idiom and
15884// the trailing binding position is unambiguous.
15885const RESERVED_BINDING_KEYWORDS: &[&str] = &[
15886    "after", "call", "case", "coerce", "complete", "consume", "done", "emit", "fail", "invoke",
15887    "record", "tell", "when", "where",
15888];
15889
15890fn validate_binding_name(
15891    rule: &RuleDecl,
15892    binding: &str,
15893    span: SourceSpan,
15894    diagnostics: &mut Vec<Diagnostic>,
15895) {
15896    if RESERVED_BINDING_KEYWORDS.contains(&binding) {
15897        diagnostics.push(Diagnostic {
15898            related: Vec::new(),
15899            span,
15900            message: format!(
15901                "rule `{}` binds reserved keyword `{binding}`",
15902                rule.name.name
15903            ),
15904            suggestion: Some(format!(
15905                "`{binding}` is a rule body keyword; choose another binding name"
15906            )),
15907        });
15908    }
15909}
15910
15911fn closest_name<'a>(target: &str, candidates: impl Iterator<Item = &'a String>) -> Option<String> {
15912    let target_lower = target.to_lowercase();
15913    candidates
15914        .map(|candidate| {
15915            let distance = edit_distance(&target_lower, &candidate.to_lowercase());
15916            (distance, candidate)
15917        })
15918        .filter(|(distance, candidate)| {
15919            *distance <= 2 && *distance < target.len().min(candidate.len())
15920        })
15921        .min_by_key(|(distance, candidate)| (*distance, candidate.as_str().to_owned()))
15922        .map(|(_, candidate)| candidate.clone())
15923}
15924
15925fn edit_distance(a: &str, b: &str) -> usize {
15926    let a: Vec<char> = a.chars().collect();
15927    let b: Vec<char> = b.chars().collect();
15928    let mut previous: Vec<usize> = (0..=b.len()).collect();
15929    let mut current = vec![0usize; b.len() + 1];
15930    for (i, a_char) in a.iter().enumerate() {
15931        current[0] = i + 1;
15932        for (j, b_char) in b.iter().enumerate() {
15933            let substitution = previous[j] + usize::from(a_char != b_char);
15934            current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
15935        }
15936        std::mem::swap(&mut previous, &mut current);
15937    }
15938    previous[b.len()]
15939}
15940
15941fn fact_read_from_when(when: &str) -> String {
15942    let (pattern, _) = split_when_guard(when);
15943    let first = pattern.split_whitespace().next().unwrap_or("<empty>");
15944    if first.chars().next().is_some_and(char::is_uppercase) {
15945        format!("schema:{first}")
15946    } else {
15947        format!("pattern:{pattern}")
15948    }
15949}
15950
15951fn parse_record_start(line: &str) -> Option<(String, Option<String>)> {
15952    let rest = line.strip_prefix("record ").or_else(|| {
15953        line.strip_prefix("done ")
15954            .and_then(|rest| rest.split_once("->"))
15955            .map(|(_, record)| record.trim())
15956            .and_then(|record| record.strip_prefix("record "))
15957    })?;
15958    let before_brace = rest.split('{').next().unwrap_or(rest).trim();
15959    let mut parts = before_brace.split_whitespace();
15960    let schema = parts.next()?.to_owned();
15961    let from_binding = match (parts.next(), parts.next(), parts.next()) {
15962        (None, None, None) => None,
15963        (Some("from"), Some(binding), None) => Some(binding.to_owned()),
15964        _ => return None,
15965    };
15966    Some((schema, from_binding))
15967}
15968
15969fn validate_record_field(
15970    rule: &RuleDecl,
15971    line: &str,
15972    record_schema: &str,
15973    semantic: &SemanticContext,
15974    binding_types: &BTreeMap<String, String>,
15975    known_roots: &BTreeSet<String>,
15976    diagnostics: &mut Vec<Diagnostic>,
15977) {
15978    let Some((field, expr)) = record_field_assignment(line) else {
15979        diagnostics.push(Diagnostic {
15980            related: Vec::new(),
15981            span: rule.body.span,
15982            message: format!(
15983                "rule `{}` has malformed field assignment in `record {record_schema}`",
15984                rule.name.name
15985            ),
15986            suggestion: Some("write record fields as `field value`".to_owned()),
15987        });
15988        return;
15989    };
15990
15991    let Some(fields) = semantic.schemas.classes.get(record_schema) else {
15992        return;
15993    };
15994    let Some(field_ty) = fields.get(field) else {
15995        diagnostics.push(Diagnostic {
15996            related: Vec::new(),
15997            span: rule.body.span,
15998            message: format!("class `{record_schema}` has no field `{field}`"),
15999            suggestion: Some(format!(
16000                "add `{field}` to `class {record_schema}` or record an existing field"
16001            )),
16002        });
16003        return;
16004    };
16005
16006    if let Some((root, path)) = expression_path(expr) {
16007        // Local scopes only, for the same reason as the terminal payload above.
16008        match check_field_path(
16009            rule,
16010            &root,
16011            &path,
16012            rule.body.span,
16013            SchemaScopes::local(&semantic.schemas),
16014            binding_types,
16015            diagnostics,
16016        ) {
16017            // A read this scope cannot resolve says nothing about the literal
16018            // and expected-value checks below either, so it stops the field
16019            // here rather than letting them judge a schema they cannot see.
16020            FieldPathCheck::SchemaNotIndexed => return,
16021            FieldPathCheck::Resolved => {}
16022            // A field access whose root is neither a bound name nor a special
16023            // root is a dangling reference: the binding does not exist.
16024            FieldPathCheck::Unbound => {
16025                if let Some(root) = dangling_value_root(expr, known_roots) {
16026                    diagnostics.push(Diagnostic {
16027                        related: Vec::new(),
16028                        span: rule.body.span,
16029                        message: format!(
16030                            "rule `{}` has unknown binding `{root}` in `record {record_schema}` field `{field}`",
16031                            rule.name.name
16032                        ),
16033                        suggestion: Some(
16034                            "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16035                                .to_owned(),
16036                        ),
16037                    });
16038                }
16039            }
16040        }
16041    }
16042
16043    validate_literal_assignment(
16044        rule,
16045        record_schema,
16046        field,
16047        field_ty,
16048        expr,
16049        semantic,
16050        diagnostics,
16051    );
16052    validate_expected_assignment(
16053        rule,
16054        record_schema,
16055        field,
16056        field_ty,
16057        expr,
16058        semantic,
16059        binding_types,
16060        diagnostics,
16061    );
16062}
16063
16064fn record_field_assignment(line: &str) -> Option<(&str, &str)> {
16065    let field_end = line.find(char::is_whitespace)?;
16066    let field = &line[..field_end];
16067    let expr = line[field_end..].trim();
16068    (!field.is_empty() && !expr.is_empty()).then_some((field, expr))
16069}
16070
16071/// Roots valid in value positions without being author bindings: the
16072/// external-event payload and the coerce prompt context. An explicit allowlist
16073/// so genuine typos are still caught.
16074const SPECIAL_VALUE_ROOTS: &[&str] = &["external", "ctx"];
16075
16076/// Collects every binding NAME a rule body introduces, from the parsed AST so it
16077/// is robust to multi-line prompts and nesting (the line-based effect collectors
16078/// only track `coerce`/`claim`, so `tell`/`exec`/etc. bindings are invisible to
16079/// `binding_types`). Used to reject dangling roots in value positions without
16080/// false-flagging valid effect results, `after` aliases, or case bindings.
16081fn collect_all_binding_names(statements: &[body::BodyStmt], out: &mut BTreeSet<String>) {
16082    for statement in statements {
16083        match statement {
16084            body::BodyStmt::Effect(effect) => {
16085                if let Some(binding) = &effect.binding {
16086                    out.insert(binding.clone());
16087                }
16088            }
16089            body::BodyStmt::Region(region) => {
16090                if let Some(view) = &region.lapse_binding {
16091                    out.insert(view.clone());
16092                }
16093                collect_all_binding_names(&region.body, out);
16094                collect_all_binding_names(&region.lapse_body, out);
16095            }
16096            body::BodyStmt::After(after) => {
16097                if let Some(alias) = &after.alias {
16098                    out.insert(alias.clone());
16099                }
16100                collect_all_binding_names(&after.body, out);
16101            }
16102            body::BodyStmt::Case(case) => {
16103                for branch in &case.branches {
16104                    if let Some(binding) = &branch.binding {
16105                        out.insert(binding.clone());
16106                    }
16107                    collect_all_binding_names(&branch.body, out);
16108                }
16109            }
16110            // `redact … as <out>` introduces the projected binding `out`.
16111            body::BodyStmt::Redact { binding, .. } => {
16112                out.insert(binding.clone());
16113            }
16114            body::BodyStmt::Record(_)
16115            | body::BodyStmt::Done { .. }
16116            | body::BodyStmt::Terminal(_)
16117            | body::BodyStmt::Milestone { .. }
16118            | body::BodyStmt::Cancel { .. } => {}
16119        }
16120    }
16121}
16122
16123/// A `source`'s `emit <signal>` must name a declared `signal` — the ingestion
16124/// mirror of the rule-side "reacts to undeclared signal" check on `when <signal>`.
16125/// Only dotted names are typed signal declarations (a bare name is a class/fact,
16126/// consistent with the reaction check). Without this a source silently admits a
16127/// signal fact no rule can react to (rules may only react to declared signals),
16128/// so ingested data is dropped with no diagnostic — this lifts the guarantee to
16129/// static `whip check`, symmetric with the clock/file/http source runtime that
16130/// admits `emit_signal`.
16131fn validate_source_emit_signal_declared(
16132    source: &SourceDecl,
16133    declared_signals: &BTreeSet<String>,
16134    diagnostics: &mut Vec<Diagnostic>,
16135) {
16136    let signal = &source.emit.signal;
16137    if signal.contains('.') && !declared_signals.contains(signal) {
16138        let suggestion = match closest_name(signal, declared_signals.iter()) {
16139            Some(candidate) => {
16140                format!("did you mean `{candidate}`? otherwise declare `signal {signal} {{ ... }}`")
16141            }
16142            None => format!("declare `signal {signal} {{ ... }}` so rules can react to it"),
16143        };
16144        diagnostics.push(Diagnostic {
16145            related: Vec::new(),
16146            span: source.emit.signal_span,
16147            message: format!(
16148                "source `{}` emits undeclared signal `{}`",
16149                source.name.name, signal
16150            ),
16151            suggestion: Some(suggestion),
16152        });
16153    }
16154
16155    // The emit fields map the source's observation record onto the signal
16156    // payload by name. Each `<field> <observe>.<obsfield>` must read a real
16157    // observation field for this source's kind, else it silently maps null at
16158    // runtime. The observation schemas below MUST mirror the records built in
16159    // the CLI source resolvers (`resolve_due_{clock,file,http}_sources`): add a
16160    // field there → add it here. Unknown providers have no known schema, so
16161    // their emit fields are not checked (avoids false positives).
16162    let observation_fields: Option<&[&str]> = match source.provider.name.as_str() {
16163        "clock" => Some(&[
16164            "scheduled_at",
16165            "observed_at",
16166            "occurrence_id",
16167            "missed_count",
16168            "schedule_name",
16169        ]),
16170        // `file` observes lines in `path` mode and (path, content-hash)
16171        // occurrences in `watch` mode (spec/std-ingress.md I2a; content
16172        // READING stays std.files).
16173        "file" if source.watch.is_some() => Some(&["path", "content_hash", "watch"]),
16174        "file" => Some(&["line", "line_index", "path"]),
16175        "http" => Some(&["item", "item_index", "url"]),
16176        _ => None,
16177    };
16178    // `dedup` reads the same observation record the emit mapping reads: an
16179    // unknown field would make the admission key silently null at runtime.
16180    if let (Some(fields), Some(SourceValue::Path { segments, .. })) =
16181        (observation_fields, &source.dedup)
16182    {
16183        if let [field] = segments.as_slice() {
16184            if !fields.contains(&field.name.as_str()) {
16185                diagnostics.push(Diagnostic {
16186                    related: Vec::new(),
16187                    span: field.span,
16188                    message: format!(
16189                        "source `{}` `dedup` reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16190                        source.name.name,
16191                        source.observe_binding.name,
16192                        field.name,
16193                        source.provider.name,
16194                        field.name
16195                    ),
16196                    suggestion: Some(format!(
16197                        "available observation fields: {}",
16198                        fields.join(", ")
16199                    )),
16200                });
16201            }
16202        }
16203    }
16204    if let Some(fields) = observation_fields {
16205        let observe = &source.observe_binding.name;
16206        for emit_field in &source.emit.fields {
16207            let SourceValue::Path {
16208                binding,
16209                segments,
16210                span,
16211            } = &emit_field.value
16212            else {
16213                continue;
16214            };
16215            if &binding.name != observe {
16216                diagnostics.push(Diagnostic {
16217                    related: Vec::new(),
16218                    span: *span,
16219                    message: format!(
16220                        "source `{}` emit reads unknown binding `{}`",
16221                        source.name.name, binding.name
16222                    ),
16223                    suggestion: Some(format!(
16224                        "the source's observation binding is `{observe}` (declared by `observe as {observe}`)"
16225                    )),
16226                });
16227                continue;
16228            }
16229            if let Some(obs_field) = segments.first() {
16230                if !fields.contains(&obs_field.name.as_str()) {
16231                    diagnostics.push(Diagnostic {
16232                        related: Vec::new(),
16233                        span: obs_field.span,
16234                        message: format!(
16235                            "source `{}` emit reads `{}.{}`, but a `{}` source's observation has no field `{}`",
16236                            source.name.name, observe, obs_field.name, source.provider.name, obs_field.name
16237                        ),
16238                        suggestion: Some(format!(
16239                            "available observation fields: {}",
16240                            fields.join(", ")
16241                        )),
16242                    });
16243                }
16244            }
16245        }
16246    }
16247}
16248
16249/// `emit signal <name> to <target>` requires `<name>` to be a declared `signal`
16250/// — the declaration is the typed payload contract at the emit site, symmetric
16251/// with the reaction-side "reacts to undeclared signal" check on `when <signal>`
16252/// (spec/event-ingress.md, "Directed injection"). Without this, an emit of an
16253/// undeclared signal only fails at runtime when the effect input is built
16254/// (`whipplescript_kernel::rule_lowering`, "emit signal of undeclared signal");
16255/// this lifts the same guarantee to static `whip check`. Recurses into
16256/// `after`/`case`/`branch`/`handler` bodies so a nested emit is covered too.
16257fn validate_emit_signal_declarations(
16258    rule: &RuleDecl,
16259    statements: &[body::BodyStmt],
16260    declared_signals: &BTreeSet<String>,
16261    diagnostics: &mut Vec<Diagnostic>,
16262) {
16263    for statement in statements {
16264        match statement {
16265            body::BodyStmt::Effect(effect) => {
16266                if let body::BodyEffectKind::Notify { event, .. } = &effect.kind {
16267                    if !declared_signals.contains(event) {
16268                        diagnostics.push(Diagnostic {
16269                            related: Vec::new(),
16270                            span: effect.span,
16271                            message: format!(
16272                                "rule `{}` emits undeclared signal `{event}`",
16273                                rule.name.name
16274                            ),
16275                            suggestion: Some(format!(
16276                                "declare `signal {event} {{ ... }}` so the emitted payload is typed and admissible, \
16277                                 or check the signal name"
16278                            )),
16279                        });
16280                    }
16281                }
16282            }
16283            body::BodyStmt::After(after) => {
16284                validate_emit_signal_declarations(rule, &after.body, declared_signals, diagnostics)
16285            }
16286            body::BodyStmt::Case(case) => {
16287                for branch in &case.branches {
16288                    validate_emit_signal_declarations(
16289                        rule,
16290                        &branch.body,
16291                        declared_signals,
16292                        diagnostics,
16293                    );
16294                }
16295            }
16296            _ => {}
16297        }
16298    }
16299}
16300
16301/// Flags dangling roots in the field payloads of body-AST effects that the
16302/// line-based validators don't reach: `emit`/`notify` (`Notify`), `file item
16303/// into` (`TrackerFile`), and ledger `append` (`LedgerAppend`). Uses the parsed
16304/// AST and the same root check as the record/coerce/tell/invoke validators.
16305fn validate_effect_field_roots(
16306    rule: &RuleDecl,
16307    statements: &[body::BodyStmt],
16308    known_roots: &BTreeSet<String>,
16309    diagnostics: &mut Vec<Diagnostic>,
16310) {
16311    for statement in statements {
16312        match statement {
16313            body::BodyStmt::Effect(effect) => match &effect.kind {
16314                body::BodyEffectKind::Notify {
16315                    target_expr,
16316                    event,
16317                    from,
16318                    fields,
16319                } => {
16320                    if let Some(from) = from {
16321                        check_operand_root(
16322                            rule,
16323                            &format!("emit `{event}` from"),
16324                            from,
16325                            known_roots,
16326                            diagnostics,
16327                        );
16328                    }
16329                    check_operand_root(
16330                        rule,
16331                        &format!("emit `{event}` target"),
16332                        target_expr,
16333                        known_roots,
16334                        diagnostics,
16335                    );
16336                    check_field_value_roots(
16337                        rule,
16338                        &format!("emit `{event}`"),
16339                        fields,
16340                        known_roots,
16341                        diagnostics,
16342                    );
16343                }
16344                body::BodyEffectKind::TrackerFile { queue, fields } => {
16345                    check_field_value_roots(
16346                        rule,
16347                        &format!("file into `{queue}`"),
16348                        fields,
16349                        known_roots,
16350                        diagnostics,
16351                    );
16352                }
16353                body::BodyEffectKind::TrackerFinish { item, fields } => {
16354                    check_operand_root(rule, "finish item", item, known_roots, diagnostics);
16355                    check_field_value_roots(rule, "finish", fields, known_roots, diagnostics);
16356                }
16357                body::BodyEffectKind::LedgerAppend { ledger, fields, .. } => {
16358                    check_field_value_roots(
16359                        rule,
16360                        &format!("append to `{ledger}`"),
16361                        fields,
16362                        known_roots,
16363                        diagnostics,
16364                    );
16365                }
16366                body::BodyEffectKind::LeaseAcquire {
16367                    resource, key_expr, ..
16368                } => {
16369                    check_operand_root(
16370                        rule,
16371                        &format!("acquire `{resource}` key"),
16372                        key_expr,
16373                        known_roots,
16374                        diagnostics,
16375                    );
16376                }
16377                body::BodyEffectKind::CounterConsume {
16378                    counter,
16379                    key_expr,
16380                    amount_expr,
16381                } => {
16382                    check_operand_root(
16383                        rule,
16384                        &format!("consume `{counter}` key"),
16385                        key_expr,
16386                        known_roots,
16387                        diagnostics,
16388                    );
16389                    check_operand_root(
16390                        rule,
16391                        &format!("consume `{counter}` amount"),
16392                        amount_expr,
16393                        known_roots,
16394                        diagnostics,
16395                    );
16396                }
16397                _ => {}
16398            },
16399            body::BodyStmt::After(after) => {
16400                validate_effect_field_roots(rule, &after.body, known_roots, diagnostics)
16401            }
16402            body::BodyStmt::Case(case) => {
16403                for branch in &case.branches {
16404                    validate_effect_field_roots(rule, &branch.body, known_roots, diagnostics);
16405                }
16406            }
16407            _ => {}
16408        }
16409    }
16410}
16411
16412/// The single source of truth for value-position root validation: returns the
16413/// dangling root of a single-path value expression — a `root.field…` access whose
16414/// root is neither a known binding nor a recognized special root — or `None`.
16415/// Bare atoms (agents, enum variants, literals) have no path and are ignored; the
16416/// `"`-guard skips values whose "path" was mis-extracted from inside a string
16417/// literal. Used by every value-position validator (record/terminal/coerce/tell/
16418/// invoke/effect payloads/operands).
16419fn dangling_value_root(value: &str, known_roots: &BTreeSet<String>) -> Option<String> {
16420    let (root, path) = expression_path(value)?;
16421    if !path.is_empty()
16422        && !value.contains('"')
16423        && !known_roots.contains(&root)
16424        && !SPECIAL_VALUE_ROOTS.contains(&root.as_str())
16425    {
16426        Some(root)
16427    } else {
16428        None
16429    }
16430}
16431
16432/// Flags a dangling root in a single effect-operand expression (e.g. an
16433/// `emit ... to <target>` target, a lease/counter `for <key>` key). Same check
16434/// as the field/record validators.
16435fn check_operand_root(
16436    rule: &RuleDecl,
16437    context: &str,
16438    operand: &str,
16439    known_roots: &BTreeSet<String>,
16440    diagnostics: &mut Vec<Diagnostic>,
16441) {
16442    if let Some(root) = dangling_value_root(operand, known_roots) {
16443        diagnostics.push(Diagnostic { related: Vec::new(),
16444            span: rule.body.span,
16445            message: format!(
16446                "rule `{}` has unknown binding `{root}` in {context} `{operand}`",
16447                rule.name.name
16448            ),
16449            suggestion: Some(
16450                "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16451                    .to_owned(),
16452            ),
16453        });
16454    }
16455}
16456
16457fn check_field_value_roots(
16458    rule: &RuleDecl,
16459    context: &str,
16460    fields: &[body::FieldAssign],
16461    known_roots: &BTreeSet<String>,
16462    diagnostics: &mut Vec<Diagnostic>,
16463) {
16464    for field in fields {
16465        match &field.value {
16466            body::FieldValue::Expr { source, .. } => {
16467                if let Some(root) = dangling_value_root(source, known_roots) {
16468                    diagnostics.push(Diagnostic { related: Vec::new(),
16469                        span: rule.body.span,
16470                        message: format!(
16471                            "rule `{}` has unknown binding `{root}` in {context} field `{}`",
16472                            rule.name.name, field.name
16473                        ),
16474                        suggestion: Some(
16475                            "reference a binding from a `when ... as name` clause, an effect `as` binding, or a `case` pattern"
16476                                .to_owned(),
16477                        ),
16478                    });
16479                }
16480            }
16481            body::FieldValue::Nested { fields, .. } => {
16482                check_field_value_roots(rule, context, fields, known_roots, diagnostics)
16483            }
16484            body::FieldValue::Shorthand => {}
16485        }
16486    }
16487}
16488
16489/// The complete set of value-position binding roots for a rule: `when` bindings
16490/// plus every binding the body introduces, collected from the parsed AST.
16491fn known_roots_for_rule(rule: &RuleDecl) -> BTreeSet<String> {
16492    let mut roots: BTreeSet<String> = binding_types_for_rule(rule).into_keys().collect();
16493    let (body_ast, _) = body::parse_rule_body(&rule.body.text, rule.body.span.start);
16494    collect_all_binding_names(&body_ast.statements, &mut roots);
16495    roots
16496}
16497
16498fn validate_record_blocks(
16499    rule: &RuleDecl,
16500    semantic: &SemanticContext,
16501    binding_types: &BTreeMap<String, String>,
16502    known_roots: &BTreeSet<String>,
16503    diagnostics: &mut Vec<Diagnostic>,
16504) {
16505    for (schema, from_binding, body) in record_blocks(&rule.body.text) {
16506        // A token where a field NAME was expected is a value the author wrote
16507        // that nothing consumes. The splitter skips it silently, so
16508        // `record Out { title  "hello" }` compiled clean and recorded the
16509        // shorthand's value instead of the literal — a different value than the
16510        // source says, with no diagnostic anywhere.
16511        for stray in body::stray_value_tokens(&body) {
16512            diagnostics.push(Diagnostic {
16513                related: Vec::new(),
16514                span: rule.body.span,
16515                message: format!(
16516                    "rule `{}` has a value with no field name in `record {schema}`: `{stray}`",
16517                    rule.name.name
16518                ),
16519                suggestion: Some(format!(
16520                    "give it a field name (`<field> {stray}`), or remove it"
16521                )),
16522            });
16523        }
16524        for assignment in collect_field_assignments(&body) {
16525            let (field, value) = match assignment {
16526                RecordFieldAssignment::Value { field, value } => (field, value),
16527                RecordFieldAssignment::Shorthand { field } => {
16528                    let value = from_binding
16529                        .as_ref()
16530                        .map(|binding| format!("{binding}.{field}"))
16531                        .unwrap_or_else(|| field.clone());
16532                    (field, value)
16533                }
16534            };
16535            let line = format!("{field} {value}");
16536            validate_record_field(
16537                rule,
16538                &line,
16539                &schema,
16540                semantic,
16541                binding_types,
16542                known_roots,
16543                diagnostics,
16544            );
16545        }
16546    }
16547}
16548
16549fn record_blocks(body: &str) -> Vec<(String, Option<String>, String)> {
16550    let mut blocks = Vec::new();
16551    let lines = body.lines().collect::<Vec<_>>();
16552    let mut index = 0usize;
16553    while index < lines.len() {
16554        let trimmed = lines[index].trim();
16555        let Some((schema, from_binding)) = parse_record_start(trimmed) else {
16556            index += 1;
16557            continue;
16558        };
16559        // Single-line record `record X { f y }`: opens and closes on one line
16560        // (brace_delta 0), so the multi-line loop below never collects its fields,
16561        // leaving them unvalidated. Extract the inner content directly.
16562        if brace_delta(trimmed) == 0 && trimmed.contains('{') {
16563            if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
16564                if close > open {
16565                    blocks.push((
16566                        schema,
16567                        from_binding,
16568                        trimmed[open + 1..close].trim().to_owned(),
16569                    ));
16570                }
16571            }
16572            index += 1;
16573            continue;
16574        }
16575        let mut depth = brace_delta(trimmed);
16576        let mut record_lines = Vec::new();
16577        index += 1;
16578        while index < lines.len() && depth > 0 {
16579            let line = lines[index];
16580            let before = depth;
16581            depth += brace_delta(line);
16582            if !(before == 1 && depth == 0 && line.trim() == "}") {
16583                record_lines.push(line.to_owned());
16584            }
16585            index += 1;
16586        }
16587        blocks.push((schema, from_binding, record_lines.join("\n")));
16588    }
16589    blocks
16590}
16591
16592fn workflow_terminal_blocks(body: &str) -> Vec<(String, String, String)> {
16593    let mut blocks = Vec::new();
16594    let lines = body.lines().collect::<Vec<_>>();
16595    let mut index = 0usize;
16596    while index < lines.len() {
16597        let trimmed = lines[index].trim();
16598        let terminal = trimmed
16599            .strip_prefix("complete ")
16600            .map(|rest| ("complete", rest))
16601            .or_else(|| trimmed.strip_prefix("fail ").map(|rest| ("fail", rest)));
16602        let Some((action, rest)) = terminal else {
16603            index += 1;
16604            continue;
16605        };
16606        let Some(name) = rest.split('{').next().and_then(|header| {
16607            let mut parts = header.split_whitespace();
16608            match (parts.next(), parts.next()) {
16609                (Some(name), None) => Some(name.to_owned()),
16610                _ => None,
16611            }
16612        }) else {
16613            index += 1;
16614            continue;
16615        };
16616        let mut depth = brace_delta(trimmed);
16617        let mut terminal_lines = Vec::new();
16618        if depth == 0 && trimmed.contains('{') {
16619            // Single-line block: `complete <name> { <fields> }` opens and closes
16620            // on this line, so its inner content never reaches the multi-line loop
16621            // below. Capture the content between the braces as the block body.
16622            if let (Some(open), Some(close)) = (trimmed.find('{'), trimmed.rfind('}')) {
16623                if close > open {
16624                    let inner = trimmed[open + 1..close].trim();
16625                    if !inner.is_empty() {
16626                        terminal_lines.push(inner.to_owned());
16627                    }
16628                }
16629            }
16630            index += 1;
16631        } else {
16632            index += 1;
16633            while index < lines.len() && depth > 0 {
16634                let line = lines[index];
16635                let before = depth;
16636                depth += brace_delta(line);
16637                if !(before == 1 && depth == 0 && line.trim() == "}") {
16638                    terminal_lines.push(line.to_owned());
16639                }
16640                index += 1;
16641            }
16642        }
16643        blocks.push((action.to_owned(), name, terminal_lines.join("\n")));
16644    }
16645    blocks
16646}
16647
16648#[derive(Clone, Debug, Eq, PartialEq)]
16649enum RecordFieldAssignment {
16650    Value { field: String, value: String },
16651    Shorthand { field: String },
16652}
16653
16654fn collect_field_assignments(body: &str) -> Vec<RecordFieldAssignment> {
16655    // Token-level splitting (R5): structure comes from tokens, never line
16656    // breaks, so a single-line multi-field payload
16657    // (`complete result { first "a" second "b" }`) collects every field —
16658    // the same splitter the kernel and table rows already use.
16659    body::split_field_assignments(body)
16660        .into_iter()
16661        .map(|assignment| match assignment.value {
16662            Some(value) => RecordFieldAssignment::Value {
16663                field: assignment.name,
16664                value,
16665            },
16666            None => RecordFieldAssignment::Shorthand {
16667                field: assignment.name,
16668            },
16669        })
16670        .collect()
16671}
16672
16673fn expression_path(expr: &str) -> Option<(String, Vec<String>)> {
16674    let mut paths = dotted_paths(expr);
16675    if paths.len() != 1 {
16676        return None;
16677    }
16678    Some(paths.remove(0))
16679}
16680
16681fn validate_literal_assignment(
16682    rule: &RuleDecl,
16683    record_schema: &str,
16684    field: &str,
16685    field_ty: &TypeSyntax,
16686    expr: &str,
16687    semantic: &SemanticContext,
16688    diagnostics: &mut Vec<Diagnostic>,
16689) {
16690    let Some(literal) = parse_literal_expr(expr) else {
16691        return;
16692    };
16693
16694    match field_ty {
16695        TypeSyntax::Primitive { name, .. } => {
16696            validate_primitive_literal(rule, record_schema, field, name, &literal, diagnostics)
16697        }
16698        TypeSyntax::LiteralString { value, .. } => {
16699            if literal != LiteralExpr::String(value.as_str()) {
16700                diagnostics.push(Diagnostic {
16701                    related: Vec::new(),
16702                    span: rule.body.span,
16703                    message: format!(
16704                        "field `{record_schema}.{field}` expects literal string `{value}`"
16705                    ),
16706                    suggestion: Some(format!("record `{field} {value:?}`")),
16707                });
16708            }
16709        }
16710        TypeSyntax::Ref { name } => {
16711            validate_enum_literal(
16712                rule,
16713                record_schema,
16714                field,
16715                &name.name,
16716                &literal,
16717                semantic,
16718                diagnostics,
16719            );
16720        }
16721        TypeSyntax::Union { variants, .. } => {
16722            validate_union_literal(rule, record_schema, field, variants, &literal, diagnostics);
16723        }
16724        TypeSyntax::AgentRef { agents, .. } => {
16725            validate_agent_ref_literal(rule, record_schema, field, agents, &literal, diagnostics);
16726        }
16727        TypeSyntax::Optional { inner, .. } => {
16728            if literal != LiteralExpr::Null {
16729                validate_literal_assignment(
16730                    rule,
16731                    record_schema,
16732                    field,
16733                    inner,
16734                    expr,
16735                    semantic,
16736                    diagnostics,
16737                );
16738            }
16739        }
16740        TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
16741    }
16742}
16743
16744#[allow(clippy::too_many_arguments)]
16745fn validate_expected_assignment(
16746    rule: &RuleDecl,
16747    record_schema: &str,
16748    field: &str,
16749    field_ty: &TypeSyntax,
16750    expr: &str,
16751    semantic: &SemanticContext,
16752    binding_types: &BTreeMap<String, String>,
16753    diagnostics: &mut Vec<Diagnostic>,
16754) {
16755    if !(expr.trim_start().starts_with('{') || expr.trim_start().starts_with('[')) {
16756        return;
16757    }
16758    validate_expr_source_against_type(
16759        rule,
16760        record_schema,
16761        field,
16762        field_ty,
16763        expr,
16764        semantic,
16765        &ExprScope::from_bindings(binding_types),
16766        diagnostics,
16767    );
16768}
16769
16770#[allow(clippy::too_many_arguments)]
16771fn validate_expr_source_against_type(
16772    rule: &RuleDecl,
16773    record_schema: &str,
16774    field: &str,
16775    expected_ty: &TypeSyntax,
16776    expr: &str,
16777    semantic: &SemanticContext,
16778    scope: &ExprScope,
16779    diagnostics: &mut Vec<Diagnostic>,
16780) {
16781    match expected_ty {
16782        TypeSyntax::Map { inner, .. } => {
16783            let parsed = match parse_expression(expr) {
16784                Ok(Expr::Object(fields)) => fields,
16785                Ok(_) => {
16786                    diagnostics.push(Diagnostic {
16787                        related: Vec::new(),
16788                        span: rule.body.span,
16789                        message: format!("field `{record_schema}.{field}` expects a map literal"),
16790                        suggestion: Some(format!("record `{field} {{ key value }}`")),
16791                    });
16792                    return;
16793                }
16794                Err(message) => {
16795                    diagnostics.push(Diagnostic {
16796                        related: Vec::new(),
16797                        span: rule.body.span,
16798                        message: format!(
16799                            "field `{record_schema}.{field}` expects a map literal: {message}"
16800                        ),
16801                        suggestion: Some(format!("record `{field} {{ key value }}`")),
16802                    });
16803                    return;
16804                }
16805            };
16806            for map_field in &parsed {
16807                validate_expr_against_type(
16808                    rule,
16809                    record_schema,
16810                    field,
16811                    inner,
16812                    &map_field.value,
16813                    semantic,
16814                    scope,
16815                    diagnostics,
16816                );
16817            }
16818        }
16819        TypeSyntax::Array { inner, .. } => match parse_expression(expr) {
16820            Ok(Expr::Array(items)) => {
16821                for item in items {
16822                    validate_expr_against_type(
16823                        rule,
16824                        record_schema,
16825                        field,
16826                        inner,
16827                        &item,
16828                        semantic,
16829                        scope,
16830                        diagnostics,
16831                    );
16832                }
16833            }
16834            Ok(expr) => validate_inferred_assignment_type(
16835                rule,
16836                record_schema,
16837                field,
16838                expected_ty,
16839                &expr,
16840                semantic,
16841                scope,
16842                diagnostics,
16843            ),
16844            Err(message) => {
16845                push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
16846            }
16847        },
16848        TypeSyntax::Optional { inner, .. } => {
16849            if expr.trim() != "null" {
16850                validate_expr_source_against_type(
16851                    rule,
16852                    record_schema,
16853                    field,
16854                    inner,
16855                    expr,
16856                    semantic,
16857                    scope,
16858                    diagnostics,
16859                );
16860            }
16861        }
16862        TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
16863            let parsed = match parse_expression(expr) {
16864                Ok(Expr::Object(fields)) => fields,
16865                Ok(expr) => {
16866                    validate_inferred_assignment_type(
16867                        rule,
16868                        record_schema,
16869                        field,
16870                        expected_ty,
16871                        &expr,
16872                        semantic,
16873                        scope,
16874                        diagnostics,
16875                    );
16876                    return;
16877                }
16878                Err(message) => {
16879                    push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics);
16880                    return;
16881                }
16882            };
16883            validate_object_literal_fields(
16884                rule,
16885                record_schema,
16886                field,
16887                &name.name,
16888                &parsed,
16889                semantic,
16890                scope,
16891                diagnostics,
16892            );
16893        }
16894        _ => match parse_expression(expr) {
16895            Ok(expr) => validate_inferred_assignment_type(
16896                rule,
16897                record_schema,
16898                field,
16899                expected_ty,
16900                &expr,
16901                semantic,
16902                scope,
16903                diagnostics,
16904            ),
16905            Err(message) => {
16906                push_invalid_assignment_expr(rule, record_schema, field, message, diagnostics)
16907            }
16908        },
16909    }
16910}
16911
16912#[allow(clippy::too_many_arguments)]
16913fn validate_expr_against_type(
16914    rule: &RuleDecl,
16915    record_schema: &str,
16916    field: &str,
16917    expected_ty: &TypeSyntax,
16918    expr: &Expr,
16919    semantic: &SemanticContext,
16920    scope: &ExprScope,
16921    diagnostics: &mut Vec<Diagnostic>,
16922) {
16923    match expr {
16924        Expr::Array(items) if matches!(expected_ty, TypeSyntax::Array { .. }) => {
16925            if let TypeSyntax::Array { inner, .. } = expected_ty {
16926                for item in items {
16927                    validate_expr_against_type(
16928                        rule,
16929                        record_schema,
16930                        field,
16931                        inner,
16932                        item,
16933                        semantic,
16934                        scope,
16935                        diagnostics,
16936                    );
16937                }
16938            }
16939        }
16940        Expr::Object(fields) => match expected_ty {
16941            TypeSyntax::Map { inner, .. } => {
16942                for field in fields {
16943                    validate_expr_against_type(
16944                        rule,
16945                        record_schema,
16946                        field.key.as_str(),
16947                        inner,
16948                        &field.value,
16949                        semantic,
16950                        scope,
16951                        diagnostics,
16952                    );
16953                }
16954            }
16955            TypeSyntax::Ref { name } if semantic.schemas.class_exists(&name.name) => {
16956                validate_object_literal_fields(
16957                    rule,
16958                    record_schema,
16959                    field,
16960                    &name.name,
16961                    fields,
16962                    semantic,
16963                    scope,
16964                    diagnostics,
16965                );
16966            }
16967            _ => validate_inferred_assignment_type(
16968                rule,
16969                record_schema,
16970                field,
16971                expected_ty,
16972                expr,
16973                semantic,
16974                scope,
16975                diagnostics,
16976            ),
16977        },
16978        _ => validate_inferred_assignment_type(
16979            rule,
16980            record_schema,
16981            field,
16982            expected_ty,
16983            expr,
16984            semantic,
16985            scope,
16986            diagnostics,
16987        ),
16988    }
16989}
16990
16991fn push_invalid_assignment_expr(
16992    rule: &RuleDecl,
16993    record_schema: &str,
16994    field: &str,
16995    message: String,
16996    diagnostics: &mut Vec<Diagnostic>,
16997) {
16998    diagnostics.push(Diagnostic {
16999        related: Vec::new(),
17000        span: rule.body.span,
17001        message: format!(
17002            "rule `{}` has invalid expression for field `{record_schema}.{field}`: {message}",
17003            rule.name.name
17004        ),
17005        suggestion: Some(
17006            "use array literals or expected-schema object literals for collection fields"
17007                .to_owned(),
17008        ),
17009    });
17010}
17011
17012#[allow(clippy::too_many_arguments)]
17013fn validate_object_literal_fields(
17014    rule: &RuleDecl,
17015    record_schema: &str,
17016    field: &str,
17017    object_schema: &str,
17018    object_fields: &[ExprObjectField],
17019    semantic: &SemanticContext,
17020    scope: &ExprScope,
17021    diagnostics: &mut Vec<Diagnostic>,
17022) {
17023    let Some(schema_fields) = semantic.schemas.classes.get(object_schema) else {
17024        return;
17025    };
17026    let mut seen = BTreeSet::new();
17027    for object_field in object_fields {
17028        if !seen.insert(object_field.key.clone()) {
17029            diagnostics.push(Diagnostic {
17030                related: Vec::new(),
17031                span: rule.body.span,
17032                message: format!(
17033                    "field `{record_schema}.{field}` repeats object field `{}`",
17034                    object_field.key
17035                ),
17036                suggestion: Some("remove the duplicate object field".to_owned()),
17037            });
17038            continue;
17039        }
17040        let Some(field_ty) = schema_fields.get(&object_field.key) else {
17041            diagnostics.push(Diagnostic {
17042                related: Vec::new(),
17043                span: rule.body.span,
17044                message: format!(
17045                    "class `{object_schema}` has no field `{}`",
17046                    object_field.key
17047                ),
17048                suggestion: Some(format!(
17049                    "add `{}` to `class {object_schema}` or use an existing field",
17050                    object_field.key
17051                )),
17052            });
17053            continue;
17054        };
17055        validate_expr_against_type(
17056            rule,
17057            object_schema,
17058            &object_field.key,
17059            field_ty,
17060            &object_field.value,
17061            semantic,
17062            scope,
17063            diagnostics,
17064        );
17065    }
17066    for (required, ty) in schema_fields {
17067        if seen.contains(required) || matches!(ty, TypeSyntax::Optional { .. }) {
17068            continue;
17069        }
17070        diagnostics.push(Diagnostic { related: Vec::new(),
17071            span: rule.body.span,
17072            message: format!(
17073                "field `{record_schema}.{field}` is missing required object field `{object_schema}.{required}`"
17074            ),
17075            suggestion: Some(format!("add `{required}` to the `{field}` object literal")),
17076        });
17077    }
17078}
17079
17080#[allow(clippy::too_many_arguments)]
17081fn validate_inferred_assignment_type(
17082    rule: &RuleDecl,
17083    record_schema: &str,
17084    field: &str,
17085    expected_ty: &TypeSyntax,
17086    expr: &Expr,
17087    semantic: &SemanticContext,
17088    scope: &ExprScope,
17089    diagnostics: &mut Vec<Diagnostic>,
17090) {
17091    let literal = expr_literal_as_literal_expr(expr);
17092    if let Some(literal) = literal {
17093        validate_literal_against_type(
17094            rule,
17095            record_schema,
17096            field,
17097            expected_ty,
17098            &literal,
17099            semantic,
17100            diagnostics,
17101        );
17102        return;
17103    }
17104
17105    let context = ExprValidationContext::rule(rule);
17106    let mut local_diagnostics = Vec::new();
17107    let actual_ty = infer_expr_type(expr, semantic, scope, &context, &mut local_diagnostics);
17108    diagnostics.extend(local_diagnostics);
17109    let expected_expr_ty = expr_type_from_type_syntax(expected_ty, semantic);
17110    if !types_comparable(&actual_ty, &expected_expr_ty) {
17111        diagnostics.push(Diagnostic {
17112            related: Vec::new(),
17113            span: rule.body.span,
17114            message: format!(
17115                "field `{record_schema}.{field}` receives incompatible expression type"
17116            ),
17117            suggestion: Some(format!(
17118                "record a value compatible with `{}`",
17119                expected_ty.to_source()
17120            )),
17121        });
17122    }
17123}
17124
17125fn validate_literal_against_type(
17126    rule: &RuleDecl,
17127    record_schema: &str,
17128    field: &str,
17129    field_ty: &TypeSyntax,
17130    literal: &LiteralExpr<'_>,
17131    semantic: &SemanticContext,
17132    diagnostics: &mut Vec<Diagnostic>,
17133) {
17134    match field_ty {
17135        TypeSyntax::Primitive { name, .. } => {
17136            validate_primitive_literal(rule, record_schema, field, name, literal, diagnostics)
17137        }
17138        TypeSyntax::LiteralString { value, .. } => {
17139            if literal != &LiteralExpr::String(value.as_str()) {
17140                diagnostics.push(Diagnostic {
17141                    related: Vec::new(),
17142                    span: rule.body.span,
17143                    message: format!(
17144                        "field `{record_schema}.{field}` expects literal string `{value}`"
17145                    ),
17146                    suggestion: Some(format!("record `{field} {value:?}`")),
17147                });
17148            }
17149        }
17150        TypeSyntax::Ref { name } => {
17151            validate_enum_literal(
17152                rule,
17153                record_schema,
17154                field,
17155                &name.name,
17156                literal,
17157                semantic,
17158                diagnostics,
17159            );
17160        }
17161        TypeSyntax::Union { variants, .. } => {
17162            validate_union_literal(rule, record_schema, field, variants, literal, diagnostics);
17163        }
17164        TypeSyntax::AgentRef { agents, .. } => {
17165            validate_agent_ref_literal(rule, record_schema, field, agents, literal, diagnostics);
17166        }
17167        TypeSyntax::Optional { inner, .. } => {
17168            if literal != &LiteralExpr::Null {
17169                validate_literal_against_type(
17170                    rule,
17171                    record_schema,
17172                    field,
17173                    inner,
17174                    literal,
17175                    semantic,
17176                    diagnostics,
17177                );
17178            }
17179        }
17180        TypeSyntax::Array { .. } | TypeSyntax::Map { .. } => {}
17181    }
17182}
17183
17184fn expr_literal_as_literal_expr(expr: &Expr) -> Option<LiteralExpr<'_>> {
17185    match expr {
17186        Expr::Literal(ExprLiteral::String(value)) => Some(LiteralExpr::String(value)),
17187        Expr::Literal(ExprLiteral::Number(value)) => Some(LiteralExpr::Number(value)),
17188        Expr::Literal(ExprLiteral::Bool(_)) => Some(LiteralExpr::Bool),
17189        Expr::Literal(ExprLiteral::Null) => Some(LiteralExpr::Null),
17190        Expr::Literal(ExprLiteral::Ident(value)) => Some(LiteralExpr::Ident(value)),
17191        _ => None,
17192    }
17193}
17194
17195fn validate_agent_ref_literal(
17196    rule: &RuleDecl,
17197    record_schema: &str,
17198    field: &str,
17199    agents: &[Ident],
17200    literal: &LiteralExpr<'_>,
17201    diagnostics: &mut Vec<Diagnostic>,
17202) {
17203    let allowed = agents
17204        .iter()
17205        .map(|agent| agent.name.as_str())
17206        .collect::<Vec<_>>();
17207    if let LiteralExpr::String(value) = literal {
17208        diagnostics.push(Diagnostic {
17209            related: Vec::new(),
17210            span: rule.body.span,
17211            message: format!(
17212                "field `{record_schema}.{field}` expects an AgentRef value, not string `{value}`"
17213            ),
17214            suggestion: Some(format!(
17215                "use an unquoted declared agent name: {}",
17216                allowed.join(", ")
17217            )),
17218        });
17219        return;
17220    }
17221    let LiteralExpr::Ident(value) = literal else {
17222        diagnostics.push(Diagnostic {
17223            related: Vec::new(),
17224            span: rule.body.span,
17225            message: format!("field `{record_schema}.{field}` expects an AgentRef value"),
17226            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17227        });
17228        return;
17229    };
17230    if !allowed.contains(value) {
17231        diagnostics.push(Diagnostic {
17232            related: Vec::new(),
17233            span: rule.body.span,
17234            message: format!("field `{record_schema}.{field}` cannot reference agent `{value}`"),
17235            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17236        });
17237    }
17238}
17239
17240fn parse_literal_expr(expr: &str) -> Option<LiteralExpr<'_>> {
17241    let expr = expr.trim().trim_end_matches(',');
17242    if let Some(value) = expr
17243        .strip_prefix('"')
17244        .and_then(|rest| rest.strip_suffix('"'))
17245    {
17246        return Some(LiteralExpr::String(value));
17247    }
17248    if expr.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
17249        && expr.chars().any(|ch| ch.is_ascii_digit())
17250    {
17251        return Some(LiteralExpr::Number(expr));
17252    }
17253    match expr {
17254        "true" => Some(LiteralExpr::Bool),
17255        "false" => Some(LiteralExpr::Bool),
17256        "null" => Some(LiteralExpr::Null),
17257        value if value.chars().all(|ch| ch.is_alphanumeric() || ch == '_') => {
17258            Some(LiteralExpr::Ident(value))
17259        }
17260        _ => None,
17261    }
17262}
17263
17264struct ExprParser<'a> {
17265    source: &'a str,
17266    tokens: Vec<ExprToken>,
17267    pos: usize,
17268    depth: usize,
17269}
17270
17271/// Recursion-depth ceiling for the guard-expression grammar. Every level of
17272/// `(`/`[`/`{` nesting and every prefix `!`/`not` descends through
17273/// `parse_unary`, so bounding it there stops a deeply-nested expression in a
17274/// workflow file from overflowing the stack and aborting the process — a
17275/// normal `Err` diagnostic is returned instead. Far above any real expression.
17276const MAX_EXPR_DEPTH: usize = 256;
17277
17278#[derive(Clone, Debug, Eq, PartialEq)]
17279struct ExprToken {
17280    kind: ExprTokenKind,
17281}
17282
17283#[derive(Clone, Debug, Eq, PartialEq)]
17284enum ExprTokenKind {
17285    Ident(String),
17286    String(String),
17287    Number(String),
17288    Symbol(char),
17289    Op(&'static str),
17290}
17291
17292impl<'a> ExprParser<'a> {
17293    fn new(source: &'a str) -> Self {
17294        Self {
17295            source,
17296            tokens: lex_expr(source),
17297            pos: 0,
17298            depth: 0,
17299        }
17300    }
17301
17302    fn parse(mut self) -> Result<Expr, String> {
17303        let expr = self.parse_or()?;
17304        if self.peek().is_some() {
17305            return Err(format!(
17306                "unexpected token in expression `{}`",
17307                self.source.trim()
17308            ));
17309        }
17310        Ok(expr)
17311    }
17312
17313    fn parse_or(&mut self) -> Result<Expr, String> {
17314        let mut expr = self.parse_and()?;
17315        while self.consume_op("||") || self.consume_ident("or") {
17316            let right = self.parse_and()?;
17317            expr = Expr::Binary {
17318                op: BinaryOp::Or,
17319                left: Box::new(expr),
17320                right: Box::new(right),
17321            };
17322        }
17323        Ok(expr)
17324    }
17325
17326    fn parse_and(&mut self) -> Result<Expr, String> {
17327        let mut expr = self.parse_comparison()?;
17328        while self.consume_op("&&") || self.consume_ident("and") {
17329            let right = self.parse_comparison()?;
17330            expr = Expr::Binary {
17331                op: BinaryOp::And,
17332                left: Box::new(expr),
17333                right: Box::new(right),
17334            };
17335        }
17336        Ok(expr)
17337    }
17338
17339    fn parse_comparison(&mut self) -> Result<Expr, String> {
17340        let mut expr = self.parse_additive()?;
17341        loop {
17342            let op = if self.consume_op("==") {
17343                Some(BinaryOp::Eq)
17344            } else if self.consume_op("!=") {
17345                Some(BinaryOp::Ne)
17346            } else if self.consume_op("<=") {
17347                Some(BinaryOp::Le)
17348            } else if self.consume_op(">=") {
17349                Some(BinaryOp::Ge)
17350            } else if self.consume_symbol('<') {
17351                Some(BinaryOp::Lt)
17352            } else if self.consume_symbol('>') {
17353                Some(BinaryOp::Gt)
17354            } else if self.consume_ident("not") {
17355                if !self.consume_ident("in") {
17356                    return Err("expected `in` after `not`".to_owned());
17357                }
17358                Some(BinaryOp::NotIn)
17359            } else if self.consume_ident("in") {
17360                Some(BinaryOp::In)
17361            } else {
17362                None
17363            };
17364            let Some(op) = op else {
17365                return Ok(expr);
17366            };
17367            let right = self.parse_additive()?;
17368            expr = Expr::Binary {
17369                op,
17370                left: Box::new(expr),
17371                right: Box::new(right),
17372            };
17373        }
17374    }
17375
17376    fn parse_additive(&mut self) -> Result<Expr, String> {
17377        let mut expr = self.parse_multiplicative()?;
17378        loop {
17379            let op = if self.consume_symbol('+') {
17380                Some(BinaryOp::Add)
17381            } else if self.consume_symbol('-') {
17382                Some(BinaryOp::Sub)
17383            } else {
17384                None
17385            };
17386            let Some(op) = op else {
17387                return Ok(expr);
17388            };
17389            let right = self.parse_multiplicative()?;
17390            expr = Expr::Binary {
17391                op,
17392                left: Box::new(expr),
17393                right: Box::new(right),
17394            };
17395        }
17396    }
17397
17398    fn parse_multiplicative(&mut self) -> Result<Expr, String> {
17399        let mut expr = self.parse_unary()?;
17400        loop {
17401            let op = if self.consume_symbol('*') {
17402                Some(BinaryOp::Mul)
17403            } else if self.consume_symbol('/') {
17404                Some(BinaryOp::Div)
17405            } else {
17406                None
17407            };
17408            let Some(op) = op else {
17409                return Ok(expr);
17410            };
17411            let right = self.parse_unary()?;
17412            expr = Expr::Binary {
17413                op,
17414                left: Box::new(expr),
17415                right: Box::new(right),
17416            };
17417        }
17418    }
17419
17420    fn parse_unary(&mut self) -> Result<Expr, String> {
17421        // Depth guard (every nesting level descends through here): return a
17422        // diagnostic rather than recurse the native stack to a crash.
17423        self.depth += 1;
17424        if self.depth > MAX_EXPR_DEPTH {
17425            self.depth -= 1;
17426            return Err(format!(
17427                "expression in `{}` is nested too deeply (limit {MAX_EXPR_DEPTH})",
17428                self.source.trim()
17429            ));
17430        }
17431        let result = self.parse_unary_inner();
17432        self.depth -= 1;
17433        result
17434    }
17435
17436    fn parse_unary_inner(&mut self) -> Result<Expr, String> {
17437        if self.consume_symbol('!') {
17438            return Ok(Expr::Unary {
17439                op: UnaryOp::Not,
17440                expr: Box::new(self.parse_unary()?),
17441            });
17442        }
17443        // Prefix `not` binds looser than comparisons so `not x in y`
17444        // reads as `not (x in y)`; binary `not in` is handled by
17445        // parse_comparison before this prefix form is reached.
17446        if self.consume_ident("not") {
17447            return Ok(Expr::Unary {
17448                op: UnaryOp::Not,
17449                expr: Box::new(self.parse_comparison()?),
17450            });
17451        }
17452        self.parse_postfix()
17453    }
17454
17455    fn parse_postfix(&mut self) -> Result<Expr, String> {
17456        let mut expr = self.parse_primary()?;
17457        loop {
17458            if self.consume_symbol('[') {
17459                let key = self.parse_or()?;
17460                self.expect_symbol(']')?;
17461                expr = Expr::Index {
17462                    target: Box::new(expr),
17463                    key: Box::new(key),
17464                };
17465                continue;
17466            }
17467            return Ok(expr);
17468        }
17469    }
17470
17471    fn parse_primary(&mut self) -> Result<Expr, String> {
17472        if self.consume_symbol('(') {
17473            let expr = self.parse_or()?;
17474            self.expect_symbol(')')?;
17475            return Ok(expr);
17476        }
17477        if self.consume_symbol('[') {
17478            let mut items = Vec::new();
17479            if self.consume_symbol(']') {
17480                return Ok(Expr::Array(items));
17481            }
17482            loop {
17483                items.push(self.parse_or()?);
17484                if self.consume_symbol(']') {
17485                    break;
17486                }
17487                self.expect_symbol(',')?;
17488            }
17489            return Ok(Expr::Array(items));
17490        }
17491        if self.consume_symbol('{') {
17492            let mut fields = Vec::new();
17493            if self.consume_symbol('}') {
17494                return Ok(Expr::Object(fields));
17495            }
17496            loop {
17497                let key = match self.advance().map(|token| token.kind.clone()) {
17498                    Some(ExprTokenKind::Ident(value) | ExprTokenKind::String(value)) => value,
17499                    _ => return Err("expected object field name".to_owned()),
17500                };
17501                let value = self.parse_or()?;
17502                fields.push(ExprObjectField { key, value });
17503                if self.consume_symbol('}') {
17504                    break;
17505                }
17506                let _ = self.consume_symbol(',');
17507            }
17508            return Ok(Expr::Object(fields));
17509        }
17510        match self.advance().map(|token| token.kind.clone()) {
17511            Some(ExprTokenKind::String(value)) => Ok(Expr::Literal(ExprLiteral::String(value))),
17512            Some(ExprTokenKind::Number(value)) => Ok(Expr::Literal(ExprLiteral::Number(value))),
17513            Some(ExprTokenKind::Ident(value)) if value == "true" => {
17514                Ok(Expr::Literal(ExprLiteral::Bool(true)))
17515            }
17516            Some(ExprTokenKind::Ident(value)) if value == "false" => {
17517                Ok(Expr::Literal(ExprLiteral::Bool(false)))
17518            }
17519            Some(ExprTokenKind::Ident(value)) if value == "null" => {
17520                Ok(Expr::Literal(ExprLiteral::Null))
17521            }
17522            Some(ExprTokenKind::Ident(value)) if value == "exists" && !self.at_symbol('(') => {
17523                let arg = match self.parse_postfix()? {
17524                    Expr::Literal(ExprLiteral::Ident(path)) => Expr::Path(vec![path]),
17525                    expr => expr,
17526                };
17527                Ok(Expr::Call {
17528                    name: value,
17529                    args: vec![arg],
17530                })
17531            }
17532            Some(ExprTokenKind::Ident(value))
17533                if matches!(value.as_str(), "count" | "exists" | "empty")
17534                    && self.at_symbol('(') =>
17535            {
17536                self.expect_symbol('(')?;
17537                if let Some(query) = self.try_parse_query()? {
17538                    self.expect_symbol(')')?;
17539                    Ok(Expr::Call {
17540                        name: value,
17541                        args: vec![query],
17542                    })
17543                } else {
17544                    let mut args = Vec::new();
17545                    if self.consume_symbol(')') {
17546                        return Ok(Expr::Call { name: value, args });
17547                    }
17548                    loop {
17549                        args.push(self.parse_or()?);
17550                        if self.consume_symbol(')') {
17551                            break;
17552                        }
17553                        self.expect_symbol(',')?;
17554                    }
17555                    Ok(Expr::Call { name: value, args })
17556                }
17557            }
17558            Some(ExprTokenKind::Ident(value)) => {
17559                let mut path = vec![value];
17560                while self.consume_symbol('.') {
17561                    let Some(ExprTokenKind::Ident(field)) =
17562                        self.advance().map(|token| token.kind.clone())
17563                    else {
17564                        return Err("expected field name after `.`".to_owned());
17565                    };
17566                    path.push(field);
17567                }
17568                if path.len() == 1 {
17569                    Ok(Expr::Literal(ExprLiteral::Ident(path.remove(0))))
17570                } else {
17571                    Ok(Expr::Path(path))
17572                }
17573            }
17574            _ => Err(format!("expected expression in `{}`", self.source.trim())),
17575        }
17576    }
17577
17578    fn try_parse_query(&mut self) -> Result<Option<Expr>, String> {
17579        let checkpoint = self.pos;
17580        let kind = if self.consume_ident("effect") {
17581            QueryKind::Effect
17582        } else if matches!(
17583            self.peek().map(|token| &token.kind),
17584            Some(ExprTokenKind::Ident(value)) if value.chars().next().is_some_and(char::is_uppercase)
17585        ) {
17586            QueryKind::Fact
17587        } else {
17588            return Ok(None);
17589        };
17590        let mut head = Vec::new();
17591        while let Some(token) = self.peek() {
17592            if self.at_symbol(')') || self.at_ident("where") {
17593                break;
17594            }
17595            head.push(self.token_text(token));
17596            self.pos += 1;
17597        }
17598        if head.is_empty() {
17599            self.pos = checkpoint;
17600            return Ok(None);
17601        }
17602        let guard = if self.consume_ident("where") {
17603            Some(Box::new(self.parse_or()?))
17604        } else {
17605            None
17606        };
17607        Ok(Some(Expr::Query {
17608            kind,
17609            head: join_query_head(&head),
17610            guard,
17611        }))
17612    }
17613
17614    fn token_text(&self, token: &ExprToken) -> String {
17615        match &token.kind {
17616            ExprTokenKind::Ident(value) | ExprTokenKind::Number(value) => value.clone(),
17617            ExprTokenKind::String(value) => format!("{value:?}"),
17618            ExprTokenKind::Symbol(value) => value.to_string(),
17619            ExprTokenKind::Op(value) => value.to_string(),
17620        }
17621    }
17622
17623    fn peek(&self) -> Option<&ExprToken> {
17624        self.tokens.get(self.pos)
17625    }
17626
17627    fn advance(&mut self) -> Option<&ExprToken> {
17628        let token = self.tokens.get(self.pos)?;
17629        self.pos += 1;
17630        Some(token)
17631    }
17632
17633    fn at_symbol(&self, symbol: char) -> bool {
17634        matches!(
17635            self.peek().map(|token| &token.kind),
17636            Some(ExprTokenKind::Symbol(value)) if *value == symbol
17637        )
17638    }
17639
17640    fn consume_symbol(&mut self, symbol: char) -> bool {
17641        if self.at_symbol(symbol) {
17642            self.pos += 1;
17643            true
17644        } else {
17645            false
17646        }
17647    }
17648
17649    fn expect_symbol(&mut self, symbol: char) -> Result<(), String> {
17650        if self.consume_symbol(symbol) {
17651            Ok(())
17652        } else {
17653            Err(format!("expected `{symbol}`"))
17654        }
17655    }
17656
17657    fn at_ident(&self, ident: &str) -> bool {
17658        matches!(
17659            self.peek().map(|token| &token.kind),
17660            Some(ExprTokenKind::Ident(value)) if value == ident
17661        )
17662    }
17663
17664    fn consume_ident(&mut self, ident: &str) -> bool {
17665        if self.at_ident(ident) {
17666            self.pos += 1;
17667            true
17668        } else {
17669            false
17670        }
17671    }
17672
17673    fn consume_op(&mut self, op: &'static str) -> bool {
17674        if matches!(
17675            self.peek().map(|token| &token.kind),
17676            Some(ExprTokenKind::Op(value)) if *value == op
17677        ) {
17678            self.pos += 1;
17679            true
17680        } else {
17681            false
17682        }
17683    }
17684}
17685
17686fn join_query_head(tokens: &[String]) -> String {
17687    let mut head = String::new();
17688    for token in tokens {
17689        if token == "." {
17690            head.push('.');
17691        } else if head.ends_with('.') || head.is_empty() {
17692            head.push_str(token);
17693        } else {
17694            head.push(' ');
17695            head.push_str(token);
17696        }
17697    }
17698    head
17699}
17700
17701fn lex_expr(source: &str) -> Vec<ExprToken> {
17702    let bytes = source.as_bytes();
17703    let mut tokens = Vec::new();
17704    let mut index = 0usize;
17705    while index < bytes.len() {
17706        let byte = bytes[index];
17707        if byte.is_ascii_whitespace() {
17708            index += 1;
17709            continue;
17710        }
17711        if is_ident_start(byte) {
17712            let start = index;
17713            index += 1;
17714            while index < bytes.len() && is_ident_continue(bytes[index]) {
17715                index += 1;
17716            }
17717            tokens.push(ExprToken {
17718                kind: ExprTokenKind::Ident(source[start..index].to_owned()),
17719            });
17720            continue;
17721        }
17722        if byte.is_ascii_digit() {
17723            let start = index;
17724            index += 1;
17725            while index < bytes.len() && (bytes[index].is_ascii_digit() || bytes[index] == b'.') {
17726                index += 1;
17727            }
17728            tokens.push(ExprToken {
17729                kind: ExprTokenKind::Number(source[start..index].to_owned()),
17730            });
17731            continue;
17732        }
17733        if byte == b'"' {
17734            let start = index + 1;
17735            index += 1;
17736            while index < bytes.len() && bytes[index] != b'"' {
17737                index += 1;
17738            }
17739            let value = source[start..index.min(bytes.len())].to_owned();
17740            index = (index + 1).min(bytes.len());
17741            tokens.push(ExprToken {
17742                kind: ExprTokenKind::String(value),
17743            });
17744            continue;
17745        }
17746        let rest = &source[index..];
17747        if rest.starts_with("&&") {
17748            tokens.push(ExprToken {
17749                kind: ExprTokenKind::Op("&&"),
17750            });
17751            index += 2;
17752        } else if rest.starts_with("||") {
17753            tokens.push(ExprToken {
17754                kind: ExprTokenKind::Op("||"),
17755            });
17756            index += 2;
17757        } else if rest.starts_with("==") {
17758            tokens.push(ExprToken {
17759                kind: ExprTokenKind::Op("=="),
17760            });
17761            index += 2;
17762        } else if rest.starts_with("!=") {
17763            tokens.push(ExprToken {
17764                kind: ExprTokenKind::Op("!="),
17765            });
17766            index += 2;
17767        } else if rest.starts_with("<=") {
17768            tokens.push(ExprToken {
17769                kind: ExprTokenKind::Op("<="),
17770            });
17771            index += 2;
17772        } else if rest.starts_with(">=") {
17773            tokens.push(ExprToken {
17774                kind: ExprTokenKind::Op(">="),
17775            });
17776            index += 2;
17777        } else {
17778            tokens.push(ExprToken {
17779                kind: ExprTokenKind::Symbol(byte as char),
17780            });
17781            index += 1;
17782        }
17783    }
17784    tokens
17785}
17786
17787fn validate_primitive_literal(
17788    rule: &RuleDecl,
17789    record_schema: &str,
17790    field: &str,
17791    primitive: &str,
17792    literal: &LiteralExpr<'_>,
17793    diagnostics: &mut Vec<Diagnostic>,
17794) {
17795    // A `secret` admits no literal at all: a credential is never a value in
17796    // source (DR-0053 §5). The generic "record a compatible value" suggestion
17797    // below would be exactly the wrong advice here.
17798    if primitive == "secret" {
17799        diagnostics.push(Diagnostic {
17800            related: Vec::new(),
17801            span: rule.body.span,
17802            message: format!(
17803                "field `{record_schema}.{field}` is `secret`: secrets have no literal form"
17804            ),
17805            suggestion: Some(
17806                "reference a declared credential; material lives with the custodian, never in \
17807                 source"
17808                    .to_owned(),
17809            ),
17810        });
17811        return;
17812    }
17813    let valid = matches!(
17814        (primitive, literal),
17815        ("string", LiteralExpr::String(_))
17816            | ("string", LiteralExpr::Ident(_))
17817            | ("int", LiteralExpr::Number(_))
17818            | ("float", LiteralExpr::Number(_))
17819            | ("bool", LiteralExpr::Bool)
17820            | ("null", LiteralExpr::Null)
17821            | ("duration", LiteralExpr::String(_))
17822            | ("time", LiteralExpr::String(_))
17823    );
17824    if !valid {
17825        diagnostics.push(Diagnostic {
17826            related: Vec::new(),
17827            span: rule.body.span,
17828            message: format!("field `{record_schema}.{field}` expects `{primitive}`"),
17829            suggestion: Some(format!("record a value compatible with `{primitive}`")),
17830        });
17831        return;
17832    }
17833    match (primitive, literal) {
17834        ("duration", LiteralExpr::String(value)) if parse_duration_seconds(value).is_none() => {
17835            diagnostics.push(Diagnostic {
17836                related: Vec::new(),
17837                span: rule.body.span,
17838                message: format!("field `{record_schema}.{field}` has invalid duration literal"),
17839                suggestion: Some("use an ISO-8601 duration such as `\"PT30M\"`".to_owned()),
17840            });
17841        }
17842        ("time", LiteralExpr::String(value)) if parse_time_epoch_seconds(value).is_none() => {
17843            diagnostics.push(Diagnostic {
17844                related: Vec::new(),
17845                span: rule.body.span,
17846                message: format!("field `{record_schema}.{field}` has invalid time literal"),
17847                suggestion: Some(
17848                    "use an RFC3339 timestamp such as `\"2026-05-29T10:00:00Z\"`".to_owned(),
17849                ),
17850            });
17851        }
17852        _ => {}
17853    }
17854}
17855
17856fn validate_enum_literal(
17857    rule: &RuleDecl,
17858    record_schema: &str,
17859    field: &str,
17860    schema: &str,
17861    literal: &LiteralExpr<'_>,
17862    semantic: &SemanticContext,
17863    diagnostics: &mut Vec<Diagnostic>,
17864) {
17865    let Some(variants) = semantic.schemas.enums.get(schema) else {
17866        return;
17867    };
17868    let LiteralExpr::Ident(variant) = literal else {
17869        diagnostics.push(Diagnostic {
17870            related: Vec::new(),
17871            span: rule.body.span,
17872            message: format!("field `{record_schema}.{field}` expects enum `{schema}`"),
17873            suggestion: Some(format!(
17874                "use one of: {}",
17875                variants.iter().cloned().collect::<Vec<_>>().join(", ")
17876            )),
17877        });
17878        return;
17879    };
17880    if !variants.contains(*variant) {
17881        diagnostics.push(Diagnostic {
17882            related: Vec::new(),
17883            span: rule.body.span,
17884            message: format!("enum `{schema}` has no variant `{variant}`"),
17885            suggestion: Some(format!(
17886                "use one of: {}",
17887                variants.iter().cloned().collect::<Vec<_>>().join(", ")
17888            )),
17889        });
17890    }
17891}
17892
17893fn validate_union_literal(
17894    rule: &RuleDecl,
17895    record_schema: &str,
17896    field: &str,
17897    variants: &[TypeSyntax],
17898    literal: &LiteralExpr<'_>,
17899    diagnostics: &mut Vec<Diagnostic>,
17900) {
17901    let allowed = variants
17902        .iter()
17903        .filter_map(|variant| match variant {
17904            TypeSyntax::LiteralString { value, .. } => Some(value.as_str()),
17905            _ => None,
17906        })
17907        .collect::<Vec<_>>();
17908    if allowed.is_empty() {
17909        return;
17910    }
17911    let LiteralExpr::String(value) = literal else {
17912        diagnostics.push(Diagnostic {
17913            related: Vec::new(),
17914            span: rule.body.span,
17915            message: format!("field `{record_schema}.{field}` expects one of its literal variants"),
17916            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17917        });
17918        return;
17919    };
17920    if !allowed.contains(value) {
17921        diagnostics.push(Diagnostic {
17922            related: Vec::new(),
17923            span: rule.body.span,
17924            message: format!("field `{record_schema}.{field}` cannot be `{value}`"),
17925            suggestion: Some(format!("use one of: {}", allowed.join(", "))),
17926        });
17927    }
17928}
17929
17930fn parse_effect_line(line: &str) -> Option<(IrEffectKind, Option<String>)> {
17931    let kind = if line.starts_with("tell ") {
17932        IrEffectKind::AgentTell
17933    } else if line.starts_with("coerce ") || line.starts_with("prompt ") {
17934        IrEffectKind::SchemaCoerce
17935    } else if line.starts_with("claim ") {
17936        IrEffectKind::TrackerClaim
17937    } else if line.starts_with("call ")
17938        || line.starts_with("recall ")
17939        || line.starts_with("learn ")
17940        || line.starts_with("curate ")
17941        || line.starts_with("promote ")
17942        || line.starts_with("undo ")
17943        || line.starts_with("transport ")
17944    {
17945        IrEffectKind::CapabilityCall
17946    } else if line.starts_with("emit ") {
17947        IrEffectKind::EventEmit
17948    } else if line.starts_with("invoke ") {
17949        IrEffectKind::WorkflowInvoke
17950    } else if line.starts_with("read ") {
17951        IrEffectKind::FileRead
17952    } else if line.starts_with("write ") {
17953        IrEffectKind::FileWrite
17954    } else if line.starts_with("import ") {
17955        IrEffectKind::FileImport
17956    } else if line.starts_with("export ") {
17957        IrEffectKind::FileExport
17958    } else if line.starts_with("acquire ") {
17959        IrEffectKind::LeaseAcquire
17960    } else if line.starts_with("renew ") {
17961        IrEffectKind::LeaseRenew
17962    } else if line.starts_with("append ") {
17963        IrEffectKind::LedgerAppend
17964    } else if line.starts_with("consume ") && line.contains(" for ") {
17965        // The counter verb (`consume <counter> for <key> …`); the bare
17966        // `consume <binding>` alias was removed, and `done` never reaches
17967        // this fn as an effect line.
17968        IrEffectKind::CounterConsume
17969    } else {
17970        return None;
17971    };
17972
17973    Some((kind, binding_after_as(line)))
17974}
17975
17976fn parse_consume_line(line: &str) -> Option<String> {
17977    // `done <binding>` consumes the matched fact. The bare `consume` alias for
17978    // `done` was removed; `consume <counter> for ...` is the distinct counter
17979    // verb (multi-word, so it never satisfies the identifier check below).
17980    let binding = line
17981        .trim()
17982        .trim_end_matches(';')
17983        .strip_prefix("done ")?
17984        .split("->")
17985        .next()
17986        .unwrap_or_default()
17987        .trim();
17988    let mut chars = binding.chars();
17989    let first = chars.next()?;
17990    if !(first.is_ascii_alphabetic() || first == '_') {
17991        return None;
17992    }
17993    chars
17994        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
17995        .then(|| binding.to_owned())
17996}
17997
17998fn binding_after_multiline_string_end(line: &str) -> Option<String> {
17999    line.strip_prefix("\"\"\"")
18000        .and_then(|rest| rest.trim().strip_prefix("as "))
18001        .and_then(|rest| rest.split_whitespace().next())
18002        .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18003        .filter(|binding| !binding.is_empty())
18004        .map(str::to_owned)
18005}
18006
18007fn validate_rule_prompt_content_type_annotation(
18008    rule: &RuleDecl,
18009    line: &str,
18010    diagnostics: &mut Vec<Diagnostic>,
18011) {
18012    if !(line.starts_with("tell ") || line.starts_with("coerce ")) {
18013        return;
18014    }
18015    let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18016        return;
18017    };
18018    diagnostics.push(Diagnostic {
18019        related: Vec::new(),
18020        span: rule.body.span,
18021        message: format!(
18022            "rule `{}` has malformed multiline prompt content type `{annotation}`",
18023            rule.name.name
18024        ),
18025        suggestion: Some(
18026            "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18027                .to_owned(),
18028        ),
18029    });
18030}
18031
18032fn validate_coerce_prompt_content_type_annotations(
18033    coerce: &CoerceDecl,
18034    diagnostics: &mut Vec<Diagnostic>,
18035) {
18036    for line in coerce.body.text.lines().map(str::trim) {
18037        if !line.starts_with("prompt ") {
18038            continue;
18039        }
18040        let Some(annotation) = malformed_prompt_content_type_annotation(line) else {
18041            continue;
18042        };
18043        diagnostics.push(Diagnostic { related: Vec::new(),
18044            span: coerce.body.span,
18045            message: format!(
18046                "coerce `{}` has malformed multiline prompt content type `{annotation}`",
18047                coerce.name.name
18048            ),
18049            suggestion: Some(
18050                "write a supported token such as `\"\"\"markdown` or put prompt text on the next line"
18051                    .to_owned(),
18052            ),
18053        });
18054    }
18055}
18056
18057fn malformed_prompt_content_type_annotation(line: &str) -> Option<String> {
18058    let (_, tail) = line.split_once("\"\"\"")?;
18059    let candidate = tail.trim();
18060    if candidate.is_empty() || candidate.contains("\"\"\"") {
18061        return None;
18062    }
18063    let mut parts = candidate.split_whitespace();
18064    let first = parts.next()?;
18065    let has_extra_text = parts.next().is_some();
18066    let first_is_supported = is_supported_prompt_content_type(first);
18067    let first_is_annotation_shaped = first_is_supported || first.contains('/');
18068    if has_extra_text && first_is_annotation_shaped {
18069        return Some(candidate.to_owned());
18070    }
18071    if first.contains('/') && !first_is_supported {
18072        return Some(first.to_owned());
18073    }
18074    None
18075}
18076
18077fn is_supported_prompt_content_type(candidate: &str) -> bool {
18078    if !is_prompt_content_type_token(candidate) {
18079        return false;
18080    }
18081    let normalized = candidate.to_ascii_lowercase();
18082    normalized.contains('/')
18083        || matches!(
18084            normalized.as_str(),
18085            "markdown" | "json" | "text" | "plain" | "html" | "xml" | "yaml" | "yml"
18086        )
18087}
18088
18089fn is_prompt_content_type_token(candidate: &str) -> bool {
18090    let mut chars = candidate.chars();
18091    let Some(first) = chars.next() else {
18092        return false;
18093    };
18094    first.is_ascii_alphanumeric()
18095        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '+' | '-' | '_'))
18096}
18097
18098pub(crate) fn binding_after_as(line: &str) -> Option<String> {
18099    let mut tokens = line.split_whitespace();
18100    while let Some(token) = tokens.next() {
18101        if token == "as" {
18102            return tokens
18103                .next()
18104                .map(|binding| binding.trim_matches(|ch: char| !ch.is_alphanumeric() && ch != '_'))
18105                .filter(|binding| !binding.is_empty())
18106                .map(str::to_owned);
18107        }
18108    }
18109    None
18110}
18111
18112fn parse_after_line(line: &str) -> Option<(String, DependencyPredicate)> {
18113    let rest = line.strip_prefix("after ")?;
18114    if rest.contains("=>") {
18115        return None;
18116    }
18117    let before_body = rest.split('{').next().unwrap_or(rest).trim();
18118    let mut parts = before_body.split_whitespace();
18119    let binding = parts.next()?.to_owned();
18120    let predicate = match parts.next()? {
18121        "succeeds" => DependencyPredicate::Succeeds,
18122        "fails" => DependencyPredicate::Fails,
18123        // `times out` / `cancelled` react only to that specific non-success
18124        // terminal status (spec/expression-kernel.md), mirroring succeeds/fails.
18125        "cancelled" => DependencyPredicate::Cancelled,
18126        "times" => {
18127            if parts.next()? != "out" {
18128                return None;
18129            }
18130            DependencyPredicate::TimedOut
18131        }
18132        // Coordination outcomes (spec/coordination.md) are completion-valued;
18133        // the arm dispatch happens on the outcome variant at lowering.
18134        "completes" | "held" | "contended" | "ok" | "over" | "promoted" | "conflicted"
18135        | "applied" | "stranded" => DependencyPredicate::Completes,
18136        // `after p reaches "<name>" [as m]` (Family C): consume the quoted
18137        // milestone name (which may contain whitespace — token-splitting used
18138        // to reject multi-word names); the IR predicate is completion-shaped
18139        // (runtime gating keys on the milestone-specific `reached` fact).
18140        "reaches" => {
18141            let rest = before_body.trim().strip_prefix(&binding)?.trim_start();
18142            let after_kw = rest.strip_prefix("reaches")?.trim_start();
18143            let quoted = after_kw.strip_prefix('"')?;
18144            let close = quoted.find('"')?;
18145            let tail = &quoted[close + 1..];
18146            let mut tail_parts = tail.split_whitespace();
18147            match (tail_parts.next(), tail_parts.next(), tail_parts.next()) {
18148                (None, None, None) => {}
18149                (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18150                _ => return None,
18151            }
18152            return Some((binding, DependencyPredicate::Completes));
18153        }
18154        _ => return None,
18155    };
18156    match (parts.next(), parts.next(), parts.next()) {
18157        (None, None, None) => {}
18158        (Some("as"), Some(alias), None) if is_identifier(alias) => {}
18159        _ => return None,
18160    }
18161    Some((binding, predicate))
18162}
18163
18164pub(crate) fn is_identifier(value: &str) -> bool {
18165    let mut chars = value.chars();
18166    let Some(first) = chars.next() else {
18167        return false;
18168    };
18169    (first.is_ascii_alphabetic() || first == '_')
18170        && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
18171}
18172
18173pub(crate) fn push_line(snapshot: &mut String, line: impl AsRef<str>) {
18174    snapshot.push_str(line.as_ref());
18175    snapshot.push('\n');
18176}
18177
18178pub(crate) fn stable_hash(value: &str) -> String {
18179    // SHA-256 truncated to 128 bits (the FNV-collision hardening swap):
18180    // source_hash/ir_hash are program-version identity — colliding them
18181    // aliases two program revisions. Report-schema digest patterns and the
18182    // Python validator mirrors must stay in lockstep with this width.
18183    use sha2::Digest;
18184    let digest = sha2::Sha256::digest(value.as_bytes());
18185    let mut hex = String::with_capacity(32);
18186    for byte in &digest[..16] {
18187        hex.push_str(&format!("{byte:02x}"));
18188    }
18189    hex
18190}
18191
18192pub fn parse_duration_seconds(value: &str) -> Option<f64> {
18193    let value = value.strip_prefix('P')?;
18194    let mut rest = value;
18195    let mut seconds = 0.0;
18196    let mut consumed = false;
18197    let mut in_time = false;
18198
18199    while !rest.is_empty() {
18200        if let Some(next) = rest.strip_prefix('T') {
18201            if in_time {
18202                return None;
18203            }
18204            in_time = true;
18205            rest = next;
18206            continue;
18207        }
18208
18209        let number_len = rest
18210            .char_indices()
18211            .take_while(|(_, ch)| ch.is_ascii_digit() || *ch == '.')
18212            .map(|(index, ch)| index + ch.len_utf8())
18213            .last()?;
18214        let number = rest[..number_len].parse::<f64>().ok()?;
18215        if !number.is_finite() {
18216            return None;
18217        }
18218        let unit = rest[number_len..].chars().next()?;
18219        rest = &rest[number_len + unit.len_utf8()..];
18220        let multiplier = match (in_time, unit) {
18221            (false, 'D') => 86_400.0,
18222            (true, 'H') => 3_600.0,
18223            (true, 'M') => 60.0,
18224            (true, 'S') => 1.0,
18225            _ => return None,
18226        };
18227        seconds += number * multiplier;
18228        consumed = true;
18229    }
18230
18231    consumed.then_some(seconds)
18232}
18233
18234pub fn parse_time_epoch_seconds(value: &str) -> Option<f64> {
18235    if value.len() < 20 {
18236        return None;
18237    }
18238    let year = parse_fixed_i32(value, 0, 4)?;
18239    require_byte(value, 4, b'-')?;
18240    let month = parse_fixed_u32(value, 5, 2)?;
18241    require_byte(value, 7, b'-')?;
18242    let day = parse_fixed_u32(value, 8, 2)?;
18243    require_byte(value, 10, b'T')?;
18244    let hour = parse_fixed_u32(value, 11, 2)?;
18245    require_byte(value, 13, b':')?;
18246    let minute = parse_fixed_u32(value, 14, 2)?;
18247    require_byte(value, 16, b':')?;
18248    let second = parse_fixed_u32(value, 17, 2)?;
18249    let mut offset_start = 19;
18250    let mut fractional_second = 0.0;
18251    if value.as_bytes().get(offset_start).copied() == Some(b'.') {
18252        let fraction_start = offset_start + 1;
18253        let fraction_len = value[fraction_start..]
18254            .char_indices()
18255            .take_while(|(_, ch)| ch.is_ascii_digit())
18256            .map(|(index, ch)| index + ch.len_utf8())
18257            .last()?;
18258        let fraction = &value[fraction_start..fraction_start + fraction_len];
18259        let scale = 10_f64.powi(i32::try_from(fraction.len()).ok()?);
18260        fractional_second = fraction.parse::<f64>().ok()? / scale;
18261        offset_start = fraction_start + fraction_len;
18262    }
18263    if !(1..=12).contains(&month)
18264        || !(1..=days_in_month(year, month)).contains(&day)
18265        || hour > 23
18266        || minute > 59
18267        || second > 60
18268    {
18269        return None;
18270    }
18271
18272    let offset_seconds = match value.as_bytes().get(offset_start).copied()? {
18273        b'Z' if value.len() == offset_start + 1 => 0,
18274        b'+' | b'-' if value.len() == offset_start + 6 => {
18275            let sign = if value.as_bytes()[offset_start] == b'+' {
18276                1
18277            } else {
18278                -1
18279            };
18280            let offset_hour = parse_fixed_i32(value, offset_start + 1, 2)?;
18281            require_byte(value, offset_start + 3, b':')?;
18282            let offset_minute = parse_fixed_i32(value, offset_start + 4, 2)?;
18283            if offset_hour > 23 || offset_minute > 59 {
18284                return None;
18285            }
18286            sign * (offset_hour * 3_600 + offset_minute * 60)
18287        }
18288        _ => return None,
18289    };
18290
18291    let days = days_from_civil(year, month, day);
18292    let local_seconds = days * 86_400 + i64::from(hour * 3_600 + minute * 60 + second.min(59));
18293    Some((local_seconds - i64::from(offset_seconds)) as f64 + fractional_second)
18294}
18295
18296fn parse_fixed_i32(value: &str, start: usize, len: usize) -> Option<i32> {
18297    value.get(start..start + len)?.parse::<i32>().ok()
18298}
18299
18300fn parse_fixed_u32(value: &str, start: usize, len: usize) -> Option<u32> {
18301    value.get(start..start + len)?.parse::<u32>().ok()
18302}
18303
18304fn require_byte(value: &str, index: usize, expected: u8) -> Option<()> {
18305    (value.as_bytes().get(index).copied()? == expected).then_some(())
18306}
18307
18308fn days_in_month(year: i32, month: u32) -> u32 {
18309    match month {
18310        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
18311        4 | 6 | 9 | 11 => 30,
18312        2 if is_leap_year(year) => 29,
18313        2 => 28,
18314        _ => 0,
18315    }
18316}
18317
18318fn is_leap_year(year: i32) -> bool {
18319    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
18320}
18321
18322fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
18323    let year = year - i32::from(month <= 2);
18324    let era = if year >= 0 { year } else { year - 399 } / 400;
18325    let year_of_era = year - era * 400;
18326    let month = month as i32;
18327    let day = day as i32;
18328    let day_of_year = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
18329    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
18330    i64::from(era * 146_097 + day_of_era - 719_468)
18331}
18332
18333/// Flat-prepend body emitter used where the output feeds IR construction (e.g.
18334/// a table's synthetic rule body, whose `body_hash` is part of program identity).
18335/// Kept byte-for-byte stable so the lowered IR / snapshots do not move; the
18336/// idempotent re-indenter for human formatting is [`format_block_body`].
18337fn push_block_body(body: &str, formatted: &mut String) {
18338    if body.is_empty() {
18339        return;
18340    }
18341    for line in body.lines() {
18342        if line.trim().is_empty() {
18343            formatted.push('\n');
18344        } else {
18345            push_line(formatted, format!("  {}", line.trim_end()));
18346        }
18347    }
18348}
18349
18350/// Net bracket-depth change for one line, ignoring brackets inside strings.
18351/// Returns `(delta, opens_unclosed_triple)`: a `true` second element means the
18352/// line starts a `"""..."""` that does not close on the same line, so following
18353/// lines are string content. ASCII markers only — UTF-8 string bytes can't
18354/// false-match.
18355fn scan_braces(line: &str) -> (i32, bool) {
18356    let bytes = line.as_bytes();
18357    let mut index = 0;
18358    let mut delta = 0i32;
18359    let mut in_string = false;
18360    while index < bytes.len() {
18361        if in_string {
18362            match bytes[index] {
18363                b'\\' => index += 1,
18364                b'"' => in_string = false,
18365                _ => {}
18366            }
18367            index += 1;
18368            continue;
18369        }
18370        if line[index..].starts_with("\"\"\"") {
18371            match line[index + 3..].find("\"\"\"") {
18372                Some(offset) => index += 3 + offset + 3,
18373                None => return (delta, true),
18374            }
18375            continue;
18376        }
18377        match bytes[index] {
18378            b'"' => in_string = true,
18379            b'{' | b'[' | b'(' => delta += 1,
18380            b'}' | b']' | b')' => delta -= 1,
18381            _ => {}
18382        }
18383        index += 1;
18384    }
18385    (delta, false)
18386}
18387
18388impl TypeSyntax {
18389    fn to_source(&self) -> String {
18390        match self {
18391            Self::Primitive { name, .. } => name.clone(),
18392            Self::LiteralString { value, .. } => format!("{value:?}"),
18393            Self::Ref { name } => name.name.clone(),
18394            Self::AgentRef { agents, .. } => {
18395                let agents = agents
18396                    .iter()
18397                    .map(|agent| agent.name.as_str())
18398                    .collect::<Vec<_>>()
18399                    .join(" | ");
18400                format!("AgentRef<{agents}>")
18401            }
18402            Self::Optional { inner, .. } => format!("{}?", inner.to_source()),
18403            Self::Array { inner, .. } => format!("{}[]", inner.to_source()),
18404            Self::Map { inner, .. } => format!("map<{}>", inner.to_source()),
18405            Self::Union { variants, .. } => variants
18406                .iter()
18407                .map(Self::to_source)
18408                .collect::<Vec<_>>()
18409                .join(" | "),
18410        }
18411    }
18412}
18413
18414#[cfg(test)]
18415#[path = "lib_tests/tests.rs"]
18416mod tests;