Skip to main content

bynk_lower/
lib.rs

1//! P6.1 (#1141): the `&CheckedProgram → Ir` lowering pass — real construction
2//! for the node kinds `design/tracks/the-ir.md`'s own P6.1 row names (`Const`,
3//! `Local`, `Global`, `Record`, `Field`, `List`, `Block`, `If`, `And`, `Or`,
4//! `Not`, `Return`, `Await`, `Send`, `Pure`); every other [`IrExprKind`]/
5//! [`IrStmt`] arm is a `todo!()` naming the slice that completes it
6//! (Decision D).
7//!
8//! **Correction (Arc D, P7.12): the claim this paragraph made through the
9//! crate carve — "nothing in this module is called from anywhere in
10//! `bynk-emit`'s existing emission path... it has no consumer yet" — was
11//! false and is corrected here rather than left standing.** `lower_service_
12//! item_ir` unconditionally lowers every handler's own *body*, and is
13//! reached for real from `bynk_check`-typed `Events`-protocol services via
14//! `lower_event_subscriber_shapes_ir` (`bynk-emit/src/project.rs`), reaching
15//! `lower_service_handler_ir` → `lower_service_handler_body_ir` →
16//! `lower_block_ir` → `lower_expr_ir`/`lower_stmt_ir` and the rest of this
17//! module's own recursive expression-lowering machinery — real, live,
18//! production code, verified panic-free across the whole e2e fixture corpus
19//! by a `catch_unwind` safety probe (see `lower_service_item_ir`'s own doc
20//! comment). A handful of top-level item constructors genuinely have no
21//! caller outside this crate's own test suite (`lower_agent_item_ir`,
22//! `lower_provider_item_ir`, `lower_fn_item_ir`, and the handler/store-field/
23//! invariant/transition helpers only those three call) — each of those is
24//! real test-harness infrastructure for the shared lowering machinery above,
25//! not dead code, per this crate carve's own accepted proposal issue.
26//!
27//! **Totality discipline (ADR 0334, Q2):** every entry point here takes a
28//! `&CheckedProgram`, not a bare `&TypedCommons` — a certified program only,
29//! so `LowerIrCtx::expr_ty`'s `.expect()` on a miss is the checker and this
30//! pass disagreeing about which expressions a unit contains, a compiler bug,
31//! not a recoverable state. This scoping is the same discipline
32//! `bynk-emit/src/emitter/emit.rs`'s `lower_workers_cross_context_call`
33//! already applies to its own `bynk.emit.unresolved_cross_context_signature`
34//! panic.
35
36use std::collections::{HashMap, HashSet};
37use std::sync::Arc;
38
39use bynk_check::checker::{self, Callee, CheckedProgram, NamedKind, Ty, TyId, TypedCommons, Types};
40use bynk_check::resolver::MethodTable;
41use bynk_syntax::ast::{
42    ActorDecl, AgentDecl, BaseType, BinOp, Block, CapRef, CapabilityDecl, CapabilityOp,
43    CommonsItem, EventPattern, EventPatternValue, Expr, ExprId, ExprKind, FieldInit, FnDecl,
44    FnName, Handler, HandlerKind, HttpMethod, InterpPart, Invariant, LambdaExpr, LiteralValue,
45    MatchArm, MatchBody, Pattern, PatternBindingKind, ProviderDecl, ProviderOp, QualifiedName,
46    ServiceDecl, ServiceProtocol, Statement, StoreField, Transition, TypeBody, TypeDecl, TypeRef,
47    UnaryOp, expr_children,
48};
49use bynk_syntax::span::Span;
50
51use bynk_ir::{
52    ActorBinder, ActorSeamIr, BindingMode, CacheIr, CapRefIr, CommitShape, ConnectionBinder,
53    ConstVal, CorsIr, EventPatternIr, EventPatternValueIr, EventSubscriberShape, Exhaustive, FnSig,
54    GlobalRef, IndexIr, IrArm, IrBinOp, IrExpr, IrExprKind, IrHandler, IrHandlerKind, IrHttpMethod,
55    IrInterpPart, IrItem, IrPat, IrPredicate, IrStmt, MUTATING_CELL_OPS, MUTATING_LOG_OPS,
56    MUTATING_MAP_CACHE_OPS, MUTATING_SET_OPS, MatchForm, OpSig, PolicyIr, ProtocolIr, ProviderBody,
57    ProviderOpIr, SecurityIr, StoreFieldIr, StoreKindIr, TypeShape, block_uses_emit,
58    match_needs_if_chain,
59};
60
61/// The lowering pass's own working state: the certified program's typed
62/// output (for `.ty`/`.types` lookups), a lexical scope stack this pass
63/// tracks itself — `TypedCommons` has no persisted "what type does this bound
64/// name have" table (that lived only in the checker's own transient `Ctx`),
65/// so the one case that needs it (a record shorthand field, `{ x }`, which
66/// has no `ExprId` of its own to key `expr_types` by) re-derives it from the
67/// same param/`let` binding sites the checker itself walked — and the
68/// enclosing fn/method's own rigid type variables (`fn identity[T](x: T)`,
69/// and a generic type's own params on one of its methods), needed by
70/// `resolve_type_ref_in` the same way `Ctx::type_vars` is
71/// (`bynk-check/src/checker.rs:2816`); `resolve_type_ref` (no `vars` set)
72/// would otherwise resolve a rigid `T` as an unknown declared type and
73/// silently fail.
74pub struct LowerIrCtx<'a> {
75    program: &'a TypedCommons,
76    scopes: Vec<HashMap<String, TyId>>,
77    type_vars: HashSet<String>,
78    /// Bumped once per synthetic temp name this pass mints — originally
79    /// [`lower_record_spread_ir`]-only (`__spread_base_{n}`: a fixed name is
80    /// unique against every *source-level* name, `__` is unlexable, but not
81    /// against another spread on the same nesting chain), generalised by
82    /// P6.15/P6.16 (review of #1238) into the one shared counter every
83    /// synthetic-temp minter uses via [`LowerIrCtx::fresh_tmp`] — `?`'s own
84    /// receiver/error temps and `is`'s own forced-receiver temp included.
85    /// A per-purpose counter (the shape this field started as) only
86    /// guarantees uniqueness *within* one kind of temp; two different kinds
87    /// nested inside each other (a spread inside a `?`'s operand, two `?`s
88    /// in one function) need the same global monotonic guarantee a
89    /// hoisting printer's own flat `const` scope requires.
90    tmp_counter: usize,
91    /// P6.15: the enclosing fn/handler/provider-op's own declared return
92    /// type, resolved by whichever of the four real body-lowering entry
93    /// points (`lower_fn_body_ir`, `lower_handler_body_ir`,
94    /// `lower_service_handler_body_ir`, `lower_provider_op_ir`) constructed
95    /// this `cx`, via [`LowerIrCtx::set_return_ty`] — `None` for every other
96    /// construction site (a signature-only reader, which never lowers an
97    /// expression body and so never reaches `ExprKind::Question`), **and**
98    /// `None` on a genuine resolve miss (review of #1238) — mirroring
99    /// `lower_service_handler_signature_ir`'s own `.unwrap_or_else(|| cx.
100    /// unit_ty())` degrade one call away: a service handler's own
101    /// `return_type` is not checker-guaranteed to resolve (that function's
102    /// own doc comment gives the full grounding), so panicking here would
103    /// turn an existing, accepted, harmless-until-now gap into an eager
104    /// crash on every fn/handler/provider-op body this pass ever lowers,
105    /// including the overwhelming majority that never read `return_ty` at
106    /// all. Every real consumer already handles `None` — mirrors
107    /// `bynk-emit/src/emitter/lower.rs`'s own `LowerCtx::return_ty`, the
108    /// value [`embed_conversion_ir`] needs for the identical reason that
109    /// function's string-emitter sibling, `embed_conversion`, needs
110    /// `cx.return_ty` — deciding whether a `Result[T,E]?`'s error type
111    /// matches the enclosing function's own declared error type, or needs a
112    /// declared `embeds` conversion first.
113    return_ty: Option<TyId>,
114    /// P6.20-pre: the enclosing agent handler's own `store Map`/`store Log`
115    /// field names — the ones `lower_handler_body_ir` deliberately does
116    /// *not* bind into `scopes` (only `Cell` fields are, v0.81's "implicit
117    /// deref" rule) because they are not ordinary locals: a bare reference
118    /// lowers to [`bynk_ir::IrExprKind::StoreQuery`], not
119    /// [`bynk_ir::IrExprKind::Local`]. Set once via
120    /// [`LowerIrCtx::set_store_queryable`], empty (and never consulted) for
121    /// every other construction site — mirrors [`LowerIrCtx::return_ty`]'s
122    /// own "`None`/empty everywhere but the one real body-lowering entry
123    /// point that needs it" shape.
124    store_queryable: HashSet<String>,
125}
126
127impl<'a> LowerIrCtx<'a> {
128    fn new(program: &'a CheckedProgram, type_vars: HashSet<String>) -> Self {
129        Self::from_commons(program.program(), type_vars)
130    }
131
132    /// #1187's own closing scoping pass: a `TypedCommons`-only constructor,
133    /// for the one real call path (`lower_op_sig_ir_from_commons`, below)
134    /// that never has a `&CheckedProgram` to unwrap. See that function's
135    /// own doc comment for why.
136    fn from_commons(commons: &'a TypedCommons, type_vars: HashSet<String>) -> Self {
137        Self {
138            program: commons,
139            scopes: vec![HashMap::new()],
140            type_vars,
141            tmp_counter: 0,
142            return_ty: None,
143            store_queryable: HashSet::new(),
144        }
145    }
146
147    /// P6.15: called once, by a real body-lowering entry point only, right
148    /// after resolving its own `return_type` — see [`LowerIrCtx::return_ty`]'s
149    /// own doc comment. Takes the resolve `Option` directly (review of
150    /// #1238) rather than a caller-side `.unwrap_or_else(|| panic!(..))` —
151    /// see that field's own doc comment for why a miss must degrade, not
152    /// crash.
153    fn set_return_ty(&mut self, ty: Option<TyId>) {
154        self.return_ty = ty;
155    }
156
157    /// P6.20-pre: called once, by `lower_handler_body_ir` only, right
158    /// after seeding `self`/`store_cells` — see
159    /// [`LowerIrCtx::store_queryable`]'s own doc comment for why this is a
160    /// separate table from `scopes` rather than an ordinary [`Self::bind`].
161    fn set_store_queryable(&mut self, names: HashSet<String>) {
162        self.store_queryable = names;
163    }
164
165    /// P6.15/P6.16 (review of #1238): the one shared synthetic-temp minter
166    /// every caller uses — see [`LowerIrCtx::tmp_counter`]'s own doc comment
167    /// for why a shared counter, not one per purpose, is load-bearing here.
168    fn fresh_tmp(&mut self, prefix: &str) -> String {
169        let n = self.tmp_counter;
170        self.tmp_counter += 1;
171        format!("{prefix}_{n}")
172    }
173
174    fn fresh_spread_tmp(&mut self) -> String {
175        self.fresh_tmp("__spread_base")
176    }
177
178    /// A type reference resolved in this pass's own rigid-variable scope —
179    /// the `resolve_type_ref_in` counterpart to `Ctx::resolve_type_ref_in`
180    /// call sites (e.g. `checker.rs:2816,2897`), not the bare
181    /// `resolve_type_ref` (which has no `vars` set and cannot resolve a
182    /// generic fn/method's own type parameters).
183    fn resolve_type_ref(&self, r: &bynk_syntax::ast::TypeRef) -> Option<TyId> {
184        checker::resolve_type_ref_in(
185            r,
186            &self.program.types,
187            &self.type_vars,
188            &self.program.ty_intern,
189        )
190    }
191
192    fn push_scope(&mut self) {
193        self.scopes.push(HashMap::new());
194    }
195
196    fn pop_scope(&mut self) {
197        self.scopes.pop();
198    }
199
200    fn bind(&mut self, name: String, ty: TyId) {
201        self.scopes
202            .last_mut()
203            .expect("bynk internal error: LowerIrCtx's scope stack is never empty")
204            .insert(name, ty);
205    }
206
207    fn lookup(&self, name: &str) -> Option<TyId> {
208        self.scopes.iter().rev().find_map(|s| s.get(name).copied())
209    }
210
211    /// R6.1 / ADR 0334: the checker already resolved every real expression's
212    /// type before this pass runs — a miss here means this pass and the
213    /// checker disagree about which expressions the unit contains, which is
214    /// a compiler bug on the certified-program path this pass is scoped to,
215    /// not a fallback-shaped state.
216    fn expr_ty(&self, id: ExprId) -> TyId {
217        self.program
218            .expr_types
219            .get(&id)
220            .unwrap_or_else(|| {
221                panic!(
222                    "bynk internal error (ADR 0334): no recorded type for {id:?} — \
223                     bynk_lower and bynk-check disagree about which \
224                     expressions this certified unit contains"
225                )
226            })
227            .ty
228    }
229
230    /// `Effect[T] -> T` — the type an `Await`/`EffectLet`/`Do` binds, not the
231    /// effect value's own recorded type. Falls back to `ty` unchanged on a
232    /// non-`Effect` input rather than panicking: every real call site here
233    /// only peels a type the checker already required to be `Effect[_]`
234    /// (`<-`/`do`/`~>`'s own gate, `bynk-check/src/checker.rs`'s
235    /// `Ctx::effectful` sites), so this is defensive, not load-bearing.
236    fn peel_effect(&self, ty: TyId) -> TyId {
237        match &*self.program.ty_intern.get(ty) {
238            Ty::Effect(inner) => *inner,
239            _ => ty,
240        }
241    }
242
243    fn unit_ty(&self) -> TyId {
244        self.program.ty_intern.intern(Ty::Unit)
245    }
246
247    /// P6.2 (#1143): the `Callee` `bynk-check` recorded for a call-shaped
248    /// expression (P6.0), if any. Deliberately `Option`, not `.expect()`-ed
249    /// like `expr_ty` — a certified program is only guaranteed a `Callee`
250    /// for shapes `calls.rs`'s six functions or the store-field ladder
251    /// dispatch (P6.0/P6.2's own "Done when" scope); a handful of shapes
252    /// this slice's own Decision C left out on purpose (`HttpResult`/
253    /// `QueueResult` bare-variant construction, `Events.emit`, the
254    /// production `is_system_http_service` address) reach here with none —
255    /// an expected miss, not a bug, so the caller decides what to do with
256    /// `None` rather than this accessor panicking on a state that is
257    /// sometimes legitimate.
258    fn callee(&self, id: ExprId) -> Option<&Callee> {
259        self.program.callees.get(&id)
260    }
261}
262
263/// A fn/method's own rigid type variables — its own `[T, ...]` type
264/// parameters, plus a generic receiver's, for a method — the `vars` set
265/// `resolve_type_ref_in` needs to resolve a rigid `T` as `Ty::Var` rather
266/// than an unknown declared type. Shared by [`lower_fn_body_ir`] and
267/// [`lower_fn_item_ir`] so the two can never independently drift on which
268/// names are rigid for the same `f`: both functions' own `ADR 0334` panic
269/// text warns specifically against `bynk_lower`'s `type_vars`
270/// disagreeing with `bynk-check`'s `Ctx::type_vars`, and a hand-duplicated
271/// copy of this exact computation would be one more place that guarantee
272/// could quietly stop holding.
273fn fn_rigid_type_vars(f: &FnDecl, program: &TypedCommons) -> HashSet<String> {
274    let mut type_vars: HashSet<String> = f
275        .type_params
276        .iter()
277        .map(|tp| tp.name.name.clone())
278        .collect();
279    if let FnName::Method { type_name, .. } = &f.name
280        && let Some(decl) = program.types.get(&type_name.name)
281    {
282        type_vars.extend(decl.type_params.iter().map(|tp| tp.name.name.clone()));
283    }
284    type_vars
285}
286
287/// A method's own generic receiver type — `Box[A]`'s `self` is
288/// `Ty::Named { name: "Box", args: [Ty::Var("A")], .. }`, not a fixed
289/// instantiation like `Box[Int]` — or `None` for a free function or a
290/// method declared with no `self` parameter. Shared by
291/// [`lower_fn_body_ir`] (which binds the result into scope under
292/// `"self"`) and [`lower_fn_item_ir`] (which records it verbatim as
293/// `IrItem::Fn::receiver`) — the same value, computed once.
294fn fn_receiver_ty(f: &FnDecl, program: &TypedCommons) -> Option<TyId> {
295    let FnName::Method { type_name, .. } = &f.name else {
296        return None;
297    };
298    if !f.has_self {
299        return None;
300    }
301    let decl = program.types.get(&type_name.name)?;
302    let self_args = decl
303        .type_params
304        .iter()
305        .map(|tp| program.ty_intern.intern(Ty::Var(tp.name.name.clone())))
306        .collect();
307    Some(checker::named_ty_with_args(
308        decl,
309        self_args,
310        &program.ty_intern,
311    ))
312}
313
314/// Wrap a lowered body block's own tail in [`IrExprKind::Return`] — the one
315/// place this pass builds a `Return` node (Bynk has no `return` keyword; see
316/// that variant's own doc comment). Shared by [`lower_fn_body_ir`] and
317/// `lower_handler_body_ir` (P6.9, #1167) — both are body-lowering entry
318/// points that produce a `Block` and need the exact same tail-wrapping, and
319/// hand-duplicating it a second time is exactly the kind of re-derivation
320/// this module's own header doc (`Callee`, P6.0) already avoids elsewhere.
321fn wrap_body_return(block: IrExpr) -> IrExpr {
322    let IrExpr {
323        kind: IrExprKind::Block { stmts, tail },
324        ty,
325        span,
326    } = block
327    else {
328        unreachable!("lower_block_ir always returns IrExprKind::Block");
329    };
330    let tail_ty = tail.ty;
331    let tail_span = tail.span;
332    IrExpr {
333        kind: IrExprKind::Block {
334            stmts,
335            tail: Box::new(IrExpr {
336                kind: IrExprKind::Return { value: tail },
337                ty: tail_ty,
338                span: tail_span,
339            }),
340        },
341        ty,
342        span,
343    }
344}
345
346/// Lower a function/method body: seeds scope from `f`'s params (and its own
347/// rigid type variables — its own `[T, ...]` type parameters, plus a
348/// generic receiver's, for a method), lowers the body as an ordinary value
349/// block, then wraps the tail via `wrap_body_return`. Distinct from
350/// [`lower_block_ir`], which lowers a *nested* block as a bare value with no
351/// such wrapping.
352///
353/// Handler bodies are out of scope for this entry point: a handler's own
354/// non-local bare-ident forms (store-field/cell reads, agent `self`, the
355/// actor binder, transition `old`/`new`) need resolved-identity plumbing
356/// (`store_fields`, `agent_state_ty`, `actor_binding` — the `Ctx` fields
357/// `check_handler_body` seeds, `checker.rs:930-960`) this entry point has no
358/// parameter for and does not commission (P6.1's own Decision C) — calling
359/// this on a handler body would silently misclassify any bare ident
360/// matching one of those forms as a `Local`/`Global` miss (`todo!()`)
361/// rather than the specific kind it actually is. `lower_handler_body_ir`
362/// (P6.9, #1167) is that dedicated entry point, finally closing this gap —
363/// it is *not* built by widening this function, since a free fn/method and
364/// an agent handler seed genuinely different scopes (rigid type variables
365/// here, agent `self`/store cells there) that would otherwise have to
366/// coexist behind one signature for no shared benefit.
367/// `lower_service_handler_body_ir` (P6.11, #1171) is the third sibling on
368/// the same rule, not a second widening — a service handler's own scope
369/// (`params`/`binder` only) is disjoint from both of the other two.
370pub fn lower_fn_body_ir(f: &FnDecl, program: &CheckedProgram) -> IrExpr {
371    let type_vars = fn_rigid_type_vars(f, program.program());
372    let mut cx = LowerIrCtx::new(program, type_vars);
373    cx.set_return_ty(cx.resolve_type_ref(&f.return_type));
374    // `self` is never in `f.params` (`FnDecl::has_self` gates it) — mirrors
375    // `check_fn`'s own binding (`checker.rs`, the `f.has_self` arm): a
376    // generic receiver's `self` carries the receiver applied to its own
377    // rigid type variables (`Box[A]`'s `self`, not a fixed `Box[Int]`).
378    if let Some(self_ty) = fn_receiver_ty(f, program.program()) {
379        cx.bind("self".to_string(), self_ty);
380    }
381    for p in &f.params {
382        let ty = cx.resolve_type_ref(&p.type_ref).unwrap_or_else(|| {
383            panic!(
384                "bynk internal error (ADR 0334): parameter `{}`'s type does not resolve in this \
385                 pass's own rigid-variable scope, but the checker already accepted this fn body — \
386                 bynk_lower's type_vars disagrees with bynk-check's Ctx::type_vars",
387                p.name.name
388            )
389        });
390        cx.bind(p.name.name.clone(), ty);
391    }
392    let block = lower_block_ir(&f.body, &mut cx);
393    wrap_body_return(block)
394}
395
396/// Lower an agent `on call` handler's own body ([DECISION E], P6.9, #1167) —
397/// [`lower_fn_body_ir`]'s own doc comment names exactly why that entry point
398/// cannot be reused here: seeds a fresh scope from `self` (bound to
399/// `state_ty`, mirroring `check_handler_body`'s own `agent_self_scope`
400/// construction, `context_checks.rs:2900-2915`), every `store_cells` entry
401/// by bare name (the v0.81 "implicit deref in read position" rule the
402/// checker's own `self_scope` loop applies, `context_checks.rs:2910-2915`),
403/// and `h`'s own `params` — then lowers the body as an ordinary value block
404/// and wraps the tail via `wrap_body_return`, same as
405/// [`lower_fn_body_ir`]. No rigid type variables: `AgentDecl` carries no
406/// `type_params` of its own, the same fact
407/// [`resolve_store_field_ty`]'s own doc comment already grounds for
408/// [`lower_store_field_ir`]. `binder` is not bound into scope: this entry
409/// point is only ever reached from [`lower_handler_ir`]'s own agent-only
410/// path ([DECISION D]), where `binder` is `None` unconditionally
411/// ([`bynk_ir::IrHandler`]'s own doc comment). As of P6.11 (#1171) a real
412/// service-handler caller does exist — `lower_service_handler_body_ir` —
413/// and it is a sibling of this function, not a widening of it; that
414/// function's own doc comment names the scope-seeding differences in full.
415///
416/// Once scope is seeded, every store-field method call and every bare `:=`
417/// in `h.body` reaches the *ordinary* [`lower_block_ir`]/[`lower_expr_ir`]/
418/// [`lower_stmt_ir`] path and lowers correctly with no new call-lowering
419/// logic ([DECISION F]): [`lower_call_ir`]'s own existing, already-shipped
420/// generic `Callee`-wrapping (P6.2) already lowers a `Callee::Store`- or
421/// `Callee::Capability`-classified call the moment it's reached, and
422/// [`lower_stmt_ir`]'s own `Statement::Assign` arm ([DECISION B]) is real as
423/// of this same slice — this function's only job is making both reachable
424/// on a handler body at all, by seeding the scope [`lower_ident_ir`] needs
425/// to classify `self`/a store cell as `Local` rather than falling through to
426/// its own unresolved-ident `todo!()`. `store_queryable` (P6.20-pre) is the
427/// same idea one step further: a bare `store Map`/`store Log` field is not a
428/// `Local` (it is not bound into `scopes` at all — a `Map`/`Log` is not a
429/// value type, [`StoreField`]'s own doc comment), so [`lower_ident_ir`]
430/// needs a second table to classify it as [`bynk_ir::IrExprKind::StoreQuery`]
431/// instead of falling through to the same `todo!()`.
432fn lower_handler_body_ir(
433    h: &Handler,
434    store_cells: &HashMap<String, TyId>,
435    store_queryable: &HashSet<String>,
436    state_ty: TyId,
437    program: &CheckedProgram,
438) -> IrExpr {
439    let mut cx = LowerIrCtx::new(program, HashSet::new());
440    cx.set_return_ty(cx.resolve_type_ref(&h.return_type));
441    cx.set_store_queryable(store_queryable.clone());
442    cx.bind("self".to_string(), state_ty);
443    for (name, ty) in store_cells {
444        cx.bind(name.clone(), *ty);
445    }
446    for p in &h.params {
447        let ty = cx.resolve_type_ref(&p.type_ref).unwrap_or_else(|| {
448            panic!(
449                "bynk internal error (ADR 0334): handler parameter `{}`'s type does not resolve \
450                 in this pass's own scope, but the checker already accepted this handler",
451                p.name.name
452            )
453        });
454        cx.bind(p.name.name.clone(), ty);
455    }
456    let block = lower_block_ir(&h.body, &mut cx);
457    wrap_body_return(block)
458}
459
460/// P6.9's real `IrHandler` constructor ([DECISION C]/[DECISION D]/
461/// [DECISION E], #1167) — lowers an agent `on call` handler `h` into a real
462/// [`IrHandler`]. `store_cells`/`state_ty`/`invariants`/`transitions` are
463/// parameters, not re-derived ([DECISION E], mirroring
464/// [`lower_invariant_ir`]'s/[`lower_transition_ir`]'s own precedent, P6.8):
465/// no persisted "this agent's store cells / state type" table survives
466/// `check_agent_decls`'s own transient scope, and `invariants`/`transitions`
467/// are themselves already-lowered [`IrPredicate`]s a caller must have
468/// produced via those same two functions — this function only threads them
469/// into [`lower_commit_shape_ir`], never lowers or re-derives them itself. A
470/// future `IrItem::Agent` builder (not commissioned by this slice — see
471/// [`bynk_ir::IrItem`]'s own doc comment) computes all four once per agent
472/// and calls this function once per handler, not re-deriving any of them per
473/// call.
474///
475/// Threading real `invariants`/`transitions` through (review of #1167,
476/// replacing this function's own original empty-slices posture) matters
477/// beyond completeness: an empty pair is structurally indistinguishable from
478/// a correct lowering of an agent that genuinely declares neither, so an
479/// empty-by-construction `commit` would have been a silent-wrong-value trap
480/// for whatever future caller forgot to populate them for real.
481///
482/// Stays agent-only as of P6.11 (#1171) — [`lower_service_handler_ir`] is
483/// this function's own sibling for a service handler, not a widening of
484/// it; see that function's own doc comment for the three reasons the split
485/// is deliberate, and this function's own `by_clause.is_none()` assert
486/// below for the guard widening would have deleted.
487pub fn lower_handler_ir(
488    h: &Handler,
489    store_cells: &HashMap<String, TyId>,
490    store_queryable: &HashSet<String>,
491    state_ty: TyId,
492    invariants: &[IrPredicate],
493    transitions: &[IrPredicate],
494    program: &CheckedProgram,
495) -> IrHandler {
496    // [DECISION D] is agent-only by contract, not merely by convention — an
497    // agent handler is checker-guaranteed to carry no `by` clause
498    // (`bynk.actor.by_on_agent`, `context_checks.rs:2986-2996`), so a
499    // `Handler` that has one has reached this entry point in error (a future
500    // service-handler caller silently dropping the binder), the same
501    // "assert the checker's invariant loudly" idiom this module's own ADR
502    // 0334 panics already use throughout.
503    assert!(
504        h.by_clause.is_none(),
505        "bynk internal error (ADR 0334): lower_handler_ir is agent-only (DECISION D) — an agent \
506         handler cannot carry a `by` clause (bynk.actor.by_on_agent), so a handler that does is a \
507         service handler reaching the wrong entry point"
508    );
509    let cx = LowerIrCtx::new(program, HashSet::new());
510    let (params, given, ret, effectful) = lower_handler_signature_ir(h, &cx);
511    let emits = block_uses_emit(&h.body, &program.program().callees);
512    let commit = lower_commit_shape_ir(&h.body, invariants, transitions, emits, program);
513    let body = lower_handler_body_ir(h, store_cells, store_queryable, state_ty, program);
514    IrHandler {
515        kind: lower_handler_kind_ir(&h.kind),
516        params,
517        given,
518        // `h.by_clause.is_none()` was just asserted above — always empty,
519        // not merely usually so.
520        actors: Vec::new(),
521        binder: None,
522        // Agent-only by contract (asserted above): the synthetic
523        // `connection` binding is a `from websocket` service-handler
524        // concept exclusively — see [`ConnectionBinder`]'s own doc
525        // comment.
526        connection: None,
527        body,
528        commit,
529        ret,
530        effectful,
531        method_name: h.method_name.as_ref().map(|i| i.name.clone()),
532    }
533}
534
535/// `(params, given, ret, effectful)` — `lower_handler_signature_ir`'s own
536/// return shape, and [`lower_service_handler_signature_ir`]'s (#1187's slice
537/// 5), reused as a named alias rather than a bare tuple at both call sites
538/// once one of them (`emit_service`, `bynk-emit/src/emitter/emit.rs`) had to
539/// spell it out in a function signature.
540pub type HandlerSignatureIr = (Vec<(String, TyId)>, Vec<String>, TyId, bool);
541
542/// A handler's own `params`/`given`/`effectful` — the one part of
543/// [`IrHandler`] construction genuinely identical between an agent handler
544/// ([`lower_handler_ir`]) and a service handler
545/// ([`lower_service_handler_ir`], P6.11, #1171), extracted so the two don't
546/// hand-duplicate it. Everything else about the two — scope seeding,
547/// `binder`, the WebSocket deferral — is genuinely different and stays
548/// unshared; see [`lower_handler_ir`]'s own doc comment for why that split
549/// is deliberate, not an oversight.
550fn lower_handler_signature_ir(h: &Handler, cx: &LowerIrCtx) -> HandlerSignatureIr {
551    let params: Vec<(String, TyId)> = h
552        .params
553        .iter()
554        .map(|p| {
555            let ty = cx.resolve_type_ref(&p.type_ref).unwrap_or_else(|| {
556                panic!(
557                    "bynk internal error (ADR 0334): handler parameter `{}`'s type does not \
558                     resolve in this pass's own scope, but the checker already accepted this \
559                     handler",
560                    p.name.name
561                )
562            });
563            (p.name.name.clone(), ty)
564        })
565        .collect();
566    let given: Vec<String> = h.given.iter().map(|c| c.key().to_string()).collect();
567    let ret = cx.resolve_type_ref(&h.return_type).unwrap_or_else(|| {
568        panic!(
569            "bynk internal error (ADR 0334): a handler's return type does not resolve in this \
570             pass's own scope, but the checker already accepted this handler"
571        )
572    });
573    let effectful = matches!(&*cx.program.ty_intern.get(ret), Ty::Effect(_));
574    (params, given, ret, effectful)
575}
576
577/// P6.50 (design/tracks/the-ir.md §6b): a return type's own syntactic
578/// `Effect[...]` wrapper — `TypeRef::Effect(_, _)`, not the *resolved*
579/// `Ty::Effect(_)` shape `lower_handler_signature_ir` reads above via
580/// `cx.program.ty_intern`. Relocated here from `emitter/emit.rs` (its
581/// original home, `#[allow(dead_code)]`-free and with eight call sites
582/// across `emit.rs`/`workers.rs`/`workers_entry.rs`) because
583/// [`lower_service_handler_signature_ir`] below was already calling *up*
584/// into it (`bynk_emit::emitter::is_effectful_return`) — the `Ast → Ir` boundary
585/// running backwards, an `Ir`-side lowering function reaching into the
586/// `emitter` module it should only ever be called *from*. `emit.rs` and
587/// friends now call `bynk_lower::is_effectful_return` instead (relocated
588/// again at the P7.12 crate carve — `emitter`/`ir::lower` are now separate
589/// crates, `bynk-emit`/`bynk-lower` respectively).
590pub fn is_effectful_return(r: &TypeRef) -> bool {
591    matches!(r, TypeRef::Effect(_, _))
592}
593
594/// #1187's slice 5 (the `Service` emitter cutover): `emit_service`'s own
595/// standalone entry point for a handler's resolved *signature* only —
596/// `params`/`ret`/`effectful`, never the body. Deliberately does not build
597/// a real [`IrHandler`]/[`bynk_ir::IrItem::Service`]: both
598/// [`lower_handler_ir`]/[`lower_service_handler_ir`] unconditionally lower
599/// the handler's own body into a real `IrExpr` (`IrHandler::body` is not
600/// `Option`), and an ordinary `from http` handler's body routinely uses `?`
601/// propagation (`ExprKind::Question`) or an `is`-expression (`ExprKind::Is`).
602/// **Correction (P6.25, 2026-08-19): both landed** — `Question` as P6.15
603/// (ADR 0337, `lower_question_ir`, decomposing to `IrExprKind::Match` per
604/// #1225's own `Ok`/`Err`/`Some`/`None` identity precedent) and `Is` as P6.16
605/// (ADR 0338, `lower_is_ir`, a forced-temp `Let` discharging R5.10). Neither
606/// is reached from this call site or any other shipped emitter path yet —
607/// `emitter/lower.rs`'s own P6.2 `Call`/`Lambda` cutover hasn't landed — so
608/// the reasoning below (a real `IrHandler` here would still panic on other,
609/// still-unconverted constructs reachable from an ordinary `from http` body)
610/// stands, just not on `Question`/`Is` specifically anymore. Building a real
611/// `IrHandler` at `emit_service`'s own call
612/// site would panic on exactly the ordinary Http services this slice needs
613/// to keep working. Mirrors
614/// [`body_writes_state`]'s own precedent (#1196): a narrow, standalone
615/// reader of already-resolved data, not the full `IrItem`/`IrHandler`
616/// assembly — the same posture, applied to signature data instead of a
617/// single boolean.
618/// Deliberately **not** `lower_handler_signature_ir(h, &cx)` (review of
619/// #1198) — that helper's own ADR 0334 `.unwrap_or_else(|| panic!(..))` on a
620/// resolution miss is correct for an *agent* handler (the checker
621/// guarantees resolution there) but not for a *service* one:
622/// `resolver.rs` skips `CommonsItem::Service` in every type-ref-resolution
623/// pass, `check_handler_body` silently skips a param whose type doesn't
624/// resolve and silently returns on an unresolvable return type (no
625/// diagnostic either way), and `check_http_handler` only constrains a
626/// param's *name* (path segment or `body`), never validates a `body:`
627/// param's own declared type. A service handler naming an undeclared type
628/// certifies today (and previously just emitted that bad name verbatim, a
629/// `tsc`-only failure) — reusing the strict helper here would turn that
630/// pre-existing, real-but-harmless-to-the-compiler gap into an ICE on the
631/// production emit path for every service in every project (confirmed live:
632/// `on POST("/x") (body: Nope) -> Effect[HttpResult[String]] by v: Visitor
633/// { ... }` panics `bynkc` before this fix). Mirrors [`lower_protocol_ir`]'s
634/// own `Ty::Unit`-on-miss posture, for the identical underlying reason (that
635/// function's own doc comment already documents the checker's Service-wide
636/// resolution gap).
637///
638/// `effectful` is computed from `h.return_type`'s own AST shape
639/// (`TypeRef::Effect(..)`, matching [`is_effectful_return`] exactly), not
640/// from the *resolved* `ret`'s `Ty::Effect(_)` shape — a
641/// resolution miss on `ret` (falling back to `Ty::Unit` below) must not
642/// silently flip an `Effect[Nope]`-returning handler to non-effectful; the
643/// top-level `Effect[...]` wrapper is always syntactically determinable
644/// regardless of whether its own inner type resolves.
645pub fn lower_service_handler_signature_ir(
646    h: &Handler,
647    program: &CheckedProgram,
648) -> HandlerSignatureIr {
649    let cx = LowerIrCtx::new(program, HashSet::new());
650    let params: Vec<(String, TyId)> = h
651        .params
652        .iter()
653        .map(|p| {
654            let ty = cx
655                .resolve_type_ref(&p.type_ref)
656                .unwrap_or_else(|| cx.unit_ty());
657            (p.name.name.clone(), ty)
658        })
659        .collect();
660    let given: Vec<String> = h.given.iter().map(|c| c.key().to_string()).collect();
661    let ret = cx
662        .resolve_type_ref(&h.return_type)
663        .unwrap_or_else(|| cx.unit_ty());
664    let effectful = is_effectful_return(&h.return_type);
665    (params, given, ret, effectful)
666}
667
668/// P6.11 ([DECISION E], #1171): lower a service handler's own body — the
669/// sibling of `lower_handler_body_ir` the reasoning in
670/// [`lower_service_handler_ir`]'s own doc comment argues for, not a
671/// widening of it. Seeds scope from `connection` (P6.13, #1179) if this
672/// handler carries one, then `h.params`, then `binder` if one was resolved
673/// — matching `check_handler_body`'s own `param_scope` construction order
674/// for a service (`checker.rs`: `params_for_check` — synthetic `connection`
675/// prepended first when present, `open_connection_param`,
676/// `context_checks.rs:1944-1954` — then `actor_binding`, then
677/// `agent_self_scope` only when present). Binder last has no observable
678/// effect today only because `handler_actor_binding` already suppresses a
679/// param-shadowing binder before this ever runs (`context_checks.rs:2050-
680/// 2055`) — recorded here so the ordering isn't silently "fixed" by a later
681/// reader who doesn't know it's already load-bearing-adjacent. No rigid
682/// type variables: `ServiceDecl` carries no `type_params` of its own, the
683/// same fact [`resolve_store_field_ty`]'s own doc comment already grounds
684/// for [`lower_store_field_ir`].
685fn lower_service_handler_body_ir(
686    h: &Handler,
687    binder: Option<&ActorBinder>,
688    connection: Option<&ConnectionBinder>,
689    program: &CheckedProgram,
690) -> IrExpr {
691    let mut cx = LowerIrCtx::new(program, HashSet::new());
692    cx.set_return_ty(cx.resolve_type_ref(&h.return_type));
693    if let Some(connection) = connection {
694        cx.bind("connection".to_string(), connection.ty);
695    }
696    // Review of #1253 (P6.23 root-cause pass): degrades to `cx.unit_ty()`
697    // on a resolve miss instead of panicking, matching this function's own
698    // signature-only sibling, `lower_service_handler_signature_ir`, one
699    // call away — an inconsistency this pass's own review process should
700    // have caught the moment the signature-only reader picked up the
701    // graceful fallback. Not an ADR 0334 violation to begin with: a
702    // service/HTTP handler's own param type is never actually
703    // resolution-checked — the fixture pinning this directly,
704    // `1199_service_handler_unresolvable_param_type_no_ice`, names why:
705    // `check_http_handler` validates a param's *name* only, never its
706    // declared type, and `resolver.rs`'s own passes skip `CommonsItem::
707    // Service` entirely — so panicking here on a state the checker itself
708    // accepts was the actual bug, not the miss.
709    for p in &h.params {
710        let ty = cx
711            .resolve_type_ref(&p.type_ref)
712            .unwrap_or_else(|| cx.unit_ty());
713        cx.bind(p.name.name.clone(), ty);
714    }
715    if let Some(binder) = binder {
716        cx.bind(binder.binder.clone(), binder.ty);
717    }
718    let block = lower_block_ir(&h.body, &mut cx);
719    wrap_body_return(block)
720}
721
722/// P6.11's real service-handler `IrHandler` constructor ([DECISION E],
723/// #1171) — the sibling to [`lower_handler_ir`], not a widening of it.
724/// [`lower_fn_body_ir`]'s own doc comment already states the governing
725/// rule ("a free fn/method and an agent handler seed genuinely different
726/// scopes … that would otherwise have to coexist behind one signature for
727/// no shared benefit"); the same reasoning gives the same answer here,
728/// more strongly:
729/// - The two scopes are disjoint, not overlapping — an agent handler body
730///   seeds `self`/`store_cells`; a service handler body seeds `params`/
731///   `binder` only. Not one of `store_cells`/`state_ty`/`invariants`/
732///   `transitions` means anything for a service.
733/// - [`lower_handler_ir`] asserts `h.by_clause.is_none()` — an explicit
734///   "agent-only by contract" guard. Widening would delete the one thing
735///   that today catches a service handler reaching the wrong entry point.
736///
737/// `protocol` is a parameter solely to disambiguate `HandlerKind::Message`
738/// — the same literal AST variant is a *queue* consumer under
739/// `ServiceProtocol::Queue` and a *WebSocket inbound frame* under
740/// `ServiceProtocol::WebSocket` (`HandlerKind::Close`'s own doc comment
741/// says so), and the checker itself dispatches on exactly this `(kind,
742/// protocol)` pair (`context_checks.rs:1938-1945`) — without `protocol`
743/// here, the `ConnectionBinder` derivation below would wrongly capture
744/// every queue consumer in the language too.
745///
746/// **`binder` is read back, not threaded in as a parameter** — the
747/// deliberate inverse of [`lower_handler_ir`]'s own [DECISION E]
748/// (`store_cells`/`state_ty`/`invariants`/`transitions` are parameters
749/// *because* no persisted table survives `check_agent_decls`'s own
750/// transient scope). Here the opposite premise holds: #1170 persisted
751/// exactly this pair into `TypedCommons::actor_bindings`, so the opposite
752/// choice follows. `None` is a legitimate outcome, not an error — a
753/// binder-less `by <Actor>`, no `by` clause at all, or a binder that
754/// shadowed a param and was suppressed (`context_checks.rs:2050-2055`) all
755/// resolve to `None` here exactly as they do in `handler_actor_binding`
756/// itself; this function does not, and must not, assert `binder.is_some()`
757/// from `h.by_clause.is_some()`.
758///
759/// `commit` passes empty `invariants`/`transitions` slices to
760/// [`lower_commit_shape_ir`] — [DECISION F]'s own predicted service call
761/// site, real for the first time. `CommitShape::Transactional` is
762/// structurally unreachable here, not merely unproduced by convention: a
763/// service declares no `store` fields for `body_writes_state` to find a
764/// write against. No `is_service` flag is added anywhere — Decision F's
765/// whole point is that none is needed.
766///
767/// **The websocket lifecycle case, closed (P6.13, [DECISION G], #1179).**
768/// A `from websocket` service's `on open`/`on message`/`on close` handler
769/// receives the synthetic leading `connection: Connection[out]` binding
770/// the checker injects into its own `params_for_check` only
771/// (`open_connection_param`, `context_checks.rs:2020-2032`, injected at
772/// `:1944-1954`) — never into `h.params`, so `params`/`given` above stay
773/// derived from `h.params` alone, exactly mirroring the checker's own
774/// asymmetry (`handler.params` itself is never mutated either). This
775/// function re-derives the same `Connection[out]` type via
776/// `cx.resolve_type_ref` over a freshly-built `TypeRef::Connection`, the
777/// same construction `open_connection_param` performs, since that function
778/// is private to `bynk-check` and there is no persisted `TypedCommons`
779/// table to read it back from (contrast `binder`, below, which #1170 did
780/// persist). `borrowed` mirrors the checker's own `borrowed_held`
781/// distinction (`context_checks.rs:1955-1963`): `false` for `on open` (a
782/// fresh owned socket, disposed via transfer to an agent), `true` for `on
783/// message`/`on close` (the borrowed firing socket). The resulting
784/// [`ConnectionBinder`] is threaded into
785/// `lower_service_handler_body_ir` so `connection.…` reads resolve via
786/// `cx.lookup` like any other bound name, and carried on [`IrHandler`]
787/// itself for any consumer that needs the owned/borrowed distinction
788/// without re-deriving it from `kind`/`protocol`.
789pub fn lower_service_handler_ir(
790    h: &Handler,
791    protocol: &ServiceProtocol,
792    program: &CheckedProgram,
793) -> IrHandler {
794    let cx = LowerIrCtx::new(program, HashSet::new());
795    // Review of #1253 (P6.23 root-cause pass): was `lower_handler_signature_ir`
796    // (the agent-oriented signature reader, which panics on a resolve miss —
797    // correct for an agent handler's own param type, which the checker does
798    // guarantee resolves) — the wrong sibling for a *service* handler, whose
799    // own param type is not resolution-checked at all
800    // (`1199_service_handler_unresolvable_param_type_no_ice` pins it; see
801    // `lower_service_handler_body_ir`'s own matching fix). Swapped for
802    // `lower_service_handler_signature_ir`, this function's own real
803    // sibling, already graceful (`cx.unit_ty()` on a miss) since it was
804    // written — this call site was simply never updated to use it.
805    let (params, given, ret, effectful) = lower_service_handler_signature_ir(h, program);
806    // Read straight off `h.by_clause`, not derived from `binder` below —
807    // review of #1180: `binder` alone loses the gate itself for a
808    // binder-less `by <Actor>` (`ActorBinder`'s own doc comment already
809    // names `None` as legitimate there) and loses the actor's own *name*
810    // even in the single-actor happy path (`ActorBinder::ty` carries only
811    // the sealed identity type). See `IrHandler::actors`'s own doc comment.
812    let actors: Vec<String> = h
813        .by_clause
814        .as_ref()
815        .map(|by| by.actors.iter().map(|a| a.name.clone()).collect())
816        .unwrap_or_default();
817    let binder = program
818        .program()
819        .actor_binding(h.span)
820        .map(|(name, ty)| ActorBinder {
821            binder: name.clone(),
822            ty: *ty,
823        });
824    let connection = match (&h.kind, protocol) {
825        (
826            HandlerKind::Open | HandlerKind::Message | HandlerKind::Close,
827            ServiceProtocol::WebSocket { out_type, .. },
828        ) => {
829            let conn_ref =
830                bynk_syntax::ast::TypeRef::Connection(Box::new(out_type.clone()), h.span);
831            let ty = cx.resolve_type_ref(&conn_ref).unwrap_or_else(|| {
832                panic!(
833                    "bynk internal error (ADR 0334): a `from websocket` lifecycle handler's \
834                     synthetic `connection: Connection[out]` type does not resolve in this \
835                     pass's own scope, but the checker already accepted this handler"
836                )
837            });
838            Some(ConnectionBinder {
839                ty,
840                borrowed: matches!(h.kind, HandlerKind::Message | HandlerKind::Close),
841            })
842        }
843        _ => None,
844    };
845    let emits = block_uses_emit(&h.body, &program.program().callees);
846    let commit = lower_commit_shape_ir(&h.body, &[], &[], emits, program);
847    let body = lower_service_handler_body_ir(h, binder.as_ref(), connection.as_ref(), program);
848    IrHandler {
849        kind: lower_handler_kind_ir(&h.kind),
850        params,
851        given,
852        actors,
853        binder,
854        connection,
855        body,
856        commit,
857        ret,
858        effectful,
859        method_name: h.method_name.as_ref().map(|i| i.name.clone()),
860    }
861}
862
863/// P6.6 (#1161): lower a `type` declaration into a real [`IrItem::Type`] —
864/// [`IrItem`]'s own doc comment names which of its seven design-sketch
865/// variants are real as of this slice (`Type`/`Fn` only, Decision D). Takes
866/// a certified `&CheckedProgram`, matching this module's own categorical
867/// discipline (this file's own header doc: "every entry point here takes a
868/// `&CheckedProgram`"), even though only `TypedCommons::types`/`ty_intern`
869/// are read — no per-expression `expr_types` lookup is involved (Q2,
870/// `design/tracks/the-ir.md` §3.2), but *which fields are read* isn't the
871/// discipline; *which failures are allowed to `panic!`* is. Every panic
872/// below asserts "the checker already accepted this declaration" — true
873/// only once `certify` has run: a bare `TypedCommons` is not certified by
874/// construction (`checker.rs`'s own `CheckedProgram` doc notes the
875/// project/batch path holds per-unit `TypedCommons` values *before* that
876/// unit's build-wide gate is decided), so accepting one here would make
877/// `resolve_type_ref_in` returning `None` a reachable, not just a buggy,
878/// outcome.
879pub fn lower_type_item_ir(decl: &Arc<TypeDecl>, program: &CheckedProgram) -> IrItem {
880    let program = program.program();
881    let type_vars: HashSet<String> = decl
882        .type_params
883        .iter()
884        .map(|tp| tp.name.name.clone())
885        .collect();
886    let resolve = |r: &bynk_syntax::ast::TypeRef| {
887        checker::resolve_type_ref_in(r, &program.types, &type_vars, &program.ty_intern)
888    };
889    let shape = match &decl.body {
890        TypeBody::Record(r) => TypeShape::Record {
891            fields: r
892                .fields
893                .iter()
894                .map(|f| {
895                    let ty = resolve(&f.type_ref).unwrap_or_else(|| {
896                        panic!(
897                            "bynk internal error (ADR 0334): field `{}` of type `{}` does not \
898                             resolve, but the checker already accepted this declaration",
899                            f.name.name, decl.name.name
900                        )
901                    });
902                    (f.name.name.clone(), ty)
903                })
904                .collect(),
905        },
906        TypeBody::Sum(s) => TypeShape::Sum {
907            variants: s
908                .variants
909                .iter()
910                .map(|v| {
911                    let payload = v
912                        .payload
913                        .iter()
914                        .map(|vf| {
915                            let ty = resolve(&vf.type_ref).unwrap_or_else(|| {
916                                panic!(
917                                    "bynk internal error (ADR 0334): field `{}` of variant `{}` \
918                                     of type `{}` does not resolve, but the checker already \
919                                     accepted this declaration",
920                                    vf.name.name, v.name.name, decl.name.name
921                                )
922                            });
923                            (vf.name.name.clone(), ty)
924                        })
925                        .collect();
926                    (v.name.name.clone(), payload)
927                })
928                .collect(),
929            embeds: s
930                .embeds
931                .iter()
932                .map(|e| {
933                    let source = resolve(&e.source_type).unwrap_or_else(|| {
934                        panic!(
935                            "bynk internal error (ADR 0334): `embeds` clause source type on \
936                             variant `{}` of type `{}` does not resolve, but the checker \
937                             already accepted this declaration",
938                            e.variant.name, decl.name.name
939                        )
940                    });
941                    (source, e.variant.name.clone())
942                })
943                .collect(),
944        },
945        TypeBody::Refined {
946            base, refinement, ..
947        } => TypeShape::Refined {
948            base: *base,
949            refinement: refinement.clone(),
950            opaque: false,
951        },
952        TypeBody::Opaque {
953            base, refinement, ..
954        } => TypeShape::Refined {
955            base: *base,
956            refinement: refinement.clone(),
957            opaque: true,
958        },
959    };
960    IrItem::Type { shape }
961}
962
963/// P6.6 (#1161): lower a `fn` declaration into a real [`IrItem::Fn`] —
964/// wraps [`lower_fn_body_ir`] (#1141, unchanged) rather than re-deriving its
965/// own rigid-variable seeding or body lowering; adds only `def`/`receiver`/
966/// `params`/`ret`/`effectful` around its existing return value. Covers both
967/// free functions and methods alike (`FnName::Free`/`FnName::Method`) —
968/// which `IrItem::Fn`s a future printer re-attaches under which
969/// `IrItem::Type`'s own namespace (R8.1) is phase 7's own concern, not
970/// decided here.
971///
972/// Takes `f: &Arc<FnDecl>` for a cheap clone into
973/// [`lower_fn_body_ir`]/`fn_receiver_ty`/`fn_rigid_type_vars`'s own calls
974/// below, not because this constructor itself keeps a copy any more — P6.39
975/// dropped `IrItem::Fn::def` (no production reader ever read it back; this
976/// constructor has no production call site at all today either).
977pub fn lower_fn_item_ir(f: &Arc<FnDecl>, program: &CheckedProgram) -> IrItem {
978    let type_vars = fn_rigid_type_vars(f, program.program());
979    let cx = LowerIrCtx::new(program, type_vars);
980    let receiver = fn_receiver_ty(f, program.program());
981    let params: Vec<(String, TyId)> = f
982        .params
983        .iter()
984        .map(|p| {
985            let ty = cx.resolve_type_ref(&p.type_ref).unwrap_or_else(|| {
986                panic!(
987                    "bynk internal error (ADR 0334): parameter `{}`'s type does not resolve in \
988                     this pass's own rigid-variable scope, but the checker already accepted \
989                     this fn — bynk_lower's type_vars disagrees with bynk-check's \
990                     Ctx::type_vars",
991                    p.name.name
992                )
993            });
994            (p.name.name.clone(), ty)
995        })
996        .collect();
997    let ret = cx.resolve_type_ref(&f.return_type).unwrap_or_else(|| {
998        panic!(
999            "bynk internal error (ADR 0334): the return type of `{}` does not resolve in this \
1000             pass's own rigid-variable scope, but the checker already accepted this fn",
1001            f.name.display()
1002        )
1003    });
1004    let effectful = matches!(&*program.program().ty_intern.get(ret), Ty::Effect(_));
1005    IrItem::Fn {
1006        receiver,
1007        params,
1008        ret,
1009        body: lower_fn_body_ir(f, program),
1010        effectful,
1011    }
1012}
1013
1014/// A store field's own element/key/value type, resolved with no rigid type
1015/// variables in scope — `AgentDecl` carries no `type_params` of its own
1016/// (its own struct shape, `bynk-syntax/src/ast.rs:908-934`), so
1017/// [`lower_store_field_ir`] never needs the `fn_rigid_type_vars`-shaped
1018/// seeding every fn/method-level constructor here does. Shared by every
1019/// arm of that function's own kind dispatch, and by [`lower_store_field_shape_ir`].
1020///
1021/// **Not an ADR 0334 `.expect()`-style panic on a resolve miss, deliberately**
1022/// — the same posture `lower_op_sig_ir`'s own doc comment already argues
1023/// for a capability op's `params`/`return_type`, confirmed empirically for
1024/// store fields specifically (#1187's Agent state-field slice, step 0):
1025/// `store x: Cell[Bogus] = "hello"` certifies today (exit 0, no diagnostic),
1026/// so a `Bogus` store-field type — undeclared, and of the wrong kind for the
1027/// `= "hello"` initializer besides — reaches this pass with no `expr_types`/
1028/// `types` entry at all. Nothing in `context_checks.rs`'s store-field
1029/// checking validates the *type reference* itself (only its shape, e.g.
1030/// `Cell`/`Map`/`Set`/`Cache`/`Log`, and `@ttl`/`@indexed` legality). Mirror
1031/// the checker's own silent-fallback posture instead of asserting a
1032/// guarantee that does not hold.
1033fn resolve_store_field_ty(cx: &LowerIrCtx, r: &bynk_syntax::ast::TypeRef) -> TyId {
1034    cx.resolve_type_ref(r).unwrap_or_else(|| cx.unit_ty())
1035}
1036
1037/// A `@name(<duration literal>)` annotation's own millisecond value — the
1038/// shared "find the annotation, read its first argument's `DurationLit`"
1039/// step both `@ttl` (`Cache`) and `@retain` (`Log`) need, factored out so
1040/// the two [`lower_store_field_ir`] arms are one call each rather than two
1041/// near-identical inline `find`/`and_then` chains. Mirrors, but does not
1042/// call, `cache_ttl_millis` (`bynk-check/src/context_checks.rs`) and the
1043/// shipped emitter's own equivalent extraction (`emit.rs`'s
1044/// `store_cache_fields`/`store_log_fields`) — those thread a
1045/// `&mut Vec<CompileError>` for a missing-`@ttl` diagnostic and are private
1046/// to their own module, so reusing them directly here is not architecturally
1047/// available; see [`lower_store_field_ir`]'s own doc comment for why this is
1048/// an accepted, named duplication rather than a gap this slice closes.
1049fn duration_millis_annotation(
1050    annotations: &[bynk_syntax::ast::Annotation],
1051    name: &str,
1052) -> Option<i64> {
1053    annotations
1054        .iter()
1055        .find(|a| a.name.name == name)
1056        .and_then(|a| match a.args.first().map(|arg| &arg.value.kind) {
1057            Some(ExprKind::DurationLit { millis, .. }) => Some(*millis),
1058            _ => None,
1059        })
1060}
1061
1062/// P6.7 (#1163): lower an agent `store` field declaration into a real
1063/// [`StoreFieldIr`] — dispatches on `f.kind.head.name` into
1064/// [`StoreKindIr`]'s five real variants; `Queue` cannot reach a certified
1065/// program (`bynk.store.kind_unsupported` gates it before `certify`, R3.10),
1066/// so this match is total over what one can actually contain, not a gap
1067/// needing its own extension later.
1068///
1069/// `@ttl`/`@retain` millis and `@indexed(by: …)` field names are read
1070/// directly off `f.annotations`, independently of `cache_ttl_millis`/
1071/// `store_log_fields`/`store_map_indexes` (`bynk-check`/the shipped
1072/// emitter) — the *pattern* is the same one those already established
1073/// ([DECISION B]/[DECISION C], #1163), not a call into shared code: this
1074/// track's own P6.0 precedent (`design/tracks/the-ir.md` §3.4, Q4) chose to
1075/// extend the checker's typed output once (`Callee`) rather than let a
1076/// lowering pass re-derive from the AST the way the shipped emitter's own
1077/// dispatcher does, and this constructor falls short of that bar — a real,
1078/// accepted duplication (three independent copies of the same
1079/// `DurationLit`-extraction shape now exist: `context_checks.rs`,
1080/// `emitter/emit.rs`, and here), not one this slice is positioned to close,
1081/// since none of the three is `pub`/shaped for a shared caller today. Within
1082/// this function the `@ttl`/`@retain` cases at least share one call each to
1083/// `duration_millis_annotation`, rather than repeating the extraction
1084/// inline a second time. `indexed` keeps the annotation's own declaration
1085/// order, deduplicated — no sort, unlike the shipped emitter's own
1086/// doubly-sorted `HashMap` intermediate ([DECISION E]'s own structural fix,
1087/// `ir.rs`'s own `StoreFieldIr::indexed` doc comment), but still guarding
1088/// against a duplicate `by:` key the checker admits (`validate_indexed_keys`
1089/// validates each `by:` argument independently, so `@indexed(by: k, by: k)`
1090/// certifies), mirroring the shipped emitter's own `store_map_indexes`
1091/// dedup guard.
1092///
1093/// `init` is constructed only for a `Cell` field ([DECISION D]) — no
1094/// checker pass types a non-`Cell` field's `init` expression (a real,
1095/// pre-existing gap this proposal's own grounding pass found:
1096/// `context_checks.rs`'s init-checking loop skips anything that isn't
1097/// `Cell`, and no other pass fills that gap either), so on a certified
1098/// program such an expression, if ever written, has no `expr_types` entry —
1099/// lowering it here would hit this pass's own ADR 0334 panic on a value the
1100/// checker never verified, not a recoverable state.
1101///
1102/// Deliberately not unified with `checker::StoreField` (`bynk-check/src/checker.rs`):
1103/// that dispatch is ephemeral, per-agent checking-time scratch, rebuilt
1104/// fresh inside `check_agent_decls` and discarded once that agent's
1105/// handler/invariant checking finishes; this constructor produces
1106/// persistent IR data with no consumer yet. Collapsing them would mean
1107/// either the checker producing IR-shaped data (crossing the phase-5/
1108/// phase-6 boundary `the-ir.md` §3.4 already drew deliberately) or this
1109/// pass consuming the checker's own discarded scratch state across a value
1110/// that no longer exists once `check_agent_decls` returns — a real, named
1111/// duplication (#1163's own Risks), not a gap this slice closes.
1112pub fn lower_store_field_ir(f: &StoreField, program: &CheckedProgram) -> StoreFieldIr {
1113    let mut cx = LowerIrCtx::new(program, HashSet::new());
1114    let (kind, indexed) = store_field_kind_and_indexed(f, &cx);
1115    // [DECISION D]: only a `Cell` field's `init` is ever lowered.
1116    let init = match &kind {
1117        StoreKindIr::Cell(_) => f.init.as_ref().map(|e| lower_expr_ir(e, &mut cx)),
1118        _ => None,
1119    };
1120    StoreFieldIr {
1121        field: f.name.name.clone(),
1122        kind,
1123        init,
1124        indexed,
1125    }
1126}
1127
1128/// The shape half of [`lower_store_field_ir`] — its `kind`/`indexed`
1129/// computation, factored out so [`lower_store_field_shape_ir`] can share it
1130/// without either duplicating the `Cell`/`Map`/`Set`/`Cache`/`Log` dispatch
1131/// or paying for a `&mut LowerIrCtx` it never needs (nothing here lowers an
1132/// expression).
1133fn store_field_kind_and_indexed(f: &StoreField, cx: &LowerIrCtx) -> (StoreKindIr, Vec<IndexIr>) {
1134    let head = f.kind.head.name.as_str();
1135    let kind = match head {
1136        "Cell" => StoreKindIr::Cell(resolve_store_field_ty(cx, &f.kind.args[0])),
1137        "Map" => StoreKindIr::Map(
1138            resolve_store_field_ty(cx, &f.kind.args[0]),
1139            resolve_store_field_ty(cx, &f.kind.args[1]),
1140        ),
1141        "Set" => StoreKindIr::Set(resolve_store_field_ty(cx, &f.kind.args[0])),
1142        "Cache" => {
1143            let k = resolve_store_field_ty(cx, &f.kind.args[0]);
1144            let v = resolve_store_field_ty(cx, &f.kind.args[1]);
1145            let ttl = duration_millis_annotation(&f.annotations, "ttl").unwrap_or_else(|| {
1146                panic!(
1147                    "bynk internal error (ADR 0334): `Cache` field `{}` has no resolvable \
1148                     `@ttl` millis, but the checker already accepted this declaration — \
1149                     bynk.store.cache_ttl_required gates a missing or malformed `@ttl` \
1150                     before certify",
1151                    f.name.name
1152                )
1153            });
1154            StoreKindIr::Cache(k, v, ttl)
1155        }
1156        "Log" => {
1157            let elem = resolve_store_field_ty(cx, &f.kind.args[0]);
1158            let retain = duration_millis_annotation(&f.annotations, "retain");
1159            StoreKindIr::Log(elem, retain)
1160        }
1161        other => panic!(
1162            "bynk internal error (ADR 0334): store field `{}` has storage kind `{other}`, which \
1163             cannot reach a certified program — only Cell/Map/Set/Cache/Log are functional \
1164             (Queue is gated by bynk.store.kind_unsupported before certify)",
1165            f.name.name
1166        ),
1167    };
1168    // [DECISION C]/[DECISION E]: one entry per distinct `by:` argument,
1169    // declaration order, no sort — legal only on `Map` (`ANNOTATIONS`'s own
1170    // registry), so this is empty by construction for every other kind.
1171    // Deduplicated: `validate_indexed_keys` (`context_checks.rs`) validates
1172    // each `by:` argument independently with no duplicate check, so
1173    // `@indexed(by: k, by: k)` certifies — mirrors the shipped emitter's own
1174    // `store_map_indexes` guard (`emit.rs`'s `!fields.contains(&k.name)`),
1175    // grounded during P6.7's own review (#1163): dropping this would mean a
1176    // duplicate `by:` produces the same sibling index table twice.
1177    let mut indexed: Vec<IndexIr> = Vec::new();
1178    for arg in f
1179        .annotations
1180        .iter()
1181        .filter(|a| a.name.name == "indexed")
1182        .flat_map(|a| &a.args)
1183    {
1184        if let (Some(l), ExprKind::Ident(k)) = (&arg.label, &arg.value.kind)
1185            && l.name == "by"
1186            && !indexed.contains(&k.name)
1187        {
1188            indexed.push(k.name.clone());
1189        }
1190    }
1191    (kind, indexed)
1192}
1193
1194/// #1187's Agent state-field slice: [`lower_store_field_ir`]'s shape-only
1195/// sibling — same `kind`/`indexed` (via `store_field_kind_and_indexed`),
1196/// `init` always `None`. This is the entry point `emit_agent`'s own state
1197/// section actually needs: a field's storage *shape* (its `Cell`/`Map`/
1198/// `Set`/`Cache`/`Log` kind and `@indexed` keys), never its `Cell` zero/
1199/// initial value expression. Deliberately never lowers `init`, unlike
1200/// [`lower_store_field_ir`] — a `Cell` field's initializer can be an
1201/// `is`-expression (`= x is SomeVariant`), which still hits `ExprKind::Is`'s
1202/// own `todo!()` a few hundred lines below (`1029_agent_static_init_hoist`'s
1203/// `store active: Cell[Bool] = if true { 5 is PositiveInt } else { false }`
1204/// hits exactly this on `lower_store_field_ir`'s own `init` arm). The
1205/// sibling `= None` shape (`223_store_cell_agent`'s own `store paymentRef:
1206/// Cell[Option[AuthId]] = None`) no longer needs this workaround as of
1207/// #1225's own ADR — `lower_store_field_ir` lowers it directly now
1208/// (`store_field_cell_option_init_none_lowers_without_panicking`). This
1209/// function's callers never need `init` at all regardless, so neither gap
1210/// is a risk for them.
1211pub fn lower_store_field_shape_ir(f: &StoreField, program: &CheckedProgram) -> StoreFieldIr {
1212    let cx = LowerIrCtx::new(program, HashSet::new());
1213    let (kind, indexed) = store_field_kind_and_indexed(f, &cx);
1214    StoreFieldIr {
1215        field: f.name.name.clone(),
1216        kind,
1217        init: None,
1218        indexed,
1219    }
1220}
1221
1222/// P6.8 ([DECISION A]/[DECISION E], #1165): lower an agent invariant into a
1223/// real [`IrPredicate`] — seeds the predicate's own scope from `store_cells`
1224/// exactly as `checker::check_invariants` does (`bynk-check/src/checker.rs`:
1225/// each `store` `Cell` field in scope by bare name, reading as its element
1226/// type), then lowers `predicate` through the ordinary [`lower_expr_ir`]
1227/// machinery unchanged. Takes `store_cells` as a parameter rather than
1228/// re-deriving it from `program` ([DECISION E]) — a certified
1229/// `CheckedProgram` carries no persisted "this agent's store cells" table;
1230/// that scope is `check_agent_decls`'s own transient scratch, never
1231/// persisted to `TypedCommons`. Called once per agent's own invariant list,
1232/// by whichever future slice builds `IrItem::Agent` for real — not once per
1233/// handler, mirroring `check_invariants`'s own once-per-agent posture.
1234pub fn lower_invariant_ir(
1235    inv: &Invariant,
1236    store_cells: &HashMap<String, TyId>,
1237    program: &CheckedProgram,
1238) -> IrPredicate {
1239    let mut cx = LowerIrCtx::new(program, HashSet::new());
1240    for (name, ty) in store_cells {
1241        cx.bind(name.clone(), *ty);
1242    }
1243    IrPredicate {
1244        name: inv.name.name.clone(),
1245        predicate: lower_expr_ir(&inv.predicate, &mut cx),
1246    }
1247}
1248
1249/// P6.8 ([DECISION A]/[DECISION E], #1165): lower a step invariant
1250/// (`Transition`) into a real [`IrPredicate`] — seeds `old`/`new`, both
1251/// bound to the agent's own synthetic state-record type, exactly as
1252/// `checker::check_transitions` does. `state_ty` is a parameter, not
1253/// re-derived, for the same reason [`lower_invariant_ir`]'s own
1254/// `store_cells` is: no persisted "this agent's state type" table survives
1255/// past `check_agent_decls`'s own transient scope. Called once per agent's
1256/// own transition list, by the same future caller [`lower_invariant_ir`]
1257/// names.
1258pub fn lower_transition_ir(
1259    tr: &Transition,
1260    state_ty: TyId,
1261    program: &CheckedProgram,
1262) -> IrPredicate {
1263    let mut cx = LowerIrCtx::new(program, HashSet::new());
1264    cx.bind("old".to_string(), state_ty);
1265    cx.bind("new".to_string(), state_ty);
1266    IrPredicate {
1267        name: tr.name.name.clone(),
1268        predicate: lower_expr_ir(&tr.predicate, &mut cx),
1269    }
1270}
1271
1272/// [DECISION B]/[DECISION C] (#1165): does `body` reach a mutating
1273/// `Callee::Store` write, or an unconditional `Statement::Assign` (`:=`),
1274/// anywhere — including inside a nested `if`/`match`/lambda? Drives
1275/// [`lower_commit_shape_ir`]'s own `Transactional` decision, and, as of
1276/// #1196 (the #1187 emitter-cutover track's own R6.5 stake), `emit_agent`'s
1277/// (`bynk-emit/src/emitter/emit.rs`) own real implicit-commit-wrapper
1278/// decision too — its previous own name-matching `block_writes_state`
1279/// (`emitter.rs`) is deleted, this function is its sole, direct
1280/// replacement. The walk's own shape is that deleted function's own
1281/// already-correct skeleton reused structurally, not re-derived:
1282/// `Block`/`If`/`Match` are hand-matched so crossing a nested block
1283/// re-enters the statement-aware case (an `expr_children` descent alone
1284/// flattens a block straight to its statements' *values*, losing the
1285/// `Statement::Assign` tag), everywhere else recurses over
1286/// `expr_children`'s total child iterator.
1287///
1288/// Unlike the deleted function's own name-based `mutating_op`, this walk
1289/// needs no per-kind receiver-name set: a `Callee::Store { op, .. }` already
1290/// carries the field's own resolved identity (the checker only ever records
1291/// one for a method the field's own kind actually declares), so `op`'s
1292/// membership in the shared mutating-verb constants ([DECISION C],
1293/// `emitter.rs`) is unambiguous checked flat, across all four kinds' lists
1294/// at once — a locally-shadowed name that would false-positive
1295/// `mutating_op` cannot false-positive here at all, the exact fix Decision
1296/// B's own Risk names, and the exact defect `#1196_agent_write_detection_
1297/// via_resolved_callee`'s own fixture pins at the emitted-output level.
1298pub fn body_writes_state(body: &Block, program: &TypedCommons) -> bool {
1299    fn is_mutating_store_write(e: &Expr, program: &TypedCommons) -> bool {
1300        match program.callees.get(&e.id) {
1301            Some(Callee::Store { op, .. }) => {
1302                MUTATING_MAP_CACHE_OPS.contains(&op.as_str())
1303                    || MUTATING_SET_OPS.contains(&op.as_str())
1304                    || MUTATING_LOG_OPS.contains(&op.as_str())
1305                    || MUTATING_CELL_OPS.contains(&op.as_str())
1306            }
1307            _ => false,
1308        }
1309    }
1310    fn stmt(s: &Statement, program: &TypedCommons) -> bool {
1311        match s {
1312            Statement::Assign(_) => true,
1313            Statement::Let(l) | Statement::EffectLet(l) => expr(&l.value, program),
1314            Statement::Expect(a) => expr(&a.value, program),
1315            Statement::Send(s) => expr(&s.value, program),
1316            Statement::Do(d) => expr(&d.value, program),
1317        }
1318    }
1319    fn expr(e: &Expr, program: &TypedCommons) -> bool {
1320        if is_mutating_store_write(e, program) {
1321            return true;
1322        }
1323        match &e.kind {
1324            ExprKind::Block(b) => body_writes_state(b, program),
1325            ExprKind::If {
1326                cond,
1327                then_block,
1328                else_block,
1329            } => {
1330                expr(cond, program)
1331                    || body_writes_state(then_block, program)
1332                    || body_writes_state(else_block, program)
1333            }
1334            ExprKind::Match { discriminant, arms } => {
1335                expr(discriminant, program)
1336                    || arms.iter().any(|a| match &a.body {
1337                        MatchBody::Expr(e) => expr(e, program),
1338                        MatchBody::Block(b) => body_writes_state(b, program),
1339                    })
1340            }
1341            _ => expr_children(e).into_iter().any(|c| expr(c, program)),
1342        }
1343    }
1344    body.statements.iter().any(|s| stmt(s, program)) || expr(&body.tail, program)
1345}
1346
1347/// P6.8 ([DECISION B]/[DECISION D]/[DECISION F], #1165): decide a handler
1348/// body's own one-of-three [`CommitShape`] from resolved data —
1349/// [`body_writes_state`]'s own write-detection walk, plus `emits` ([DECISION
1350/// D]: the caller's own `bynk_emit::emitter::block_uses_emit(body)` call, not
1351/// re-derived here — no `Callee` classification exists for `Events.emit` to
1352/// consume instead, see that decision's own grounding). Does not lower
1353/// `body` into an `IrExpr` tree first ([DECISION B]) — deciding the shape
1354/// only needs the two booleans, and lowering the whole body just to throw
1355/// the result away would be pure waste; a real `IrHandler.body` lowering is
1356/// a separate, not-yet-commissioned step (see [`bynk_ir::IrItem`]'s own
1357/// doc comment).
1358///
1359/// Shape-agnostic between an agent and a service handler ([DECISION F]) — no
1360/// `is_store_agent` flag: a service handler's own call site simply passes
1361/// empty `invariants`/`transitions` slices (a service has no `store` block
1362/// to declare them against), and [`body_writes_state`] naturally finds
1363/// neither a mutating `Callee::Store` nor a bare `:=` in a service body (no
1364/// store fields to write), so `Transactional` is never constructed for one —
1365/// matching the shipped emitter's own `emit_service`, which already only
1366/// ever produces the other two shapes.
1367pub fn lower_commit_shape_ir(
1368    body: &Block,
1369    invariants: &[IrPredicate],
1370    transitions: &[IrPredicate],
1371    emits: bool,
1372    program: &CheckedProgram,
1373) -> CommitShape {
1374    if body_writes_state(body, program.program()) {
1375        CommitShape::Transactional {
1376            invariants: invariants.to_vec(),
1377            transitions: transitions.to_vec(),
1378        }
1379    } else if emits {
1380        CommitShape::FlushEvents
1381    } else {
1382        CommitShape::ReadOnly
1383    }
1384}
1385
1386/// P6.11 ([DECISION C], #1171): reshape a `from Events(E { … })` pattern
1387/// into a real [`EventPatternIr`] — pure structural reshaping, no
1388/// `&CheckedProgram` parameter and no resolution: every field's own
1389/// matched value is either already a literal or an unresolved variant tag,
1390/// neither needing a `TyId`. `EventPatternValue::Literal` lowers through
1391/// the same `LiteralValue -> ConstVal` match [`lower_pattern_ir`] already
1392/// uses for `Pattern::Literal`'s identical closed set.
1393fn lower_event_pattern_ir(pattern: &EventPattern) -> EventPatternIr {
1394    EventPatternIr {
1395        fields: pattern
1396            .fields
1397            .iter()
1398            .map(|f| {
1399                let value = match &f.value {
1400                    EventPatternValue::Literal { value, .. } => {
1401                        EventPatternValueIr::Const(match value {
1402                            LiteralValue::Int(n) => ConstVal::Int(*n),
1403                            LiteralValue::Str(s) => ConstVal::Str(s.clone()),
1404                            LiteralValue::Bool(b) => ConstVal::Bool(*b),
1405                        })
1406                    }
1407                    EventPatternValue::Variant { variant, .. } => EventPatternValueIr::Variant {
1408                        tag: variant.name.clone(),
1409                    },
1410                };
1411                (f.name.name.clone(), value)
1412            })
1413            .collect(),
1414    }
1415}
1416
1417/// P6.11 ([DECISION A], #1171): lower a service's own `from <protocol>`
1418/// header into a real [`ProtocolIr`] — standalone, takes the sub-node
1419/// rather than the owning `ServiceDecl` (mirrors [`lower_store_field_ir`]),
1420/// so a `from websocket`/`from Events` fixture can pin the descriptor by
1421/// itself even where [`lower_service_handler_ir`] cannot yet lower every
1422/// handler on the same service (the WebSocket lifecycle-body deferral —
1423/// see that function's own doc comment).
1424///
1425/// `WebSocket`/`Events`'s own type refs resolve through the `Ty::Unit`
1426/// fallback, not an ADR 0334 panic — deliberately, and for the same reason
1427/// [`lower_agent_item_ir`]'s own `key_ty` does: `resolver.rs` skips
1428/// `CommonsItem::Service` in every one of its own type-ref-resolution
1429/// passes (`resolver.rs:303-304`/`493-494`/`577-578`), and the one checker
1430/// site that does resolve a WebSocket frame type itself falls back to
1431/// `Ty::Unit` on a miss rather than erroring (`context_checks.rs:775-778`).
1432/// Panicking here would make this the second ADR-0334 site in this module
1433/// asserting a guarantee the checker doesn't actually give — the first
1434/// being `lower_agent_item_ir`'s own `key_ty`, review of #1169.
1435pub fn lower_protocol_ir(protocol: &ServiceProtocol, program: &CheckedProgram) -> ProtocolIr {
1436    lower_protocol_ir_from_commons(protocol, program.program())
1437}
1438
1439/// P6.24a: a `TypedCommons`-only sibling of [`lower_protocol_ir`], the same
1440/// split `lower_op_sig_ir`/[`lower_op_sig_ir_from_commons`] already
1441/// established — for a call site holding only a unit's own `TypedCommons`,
1442/// never a `&CheckedProgram` (`emitter.rs`'s `emit_project_imports`, a
1443/// header-import-collection pass that runs well outside the per-declaration
1444/// emission loop any `CheckedProgram` is threaded through). Sound for the
1445/// identical reason: this function never calls `LowerIrCtx::expr_ty`, the
1446/// one method whose `.expect()`-panic needs a genuinely certified program.
1447pub fn lower_protocol_ir_from_commons(
1448    protocol: &ServiceProtocol,
1449    commons: &TypedCommons,
1450) -> ProtocolIr {
1451    let cx = LowerIrCtx::from_commons(commons, HashSet::new());
1452    match protocol {
1453        ServiceProtocol::Call => ProtocolIr::Call,
1454        ServiceProtocol::Http => ProtocolIr::Http,
1455        ServiceProtocol::Cron => ProtocolIr::Cron,
1456        ServiceProtocol::Queue { name } => ProtocolIr::Queue { name: name.clone() },
1457        ServiceProtocol::WebSocket { in_type, out_type } => ProtocolIr::WebSocket {
1458            in_ty: cx.resolve_type_ref(in_type).unwrap_or_else(|| cx.unit_ty()),
1459            out_ty: cx
1460                .resolve_type_ref(out_type)
1461                .unwrap_or_else(|| cx.unit_ty()),
1462        },
1463        ServiceProtocol::Events {
1464            event_type,
1465            pattern,
1466            schema_dispatch,
1467        } => ProtocolIr::Events {
1468            event: cx
1469                .resolve_type_ref(event_type)
1470                .unwrap_or_else(|| cx.unit_ty()),
1471            pattern: pattern.as_ref().map(lower_event_pattern_ir),
1472            schema_dispatch: schema_dispatch.as_ref().map(|d| {
1473                let bynk_syntax::ast::SchemaVersionPattern::Literal(version) = d.pattern;
1474                version
1475            }),
1476        },
1477    }
1478}
1479
1480/// P6.11 ([DECISION D], #1171): interpret a service's own `cors`/
1481/// `security`/`limits` blocks into a real [`PolicyIr`] — private and takes
1482/// no `&CheckedProgram`, deliberately: this function resolves no type,
1483/// reads no `expr_types`, and cannot panic, so threading a `&CheckedProgram`
1484/// through it just to satisfy this module's own "every entry point takes a
1485/// certified program" header rule would be cargo-culting a rule about
1486/// *which failures may panic*, not about which fields are read (the same
1487/// distinction [`lower_type_item_ir`]'s own doc comment already draws).
1488/// Precedent for a private, non-`&CheckedProgram` helper in this module:
1489/// [`body_writes_state`], `duration_millis_annotation`.
1490///
1491/// `None` whenever `service.protocol` is not `ServiceProtocol::Http` — the
1492/// checker itself gates all three blocks to HTTP only
1493/// (`bynk.http.cors_not_http`/`security_not_http`/`limits_not_http`,
1494/// `context_checks.rs`), so a `cors { }` block on, say, a `from cron`
1495/// service parses but never certifies as meaningful; not just when the
1496/// source declares none of the three blocks.
1497///
1498/// Every field here is exactly one already-shipped typed accessor's return
1499/// value (`CorsPolicy::origins()`/`credentials()`/`allow_headers()`/
1500/// `max_age_secs()`, `SecurityPolicy::nosniff()`/`hsts_max_age_secs()`,
1501/// `LimitsPolicy::max_body()`) — see [`bynk_ir::PolicyIr`]'s own doc
1502/// comment for why interpreting through these, not passing the raw AST
1503/// struct through, is the point of this constructor. The `security: None`
1504/// arm materialises `SecurityIr { nosniff: true, hsts_max_age_secs: None }`
1505/// — the shipped emitter's own already-established default
1506/// (`emitter/workers_entry.rs`), reproduced verbatim, not invented here.
1507fn lower_policy_ir(service: &ServiceDecl) -> Option<PolicyIr> {
1508    if !matches!(service.protocol, ServiceProtocol::Http) {
1509        return None;
1510    }
1511    Some(PolicyIr {
1512        cors: service.cors.as_ref().map(|p| CorsIr {
1513            origins: p.origins(),
1514            credentials: p.credentials(),
1515            allow_headers: p.allow_headers(),
1516            max_age_secs: p.max_age_secs(),
1517        }),
1518        security: match &service.security {
1519            Some(p) => SecurityIr {
1520                nosniff: p.nosniff(),
1521                hsts_max_age_secs: p.hsts_max_age_secs(),
1522            },
1523            None => SecurityIr {
1524                nosniff: true,
1525                hsts_max_age_secs: None,
1526            },
1527        },
1528        max_body_bytes: service.limits.as_ref().and_then(|p| p.max_body()),
1529    })
1530}
1531
1532/// #1228: a GET handler's own `@cache(maxAge:, scope:)` freshness policy —
1533/// [`bynk_ir::CacheIr`]'s own doc comment has the full grounding for why
1534/// this is a standalone reader rather than a `PolicyIr` field. Field-for-
1535/// field the same extraction `emitter/workers_entry.rs`'s own (now
1536/// superseded) `cache_policy_for` did: only a `GET` yields a policy;
1537/// project validation (`bynk.http.cache_*`) has already rejected a
1538/// `@cache` anywhere else, and a malformed `maxAge` there, so a missing or
1539/// ill-formed annotation here simply yields `None` — no `&CheckedProgram`
1540/// needed, the same posture `lower_policy_ir`'s own doc comment already
1541/// argues for: `maxAge`/`scope` are already-resolved syntactic literals
1542/// (`ExprKind::DurationLit`/`Ident`), not a type this pass would ever need
1543/// to resolve.
1544pub fn lower_route_cache_ir(h: &Handler) -> Option<CacheIr> {
1545    if !matches!(
1546        h.kind,
1547        HandlerKind::Http {
1548            method: HttpMethod::Get,
1549            ..
1550        }
1551    ) {
1552        return None;
1553    }
1554    let ann = h.annotations.iter().find(|a| a.name.name == "cache")?;
1555    let mut max_age_millis: Option<i64> = None;
1556    let mut scope = "private";
1557    for arg in &ann.args {
1558        match arg.label.as_ref().map(|l| l.name.as_str()) {
1559            Some("maxAge") => {
1560                if let ExprKind::DurationLit { millis, .. } = &arg.value.kind {
1561                    max_age_millis = Some(*millis);
1562                }
1563            }
1564            Some("scope") => {
1565                if let ExprKind::Ident(id) = &arg.value.kind
1566                    && id.name == "public"
1567                {
1568                    scope = "public";
1569                }
1570            }
1571            _ => {}
1572        }
1573    }
1574    Some(CacheIr {
1575        max_age_secs: max_age_millis? / 1000,
1576        scope,
1577    })
1578}
1579
1580/// #1228: a route's own `@limit(maxBody:)` annotation, if present — the
1581/// override half of `emitter/workers_entry.rs`'s own (now superseded)
1582/// `effective_max_body`; the service-wide `limits { maxBody }` fallback
1583/// stays that function's own concern (already IR-native via
1584/// `PolicyIr::max_body_bytes`, but read from a *service*, not a per-route
1585/// `Handler`, so it does not move here). Project validation
1586/// (`bynk.http.limit_*`/`limits_*`) has already rejected a malformed or
1587/// misplaced `@limit`, so an absent/ill-formed annotation here simply
1588/// yields `None` — the caller's own service-default fallback still
1589/// applies. No `&CheckedProgram` needed, same reasoning as
1590/// [`lower_route_cache_ir`]: `maxBody` is an already-resolved
1591/// `ExprKind::IntLit`, not a type.
1592pub fn lower_route_limit_ir(h: &Handler) -> Option<i64> {
1593    let ann = h.annotations.iter().find(|a| a.name.name == "limit")?;
1594    for arg in &ann.args {
1595        if arg.label.as_ref().map(|l| l.name.as_str()) == Some("maxBody")
1596            && let ExprKind::IntLit { value: n, .. } = &arg.value.kind
1597            && *n > 0
1598        {
1599            return Some(*n);
1600        }
1601    }
1602    None
1603}
1604
1605/// P6.10 (#1169): assemble an agent declaration into a real
1606/// [`bynk_ir::IrItem::Agent`] — wires every prior slice's own standalone
1607/// constructor ([`lower_store_field_ir`] since P6.7, [`lower_invariant_ir`]/
1608/// [`lower_transition_ir`]/[`lower_commit_shape_ir`] since P6.8,
1609/// [`lower_handler_ir`] since P6.9) rather than re-deriving any of their
1610/// logic — the first `IrItem` variant assembled from other already-real IR
1611/// data rather than lowered fresh from the AST by itself.
1612///
1613/// Computes `state`/`store_cells`/`state_ty` once and reuses them for every
1614/// downstream call that needs them — the "future `IrItem::Agent` builder"
1615/// every one of those constructors' own doc comments already named as the
1616/// job that would do this. `store_cells` is derived from `state`'s own
1617/// already-lowered [`StoreKindIr::Cell`] entries, not re-resolved
1618/// independently a second time from the AST — the one place this function
1619/// could have re-derived something it already has, and doesn't.
1620/// `invariants`/`transitions` are lowered once, then passed to *every*
1621/// handler's own [`lower_handler_ir`] call, not just the store-writing
1622/// ones: [`lower_commit_shape_ir`] only ever reads them for a
1623/// `Transactional` shape, so a read-only or event-flushing handler simply
1624/// carries the slices it was handed without using them, the same "pass
1625/// what a callee needs, let it decide whether to use it" posture
1626/// [`lower_commit_shape_ir`]'s own `emits` parameter already established.
1627pub fn lower_agent_item_ir(agent: &AgentDecl, program: &CheckedProgram) -> IrItem {
1628    let cx = LowerIrCtx::new(program, HashSet::new());
1629    // Not an ADR 0334 `.expect()`-style panic, deliberately: unlike a
1630    // handler param/return type, `agent.key_type` is never actually
1631    // resolution-checked by the checker at all — `resolver.rs` skips
1632    // `CommonsItem::Agent` in every one of its own `check_type_ref_resolves*`
1633    // passes, and `context_checks.rs:2854` itself falls back to `Ty::Unit`
1634    // on a miss rather than erroring (`agent Ledger { key id: Bogus … }`
1635    // certifies today, silently). Panicking here on a state the checker
1636    // itself accepts would make this the first ADR 0334 site in this module
1637    // to assert a guarantee that does not actually hold — mirror the
1638    // checker's own fallback instead (review of #1169).
1639    let key_ty = cx
1640        .resolve_type_ref(&agent.key_type)
1641        .unwrap_or_else(|| cx.unit_ty());
1642    let state: Vec<StoreFieldIr> = agent
1643        .store_fields
1644        .iter()
1645        .map(|f| lower_store_field_ir(f, program))
1646        .collect();
1647    let store_cells: HashMap<String, TyId> = state
1648        .iter()
1649        .filter_map(|f| match f.kind {
1650            StoreKindIr::Cell(ty) => Some((f.field.clone(), ty)),
1651            _ => None,
1652        })
1653        .collect();
1654    // P6.20-pre (review of #1240): only `Map` fields, not `Log`/`Set`/
1655    // `Cache`. The shipped emitter's own `is_agent_store_log`
1656    // (`emitter/lower.rs:4117`, "v0.95 ADR 0121") suggested `Log` belonged
1657    // here alongside `Map` — but `bynk-check` itself never special-cases
1658    // `StoreField::Log` in a bare-value or `FieldAccess` dispatch the way it
1659    // does `StoreField::Map` (`checker.rs:3477-3481` for the bare-value
1660    // case, `checker/expressions.rs:2592` for `.entries`/`.keys`/`.values`,
1661    // ADR 0184); grep confirms every other `StoreField::Log` site in
1662    // `bynk-check` is an unrelated `:=`-target check, a method dispatch, or
1663    // the table build. A bare `store Log` field used as a value is
1664    // therefore not checker-legal today (`bynk.resolve.unknown_name` on a
1665    // certified program) — the same bucket `Set`/`Cache` are already in,
1666    // for the same reason: leaving it out of this table and falling through
1667    // to `lower_ident_ir`'s own `todo!()` is correct, structurally
1668    // unreachable rather than merely unhandled. If a future checker slice
1669    // makes a bare `Log` value legal, this table (and `ir.rs`'s own
1670    // `StoreQuery` doc comment) need revisiting alongside it.
1671    let store_queryable: HashSet<String> = state
1672        .iter()
1673        .filter_map(|f| match f.kind {
1674            StoreKindIr::Map(_, _) => Some(f.field.clone()),
1675            _ => None,
1676        })
1677        .collect();
1678    let state_ty = program.program().ty_intern.intern(Ty::Named {
1679        name: format!("{}State", agent.name.name),
1680        kind: checker::NamedKind::Record,
1681        args: Vec::new(),
1682    });
1683    let invariants: Vec<IrPredicate> = agent
1684        .invariants
1685        .iter()
1686        .map(|inv| lower_invariant_ir(inv, &store_cells, program))
1687        .collect();
1688    let transitions: Vec<IrPredicate> = agent
1689        .transitions
1690        .iter()
1691        .map(|tr| lower_transition_ir(tr, state_ty, program))
1692        .collect();
1693    let handlers: Vec<IrHandler> = agent
1694        .handlers
1695        .iter()
1696        .map(|h| {
1697            lower_handler_ir(
1698                h,
1699                &store_cells,
1700                &store_queryable,
1701                state_ty,
1702                &invariants,
1703                &transitions,
1704                program,
1705            )
1706        })
1707        .collect();
1708    IrItem::Agent {
1709        def: agent.name.name.clone(),
1710        key: (agent.key_name.name.clone(), key_ty),
1711        state,
1712        handlers,
1713        invariants,
1714        transitions,
1715    }
1716}
1717
1718/// P6.11 (#1171): assemble a service declaration into a real
1719/// [`bynk_ir::IrItem::Service`] — structural mirror of
1720/// [`lower_agent_item_ir`], wiring [`lower_protocol_ir`],
1721/// [`lower_service_handler_ir`] and `lower_policy_ir` rather than
1722/// re-deriving any of their logic. Unlike the agent case, there is no
1723/// shared per-declaration context to compute once: a service has no
1724/// `store_cells`/`state_ty`/invariants/transitions (none of those concepts
1725/// exist for a service at all), so `&service.protocol`, already on the
1726/// declaration, is threaded to each handler call directly — the "compute
1727/// once, reuse" step [`lower_agent_item_ir`]'s own doc comment describes
1728/// degenerates to a borrow here, not because this function skips a step,
1729/// but because a service's own handlers have nothing else to share.
1730///
1731/// **No default-`by`/default-`given` inheritance logic, deliberately.**
1732/// `project_model::inject_service_defaults` (`bynk-check/src/project_model.rs`)
1733/// already mutated `handler.by_clause`/`handler.given` in place at pipeline
1734/// phase 2b (`bynk-check/src/analysis.rs`), overriding — not merging —
1735/// before `check_service_decls`, let alone this pass, ever runs. `h.by_clause`/
1736/// `h.given` are already final by the time [`lower_service_handler_ir`] sees
1737/// them; this is stated so a future reader who sees `ServiceDecl::default_by`/
1738/// `default_given` unread by this function knows that is correct, not an
1739/// omission.
1740pub fn lower_service_item_ir(service: &ServiceDecl, program: &CheckedProgram) -> IrItem {
1741    IrItem::Service {
1742        def: service.name.name.clone(),
1743        protocol: lower_protocol_ir(&service.protocol, program),
1744        handlers: service
1745            .handlers
1746            .iter()
1747            .map(|h| lower_service_handler_ir(h, &service.protocol, program))
1748            .collect(),
1749        policy: lower_policy_ir(service),
1750    }
1751}
1752
1753/// P6.47 (design/tracks/the-ir.md §6b): every `from Events(E)` service in
1754/// `program`'s own unit, captured as an [`bynk_ir::EventSubscriberShape`]
1755/// keyed by service name — see that struct's own doc comment for why this is
1756/// captured now rather than re-derived cross-unit at compose time. Absorbs
1757/// the `ServiceProtocol::Events` pre-filter this function's own single call
1758/// site used to apply externally: [`lower_service_item_ir`] unconditionally
1759/// lowers every handler's own *body* (not just its declared shape), so the
1760/// guard stays first here too — a cheap, structural pre-filter (which
1761/// services even have a shape to capture), not a resurrected raw-AST *read*
1762/// — before paying for a full lowering pass on a matching service. Safe as
1763/// of #1254: a `catch_unwind` probe wrapping [`lower_service_item_ir`] across
1764/// the entire e2e fixture corpus found zero panics, down from ~51 when the
1765/// P6.23 investigation first ran.
1766pub fn lower_event_subscriber_shapes_ir(
1767    program: &CheckedProgram,
1768) -> HashMap<String, EventSubscriberShape> {
1769    let mut out = HashMap::new();
1770    for item in &program.program().commons.items {
1771        if let CommonsItem::Service(s) = item
1772            && matches!(&s.protocol, ServiceProtocol::Events { .. })
1773        {
1774            let IrItem::Service {
1775                protocol:
1776                    ProtocolIr::Events {
1777                        schema_dispatch, ..
1778                    },
1779                handlers,
1780                ..
1781            } = lower_service_item_ir(s, program)
1782            else {
1783                panic!(
1784                    "bynk internal error: lower_service_item_ir did not return \
1785                     IrItem::Service{{ protocol: ProtocolIr::Events, .. }} for a service \
1786                     whose own AST protocol is ServiceProtocol::Events"
1787                )
1788            };
1789            let two_param_handler = handlers
1790                .iter()
1791                .find(|h| matches!(h.kind, IrHandlerKind::Event))
1792                .is_some_and(|h| h.params.len() == 2);
1793            out.insert(
1794                s.name.name.clone(),
1795                EventSubscriberShape {
1796                    two_param_handler,
1797                    schema_dispatch: schema_dispatch.is_some(),
1798                },
1799            );
1800        }
1801    }
1802    out
1803}
1804
1805/// P6.12 (#1173): assemble a capability declaration into a real
1806/// [`bynk_ir::IrItem::Capability`] — structural mirror of
1807/// [`lower_service_item_ir`]/[`lower_agent_item_ir`], but with nothing to
1808/// compute once and share: `CapabilityDecl` carries no state/invariants/
1809/// transitions/protocol of its own, only `ops`, so each op lowers
1810/// independently through `lower_op_sig_ir`.
1811pub fn lower_capability_item_ir(cap: &CapabilityDecl, program: &CheckedProgram) -> IrItem {
1812    IrItem::Capability {
1813        def: cap.name.name.clone(),
1814        ops: cap
1815            .ops
1816            .iter()
1817            .map(|op| lower_op_sig_ir(op, program))
1818            .collect(),
1819    }
1820}
1821
1822/// P6.29 (design/tracks/the-ir.md §6a): the `TypedCommons`-only counterpart to
1823/// [`lower_capability_item_ir`], for call sites (`emitter/lower.rs`'s
1824/// `cap_op_param_names`) that have a `TypedCommons` in hand but no
1825/// `CheckedProgram` — `LowerCtx`/`ModuleCtx` never carry one (see
1826/// [`lower_op_sig_ir_from_commons`], this function's own single-op sibling,
1827/// for the identical reason it exists as a separate entry point rather than a
1828/// thin wrapper over the `CheckedProgram`-driven `lower_op_sig_ir`).
1829///
1830/// Resolves one capability operation's signature by name — "find the op
1831/// named `op` on the capability named `cap`" has no IR-native replacement
1832/// (nothing indexes capabilities by name once lowered), so this still walks
1833/// `TypedCommons::commons.items` the same way the code it replaces did.
1834/// First match in item order; `None` on no match, mirroring the caller's own
1835/// prior fallthrough-to-empty behaviour exactly.
1836pub fn capability_op_sig_from_commons(
1837    commons: &TypedCommons,
1838    cap: &str,
1839    op: &str,
1840) -> Option<OpSig> {
1841    commons.commons.items.iter().find_map(|item| {
1842        let CommonsItem::Capability(c) = item else {
1843            return None;
1844        };
1845        if c.name.name != cap {
1846            return None;
1847        }
1848        c.ops
1849            .iter()
1850            .find(|o| o.name.name == op)
1851            .map(|o| lower_op_sig_ir_from_commons(o, commons))
1852    })
1853}
1854
1855/// P6.12 (#1173): lower one capability operation's own signature into a real
1856/// [`bynk_ir::OpSig`] — the reference's own `IrItem::Capability` sketch
1857/// names this type (`ops: Vec<OpSig>`) but never defines it. Resolves
1858/// `params`/`return_ty` in the scope `op.type_params` names, the same
1859/// per-op rigid-variable seeding `context_checks::build_capability_op_info`
1860/// (`bynk-check/src/context_checks.rs`) already gives a generic op for the
1861/// checker-facing `CapabilityOpInfo` — an op's own `[T, …]` list is scoped to
1862/// the op itself, not the capability (`CapabilityDecl` carries no
1863/// `type_params` of its own), so this seeds a fresh [`LowerIrCtx`] per op
1864/// rather than reusing `fn_rigid_type_vars`'s fn/method-shaped
1865/// receiver-widening, which does not apply here.
1866///
1867/// **Not an ADR 0334 `.expect()`-style panic on a resolve miss,
1868/// deliberately** — the same posture [`lower_agent_item_ir`]'s own `key_ty`
1869/// doc comment already argues for `agent.key_type`, and for the identical
1870/// reason: a capability op's own `params`/`return_type` are never actually
1871/// resolution-checked by the checker at all. The resolver skips
1872/// `CommonsItem::Capability` outright (`resolver.rs:301/491/575`, "v0.5
1873/// items are resolved via a separate context-level pass"); the context-level
1874/// pass that replaces it, `check_capability_decls`, only calls
1875/// `checker::record_type_refs`, which silently does nothing on a name absent
1876/// from `types` rather than erroring (`checker.rs:2593-2597`); and
1877/// `build_capability_op_info` itself, the checker-facing constructor this
1878/// pass mirrors, degrades to `Ty::Unit` on the same miss rather than
1879/// treating it as impossible (`context_checks.rs:36,40`). `capability Store
1880/// { fn get(k: Bogus) -> Effect[Int] }` certifies today, silently. Panicking
1881/// here on a state the checker itself accepts would make this the first ADR
1882/// 0334 site in this module to assert a guarantee that does not actually
1883/// hold — mirror the checker's own fallback instead (review of #1182).
1884fn lower_op_sig_ir(op: &CapabilityOp, program: &CheckedProgram) -> OpSig {
1885    lower_op_sig_ir_from_commons(op, program.program())
1886}
1887
1888/// #1187's own closing scoping pass: a `TypedCommons`-only sibling of
1889/// `lower_op_sig_ir`, for the one real call site that never has a
1890/// `&CheckedProgram` — `emitter/lower.rs`'s `cap_op_param_names`, feeding
1891/// `trace(Cap.op)`/`with`-predicate observation lowering
1892/// (`bynk.test`'s DSL). That call path's own `TypedCommons` is a synthetic,
1893/// hand-assembled project-wide view (`project/tests_emit.rs`'s
1894/// `synthetic_typed_commons_for_target`, merging every consumed unit's own
1895/// `capability` declarations into one scratch commons for lookup) — never
1896/// itself the output of `certify`, so wrapping it as a `CheckedProgram`
1897/// here would misrepresent an uncertified value as certified
1898/// (`CheckedProgram`'s own doc comment, `bynk-check/src/checker.rs`, warns
1899/// against exactly this). Splitting this out is sound precisely because
1900/// this function never calls `LowerIrCtx::expr_ty` — the one method whose
1901/// `.expect()`-panic needs a genuinely certified program, the reason this
1902/// module's own file-level doc comment gives for taking `&CheckedProgram`
1903/// everywhere else. `resolve_type_ref`/`unit_ty()` (below) both degrade via
1904/// `.unwrap_or_else` and read nothing `TypedCommons` doesn't already expose
1905/// directly.
1906pub fn lower_op_sig_ir_from_commons(op: &CapabilityOp, commons: &TypedCommons) -> OpSig {
1907    let type_vars: HashSet<String> = op
1908        .type_params
1909        .iter()
1910        .map(|tp| tp.name.name.clone())
1911        .collect();
1912    let cx = LowerIrCtx::from_commons(commons, type_vars);
1913    let params: Vec<(String, TyId)> = op
1914        .params
1915        .iter()
1916        .map(|p| {
1917            let ty = cx
1918                .resolve_type_ref(&p.type_ref)
1919                .unwrap_or_else(|| cx.unit_ty());
1920            (p.name.name.clone(), ty)
1921        })
1922        .collect();
1923    let return_ty = cx
1924        .resolve_type_ref(&op.return_type)
1925        .unwrap_or_else(|| cx.unit_ty());
1926    OpSig {
1927        name: op.name.name.clone(),
1928        type_params: op
1929            .type_params
1930            .iter()
1931            .map(|tp| tp.name.name.clone())
1932            .collect(),
1933        params,
1934        return_ty,
1935    }
1936}
1937
1938/// P6.18: [`bynk_ir::FnSig`]'s own constructor — a `fn`'s own resolved
1939/// signature, for a call site holding only that fn's *declaring* unit's own
1940/// combined types (`bynk_check::symbols::combined_types_for`'s return shape),
1941/// never a `CheckedProgram`. The one real call site
1942/// (`bynk-emit/src/project.rs`'s `build_emit_unit_ctx`) reads a `uses`-
1943/// imported *foreign* unit's own attached methods, whose own `CheckedProgram`
1944/// does not survive past that unit's own `check_unit_files` iteration — the
1945/// same "dropped before any later, project-wide pass runs" shape
1946/// `unit_callees` (#1202)/`EventSubscriberShape` (#1232) both work around,
1947/// except here no project-wide accumulator is needed at all: unlike a
1948/// `Callee`/event-subscriber-shape classification (checker-only facts, never
1949/// re-derivable from raw declarations alone), a fn signature's own
1950/// `params`/`return_type` are ordinary type references, resolvable from that
1951/// unit's own declared types the same way [`lower_op_sig_ir_from_commons`]
1952/// already resolves a capability op's — so a bare types map is sufficient,
1953/// the same non-`CheckedProgram` scope that function already established.
1954///
1955/// Only the method's own `[T, …]` list seeds the rigid-variable scope, not
1956/// its generic receiver's — the one real caller (`emit_forwarded_methods`)
1957/// never renders `self`'s own type through this value at all (it takes the
1958/// *consumer* context's own rebranded type name directly), so resolving
1959/// `fn_receiver_ty` here would be dead work.
1960pub fn lower_fn_sig_ir_from_types(
1961    f: &FnDecl,
1962    types: &HashMap<String, Arc<TypeDecl>>,
1963    tys: &Types,
1964) -> FnSig {
1965    let type_vars: HashSet<String> = f
1966        .type_params
1967        .iter()
1968        .map(|tp| tp.name.name.clone())
1969        .collect();
1970    let unit_ty = || tys.intern(Ty::Unit);
1971    let params: Vec<(String, TyId)> = f
1972        .params
1973        .iter()
1974        .map(|p| {
1975            let ty = checker::resolve_type_ref_in(&p.type_ref, types, &type_vars, tys)
1976                .unwrap_or_else(unit_ty);
1977            (p.name.name.clone(), ty)
1978        })
1979        .collect();
1980    let return_ty = checker::resolve_type_ref_in(&f.return_type, types, &type_vars, tys)
1981        .unwrap_or_else(unit_ty);
1982    let name = match &f.name {
1983        FnName::Method { method_name, .. } => method_name.name.clone(),
1984        FnName::Free(id) => id.name.clone(),
1985    };
1986    FnSig {
1987        name,
1988        has_self: f.has_self,
1989        params,
1990        return_ty,
1991    }
1992}
1993
1994/// P6.x (#1137): [`lower_fn_sig_ir_from_types`] over an entire
1995/// [`MethodTable`]'s own instance + static entries — the attached-method
1996/// gathering [`bynk-emit`'s `build_emit_unit_ctx`] needs for a `uses`-imported
1997/// type. Filters to [`FnName::Method`] before lowering: `ResolverMethodTable`
1998/// only ever collects attached methods in practice (`bynk-check/src/resolver.rs`'s
1999/// own doc comment on [`MethodTable`]), but the filter stays as a defensive
2000/// match rather than an assumption, matching the caller's own pre-existing
2001/// posture one step earlier — this just moves that posture in front of the
2002/// lowering call instead of behind it, so the `FnName` read (and the filter
2003/// itself) never has to leave this module.
2004pub fn lower_attached_fn_sig_ir_from_types(
2005    mt: &MethodTable,
2006    types: &HashMap<String, Arc<TypeDecl>>,
2007    tys: &Types,
2008) -> Vec<FnSig> {
2009    mt.instance
2010        .values()
2011        .chain(mt.statics.values())
2012        .filter(|f| matches!(f.name, FnName::Method { .. }))
2013        .map(|f| lower_fn_sig_ir_from_types(f, types, tys))
2014        .collect()
2015}
2016
2017/// P6.24a: pure, unconditional [`HandlerKind`] → [`IrHandlerKind`]
2018/// conversion — every field is already fully resolved at parse time, so
2019/// unlike almost every other function in this module this one takes no
2020/// `&CheckedProgram`/`&TypedCommons` at all and can never miss.
2021pub fn lower_handler_kind_ir(k: &HandlerKind) -> IrHandlerKind {
2022    match k {
2023        HandlerKind::Call => IrHandlerKind::Call,
2024        HandlerKind::Http { method, path } => IrHandlerKind::Http {
2025            method: lower_http_method_ir(*method),
2026            path: path.clone(),
2027        },
2028        HandlerKind::Cron { expr } => IrHandlerKind::Cron { expr: expr.clone() },
2029        HandlerKind::Message => IrHandlerKind::Message,
2030        HandlerKind::Open => IrHandlerKind::Open,
2031        HandlerKind::Close => IrHandlerKind::Close,
2032        HandlerKind::Event => IrHandlerKind::Event,
2033    }
2034}
2035
2036/// [`lower_handler_kind_ir`]'s own `HttpMethod` half.
2037fn lower_http_method_ir(m: HttpMethod) -> IrHttpMethod {
2038    match m {
2039        HttpMethod::Get => IrHttpMethod::Get,
2040        HttpMethod::Post => IrHttpMethod::Post,
2041        HttpMethod::Put => IrHttpMethod::Put,
2042        HttpMethod::Patch => IrHttpMethod::Patch,
2043        HttpMethod::Delete => IrHttpMethod::Delete,
2044    }
2045}
2046
2047/// P6.14 (#1174): assemble a provider declaration into a real
2048/// [`bynk_ir::IrItem::Provider`] — reads `ProviderDecl::external`
2049/// straight into [`ProviderBody`]'s own `Bynk`/`External` dispatch
2050/// ([`bynk_ir::IrItem`]'s own doc comment has the full grounding for why
2051/// this, unlike `Actor`, was buildable this slice). `external: true` means
2052/// `ops` is empty by the field's own doc comment
2053/// (`bynk-syntax/src/ast.rs:592-595`) — nothing to lower. `given` lowers
2054/// unconditionally via [`lower_provider_given_ir`] (#1187's Provider
2055/// `given`/deps-wiring slice, fixing a real gap the `External` variant's own
2056/// bare-unit shape used to leave: `provider.given` is populated the same way
2057/// regardless of `external`, and [`ProviderBody::Bynk`]'s own doc comment
2058/// names why it, unlike `module`, is not deferrable — that reasoning always
2059/// applied to `External` too, just wasn't wired through).
2060pub fn lower_provider_item_ir(provider: &ProviderDecl, program: &CheckedProgram) -> IrItem {
2061    let given = lower_provider_given_ir(provider);
2062    let body = if provider.external {
2063        ProviderBody::External { given }
2064    } else {
2065        ProviderBody::Bynk {
2066            given,
2067            ops: provider
2068                .ops
2069                .iter()
2070                .map(|op| lower_provider_op_ir(op, program))
2071                .collect(),
2072        }
2073    };
2074    IrItem::Provider {
2075        def: provider.provider_name.name.clone(),
2076        cap: provider.capability.name.clone(),
2077        body,
2078    }
2079}
2080
2081/// A provider's own `given` clause, resolved independent of
2082/// [`ProviderBody`]'s `external`/`Bynk` dispatch and independent of
2083/// [`lower_provider_item_ir`]'s own full assembly (#1187's Provider
2084/// `given`/deps-wiring slice) — the standalone entry point
2085/// `bynk-emit/src/project.rs`'s `instantiate_provider_ts_expr` actually calls.
2086/// Building a real `IrItem::Provider` there would still need care for a
2087/// `Bynk` provider specifically: `ProviderBody::Bynk::ops` unconditionally
2088/// lowers every op's body through `lower_provider_op_ir` → `lower_expr_ir`,
2089/// which does not yet handle every expression shape a real op body can
2090/// contain. **Correction (P6.25, 2026-08-19): `?` propagation
2091/// (`ExprKind::Question`) and an `is`-expression (`ExprKind::Is`) are no
2092/// longer among those gaps** — both landed (P6.15/ADR 0337, P6.16/ADR 0338,
2093/// following #1225's own `Ok`/`Err`/`Some`/`None` construction fix) — but
2094/// `lower_expr_ir` still has two production-reachable `todo!()`s
2095/// (`lower_call_ir`'s missing-`Callee` guard and a bare ident naming a free
2096/// fn used as a value, P6.2 territory), so a real op body is not yet
2097/// unconditionally safe to build; the risk has just narrowed. This function
2098/// never touches `ops`/bodies, so it carries none of that risk;
2099/// [`lower_provider_item_ir`] itself now calls it too, rather than
2100/// hand-duplicating the one-line `map`.
2101pub fn lower_provider_given_ir(provider: &ProviderDecl) -> Vec<CapRefIr> {
2102    provider.given.iter().map(lower_cap_ref_ir).collect()
2103}
2104
2105/// #1187's slice 6 plumbing (sibling of [`lower_provider_given_ir`]): a
2106/// handler's own `given` clause, resolved independent of any full
2107/// `IrHandler`/`IrItem` assembly — the standalone entry point for
2108/// `project.rs`'s `plan_agent_given_deps`, `EmitProjectCtx::
2109/// agent_method_givens`, and `emitter/workers.rs`'s own `given` collection.
2110/// Reuses `lower_cap_ref_ir` verbatim; a handler's `given` is syntactically
2111/// identical to a provider's (`bynk_syntax::ast::CapRef`), so this is the
2112/// same one-line adapter, not a new design.
2113pub fn lower_handler_given_ir(h: &Handler) -> Vec<CapRefIr> {
2114    h.given.iter().map(lower_cap_ref_ir).collect()
2115}
2116
2117/// #1187's slice 3: a handler's resolved actor-verification seam — the same
2118/// "narrow, standalone reader of already-resolved data" precedent
2119/// [`body_writes_state`]/[`lower_service_handler_signature_ir`] established,
2120/// applied to `bynk-check`'s own five actor-seam resolvers
2121/// (`bynk-check/src/actors.rs`) instead of a full `IrHandler` assembly.
2122/// [`ActorSeamIr`]'s own doc comment has the full grounding for the
2123/// priority order and for the deliberately-missing `Signature` variant.
2124///
2125/// Replaces the hand-duplicated "try N resolvers, branch on which
2126/// returned `Some`" call sites this slice converts: `emit_service`
2127/// (`emitter/emit.rs`) and `emit_worker_compose`'s HTTP-dispatch match
2128/// (`emitter/workers.rs`). Deliberately does **not** yet replace every
2129/// caller of the five resolvers — `secrets.rs`'s `declared_secrets` unions
2130/// *all* matching seams' secrets rather than picking one (a different
2131/// shape this enum doesn't model), and the remaining call sites
2132/// (`emitter/workers_entry.rs`, `emitter/workers.rs`'s other two sites,
2133/// `emitter/emit.rs`'s `any_service_binds_caller`/`emit_make_surface`/
2134/// `ws_open_hosts_for`, `project/tests_emit.rs`) each call exactly one
2135/// resolver with nothing to collapse against — converting them to build a
2136/// five-variant enum just to immediately match out one arm would add
2137/// indirection without removing any real duplication.
2138pub fn lower_actor_seam_ir(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> ActorSeamIr {
2139    if let Some(members) = bynk_check::actors::sum_members_for(handler, actors) {
2140        return ActorSeamIr::Sum(members);
2141    }
2142    if let Some(seam) = bynk_check::actors::bearer_seam_for(handler, actors) {
2143        return ActorSeamIr::Bearer(seam);
2144    }
2145    if let Some(seam) = bynk_check::actors::oidc_seam_for(handler, actors) {
2146        return ActorSeamIr::Oidc(seam);
2147    }
2148    if let Some(binder) = bynk_check::actors::caller_binder_for(handler, actors) {
2149        return ActorSeamIr::Caller(binder);
2150    }
2151    ActorSeamIr::None
2152}
2153
2154/// P6.14 (#1174, review of #1186): adapt one `given` entry into a real
2155/// [`bynk_ir::CapRefIr`] — [`CapRefIr`]'s own doc comment has the full
2156/// grounding for the `QualifiedName -> String` flattening and for why a
2157/// `Some` prefix is preserved unresolved.
2158fn lower_cap_ref_ir(cap_ref: &CapRef) -> CapRefIr {
2159    CapRefIr {
2160        context: cap_ref.context.as_ref().map(QualifiedName::joined),
2161        name: cap_ref.name.name.clone(),
2162    }
2163}
2164
2165/// P6.14 (#1174): lower one provider operation's own signature *and* body
2166/// into a real [`bynk_ir::ProviderOpIr`] — a new sibling of
2167/// [`lower_fn_body_ir`], not a widening of it, seeding a scope with just
2168/// this op's own `params` (no `self`, no store cells: `check_provider_decls`
2169/// checks every op via `checker::check_handler_body` with
2170/// `HandlerBodyCheck::new`'s own "everything optional empty" default,
2171/// `bynk-check/src/checker.rs:1055` — a provider op's `given` capabilities
2172/// need no scope entry of their own, resolved the same already-generic
2173/// `Callee`-wrapping `lower_handler_body_ir`'s own doc comment credits for
2174/// handler bodies). No rigid type variables: `ProviderOp` carries no
2175/// `type_params` of its own ([`bynk_ir::ProviderOpIr`]'s own doc comment
2176/// has the full contrast with [`OpSig::type_params`]).
2177///
2178/// **ADR 0334 panic-on-miss, not `lower_op_sig_ir`'s lenient `Ty::Unit`
2179/// fallback** — unlike a bare `CapabilityOp` signature, a `ProviderOp`'s
2180/// `params`/`return_type` are resolved for real by `check_handler_body`
2181/// (the same machinery a fn body or handler body goes through), so a
2182/// resolve miss here is exactly the "checker accepted this, this pass
2183/// disagrees" internal-error case [`lower_fn_item_ir`]'s own panics guard
2184/// against, not a state the checker is known to accept silently.
2185fn lower_provider_op_ir(op: &ProviderOp, program: &CheckedProgram) -> ProviderOpIr {
2186    let mut cx = LowerIrCtx::new(program, HashSet::new());
2187    // Review of #1238: kept as a graceful degrade, not the panic this
2188    // function's own params still use just below (that policy stands for
2189    // params — see this function's own doc comment) — set_return_ty's own
2190    // `None` case exists specifically so a `?`-adjacent metadata miss never
2191    // crashes a body that does not even contain a `?`, and mixing panic/
2192    // degrade across the four `set_return_ty` call sites for the same field
2193    // is a hazard of its own, not a benefit.
2194    cx.set_return_ty(cx.resolve_type_ref(&op.return_type));
2195    let params: Vec<(String, TyId)> = op
2196        .params
2197        .iter()
2198        .map(|p| {
2199            let ty = cx.resolve_type_ref(&p.type_ref).unwrap_or_else(|| {
2200                panic!(
2201                    "bynk internal error (ADR 0334): parameter `{}`'s type does not resolve in \
2202                     this pass's own scope, but the checker already accepted this provider op's \
2203                     body via check_handler_body — bynk_lower's resolution disagrees \
2204                     with bynk-check's",
2205                    p.name.name
2206                )
2207            });
2208            cx.bind(p.name.name.clone(), ty);
2209            (p.name.name.clone(), ty)
2210        })
2211        .collect();
2212    let return_ty = cx.resolve_type_ref(&op.return_type).unwrap_or_else(|| {
2213        panic!(
2214            "bynk internal error (ADR 0334): the return type of provider op `{}` does not \
2215             resolve in this pass's own scope, but the checker already accepted this provider \
2216             op's body via check_handler_body",
2217            op.name.name
2218        )
2219    });
2220    let body = wrap_body_return(lower_block_ir(&op.body, &mut cx));
2221    ProviderOpIr {
2222        name: op.name.name.clone(),
2223        params,
2224        return_ty,
2225        body,
2226    }
2227}
2228
2229/// Lower a block as a value — no `Return` wrapping (see
2230/// [`lower_fn_body_ir`]'s doc comment for the distinction). A block's own
2231/// type is always its tail's type.
2232pub fn lower_block_ir(block: &Block, cx: &mut LowerIrCtx) -> IrExpr {
2233    cx.push_scope();
2234    let stmts: Vec<IrStmt> = block
2235        .statements
2236        .iter()
2237        .map(|s| lower_stmt_ir(s, cx))
2238        .collect();
2239    let tail = lower_expr_ir(&block.tail, cx);
2240    cx.pop_scope();
2241    let ty = tail.ty;
2242    IrExpr {
2243        kind: IrExprKind::Block {
2244            stmts,
2245            tail: Box::new(tail),
2246        },
2247        ty,
2248        span: block.span,
2249    }
2250}
2251
2252fn lower_stmt_ir(s: &Statement, cx: &mut LowerIrCtx) -> IrStmt {
2253    match s {
2254        Statement::Let(l) => {
2255            let value = lower_expr_ir(&l.value, cx);
2256            // The checker binds the *annotation's* type when one is present
2257            // (`type_of_block`'s `Statement::Let` arm, `checker.rs:2829-2853`)
2258            // — `compatible()` admits a refined value under its base's
2259            // annotation, so the bound type can genuinely differ from the
2260            // RHS expression's own recorded type. `value.ty` stays the RHS's
2261            // own honest type (R6.1, this expression's real checked type);
2262            // only this pass's own scope bookkeeping (read back by a later
2263            // shorthand-field lookup) uses the annotation-preferred one, the
2264            // same distinction the checker itself draws between an
2265            // expression's type and a binding's type.
2266            let bound_ty = l.type_annot.as_ref().map_or(value.ty, |a| {
2267                cx.resolve_type_ref(a).unwrap_or_else(|| {
2268                    panic!(
2269                        "bynk internal error (ADR 0334): `let` annotation for `{}` does not \
2270                         resolve in this pass's own rigid-variable scope, but the checker \
2271                         already accepted this binding",
2272                        l.name.name
2273                    )
2274                })
2275            });
2276            // Mirrors `checker.rs:2854`: `_` is never bound.
2277            if l.name.name != "_" {
2278                cx.bind(l.name.name.clone(), bound_ty);
2279            }
2280            IrStmt::Let {
2281                local: l.name.name.clone(),
2282                value,
2283            }
2284        }
2285        Statement::EffectLet(l) => {
2286            let effect = lower_expr_ir(&l.value, cx);
2287            let span = effect.span;
2288            let ty = cx.peel_effect(effect.ty);
2289            // Same annotation-vs-RHS distinction as `Statement::Let` above,
2290            // peeled through `Effect[_]` (`checker.rs:2894-2953`'s own
2291            // `EffectLet` arm).
2292            let bound_ty = l.type_annot.as_ref().map_or(ty, |a| {
2293                cx.resolve_type_ref(a).unwrap_or_else(|| {
2294                    panic!(
2295                        "bynk internal error (ADR 0334): `let <-` annotation for `{}` does not \
2296                         resolve in this pass's own rigid-variable scope, but the checker \
2297                         already accepted this binding",
2298                        l.name.name
2299                    )
2300                })
2301            });
2302            if l.name.name != "_" {
2303                cx.bind(l.name.name.clone(), bound_ty);
2304            }
2305            IrStmt::Let {
2306                local: l.name.name.clone(),
2307                value: IrExpr {
2308                    kind: IrExprKind::Await {
2309                        effect: Box::new(effect),
2310                    },
2311                    ty,
2312                    span,
2313                },
2314            }
2315        }
2316        Statement::Send(send) => {
2317            let effect = lower_expr_ir(&send.value, cx);
2318            let span = effect.span;
2319            IrStmt::Expr {
2320                value: IrExpr {
2321                    kind: IrExprKind::Send {
2322                        effect: Box::new(effect),
2323                    },
2324                    ty: cx.unit_ty(),
2325                    span,
2326                },
2327            }
2328        }
2329        Statement::Do(d) => {
2330            let effect = lower_expr_ir(&d.value, cx);
2331            let span = effect.span;
2332            let ty = cx.peel_effect(effect.ty);
2333            IrStmt::Expr {
2334                value: IrExpr {
2335                    kind: IrExprKind::Await {
2336                        effect: Box::new(effect),
2337                    },
2338                    ty,
2339                    span,
2340                },
2341            }
2342        }
2343        // Not named by any P6.x rule (design/tracks/the-ir.md §6) — test-only,
2344        // no IR target proposed anywhere in this track.
2345        Statement::Expect(_) => todo!(
2346            "Statement::Expect has no IrStmt target — not named by any rule this track commissions"
2347        ),
2348        // `cell := expr` — the unconditional `Cell` write ([DECISION B],
2349        // P6.9, #1167). `checker.rs`'s own `Statement::Assign` arm resolves
2350        // `a.target.name` directly against `ctx.store_fields` by bare name
2351        // and never keys a `Callee` at all (only `a.value`, an ordinary
2352        // sub-expression, ever gets one) — no `ExprId`-keyed sink was ever
2353        // actually necessary, on a premise two prior slices (P6.7/P6.8) held
2354        // without re-checking it. `field` is this module's usual "no arena"
2355        // substitution; `value` lowers through the ordinary
2356        // [`lower_expr_ir`] path unchanged.
2357        Statement::Assign(a) => IrStmt::Assign {
2358            field: a.target.name.clone(),
2359            value: lower_expr_ir(&a.value, cx),
2360        },
2361    }
2362}
2363
2364/// P6.15 (design/tracks/the-ir.md §6): `?`'s real IR desugar — a genuine
2365/// design fork the reference's own sketch (`Question(e)` → `Match{scrutinee:
2366/// e, arms: [Ok(v) => v, Err(e) => Return(Err(convert(e)))]}`) gets half
2367/// right. Two of the shipped desugar's three shapes (`emitter/lower.rs:
2368/// 1014-1061`, ADR 0177/ADR 0178) really are exactly that `Match{Ok, Err}`
2369/// shape, `convert` either the identity (bare propagation) or a real
2370/// `embeds` conversion — the reference's sketch already covers those. The
2371/// third, `Option[T]?`, does not: it is a `Match{Some, None}`, not
2372/// `Match{Ok, Err}`, and its early-return value is the synthesized
2373/// `HttpResult.NotFound` sentinel (`IrExprKind::HttpResultNotFound`), never
2374/// an `Err` construction at all. This function generalises the reference's
2375/// own sketch to the real two-scrutinee-shape, `Match`-based desugar,
2376/// reusing P6.4/P6.5's already-shipped `IrExprKind::Match`/`IrArm`/
2377/// `IrPat::Variant` machinery rather than inventing a bespoke opaque
2378/// `Question` node — R6.7's own "desugaring happens exactly once, in phase
2379/// 6" mandate reads as a decomposition requirement, not license to defer
2380/// the real work to a future printer behind an opaque node the way
2381/// [`IrExprKind::HttpResultNotFound`] itself deliberately is not (that one
2382/// has no further structure to decompose; a `Match` does).
2383///
2384/// Dormant as of this slice: no shipped emitter path calls into
2385/// `lower_expr_ir`'s `Question` arm yet (P6.2's own emitter-side cutover has
2386/// not landed) — verified by unit test only, the same posture #1225's own
2387/// `Ok`/`Err`/`Some`/`None` construction landed under.
2388fn lower_question_ir(inner: &Expr, ty: TyId, span: Span, cx: &mut LowerIrCtx) -> IrExpr {
2389    let scrutinee = Box::new(lower_expr_ir(inner, cx));
2390    let operand_ty = scrutinee.ty;
2391    let is_option = matches!(&*cx.program.ty_intern.get(operand_ty), Ty::Option(_));
2392
2393    let value_local = cx.fresh_tmp("__question_value");
2394    let (ok_tag, err_tag) = if is_option {
2395        ("Some", "None")
2396    } else {
2397        ("Ok", "Err")
2398    };
2399    let ok_variant = variant_info_of(operand_ty, ok_tag, cx.program);
2400    let ok_field_ty = ok_variant
2401        .payload
2402        .first()
2403        .map(|(_, t)| *t)
2404        .unwrap_or_else(|| cx.unit_ty());
2405    let ok_arm = IrArm {
2406        pat: IrPat::Variant {
2407            scrutinee_ty: operand_ty,
2408            tag: ok_tag.to_string(),
2409            fields: vec![(
2410                "value".to_string(),
2411                Box::new(IrPat::Bind {
2412                    local: value_local.clone(),
2413                }),
2414            )],
2415        },
2416        guard: None,
2417        body: IrExpr {
2418            kind: IrExprKind::Local(value_local.clone()),
2419            ty: ok_field_ty,
2420            span,
2421        },
2422        binds: vec![value_local],
2423        binding_mode: BindingMode::Direct,
2424    };
2425
2426    let err_local = cx.fresh_tmp("__question_error");
2427    let (err_fields, err_binds, err_body) = if is_option {
2428        // ADR 0177: `None` early-returns the synthesized `HttpResult.
2429        // NotFound` sentinel — never an `Err` construction. Review of
2430        // #1238: the sentinel's own `.ty` is the enclosing fn/handler's own
2431        // `HttpResult[_]` return type (peeled through `Effect`, the same
2432        // check_question itself gates `Option[T]?` on, `bynk-check/src/
2433        // checker.rs`'s own `peel_to_http_result`) — never `Unit` — and the
2434        // wrapping `Return` node carries that same type, matching
2435        // `wrap_body_return`'s own "Return's own ty is the returned value's
2436        // ty" convention (the only other `Return` producer in this module).
2437        // Falls back to `unit_ty()` only in the defensive, unreachable-in-
2438        // practice case where `return_ty` is absent or doesn't peel to one.
2439        let http_result_ty = cx
2440            .return_ty
2441            .map(|rt| peel_effect_ty(rt, cx.program.tys()))
2442            .filter(|rt| matches!(&*cx.program.tys().get(*rt), Ty::HttpResult(_)))
2443            .unwrap_or_else(|| cx.unit_ty());
2444        (
2445            Vec::new(),
2446            Vec::new(),
2447            IrExpr {
2448                kind: IrExprKind::Return {
2449                    value: Box::new(IrExpr {
2450                        kind: IrExprKind::HttpResultNotFound,
2451                        ty: http_result_ty,
2452                        span,
2453                    }),
2454                },
2455                ty: http_result_ty,
2456                span,
2457            },
2458        )
2459    } else {
2460        let err_variant = variant_info_of(operand_ty, "Err", cx.program);
2461        let err_field_ty = err_variant
2462            .payload
2463            .first()
2464            .map(|(_, t)| *t)
2465            .unwrap_or_else(|| cx.unit_ty());
2466        let err_value = IrExpr {
2467            kind: IrExprKind::Local(err_local.clone()),
2468            ty: err_field_ty,
2469            span,
2470        };
2471        // ADR 0178: a declared `embeds` conversion wraps the propagated
2472        // error; an exact/compatible match propagates it unchanged.
2473        let converted = match embed_conversion_ir(err_field_ty, cx) {
2474            Some((embed_ty, variant)) => IrExpr {
2475                kind: IrExprKind::Variant {
2476                    tag: variant,
2477                    payload: vec![err_value],
2478                },
2479                ty: embed_ty,
2480                span,
2481            },
2482            None => err_value,
2483        };
2484        // The constructed `Err(..)`'s own type is the enclosing fn/handler/
2485        // op's own declared return type (peeled through `Effect`) — the
2486        // real `Result[_, _]` this value is actually returned as. Falls
2487        // back to the operand's own type on a defensive miss (no
2488        // `return_ty` recorded, or it doesn't peel to a `Result`) — this
2489        // branch is unreachable at runtime regardless (an early return),
2490        // so a best-effort `.ty` here mirrors this module's own established
2491        // non-panicking posture for metadata with no runtime consequence.
2492        let result_ty = cx
2493            .return_ty
2494            .map(|rt| peel_effect_ty(rt, cx.program.tys()))
2495            .filter(|rt| matches!(&*cx.program.tys().get(*rt), Ty::Result(..)))
2496            .unwrap_or(operand_ty);
2497        let err_construction = IrExpr {
2498            kind: IrExprKind::Variant {
2499                tag: "Err".to_string(),
2500                payload: vec![converted],
2501            },
2502            ty: result_ty,
2503            span,
2504        };
2505        (
2506            vec![(
2507                "error".to_string(),
2508                Box::new(IrPat::Bind {
2509                    local: err_local.clone(),
2510                }),
2511            )],
2512            vec![err_local],
2513            IrExpr {
2514                kind: IrExprKind::Return {
2515                    // Review of #1238: matches `wrap_body_return`'s own
2516                    // "Return's ty is the returned value's ty" convention —
2517                    // `result_ty`, not `Unit`.
2518                    value: Box::new(err_construction),
2519                },
2520                ty: result_ty,
2521                span,
2522            },
2523        )
2524    };
2525    let err_arm = IrArm {
2526        pat: IrPat::Variant {
2527            scrutinee_ty: operand_ty,
2528            tag: err_tag.to_string(),
2529            fields: err_fields,
2530        },
2531        guard: None,
2532        body: err_body,
2533        binds: err_binds,
2534        binding_mode: BindingMode::Direct,
2535    };
2536
2537    IrExpr {
2538        kind: IrExprKind::Match {
2539            scrutinee,
2540            arms: vec![ok_arm, err_arm],
2541            exhaustive: Exhaustive::Total,
2542            form: MatchForm::Flat,
2543        },
2544        ty,
2545        span,
2546    }
2547}
2548
2549/// `lower_question_ir`'s own `Effect`-peeling half — mirrors
2550/// `emitter/lower.rs`'s `peel_result_err` shape (peel through `Effect` to
2551/// reach the real `Result[_, _]`) but stops one layer earlier, returning
2552/// the whole peeled type rather than just its `Err` half, since
2553/// `lower_question_ir` needs both (the full type for the constructed
2554/// `Err(..)`'s own `.ty`, just the `Err` half for [`embed_conversion_ir`]'s
2555/// own `target_err`).
2556fn peel_effect_ty(ty: TyId, tys: &Types) -> TyId {
2557    match &*tys.get(ty) {
2558        Ty::Effect(inner) => peel_effect_ty(*inner, tys),
2559        _ => ty,
2560    }
2561}
2562
2563/// `lower_question_ir`'s own IR-native sibling of `emitter/lower.rs`'s
2564/// `embed_conversion` — same two checker primitives
2565/// (`checker::compatible`/`checker::embedding_for`), same reasoning, ported
2566/// rather than duplicated: `source_err_ty` (the `?` operand's own `Err`
2567/// type) is passed in directly (this pass's caller already resolved it,
2568/// unlike the string emitter's own `operand_ty: Option<TyId>` re-derivation
2569/// from `expr_types`), and the enclosing return type comes from
2570/// [`LowerIrCtx::return_ty`] instead of `LowerCtx::return_ty`. `None` on any
2571/// miss along the way (no recorded `return_ty`, it doesn't peel to a
2572/// `Result`, or the two error types are already compatible) — bare
2573/// propagation, the same fallback the string emitter's own `?`-then-`?`
2574/// chain already applies.
2575fn embed_conversion_ir(source_err_ty: TyId, cx: &LowerIrCtx) -> Option<(TyId, String)> {
2576    let tys = cx.program.tys();
2577    let target_ty = peel_effect_ty(cx.return_ty?, tys);
2578    let Ty::Result(_, target_err_ty) = &*tys.get(target_ty) else {
2579        return None;
2580    };
2581    // Review of #1238: argument order matters — `compatible(t, u)` means "t
2582    // usable where u is expected," and `check_question`'s own call
2583    // (`bynk-check/src/checker/expressions.rs`) passes `(operand_err,
2584    // fn_err)`, not the reverse.
2585    if checker::compatible(source_err_ty, *target_err_ty, tys) {
2586        return None;
2587    }
2588    let (_ty_name, variant) =
2589        checker::embedding_for(*target_err_ty, source_err_ty, &cx.program.types, tys)?;
2590    Some((*target_err_ty, variant))
2591}
2592
2593/// P6.16 (design/tracks/the-ir.md §6): `is`'s real IR desugar. Scoped
2594/// narrower than R5.9/R5.10's own combined framing might suggest — traced
2595/// directly against the shipped desugar (`emitter/lower.rs`'s `lower_is`)
2596/// rather than assumed from #1157's own Decision D: `lower_is` itself
2597/// constructs only a forced receiver temp (R5.10) plus a boolean test, and
2598/// never a narrowing *binding* — R5.9's own "narrowing is a scope operation
2599/// … recorded in the IR" describes a *separate*, later concern (how `&&`/
2600/// `if` apply `is`'s own boolean result to introduce a binding into a
2601/// following scope, `gather_is_bindings_for_emit`), not a prerequisite this
2602/// function itself needs. So `Is` lowers fully here; R5.9's own cross-site
2603/// narrowing-propagation machinery stays a distinct, still-open design
2604/// question this function does not settle.
2605///
2606/// `value is Name` (no bindings) where `Name` is a *declared refined type*
2607/// is a different check from `value is Variant(...)` (a sum-tag test) —
2608/// mirrors `lower_is`'s own `is_refined_is_check` disambiguation exactly,
2609/// since the two syntactically look identical (a bare name after `is`) and
2610/// only the checker's own type information (via `Ty::Base`/`Ty::Named{kind:
2611/// Refined, ..}`) tells them apart. Every other pattern shape routes through
2612/// [`lower_pattern_ir`] (P6.4, #1157 — built for exactly this, never wired
2613/// until now) plus [`lower_pattern_test_ir`], this function's own new
2614/// boolean-test walk over the resulting `IrPat`.
2615fn lower_is_ir(
2616    value: &Expr,
2617    pattern: &Pattern,
2618    ty: TyId,
2619    span: Span,
2620    cx: &mut LowerIrCtx,
2621) -> IrExpr {
2622    let scrutinee = lower_expr_ir(value, cx);
2623    let scrutinee_ty = scrutinee.ty;
2624    let recv_local = cx.fresh_tmp("__is_receiver");
2625    let recv = IrExpr {
2626        kind: IrExprKind::Local(recv_local.clone()),
2627        ty: scrutinee_ty,
2628        span,
2629    };
2630
2631    let bool_expr = match pattern {
2632        Pattern::Wildcard(_) | Pattern::Binding(_) => IrExpr {
2633            kind: IrExprKind::Const(ConstVal::Bool(true)),
2634            ty,
2635            span,
2636        },
2637        Pattern::Variant {
2638            variant, bindings, ..
2639        } if bindings.is_empty() && is_refined_is_check_ir(scrutinee_ty, &variant.name, cx) => {
2640            refined_check_ir(&recv, &variant.name, ty, span, cx)
2641        }
2642        _ => {
2643            let ir_pat = lower_pattern_ir(pattern, scrutinee_ty, cx.program);
2644            let tests = lower_pattern_test_ir(&recv, scrutinee_ty, &ir_pat, ty, cx.program);
2645            fold_and_ir(tests, ty, span)
2646        }
2647    };
2648
2649    IrExpr {
2650        kind: IrExprKind::Block {
2651            stmts: vec![IrStmt::Let {
2652                local: recv_local,
2653                value: scrutinee,
2654            }],
2655            tail: Box::new(bool_expr),
2656        },
2657        ty,
2658        span,
2659    }
2660}
2661
2662/// `lower_is_ir`'s own refined-type-name disambiguation — a bare `is Name`
2663/// tests a *refined type* rather than a sum variant when the operand's own
2664/// checked type is base-ish and `Name` names a declared `TypeBody::Refined`.
2665/// Mirrors `emitter.rs`'s `LowerCtx::is_refined_is_check` exactly.
2666fn is_refined_is_check_ir(operand_ty: TyId, name: &str, cx: &LowerIrCtx) -> bool {
2667    let value_baseish = matches!(
2668        &*cx.program.ty_intern.get(operand_ty),
2669        Ty::Base(_)
2670            | Ty::Named {
2671                kind: NamedKind::Refined(_),
2672                ..
2673            }
2674    );
2675    let name_refined = matches!(
2676        cx.program.types.get(name).map(|d| &d.body),
2677        Some(TypeBody::Refined { .. })
2678    );
2679    value_baseish && name_refined
2680}
2681
2682/// `lower_is_ir`'s own refined-type-name check construction — reads the
2683/// named refined type's own declared `base`/`refinement` and wraps them in
2684/// [`IrExprKind::RefinedCheck`], the same reused-verbatim payload
2685/// [`lower_pattern_test_ir`]'s own `IrPat::Refined` arm constructs for an
2686/// inline `_ where predicate` clause.
2687fn refined_check_ir(
2688    recv: &IrExpr,
2689    name: &str,
2690    bool_ty: TyId,
2691    span: Span,
2692    cx: &LowerIrCtx,
2693) -> IrExpr {
2694    let Some(TypeBody::Refined {
2695        base, refinement, ..
2696    }) = cx.program.types.get(name).map(|d| &d.body)
2697    else {
2698        panic!(
2699            "bynk internal error (ADR 0334): `{name}` does not resolve to a declared refined \
2700             type, but is_refined_is_check_ir just confirmed it does"
2701        )
2702    };
2703    IrExpr {
2704        kind: IrExprKind::RefinedCheck {
2705            value: Box::new(recv.clone()),
2706            base: *base,
2707            refinement: refinement.clone(),
2708        },
2709        ty: bool_ty,
2710        span,
2711    }
2712}
2713
2714/// `lower_is_ir`'s own recursive boolean-test walk over an already-lowered
2715/// [`IrPat`] — the IR-native sibling of `emitter/lower.rs`'s own
2716/// `pattern_match_tests`, ported rather than duplicated: same recursive
2717/// shape (one test per structural constraint, accumulated then AND-joined
2718/// by the caller), same skip-irrefutable-payload rule, but reading
2719/// `IrPat`'s own already-resolved `fields`/`scrutinee_ty` instead of
2720/// re-deriving a positional field's name from `LowerCtx::positional_field_name`
2721/// — `lower_pattern_ir` (P6.4, #1157) already did that resolution once, via
2722/// the identical `variant_info_of`/`variants_of` this function's own
2723/// `IrPat::Variant` arm re-derives payload *types* through (field *names*
2724/// are already on the `IrPat` itself, nothing left to look up for those).
2725fn lower_pattern_test_ir(
2726    path: &IrExpr,
2727    path_ty: TyId,
2728    pat: &IrPat,
2729    bool_ty: TyId,
2730    program: &TypedCommons,
2731) -> Vec<IrExpr> {
2732    match pat {
2733        IrPat::Wild | IrPat::Bind { .. } => Vec::new(),
2734        IrPat::Const { value } => vec![IrExpr {
2735            kind: IrExprKind::BinOp {
2736                op: IrBinOp::Eq,
2737                lhs: Box::new(path.clone()),
2738                rhs: Box::new(IrExpr {
2739                    kind: IrExprKind::Const(value.clone()),
2740                    ty: path_ty,
2741                    span: path.span,
2742                }),
2743            },
2744            ty: bool_ty,
2745            span: path.span,
2746        }],
2747        IrPat::Refined { inner, refinement } => {
2748            let mut tests = lower_pattern_test_ir(path, path_ty, inner, bool_ty, program);
2749            let base = literal_base_of_ty_ir(path_ty, program.tys()).unwrap_or_else(|| {
2750                panic!(
2751                    "bynk internal error (ADR 0334): a refined pattern's scrutinee must be \
2752                     literal-kind (Int/Float/String/Bool), but the checker already accepted \
2753                     this pattern"
2754                )
2755            });
2756            tests.push(IrExpr {
2757                kind: IrExprKind::RefinedCheck {
2758                    value: Box::new(path.clone()),
2759                    base,
2760                    refinement: Some(refinement.clone()),
2761                },
2762                ty: bool_ty,
2763                span: path.span,
2764            });
2765            tests
2766        }
2767        IrPat::Variant {
2768            scrutinee_ty,
2769            tag,
2770            fields,
2771        } => {
2772            let string_ty = program.tys().intern(Ty::Base(BaseType::String));
2773            let mut tests = vec![IrExpr {
2774                kind: IrExprKind::BinOp {
2775                    op: IrBinOp::Eq,
2776                    lhs: Box::new(IrExpr {
2777                        kind: IrExprKind::Field {
2778                            base: Box::new(path.clone()),
2779                            field: "tag".to_string(),
2780                        },
2781                        ty: string_ty,
2782                        span: path.span,
2783                    }),
2784                    rhs: Box::new(IrExpr {
2785                        kind: IrExprKind::Const(ConstVal::Str(tag.clone())),
2786                        ty: string_ty,
2787                        span: path.span,
2788                    }),
2789                },
2790                ty: bool_ty,
2791                span: path.span,
2792            }];
2793            let variant_info = variant_info_of(*scrutinee_ty, tag, program);
2794            for (field_name, sub_pat) in fields {
2795                if is_irrefutable_ir(sub_pat) {
2796                    continue;
2797                }
2798                let field_ty = variant_info
2799                    .payload
2800                    .iter()
2801                    .find(|(n, _)| n == field_name)
2802                    .map(|(_, t)| *t)
2803                    .unwrap_or(bool_ty);
2804                let field_path = IrExpr {
2805                    kind: IrExprKind::Field {
2806                        base: Box::new(path.clone()),
2807                        field: field_name.clone(),
2808                    },
2809                    ty: field_ty,
2810                    span: path.span,
2811                };
2812                tests.extend(lower_pattern_test_ir(
2813                    &field_path,
2814                    field_ty,
2815                    sub_pat,
2816                    bool_ty,
2817                    program,
2818                ));
2819            }
2820            tests
2821        }
2822        IrPat::Or { alts } => {
2823            let terms: Vec<IrExpr> = alts
2824                .iter()
2825                .map(|alt| {
2826                    let t = lower_pattern_test_ir(path, path_ty, alt, bool_ty, program);
2827                    fold_and_ir(t, bool_ty, path.span)
2828                })
2829                .collect();
2830            vec![fold_or_ir(terms, bool_ty, path.span)]
2831        }
2832    }
2833}
2834
2835/// [`lower_pattern_test_ir`]'s own `IrPat`-level mirror of
2836/// `bynk_syntax::ast::Pattern::is_irrefutable` — `Wild`/`Bind` always match,
2837/// an `Or` does iff any alternative does.
2838fn is_irrefutable_ir(pat: &IrPat) -> bool {
2839    match pat {
2840        IrPat::Wild | IrPat::Bind { .. } => true,
2841        IrPat::Or { alts } => alts.iter().any(is_irrefutable_ir),
2842        _ => false,
2843    }
2844}
2845
2846/// `lower_is_ir`'s/[`lower_pattern_test_ir`]'s own IR-native mirror of
2847/// `emitter/lower.rs`'s `literal_base_of_ty` — the literal-kind base a
2848/// refined pattern's own scrutinee must resolve to.
2849fn literal_base_of_ty_ir(ty: TyId, tys: &Types) -> Option<BaseType> {
2850    let base = match &*tys.get(ty) {
2851        Ty::Base(b) => *b,
2852        Ty::Named {
2853            kind: NamedKind::Refined(b),
2854            ..
2855        } => *b,
2856        _ => return None,
2857    };
2858    matches!(base, BaseType::Int | BaseType::String | BaseType::Bool).then_some(base)
2859}
2860
2861/// AND-folds a list of boolean tests — empty (a pattern with no structural
2862/// constraints, e.g. a bare `Bind`) is vacuously `true`, matching
2863/// `pattern_match_tests`' own `tests.is_empty()` → `"true"` fallback.
2864fn fold_and_ir(tests: Vec<IrExpr>, bool_ty: TyId, span: Span) -> IrExpr {
2865    let mut iter = tests.into_iter();
2866    let Some(first) = iter.next() else {
2867        return IrExpr {
2868            kind: IrExprKind::Const(ConstVal::Bool(true)),
2869            ty: bool_ty,
2870            span,
2871        };
2872    };
2873    iter.fold(first, |acc, next| IrExpr {
2874        kind: IrExprKind::And {
2875            lhs: Box::new(acc),
2876            rhs: Box::new(next),
2877        },
2878        ty: bool_ty,
2879        span,
2880    })
2881}
2882
2883/// OR-folds an `Or`-pattern's own per-alternative tests — never empty in
2884/// practice (`Pattern::Or` always carries at least two alternatives), but
2885/// degrades to `false` rather than panicking on the same "metadata with no
2886/// runtime consequence" posture this module already applies elsewhere.
2887fn fold_or_ir(terms: Vec<IrExpr>, bool_ty: TyId, span: Span) -> IrExpr {
2888    let mut iter = terms.into_iter();
2889    let Some(first) = iter.next() else {
2890        return IrExpr {
2891            kind: IrExprKind::Const(ConstVal::Bool(false)),
2892            ty: bool_ty,
2893            span,
2894        };
2895    };
2896    iter.fold(first, |acc, next| IrExpr {
2897        kind: IrExprKind::Or {
2898            lhs: Box::new(acc),
2899            rhs: Box::new(next),
2900        },
2901        ty: bool_ty,
2902        span,
2903    })
2904}
2905
2906pub fn lower_expr_ir(e: &Expr, cx: &mut LowerIrCtx) -> IrExpr {
2907    let ty = cx.expr_ty(e.id);
2908    let span = e.span;
2909    match &e.kind {
2910        ExprKind::IntLit { value, .. } => IrExpr {
2911            kind: IrExprKind::Const(ConstVal::Int(*value)),
2912            ty,
2913            span,
2914        },
2915        ExprKind::FloatLit { value, .. } => IrExpr {
2916            kind: IrExprKind::Const(ConstVal::Float(*value)),
2917            ty,
2918            span,
2919        },
2920        ExprKind::DurationLit { millis, .. } => IrExpr {
2921            kind: IrExprKind::Const(ConstVal::DurationMillis(*millis)),
2922            ty,
2923            span,
2924        },
2925        ExprKind::StrLit(s) => IrExpr {
2926            kind: IrExprKind::Const(ConstVal::Str(s.clone())),
2927            ty,
2928            span,
2929        },
2930        ExprKind::BoolLit(b) => IrExpr {
2931            kind: IrExprKind::Const(ConstVal::Bool(*b)),
2932            ty,
2933            span,
2934        },
2935        ExprKind::UnitLit => IrExpr {
2936            kind: IrExprKind::Const(ConstVal::Unit),
2937            ty,
2938            span,
2939        },
2940
2941        ExprKind::Ident(id) => IrExpr {
2942            kind: lower_ident_ir(&id.name, Some(e.id), cx),
2943            ty,
2944            span,
2945        },
2946
2947        ExprKind::RecordConstruction { fields, .. } => IrExpr {
2948            kind: IrExprKind::Record {
2949                fields: fields
2950                    .iter()
2951                    .map(|f| {
2952                        let value = match &f.value {
2953                            Some(v) => lower_expr_ir(v, cx),
2954                            // Shorthand `{ x }` — no `Expr`/`ExprId` of its
2955                            // own (the parser never builds one), so its type
2956                            // comes from this pass's own scope, exactly the
2957                            // local binding the checker's `ctx.lookup` read
2958                            // at `bynk-check/src/checker/expressions.rs:2427`.
2959                            None => {
2960                                let local_ty = cx.lookup(&f.name.name).unwrap_or_else(|| {
2961                                    panic!(
2962                                        "bynk internal error (ADR 0334): shorthand field `{}` has \
2963                                         no local binding in this pass's own scope — the checker \
2964                                         accepted it, so its own `ctx.lookup` must have found one",
2965                                        f.name.name
2966                                    )
2967                                });
2968                                IrExpr {
2969                                    kind: lower_ident_ir(&f.name.name, None, cx),
2970                                    ty: local_ty,
2971                                    span: f.name.span,
2972                                }
2973                            }
2974                        };
2975                        (f.name.name.clone(), value)
2976                    })
2977                    .collect(),
2978            },
2979            ty,
2980            span,
2981        },
2982        ExprKind::FieldAccess { receiver, field } => {
2983            // P6.23 root-cause pass (review of #1253): a qualified nullary
2984            // sum-variant reference (`Region.Domestic`) parses as an
2985            // ordinary `FieldAccess`, but the checker's own
2986            // `check_field_access` (`expressions.rs`) intercepts this shape
2987            // — `receiver` bare-names a declared sum type owning a variant
2988            // tagged `field.name` — *before* ever independently
2989            // type-checking `receiver` itself; it returns the variant's own
2990            // type directly, so `receiver`'s own `ExprId` never gets a
2991            // recorded type. The generic `lower_expr_ir(receiver, cx)`
2992            // recursion below then panics on ADR 0334's own "no recorded
2993            // type" guard. Mirrors the checker's dispatch (same
2994            // `cx.lookup(...).is_none()` shadowing guard) instead of
2995            // re-deriving it, rather than lowering `receiver` at all.
2996            if let ExprKind::Ident(id) = &receiver.kind
2997                && cx.lookup(&id.name).is_none()
2998                && let Some(decl) = cx.program.types.get(&id.name)
2999                && let TypeBody::Sum(s) = &decl.body
3000                && s.variants.iter().any(|v| v.name.name == field.name)
3001            {
3002                IrExpr {
3003                    kind: IrExprKind::Variant {
3004                        tag: field.name.clone(),
3005                        payload: Vec::new(),
3006                    },
3007                    ty,
3008                    span,
3009                }
3010            } else {
3011                IrExpr {
3012                    kind: IrExprKind::Field {
3013                        base: Box::new(lower_expr_ir(receiver, cx)),
3014                        field: field.name.clone(),
3015                    },
3016                    ty,
3017                    span,
3018                }
3019            }
3020        }
3021        ExprKind::ListLit(elems) => IrExpr {
3022            kind: IrExprKind::List {
3023                elems: elems.iter().map(|el| lower_expr_ir(el, cx)).collect(),
3024            },
3025            ty,
3026            span,
3027        },
3028        ExprKind::Block(b) => lower_block_ir(b, cx),
3029        ExprKind::If {
3030            cond,
3031            then_block,
3032            else_block,
3033        } => IrExpr {
3034            kind: IrExprKind::If {
3035                cond: Box::new(lower_expr_ir(cond, cx)),
3036                then_: Box::new(lower_block_ir(then_block, cx)),
3037                else_: Box::new(lower_block_ir(else_block, cx)),
3038            },
3039            ty,
3040            span,
3041        },
3042        ExprKind::BinOp(BinOp::And, lhs, rhs) => IrExpr {
3043            kind: IrExprKind::And {
3044                lhs: Box::new(lower_expr_ir(lhs, cx)),
3045                rhs: Box::new(lower_expr_ir(rhs, cx)),
3046            },
3047            ty,
3048            span,
3049        },
3050        ExprKind::BinOp(BinOp::Or, lhs, rhs) => IrExpr {
3051            kind: IrExprKind::Or {
3052                lhs: Box::new(lower_expr_ir(lhs, cx)),
3053                rhs: Box::new(lower_expr_ir(rhs, cx)),
3054            },
3055            ty,
3056            span,
3057        },
3058        ExprKind::UnaryOp(UnaryOp::Not, inner) => IrExpr {
3059            kind: IrExprKind::Not {
3060                operand: Box::new(lower_expr_ir(inner, cx)),
3061            },
3062            ty,
3063            span,
3064        },
3065        ExprKind::EffectPure(inner) => IrExpr {
3066            kind: IrExprKind::Pure {
3067                value: Box::new(lower_expr_ir(inner, cx)),
3068            },
3069            ty,
3070            span,
3071        },
3072        // Parens carry no semantic weight once parsed — not one of the
3073        // reference's own node kinds, unwrapped here so lowering a merely-
3074        // parenthesized covered-node subexpression doesn't panic.
3075        ExprKind::Paren(inner) => lower_expr_ir(inner, cx),
3076
3077        // `p implies q` -> `Or { lhs: Not(p), rhs: q }` (P6.3, design/tracks/
3078        // the-ir.md §6, R6.7 — the reference's own Part 6.4 table). Peeled off
3079        // the comparison/arithmetic `BinOp` arm below (real as of #1189) —
3080        // `Implies` desugars away entirely rather than sharing that arm's
3081        // `IrBinOp` shape.
3082        //
3083        // This flat `Or`/`Not` pair has nowhere to carry an `is` binding from
3084        // the antecedent into the consequent (`p is Foo(x) implies f(x)`,
3085        // the reason the string emitter's own `Implies` handling exists,
3086        // `emitter/lower.rs:4332`'s `lower_and_with_is`). Correction (P6.25,
3087        // 2026-08-19): `ExprKind::Is` landed (P6.16/ADR 0338) — this gap is
3088        // real and reachable now, not hypothetical. ADR 0338 traced `Is`
3089        // itself and found the narrowing binding is never `Is`'s own concern
3090        // (it's applied by `&&`/`implies`/`if`'s own consuming call sites),
3091        // so this arm's own missing binding-carry is R5.9's still-open,
3092        // still-unscoped cross-site narrowing-propagation gap (design/tracks/
3093        // the-ir.md §7), not a P6.4/`Is`-landing prerequisite as this comment
3094        // used to say.
3095        ExprKind::BinOp(BinOp::Implies, lhs, rhs) => IrExpr {
3096            kind: IrExprKind::Or {
3097                // `Not`'s own type is Bool — the same type `Implies` itself
3098                // (and therefore `lhs`) already carries, so no new type is
3099                // synthesised here, only reused.
3100                lhs: Box::new(IrExpr {
3101                    kind: IrExprKind::Not {
3102                        operand: Box::new(lower_expr_ir(lhs, cx)),
3103                    },
3104                    ty,
3105                    span: lhs.span,
3106                }),
3107                rhs: Box::new(lower_expr_ir(rhs, cx)),
3108            },
3109            ty,
3110            span,
3111        },
3112
3113        // Comparison/arithmetic (#1189, ir.rs's own Decision-D-extension
3114        // note): a shared `IrExprKind::BinOp { op, .. }` node, not ten
3115        // near-duplicate variants — see `IrExprKind::BinOp`'s own doc
3116        // comment for why that shape (not one variant per operator, unlike
3117        // `And`/`Or`/`Not` above) is the right call here. `lhs`/`rhs` lower
3118        // independently; no `is`-binding propagation applies (that's
3119        // `Implies`/`And`'s own concern, unaffected by this arm).
3120        ExprKind::BinOp(
3121            op @ (BinOp::Eq
3122            | BinOp::NotEq
3123            | BinOp::Lt
3124            | BinOp::LtEq
3125            | BinOp::Gt
3126            | BinOp::GtEq
3127            | BinOp::Add
3128            | BinOp::Sub
3129            | BinOp::Mul
3130            | BinOp::Div),
3131            lhs,
3132            rhs,
3133        ) => {
3134            let ir_op = match op {
3135                BinOp::Eq => IrBinOp::Eq,
3136                BinOp::NotEq => IrBinOp::NotEq,
3137                BinOp::Lt => IrBinOp::Lt,
3138                BinOp::LtEq => IrBinOp::LtEq,
3139                BinOp::Gt => IrBinOp::Gt,
3140                BinOp::GtEq => IrBinOp::GtEq,
3141                BinOp::Add => IrBinOp::Add,
3142                BinOp::Sub => IrBinOp::Sub,
3143                BinOp::Mul => IrBinOp::Mul,
3144                BinOp::Div => IrBinOp::Div,
3145                BinOp::And | BinOp::Or | BinOp::Implies => {
3146                    unreachable!("And/Or/Implies are matched by their own arms above")
3147                }
3148            };
3149            IrExpr {
3150                kind: IrExprKind::BinOp {
3151                    op: ir_op,
3152                    lhs: Box::new(lower_expr_ir(lhs, cx)),
3153                    rhs: Box::new(lower_expr_ir(rhs, cx)),
3154                },
3155                ty,
3156                span,
3157            }
3158        }
3159        // `-operand` (#1189) — `Not`'s own arithmetic counterpart, same
3160        // shape.
3161        ExprKind::UnaryOp(UnaryOp::Neg, inner) => IrExpr {
3162            kind: IrExprKind::Neg {
3163                operand: Box::new(lower_expr_ir(inner, cx)),
3164            },
3165            ty,
3166            span,
3167        },
3168        // Interpolated strings (#1189) — each hole lowers through the
3169        // ordinary `lower_expr_ir` machinery; chunks carry over verbatim.
3170        ExprKind::InterpStr(parts) => IrExpr {
3171            kind: IrExprKind::InterpStr {
3172                parts: parts.iter().map(|p| lower_interp_part_ir(p, cx)).collect(),
3173            },
3174            ty,
3175            span,
3176        },
3177        ExprKind::Call {
3178            type_args, args, ..
3179        } => lower_call_ir(e, None, type_args, args, cx),
3180        ExprKind::Lambda(lambda) => lower_lambda_ir(e, lambda, cx),
3181        // #1225's own ADR: the four built-in constructors lower to the same
3182        // `IrExprKind::Variant` a user-declared sum's own `Callee::Ctor`
3183        // does (`lower_call_ir`, above) — `IrExprKind::Variant`'s own doc
3184        // comment has the full grounding for why no `Callee` classification
3185        // is needed here either: `ty` (already resolved for every `IrExpr`,
3186        // R6.1) already carries the constructed sum's own identity, the
3187        // same way it does for a real `Callee::Ctor` call.
3188        ExprKind::Ok(inner) => IrExpr {
3189            kind: IrExprKind::Variant {
3190                tag: "Ok".to_string(),
3191                payload: vec![lower_expr_ir(inner, cx)],
3192            },
3193            ty,
3194            span,
3195        },
3196        ExprKind::Err(inner) => IrExpr {
3197            kind: IrExprKind::Variant {
3198                tag: "Err".to_string(),
3199                payload: vec![lower_expr_ir(inner, cx)],
3200            },
3201            ty,
3202            span,
3203        },
3204        ExprKind::Some(inner) => IrExpr {
3205            kind: IrExprKind::Variant {
3206                tag: "Some".to_string(),
3207                payload: vec![lower_expr_ir(inner, cx)],
3208            },
3209            ty,
3210            span,
3211        },
3212        ExprKind::None => IrExpr {
3213            kind: IrExprKind::Variant {
3214                tag: "None".to_string(),
3215                payload: Vec::new(),
3216            },
3217            ty,
3218            span,
3219        },
3220        ExprKind::Question(inner) => lower_question_ir(inner, ty, span, cx),
3221        ExprKind::ConstructorCall { args, .. } => lower_call_ir(e, None, &[], args, cx),
3222        ExprKind::MethodCall {
3223            receiver,
3224            type_args,
3225            args,
3226            ..
3227        } => lower_call_ir(e, Some(receiver), type_args, args, cx),
3228        ExprKind::Match { discriminant, arms } => {
3229            let scrutinee = Box::new(lower_expr_ir(discriminant, cx));
3230            // Deliberately the lowered `IrExpr`'s own `ty`, not
3231            // `cx.expr_ty(discriminant.id)` (the AST-keyed type P6.4's own
3232            // standalone fixtures read instead, `lower_match_fixture` below)
3233            // — the two agree everywhere reachable today, but `scrutinee.ty`
3234            // stays correct even if a future discriminant ever lowers
3235            // through a type-adjusting arm (a `peel_effect` site), where the
3236            // recorded AST type and the lowered node's own type can diverge.
3237            let scrutinee_ty = scrutinee.ty;
3238            let ir_arms: Vec<IrArm> = arms
3239                .iter()
3240                .map(|a| lower_arm_ir(a, scrutinee_ty, cx))
3241                .collect();
3242            let exhaustive = lower_exhaustive_ir(arms);
3243            let form = if match_needs_if_chain(arms) {
3244                MatchForm::IfChain
3245            } else {
3246                MatchForm::Flat
3247            };
3248            // Not recorded here: the string emitter also decides, at this
3249            // same site, whether the discriminant needs hoisting to a temp
3250            // before an if-chain re-evaluates it per arm
3251            // (`is_narrowable_path`, `emitter/lower.rs:5068` — re-evaluating
3252            // a non-ident/field discriminant could repeat side effects).
3253            // Left deliberately derivable from `scrutinee`'s own `Local`/
3254            // `Field` shape rather than mirrored onto `IrExprKind::Match` as
3255            // a second flag — the same "can never silently disagree"
3256            // argument Decision B makes for `form` applies here too, just
3257            // resolved by omission instead of reuse.
3258            IrExpr {
3259                kind: IrExprKind::Match {
3260                    scrutinee,
3261                    arms: ir_arms,
3262                    exhaustive,
3263                    form,
3264                },
3265                ty,
3266                span,
3267            }
3268        }
3269        ExprKind::Is { value, pattern } => lower_is_ir(value, pattern, ty, span, cx),
3270        ExprKind::RecordSpread {
3271            base, overrides, ..
3272        } => lower_record_spread_ir(ty, span, base, overrides, cx),
3273        ExprKind::Expect(_) => todo!(
3274            "`expect` expression — test-body-only, gated by ctx.in_test_body \
3275             (bynk-check/src/checker.rs's check_body), unreachable through this pass's own \
3276             single-file checked_program/find_fn/lower_fn harness without first building or \
3277             routing through bynk-check's heavier project-level test machinery (P6.3, #1145, \
3278             Decision C)"
3279        ),
3280        ExprKind::Val { .. } => todo!(
3281            "`Val[T]` — test-body-only, same unreachable-through-this-harness gap as `expect` \
3282             above (P6.3, #1145, Decision C)"
3283        ),
3284        ExprKind::Wire(_) => todo!(
3285            "`Wire(...)` — test-body-only, same unreachable-through-this-harness gap as `expect` \
3286             above (P6.3, #1145, Decision C)"
3287        ),
3288        ExprKind::Observation(_) => todo!(
3289            "capability-call observation — test-body-only, same unreachable-through-this-harness \
3290             gap as `expect` above (P6.3, #1145, Decision C)"
3291        ),
3292        ExprKind::Trace { .. } => todo!(
3293            "`trace(...)` — test-body-only, same unreachable-through-this-harness gap as \
3294             `expect` above (P6.3, #1145, Decision C)"
3295        ),
3296    }
3297}
3298
3299/// [`IrExprKind::InterpStr`]'s own per-part lowering (#1189) — a literal
3300/// chunk carries over verbatim; a hole lowers through the ordinary
3301/// [`lower_expr_ir`] machinery, same as any other subexpression.
3302fn lower_interp_part_ir(part: &InterpPart, cx: &mut LowerIrCtx) -> IrInterpPart {
3303    match part {
3304        InterpPart::Chunk(s) => IrInterpPart::Chunk(s.clone()),
3305        InterpPart::Hole(e) => IrInterpPart::Hole(Box::new(lower_expr_ir(e, cx))),
3306    }
3307}
3308
3309/// Shared by `Call`/`MethodCall`/qualified-`ConstructorCall`: read back the
3310/// `Callee` P6.0 already recorded for `e.id` and build the matching
3311/// `IrExprKind` — never re-derive which dispatch branch fired. `receiver`
3312/// is `Some` only for `MethodCall`; `Call`/`ConstructorCall` have none, so
3313/// `type_args`/`args` alone determine the call's own arguments.
3314///
3315/// A `Callee::Ctor` becomes [`IrExprKind::Variant`], not `Call` — sum-variant
3316/// construction is a distinct node in Part 6.2's own shape, even though
3317/// `check_call`/`check_static_call` dispatch it identically to a real call.
3318/// Every other `Callee` becomes [`IrExprKind::Call`]. A method call's own
3319/// `receiver` is lowered and prepended to `args` *only* when the `Callee`
3320/// says the receiver is a genuine value the call operates on — `Method`
3321/// (`self`), `Kernel` (the collection/etc. value), `Agent` (the instance) —
3322/// the same way UFCS already treats `self` as an ordinary leading argument
3323/// (`check_method_call`'s own doc comment: "UFCS-style call resolution").
3324/// Every other `Callee` reached via `MethodCall` (`Static`, `Capability`,
3325/// `CrossCap`, `Cross`, `TestService`, `Store`, `Query`) has a receiver that
3326/// is a pure namespace/field reference already fully captured by the
3327/// `Callee`'s own fields (a type name, a capability name, a store field's
3328/// own name) — lowering and prepending it would be redundant at best (its
3329/// identity is already recorded) and wrong at worst (a bare type name like
3330/// `Point` in `Point.origin()` is not a `Local`, not a `Global`-shaped
3331/// nullary variant, and not a free function — lowering it as an ordinary
3332/// expression would hit `lower_ident_ir`'s own fallback `todo!()` for no
3333/// good reason). This is a lowering-time convention with no consumer yet to
3334/// validate it against (Decision A, #1143) — worth a second look once a
3335/// real `Ir → TS` printer is proposed.
3336fn lower_call_ir(
3337    e: &Expr,
3338    receiver: Option<&Expr>,
3339    type_args: &[bynk_syntax::ast::TypeRef],
3340    args: &[Expr],
3341    cx: &mut LowerIrCtx,
3342) -> IrExpr {
3343    let ty = cx.expr_ty(e.id);
3344    let span = e.span;
3345    let Some(callee) = cx.callee(e.id).cloned() else {
3346        todo!(
3347            "no Callee recorded for this call at {span:?} — one of the shapes Decision C (#1143) \
3348             left out on purpose (HttpResult/QueueResult bare-variant construction, Events.emit, \
3349             the production is_system_http_service address), or a genuine, newly-discovered gap"
3350        )
3351    };
3352    if let Callee::Ctor { tag, .. } = callee {
3353        // `IrExprKind::Variant` has no `targs` slot, unlike `Call` below —
3354        // deliberate, not dropped: `type_args` can never be non-empty here.
3355        // `check_call`'s own gate rejects any explicit type argument before
3356        // it even considers whether `name` is a variant constructor
3357        // ("`{name}` is not a generic function — it takes no type
3358        // arguments", `calls.rs:484-495`), and `ConstructorCall` (the
3359        // qualified form, `Opt.Some(x)`) has no `type_args` slot on the AST
3360        // at all — a generic sum's own instantiation is inferred from the
3361        // payload's argument types instead (`check_variant_construction`),
3362        // never named explicitly at a call site.
3363        //
3364        // `Callee::Ctor`'s own `sum: Arc<TypeDecl>` field is read no
3365        // further than this match's own destructure — #1225's own ADR
3366        // (`IrExprKind::Variant`'s own doc comment has the full grounding)
3367        // found `ty` (already computed above) already carries the
3368        // identical identity as a `TyId`, so `IrExprKind::Variant` itself
3369        // carries no separate `sum` field to populate.
3370        return IrExpr {
3371            kind: IrExprKind::Variant {
3372                tag,
3373                payload: args.iter().map(|a| lower_expr_ir(a, cx)).collect(),
3374            },
3375            ty,
3376            span,
3377        };
3378    }
3379    let receiver_is_a_value = matches!(
3380        callee,
3381        Callee::Method(_) | Callee::Kernel { .. } | Callee::Agent { .. }
3382    );
3383    let mut ir_args: Vec<IrExpr> = Vec::with_capacity(args.len() + receiver_is_a_value as usize);
3384    if receiver_is_a_value && let Some(r) = receiver {
3385        ir_args.push(lower_expr_ir(r, cx));
3386    }
3387    ir_args.extend(args.iter().map(|a| lower_expr_ir(a, cx)));
3388    let targs = type_args
3389        .iter()
3390        .map(|t| {
3391            cx.resolve_type_ref(t).unwrap_or_else(|| {
3392                panic!(
3393                    "bynk internal error (ADR 0334): an explicit call-site type argument does \
3394                     not resolve in this pass's own rigid-variable scope, but the checker \
3395                     already accepted this call"
3396                )
3397            })
3398        })
3399        .collect();
3400    IrExpr {
3401        kind: IrExprKind::Call {
3402            callee,
3403            targs,
3404            args: ir_args,
3405        },
3406        ty,
3407        span,
3408    }
3409}
3410
3411/// A lambda's own recorded type is always `Ty::Fn { params, ret }`
3412/// (`check_lambda`, `bynk-check/src/checker/expressions.rs:974`) — that
3413/// `params` list is this pass's only source for each parameter's type,
3414/// since `LambdaParam::type_ref` is optional and, when absent, the
3415/// checker infers it from context (an expected function type) rather than
3416/// resolving it from an annotation this pass could re-derive.
3417fn lower_lambda_ir(e: &Expr, lambda: &LambdaExpr, cx: &mut LowerIrCtx) -> IrExpr {
3418    let ty = cx.expr_ty(e.id);
3419    let param_tys: Vec<TyId> = match &*cx.program.ty_intern.get(ty) {
3420        Ty::Fn { params, .. } => params.clone(),
3421        _ => panic!(
3422            "bynk internal error (ADR 0334): a Lambda's own recorded type is not Ty::Fn — \
3423             check_lambda only ever types one as a function type"
3424        ),
3425    };
3426    assert_eq!(
3427        lambda.params.len(),
3428        param_tys.len(),
3429        "bynk internal error (ADR 0334): a Lambda's own recorded Ty::Fn has a different arity \
3430         than its own AST params — bynk_lower and bynk-check disagree about this \
3431         lambda's shape"
3432    );
3433    cx.push_scope();
3434    for (p, pty) in lambda.params.iter().zip(&param_tys) {
3435        cx.bind(p.name.name.clone(), *pty);
3436    }
3437    let body = lower_expr_ir(&lambda.body, cx);
3438    cx.pop_scope();
3439    IrExpr {
3440        kind: IrExprKind::Lambda {
3441            params: lambda.params.iter().map(|p| p.name.name.clone()).collect(),
3442            body: Box::new(body),
3443            // Free-variable analysis isn't built here — nothing consumes
3444            // Lambda's IR yet (Decision A, #1143); a later slice computes
3445            // this once a real need (closure-conversion, a printer) exists
3446            // to validate it against.
3447            captures: Vec::new(),
3448        },
3449        ty,
3450        span: e.span,
3451    }
3452}
3453
3454/// Classify a bare `Ident` as `Local` or `Global` — Decision C's narrow
3455/// scope (refined during implementation, see [`GlobalRef`]'s doc comment for
3456/// why the `HttpResult`/`QueueResult` case named in the original proposal
3457/// was dropped).
3458///
3459/// The `Global` probe is a pure name-shaped lookup with nothing tying it to
3460/// "this name is not one of the other, unmigrated forms" — a real
3461/// collision risk a review of this slice named directly. Only one of those
3462/// forms is cheaply excludable here (a bare free-function name, `ctx.input.
3463/// fns` in `check_ident`'s own ladder, `checker/expressions.rs:56-101` —
3464/// checked *before* its own nullary-variant fallback): excluding it first
3465/// closes the one case this pass can actually reach today, since this
3466/// entry point only ever runs over a `fn`/method body ([`lower_fn_body_ir`]'s
3467/// own doc comment) where a free function is the one non-local, non-variant
3468/// bare ident that legitimately occurs (a fn-value reference, `Callee`/
3469/// `Lambda`-adjacent, P6.2 territory). The remaining forms —
3470/// `bynk-emit/src/emitter/lower.rs`'s `lower_ident` still special-cases
3471/// `old`/`new` transition binders, invariant state-field reads, agent
3472/// store-cell/store-map/store-log reads, the multi-actor `deps.who` binder —
3473/// are handler-body-only and structurally unreachable through this entry
3474/// point (it has no `store_fields`/`agent_state_ty`/`actor_binding`
3475/// parameter to carry them), not merely unexcluded; each needs its own
3476/// resolved-identity plumbing this slice does not commission (Decision C).
3477fn lower_ident_ir(name: &str, expr_id: Option<ExprId>, cx: &LowerIrCtx) -> IrExprKind {
3478    if cx.lookup(name).is_some() {
3479        return IrExprKind::Local(name.to_string());
3480    }
3481    // P6.21/P6.23 (review of #1251/#1252): a bare `HttpResult`/`QueueResult`
3482    // nullary variant reference (`NotFound`, `Ack`) — Decision C's own
3483    // originally-in-scope case (`GlobalRef`'s own doc comment names it),
3484    // dropped for lack of a sink until #1251 added `Callee::Intrinsic` for
3485    // it. Lowers the same way `lower_call_ir` already lowers the
3486    // call-with-args sibling (`Retry(reason)`) — a generic
3487    // `IrExprKind::Call` wrapping the `Callee`, empty `args` for the
3488    // nullary case — so both forms of the same variant share one shape.
3489    // Checked by per-expression `Callee` lookup, not by name: unlike
3490    // `store_queryable` above, there is no re-derivation/shadowing question
3491    // that could make this check's own *position* in the ladder wrong — the
3492    // checker already decided, once, whether *this* expression is this
3493    // shape, so a miss here just falls through, correctly, to whichever
3494    // check actually matches.
3495    if let Some(callee @ Callee::Intrinsic { .. }) = expr_id.and_then(|id| cx.callee(id)) {
3496        return IrExprKind::Call {
3497            callee: callee.clone(),
3498            targs: Vec::new(),
3499            args: Vec::new(),
3500        };
3501    }
3502    // P6.20-pre (review of #1240): checked immediately after `cx.lookup`,
3503    // matching the checker's own precedence — `checker.rs:3477-3481` runs
3504    // `ctx.lookup(...).is_none() && ctx.store_fields.get(...)` and returns
3505    // *before* `check_ident` (where a free-fn reference or a nullary variant
3506    // resolve) ever sees the name; the shipped emitter's own
3507    // `is_agent_store_map`/`is_agent_store_log` (`emitter/lower.rs:4111,4117`)
3508    // put the same check ahead of its own variant-construction branch. This
3509    // was originally checked *last* here, which only happens to agree with
3510    // the checker when no other candidate shares the store field's name — a
3511    // store `Map` field colliding with a free fn of the same name hit the
3512    // free-fn `todo!()` below instead of `StoreQuery`, and colliding with a
3513    // nullary variant silently returned the wrong node (`Global`) instead.
3514    // The `cx.lookup(name).is_some()` guard just above already gives this
3515    // check the one shadowing property it actually needs (a local or `Cell`
3516    // field wins), so moving it here loses nothing.
3517    if cx.store_queryable.contains(name) {
3518        return IrExprKind::StoreQuery(name.to_string());
3519    }
3520    if cx.program.fns.contains_key(name) {
3521        todo!(
3522            "bare ident `{name}` names a free function used as a value — Callee/Lambda-adjacent, \
3523             P6.2 territory, not a Global reference"
3524        )
3525    }
3526    if nullary_variant_owner(name, cx).is_some() {
3527        return IrExprKind::Global(GlobalRef {
3528            tag: name.to_string(),
3529        });
3530    }
3531    todo!(
3532        "bare ident `{name}` is neither a locally-bound name, a free function, nor a bare \
3533         nullary sum-variant reference — one of lower_ident's other special cases (store field, \
3534         agent `self`, actor binder, transition `old`/`new`), structurally unreachable through \
3535         lower_fn_body_ir (see its own doc comment) but left unhandled here defensively"
3536    )
3537}
3538
3539/// The unique sum type owning a nullary (empty-payload) variant named
3540/// `name`, if exactly one exists. **Not** the same test `check_ident`'s own
3541/// fallback arm uses (`bynk-check/src/checker/expressions.rs:102-130`) —
3542/// that arm filters candidate owners by name *only*, requires exactly one,
3543/// and only then checks the matched variant's payload (a non-empty payload
3544/// there is a diagnostic, `bynk.types.variant_missing_payload`, not a
3545/// non-match). This filters by name *and* empty payload before the
3546/// uniqueness test, so a name matching one sum's nullary variant and a
3547/// second sum's non-nullary variant of the same name resolves here
3548/// (uniquely nullary) where `check_ident` would reject it (two owners).
3549/// Unreachable on a certified program today — the checker's own stricter
3550/// ladder already rejected that source — so this is a documented
3551/// divergence, not a live bug; re-run here (rather than read back) because
3552/// `check_ident`'s own verdict isn't recorded anywhere a later reader can
3553/// read.
3554fn nullary_variant_owner(name: &str, cx: &LowerIrCtx) -> Option<Arc<bynk_syntax::ast::TypeDecl>> {
3555    let mut owners = cx.program.types.values().filter(|t| {
3556        matches!(&t.body, TypeBody::Sum(s) if s.variants.iter().any(|v| v.name.name == name && v.payload.is_empty()))
3557    });
3558    let owner = owners.next()?;
3559    if owners.next().is_some() {
3560        return None;
3561    }
3562    Some(Arc::clone(owner))
3563}
3564
3565/// The declaring `TypeDecl` for a `Ty::Named` type — used by `Record`'s own
3566/// lowering, where `ty` (this node's already-resolved type) already names
3567/// the type the checker resolved `RecordConstruction`'s `type_name` against.
3568fn named_decl(ty: TyId, cx: &LowerIrCtx) -> Arc<bynk_syntax::ast::TypeDecl> {
3569    let Ty::Named { name, .. } = &*cx.program.ty_intern.get(ty) else {
3570        panic!(
3571            "bynk internal error (ADR 0334): a RecordConstruction's own resolved type is not \
3572             Ty::Named — the checker only ever types one as its declaring record type"
3573        )
3574    };
3575    Arc::clone(cx.program.types.get(name).unwrap_or_else(|| {
3576        panic!(
3577            "bynk internal error (ADR 0334): `{name}` has no TypedCommons::types entry, but a \
3578             RecordConstruction just resolved to it"
3579        )
3580    }))
3581}
3582
3583/// `TypeName { ...base, field: value, ... }` -> `Block { stmts: [Let(tmp,
3584/// base), <discarded shadowed-override effects>], tail: Record {
3585/// <complete, resolved field list> } }` (P6.3, design/tracks/the-ir.md §6,
3586/// R6.7 — the reference's own Part 6.4 table, Decision E). Every field the
3587/// target record declares is present in the tail `Record`, each resolved
3588/// from `overrides` when named there, or otherwise from a synthesised
3589/// `tmp.<field>` read — the same complete-by-construction shape
3590/// `RecordConstruction` already lowers to, not the current string emitter's
3591/// own raw `...spread` splice (`emitter/lower.rs`'s `lower_record_spread`).
3592///
3593/// `fields`' own order is *evaluation* order, not declared-field order (the
3594/// convention `RecordConstruction`'s own lowering above already sets, by
3595/// simply preserving whatever order its own source `fields` were written
3596/// in): every overridden field lands in `fields` in the *source* order its
3597/// override was written, ahead of every spread-through field (declared
3598/// order, since a bare `tmp.<field>` read has no side effect of its own, so
3599/// its exact position among the others is not observable). This matters
3600/// because an override's value may be effectful (`body_performs_effects`,
3601/// `bynk-check/src/checker/expressions.rs:1303`, walks `overrides` for
3602/// exactly that reason) — the current string emitter's own splice already
3603/// evaluates overrides in source order, and a future consumer reading
3604/// `fields` left-to-right must reproduce that, not silently reorder it to
3605/// match field *declaration* order instead.
3606fn lower_record_spread_ir(
3607    ty: TyId,
3608    span: Span,
3609    base: &Expr,
3610    overrides: &[FieldInit],
3611    cx: &mut LowerIrCtx,
3612) -> IrExpr {
3613    // `ty` is this spread's own resolved type — `check_record_spread`
3614    // returns the base's own type unchanged (Some(base_ty)), so the
3615    // declaring `TypeDecl` and its applied type arguments both come from
3616    // here, the same way `RecordConstruction`'s lowering reads `def` back
3617    // from its own resolved type rather than re-resolving `type_name`.
3618    let def = named_decl(ty, cx);
3619    let base_args = match &*cx.program.ty_intern.get(ty) {
3620        Ty::Named { args, .. } => args.clone(),
3621        _ => unreachable!("named_decl above already panics on a non-Ty::Named `ty`"),
3622    };
3623    let TypeBody::Record(record_body) = &def.body else {
3624        panic!(
3625            "bynk internal error (ADR 0334): `{}` is a RecordSpread's own resolved record type, \
3626             but its declaration is not TypeBody::Record",
3627            def.name.name
3628        )
3629    };
3630    let declared: HashSet<&str> = record_body
3631        .fields
3632        .iter()
3633        .map(|f| f.name.name.as_str())
3634        .collect();
3635
3636    let base_ir = lower_expr_ir(base, cx);
3637    let base_ty = base_ir.ty;
3638    let base_span = base_ir.span;
3639    let tmp = cx.fresh_spread_tmp();
3640    let mut stmts = vec![IrStmt::Let {
3641        local: tmp.clone(),
3642        value: base_ir,
3643    }];
3644
3645    // Lower every override's value in source order — same order the string
3646    // emitter's own splice and `body_performs_effects`'s own walk already
3647    // use. `check_record_spread` (`bynk-check/src/checker/expressions.rs:2269`)
3648    // has no duplicate-name diagnostic, so a field named more than once
3649    // type-checks today; `overridden` keeps every occurrence (not just the
3650    // last) so a shadowed occurrence's own effect isn't silently dropped
3651    // below.
3652    let overridden: Vec<(String, IrExpr)> = overrides
3653        .iter()
3654        .map(|f| {
3655            if !declared.contains(f.name.name.as_str()) {
3656                // check_record_spread's own bynk.record_spread.unknown_field
3657                // diagnostic already rejects this on any program that
3658                // reaches lowering — ADR 0334 discipline: a live mismatch
3659                // here is this pass and the checker disagreeing about an
3660                // already-certified program, a compiler bug, not a silent
3661                // skip.
3662                panic!(
3663                    "bynk internal error (ADR 0334): record spread override `{}` names a field \
3664                     `{}` does not declare, but the checker already accepted this spread",
3665                    f.name.name, def.name.name
3666                )
3667            }
3668            let value = match &f.value {
3669                // An override's own value is lowered exactly like
3670                // `RecordConstruction`'s explicit/shorthand field above —
3671                // same two forms, same rules for each.
3672                Some(v) => lower_expr_ir(v, cx),
3673                None => {
3674                    let local_ty = cx.lookup(&f.name.name).unwrap_or_else(|| {
3675                        panic!(
3676                            "bynk internal error (ADR 0334): shorthand spread override `{}` has \
3677                             no local binding in this pass's own scope — the checker accepted \
3678                             it, so its own `ctx.lookup` must have found one",
3679                            f.name.name
3680                        )
3681                    });
3682                    IrExpr {
3683                        kind: lower_ident_ir(&f.name.name, None, cx),
3684                        ty: local_ty,
3685                        span: f.name.span,
3686                    }
3687                }
3688            };
3689            (f.name.name.clone(), value)
3690        })
3691        .collect();
3692
3693    // A name's *last* occurrence is the one whose value the checker actually
3694    // admits into the resulting record (`check_record_spread` type-checks
3695    // every occurrence but the emitted value is always the last-written
3696    // one) — every earlier occurrence still ran, so it becomes a discarded
3697    // statement here, preserving its own effect without contributing a
3698    // field.
3699    let mut last_index: HashMap<String, usize> = HashMap::new();
3700    for (i, (name, _)) in overridden.iter().enumerate() {
3701        last_index.insert(name.clone(), i);
3702    }
3703    let mut fields: Vec<(String, IrExpr)> = Vec::with_capacity(record_body.fields.len());
3704    let mut overridden_names: HashSet<String> = HashSet::new();
3705    for (i, (name, value)) in overridden.into_iter().enumerate() {
3706        if last_index[&name] == i {
3707            overridden_names.insert(name.clone());
3708            fields.push((name, value));
3709        } else {
3710            stmts.push(IrStmt::Expr { value });
3711        }
3712    }
3713
3714    // Spread-through fields — every declared field `overrides` didn't name
3715    // — appended after, in declared order; a bare `tmp.<field>` read has no
3716    // effect of its own, so no ordering among these is observable. The
3717    // field's own type is the declared type instantiated at this spread's
3718    // own base type arguments (v0.157/ADR 0183's `instantiate_field_ty`,
3719    // the same substitution `check_record_spread` already applies to
3720    // type-check an override against a generic record).
3721    for decl_field in &record_body.fields {
3722        if overridden_names.contains(decl_field.name.name.as_str()) {
3723            continue;
3724        }
3725        let field_ty = checker::instantiate_field_ty(
3726            &def,
3727            &base_args,
3728            &decl_field.type_ref,
3729            &cx.program.types,
3730            &cx.program.ty_intern,
3731        )
3732        .unwrap_or_else(|| {
3733            panic!(
3734                "bynk internal error (ADR 0334): declared field `{}` of `{}` does not resolve \
3735                 against this spread's own base type arguments, but the checker already \
3736                 accepted this record spread",
3737                decl_field.name.name, def.name.name
3738            )
3739        });
3740        fields.push((
3741            decl_field.name.name.clone(),
3742            IrExpr {
3743                kind: IrExprKind::Field {
3744                    base: Box::new(IrExpr {
3745                        kind: IrExprKind::Local(tmp.clone()),
3746                        ty: base_ty,
3747                        span: base_span,
3748                    }),
3749                    field: decl_field.name.name.clone(),
3750                },
3751                ty: field_ty,
3752                span: decl_field.span,
3753            },
3754        ));
3755    }
3756
3757    IrExpr {
3758        kind: IrExprKind::Block {
3759            stmts,
3760            tail: Box::new(IrExpr {
3761                kind: IrExprKind::Record { fields },
3762                ty,
3763                span,
3764            }),
3765        },
3766        ty,
3767        span,
3768    }
3769}
3770
3771/// P6.4 (design/tracks/the-ir.md §6, #1157): `&Pattern -> IrPat`, tested
3772/// standalone against real certified programs. Since P6.5 (#1159), also
3773/// reached indirectly through [`lower_expr_ir`]'s `Match` arm (via
3774/// [`lower_arm_ir`]). `Question`/`Is` landed separately (P6.15/ADR 0337,
3775/// P6.16/ADR 0338, corrected P6.25) — `Question` reuses this same `IrPat`
3776/// machinery (its own `IrExprKind::Match` desugar, per its ADR), `Is` does
3777/// not (`lower_is_ir` calls [`lower_pattern_ir`] directly, not through
3778/// `Match`).
3779///
3780/// `scrutinee_ty` is the type of whatever value this particular pattern
3781/// matches against — the match's own discriminant at the top level, or a
3782/// payload field's own type one level down inside a `Variant` pattern.
3783/// Every leaf but `Variant` ignores it structurally (a literal/binding/
3784/// wildcard pattern's own shape never depends on the scrutinee's type); a
3785/// `Variant` pattern uses it to resolve `tag`/`fields` through the
3786/// checker's own `variants_of` (Decision A) — the one place `bynk-check`
3787/// state is required at all, since `IrPat` otherwise needs nothing beyond
3788/// the AST `Pattern` it lowers.
3789fn lower_pattern_ir(pattern: &Pattern, scrutinee_ty: TyId, program: &TypedCommons) -> IrPat {
3790    match pattern {
3791        Pattern::Wildcard(_) => IrPat::Wild,
3792        Pattern::Binding(id) => IrPat::Bind {
3793            local: id.name.clone(),
3794        },
3795        Pattern::Literal { value, .. } => IrPat::Const {
3796            value: match value {
3797                LiteralValue::Int(n) => ConstVal::Int(*n),
3798                LiteralValue::Str(s) => ConstVal::Str(s.clone()),
3799                LiteralValue::Bool(b) => ConstVal::Bool(*b),
3800            },
3801        },
3802        Pattern::Variant {
3803            variant, bindings, ..
3804        } => {
3805            let variant_info = variant_info_of(scrutinee_ty, &variant.name, program);
3806            let fields = bindings
3807                .iter()
3808                .enumerate()
3809                .map(|(idx, b)| match &b.kind {
3810                    PatternBindingKind::Named { field, pattern } => {
3811                        let field_ty = variant_info
3812                            .payload
3813                            .iter()
3814                            .find(|(name, _)| name == &field.name)
3815                            .map(|(_, ty)| *ty)
3816                            .unwrap_or_else(|| {
3817                                panic!(
3818                                    "bynk internal error (ADR 0334): named pattern field `{}` \
3819                                     does not resolve against variant `{}`'s own payload, but \
3820                                     the checker already accepted this pattern",
3821                                    field.name, variant.name
3822                                )
3823                            });
3824                        (
3825                            field.name.clone(),
3826                            Box::new(lower_pattern_ir(pattern, field_ty, program)),
3827                        )
3828                    }
3829                    PatternBindingKind::Positional { pattern } => {
3830                        let (name, field_ty) =
3831                            variant_info.payload.get(idx).cloned().unwrap_or_else(|| {
3832                                panic!(
3833                                    "bynk internal error (ADR 0334): positional pattern binding \
3834                                     {idx} has no matching payload field on variant `{}`, but \
3835                                     the checker already accepted this pattern's arity",
3836                                    variant.name
3837                                )
3838                            });
3839                        (name, Box::new(lower_pattern_ir(pattern, field_ty, program)))
3840                    }
3841                })
3842                .collect();
3843            IrPat::Variant {
3844                scrutinee_ty,
3845                tag: variant.name.clone(),
3846                fields,
3847            }
3848        }
3849        Pattern::Refined {
3850            inner, predicate, ..
3851        } => IrPat::Refined {
3852            inner: Box::new(lower_pattern_ir(inner, scrutinee_ty, program)),
3853            refinement: predicate.clone(),
3854        },
3855        Pattern::Or(alts, _) => IrPat::Or {
3856            alts: alts
3857                .iter()
3858                .map(|p| lower_pattern_ir(p, scrutinee_ty, program))
3859                .collect(),
3860        },
3861    }
3862}
3863
3864/// Shared by [`lower_pattern_ir`]'s `Variant` arm and
3865/// [`collect_pattern_binding_tys`]'s own mirror walk: resolve `tag` against
3866/// `scrutinee_ty` through the checker's `variants_of` (Decision A, R5.11).
3867/// `.expect()`-not-fallback (ADR 0334) — both call sites are reached only
3868/// from a certified program's own pattern, which `check_pattern`
3869/// (`bynk-check/src/checker/expressions.rs:3162`) already required to name
3870/// a real variant of a real variant-kind scrutinee.
3871fn variant_info_of(scrutinee_ty: TyId, tag: &str, program: &TypedCommons) -> checker::VariantInfo {
3872    checker::variants_of(scrutinee_ty, &program.types, program.tys())
3873        .unwrap_or_else(|| {
3874            panic!(
3875                "bynk internal error (ADR 0334): variant pattern `{tag}` matches against a \
3876                 non-variant-kind scrutinee, but the checker already accepted this pattern"
3877            )
3878        })
3879        .into_iter()
3880        .find(|v| v.name == tag)
3881        .unwrap_or_else(|| {
3882            panic!(
3883                "bynk internal error (ADR 0334): scrutinee has no variant `{tag}`, but the \
3884                 checker already accepted this pattern"
3885            )
3886        })
3887}
3888
3889/// Mirrors [`lower_pattern_ir`]'s own recursive walk but collects `(name,
3890/// TyId)` pairs instead of building `IrPat` nodes — [`lower_arm_ir`]'s own
3891/// need, not `IrPat`'s: a bound name's type has nowhere to live on `IrPat`
3892/// itself (R6.1 is an `IrExpr` rule, not a pattern rule; the reference's own
3893/// `IrPat::Bind` carries no type either), yet this pass's `guard`/`body`
3894/// lowering needs every bound name in scope to resolve as a `Local` the
3895/// same way a `Block`'s own `Let` does. The checker's own equivalent table
3896/// (`Ctx::pattern_binding_types`) is transient — never persisted onto
3897/// `TypedCommons` — so this pass re-derives it here rather than reading a
3898/// checked-output field.
3899fn collect_pattern_binding_tys(
3900    pattern: &Pattern,
3901    ty: TyId,
3902    program: &TypedCommons,
3903    out: &mut Vec<(String, TyId)>,
3904) {
3905    match pattern {
3906        Pattern::Wildcard(_) | Pattern::Literal { .. } => {}
3907        Pattern::Binding(id) => out.push((id.name.clone(), ty)),
3908        Pattern::Variant {
3909            variant, bindings, ..
3910        } => {
3911            let variant_info = variant_info_of(ty, &variant.name, program);
3912            // `.expect()`-not-fallback (ADR 0334), matching `lower_pattern_ir`'s
3913            // identical lookups on the identical condition — this walk runs
3914            // over the same certified pattern, so a miss here is the same
3915            // checker/lowering disagreement, not a softer case.
3916            for (idx, b) in bindings.iter().enumerate() {
3917                match &b.kind {
3918                    PatternBindingKind::Named { field, pattern } => {
3919                        let field_ty = variant_info
3920                            .payload
3921                            .iter()
3922                            .find(|(name, _)| name == &field.name)
3923                            .map(|(_, ty)| *ty)
3924                            .unwrap_or_else(|| {
3925                                panic!(
3926                                    "bynk internal error (ADR 0334): named pattern field `{}` \
3927                                     does not resolve against variant `{}`'s own payload, but \
3928                                     the checker already accepted this pattern",
3929                                    field.name, variant.name
3930                                )
3931                            });
3932                        collect_pattern_binding_tys(pattern, field_ty, program, out);
3933                    }
3934                    PatternBindingKind::Positional { pattern } => {
3935                        let (_, field_ty) =
3936                            variant_info.payload.get(idx).cloned().unwrap_or_else(|| {
3937                                panic!(
3938                                    "bynk internal error (ADR 0334): positional pattern binding \
3939                                     {idx} has no matching payload field on variant `{}`, but \
3940                                     the checker already accepted this pattern's arity",
3941                                    variant.name
3942                                )
3943                            });
3944                        collect_pattern_binding_tys(pattern, field_ty, program, out);
3945                    }
3946                }
3947            }
3948        }
3949        Pattern::Refined { inner, .. } => collect_pattern_binding_tys(inner, ty, program, out),
3950        // Every alternative binds the same names at the same types
3951        // (`check_or_pattern_bindings`'s own Rule 1/2) — the first
3952        // alternative only, matching `Pattern::bound_names`'s own
3953        // defensive default.
3954        Pattern::Or(alts, _) => {
3955            if let Some(first) = alts.first() {
3956                collect_pattern_binding_tys(first, ty, program, out);
3957            }
3958        }
3959    }
3960}
3961
3962/// R5.5 (Decision C): `true` iff `pat` contains an `Or` anywhere in its own
3963/// tree — the fact [`IrArm::binding_mode`] records once rather than the
3964/// emitter re-discovering it at emission time.
3965fn ir_pat_contains_or(pat: &IrPat) -> bool {
3966    match pat {
3967        IrPat::Wild | IrPat::Bind { .. } | IrPat::Const { .. } => false,
3968        IrPat::Variant { fields, .. } => fields.iter().any(|(_, p)| ir_pat_contains_or(p)),
3969        IrPat::Refined { inner, .. } => ir_pat_contains_or(inner),
3970        IrPat::Or { .. } => true,
3971    }
3972}
3973
3974/// P6.4 (#1157): the non-form-deciding parts of a `MatchArm` —
3975/// `pat`/`guard`/`body`/`binds`/`binding_mode` — everything but the
3976/// `MatchForm` policy R5.2 decides, which P6.5 (#1159) builds separately
3977/// (`match_needs_if_chain`, reused from the string emitter — Decision B).
3978/// Tested standalone the same way [`lower_pattern_ir`] is; since P6.5, also
3979/// called per-arm by [`lower_expr_ir`]'s real `Match` arm.
3980///
3981/// R5.4's ordering (structural test, then refinement, then bindings, then
3982/// guard) is why `guard`/`body` are lowered with the pattern's own bound
3983/// names already pushed into scope — a guard must be able to read them.
3984fn lower_arm_ir(arm: &MatchArm, scrutinee_ty: TyId, cx: &mut LowerIrCtx) -> IrArm {
3985    let pat = lower_pattern_ir(&arm.pattern, scrutinee_ty, cx.program);
3986    let binds: Vec<String> = arm
3987        .pattern
3988        .bound_names()
3989        .into_iter()
3990        .map(|id| id.name.clone())
3991        .collect();
3992
3993    let mut bind_tys = Vec::new();
3994    collect_pattern_binding_tys(&arm.pattern, scrutinee_ty, cx.program, &mut bind_tys);
3995
3996    cx.push_scope();
3997    for (name, ty) in bind_tys {
3998        cx.bind(name, ty);
3999    }
4000    let guard = arm.guard.as_ref().map(|g| lower_expr_ir(g, cx));
4001    let body = match &arm.body {
4002        MatchBody::Expr(e) => lower_expr_ir(e, cx),
4003        MatchBody::Block(b) => lower_block_ir(b, cx),
4004    };
4005    cx.pop_scope();
4006
4007    let binding_mode = if ir_pat_contains_or(&pat) {
4008        BindingMode::OrDispatch
4009    } else {
4010        BindingMode::Direct
4011    };
4012
4013    IrArm {
4014        pat,
4015        guard,
4016        body,
4017        binds,
4018        binding_mode,
4019    }
4020}
4021
4022/// P6.4 (#1157, Decision B — extends ADR 0334's `.expect()`-not-fallback
4023/// discipline to a second rule): a certified `CheckedProgram` can never
4024/// contain a structurally non-exhaustive match — every diagnostic path that
4025/// would produce one (`bynk.types.non_exhaustive_match`) is error-severity,
4026/// and `certify` (R3.10) rejects any unit carrying one. This pass therefore
4027/// never re-derives the verdict `bynk-check`'s own `saw_wildcard`/
4028/// `missing_patterns` machinery already computed — it trusts it, checking
4029/// only the one structural fact that verdict *implies* and this function
4030/// can cheaply confirm without rebuilding `missing_patterns`' own witness
4031/// machinery: a guarded arm never contributes to coverage
4032/// (`bynk-check/src/checker/expressions.rs`'s own `unguarded` filter,
4033/// `:3016`/`:3034`), so a certified match always has at least one unguarded
4034/// arm. `Exhaustive::Partial` stays real, inhabited code (a future
4035/// non-certified producer's own lane) — just never constructed here.
4036fn lower_exhaustive_ir(arms: &[MatchArm]) -> Exhaustive {
4037    if arms.iter().any(|a| a.guard.is_none()) {
4038        Exhaustive::Total
4039    } else {
4040        unreachable!(
4041            "bynk internal error (ADR 0334, extended by Decision B / #1157): a certified \
4042             program's match is never empty (the parser itself already requires at least one \
4043             arm) and always has at least one unguarded arm — bynk-check's own missing_patterns \
4044             gate (bynk.types.non_exhaustive_match, error-severity, rejected by certify per \
4045             R3.10) guarantees it"
4046        )
4047    }
4048}
4049
4050/// Decision E: targeted minimal fixtures, one per node kind this slice
4051/// covers, staying strictly inside the subset [`lower_expr_ir`]/
4052/// [`lower_stmt_ir`] actually implement — not a walk over the real
4053/// `bynkc/tests/fixtures/positive` corpus, which hits an unimplemented
4054/// `Match`/`Call` arm within a few lines of almost any real fixture.
4055#[cfg(test)]
4056mod tests {
4057    use super::*;
4058    use bynk_check::builtin_names::types::QUEUE_RESULT;
4059    use bynk_check::checker::CheckedProgram;
4060    use bynk_check::hints::HintSink;
4061    use bynk_check::index::RefSink;
4062    use bynk_check::locals::LocalsSink;
4063    use bynk_check::requirements::RequirementSink;
4064    use bynk_check::{checker, context_checks, resolver, symbols};
4065    use bynk_project::UnitKind;
4066    use bynk_syntax::ast::PredKind;
4067    use bynk_syntax::ast::{Commons, CommonsItem, FnDecl, SourceUnit};
4068    use bynk_syntax::{lexer, parser};
4069
4070    fn checked_program(source: &str) -> CheckedProgram {
4071        let tokens = lexer::tokenize(source).expect("lex");
4072        let (commons, warnings) = parser::parse_with_warnings(&tokens, source).expect("parse");
4073        let resolved = resolver::resolve(commons).expect("resolve");
4074        let typed = checker::check(resolved).expect("check");
4075        checker::certify(typed, warnings).expect("certify")
4076    }
4077
4078    fn find_fn<'a>(program: &'a CheckedProgram, name: &str) -> &'a FnDecl {
4079        program
4080            .program()
4081            .commons
4082            .items
4083            .iter()
4084            .find_map(|item| match item {
4085                CommonsItem::Fn(f) if f.name.display() == name => Some(f),
4086                _ => None,
4087            })
4088            .unwrap_or_else(|| panic!("no fn named `{name}` in this fixture"))
4089    }
4090
4091    fn lower_fn(program: &CheckedProgram, name: &str) -> IrExpr {
4092        let f = find_fn(program, name);
4093        lower_fn_body_ir(f, program)
4094    }
4095
4096    fn find_type<'a>(program: &'a CheckedProgram, name: &str) -> &'a Arc<TypeDecl> {
4097        program
4098            .program()
4099            .types
4100            .get(name)
4101            .unwrap_or_else(|| panic!("no type named `{name}` in this fixture"))
4102    }
4103
4104    /// Like [`find_fn`], but returns the program's own `Arc<FnDecl>` handle
4105    /// (from `TypedCommons.fns`/`.methods`, not re-walked from
4106    /// `commons.items`) — [`lower_fn_item_ir`] takes `&Arc<FnDecl>`
4107    /// specifically so `IrItem::Fn::def` can reuse this same `Arc`.
4108    fn find_fn_arc<'a>(program: &'a CheckedProgram, name: &str) -> &'a Arc<FnDecl> {
4109        if let Some((type_name, method_name)) = name.split_once('.') {
4110            let table = program.program().methods.get(type_name).unwrap_or_else(|| {
4111                panic!("no method table for type `{type_name}` in this fixture")
4112            });
4113            return table
4114                .instance
4115                .get(method_name)
4116                .or_else(|| table.statics.get(method_name))
4117                .unwrap_or_else(|| panic!("no method named `{name}` in this fixture"));
4118        }
4119        program
4120            .program()
4121            .fns
4122            .get(name)
4123            .unwrap_or_else(|| panic!("no fn named `{name}` in this fixture"))
4124    }
4125
4126    /// Every fixture's `fn`-body wrapping — asserts the outer shape once so
4127    /// each node-kind test below only asserts its own tail, not this too.
4128    fn fn_tail(ir: &IrExpr) -> &IrExpr {
4129        let IrExprKind::Block { stmts, tail } = &ir.kind else {
4130            panic!(
4131                "lower_fn_body_ir always returns IrExprKind::Block, got {:?}",
4132                ir.kind
4133            )
4134        };
4135        assert!(
4136            stmts.is_empty(),
4137            "this helper is for single-tail bodies only"
4138        );
4139        let IrExprKind::Return { value } = &tail.kind else {
4140            panic!(
4141                "a fn body's own tail is always wrapped in Return, got {:?}",
4142                tail.kind
4143            )
4144        };
4145        value
4146    }
4147
4148    #[test]
4149    fn const_covers_every_bynk_literal_form() {
4150        let program = checked_program(
4151            r#"
4152commons demo {
4153  fn int_lit() -> Int { 1 }
4154  fn float_lit() -> Float { 1.5 }
4155  fn duration_lit() -> Duration { 5.minutes }
4156  fn str_lit() -> String { "hi" }
4157  fn bool_lit() -> Bool { true }
4158  fn unit_lit() -> () { () }
4159}
4160"#,
4161        );
4162        let cases: &[(&str, ConstVal)] = &[
4163            ("int_lit", ConstVal::Int(1)),
4164            ("float_lit", ConstVal::Float(1.5)),
4165            ("duration_lit", ConstVal::DurationMillis(5 * 60 * 1000)),
4166            ("str_lit", ConstVal::Str("hi".to_string())),
4167            ("bool_lit", ConstVal::Bool(true)),
4168            ("unit_lit", ConstVal::Unit),
4169        ];
4170        for (fn_name, expected) in cases {
4171            let ir = lower_fn(&program, fn_name);
4172            let tail = fn_tail(&ir);
4173            let IrExprKind::Const(actual) = &tail.kind else {
4174                panic!("{fn_name}: expected Const, got {:?}", tail.kind)
4175            };
4176            assert_eq!(actual, expected, "{fn_name}");
4177        }
4178    }
4179
4180    #[test]
4181    fn local_reads_a_bound_param() {
4182        let program = checked_program(
4183            r#"
4184commons demo {
4185  fn identity(n: Int) -> Int { n }
4186}
4187"#,
4188        );
4189        let ir = lower_fn(&program, "identity");
4190        let tail = fn_tail(&ir);
4191        assert!(matches!(&tail.kind, IrExprKind::Local(name) if name == "n"));
4192    }
4193
4194    #[test]
4195    fn generic_fn_type_parameters_are_rigid_variables_not_unresolvable_declared_types() {
4196        // `resolve_type_ref` (no `vars` set) resolves a fn's own type
4197        // parameter `T` as an unknown *declared* type and fails — the same
4198        // failure mode `checker.rs`'s own `Ctx::type_vars` +
4199        // `resolve_type_ref_in` exists to avoid. Without it, `x`'s own type
4200        // never binds, and this test's own body — a bare `Local` — would
4201        // wrongly fall through to `lower_ident_ir`'s `todo!()`.
4202        let program = checked_program(
4203            r#"
4204commons demo {
4205  fn identity[T](x: T) -> T { x }
4206}
4207"#,
4208        );
4209        let ir = lower_fn(&program, "identity");
4210        let tail = fn_tail(&ir);
4211        assert!(matches!(&tail.kind, IrExprKind::Local(name) if name == "x"));
4212    }
4213
4214    #[test]
4215    fn global_covers_a_bare_nullary_sum_variant() {
4216        let program = checked_program(
4217            r#"
4218commons demo {
4219  type Outcome =
4220    | Hit(score: Int)
4221    | Miss
4222
4223  fn make() -> Outcome { Miss }
4224}
4225"#,
4226        );
4227        let ir = lower_fn(&program, "make");
4228        let tail = fn_tail(&ir);
4229        let IrExprKind::Global(g) = &tail.kind else {
4230            panic!("expected Global, got {:?}", tail.kind)
4231        };
4232        assert_eq!(g.tag, "Miss");
4233    }
4234
4235    #[test]
4236    #[should_panic(expected = "Callee/Lambda-adjacent")]
4237    fn bare_free_function_reference_is_excluded_from_the_global_probe() {
4238        // A bare function-value reference (not a call) is the one non-local,
4239        // non-variant ident this pass can actually reach — `lower_ident_ir`
4240        // must stop here rather than risk `nullary_variant_owner` matching a
4241        // same-named variant by coincidence (a real collision risk a review
4242        // of this slice named directly).
4243        let program = checked_program(
4244            r#"
4245commons demo {
4246  fn double(n: Int) -> Int { n * 2 }
4247
4248  fn get_double() -> (Int) -> Int { double }
4249}
4250"#,
4251        );
4252        let _ = lower_fn(&program, "get_double");
4253    }
4254
4255    #[test]
4256    fn record_construction_covers_explicit_and_shorthand_fields() {
4257        let program = checked_program(
4258            r#"
4259commons demo {
4260  type Point = { x: Int, y: Int }
4261
4262  fn explicit() -> Point { Point { x: 1, y: 2 } }
4263  fn shorthand(x: Int, y: Int) -> Point { Point { x, y } }
4264}
4265"#,
4266        );
4267        let explicit_ir = lower_fn(&program, "explicit");
4268        let explicit_tail = fn_tail(&explicit_ir);
4269        let IrExprKind::Record {
4270            fields: explicit_fields,
4271        } = &explicit_tail.kind
4272        else {
4273            panic!("explicit: expected Record, got {:?}", explicit_tail.kind)
4274        };
4275        assert_eq!(explicit_fields.len(), 2);
4276        assert_eq!(explicit_fields[0].0, "x");
4277        assert!(matches!(
4278            &explicit_fields[0].1.kind,
4279            IrExprKind::Const(ConstVal::Int(1))
4280        ));
4281        assert_eq!(explicit_fields[1].0, "y");
4282        assert!(matches!(
4283            &explicit_fields[1].1.kind,
4284            IrExprKind::Const(ConstVal::Int(2))
4285        ));
4286
4287        let shorthand_ir = lower_fn(&program, "shorthand");
4288        let shorthand_tail = fn_tail(&shorthand_ir);
4289        let IrExprKind::Record {
4290            fields: shorthand_fields,
4291        } = &shorthand_tail.kind
4292        else {
4293            panic!("shorthand: expected Record, got {:?}", shorthand_tail.kind)
4294        };
4295        assert_eq!(shorthand_fields.len(), 2);
4296        assert_eq!(shorthand_fields[0].0, "x");
4297        assert!(matches!(&shorthand_fields[0].1.kind, IrExprKind::Local(n) if n == "x"));
4298        assert_eq!(shorthand_fields[1].0, "y");
4299        assert!(matches!(&shorthand_fields[1].1.kind, IrExprKind::Local(n) if n == "y"));
4300        // The shorthand path is the only reason `LowerIrCtx` tracks a scope
4301        // stack at all — assert the thing it uniquely produces (a field's
4302        // `ty`, taken from `cx.lookup` since a shorthand field has no
4303        // `ExprId` of its own) actually matches the param's real type.
4304        assert!(matches!(
4305            &*program.program().ty_intern.get(shorthand_fields[0].1.ty),
4306            Ty::Base(bynk_syntax::ast::BaseType::Int)
4307        ));
4308        assert!(matches!(
4309            &*program.program().ty_intern.get(shorthand_fields[1].1.ty),
4310            Ty::Base(bynk_syntax::ast::BaseType::Int)
4311        ));
4312    }
4313
4314    #[test]
4315    fn field_access_reads_a_record_field() {
4316        let program = checked_program(
4317            r#"
4318commons demo {
4319  type Point = { x: Int, y: Int }
4320
4321  fn get_x(p: Point) -> Int { p.x }
4322}
4323"#,
4324        );
4325        let ir = lower_fn(&program, "get_x");
4326        let tail = fn_tail(&ir);
4327        let IrExprKind::Field { base, field } = &tail.kind else {
4328            panic!("expected Field, got {:?}", tail.kind)
4329        };
4330        assert_eq!(field, "x");
4331        assert!(matches!(&base.kind, IrExprKind::Local(name) if name == "p"));
4332    }
4333
4334    #[test]
4335    fn list_literal_lowers_every_element() {
4336        let program = checked_program(
4337            r#"
4338commons demo {
4339  fn make() -> List[Int] { [1, 2, 3] }
4340}
4341"#,
4342        );
4343        let ir = lower_fn(&program, "make");
4344        let tail = fn_tail(&ir);
4345        let IrExprKind::List { elems } = &tail.kind else {
4346            panic!("expected List, got {:?}", tail.kind)
4347        };
4348        assert_eq!(elems.len(), 3);
4349        assert!(matches!(
4350            &elems[0].kind,
4351            IrExprKind::Const(ConstVal::Int(1))
4352        ));
4353    }
4354
4355    #[test]
4356    fn if_lowers_both_branches_as_blocks_not_return_wrapped() {
4357        // Also this slice's only real coverage of nested (non-fn-body) Block
4358        // lowering: `bynk_syntax::parser` constructs `ExprKind::Block` only
4359        // as a lambda body (`parser/statements.rs:421-428`, P6.2 territory) —
4360        // an `if`/`else` branch's `Block` is a bare AST field
4361        // (`ExprKind::If { then_block: Box<Block>, .. }`), not wrapped in
4362        // `ExprKind::Block`, but it is lowered through the exact same
4363        // `lower_block_ir` an `ExprKind::Block` would be, so this is the
4364        // real, reachable test for "a nested block is not Return-wrapped,
4365        // unlike a fn body's own outermost block" (`lower_fn_body_ir`'s own
4366        // doc comment).
4367        let program = checked_program(
4368            r#"
4369commons demo {
4370  fn choose(b: Bool) -> Int {
4371    if b { 1 } else { 2 }
4372  }
4373}
4374"#,
4375        );
4376        let ir = lower_fn(&program, "choose");
4377        let tail = fn_tail(&ir);
4378        let IrExprKind::If { cond, then_, else_ } = &tail.kind else {
4379            panic!("expected If, got {:?}", tail.kind)
4380        };
4381        assert!(matches!(&cond.kind, IrExprKind::Local(name) if name == "b"));
4382        let IrExprKind::Block {
4383            tail: then_tail, ..
4384        } = &then_.kind
4385        else {
4386            panic!("expected then_ to be a Block, got {:?}", then_.kind)
4387        };
4388        assert!(matches!(
4389            &then_tail.kind,
4390            IrExprKind::Const(ConstVal::Int(1))
4391        ));
4392        let IrExprKind::Block {
4393            tail: else_tail, ..
4394        } = &else_.kind
4395        else {
4396            panic!("expected else_ to be a Block, got {:?}", else_.kind)
4397        };
4398        assert!(matches!(
4399            &else_tail.kind,
4400            IrExprKind::Const(ConstVal::Int(2))
4401        ));
4402    }
4403
4404    #[test]
4405    fn and_or_not_are_real_tree_nodes() {
4406        let program = checked_program(
4407            r#"
4408commons demo {
4409  fn conj(a: Bool, b: Bool) -> Bool { a && b }
4410  fn disj(a: Bool, b: Bool) -> Bool { a || b }
4411  fn negate(a: Bool) -> Bool { !a }
4412}
4413"#,
4414        );
4415        let and_ir = lower_fn(&program, "conj");
4416        assert!(matches!(fn_tail(&and_ir).kind, IrExprKind::And { .. }));
4417        let or_ir = lower_fn(&program, "disj");
4418        assert!(matches!(fn_tail(&or_ir).kind, IrExprKind::Or { .. }));
4419        let not_ir = lower_fn(&program, "negate");
4420        assert!(matches!(fn_tail(&not_ir).kind, IrExprKind::Not { .. }));
4421    }
4422
4423    #[test]
4424    fn pure_wraps_a_synchronous_value_as_effect() {
4425        let program = checked_program(
4426            r#"
4427commons demo {
4428  fn make() -> Effect[Int] { Effect.pure(1) }
4429}
4430"#,
4431        );
4432        let ir = lower_fn(&program, "make");
4433        let tail = fn_tail(&ir);
4434        let IrExprKind::Pure { value } = &tail.kind else {
4435            panic!("expected Pure, got {:?}", tail.kind)
4436        };
4437        assert!(matches!(&value.kind, IrExprKind::Const(ConstVal::Int(1))));
4438    }
4439
4440    #[test]
4441    fn await_peels_effect_from_an_effect_let_binding() {
4442        let program = checked_program(
4443            r#"
4444commons demo {
4445  fn use_it() -> Effect[Int] {
4446    let x <- Effect.pure(1)
4447    Effect.pure(x)
4448  }
4449}
4450"#,
4451        );
4452        let f = find_fn(&program, "use_it");
4453        let ir = lower_fn_body_ir(f, &program);
4454        let IrExprKind::Block { stmts, .. } = &ir.kind else {
4455            panic!("expected Block")
4456        };
4457        assert_eq!(stmts.len(), 1);
4458        let IrStmt::Let { local, value } = &stmts[0] else {
4459            panic!("expected Let, got {:?}", stmts[0])
4460        };
4461        assert_eq!(local, "x");
4462        assert!(matches!(&value.kind, IrExprKind::Await { .. }));
4463    }
4464
4465    #[test]
4466    fn let_annotation_widens_the_bound_scope_type_not_just_the_rhs_expression() {
4467        // `compatible()` admits a refined value under its own base's
4468        // annotation — `let n: Int = p` with `p: Reps` leaves the checker's
4469        // scope holding `Int`, not the narrower `Reps`. The `let` statement's
4470        // own `value` keeps the RHS expression's own honest (refined) type
4471        // (R6.1); only the *bound* name — read back here by the shorthand
4472        // field `{ n }`, which has no `ExprId` of its own to fall back on —
4473        // must reflect the annotation's widened type instead.
4474        let program = checked_program(
4475            r#"
4476commons demo {
4477  type Reps = Int where InRange(1, 100)
4478  type Wrapper = { n: Int }
4479
4480  fn make(p: Reps) -> Wrapper {
4481    let n: Int = p
4482    Wrapper { n }
4483  }
4484}
4485"#,
4486        );
4487        let f = find_fn(&program, "make");
4488        let ir = lower_fn_body_ir(f, &program);
4489        let IrExprKind::Block { stmts, tail } = &ir.kind else {
4490            panic!("expected Block")
4491        };
4492        let IrStmt::Let { local, value } = &stmts[0] else {
4493            panic!("expected Let, got {:?}", stmts[0])
4494        };
4495        assert_eq!(local, "n");
4496        assert!(matches!(
4497            &*program.program().ty_intern.get(value.ty),
4498            Ty::Named {
4499                kind: bynk_check::checker::NamedKind::Refined(_),
4500                ..
4501            }
4502        ));
4503        let IrExprKind::Return { value: wrapper } = &tail.kind else {
4504            panic!("expected Return, got {:?}", tail.kind)
4505        };
4506        let IrExprKind::Record { fields, .. } = &wrapper.kind else {
4507            panic!("expected Record, got {:?}", wrapper.kind)
4508        };
4509        assert_eq!(fields[0].0, "n");
4510        assert!(matches!(&fields[0].1.kind, IrExprKind::Local(name) if name == "n"));
4511        assert!(matches!(
4512            &*program.program().ty_intern.get(fields[0].1.ty),
4513            Ty::Base(bynk_syntax::ast::BaseType::Int)
4514        ));
4515    }
4516
4517    #[test]
4518    fn do_statement_lowers_to_a_discarded_await() {
4519        let program = checked_program(
4520            r#"
4521commons demo {
4522  fn use_it() -> Effect[()] {
4523    do Effect.pure(())
4524    Effect.pure(())
4525  }
4526}
4527"#,
4528        );
4529        let f = find_fn(&program, "use_it");
4530        let ir = lower_fn_body_ir(f, &program);
4531        let IrExprKind::Block { stmts, .. } = &ir.kind else {
4532            panic!("expected Block")
4533        };
4534        assert_eq!(stmts.len(), 1);
4535        let IrStmt::Expr { value } = &stmts[0] else {
4536            panic!("expected Expr, got {:?}", stmts[0])
4537        };
4538        assert!(matches!(&value.kind, IrExprKind::Await { .. }));
4539    }
4540
4541    #[test]
4542    fn send_statement_lowers_to_a_fire_and_forget_send_typed_unit() {
4543        let program = checked_program(
4544            r#"
4545commons demo {
4546  fn use_it() -> Effect[()] {
4547    ~> Effect.pure(())
4548    Effect.pure(())
4549  }
4550}
4551"#,
4552        );
4553        let f = find_fn(&program, "use_it");
4554        let ir = lower_fn_body_ir(f, &program);
4555        let IrExprKind::Block { stmts, .. } = &ir.kind else {
4556            panic!("expected Block")
4557        };
4558        assert_eq!(stmts.len(), 1);
4559        let IrStmt::Expr { value } = &stmts[0] else {
4560            panic!("expected Expr, got {:?}", stmts[0])
4561        };
4562        assert!(matches!(&value.kind, IrExprKind::Send { .. }));
4563        assert!(matches!(
4564            &*program.program().ty_intern.get(value.ty),
4565            bynk_check::checker::Ty::Unit
4566        ));
4567    }
4568
4569    // P6.2 (#1143): Call/Lambda/Variant, driven entirely by the Callee P6.0
4570    // already recorded. `Callee::Store`/`Callee::Query` are deliberately
4571    // untested here — both dispatch only inside an agent handler body,
4572    // which needs the full context/project pipeline
4573    // (`context_checks::check_context_declarations`, a `UnitTable`,
4574    // `CrossContextInfo`) to check at all; `checked_program`'s own
4575    // single-file `resolver::resolve`/`checker::check` pipeline never
4576    // walks a `CommonsItem::Agent` (only `CommonsItem::Fn`), the same
4577    // "needs the full pipeline" limitation P6.0's own differential test
4578    // (`bynk-check/tests/callee_classification.rs`) already documented for
4579    // `Capability`/`CrossCap`/`Cross`/`Agent`.
4580
4581    #[test]
4582    fn call_driven_by_callee_fn_lowers_a_free_function_call() {
4583        let program = checked_program(
4584            r#"
4585commons demo {
4586  fn double(n: Int) -> Int { n }
4587
4588  fn use_it() -> Int { double(1) }
4589}
4590"#,
4591        );
4592        let ir = lower_fn(&program, "use_it");
4593        let tail = fn_tail(&ir);
4594        let IrExprKind::Call {
4595            callee,
4596            targs,
4597            args,
4598        } = &tail.kind
4599        else {
4600            panic!("expected Call, got {:?}", tail.kind)
4601        };
4602        assert!(matches!(callee, Callee::Fn(f) if f.name.display() == "double"));
4603        assert!(targs.is_empty());
4604        assert_eq!(args.len(), 1);
4605        assert!(matches!(&args[0].kind, IrExprKind::Const(ConstVal::Int(1))));
4606    }
4607
4608    #[test]
4609    fn call_with_an_explicit_type_argument_resolves_targs() {
4610        let program = checked_program(
4611            r#"
4612commons demo {
4613  fn identity[T](x: T) -> T { x }
4614
4615  fn use_it() -> Int { identity[Int](1) }
4616}
4617"#,
4618        );
4619        let ir = lower_fn(&program, "use_it");
4620        let tail = fn_tail(&ir);
4621        let IrExprKind::Call { callee, targs, .. } = &tail.kind else {
4622            panic!("expected Call, got {:?}", tail.kind)
4623        };
4624        assert!(matches!(callee, Callee::Fn(f) if f.name.display() == "identity"));
4625        assert_eq!(targs.len(), 1);
4626        assert!(matches!(
4627            &*program.program().ty_intern.get(targs[0]),
4628            Ty::Base(bynk_syntax::ast::BaseType::Int)
4629        ));
4630    }
4631
4632    #[test]
4633    fn call_with_an_explicit_type_argument_naming_the_enclosing_fns_own_rigid_var() {
4634        // The case `resolve_type_ref_in`'s own `type_vars` set exists for
4635        // (`lower.rs`'s own `LowerIrCtx::resolve_type_ref`) — a wrong
4636        // `type_vars` seed for `wrap`'s own body turns into a panic here
4637        // (`identity[U]`'s own `U` fails to resolve as a declared type)
4638        // rather than a silently wrong result.
4639        let program = checked_program(
4640            r#"
4641commons demo {
4642  fn identity[T](x: T) -> T { x }
4643
4644  fn wrap[U](x: U) -> U { identity[U](x) }
4645}
4646"#,
4647        );
4648        let ir = lower_fn(&program, "wrap");
4649        let tail = fn_tail(&ir);
4650        let IrExprKind::Call { callee, targs, .. } = &tail.kind else {
4651            panic!("expected Call, got {:?}", tail.kind)
4652        };
4653        assert!(matches!(callee, Callee::Fn(f) if f.name.display() == "identity"));
4654        assert_eq!(targs.len(), 1);
4655        assert!(matches!(
4656            &*program.program().ty_intern.get(targs[0]),
4657            Ty::Var(name) if name == "U"
4658        ));
4659    }
4660
4661    #[test]
4662    fn call_driven_by_callee_value_applies_a_function_typed_local() {
4663        let program = checked_program(
4664            r#"
4665commons demo {
4666  fn apply(f: (Int) -> Int, x: Int) -> Int { f(x) }
4667}
4668"#,
4669        );
4670        let ir = lower_fn(&program, "apply");
4671        let tail = fn_tail(&ir);
4672        let IrExprKind::Call { callee, args, .. } = &tail.kind else {
4673            panic!("expected Call, got {:?}", tail.kind)
4674        };
4675        assert!(matches!(callee, Callee::Value(name) if name == "f"));
4676        assert_eq!(args.len(), 1);
4677        assert!(matches!(&args[0].kind, IrExprKind::Local(n) if n == "x"));
4678    }
4679
4680    #[test]
4681    fn bare_and_qualified_variant_construction_both_lower_to_variant() {
4682        let program = checked_program(
4683            r#"
4684commons demo {
4685  type Shape =
4686    | Circle(radius: Int)
4687    | Square(side: Int)
4688
4689  fn bare(n: Int) -> Shape { Circle(n) }
4690  fn qualified(n: Int) -> Shape { Shape.Circle(n) }
4691}
4692"#,
4693        );
4694        for fn_name in ["bare", "qualified"] {
4695            let ir = lower_fn(&program, fn_name);
4696            let tail = fn_tail(&ir);
4697            let IrExprKind::Variant { tag, payload } = &tail.kind else {
4698                panic!("{fn_name}: expected Variant, got {:?}", tail.kind)
4699            };
4700            // #1225's own ADR: no `sum` field on `Variant` itself — the
4701            // constructed sum's own identity is the wrapping `IrExpr::ty`.
4702            assert!(
4703                matches!(
4704                    &*program.program().ty_intern.get(tail.ty),
4705                    Ty::Named { name, .. } if name == "Shape"
4706                ),
4707                "{fn_name}: expected tail.ty to resolve to the Shape sum, got {:?}",
4708                program.program().ty_intern.get(tail.ty)
4709            );
4710            assert_eq!(tag, "Circle");
4711            assert_eq!(payload.len(), 1);
4712            assert!(matches!(&payload[0].kind, IrExprKind::Local(n) if n == "n"));
4713        }
4714    }
4715
4716    /// #1225's own ADR resolution: `Ok`/`Err`/`Some`/`None` each lower to a
4717    /// real `IrExprKind::Variant`, the same node shape a user-declared
4718    /// sum's own `Callee::Ctor` construction lowers to
4719    /// (`bare_and_qualified_variant_construction_both_lower_to_variant`,
4720    /// above) — no separate `IrExprKind` needed for the built-in case, and
4721    /// no `Callee` classification either (confirmed during scoping: neither
4722    /// `check_ok`/`check_err`/`check_some`/`check_none` ever records one).
4723    ///
4724    /// Review of #1227: `tag`/`payload` alone would pass even if `tail.ty`
4725    /// carried the wrong identity (or none at all) — the actual claim the
4726    /// ADR rests on is that the *wrapping* `IrExpr::ty` is what a consumer
4727    /// reads instead of a `sum` field, so each case also asserts `tail.ty`
4728    /// resolves to the built-in sum actually constructed. `ok_http_case`
4729    /// pins the fifth, easy-to-miss shape: `Ok` is overloaded between
4730    /// `Result` and `HttpResult` (`check_ok`, `bynk-check/src/checker/
4731    /// expressions.rs`, peels the surrounding return type to decide), so a
4732    /// handler-shaped `Ok(...)` reaches this same arm with `ty =
4733    /// Ty::HttpResult(_)`, a third sum identity the claim has to cover —
4734    /// `variants_of`'s own `Ty::HttpResult` arm (`checker.rs`, backed by
4735    /// `HTTP_VARIANTS`, `bynk-syntax/src/ast.rs`) resolves it the same way.
4736    #[test]
4737    fn ok_err_some_none_all_lower_to_variant_by_tag_and_carry_their_sum_identity_on_ty() {
4738        let program = checked_program(
4739            r#"
4740commons demo {
4741  fn ok_case() -> Result[Int, String] { Ok(1) }
4742  fn err_case() -> Result[Int, String] { Err("bad") }
4743  fn some_case() -> Option[Int] { Some(1) }
4744  fn none_case() -> Option[Int] { None }
4745  fn ok_http_case() -> HttpResult[Int] { Ok(1) }
4746}
4747"#,
4748        );
4749        for (fn_name, expect_tag, expect_payload_len) in [
4750            ("ok_case", "Ok", 1),
4751            ("err_case", "Err", 1),
4752            ("some_case", "Some", 1),
4753            ("none_case", "None", 0),
4754            ("ok_http_case", "Ok", 1),
4755        ] {
4756            let ir = lower_fn(&program, fn_name);
4757            let tail = fn_tail(&ir);
4758            let IrExprKind::Variant { tag, payload } = &tail.kind else {
4759                panic!("{fn_name}: expected Variant, got {:?}", tail.kind)
4760            };
4761            assert_eq!(tag, expect_tag, "{fn_name}");
4762            assert_eq!(payload.len(), expect_payload_len, "{fn_name}");
4763            let resolved = &*program.program().ty_intern.get(tail.ty);
4764            let sum_matches = match fn_name {
4765                "ok_case" | "err_case" => matches!(resolved, Ty::Result(..)),
4766                "some_case" | "none_case" => matches!(resolved, Ty::Option(_)),
4767                "ok_http_case" => matches!(resolved, Ty::HttpResult(_)),
4768                _ => unreachable!(),
4769            };
4770            assert!(
4771                sum_matches,
4772                "{fn_name}: expected tail.ty to resolve to the constructed sum, got {resolved:?}"
4773            );
4774        }
4775    }
4776
4777    #[test]
4778    fn question_on_option_lowers_to_a_some_none_match_and_none_returns_http_result_not_found() {
4779        let program = checked_program(
4780            r#"
4781commons demo {
4782  fn maybe() -> Option[Int] { Some(1) }
4783  fn lift() -> HttpResult[Int] {
4784    let v = maybe()?
4785    Ok(v)
4786  }
4787}
4788"#,
4789        );
4790        let ir = lower_fn(&program, "lift");
4791        let IrExprKind::Block { stmts, .. } = &ir.kind else {
4792            panic!("expected Block, got {:?}", ir.kind)
4793        };
4794        let IrStmt::Let { value, .. } = &stmts[0] else {
4795            panic!(
4796                "expected the first statement to be a Let, got {:?}",
4797                stmts[0]
4798            )
4799        };
4800        let IrExprKind::Match {
4801            scrutinee,
4802            arms,
4803            exhaustive,
4804            form,
4805        } = &value.kind
4806        else {
4807            panic!("expected Match, got {:?}", value.kind)
4808        };
4809        assert!(matches!(&scrutinee.kind, IrExprKind::Call { .. }));
4810        assert!(matches!(
4811            &*program.program().ty_intern.get(scrutinee.ty),
4812            Ty::Option(_)
4813        ));
4814        assert_eq!(arms.len(), 2);
4815        let IrPat::Variant {
4816            tag: some_tag,
4817            fields: some_fields,
4818            ..
4819        } = &arms[0].pat
4820        else {
4821            panic!("expected arm 0's pat to be Variant, got {:?}", arms[0].pat)
4822        };
4823        assert_eq!(some_tag, "Some");
4824        assert_eq!(some_fields.len(), 1);
4825        assert!(matches!(&arms[0].body.kind, IrExprKind::Local(_)));
4826        let IrPat::Variant {
4827            tag: none_tag,
4828            fields: none_fields,
4829            ..
4830        } = &arms[1].pat
4831        else {
4832            panic!("expected arm 1's pat to be Variant, got {:?}", arms[1].pat)
4833        };
4834        assert_eq!(none_tag, "None");
4835        assert!(none_fields.is_empty());
4836        let IrExprKind::Return { value: returned } = &arms[1].body.kind else {
4837            panic!(
4838                "expected arm 1's body to be Return, got {:?}",
4839                arms[1].body.kind
4840            )
4841        };
4842        assert!(matches!(returned.kind, IrExprKind::HttpResultNotFound));
4843        assert!(matches!(exhaustive, Exhaustive::Total));
4844        assert_eq!(*form, MatchForm::Flat);
4845    }
4846
4847    #[test]
4848    fn question_on_result_with_a_compatible_error_type_propagates_the_scrutinee_unchanged() {
4849        let program = checked_program(
4850            r#"
4851commons demo {
4852  fn g() -> Result[Int, String] { Ok(1) }
4853  fn bare_case() -> Result[Int, String] {
4854    let v = g()?
4855    Ok(v)
4856  }
4857}
4858"#,
4859        );
4860        let ir = lower_fn(&program, "bare_case");
4861        let IrExprKind::Block { stmts, .. } = &ir.kind else {
4862            panic!("expected Block, got {:?}", ir.kind)
4863        };
4864        let IrStmt::Let { value, .. } = &stmts[0] else {
4865            panic!(
4866                "expected the first statement to be a Let, got {:?}",
4867                stmts[0]
4868            )
4869        };
4870        let IrExprKind::Match { arms, .. } = &value.kind else {
4871            panic!("expected Match, got {:?}", value.kind)
4872        };
4873        let IrPat::Variant { tag: ok_tag, .. } = &arms[0].pat else {
4874            panic!("expected arm 0's pat to be Variant, got {:?}", arms[0].pat)
4875        };
4876        assert_eq!(ok_tag, "Ok");
4877        let IrPat::Variant {
4878            tag: err_tag,
4879            fields: err_fields,
4880            ..
4881        } = &arms[1].pat
4882        else {
4883            panic!("expected arm 1's pat to be Variant, got {:?}", arms[1].pat)
4884        };
4885        assert_eq!(err_tag, "Err");
4886        assert_eq!(err_fields.len(), 1);
4887        let IrExprKind::Return { value: returned } = &arms[1].body.kind else {
4888            panic!(
4889                "expected arm 1's body to be Return, got {:?}",
4890                arms[1].body.kind
4891            )
4892        };
4893        let IrExprKind::Variant {
4894            tag: returned_tag,
4895            payload: returned_payload,
4896        } = &returned.kind
4897        else {
4898            panic!("expected a re-constructed Err, got {:?}", returned.kind)
4899        };
4900        assert_eq!(returned_tag, "Err");
4901        assert_eq!(returned_payload.len(), 1);
4902        let IrPat::Bind { local: bound_name } = &*err_fields[0].1 else {
4903            panic!(
4904                "expected arm 1's own payload pattern to be a Bind, got {:?}",
4905                err_fields[0].1
4906            )
4907        };
4908        // No embed conversion needed (both sides are `String`) — the propagated
4909        // error is the bound local unchanged, not a wrapped construction.
4910        assert!(matches!(&returned_payload[0].kind, IrExprKind::Local(n) if n == bound_name));
4911    }
4912
4913    #[test]
4914    fn question_on_result_with_a_declared_embeds_conversion_wraps_the_propagated_error() {
4915        let program = checked_program(
4916            r#"
4917commons demo {
4918  type PaymentError = enum { Declined, InsufficientFunds }
4919
4920  type OrderError =
4921    | OutOfStock(sku: String, qty: Int)
4922    | Payment(reason: PaymentError)
4923    embeds PaymentError as Payment
4924
4925  fn charge() -> Result[Int, PaymentError] { Ok(1) }
4926  fn embed_case() -> Result[Int, OrderError] {
4927    let v = charge()?
4928    Ok(v)
4929  }
4930}
4931"#,
4932        );
4933        let ir = lower_fn(&program, "embed_case");
4934        let IrExprKind::Block { stmts, .. } = &ir.kind else {
4935            panic!("expected Block, got {:?}", ir.kind)
4936        };
4937        let IrStmt::Let { value, .. } = &stmts[0] else {
4938            panic!(
4939                "expected the first statement to be a Let, got {:?}",
4940                stmts[0]
4941            )
4942        };
4943        let IrExprKind::Match { arms, .. } = &value.kind else {
4944            panic!("expected Match, got {:?}", value.kind)
4945        };
4946        let IrExprKind::Return { value: returned } = &arms[1].body.kind else {
4947            panic!(
4948                "expected arm 1's body to be Return, got {:?}",
4949                arms[1].body.kind
4950            )
4951        };
4952        let IrExprKind::Variant {
4953            tag: returned_tag,
4954            payload: returned_payload,
4955        } = &returned.kind
4956        else {
4957            panic!("expected a re-constructed Err, got {:?}", returned.kind)
4958        };
4959        assert_eq!(returned_tag, "Err");
4960        assert_eq!(returned_payload.len(), 1);
4961        let IrExprKind::Variant {
4962            tag: embed_tag,
4963            payload: embed_payload,
4964        } = &returned_payload[0].kind
4965        else {
4966            panic!(
4967                "expected the propagated error to be wrapped in the declared embed \
4968                 construction, got {:?}",
4969                returned_payload[0].kind
4970            )
4971        };
4972        assert_eq!(embed_tag, "Payment");
4973        assert_eq!(embed_payload.len(), 1);
4974        assert!(matches!(&embed_payload[0].kind, IrExprKind::Local(_)));
4975        assert!(matches!(
4976            &*program.program().ty_intern.get(returned_payload[0].ty),
4977            Ty::Named { name, .. } if name == "OrderError"
4978        ));
4979    }
4980
4981    #[test]
4982    fn question_embeds_conversion_still_resolves_when_the_enclosing_return_is_effect_wrapped() {
4983        // Review of #1238: duplicates the test above under `Effect[Result[..]]`
4984        // specifically to exercise `peel_effect_ty`'s own recursive arm —
4985        // every other test's `return_ty` hits the base case on the first call.
4986        let program = checked_program(
4987            r#"
4988commons demo {
4989  type PaymentError = enum { Declined, InsufficientFunds }
4990
4991  type OrderError =
4992    | OutOfStock(sku: String, qty: Int)
4993    | Payment(reason: PaymentError)
4994    embeds PaymentError as Payment
4995
4996  fn charge() -> Result[Int, PaymentError] { Ok(1) }
4997  fn embed_case() -> Effect[Result[Int, OrderError]] {
4998    let v = charge()?
4999    Ok(v)
5000  }
5001}
5002"#,
5003        );
5004        let ir = lower_fn(&program, "embed_case");
5005        let IrExprKind::Block { stmts, .. } = &ir.kind else {
5006            panic!("expected Block, got {:?}", ir.kind)
5007        };
5008        let IrStmt::Let { value, .. } = &stmts[0] else {
5009            panic!(
5010                "expected the first statement to be a Let, got {:?}",
5011                stmts[0]
5012            )
5013        };
5014        let IrExprKind::Match { arms, .. } = &value.kind else {
5015            panic!("expected Match, got {:?}", value.kind)
5016        };
5017        let IrExprKind::Return { value: returned } = &arms[1].body.kind else {
5018            panic!(
5019                "expected arm 1's body to be Return, got {:?}",
5020                arms[1].body.kind
5021            )
5022        };
5023        let IrExprKind::Variant {
5024            tag: returned_tag,
5025            payload: returned_payload,
5026        } = &returned.kind
5027        else {
5028            panic!("expected a re-constructed Err, got {:?}", returned.kind)
5029        };
5030        assert_eq!(returned_tag, "Err");
5031        let IrExprKind::Variant { tag: embed_tag, .. } = &returned_payload[0].kind else {
5032            panic!(
5033                "expected the propagated error wrapped in the declared embed construction \
5034                 even through the Effect wrapper, got {:?}",
5035                returned_payload[0].kind
5036            )
5037        };
5038        assert_eq!(
5039            embed_tag, "Payment",
5040            "peel_effect_ty must peel through Effect[..] to find the Result[_, OrderError] \
5041             underneath — an unpeeled Effect[Result[..]] would make target_err_ty resolve to \
5042             something other than OrderError and this embed lookup would silently miss"
5043        );
5044        assert!(matches!(
5045            &*program.program().ty_intern.get(returned_payload[0].ty),
5046            Ty::Named { name, .. } if name == "OrderError"
5047        ));
5048    }
5049
5050    #[test]
5051    fn question_reached_from_an_agent_handler_body_sets_return_ty_without_panicking() {
5052        // Review of #1238: `lower_fn_body_ir`'s own set_return_ty call site
5053        // was already exercised by every `Question`/`Is` test above (they
5054        // all lower through `lower_fn`/`lower_fn_body_ir`) — this and the
5055        // next two tests specifically reach the other three real
5056        // body-lowering entry points (`lower_handler_body_ir`,
5057        // `lower_service_handler_body_ir`, `lower_provider_op_ir`) that a
5058        // panicking `set_return_ty` would have crashed on the resolve-miss
5059        // path the review named, even for a body containing no `?` at all.
5060        let program = checked_context_program(
5061            r#"
5062context demo
5063
5064agent Counter {
5065  key id: String
5066  store n: Cell[Int] = 0
5067
5068  on call bump() -> Effect[Result[Int, String]] {
5069    Ok(1)
5070  }
5071}
5072"#,
5073        );
5074        let agent = program
5075            .program()
5076            .commons
5077            .items
5078            .iter()
5079            .find_map(|item| match item {
5080                CommonsItem::Agent(a) if a.name.name == "Counter" => Some(a),
5081                _ => None,
5082            })
5083            .unwrap_or_else(|| panic!("no agent named Counter in this fixture"));
5084        let handler = &agent.handlers[0];
5085        // Reaching this at all (rather than panicking on the handler's own
5086        // return-type resolution) is the assertion.
5087        let _ = lower_handler_ir(
5088            handler,
5089            &HashMap::new(),
5090            &HashSet::new(),
5091            program.program().ty_intern.intern(Ty::Unit),
5092            &[],
5093            &[],
5094            &program,
5095        );
5096    }
5097
5098    #[test]
5099    fn question_reached_from_a_provider_op_body_sets_return_ty_without_panicking() {
5100        let program = checked_context_program(
5101            r#"
5102context demo
5103
5104capability Charge {
5105  fn run() -> Effect[Result[Int, String]]
5106}
5107
5108provides Charge = FakeCharge {
5109  fn run() -> Effect[Result[Int, String]] {
5110    Ok(1)
5111  }
5112}
5113"#,
5114        );
5115        let provider = find_provider(&program, "FakeCharge");
5116        // Reaching this at all (rather than panicking on the op's own
5117        // return-type resolution) is the assertion.
5118        let _ = lower_provider_item_ir(provider, &program);
5119    }
5120
5121    #[test]
5122    fn is_on_a_declared_refined_type_forces_a_receiver_temp_and_lowers_to_refined_check() {
5123        let program = checked_program(
5124            r#"
5125commons demo {
5126  type Quantity = Int where InRange(1, 100)
5127
5128  fn valid(n: Int) -> Bool {
5129    n is Quantity
5130  }
5131}
5132"#,
5133        );
5134        let ir = lower_fn(&program, "valid");
5135        let tail = fn_tail(&ir);
5136        let IrExprKind::Block { stmts, tail: inner } = &tail.kind else {
5137            panic!("expected Block, got {:?}", tail.kind)
5138        };
5139        let IrStmt::Let { local, value } = &stmts[0] else {
5140            panic!(
5141                "expected the first statement to be a Let, got {:?}",
5142                stmts[0]
5143            )
5144        };
5145        assert_eq!(local, "__is_receiver_0");
5146        assert!(matches!(&value.kind, IrExprKind::Local(n) if n == "n"));
5147        let IrExprKind::RefinedCheck {
5148            value: checked,
5149            base,
5150            refinement,
5151        } = &inner.kind
5152        else {
5153            panic!("expected RefinedCheck, got {:?}", inner.kind)
5154        };
5155        assert!(matches!(&checked.kind, IrExprKind::Local(n) if n == "__is_receiver_0"));
5156        assert_eq!(*base, BaseType::Int);
5157        let refinement = refinement
5158            .as_ref()
5159            .unwrap_or_else(|| panic!("expected a real refinement, got None"));
5160        assert_eq!(refinement.predicates.len(), 1);
5161        assert!(matches!(
5162            refinement.predicates[0].kind,
5163            PredKind::InRange(..)
5164        ));
5165    }
5166
5167    #[test]
5168    fn is_on_a_bare_variant_lowers_to_a_single_tag_test() {
5169        let program = checked_program(
5170            r#"
5171commons demo {
5172  fn check(r: Result[Int, String]) -> Bool {
5173    r is Ok(_)
5174  }
5175}
5176"#,
5177        );
5178        let ir = lower_fn(&program, "check");
5179        let tail = fn_tail(&ir);
5180        let IrExprKind::Block { tail: inner, .. } = &tail.kind else {
5181            panic!("expected Block, got {:?}", tail.kind)
5182        };
5183        let IrExprKind::BinOp { op, lhs, rhs } = &inner.kind else {
5184            panic!("expected a single BinOp tag test, got {:?}", inner.kind)
5185        };
5186        assert_eq!(*op, IrBinOp::Eq);
5187        assert!(matches!(
5188            &lhs.kind,
5189            IrExprKind::Field { field, .. } if field == "tag"
5190        ));
5191        assert!(matches!(&rhs.kind, IrExprKind::Const(ConstVal::Str(s)) if s == "Ok"));
5192    }
5193
5194    #[test]
5195    fn is_on_a_nested_variant_ands_the_outer_and_inner_tag_tests() {
5196        let program = checked_program(
5197            r#"
5198commons demo {
5199  type Fault =
5200    | NotFound
5201    | Denied(reason: String)
5202
5203  fn describe(r: Result[Int, Fault]) -> Bool {
5204    r is Err(Denied(_))
5205  }
5206}
5207"#,
5208        );
5209        let ir = lower_fn(&program, "describe");
5210        let tail = fn_tail(&ir);
5211        let IrExprKind::Block { tail: inner, .. } = &tail.kind else {
5212            panic!("expected Block, got {:?}", tail.kind)
5213        };
5214        let IrExprKind::And { lhs, rhs } = &inner.kind else {
5215            panic!(
5216                "expected the outer+inner tag tests And-joined, got {:?}",
5217                inner.kind
5218            )
5219        };
5220        let IrExprKind::BinOp {
5221            lhs: outer_field,
5222            rhs: outer_tag,
5223            ..
5224        } = &lhs.kind
5225        else {
5226            panic!("expected the outer test to be a BinOp, got {:?}", lhs.kind)
5227        };
5228        assert!(
5229            matches!(&outer_field.kind, IrExprKind::Field { base, field } if field == "tag" && matches!(&base.kind, IrExprKind::Local(n) if n == "__is_receiver_0"))
5230        );
5231        assert!(matches!(&outer_tag.kind, IrExprKind::Const(ConstVal::Str(s)) if s == "Err"));
5232        let IrExprKind::BinOp {
5233            lhs: inner_field,
5234            rhs: inner_tag,
5235            ..
5236        } = &rhs.kind
5237        else {
5238            panic!("expected the inner test to be a BinOp, got {:?}", rhs.kind)
5239        };
5240        assert!(matches!(
5241            &inner_field.kind,
5242            IrExprKind::Field { field, .. } if field == "tag"
5243        ));
5244        assert!(matches!(&inner_tag.kind, IrExprKind::Const(ConstVal::Str(s)) if s == "Denied"));
5245        // The nested field access is rooted at the outer `.error` payload, not
5246        // the bare receiver — proves the payload field name/type came from
5247        // `IrPat`'s own already-resolved `fields`, not a re-derived guess.
5248        let IrExprKind::Field {
5249            base: nested_base,
5250            field: nested_field,
5251        } = &inner_field.kind
5252        else {
5253            panic!("expected inner_field to be a Field access")
5254        };
5255        assert_eq!(nested_field, "tag");
5256        assert!(matches!(
5257            &nested_base.kind,
5258            IrExprKind::Field { field, .. } if field == "error"
5259        ));
5260    }
5261
5262    #[test]
5263    fn is_on_an_or_pattern_or_folds_each_alternatives_and_joined_tests() {
5264        let program = checked_program(
5265            r#"
5266commons demo {
5267  fn check(r: Result[Int, String]) -> Bool {
5268    r is Ok(_) | Err(_)
5269  }
5270}
5271"#,
5272        );
5273        let ir = lower_fn(&program, "check");
5274        let tail = fn_tail(&ir);
5275        let IrExprKind::Block { tail: inner, .. } = &tail.kind else {
5276            panic!("expected Block, got {:?}", tail.kind)
5277        };
5278        let IrExprKind::Or { lhs, rhs } = &inner.kind else {
5279            panic!(
5280                "expected the two alternatives Or-joined, got {:?}",
5281                inner.kind
5282            )
5283        };
5284        assert!(matches!(&lhs.kind, IrExprKind::BinOp { .. }));
5285        assert!(matches!(&rhs.kind, IrExprKind::BinOp { .. }));
5286    }
5287
5288    #[test]
5289    fn method_call_driven_by_callee_method_prepends_the_receiver() {
5290        let program = checked_program(
5291            r#"
5292commons demo {
5293  type Point = { x: Int, y: Int }
5294
5295  fn Point.shiftX(self, dx: Int) -> Point {
5296    Point { x: self.x, y: self.y }
5297  }
5298
5299  fn use_it(p: Point, dx: Int) -> Point { p.shiftX(dx) }
5300}
5301"#,
5302        );
5303        let ir = lower_fn(&program, "use_it");
5304        let tail = fn_tail(&ir);
5305        let IrExprKind::Call { callee, args, .. } = &tail.kind else {
5306            panic!("expected Call, got {:?}", tail.kind)
5307        };
5308        assert!(matches!(callee, Callee::Method(f) if f.name.display().ends_with("shiftX")));
5309        assert_eq!(args.len(), 2);
5310        assert!(matches!(&args[0].kind, IrExprKind::Local(n) if n == "p"));
5311        assert!(matches!(&args[1].kind, IrExprKind::Local(n) if n == "dx"));
5312    }
5313
5314    #[test]
5315    fn static_call_driven_by_callee_static_has_no_prepended_receiver() {
5316        let program = checked_program(
5317            r#"
5318commons demo {
5319  type Point = { x: Int, y: Int }
5320
5321  fn Point.origin() -> Point { Point { x: 0, y: 0 } }
5322
5323  fn use_it() -> Point { Point.origin() }
5324}
5325"#,
5326        );
5327        let ir = lower_fn(&program, "use_it");
5328        let tail = fn_tail(&ir);
5329        let IrExprKind::Call { callee, args, .. } = &tail.kind else {
5330            panic!("expected Call, got {:?}", tail.kind)
5331        };
5332        assert!(matches!(callee, Callee::Static(f) if f.name.display().ends_with("origin")));
5333        // `Point` (the receiver) is a type name, not a value — must not be
5334        // lowered and prepended, unlike the Method case above.
5335        assert!(args.is_empty());
5336    }
5337
5338    #[test]
5339    fn kernel_method_call_prepends_the_receiver() {
5340        let program = checked_program(
5341            r#"
5342commons demo {
5343  fn identity_all(xs: List[Int]) -> List[Int] { xs.map((y) => y) }
5344}
5345"#,
5346        );
5347        let ir = lower_fn(&program, "identity_all");
5348        let tail = fn_tail(&ir);
5349        let IrExprKind::Call { callee, args, .. } = &tail.kind else {
5350            panic!("expected Call, got {:?}", tail.kind)
5351        };
5352        assert!(matches!(callee, Callee::Kernel { op, .. } if op == "map"));
5353        assert_eq!(args.len(), 2);
5354        assert!(matches!(&args[0].kind, IrExprKind::Local(n) if n == "xs"));
5355        let IrExprKind::Lambda {
5356            params,
5357            body,
5358            captures,
5359        } = &args[1].kind
5360        else {
5361            panic!("expected Lambda, got {:?}", args[1].kind)
5362        };
5363        assert_eq!(params, &["y".to_string()]);
5364        assert!(captures.is_empty());
5365        assert!(matches!(&body.kind, IrExprKind::Local(n) if n == "y"));
5366    }
5367
5368    // P6.3 (#1145): Implies/RecordSpread desugaring — the only two of the
5369    // reference's own Part 6.4 desugaring-table rows this slice covers
5370    // (Decision D); every other row named there stays a `todo!()` citing its
5371    // own specific blocker (Decisions A–C).
5372
5373    #[test]
5374    fn implies_desugars_to_or_not() {
5375        let program = checked_program(
5376            r#"
5377commons demo {
5378  fn imp(p: Bool, q: Bool) -> Bool { p implies q }
5379}
5380"#,
5381        );
5382        let ir = lower_fn(&program, "imp");
5383        let tail = fn_tail(&ir);
5384        let IrExprKind::Or { lhs, rhs } = &tail.kind else {
5385            panic!("expected Or, got {:?}", tail.kind)
5386        };
5387        let IrExprKind::Not { operand } = &lhs.kind else {
5388            panic!("expected Or's lhs to be Not, got {:?}", lhs.kind)
5389        };
5390        assert!(matches!(&operand.kind, IrExprKind::Local(n) if n == "p"));
5391        assert!(matches!(&rhs.kind, IrExprKind::Local(n) if n == "q"));
5392        assert!(matches!(
5393            &*program.program().ty_intern.get(lhs.ty),
5394            Ty::Base(bynk_syntax::ast::BaseType::Bool)
5395        ));
5396    }
5397
5398    // #1189: comparison/arithmetic `BinOp`, `UnaryOp::Neg`, `InterpStr` —
5399    // the gap P6.2/P6.3 each confirmed and left `todo!()`, closed here.
5400
5401    #[test]
5402    fn comparison_and_arithmetic_binops_lower_to_a_shared_dedicated_node() {
5403        let program = checked_program(
5404            r#"
5405commons demo {
5406  fn nonneg(balance: Int) -> Bool { balance >= 0 }
5407  fn total(a: Int, b: Int) -> Int { a + b }
5408}
5409"#,
5410        );
5411        let cmp_ir = lower_fn(&program, "nonneg");
5412        let IrExprKind::BinOp { op, lhs, rhs } = &fn_tail(&cmp_ir).kind else {
5413            panic!("expected BinOp, got {:?}", fn_tail(&cmp_ir).kind)
5414        };
5415        assert_eq!(*op, IrBinOp::GtEq);
5416        assert!(matches!(&lhs.kind, IrExprKind::Local(n) if n == "balance"));
5417        assert!(matches!(&rhs.kind, IrExprKind::Const(ConstVal::Int(0))));
5418        assert!(matches!(
5419            &*program.program().ty_intern.get(fn_tail(&cmp_ir).ty),
5420            Ty::Base(bynk_syntax::ast::BaseType::Bool)
5421        ));
5422
5423        let arith_ir = lower_fn(&program, "total");
5424        let IrExprKind::BinOp { op, lhs, rhs } = &fn_tail(&arith_ir).kind else {
5425            panic!("expected BinOp, got {:?}", fn_tail(&arith_ir).kind)
5426        };
5427        assert_eq!(*op, IrBinOp::Add);
5428        assert!(matches!(&lhs.kind, IrExprKind::Local(n) if n == "a"));
5429        assert!(matches!(&rhs.kind, IrExprKind::Local(n) if n == "b"));
5430        assert!(matches!(
5431            &*program.program().ty_intern.get(fn_tail(&arith_ir).ty),
5432            Ty::Base(bynk_syntax::ast::BaseType::Int)
5433        ));
5434    }
5435
5436    #[test]
5437    fn unary_neg_lowers_to_its_own_dedicated_node() {
5438        let program = checked_program(
5439            r#"
5440commons demo {
5441  fn negate(x: Int) -> Int { -x }
5442}
5443"#,
5444        );
5445        let ir = lower_fn(&program, "negate");
5446        let IrExprKind::Neg { operand } = &fn_tail(&ir).kind else {
5447            panic!("expected Neg, got {:?}", fn_tail(&ir).kind)
5448        };
5449        assert!(matches!(&operand.kind, IrExprKind::Local(n) if n == "x"));
5450        assert!(matches!(
5451            &*program.program().ty_intern.get(fn_tail(&ir).ty),
5452            Ty::Base(bynk_syntax::ast::BaseType::Int)
5453        ));
5454    }
5455
5456    #[test]
5457    fn every_binop_tag_maps_to_its_own_ir_binop_variant() {
5458        // The hand-written ten-arm `BinOp -> IrBinOp` mapping is the one
5459        // place this change can be silently wrong (a transposition compiles
5460        // and clippy-passes) — every tag gets its own assertion here, not
5461        // just the two `comparison_and_arithmetic_binops_lower_to_a_shared_
5462        // dedicated_node` happens to cover incidentally (review of #1195).
5463        let program = checked_program(
5464            r#"
5465commons demo {
5466  fn eq(a: Int, b: Int) -> Bool { a == b }
5467  fn neq(a: Int, b: Int) -> Bool { a != b }
5468  fn lt(a: Int, b: Int) -> Bool { a < b }
5469  fn lteq(a: Int, b: Int) -> Bool { a <= b }
5470  fn gt(a: Int, b: Int) -> Bool { a > b }
5471  fn gteq(a: Int, b: Int) -> Bool { a >= b }
5472  fn add(a: Int, b: Int) -> Int { a + b }
5473  fn sub(a: Int, b: Int) -> Int { a - b }
5474  fn mul(a: Int, b: Int) -> Int { a * b }
5475  fn div(a: Int, b: Int) -> Int { a / b }
5476}
5477"#,
5478        );
5479        let cases = [
5480            ("eq", IrBinOp::Eq),
5481            ("neq", IrBinOp::NotEq),
5482            ("lt", IrBinOp::Lt),
5483            ("lteq", IrBinOp::LtEq),
5484            ("gt", IrBinOp::Gt),
5485            ("gteq", IrBinOp::GtEq),
5486            ("add", IrBinOp::Add),
5487            ("sub", IrBinOp::Sub),
5488            ("mul", IrBinOp::Mul),
5489            ("div", IrBinOp::Div),
5490        ];
5491        for (fn_name, expected_op) in cases {
5492            let ir = lower_fn(&program, fn_name);
5493            let IrExprKind::BinOp { op, .. } = &fn_tail(&ir).kind else {
5494                panic!("{fn_name}: expected BinOp, got {:?}", fn_tail(&ir).kind)
5495            };
5496            assert_eq!(*op, expected_op, "{fn_name}: wrong IrBinOp tag");
5497        }
5498    }
5499
5500    #[test]
5501    fn interp_str_lowers_chunks_verbatim_and_holes_as_ordinary_expressions() {
5502        let program = checked_program(
5503            r#"
5504commons demo {
5505  fn greet(name: String) -> String { "hi \(name)!" }
5506}
5507"#,
5508        );
5509        let ir = lower_fn(&program, "greet");
5510        let IrExprKind::InterpStr { parts } = &fn_tail(&ir).kind else {
5511            panic!("expected InterpStr, got {:?}", fn_tail(&ir).kind)
5512        };
5513        assert_eq!(parts.len(), 3);
5514        assert!(matches!(&parts[0], IrInterpPart::Chunk(s) if s == "hi "));
5515        let IrInterpPart::Hole(hole) = &parts[1] else {
5516            panic!("expected Hole, got {:?}", parts[1])
5517        };
5518        assert!(matches!(&hole.kind, IrExprKind::Local(n) if n == "name"));
5519        assert!(matches!(&parts[2], IrInterpPart::Chunk(s) if s == "!"));
5520        assert!(matches!(
5521            &*program.program().ty_intern.get(fn_tail(&ir).ty),
5522            Ty::Base(bynk_syntax::ast::BaseType::String)
5523        ));
5524    }
5525
5526    #[test]
5527    fn interp_str_covers_leading_hole_hole_only_and_adjacent_holes() {
5528        // `split_interp` (`bynk-syntax/src/lexer.rs`) never pushes an empty
5529        // `Chunk` — a leading/trailing/adjacent hole has no empty-string
5530        // sibling segment either side of it. Pins that `lower_interp_part_ir`
5531        // sees exactly the segments the lexer produces, not a padded shape.
5532        let program = checked_program(
5533            r#"
5534commons demo {
5535  fn lead(name: String) -> String { "\(name) hi" }
5536  fn only(name: String) -> String { "\(name)" }
5537  fn adjacent(a: String, b: String) -> String { "\(a)\(b)" }
5538}
5539"#,
5540        );
5541
5542        let lead = lower_fn(&program, "lead");
5543        let IrExprKind::InterpStr { parts } = &fn_tail(&lead).kind else {
5544            panic!("expected InterpStr, got {:?}", fn_tail(&lead).kind)
5545        };
5546        assert_eq!(parts.len(), 2, "no leading empty Chunk before the hole");
5547        assert!(matches!(&parts[0], IrInterpPart::Hole(h)
5548            if matches!(&h.kind, IrExprKind::Local(n) if n == "name")));
5549        assert!(matches!(&parts[1], IrInterpPart::Chunk(s) if s == " hi"));
5550
5551        let only = lower_fn(&program, "only");
5552        let IrExprKind::InterpStr { parts } = &fn_tail(&only).kind else {
5553            panic!("expected InterpStr, got {:?}", fn_tail(&only).kind)
5554        };
5555        assert_eq!(
5556            parts.len(),
5557            1,
5558            "a hole-only string is just the one Hole segment"
5559        );
5560        assert!(matches!(&parts[0], IrInterpPart::Hole(_)));
5561
5562        let adjacent = lower_fn(&program, "adjacent");
5563        let IrExprKind::InterpStr { parts } = &fn_tail(&adjacent).kind else {
5564            panic!("expected InterpStr, got {:?}", fn_tail(&adjacent).kind)
5565        };
5566        assert_eq!(parts.len(), 2, "no empty Chunk between adjacent holes");
5567        assert!(matches!(&parts[0], IrInterpPart::Hole(h)
5568            if matches!(&h.kind, IrExprKind::Local(n) if n == "a")));
5569        assert!(matches!(&parts[1], IrInterpPart::Hole(h)
5570            if matches!(&h.kind, IrExprKind::Local(n) if n == "b")));
5571    }
5572
5573    #[test]
5574    fn record_spread_resolves_every_declared_field_override_and_spread_through() {
5575        let program = checked_program(
5576            r#"
5577commons demo {
5578  type Point = { x: Int, y: Int, z: Int }
5579
5580  fn shift(p: Point, y: Int) -> Point { Point { ...p, x: 0, y } }
5581}
5582"#,
5583        );
5584        let ir = lower_fn(&program, "shift");
5585        let tail = fn_tail(&ir);
5586        let IrExprKind::Block {
5587            stmts,
5588            tail: block_tail,
5589        } = &tail.kind
5590        else {
5591            panic!("expected Block, got {:?}", tail.kind)
5592        };
5593        assert_eq!(stmts.len(), 1);
5594        let IrStmt::Let { local, value } = &stmts[0] else {
5595            panic!("expected Let, got {:?}", stmts[0])
5596        };
5597        assert_eq!(local, "__spread_base_0");
5598        assert!(matches!(&value.kind, IrExprKind::Local(n) if n == "p"));
5599
5600        let IrExprKind::Record { fields } = &block_tail.kind else {
5601            panic!("expected Record, got {:?}", block_tail.kind)
5602        };
5603        assert_eq!(fields.len(), 3);
5604
5605        // `x: 0` — a full-form override.
5606        assert_eq!(fields[0].0, "x");
5607        assert!(matches!(
5608            &fields[0].1.kind,
5609            IrExprKind::Const(ConstVal::Int(0))
5610        ));
5611
5612        // `y` — a shorthand override, reading the `y` parameter, not `p.y`.
5613        assert_eq!(fields[1].0, "y");
5614        assert!(matches!(&fields[1].1.kind, IrExprKind::Local(n) if n == "y"));
5615
5616        // `z` — not overridden, spread through as a synthesised `tmp.z` read.
5617        assert_eq!(fields[2].0, "z");
5618        let IrExprKind::Field { base, field } = &fields[2].1.kind else {
5619            panic!("expected Field, got {:?}", fields[2].1.kind)
5620        };
5621        assert_eq!(field, "z");
5622        assert!(matches!(&base.kind, IrExprKind::Local(n) if n == "__spread_base_0"));
5623        assert!(matches!(
5624            &*program.program().ty_intern.get(fields[2].1.ty),
5625            Ty::Base(bynk_syntax::ast::BaseType::Int)
5626        ));
5627    }
5628
5629    #[test]
5630    fn record_spread_orders_fields_by_evaluation_order_not_declaration_order() {
5631        // Overrides land in `fields` in *source* order, not the record's own
5632        // declared field order — `y`'s override is written before `x`'s, so
5633        // it must come first, since an override's value may be effectful
5634        // and a future reader walks `fields` left to right to reproduce
5635        // that ordering (see `lower_record_spread_ir`'s own doc comment).
5636        let program = checked_program(
5637            r#"
5638commons demo {
5639  type Point = { x: Int, y: Int, z: Int }
5640
5641  fn shift(p: Point) -> Point { Point { ...p, y: 1, x: 2 } }
5642}
5643"#,
5644        );
5645        let ir = lower_fn(&program, "shift");
5646        let tail = fn_tail(&ir);
5647        let IrExprKind::Block {
5648            tail: block_tail, ..
5649        } = &tail.kind
5650        else {
5651            panic!("expected Block, got {:?}", tail.kind)
5652        };
5653        let IrExprKind::Record { fields, .. } = &block_tail.kind else {
5654            panic!("expected Record, got {:?}", block_tail.kind)
5655        };
5656        let names: Vec<&str> = fields.iter().map(|(n, _)| n.as_str()).collect();
5657        assert_eq!(names, vec!["y", "x", "z"]);
5658    }
5659
5660    #[test]
5661    fn record_spread_duplicate_override_keeps_the_last_value_and_still_runs_the_earlier_one() {
5662        // `check_record_spread` has no duplicate-name diagnostic, so `x` is
5663        // named twice here and type-checks — the resulting field must take
5664        // `x`'s *last* value (2), but `x`'s *first* value (1) must still
5665        // run, as a discarded statement, since it may have been effectful.
5666        let program = checked_program(
5667            r#"
5668commons demo {
5669  type Point = { x: Int, y: Int }
5670
5671  fn shift(p: Point) -> Point { Point { ...p, x: 1, x: 2 } }
5672}
5673"#,
5674        );
5675        let ir = lower_fn(&program, "shift");
5676        let tail = fn_tail(&ir);
5677        let IrExprKind::Block {
5678            stmts,
5679            tail: block_tail,
5680        } = &tail.kind
5681        else {
5682            panic!("expected Block, got {:?}", tail.kind)
5683        };
5684        assert_eq!(stmts.len(), 2, "the base Let plus one discarded duplicate");
5685        let IrStmt::Expr { value: discarded } = &stmts[1] else {
5686            panic!(
5687                "expected the second stmt to be a discarded Expr, got {:?}",
5688                stmts[1]
5689            )
5690        };
5691        assert!(matches!(
5692            &discarded.kind,
5693            IrExprKind::Const(ConstVal::Int(1))
5694        ));
5695
5696        let IrExprKind::Record { fields, .. } = &block_tail.kind else {
5697            panic!("expected Record, got {:?}", block_tail.kind)
5698        };
5699        assert_eq!(fields.len(), 2);
5700        assert_eq!(fields[0].0, "x");
5701        assert!(matches!(
5702            &fields[0].1.kind,
5703            IrExprKind::Const(ConstVal::Int(2))
5704        ));
5705    }
5706
5707    #[test]
5708    fn record_spread_instantiates_a_generic_records_spread_through_field_type() {
5709        // `Point`-shaped fixtures above are all monomorphic, so
5710        // `instantiate_field_ty`'s own substitution (ADR 0183/v0.157) is a
5711        // no-op there — a generic record's spread-through field is the one
5712        // case that actually exercises it, since `item`'s declared type is
5713        // the record's own rigid `T`, only resolvable via `base_args`.
5714        let program = checked_program(
5715            r#"
5716commons demo {
5717  type Boxed[T] = { item: T, tag: String }
5718
5719  fn retag(b: Boxed[Int]) -> Boxed[Int] { Boxed { ...b, tag: "x" } }
5720}
5721"#,
5722        );
5723        let ir = lower_fn(&program, "retag");
5724        let tail = fn_tail(&ir);
5725        let IrExprKind::Block {
5726            tail: block_tail, ..
5727        } = &tail.kind
5728        else {
5729            panic!("expected Block, got {:?}", tail.kind)
5730        };
5731        let IrExprKind::Record { fields, .. } = &block_tail.kind else {
5732            panic!("expected Record, got {:?}", block_tail.kind)
5733        };
5734        let (_, item) = fields
5735            .iter()
5736            .find(|(n, _)| n == "item")
5737            .expect("item field present");
5738        let IrExprKind::Field { .. } = &item.kind else {
5739            panic!(
5740                "expected item to spread through as Field, got {:?}",
5741                item.kind
5742            )
5743        };
5744        assert!(matches!(
5745            &*program.program().ty_intern.get(item.ty),
5746            Ty::Base(bynk_syntax::ast::BaseType::Int)
5747        ));
5748    }
5749
5750    // ==== P6.4 (#1157): IrPat/IrArm/Exhaustive, tested standalone ====
5751    //
5752    // `lower_pattern_ir`/`lower_arm_ir`/`lower_exhaustive_ir` are exercised
5753    // here at their own standalone granularity — each fixture below digs its
5754    // own `match` straight out of a fixture fn's body tail and lowers it
5755    // through these three entry points directly, bypassing `lower_expr_ir`.
5756    // Since P6.5 (#1159), the same three are also reached through the
5757    // ordinary `lower_expr_ir`/`lower_fn_body_ir` path — see the `match_*`
5758    // tests below this section for that coverage.
5759
5760    /// Every fixture below is a single-expression `fn` body whose tail is
5761    /// `ExprKind::Match` — no general-purpose statement walk is needed.
5762    fn find_match_arms(f: &FnDecl) -> (&Expr, &[MatchArm]) {
5763        let ExprKind::Match { discriminant, arms } = &f.body.tail.kind else {
5764            panic!(
5765                "fixture's fn body tail is not a Match, got {:?}",
5766                f.body.tail.kind
5767            )
5768        };
5769        (discriminant, arms)
5770    }
5771
5772    /// Binds the fn's own params into scope first (mirroring
5773    /// `lower_fn_body_ir`'s own setup) so a guard/body that reads an outer
5774    /// param — not just a pattern binding — resolves too, then lowers every
5775    /// arm of the fixture's own top-level `match` plus its `Exhaustive`.
5776    fn lower_match_fixture(program: &CheckedProgram, fn_name: &str) -> (Vec<IrArm>, Exhaustive) {
5777        let f = find_fn(program, fn_name);
5778        let (discriminant, arms) = find_match_arms(f);
5779        let disc_ty = program
5780            .program()
5781            .expr_types
5782            .get(&discriminant.id)
5783            .unwrap_or_else(|| panic!("{fn_name}: discriminant has no recorded type"))
5784            .ty;
5785        let mut cx = LowerIrCtx::new(program, HashSet::new());
5786        for p in &f.params {
5787            let ty = cx.resolve_type_ref(&p.type_ref).unwrap_or_else(|| {
5788                panic!("{fn_name}: param `{}`'s type does not resolve", p.name.name)
5789            });
5790            cx.bind(p.name.name.clone(), ty);
5791        }
5792        let ir_arms: Vec<IrArm> = arms
5793            .iter()
5794            .map(|a| lower_arm_ir(a, disc_ty, &mut cx))
5795            .collect();
5796        let exhaustive = lower_exhaustive_ir(arms);
5797        (ir_arms, exhaustive)
5798    }
5799
5800    #[test]
5801    fn pattern_ir_covers_wildcard_binding_and_literal_leaves() {
5802        let program = checked_program(
5803            r#"
5804commons demo {
5805  fn wildcard_case(n: Int) -> Int {
5806    match n {
5807      _ => 0
5808    }
5809  }
5810
5811  fn binding_case(n: Int) -> Int {
5812    match n {
5813      m => m
5814    }
5815  }
5816
5817  fn literal_case(n: Int) -> String {
5818    match n {
5819      0 => "zero"
5820      other => "other"
5821    }
5822  }
5823}
5824"#,
5825        );
5826
5827        let (wild_arms, wild_exhaustive) = lower_match_fixture(&program, "wildcard_case");
5828        assert_eq!(wild_arms.len(), 1);
5829        assert!(matches!(&wild_arms[0].pat, IrPat::Wild));
5830        assert!(wild_arms[0].binds.is_empty());
5831        assert_eq!(wild_arms[0].binding_mode, BindingMode::Direct);
5832        assert!(matches!(wild_exhaustive, Exhaustive::Total));
5833
5834        let (binding_arms, _) = lower_match_fixture(&program, "binding_case");
5835        assert!(matches!(&binding_arms[0].pat, IrPat::Bind { local } if local == "m"));
5836        assert_eq!(binding_arms[0].binds, vec!["m".to_string()]);
5837        assert!(matches!(&binding_arms[0].body.kind, IrExprKind::Local(n) if n == "m"));
5838
5839        let (literal_arms, literal_exhaustive) = lower_match_fixture(&program, "literal_case");
5840        assert!(matches!(
5841            &literal_arms[0].pat,
5842            IrPat::Const {
5843                value: ConstVal::Int(0)
5844            }
5845        ));
5846        assert!(matches!(&literal_arms[1].pat, IrPat::Bind { local } if local == "other"));
5847        assert!(matches!(literal_exhaustive, Exhaustive::Total));
5848    }
5849
5850    #[test]
5851    fn pattern_ir_variant_over_a_user_sum_resolves_tag_and_payload_fields() {
5852        let program = checked_program(
5853            r#"
5854commons demo {
5855  type Outcome =
5856    | Hit(score: Int)
5857    | Miss
5858
5859  fn variant_user_sum(o: Outcome) -> Int {
5860    match o {
5861      Hit(score) => score
5862      Miss => 0
5863    }
5864  }
5865}
5866"#,
5867        );
5868
5869        let (arms, exhaustive) = lower_match_fixture(&program, "variant_user_sum");
5870        assert_eq!(arms.len(), 2);
5871
5872        let IrPat::Variant { tag, fields, .. } = &arms[0].pat else {
5873            panic!("expected Variant, got {:?}", arms[0].pat)
5874        };
5875        assert_eq!(tag, "Hit");
5876        assert_eq!(fields.len(), 1);
5877        assert_eq!(fields[0].0, "score");
5878        assert!(matches!(&*fields[0].1, IrPat::Bind { local } if local == "score"));
5879        assert_eq!(arms[0].binds, vec!["score".to_string()]);
5880        assert!(matches!(&arms[0].body.kind, IrExprKind::Local(n) if n == "score"));
5881
5882        let IrPat::Variant { tag, fields, .. } = &arms[1].pat else {
5883            panic!("expected Variant, got {:?}", arms[1].pat)
5884        };
5885        assert_eq!(tag, "Miss");
5886        assert!(fields.is_empty());
5887        assert!(arms[1].binds.is_empty());
5888
5889        assert!(matches!(exhaustive, Exhaustive::Total));
5890    }
5891
5892    #[test]
5893    fn pattern_ir_variant_over_result_and_option_resolves_via_variants_of() {
5894        let program = checked_program(
5895            r#"
5896commons demo {
5897  fn variant_result(r: Result[Int, String]) -> Int {
5898    match r {
5899      Ok(v) => v
5900      Err(_) => 0
5901    }
5902  }
5903
5904  fn variant_option(o: Option[Int]) -> Int {
5905    match o {
5906      Some(v) => v
5907      None => 0
5908    }
5909  }
5910}
5911"#,
5912        );
5913
5914        let (result_arms, _) = lower_match_fixture(&program, "variant_result");
5915        let IrPat::Variant { tag, fields, .. } = &result_arms[0].pat else {
5916            panic!("expected Variant, got {:?}", result_arms[0].pat)
5917        };
5918        assert_eq!(tag, "Ok");
5919        assert_eq!(fields[0].0, "value");
5920        assert!(matches!(&*fields[0].1, IrPat::Bind { local } if local == "v"));
5921        let IrPat::Variant { tag, fields, .. } = &result_arms[1].pat else {
5922            panic!("expected Variant, got {:?}", result_arms[1].pat)
5923        };
5924        assert_eq!(tag, "Err");
5925        assert_eq!(fields[0].0, "error");
5926        assert!(matches!(&*fields[0].1, IrPat::Wild));
5927
5928        let (option_arms, _) = lower_match_fixture(&program, "variant_option");
5929        let IrPat::Variant { tag, fields, .. } = &option_arms[0].pat else {
5930            panic!("expected Variant, got {:?}", option_arms[0].pat)
5931        };
5932        assert_eq!(tag, "Some");
5933        assert_eq!(fields[0].0, "value");
5934        let IrPat::Variant { tag, fields, .. } = &option_arms[1].pat else {
5935            panic!("expected Variant, got {:?}", option_arms[1].pat)
5936        };
5937        assert_eq!(tag, "None");
5938        assert!(fields.is_empty());
5939    }
5940
5941    #[test]
5942    fn pattern_ir_refined_wraps_the_inner_pattern_and_stays_direct_mode() {
5943        let program = checked_program(
5944            r#"
5945commons demo {
5946  fn refined_case(n: Int) -> Int {
5947    match n {
5948      _ where Positive => 1
5949      _ => 0
5950    }
5951  }
5952}
5953"#,
5954        );
5955
5956        let (arms, exhaustive) = lower_match_fixture(&program, "refined_case");
5957        let IrPat::Refined { inner, refinement } = &arms[0].pat else {
5958            panic!("expected Refined, got {:?}", arms[0].pat)
5959        };
5960        assert!(matches!(&**inner, IrPat::Wild));
5961        assert_eq!(refinement.predicates.len(), 1);
5962        assert_eq!(refinement.predicates[0].kind.name(), "Positive");
5963        assert_eq!(arms[0].binding_mode, BindingMode::Direct);
5964        assert!(matches!(exhaustive, Exhaustive::Total));
5965    }
5966
5967    #[test]
5968    fn pattern_ir_or_pattern_records_or_dispatch_binding_mode_and_shared_binds() {
5969        let program = checked_program(
5970            r#"
5971commons demo {
5972  type Outcome =
5973    | Hit(score: Int)
5974    | Boost(score: Int)
5975    | Miss
5976
5977  fn or_case(o: Outcome) -> Int {
5978    match o {
5979      Hit(score) | Boost(score) => score
5980      Miss => 0
5981    }
5982  }
5983}
5984"#,
5985        );
5986
5987        let (arms, exhaustive) = lower_match_fixture(&program, "or_case");
5988        let IrPat::Or { alts } = &arms[0].pat else {
5989            panic!("expected Or, got {:?}", arms[0].pat)
5990        };
5991        assert_eq!(alts.len(), 2);
5992        assert!(matches!(&alts[0], IrPat::Variant { tag, .. } if tag == "Hit"));
5993        assert!(matches!(&alts[1], IrPat::Variant { tag, .. } if tag == "Boost"));
5994        // The whole point of R5.5/Decision C — a name shared across
5995        // alternatives, at different structural paths, still surfaces as
5996        // one `binds` entry and flips the arm's own dispatch mode.
5997        assert_eq!(arms[0].binds, vec!["score".to_string()]);
5998        assert_eq!(arms[0].binding_mode, BindingMode::OrDispatch);
5999        assert!(matches!(&arms[0].body.kind, IrExprKind::Local(n) if n == "score"));
6000
6001        // `Miss`'s own arm has no `Or` anywhere in its pattern — stays
6002        // `Direct`, proving the mode is per-arm, not a whole-match property.
6003        assert_eq!(arms[1].binding_mode, BindingMode::Direct);
6004
6005        assert!(matches!(exhaustive, Exhaustive::Total));
6006    }
6007
6008    #[test]
6009    fn pattern_ir_arm_guard_lowers_and_sees_the_patterns_own_bindings() {
6010        // R5.4's ordering claim (a guard must be able to read the pattern's
6011        // own bound names) needs a guard that is itself an expression this
6012        // pass can lower. A `Bool` payload field read straight back as the
6013        // guard exercises the exact same scope-ordering property with the
6014        // simplest possible guard expression — no need for a comparison
6015        // (real as of #1189) to pin this claim.
6016        let program = checked_program(
6017            r#"
6018commons demo {
6019  type Outcome =
6020    | Hit(flag: Bool)
6021    | Miss
6022
6023  fn guarded(o: Outcome) -> Int {
6024    match o {
6025      Hit(flag) if flag => 1
6026      _ => 0
6027    }
6028  }
6029}
6030"#,
6031        );
6032
6033        let (arms, exhaustive) = lower_match_fixture(&program, "guarded");
6034        let guard = arms[0]
6035            .guard
6036            .as_ref()
6037            .unwrap_or_else(|| panic!("expected a lowered guard, got {:?}", arms[0].guard));
6038        assert!(matches!(&guard.kind, IrExprKind::Local(n) if n == "flag"));
6039        assert!(matches!(exhaustive, Exhaustive::Total));
6040    }
6041
6042    #[test]
6043    fn pattern_ir_named_bindings_resolve_by_field_name_not_position() {
6044        let program = checked_program(
6045            r#"
6046commons demo {
6047  type Pair =
6048    | Pair(a: Int, b: String)
6049    | Empty
6050
6051  fn named_reordered(p: Pair) -> String {
6052    match p {
6053      Pair(b: s, a: n) => s
6054      Empty => "none"
6055    }
6056  }
6057
6058  fn named_subset(p: Pair) -> Int {
6059    match p {
6060      Pair(a: n) => n
6061      Empty => 0
6062    }
6063  }
6064}
6065"#,
6066        );
6067
6068        // Out-of-order named bindings: `b` (String) is written first in
6069        // source order, `a` (Int) second — an index-keyed lookup would bind
6070        // `s` against `a`'s own `Int` field instead of `b`'s `String` one.
6071        let (reordered_arms, _) = lower_match_fixture(&program, "named_reordered");
6072        let IrPat::Variant { fields, .. } = &reordered_arms[0].pat else {
6073            panic!("expected Variant, got {:?}", reordered_arms[0].pat)
6074        };
6075        assert_eq!(fields.len(), 2);
6076        assert_eq!(fields[0].0, "b");
6077        assert!(matches!(&*fields[0].1, IrPat::Bind { local } if local == "s"));
6078        assert_eq!(fields[1].0, "a");
6079        assert!(matches!(&*fields[1].1, IrPat::Bind { local } if local == "n"));
6080
6081        // The named form's documented strict-subset case: `Pair(a: n)`
6082        // binds only `a`, leaving `b` entirely out of `fields` — not padded
6083        // with a synthesised wildcard for the field it doesn't name.
6084        let (subset_arms, _) = lower_match_fixture(&program, "named_subset");
6085        let IrPat::Variant { fields, .. } = &subset_arms[0].pat else {
6086            panic!("expected Variant, got {:?}", subset_arms[0].pat)
6087        };
6088        assert_eq!(fields.len(), 1);
6089        assert_eq!(fields[0].0, "a");
6090        assert!(matches!(&*fields[0].1, IrPat::Bind { local } if local == "n"));
6091    }
6092
6093    #[test]
6094    fn pattern_ir_nested_variant_pattern_threads_a_derived_field_ty() {
6095        // The one shape where the recursive `lower_pattern_ir` call re-enters
6096        // `variant_info_of` against a *derived* `field_ty` (`Ok`'s own
6097        // `value` field, `Option[Int]`) rather than the top-level scrutinee
6098        // — the substance of Decision A, otherwise only exercised to depth 1.
6099        let program = checked_program(
6100            r#"
6101commons demo {
6102  fn nested_variant(x: Result[Option[Int], String]) -> Int {
6103    match x {
6104      Ok(Some(v)) => v
6105      Ok(None) => 0
6106      Err(_) => 0
6107    }
6108  }
6109}
6110"#,
6111        );
6112
6113        let (arms, exhaustive) = lower_match_fixture(&program, "nested_variant");
6114        let IrPat::Variant {
6115            tag: outer_tag,
6116            fields: outer_fields,
6117            ..
6118        } = &arms[0].pat
6119        else {
6120            panic!("expected Variant, got {:?}", arms[0].pat)
6121        };
6122        assert_eq!(outer_tag, "Ok");
6123        assert_eq!(outer_fields[0].0, "value");
6124        let IrPat::Variant {
6125            tag: inner_tag,
6126            fields: inner_fields,
6127            ..
6128        } = &*outer_fields[0].1
6129        else {
6130            panic!("expected a nested Variant, got {:?}", outer_fields[0].1)
6131        };
6132        assert_eq!(inner_tag, "Some");
6133        assert!(matches!(&*inner_fields[0].1, IrPat::Bind { local } if local == "v"));
6134        assert!(matches!(&arms[0].body.kind, IrExprKind::Local(n) if n == "v"));
6135
6136        assert!(matches!(exhaustive, Exhaustive::Total));
6137    }
6138
6139    #[test]
6140    #[should_panic(expected = "always has at least one unguarded arm")]
6141    fn exhaustive_ir_panics_if_every_arm_is_guarded() {
6142        // Never reachable through a real certified program — the checker's
6143        // own `missing_patterns` gate (`bynk.types.non_exhaustive_match`)
6144        // already rejects an all-guarded arm list as non-exhaustive before
6145        // `certify` ever produces a `CheckedProgram` (finding 3, #1157).
6146        // Hand-built here, bypassing `checked_program` entirely, to prove
6147        // the ADR 0334-style discipline Decision B commissions is real, not
6148        // decorative.
6149        let dummy_expr = Expr {
6150            id: ExprId(0),
6151            kind: ExprKind::BoolLit(true),
6152            span: Span::default(),
6153        };
6154        let arm = MatchArm {
6155            pattern: Pattern::Wildcard(Span::default()),
6156            guard: Some(dummy_expr.clone()),
6157            body: MatchBody::Expr(dummy_expr),
6158            span: Span::default(),
6159        };
6160        let _ = lower_exhaustive_ir(std::slice::from_ref(&arm));
6161    }
6162
6163    // ==== P6.5 (#1159): Match wired to a real consumer ====
6164    //
6165    // Unlike the P6.4 section above, every fixture below goes through the
6166    // ordinary `lower_expr_ir`/`lower_fn_body_ir` path — no digging a
6167    // fixture's own `match` out by hand and calling `lower_arm_ir`/
6168    // `lower_exhaustive_ir` directly.
6169
6170    #[test]
6171    fn match_through_lower_expr_ir_builds_a_real_match_node_with_flat_form() {
6172        // Also this slice's own end-to-end coverage of `Exhaustive::Total`
6173        // reachable through `lower_fn_body_ir` — the P6.4 section above only
6174        // ever reaches it through the standalone `lower_exhaustive_ir` entry
6175        // point, not the real `ExprKind::Match` arm this slice wires up.
6176        let program = checked_program(
6177            r#"
6178commons demo {
6179  type Outcome =
6180    | Hit(score: Int)
6181    | Miss
6182
6183  fn describe(o: Outcome) -> Int {
6184    match o {
6185      Hit(score) => score
6186      Miss => 0
6187    }
6188  }
6189}
6190"#,
6191        );
6192        let ir = lower_fn(&program, "describe");
6193        let tail = fn_tail(&ir);
6194        let IrExprKind::Match {
6195            scrutinee,
6196            arms,
6197            exhaustive,
6198            form,
6199        } = &tail.kind
6200        else {
6201            panic!("expected Match, got {:?}", tail.kind)
6202        };
6203        assert!(matches!(&scrutinee.kind, IrExprKind::Local(n) if n == "o"));
6204        assert_eq!(arms.len(), 2);
6205        assert!(matches!(&arms[0].pat, IrPat::Variant { tag, .. } if tag == "Hit"));
6206        assert!(matches!(&arms[0].body.kind, IrExprKind::Local(n) if n == "score"));
6207        assert!(matches!(&arms[1].pat, IrPat::Variant { tag, .. } if tag == "Miss"));
6208        assert!(matches!(exhaustive, Exhaustive::Total));
6209        assert_eq!(*form, MatchForm::Flat);
6210    }
6211
6212    #[test]
6213    fn match_with_a_guarded_arm_gets_if_chain_form() {
6214        let program = checked_program(
6215            r#"
6216commons demo {
6217  type Outcome =
6218    | Hit(flag: Bool)
6219    | Miss
6220
6221  fn describe(o: Outcome) -> Int {
6222    match o {
6223      Hit(flag) if flag => 1
6224      _ => 0
6225    }
6226  }
6227}
6228"#,
6229        );
6230        let ir = lower_fn(&program, "describe");
6231        let tail = fn_tail(&ir);
6232        let IrExprKind::Match { arms, form, .. } = &tail.kind else {
6233            panic!("expected Match, got {:?}", tail.kind)
6234        };
6235        assert_eq!(*form, MatchForm::IfChain);
6236        // Not just `form` — the guard itself must actually lower, and
6237        // (R5.4 ordering) must see the pattern's own bound name `flag`
6238        // already in scope, resolving as a `Local` rather than falling
6239        // through to `lower_ident_ir`'s `todo!()`. `lower_arm_ir` already
6240        // covers this standalone (`pattern_ir_arm_guard_lowers_and_sees_the_
6241        // patterns_own_bindings`); this pins the same property reached
6242        // through the real `ExprKind::Match` arm instead.
6243        let guard = arms[0]
6244            .guard
6245            .as_ref()
6246            .unwrap_or_else(|| panic!("expected a lowered guard, got {:?}", arms[0].guard));
6247        assert!(matches!(&guard.kind, IrExprKind::Local(n) if n == "flag"));
6248    }
6249
6250    #[test]
6251    fn match_with_a_nested_refutable_payload_pattern_gets_if_chain_form() {
6252        let program = checked_program(
6253            r#"
6254commons demo {
6255  fn describe(x: Result[Option[Int], String]) -> Int {
6256    match x {
6257      Ok(Some(v)) => v
6258      Ok(None) => 0
6259      Err(_) => 0
6260    }
6261  }
6262}
6263"#,
6264        );
6265        let ir = lower_fn(&program, "describe");
6266        let tail = fn_tail(&ir);
6267        let IrExprKind::Match { form, .. } = &tail.kind else {
6268            panic!("expected Match, got {:?}", tail.kind)
6269        };
6270        assert_eq!(*form, MatchForm::IfChain);
6271    }
6272
6273    #[test]
6274    fn match_with_a_refined_arm_gets_if_chain_form() {
6275        // The third of `match_needs_if_chain`'s three disjuncts
6276        // (`Pattern::Refined`), untested through `lower_expr_ir` by the two
6277        // tests above — also pins the IR counterpart this same slice builds
6278        // for a `where`-refined arm (`IrPat::Refined`), so `form` and the
6279        // pattern shape are checked together rather than in isolation.
6280        let program = checked_program(
6281            r#"
6282commons demo {
6283  fn describe(n: Int) -> Int {
6284    match n {
6285      _ where Positive => 1
6286      _ => 0
6287    }
6288  }
6289}
6290"#,
6291        );
6292        let ir = lower_fn(&program, "describe");
6293        let tail = fn_tail(&ir);
6294        let IrExprKind::Match { arms, form, .. } = &tail.kind else {
6295            panic!("expected Match, got {:?}", tail.kind)
6296        };
6297        assert!(matches!(&arms[0].pat, IrPat::Refined { .. }));
6298        assert_eq!(*form, MatchForm::IfChain);
6299    }
6300
6301    #[test]
6302    fn match_with_a_bindingless_or_pattern_stays_flat() {
6303        // The counter-intuitive half of `pattern_has_nested_test`'s own
6304        // `Pattern::Or` rule: a bindingless or-pattern (`Hit | Boost`) stays
6305        // `Flat` — only an or-pattern *with* bindings, or one with a nested
6306        // refutable payload, needs the if-chain. A distinct axis from
6307        // `IrArm::binding_mode` (`OrDispatch` the moment `IrPat::Or` occurs
6308        // anywhere, bindings or not, R5.5 — see `ir_pat_contains_or`'s own
6309        // doc comment), so no assertion on `binding_mode` here. The
6310        // cheapest way to catch a future "any `Or` => if-chain" regression,
6311        // in either the emitter predicate or a re-derivation of it, is
6312        // pinning the case that would silently flip first.
6313        let program = checked_program(
6314            r#"
6315commons demo {
6316  type Outcome =
6317    | Hit
6318    | Boost
6319    | Miss
6320
6321  fn describe(o: Outcome) -> Int {
6322    match o {
6323      Hit | Boost => 1
6324      Miss => 0
6325    }
6326  }
6327}
6328"#,
6329        );
6330        let ir = lower_fn(&program, "describe");
6331        let tail = fn_tail(&ir);
6332        let IrExprKind::Match { arms, form, .. } = &tail.kind else {
6333            panic!("expected Match, got {:?}", tail.kind)
6334        };
6335        assert!(matches!(&arms[0].pat, IrPat::Or { .. }));
6336        assert_eq!(*form, MatchForm::Flat);
6337    }
6338
6339    #[test]
6340    fn match_construction_is_position_agnostic_tail_vs_nested() {
6341        // Mirrors `if_lowers_both_branches_as_blocks_not_return_wrapped`'s
6342        // own precedent: `Match`'s own scrutinee/arms/exhaustive/form don't
6343        // depend on whether the match sits in a fn body's own tail (wrapped
6344        // in `Return` by `lower_fn_body_ir`) or nested inside another
6345        // expression (here, a `let`'s own value) — the same source-level
6346        // match lowers to the same shape either way, since `MatchForm`
6347        // deliberately doesn't record a position axis (Decision A).
6348        let program = checked_program(
6349            r#"
6350commons demo {
6351  type Outcome =
6352    | Hit(score: Int)
6353    | Miss
6354
6355  fn tail_position(o: Outcome) -> Int {
6356    match o {
6357      Hit(score) => score
6358      Miss => 0
6359    }
6360  }
6361
6362  fn nested_position(o: Outcome) -> Int {
6363    let result = match o {
6364      Hit(score) => score
6365      Miss => 0
6366    }
6367    result
6368  }
6369}
6370"#,
6371        );
6372
6373        let tail_ir = lower_fn(&program, "tail_position");
6374        let tail = fn_tail(&tail_ir);
6375        let IrExprKind::Match {
6376            arms: tail_arms,
6377            exhaustive: tail_exhaustive,
6378            form: tail_form,
6379            ..
6380        } = &tail.kind
6381        else {
6382            panic!("tail_position: expected Match, got {:?}", tail.kind)
6383        };
6384
6385        let nested_ir = lower_fn(&program, "nested_position");
6386        let IrExprKind::Block { stmts, .. } = &nested_ir.kind else {
6387            panic!(
6388                "lower_fn_body_ir always returns IrExprKind::Block, got {:?}",
6389                nested_ir.kind
6390            )
6391        };
6392        let IrStmt::Let {
6393            value: nested_match,
6394            ..
6395        } = &stmts[0]
6396        else {
6397            panic!("nested_position: expected a Let statement, got {stmts:?}")
6398        };
6399        let IrExprKind::Match {
6400            arms: nested_arms,
6401            exhaustive: nested_exhaustive,
6402            form: nested_form,
6403            ..
6404        } = &nested_match.kind
6405        else {
6406            panic!(
6407                "nested_position: expected Match, got {:?}",
6408                nested_match.kind
6409            )
6410        };
6411
6412        fn tags(arms: &[IrArm]) -> Vec<&str> {
6413            arms.iter()
6414                .map(|a| match &a.pat {
6415                    IrPat::Variant { tag, .. } => tag.as_str(),
6416                    other => panic!("expected a Variant pattern, got {other:?}"),
6417                })
6418                .collect()
6419        }
6420        assert_eq!(tags(tail_arms), tags(nested_arms));
6421        assert_eq!(*tail_form, *nested_form);
6422        assert!(matches!(tail_exhaustive, Exhaustive::Total));
6423        assert!(matches!(nested_exhaustive, Exhaustive::Total));
6424    }
6425
6426    #[test]
6427    fn type_item_record_resolves_fields_and_generic_rigid_vars() {
6428        let program = checked_program(
6429            r#"
6430commons demo {
6431  type Box[T] = { value: T }
6432}
6433"#,
6434        );
6435        let decl = find_type(&program, "Box");
6436        let item = lower_type_item_ir(decl, &program);
6437        let IrItem::Type { shape } = &item else {
6438            panic!("expected IrItem::Type, got {item:?}")
6439        };
6440        let TypeShape::Record { fields } = shape else {
6441            panic!("expected TypeShape::Record, got {shape:?}")
6442        };
6443        assert_eq!(fields.len(), 1);
6444        assert_eq!(fields[0].0, "value");
6445        assert!(matches!(
6446            &*program.program().ty_intern.get(fields[0].1),
6447            Ty::Var(name) if name == "T"
6448        ));
6449    }
6450
6451    #[test]
6452    fn type_item_sum_resolves_variant_payloads_and_embeds() {
6453        let program = checked_program(
6454            r#"
6455commons demo {
6456  type PaymentError = enum { Declined, InsufficientFunds }
6457
6458  type OrderError =
6459    | OutOfStock(sku: String, qty: Int)
6460    | Payment(reason: PaymentError)
6461    embeds PaymentError as Payment
6462}
6463"#,
6464        );
6465        let decl = find_type(&program, "OrderError");
6466        let item = lower_type_item_ir(decl, &program);
6467        let IrItem::Type { shape, .. } = &item else {
6468            panic!("expected IrItem::Type, got {item:?}")
6469        };
6470        let TypeShape::Sum { variants, embeds } = shape else {
6471            panic!("expected TypeShape::Sum, got {shape:?}")
6472        };
6473        assert_eq!(variants.len(), 2);
6474        assert_eq!(variants[0].0, "OutOfStock");
6475        assert_eq!(variants[0].1.len(), 2);
6476        assert_eq!(variants[0].1[0].0, "sku");
6477        assert!(matches!(
6478            &*program.program().ty_intern.get(variants[0].1[0].1),
6479            Ty::Base(bynk_syntax::ast::BaseType::String)
6480        ));
6481        assert_eq!(variants[0].1[1].0, "qty");
6482        assert!(matches!(
6483            &*program.program().ty_intern.get(variants[0].1[1].1),
6484            Ty::Base(bynk_syntax::ast::BaseType::Int)
6485        ));
6486        assert_eq!(variants[1].0, "Payment");
6487        assert_eq!(variants[1].1.len(), 1);
6488        assert_eq!(variants[1].1[0].0, "reason");
6489
6490        assert_eq!(embeds.len(), 1);
6491        let (source, tag) = &embeds[0];
6492        assert_eq!(tag, "Payment");
6493        let Ty::Named { name, .. } = &*program.program().ty_intern.get(*source) else {
6494            panic!("expected embeds source to resolve to a named type")
6495        };
6496        assert_eq!(name, "PaymentError");
6497    }
6498
6499    #[test]
6500    fn type_item_refined_and_opaque_cover_bare_and_predicated_and_opaque_forms() {
6501        let program = checked_program(
6502            r#"
6503commons demo {
6504  type Age = Int where Positive
6505  type UserId = opaque Int
6506  type Bare = Int
6507}
6508"#,
6509        );
6510        let age = find_type(&program, "Age");
6511        let IrItem::Type {
6512            shape: age_shape, ..
6513        } = lower_type_item_ir(age, &program)
6514        else {
6515            unreachable!()
6516        };
6517        let TypeShape::Refined {
6518            base,
6519            refinement,
6520            opaque,
6521        } = age_shape
6522        else {
6523            panic!("expected TypeShape::Refined for Age")
6524        };
6525        assert_eq!(base, bynk_syntax::ast::BaseType::Int);
6526        assert!(refinement.is_some());
6527        assert!(!opaque);
6528
6529        let user_id = find_type(&program, "UserId");
6530        let IrItem::Type {
6531            shape: user_id_shape,
6532            ..
6533        } = lower_type_item_ir(user_id, &program)
6534        else {
6535            unreachable!()
6536        };
6537        let TypeShape::Refined {
6538            refinement, opaque, ..
6539        } = user_id_shape
6540        else {
6541            panic!("expected TypeShape::Refined for UserId")
6542        };
6543        assert!(refinement.is_none());
6544        assert!(opaque);
6545
6546        let bare = find_type(&program, "Bare");
6547        let IrItem::Type {
6548            shape: bare_shape, ..
6549        } = lower_type_item_ir(bare, &program)
6550        else {
6551            unreachable!()
6552        };
6553        let TypeShape::Refined {
6554            refinement, opaque, ..
6555        } = bare_shape
6556        else {
6557            panic!("expected TypeShape::Refined for Bare")
6558        };
6559        assert!(refinement.is_none());
6560        assert!(!opaque);
6561    }
6562
6563    #[test]
6564    fn fn_item_covers_effectful_and_pure_and_generic_and_method() {
6565        let program = checked_program(
6566            r#"
6567commons demo {
6568  type Box[A] = { value: A }
6569
6570  fn Box.get(self) -> A { self.value }
6571
6572  fn Box.fetch(self) -> Effect[A] { Effect.pure(self.value) }
6573
6574  fn two_params(a: Int, b: Int) -> Int { a }
6575
6576  fn identity[T](x: T) -> T { x }
6577
6578  fn fetch() -> Effect[Int] { Effect.pure(1) }
6579}
6580"#,
6581        );
6582
6583        let two_params = find_fn_arc(&program, "two_params");
6584        let IrItem::Fn {
6585            receiver,
6586            params,
6587            ret,
6588            effectful,
6589            ..
6590        } = lower_fn_item_ir(two_params, &program)
6591        else {
6592            unreachable!()
6593        };
6594        assert!(receiver.is_none(), "a free function has no receiver");
6595        assert_eq!(params.len(), 2);
6596        assert_eq!(params[0].0, "a");
6597        assert_eq!(params[1].0, "b");
6598        assert!(matches!(
6599            &*program.program().ty_intern.get(ret),
6600            Ty::Base(bynk_syntax::ast::BaseType::Int)
6601        ));
6602        assert!(!effectful);
6603
6604        let fetch = find_fn_arc(&program, "fetch");
6605        let IrItem::Fn { effectful, .. } = lower_fn_item_ir(fetch, &program) else {
6606            unreachable!()
6607        };
6608        assert!(effectful);
6609
6610        let identity = find_fn_arc(&program, "identity");
6611        let IrItem::Fn { params, ret, .. } = lower_fn_item_ir(identity, &program) else {
6612            unreachable!()
6613        };
6614        assert!(matches!(
6615            &*program.program().ty_intern.get(params[0].1),
6616            Ty::Var(n) if n == "T"
6617        ));
6618        assert!(matches!(
6619            &*program.program().ty_intern.get(ret),
6620            Ty::Var(n) if n == "T"
6621        ));
6622
6623        let method = find_fn_arc(&program, "Box.get");
6624        let IrItem::Fn {
6625            receiver,
6626            params,
6627            ret,
6628            effectful,
6629            ..
6630        } = lower_fn_item_ir(method, &program)
6631        else {
6632            unreachable!()
6633        };
6634        assert!(params.is_empty(), "self is not a param — see receiver");
6635        let Some(receiver_ty) = receiver else {
6636            panic!("expected Box.get to carry a receiver")
6637        };
6638        let Ty::Named { name, args, .. } = &*program.program().ty_intern.get(receiver_ty) else {
6639            panic!("expected the receiver to resolve to a named type")
6640        };
6641        assert_eq!(name, "Box");
6642        assert_eq!(args.len(), 1);
6643        assert!(matches!(
6644            &*program.program().ty_intern.get(args[0]),
6645            Ty::Var(n) if n == "A"
6646        ));
6647        assert!(matches!(
6648            &*program.program().ty_intern.get(ret),
6649            Ty::Var(n) if n == "A"
6650        ));
6651        assert!(!effectful);
6652
6653        let effectful_method = find_fn_arc(&program, "Box.fetch");
6654        let IrItem::Fn {
6655            receiver,
6656            effectful,
6657            ..
6658        } = lower_fn_item_ir(effectful_method, &program)
6659        else {
6660            unreachable!()
6661        };
6662        assert!(
6663            receiver.is_some(),
6664            "Box.fetch is also a method with a receiver"
6665        );
6666        assert!(effectful, "Effect[A] return makes Box.fetch effectful");
6667    }
6668
6669    #[test]
6670    fn type_item_sum_covers_a_payload_less_variant() {
6671        let program = checked_program(
6672            r#"
6673commons demo {
6674  type PaymentError = enum { Declined, InsufficientFunds }
6675}
6676"#,
6677        );
6678        let decl = find_type(&program, "PaymentError");
6679        let item = lower_type_item_ir(decl, &program);
6680        let IrItem::Type { shape, .. } = &item else {
6681            panic!("expected IrItem::Type, got {item:?}")
6682        };
6683        let TypeShape::Sum { variants, embeds } = shape else {
6684            panic!("expected TypeShape::Sum, got {shape:?}")
6685        };
6686        assert_eq!(variants.len(), 2);
6687        assert_eq!(variants[0].0, "Declined");
6688        assert!(
6689            variants[0].1.is_empty(),
6690            "a bare variant carries no payload"
6691        );
6692        assert_eq!(variants[1].0, "InsufficientFunds");
6693        assert!(variants[1].1.is_empty());
6694        assert!(embeds.is_empty());
6695    }
6696
6697    #[test]
6698    fn type_item_record_drops_a_fields_own_inline_refinement() {
6699        // Decision B extension, `ir.rs`'s own `TypeShape::Record` doc
6700        // comment: a field's inline `where` clause is a construction-time
6701        // constraint the checker already enforces, not part of the emitted
6702        // shape — pin that the field still lowers to `(name, ty)` with the
6703        // refinement silently absent, not that lowering rejects it.
6704        let program = checked_program(
6705            r#"
6706commons demo {
6707  type Account = { balance: Int where NonNegative }
6708}
6709"#,
6710        );
6711        let decl = find_type(&program, "Account");
6712        let item = lower_type_item_ir(decl, &program);
6713        let IrItem::Type { shape, .. } = &item else {
6714            panic!("expected IrItem::Type, got {item:?}")
6715        };
6716        let TypeShape::Record { fields } = shape else {
6717            panic!("expected TypeShape::Record, got {shape:?}")
6718        };
6719        assert_eq!(fields.len(), 1);
6720        assert_eq!(fields[0].0, "balance");
6721        assert!(matches!(
6722            &*program.program().ty_intern.get(fields[0].1),
6723            Ty::Base(bynk_syntax::ast::BaseType::Int)
6724        ));
6725    }
6726
6727    #[test]
6728    fn type_item_record_resolves_a_generic_type_application_field() {
6729        // The `TypeRef::App` arm of `resolve_type_ref_in` — the one arm
6730        // that returns `None` for an unknown/unapplied name, and so the one
6731        // most likely to silently hit this pass's own ADR 0334 panic if the
6732        // field's resolution were ever wired up wrong.
6733        let program = checked_program(
6734            r#"
6735commons demo {
6736  type Box[T] = { value: T }
6737  type Wrapper = { boxed: Box[Int] }
6738}
6739"#,
6740        );
6741        let decl = find_type(&program, "Wrapper");
6742        let item = lower_type_item_ir(decl, &program);
6743        let IrItem::Type { shape, .. } = &item else {
6744            panic!("expected IrItem::Type, got {item:?}")
6745        };
6746        let TypeShape::Record { fields } = shape else {
6747            panic!("expected TypeShape::Record, got {shape:?}")
6748        };
6749        assert_eq!(fields.len(), 1);
6750        assert_eq!(fields[0].0, "boxed");
6751        let Ty::Named { name, args, .. } = &*program.program().ty_intern.get(fields[0].1) else {
6752            panic!("expected `boxed` to resolve to a named type")
6753        };
6754        assert_eq!(name, "Box");
6755        assert_eq!(args.len(), 1);
6756        assert!(matches!(
6757            &*program.program().ty_intern.get(args[0]),
6758            Ty::Base(bynk_syntax::ast::BaseType::Int)
6759        ));
6760    }
6761
6762    /// Like [`checked_program`], but for source that declares an `agent`
6763    /// and/or a `service` — both are only legal inside a `context`, not a
6764    /// bare `commons` (`bynk.agent.outside_context`/the service
6765    /// equivalent), so `source` is parsed as a context unit and its items
6766    /// re-wrapped into a [`Commons`] value before re-using the same
6767    /// `resolve`/`check` pipeline `checked_program` does.
6768    /// `resolver::resolve`/`checker::check` both already treat
6769    /// `CommonsItem::Agent`/`Service` as inert (v0.5 declaration kinds "go
6770    /// through the context-level v0.5 path", `resolver.rs`'s own comment)
6771    /// — real agent/service checking (`store` field kinds, handler bodies,
6772    /// `expr_types` for a `Cell` initialiser, actor bindings) only happens
6773    /// via `context_checks::check_context_declarations`, called here by
6774    /// hand with a [`symbols::UnitTable`] built directly from the checked
6775    /// commons' own agent/service/actor and local `capability` items (a
6776    /// handler's `given <Cap>` resolves against `table.capabilities` —
6777    /// populated here so a fixture can declare its own local capability,
6778    /// but still no cross-context `uses`/`consumes` in any fixture this
6779    /// helper is given, so `resolver::CrossContextInfo::default()` is
6780    /// exact, not an approximation — in particular, no `from Events(E)`
6781    /// fixture is possible here, since a real subscription needs
6782    /// `consumes bynk { Events }`).
6783    ///
6784    /// **P6.11 (#1171) adds `services`/`actors` to the table and a
6785    /// pre-`resolve` `inject_service_defaults` pass.** `table.actors`
6786    /// matters even for a fixture using only the prelude (`Caller`,
6787    /// `Visitor`): `actor_identity_ty` resolves a *local* `actor`
6788    /// declaration through `table.actors` and silently falls through to
6789    /// the prelude/`Ty::Unit` otherwise
6790    /// (`context_checks.rs::actor_identity_ty`), so a `by u: Buyer`
6791    /// fixture without this would assert against a wrong `TyId` with no
6792    /// error at all. `inject_service_defaults` stands in for
6793    /// `bynk-check/src/analysis.rs`'s own pipeline-phase-2b call — this
6794    /// reduced harness has no such phase — so a fixture relying on a
6795    /// service-level `by`/`given` default (rather than declaring one per
6796    /// handler) would otherwise silently see it un-inherited, pinning the
6797    /// wrong fact with no failure to signal it.
6798    fn checked_context_program(source: &str) -> CheckedProgram {
6799        let tokens = lexer::tokenize(source).expect("lex");
6800        let unit = parser::parse_unit(&tokens, source).expect("parse");
6801        let SourceUnit::Context(mut ctx) = unit else {
6802            panic!("expected a context unit, got {unit:?}")
6803        };
6804        for item in &mut ctx.items {
6805            if let CommonsItem::Service(svc) = item {
6806                bynk_check::project_model::inject_service_defaults(svc);
6807            }
6808        }
6809        let commons = Commons {
6810            name: ctx.name,
6811            items: ctx.items,
6812            uses: ctx.uses,
6813            documentation: ctx.documentation,
6814            form: ctx.form,
6815            span: ctx.span,
6816            trivia: ctx.trivia,
6817            trailing_comments: ctx.trailing_comments,
6818        };
6819        let resolved = resolver::resolve(commons).expect("resolve");
6820        let mut typed = checker::check(resolved).expect("check");
6821        let agents: HashMap<String, AgentDecl> = typed
6822            .commons
6823            .items
6824            .iter()
6825            .filter_map(|item| match item {
6826                CommonsItem::Agent(a) => Some((a.name.name.clone(), a.clone())),
6827                _ => None,
6828            })
6829            .collect();
6830        let services: HashMap<String, ServiceDecl> = typed
6831            .commons
6832            .items
6833            .iter()
6834            .filter_map(|item| match item {
6835                CommonsItem::Service(s) => Some((s.name.name.clone(), s.clone())),
6836                _ => None,
6837            })
6838            .collect();
6839        let actors: HashMap<String, bynk_syntax::ast::ActorDecl> = typed
6840            .commons
6841            .items
6842            .iter()
6843            .filter_map(|item| match item {
6844                CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
6845                _ => None,
6846            })
6847            .collect();
6848        // A `given <Cap>` clause on a handler resolves against
6849        // `table.capabilities` (`context_checks.rs`'s own `capability_info_map`
6850        // construction) — populated here from this fixture's own local
6851        // `capability` declarations so a handler can legitimately declare one
6852        // (e.g. `Log.append`'s own `given Clock` requirement), the same
6853        // "no cross-context uses/consumes" scope this helper's own doc
6854        // comment already names for `agents`/`types`.
6855        let capabilities: HashMap<String, bynk_syntax::ast::CapabilityDecl> = typed
6856            .commons
6857            .items
6858            .iter()
6859            .filter_map(|item| match item {
6860                CommonsItem::Capability(c) => Some((c.name.name.clone(), c.clone())),
6861                _ => None,
6862            })
6863            .collect();
6864        // P6.14 (#1174): `check_provider_decls` (the pass that actually
6865        // type-checks a `ProviderOp`'s own body via `check_handler_body`)
6866        // reads `table.providers`, keyed by capability name — same "one
6867        // provider per capability in v0.5" convention
6868        // `symbols::UnitTable::providers`'s own doc comment names. Populated
6869        // here for the same reason `capabilities` is (above): without it, a
6870        // fixture's own `provides` declaration is silently never checked at
6871        // all, not merely under-checked (feedback memory
6872        // "bynk-emit test harness scope").
6873        let providers: HashMap<String, ProviderDecl> = typed
6874            .commons
6875            .items
6876            .iter()
6877            .filter_map(|item| match item {
6878                CommonsItem::Provider(p) => Some((p.capability.name.clone(), p.clone())),
6879                _ => None,
6880            })
6881            .collect();
6882        let table = symbols::UnitTable {
6883            kind: Some(UnitKind::Context),
6884            types: typed.types.clone(),
6885            agents,
6886            services,
6887            actors,
6888            capabilities,
6889            providers,
6890            ..symbols::UnitTable::default()
6891        };
6892        let tys = typed.ty_intern.clone();
6893        let errors = context_checks::check_context_declarations(
6894            &mut typed,
6895            &table,
6896            &resolver::CrossContextInfo::default(),
6897            true,
6898            &HashSet::new(),
6899            &HashMap::new(),
6900            &mut RefSink::new(),
6901            &mut HintSink::new(),
6902            &mut LocalsSink::new(),
6903            &mut RequirementSink::new(),
6904            &tys,
6905        );
6906        checker::certify(typed, errors).expect("certify")
6907    }
6908
6909    fn find_agent<'a>(program: &'a CheckedProgram, name: &str) -> &'a AgentDecl {
6910        program
6911            .program()
6912            .commons
6913            .items
6914            .iter()
6915            .find_map(|item| match item {
6916                CommonsItem::Agent(a) if a.name.name == name => Some(a),
6917                _ => None,
6918            })
6919            .unwrap_or_else(|| panic!("no agent named `{name}` in this fixture"))
6920    }
6921
6922    /// `lower_actor_seam_ir`'s own `actors` map — rebuilt the same way
6923    /// `checked_context_program` builds its own throwaway `table.actors`
6924    /// (not itself exposed on `CheckedProgram`), since the resolvers it
6925    /// wraps take the same `HashMap<String, ActorDecl>` shape the real
6926    /// `table.actors`/`ctx.actors` emitter-side callers already carry.
6927    fn actors_map(program: &CheckedProgram) -> HashMap<String, ActorDecl> {
6928        program
6929            .program()
6930            .commons
6931            .items
6932            .iter()
6933            .filter_map(|item| match item {
6934                CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
6935                _ => None,
6936            })
6937            .collect()
6938    }
6939
6940    fn find_service<'a>(program: &'a CheckedProgram, name: &str) -> &'a ServiceDecl {
6941        program
6942            .program()
6943            .commons
6944            .items
6945            .iter()
6946            .find_map(|item| match item {
6947                CommonsItem::Service(s) if s.name.name == name => Some(s),
6948                _ => None,
6949            })
6950            .unwrap_or_else(|| panic!("no service named `{name}` in this fixture"))
6951    }
6952
6953    fn find_capability<'a>(program: &'a CheckedProgram, name: &str) -> &'a CapabilityDecl {
6954        program
6955            .program()
6956            .commons
6957            .items
6958            .iter()
6959            .find_map(|item| match item {
6960                CommonsItem::Capability(c) if c.name.name == name => Some(c),
6961                _ => None,
6962            })
6963            .unwrap_or_else(|| panic!("no capability named `{name}` in this fixture"))
6964    }
6965
6966    fn find_provider<'a>(program: &'a CheckedProgram, name: &str) -> &'a ProviderDecl {
6967        program
6968            .program()
6969            .commons
6970            .items
6971            .iter()
6972            .find_map(|item| match item {
6973                CommonsItem::Provider(p) if p.provider_name.name == name => Some(p),
6974                _ => None,
6975            })
6976            .unwrap_or_else(|| panic!("no provider named `{name}` in this fixture"))
6977    }
6978
6979    /// `find_handler` (below) matches on `method_name`, which is always
6980    /// `None` for a service handler — useless here. Matches on
6981    /// `HandlerKind` equality instead; not a unique identity on its own (a
6982    /// service may declare several handlers sharing one `HandlerKind`, all
6983    /// `on call`), so a fixture with more than one same-kind handler must
6984    /// index `service.handlers`/the lowered `handlers` slice directly
6985    /// instead of calling this twice.
6986    fn find_service_handler<'a>(service: &'a ServiceDecl, kind: &HandlerKind) -> &'a Handler {
6987        service
6988            .handlers
6989            .iter()
6990            .find(|h| &h.kind == kind)
6991            .unwrap_or_else(|| {
6992                panic!(
6993                    "no handler of kind {kind:?} on service `{}`",
6994                    service.name.name
6995                )
6996            })
6997    }
6998
6999    fn find_store_field<'a>(agent: &'a AgentDecl, name: &str) -> &'a StoreField {
7000        agent
7001            .store_fields
7002            .iter()
7003            .find(|f| f.name.name == name)
7004            .unwrap_or_else(|| {
7005                panic!(
7006                    "no store field named `{name}` on agent `{}`",
7007                    agent.name.name
7008                )
7009            })
7010    }
7011
7012    #[test]
7013    fn store_field_cell_with_initialiser_and_without() {
7014        let program = checked_context_program(
7015            r#"
7016context demo
7017
7018agent Counter {
7019  key id: String
7020  store balance: Cell[Int] = 0
7021  store hint: Cell[String]
7022
7023  on call touch() -> Effect[()] {
7024    Effect.pure(())
7025  }
7026}
7027"#,
7028        );
7029        let agent = find_agent(&program, "Counter");
7030
7031        let balance = find_store_field(agent, "balance");
7032        let ir = lower_store_field_ir(balance, &program);
7033        assert_eq!(ir.field, "balance");
7034        assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
7035        assert!(
7036            ir.init.is_some(),
7037            "a Cell field with an initialiser lowers a real init expression"
7038        );
7039        assert!(ir.indexed.is_empty());
7040
7041        let hint = find_store_field(agent, "hint");
7042        let ir = lower_store_field_ir(hint, &program);
7043        assert_eq!(ir.field, "hint");
7044        assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
7045        assert!(
7046            ir.init.is_none(),
7047            "Decision D's own boundary: a Cell field with no initialiser lowers to None, \
7048             not merely reached by omission"
7049        );
7050    }
7051
7052    /// #1187's Agent state-field slice: [`lower_store_field_shape_ir`]
7053    /// exists precisely because [`lower_store_field_ir`] could not lower
7054    /// these two shapes without hitting an `IrExprKind` gap — `= None`
7055    /// (`ExprKind::None`) hit the `Ok`/`Err`/`Some`/`None` gap, closed as of
7056    /// #1225's own ADR; an `is`-expression initialiser (`ExprKind::Is`)
7057    /// still hits its own separate, still-open `todo!()` a few hundred
7058    /// lines below. Both are real, certified fixtures, not hypothetical:
7059    /// `223_store_cell_agent` (`store paymentRef: Cell[Option[AuthId]] =
7060    /// None`, now lowered directly by `lower_store_field_ir` too — see
7061    /// `store_field_cell_option_init_none_lowers_without_panicking`) and
7062    /// `1029_agent_static_init_hoist` (`store active: Cell[Bool] = if true
7063    /// { 5 is PositiveInt } else { false }`, still `is`-blocked). This pins
7064    /// that the shape-only reader never touches `init` at all regardless —
7065    /// unconditionally true, not contingent on either gap's own status — so
7066    /// it lowers both fields cleanly whether or not `lower_store_field_ir`
7067    /// itself still would panic on them.
7068    #[test]
7069    fn store_field_shape_ir_does_not_panic_on_none_or_is_initialisers() {
7070        let program = checked_context_program(
7071            r#"
7072context demo
7073
7074type AuthId = String where NonEmpty
7075
7076agent Order {
7077  key id: String
7078  store paymentRef: Cell[Option[AuthId]] = None
7079
7080  on call touch() -> Effect[()] {
7081    Effect.pure(())
7082  }
7083}
7084"#,
7085        );
7086        let agent = find_agent(&program, "Order");
7087        let payment_ref = find_store_field(agent, "paymentRef");
7088        let ir = lower_store_field_shape_ir(payment_ref, &program);
7089        assert_eq!(ir.field, "paymentRef");
7090        assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
7091        assert!(
7092            ir.init.is_none(),
7093            "the shape-only reader never lowers init, regardless of the source field"
7094        );
7095
7096        let program = checked_context_program(
7097            r#"
7098context demo
7099
7100type PositiveInt = Int where Positive
7101
7102agent Meter {
7103  key id: String
7104  store active: Cell[Bool] = if true { 5 is PositiveInt } else { false }
7105
7106  on call touch() -> Effect[()] {
7107    Effect.pure(())
7108  }
7109}
7110"#,
7111        );
7112        let agent = find_agent(&program, "Meter");
7113        let active = find_store_field(agent, "active");
7114        let ir = lower_store_field_shape_ir(active, &program);
7115        assert_eq!(ir.field, "active");
7116        assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
7117        assert!(ir.init.is_none());
7118    }
7119
7120    #[test]
7121    fn store_field_falls_back_to_unit_on_an_unresolvable_type_like_the_checker_does() {
7122        // #1187's Agent state-field slice, step 0: no checker pass validates
7123        // a store field's own type reference (only its shape — `Cell`/`Map`/
7124        // `Set`/`Cache`/`Log` — and `@ttl`/`@indexed` legality), so
7125        // `store x: Cell[Bogus] = "hello"` certifies today (exit 0, no
7126        // diagnostic, verified empirically against the real `bynkc` binary)
7127        // even though `Bogus` is undeclared and the initialiser's own type
7128        // doesn't match it. `resolve_store_field_ty` must mirror
7129        // `lower_op_sig_ir`'s own `Ty::Unit` fallback rather than panic on a
7130        // state that is, in fact, reachable from source — shared by
7131        // `lower_store_field_ir` and `lower_store_field_shape_ir` alike.
7132        // Only the shape reader is exercised end-to-end here: with the
7133        // field's own type unresolved, the checker's init-checking loop
7134        // leaves `"hello"` untyped too (a second, separate gap in the
7135        // dormant, not-yet-wired `lower_store_field_ir`'s `init` arm — out
7136        // of scope for this slice, which never lowers `init` at all).
7137        let program = checked_context_program(
7138            r#"
7139context demo
7140
7141agent Widget {
7142  key id: String
7143  store x: Cell[Bogus] = "hello"
7144
7145  on call touch() -> Effect[()] {
7146    Effect.pure(())
7147  }
7148}
7149"#,
7150        );
7151        let agent = find_agent(&program, "Widget");
7152        let x = find_store_field(agent, "x");
7153        // Would already have panicked inside `checked_context_program`'s own
7154        // `.expect("certify")` if this were rejected upstream — reaching
7155        // here at all is part of what this test pins.
7156        let shape = lower_store_field_shape_ir(x, &program);
7157        let StoreKindIr::Cell(ty) = shape.kind else {
7158            panic!("expected StoreKindIr::Cell, got {:?}", shape.kind)
7159        };
7160        assert!(matches!(&*program.program().ty_intern.get(ty), Ty::Unit));
7161    }
7162
7163    #[test]
7164    fn store_field_cell_init_qualified_constructor_call_lowers_without_panicking() {
7165        // A `Cell` field's own initialiser is checked via
7166        // `checker::check_state_initialiser`, which types call-shaped
7167        // sub-expressions (`T.unsafe(lit)`, a qualified sum-variant
7168        // constructor) through the same `Ctx` every other body check uses —
7169        // including recording a `Callee` for the call. `lower_store_field_ir`
7170        // lowers that same init expression through `lower_expr_ir`, which
7171        // reads `program.callees` unconditionally for a call-shaped node
7172        // (ADR 0334): this pins that `check_static_initialiser` persists its
7173        // own `Callee` into the real `typed.callees` sink rather than a
7174        // throwaway local, so this doesn't panic on a certified program the
7175        // checker legitimately accepted.
7176        let program = checked_context_program(
7177            r#"
7178context demo
7179
7180type PositiveId = opaque Int where NonNegative
7181
7182agent Counter {
7183  key id: String
7184  store n: Cell[PositiveId] = PositiveId.unsafe(1)
7185
7186  on call touch() -> Effect[()] {
7187    Effect.pure(())
7188  }
7189}
7190"#,
7191        );
7192        let agent = find_agent(&program, "Counter");
7193        let n = find_store_field(agent, "n");
7194        let ir = lower_store_field_ir(n, &program);
7195        assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
7196        assert!(ir.init.is_some());
7197    }
7198
7199    /// #1225's own ADR resolution, verified against the real regression it
7200    /// closes: `223_store_cell_agent` (`bynkc/tests/fixtures/positive`) has
7201    /// `store paymentRef: Cell[Option[AuthId]] = None` — before this ADR,
7202    /// `lower_store_field_ir`'s own `init` lowering hit the `ExprKind::None`
7203    /// `todo!()` this module used to carry (`lower_store_field_shape_ir`,
7204    /// #1187's Agent state-field slice 2a, was built specifically to avoid
7205    /// ever lowering `init` at all, precisely because of this gap — see its
7206    /// own doc comment). This pins that the *full*, `init`-lowering
7207    /// `lower_store_field_ir` no longer panics on the exact shape that
7208    /// fixture carries.
7209    #[test]
7210    fn store_field_cell_option_init_none_lowers_without_panicking() {
7211        let program = checked_context_program(
7212            r#"
7213context demo
7214
7215type AuthId = String where NonEmpty
7216
7217agent Order {
7218  key id: String
7219  store paymentRef: Cell[Option[AuthId]] = None
7220
7221  on call touch() -> Effect[()] {
7222    Effect.pure(())
7223  }
7224}
7225"#,
7226        );
7227        let agent = find_agent(&program, "Order");
7228        let payment_ref = find_store_field(agent, "paymentRef");
7229        let ir = lower_store_field_ir(payment_ref, &program);
7230        assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
7231        let Some(init) = &ir.init else {
7232            panic!("expected a lowered `None` initialiser, got None")
7233        };
7234        assert!(matches!(
7235            &init.kind,
7236            IrExprKind::Variant { tag, payload } if tag == "None" && payload.is_empty()
7237        ));
7238    }
7239
7240    #[test]
7241    fn store_field_map_indexed_zero_one_and_multiple() {
7242        let program = checked_context_program(
7243            r#"
7244context demo
7245
7246type Reservation = {
7247  id: String,
7248  orderId: String,
7249  status: String,
7250}
7251
7252agent Inventory {
7253  key sku: String
7254  store noIndex: Map[String, Reservation]
7255  store oneIndex: Map[String, Reservation] @indexed(by: orderId)
7256  store twoIndex: Map[String, Reservation] @indexed(by: orderId, by: status)
7257  store dupIndex: Map[String, Reservation] @indexed(by: orderId, by: orderId)
7258
7259  on call touch() -> Effect[()] {
7260    Effect.pure(())
7261  }
7262}
7263"#,
7264        );
7265        let agent = find_agent(&program, "Inventory");
7266
7267        let no_index = find_store_field(agent, "noIndex");
7268        let ir = lower_store_field_ir(no_index, &program);
7269        assert!(matches!(ir.kind, StoreKindIr::Map(_, _)));
7270        assert!(ir.indexed.is_empty());
7271        assert!(
7272            ir.init.is_none(),
7273            "Decision D: init is None for a non-Cell kind"
7274        );
7275
7276        let one_index = find_store_field(agent, "oneIndex");
7277        let ir = lower_store_field_ir(one_index, &program);
7278        assert_eq!(ir.indexed, vec!["orderId".to_string()]);
7279
7280        let two_index = find_store_field(agent, "twoIndex");
7281        let ir = lower_store_field_ir(two_index, &program);
7282        assert_eq!(
7283            ir.indexed,
7284            vec!["orderId".to_string(), "status".to_string()],
7285            "Vec<IndexIr>'s own multi-entry case, in the annotation's own by: order"
7286        );
7287
7288        // `validate_indexed_keys` (bynk-check) validates each `by:`
7289        // argument independently with no duplicate check, so
7290        // `@indexed(by: orderId, by: orderId)` certifies — this pins that
7291        // lowering still dedupes, mirroring the shipped emitter's own
7292        // `store_map_indexes` guard, rather than emitting the same sibling
7293        // index table twice.
7294        let dup_index = find_store_field(agent, "dupIndex");
7295        let ir = lower_store_field_ir(dup_index, &program);
7296        assert_eq!(
7297            ir.indexed,
7298            vec!["orderId".to_string()],
7299            "a duplicate by: key collapses to one sibling-table entry"
7300        );
7301    }
7302
7303    #[test]
7304    fn store_field_set_element_type() {
7305        let program = checked_context_program(
7306            r#"
7307context demo
7308
7309agent Tags {
7310  key id: String
7311  store tags: Set[String]
7312
7313  on call touch() -> Effect[()] {
7314    Effect.pure(())
7315  }
7316}
7317"#,
7318        );
7319        let agent = find_agent(&program, "Tags");
7320        let tags = find_store_field(agent, "tags");
7321        let ir = lower_store_field_ir(tags, &program);
7322        assert_eq!(ir.field, "tags");
7323        assert!(matches!(ir.kind, StoreKindIr::Set(_)));
7324        assert!(ir.init.is_none());
7325        assert!(ir.indexed.is_empty());
7326    }
7327
7328    #[test]
7329    fn store_field_cache_resolves_ttl_millis() {
7330        let program = checked_context_program(
7331            r#"
7332context demo
7333
7334agent Sessions {
7335  key id: String
7336  store live: Cache[String, Int] @ttl(5.minutes)
7337
7338  on call touch() -> Effect[()] {
7339    Effect.pure(())
7340  }
7341}
7342"#,
7343        );
7344        let agent = find_agent(&program, "Sessions");
7345        let live = find_store_field(agent, "live");
7346        let ir = lower_store_field_ir(live, &program);
7347        assert_eq!(ir.field, "live");
7348        let StoreKindIr::Cache(_, _, ttl) = ir.kind else {
7349            panic!("expected StoreKindIr::Cache, got {:?}", ir.kind)
7350        };
7351        assert_eq!(
7352            ttl,
7353            5 * 60 * 1000,
7354            "5.minutes' own DurationLit.millis, not re-derived arithmetic"
7355        );
7356    }
7357
7358    #[test]
7359    fn store_field_log_with_and_without_retain() {
7360        let program = checked_context_program(
7361            r#"
7362context demo
7363
7364agent Inventory {
7365  key id: String
7366  store historyWithRetain: Log[String] @retain(30.days)
7367  store historyNoRetain: Log[String]
7368
7369  on call touch() -> Effect[()] {
7370    Effect.pure(())
7371  }
7372}
7373"#,
7374        );
7375        let agent = find_agent(&program, "Inventory");
7376
7377        let with_retain = find_store_field(agent, "historyWithRetain");
7378        let ir = lower_store_field_ir(with_retain, &program);
7379        let StoreKindIr::Log(_, retain) = ir.kind else {
7380            panic!("expected StoreKindIr::Log, got {:?}", ir.kind)
7381        };
7382        assert_eq!(retain, Some(30 * 24 * 60 * 60 * 1000));
7383
7384        let no_retain = find_store_field(agent, "historyNoRetain");
7385        let ir = lower_store_field_ir(no_retain, &program);
7386        let StoreKindIr::Log(_, retain) = ir.kind else {
7387            panic!("expected StoreKindIr::Log, got {:?}", ir.kind)
7388        };
7389        assert_eq!(retain, None, "@retain is genuinely optional on Log");
7390    }
7391
7392    fn find_handler<'a>(agent: &'a AgentDecl, name: &str) -> &'a Handler {
7393        agent
7394            .handlers
7395            .iter()
7396            .find(|h| h.method_name.as_ref().is_some_and(|m| m.name == name))
7397            .unwrap_or_else(|| panic!("no handler named `{name}` on agent `{}`", agent.name.name))
7398    }
7399
7400    /// The agent's own synthetic `<Agent>State` record type — re-derives
7401    /// `context_checks.rs`'s own `state_ty` construction (`tys.intern(Ty::Named {
7402    /// name: "<Agent>State", .. })`) rather than looking it up: the synthetic
7403    /// declaration itself lives only in `check_agent_decls`'s own transient
7404    /// `types_for_handler` clone, never persisted to `TypedCommons::types`, but
7405    /// `Types::intern` is content-addressed, so re-interning the identical `Ty`
7406    /// value here returns the exact `TyId` the checker already minted.
7407    fn agent_state_ty(program: &CheckedProgram, agent_name: &str) -> TyId {
7408        program.program().ty_intern.intern(Ty::Named {
7409            name: format!("{agent_name}State"),
7410            kind: checker::NamedKind::Record,
7411            args: Vec::new(),
7412        })
7413    }
7414
7415    /// [`lower_invariant_ir`]'s own `store_cells` parameter, built the same
7416    /// way [`lower_invariant_ir`]'s own doc comment says a future `IrItem::Agent`
7417    /// caller must: from each `Cell` field's own already-lowered
7418    /// [`StoreFieldIr::kind`], not re-resolved independently.
7419    fn agent_store_cells(program: &CheckedProgram, agent: &AgentDecl) -> HashMap<String, TyId> {
7420        agent
7421            .store_fields
7422            .iter()
7423            .filter(|f| f.kind.head.name == "Cell")
7424            .map(|f| {
7425                let ir = lower_store_field_ir(f, program);
7426                let StoreKindIr::Cell(ty) = ir.kind else {
7427                    unreachable!("filtered to Cell fields above")
7428                };
7429                (ir.field, ty)
7430            })
7431            .collect()
7432    }
7433
7434    /// [`lower_handler_ir`]'s own `store_queryable` parameter (P6.20-pre) —
7435    /// the `Map` sibling of [`agent_store_cells`] above. `Log` is not
7436    /// included — see [`lower_agent_item_ir`]'s own `store_queryable`
7437    /// construction for why (review of #1240).
7438    fn agent_store_queryable(_program: &CheckedProgram, agent: &AgentDecl) -> HashSet<String> {
7439        agent
7440            .store_fields
7441            .iter()
7442            .filter(|f| f.kind.head.name == "Map")
7443            .map(|f| f.name.name.clone())
7444            .collect()
7445    }
7446
7447    #[test]
7448    fn invariant_ir_references_a_store_cell_by_bare_name() {
7449        let program = checked_context_program(
7450            r#"
7451context demo
7452
7453agent Counter {
7454  key id: String
7455  store active: Cell[Bool] = true
7456
7457  invariant staysKnown: active
7458
7459  on call touch() -> Effect[()] {
7460    Effect.pure(())
7461  }
7462}
7463"#,
7464        );
7465        let agent = find_agent(&program, "Counter");
7466        let store_cells = agent_store_cells(&program, agent);
7467        let inv = agent
7468            .invariants
7469            .iter()
7470            .find(|i| i.name.name == "staysKnown")
7471            .expect("invariant `staysKnown` in this fixture");
7472        let ir = lower_invariant_ir(inv, &store_cells, &program);
7473        assert_eq!(ir.name, "staysKnown");
7474        assert!(
7475            matches!(&ir.predicate.kind, IrExprKind::Local(n) if n == "active"),
7476            "a bare-name read of a store Cell field lowers to Local, got {:?}",
7477            ir.predicate.kind
7478        );
7479    }
7480
7481    #[test]
7482    fn transition_ir_references_both_old_and_new() {
7483        let program = checked_context_program(
7484            r#"
7485context demo
7486
7487agent Counter {
7488  key id: String
7489  store active: Cell[Bool] = true
7490
7491  transition activeImplication: old.active implies new.active
7492
7493  on call touch() -> Effect[()] {
7494    Effect.pure(())
7495  }
7496}
7497"#,
7498        );
7499        let agent = find_agent(&program, "Counter");
7500        let state_ty = agent_state_ty(&program, "Counter");
7501        let tr = agent
7502            .transitions
7503            .iter()
7504            .find(|t| t.name.name == "activeImplication")
7505            .expect("transition `activeImplication` in this fixture");
7506        let ir = lower_transition_ir(tr, state_ty, &program);
7507        assert_eq!(ir.name, "activeImplication");
7508        // `p implies q` lowers to `Or { lhs: Not(p), rhs: q }` (P6.3) — pin
7509        // that both `old`/`new` reach this predicate as `Field` accesses on
7510        // a `Local` bound to `state_ty`, not left unresolved.
7511        let IrExprKind::Or { lhs, rhs } = &ir.predicate.kind else {
7512            panic!(
7513                "expected `implies` to lower to Or, got {:?}",
7514                ir.predicate.kind
7515            )
7516        };
7517        let IrExprKind::Not { operand } = &lhs.kind else {
7518            panic!("expected Or's own lhs to be Not, got {:?}", lhs.kind)
7519        };
7520        let IrExprKind::Field { base, field } = &operand.kind else {
7521            panic!(
7522                "expected `old.active` to lower to Field, got {:?}",
7523                operand.kind
7524            )
7525        };
7526        assert!(matches!(&base.kind, IrExprKind::Local(n) if n == "old"));
7527        assert_eq!(field, "active");
7528        let IrExprKind::Field { base, field } = &rhs.kind else {
7529            panic!(
7530                "expected `new.active` to lower to Field, got {:?}",
7531                rhs.kind
7532            )
7533        };
7534        assert!(matches!(&base.kind, IrExprKind::Local(n) if n == "new"));
7535        assert_eq!(field, "active");
7536    }
7537
7538    /// Every [`CommitShape`] "Done when" case (#1165) lives on one agent —
7539    /// each handler exercises exactly one classification so a failing
7540    /// assertion names its own scenario unambiguously.
7541    fn commit_shape_fixture() -> CheckedProgram {
7542        checked_context_program(
7543            r#"
7544context demo
7545
7546type Box = { n: Int }
7547
7548fn Box.put(self, x: Int) -> Effect[()] {
7549  Effect.pure(())
7550}
7551
7552capability Clock {
7553  fn now() -> Effect[Int]
7554}
7555
7556provides Clock = FixedClock {
7557  fn now() -> Effect[Int] {
7558    42
7559  }
7560}
7561
7562agent Widget {
7563  key id: String
7564  store items: Map[String, Int]
7565  store active: Cell[Bool] = true
7566  store tags: Set[String]
7567  store history: Log[String]
7568
7569  on call readOnlyPlain() -> Effect[()] {
7570    Effect.pure(())
7571  }
7572
7573  on call readOnlyQuery() -> Effect[Int] {
7574    items.size()
7575  }
7576
7577  on call nestedMutation(xs: List[String], flag: Bool) -> Effect[()] {
7578    if flag {
7579      match flag {
7580        true => xs.forEach((x) => items.put(x, 1))
7581        false => Effect.pure(())
7582      }
7583    } else {
7584      Effect.pure(())
7585    }
7586  }
7587
7588  on call bareAssign(v: Bool) -> Effect[()] {
7589    active := v
7590    Effect.pure(())
7591  }
7592
7593  on call shadowedName(items: Box, x: Int) -> Effect[()] {
7594    let _ <- items.put(x)
7595    Effect.pure(())
7596  }
7597
7598  on call cellUpdate() -> Effect[()] {
7599    let _ <- active.update((b) => !b)
7600    Effect.pure(())
7601  }
7602
7603  on call setAdd(t: String) -> Effect[()] {
7604    let _ <- tags.add(t)
7605    Effect.pure(())
7606  }
7607
7608  on call logAppend(t: String) -> Effect[()] given Clock {
7609    let _ <- history.append(t)
7610    Effect.pure(())
7611  }
7612}
7613"#,
7614        )
7615    }
7616
7617    fn commit_shape_of(program: &CheckedProgram, handler_name: &str) -> CommitShape {
7618        let agent = find_agent(program, "Widget");
7619        let handler = find_handler(agent, handler_name);
7620        let emits = block_uses_emit(&handler.body, &program.program().callees);
7621        lower_commit_shape_ir(&handler.body, &[], &[], emits, program)
7622    }
7623
7624    #[test]
7625    fn commit_shape_read_only_for_a_plain_body() {
7626        let program = commit_shape_fixture();
7627        assert!(matches!(
7628            commit_shape_of(&program, "readOnlyPlain"),
7629            CommitShape::ReadOnly
7630        ));
7631    }
7632
7633    #[test]
7634    fn commit_shape_read_only_for_a_non_mutating_store_read() {
7635        // `items.size()` records a `Callee::Store { op: "size", .. }` — not in
7636        // any of the shared mutating-verb constants — so this pins that a
7637        // real, resolved store read never false-positives into `Transactional`.
7638        let program = commit_shape_fixture();
7639        assert!(matches!(
7640            commit_shape_of(&program, "readOnlyQuery"),
7641            CommitShape::ReadOnly
7642        ));
7643    }
7644
7645    #[test]
7646    fn commit_shape_flush_events_for_an_emit_only_body() {
7647        // `emits` is a plain parameter ([DECISION D]) — the shipped
7648        // `bynk_emit::emitter::block_uses_emit(body)` is the real caller's own
7649        // computation, reused verbatim rather than re-derived here, so this
7650        // pins `lower_commit_shape_ir`'s own decision given a non-writing
7651        // body and `emits: true` directly, independent of exercising
7652        // `block_uses_emit` itself (which has no `Events` capability to
7653        // resolve in this reduced test harness — `checked_context_program`'s
7654        // own doc comment names the `uses`/`consumes` limitation).
7655        let program = commit_shape_fixture();
7656        let agent = find_agent(&program, "Widget");
7657        let handler = find_handler(agent, "readOnlyPlain");
7658        let shape = lower_commit_shape_ir(&handler.body, &[], &[], true, &program);
7659        assert!(matches!(shape, CommitShape::FlushEvents));
7660    }
7661
7662    #[test]
7663    fn commit_shape_transactional_for_a_write_nested_in_if_match_lambda() {
7664        // Reaches `items.put(x, 1)` through If -> Match -> Lambda (forEach's
7665        // own closure argument) — pins that this walk's descent matches
7666        // `block_writes_state`'s already-correct one, including through a
7667        // lambda (R6.5's own `#54` worked example).
7668        let program = commit_shape_fixture();
7669        assert!(matches!(
7670            commit_shape_of(&program, "nestedMutation"),
7671            CommitShape::Transactional { .. }
7672        ));
7673    }
7674
7675    #[test]
7676    fn commit_shape_transactional_for_a_bare_cell_assign() {
7677        let program = commit_shape_fixture();
7678        assert!(matches!(
7679            commit_shape_of(&program, "bareAssign"),
7680            CommitShape::Transactional { .. }
7681        ));
7682    }
7683
7684    #[test]
7685    fn commit_shape_read_only_for_a_locally_shadowed_store_field_name() {
7686        // `shadowedName`'s own `items: Box` parameter shadows the agent's
7687        // `items` store field for the checker's own store-dispatch guard
7688        // (`ctx.lookup(id.name).is_none()`, `checker.rs`) — `items.put(x)`
7689        // resolves to `Box`'s own user-declared `put` method, `Callee::Method`,
7690        // never `Callee::Store`. Pins Decision B's own named improvement over
7691        // `block_writes_state`'s receiver-name matching, which cannot tell
7692        // this apart from a real store write and would over-approximate to
7693        // `Transactional`.
7694        let program = commit_shape_fixture();
7695        assert!(matches!(
7696            commit_shape_of(&program, "shadowedName"),
7697            CommitShape::ReadOnly
7698        ));
7699    }
7700
7701    #[test]
7702    fn commit_shape_transactional_for_a_cell_update_method_call() {
7703        // `active.update(f)` (ADR 0125) is the one `Cell` write that reaches
7704        // this walk as a method call rather than `Statement::Assign` — pins
7705        // `MUTATING_CELL_OPS`, otherwise unexercised by `bareAssign`'s own
7706        // `:=` case.
7707        let program = commit_shape_fixture();
7708        assert!(matches!(
7709            commit_shape_of(&program, "cellUpdate"),
7710            CommitShape::Transactional { .. }
7711        ));
7712    }
7713
7714    #[test]
7715    fn commit_shape_transactional_for_a_set_add_method_call() {
7716        // Pins `MUTATING_SET_OPS`, otherwise unexercised — `nestedMutation`'s
7717        // own write is a `Map.put`.
7718        let program = commit_shape_fixture();
7719        assert!(matches!(
7720            commit_shape_of(&program, "setAdd"),
7721            CommitShape::Transactional { .. }
7722        ));
7723    }
7724
7725    #[test]
7726    fn commit_shape_transactional_for_a_log_append_method_call() {
7727        // Pins `MUTATING_LOG_OPS`, otherwise unexercised.
7728        let program = commit_shape_fixture();
7729        assert!(matches!(
7730            commit_shape_of(&program, "logAppend"),
7731            CommitShape::Transactional { .. }
7732        ));
7733    }
7734
7735    #[test]
7736    fn commit_shape_transactional_carries_its_own_invariants_and_transitions_unswapped() {
7737        // The only place this constructor moves data rather than deciding a
7738        // boolean: `Transactional { invariants, transitions }` must carry
7739        // exactly the slices it was given, in the field they were given
7740        // under — not swapped, not dropped.
7741        let program = commit_shape_fixture();
7742        let agent = find_agent(&program, "Widget");
7743        let handler = find_handler(agent, "bareAssign");
7744        let bool_ty = program
7745            .program()
7746            .ty_intern
7747            .intern(Ty::Base(bynk_syntax::ast::BaseType::Bool));
7748        let mk = |name: &str| IrPredicate {
7749            name: name.to_string(),
7750            predicate: IrExpr {
7751                kind: IrExprKind::Const(ConstVal::Bool(true)),
7752                ty: bool_ty,
7753                span: Span::new(0, 0),
7754            },
7755        };
7756        let invariants = vec![mk("invOnly")];
7757        let transitions = vec![mk("transitionOnly")];
7758        let shape =
7759            lower_commit_shape_ir(&handler.body, &invariants, &transitions, false, &program);
7760        let CommitShape::Transactional {
7761            invariants: got_inv,
7762            transitions: got_tr,
7763        } = shape
7764        else {
7765            panic!("expected Transactional, got a different CommitShape")
7766        };
7767        assert_eq!(got_inv.len(), 1);
7768        assert_eq!(got_inv[0].name, "invOnly");
7769        assert_eq!(got_tr.len(), 1);
7770        assert_eq!(got_tr[0].name, "transitionOnly");
7771    }
7772
7773    /// Every [`IrHandler`] "Done when" case (#1167) lives on one agent — a
7774    /// read-only handler, a store-writing handler exercising `:=` with a
7775    /// declared param, a handler with a `given` capability actually called
7776    /// in the body, and (review of #1167) a handler exercising a
7777    /// `Callee::Store` method call on a non-`Cell` field — the one
7778    /// [DECISION F] claim the other three don't reach, since none of them
7779    /// calls a store method at all, and `entries` (a `Map`, never bound into
7780    /// `lower_handler_body_ir`'s own scope — only `Cell` fields are) pins
7781    /// that the never-bound path really is unreachable rather than merely
7782    /// untested.
7783    fn handler_ir_fixture() -> CheckedProgram {
7784        checked_context_program(
7785            r#"
7786context demo
7787
7788capability Clock {
7789  fn now() -> Effect[Int]
7790}
7791
7792provides Clock = FixedClock {
7793  fn now() -> Effect[Int] {
7794    42
7795  }
7796}
7797
7798fn passThroughQuery(q: Query[Int]) -> Query[Int] {
7799  q
7800}
7801
7802-- P6.20-pre (review of #1240): a free fn sharing a name with `Ledger`'s own
7803-- `entries` store field — pins that the store-field dispatch wins (checked
7804-- immediately after `cx.lookup`, ahead of the free-fn probe), not the other
7805-- way around.
7806fn entries(n: Int) -> Int {
7807  n
7808}
7809
7810agent Ledger {
7811  key id: String
7812  store balance: Cell[Int] = 0
7813  store entries: Map[String, Int]
7814
7815  on call peek() -> Effect[Int] {
7816    Effect.pure(balance)
7817  }
7818
7819  on call deposit(amount: Int) -> Effect[()] {
7820    balance := amount
7821    Effect.pure(())
7822  }
7823
7824  on call touchClock() -> Effect[Int] given Clock {
7825    let t <- Clock.now()
7826    Effect.pure(t)
7827  }
7828
7829  on call addEntry(k: String, v: Int) -> Effect[()] {
7830    let _ <- entries.put(k, v)
7831    Effect.pure(())
7832  }
7833
7834  -- P6.20-pre: `.keys` on a bare store `Map` field — the FieldAccess
7835  -- receiver-position shape (ADR 0184) that panicked as `Inventory`/`items`,
7836  -- `Ledger`/`balances` in the real fixture corpus.
7837  on call entryKeys() -> Effect[Query[String]] {
7838    Effect.pure(entries.keys)
7839  }
7840
7841  -- P6.20-pre: a bare store `Map` field passed as a plain argument — the
7842  -- argument-position shape (ADR 0120) that panicked as `Sales`/`orders` in
7843  -- the real fixture corpus (`lines.joinOn(orders, ...)`).
7844  on call passEntries() -> Effect[Query[Int]] {
7845    Effect.pure(passThroughQuery(entries))
7846  }
7847
7848  -- P6.20-pre (review of #1240): the store field `entries` and the free fn
7849  -- `entries` (declared above, colliding by name) both exist — the checker
7850  -- resolves the bare reference to the store field's own `Query[Int]`
7851  -- before `check_ident` (where a free-fn reference would resolve) ever
7852  -- sees it, so this must lower to StoreQuery, not panic on the free-fn arm.
7853  on call bareEntriesCollidesWithAFreeFn() -> Effect[Query[Int]] {
7854    Effect.pure(entries)
7855  }
7856
7857  -- P6.20-pre (review of #1240): a handler param named `entries` shadows
7858  -- the store field of the same name — must lower to Local, not StoreQuery.
7859  on call shadowedEntries(entries: Query[Int]) -> Effect[Query[Int]] {
7860    Effect.pure(entries)
7861  }
7862}
7863"#,
7864        )
7865    }
7866
7867    fn handler_ir_of(program: &CheckedProgram, handler_name: &str) -> IrHandler {
7868        handler_ir_of_with_predicates(program, handler_name, &[], &[])
7869    }
7870
7871    fn handler_ir_of_with_predicates(
7872        program: &CheckedProgram,
7873        handler_name: &str,
7874        invariants: &[IrPredicate],
7875        transitions: &[IrPredicate],
7876    ) -> IrHandler {
7877        let agent = find_agent(program, "Ledger");
7878        let handler = find_handler(agent, handler_name);
7879        let store_cells = agent_store_cells(program, agent);
7880        let store_queryable = agent_store_queryable(program, agent);
7881        let state_ty = agent_state_ty(program, "Ledger");
7882        lower_handler_ir(
7883            handler,
7884            &store_cells,
7885            &store_queryable,
7886            state_ty,
7887            invariants,
7888            transitions,
7889            program,
7890        )
7891    }
7892
7893    #[test]
7894    fn handler_ir_read_only_handler_has_no_binder_and_read_only_commit() {
7895        let program = handler_ir_fixture();
7896        let ir = handler_ir_of(&program, "peek");
7897        assert!(
7898            ir.binder.is_none(),
7899            "Decision D: an agent handler's own binder is always None, pinned explicitly rather \
7900             than left to omission"
7901        );
7902        assert_eq!(ir.kind, IrHandlerKind::Call);
7903        assert!(matches!(ir.commit, CommitShape::ReadOnly));
7904        assert_eq!(ir.method_name.as_deref(), Some("peek"));
7905        assert!(ir.effectful, "peek returns Effect[Int]");
7906        assert!(ir.params.is_empty());
7907        assert!(ir.given.is_empty());
7908        let IrExprKind::Block { stmts, tail } = &ir.body.kind else {
7909            panic!("expected a Block, got {:?}", ir.body.kind)
7910        };
7911        assert!(stmts.is_empty());
7912        let IrExprKind::Return { value } = &tail.kind else {
7913            panic!(
7914                "expected the tail to be wrapped in Return, got {:?}",
7915                tail.kind
7916            )
7917        };
7918        let IrExprKind::Pure { value } = &value.kind else {
7919            panic!(
7920                "expected `Effect.pure(balance)` to lower to Pure, got {:?}",
7921                value.kind
7922            )
7923        };
7924        assert!(
7925            matches!(&value.kind, IrExprKind::Local(n) if n == "balance"),
7926            "a bare-name read of a store Cell field lowers to Local, got {:?}",
7927            value.kind
7928        );
7929    }
7930
7931    #[test]
7932    fn handler_ir_store_writing_handler_lowers_assign_and_transactional_commit() {
7933        let program = handler_ir_fixture();
7934        let ir = handler_ir_of(&program, "deposit");
7935        assert!(ir.binder.is_none());
7936        let int_ty = program
7937            .program()
7938            .ty_intern
7939            .intern(Ty::Base(bynk_syntax::ast::BaseType::Int));
7940        assert_eq!(
7941            ir.params,
7942            vec![("amount".to_string(), int_ty)],
7943            "a declared param's type must resolve and bind into the body's own scope"
7944        );
7945        assert_eq!(ir.method_name.as_deref(), Some("deposit"));
7946        let CommitShape::Transactional {
7947            invariants,
7948            transitions,
7949        } = &ir.commit
7950        else {
7951            panic!("expected Transactional, got {:?}", ir.commit)
7952        };
7953        // `handler_ir_of` (unlike `handler_ir_of_with_predicates`) passes no
7954        // predicates — empty here reflects this test's own call, not a
7955        // limit of `lower_handler_ir` itself; see
7956        // `handler_ir_threads_invariants_and_transitions_into_commit_unswapped`
7957        // for the threading pin.
7958        assert!(invariants.is_empty());
7959        assert!(transitions.is_empty());
7960        let IrExprKind::Block { stmts, .. } = &ir.body.kind else {
7961            panic!("expected a Block, got {:?}", ir.body.kind)
7962        };
7963        assert_eq!(
7964            stmts.len(),
7965            1,
7966            "the `:=` write is the body's only statement"
7967        );
7968        let IrStmt::Assign { field, value } = &stmts[0] else {
7969            panic!("expected IrStmt::Assign, got {:?}", stmts[0])
7970        };
7971        assert_eq!(field, "balance");
7972        assert!(
7973            matches!(&value.kind, IrExprKind::Local(n) if n == "amount"),
7974            "the assigned value reads the handler's own `amount` param, got {:?}",
7975            value.kind
7976        );
7977    }
7978
7979    #[test]
7980    fn handler_ir_given_capability_recorded_and_call_lowers_as_ordinary_callee() {
7981        let program = handler_ir_fixture();
7982        let ir = handler_ir_of(&program, "touchClock");
7983        assert!(ir.binder.is_none());
7984        assert_eq!(ir.given, vec!["Clock".to_string()]);
7985        let IrExprKind::Block { stmts, .. } = &ir.body.kind else {
7986            panic!("expected a Block, got {:?}", ir.body.kind)
7987        };
7988        assert_eq!(stmts.len(), 1);
7989        let IrStmt::Let { local, value } = &stmts[0] else {
7990            panic!("expected IrStmt::Let, got {:?}", stmts[0])
7991        };
7992        assert_eq!(local, "t");
7993        let IrExprKind::Await { effect } = &value.kind else {
7994            panic!(
7995                "expected `let t <- Clock.now()` to lower to Await, got {:?}",
7996                value.kind
7997            )
7998        };
7999        let IrExprKind::Call { callee, args, .. } = &effect.kind else {
8000            panic!(
8001                "expected `Clock.now()` to lower as an ordinary Callee-classified Call \
8002                 (Decision F — no new call-lowering logic needed), got {:?}",
8003                effect.kind
8004            )
8005        };
8006        assert!(
8007            matches!(callee, Callee::Capability { cap, op } if cap == "Clock" && op == "now"),
8008            "expected Callee::Capability {{ cap: \"Clock\", op: \"now\" }}, got {callee:?}"
8009        );
8010        assert!(args.is_empty());
8011    }
8012
8013    #[test]
8014    fn handler_ir_store_method_call_on_a_non_cell_field_lowers_as_ordinary_callee() {
8015        // The one Decision F claim `peek`/`deposit`/`touchClock` don't reach:
8016        // a `Callee::Store` method call, on a `Map` field never bound into
8017        // `lower_handler_body_ir`'s own scope (only `Cell` fields are) —
8018        // pins that `entries` is never looked up as an ident at all, not
8019        // merely that it happens not to be, since `receiver_is_a_value`
8020        // excludes `Callee::Store` from ever lowering its own receiver.
8021        let program = handler_ir_fixture();
8022        let ir = handler_ir_of(&program, "addEntry");
8023        assert!(ir.binder.is_none());
8024        assert!(
8025            matches!(ir.commit, CommitShape::Transactional { .. }),
8026            "Map.put is a mutating Callee::Store op"
8027        );
8028        let IrExprKind::Block { stmts, .. } = &ir.body.kind else {
8029            panic!("expected a Block, got {:?}", ir.body.kind)
8030        };
8031        assert_eq!(stmts.len(), 1);
8032        let IrStmt::Let { local, value } = &stmts[0] else {
8033            panic!("expected IrStmt::Let, got {:?}", stmts[0])
8034        };
8035        assert_eq!(local, "_");
8036        let IrExprKind::Await { effect } = &value.kind else {
8037            panic!(
8038                "expected `let _ <- entries.put(k, v)` to lower to Await, got {:?}",
8039                value.kind
8040            )
8041        };
8042        let IrExprKind::Call { callee, args, .. } = &effect.kind else {
8043            panic!(
8044                "expected `entries.put(k, v)` to lower as an ordinary Callee-classified Call, \
8045                 got {:?}",
8046                effect.kind
8047            )
8048        };
8049        assert!(
8050            matches!(callee, Callee::Store { field, op } if field == "entries" && op == "put"),
8051            "expected Callee::Store {{ field: \"entries\", op: \"put\" }}, got {callee:?}"
8052        );
8053        assert_eq!(
8054            args.len(),
8055            2,
8056            "the receiver is never prepended for Callee::Store"
8057        );
8058        assert!(matches!(&args[0].kind, IrExprKind::Local(n) if n == "k"));
8059        assert!(matches!(&args[1].kind, IrExprKind::Local(n) if n == "v"));
8060    }
8061
8062    #[test]
8063    fn handler_ir_bare_store_map_field_as_field_access_receiver_lowers_to_store_query() {
8064        // P6.20-pre: `entries.keys` — `entries` is the *receiver* of a
8065        // `FieldAccess`, not a method-call receiver (`Callee`-classified
8066        // calls are the only receiver `lower_call_ir` excludes), so before
8067        // this slice it fell straight through `lower_expr_ir`'s ordinary
8068        // `ExprKind::Ident` arm into `lower_ident_ir`'s unresolved-ident
8069        // `todo!()` — reproducing the real panic found empirically against
8070        // `353_map_entries_query`'s `Inventory`/`items` and `Ledger`/
8071        // `balances` agents.
8072        let program = handler_ir_fixture();
8073        let ir = handler_ir_of(&program, "entryKeys");
8074        let IrExprKind::Block { tail, .. } = &ir.body.kind else {
8075            panic!("expected a Block with a tail, got {:?}", ir.body.kind)
8076        };
8077        let IrExprKind::Return { value } = &tail.kind else {
8078            panic!("expected the tail to wrap in Return, got {:?}", tail.kind)
8079        };
8080        let IrExprKind::Pure { value } = &value.kind else {
8081            panic!(
8082                "expected Effect.pure(...) to lower to Pure, got {:?}",
8083                value.kind
8084            )
8085        };
8086        let IrExprKind::Field { base, field } = &value.kind else {
8087            panic!(
8088                "expected entries.keys to lower to Field, got {:?}",
8089                value.kind
8090            )
8091        };
8092        assert_eq!(field, "keys");
8093        assert!(
8094            matches!(&base.kind, IrExprKind::StoreQuery(name) if name == "entries"),
8095            "expected the receiver `entries` to lower to StoreQuery, got {:?}",
8096            base.kind
8097        );
8098    }
8099
8100    #[test]
8101    fn handler_ir_bare_store_map_field_as_plain_argument_lowers_to_store_query() {
8102        // P6.20-pre: `passThroughQuery(entries)` — `entries` at a plain
8103        // argument position, the exact shape that panicked as `Sales`/
8104        // `orders` (`lines.joinOn(orders, ...)`) in the real fixture corpus.
8105        let program = handler_ir_fixture();
8106        let ir = handler_ir_of(&program, "passEntries");
8107        let IrExprKind::Block { tail, .. } = &ir.body.kind else {
8108            panic!("expected a Block with a tail, got {:?}", ir.body.kind)
8109        };
8110        let IrExprKind::Return { value } = &tail.kind else {
8111            panic!("expected the tail to wrap in Return, got {:?}", tail.kind)
8112        };
8113        let IrExprKind::Pure { value } = &value.kind else {
8114            panic!(
8115                "expected Effect.pure(...) to lower to Pure, got {:?}",
8116                value.kind
8117            )
8118        };
8119        let IrExprKind::Call { args, .. } = &value.kind else {
8120            panic!(
8121                "expected passThroughQuery(entries) to lower to a Call, got {:?}",
8122                value.kind
8123            )
8124        };
8125        assert_eq!(args.len(), 1);
8126        assert!(
8127            matches!(&args[0].kind, IrExprKind::StoreQuery(name) if name == "entries"),
8128            "expected the bare argument `entries` to lower to StoreQuery, got {:?}",
8129            args[0].kind
8130        );
8131    }
8132
8133    #[test]
8134    fn handler_ir_bare_store_map_field_wins_over_a_colliding_free_fn_of_the_same_name() {
8135        // Review of #1240 (finding 1): the store-field dispatch must be
8136        // checked *before* the free-fn probe in `lower_ident_ir`'s own
8137        // ladder, matching the checker's own precedence
8138        // (`checker.rs:3477-3481` returns before `check_ident` — where a
8139        // free-fn reference resolves — ever sees the name). Before that
8140        // fix, `entries` colliding with the free fn `entries` declared in
8141        // `handler_ir_fixture` hit the free-fn `todo!()` instead.
8142        let program = handler_ir_fixture();
8143        let ir = handler_ir_of(&program, "bareEntriesCollidesWithAFreeFn");
8144        let IrExprKind::Block { tail, .. } = &ir.body.kind else {
8145            panic!("expected a Block with a tail, got {:?}", ir.body.kind)
8146        };
8147        let IrExprKind::Return { value } = &tail.kind else {
8148            panic!("expected the tail to wrap in Return, got {:?}", tail.kind)
8149        };
8150        let IrExprKind::Pure { value } = &value.kind else {
8151            panic!(
8152                "expected Effect.pure(...) to lower to Pure, got {:?}",
8153                value.kind
8154            )
8155        };
8156        assert!(
8157            matches!(&value.kind, IrExprKind::StoreQuery(name) if name == "entries"),
8158            "expected the store field to win over the colliding free fn, got {:?}",
8159            value.kind
8160        );
8161    }
8162
8163    #[test]
8164    fn handler_ir_a_local_param_shadowing_a_store_map_field_name_lowers_to_local() {
8165        // Review of #1240 (finding 1): the `cx.lookup(name).is_some()` guard
8166        // at the very top of `lower_ident_ir` is the one shadowing property
8167        // the store-field dispatch actually depends on — pin it directly,
8168        // independent of where in the ladder the store-field check sits.
8169        let program = handler_ir_fixture();
8170        let ir = handler_ir_of(&program, "shadowedEntries");
8171        let IrExprKind::Block { tail, .. } = &ir.body.kind else {
8172            panic!("expected a Block with a tail, got {:?}", ir.body.kind)
8173        };
8174        let IrExprKind::Return { value } = &tail.kind else {
8175            panic!("expected the tail to wrap in Return, got {:?}", tail.kind)
8176        };
8177        let IrExprKind::Pure { value } = &value.kind else {
8178            panic!(
8179                "expected Effect.pure(...) to lower to Pure, got {:?}",
8180                value.kind
8181            )
8182        };
8183        assert!(
8184            matches!(&value.kind, IrExprKind::Local(name) if name == "entries"),
8185            "expected the shadowing param to win over the store field, got {:?}",
8186            value.kind
8187        );
8188    }
8189
8190    #[test]
8191    fn handler_ir_threads_invariants_and_transitions_into_commit_unswapped() {
8192        // Review of #1167: `lower_handler_ir` originally passed
8193        // `lower_commit_shape_ir` empty `invariants`/`transitions` slices
8194        // unconditionally — indistinguishable from an agent that genuinely
8195        // declares neither. Now threaded through as ordinary parameters,
8196        // mirroring `commit_shape_transactional_carries_its_own_invariants_and_transitions_unswapped`'s
8197        // own pin one layer up.
8198        let program = handler_ir_fixture();
8199        let bool_ty = program
8200            .program()
8201            .ty_intern
8202            .intern(Ty::Base(bynk_syntax::ast::BaseType::Bool));
8203        let mk = |name: &str| IrPredicate {
8204            name: name.to_string(),
8205            predicate: IrExpr {
8206                kind: IrExprKind::Const(ConstVal::Bool(true)),
8207                ty: bool_ty,
8208                span: Span::new(0, 0),
8209            },
8210        };
8211        let invariants = vec![mk("invOnly")];
8212        let transitions = vec![mk("transitionOnly")];
8213        let ir = handler_ir_of_with_predicates(&program, "deposit", &invariants, &transitions);
8214        let CommitShape::Transactional {
8215            invariants: got_inv,
8216            transitions: got_tr,
8217        } = ir.commit
8218        else {
8219            panic!("expected Transactional, got {:?}", ir.commit)
8220        };
8221        assert_eq!(got_inv.len(), 1);
8222        assert_eq!(got_inv[0].name, "invOnly");
8223        assert_eq!(got_tr.len(), 1);
8224        assert_eq!(got_tr[0].name, "transitionOnly");
8225    }
8226
8227    /// Every [`IrItem::Agent`] "Done when" case (#1169) lives on one agent —
8228    /// a `Cell` field an invariant/transition and a handler all reference by
8229    /// bare name, a `Map` field only a handler reaches (via `Callee::Store`),
8230    /// one read-only handler, and one store-writing handler whose own
8231    /// `commit` must carry the agent's real invariants/transitions, not an
8232    /// empty pair.
8233    ///
8234    /// **Named gap, not silently absorbed (review of #1169):** no handler
8235    /// here exercises `CommitShape::FlushEvents` (a non-writing handler that
8236    /// emits) — the one arm [`lower_commit_shape_ir`] can produce that this
8237    /// fixture never reaches. A real `Events.emit` call needs an `event`
8238    /// declaration plus `consumes bynk { Events }`, which
8239    /// [`checked_context_program`]'s own doc comment already names as out
8240    /// of scope for this reduced harness (`resolver::CrossContextInfo`
8241    /// hardcoded to `::default()`) — extending it is a deliberate, separate
8242    /// change, not a two-line addition here. The risk this would pin —
8243    /// threading real, non-empty `invariants`/`transitions` into every
8244    /// handler somehow pushing a non-writing one into `Transactional` — is
8245    /// already structurally impossible: [`lower_commit_shape_ir`]'s own
8246    /// branch is decided by [`body_writes_state`] alone, never by whether
8247    /// `invariants`/`transitions` are empty, and `peek`'s own `ReadOnly`
8248    /// assertion below already exercises exactly that (non-empty
8249    /// predicates, still `ReadOnly`) for the `body_writes_state == false`
8250    /// case `FlushEvents` shares.
8251    fn agent_item_fixture() -> CheckedProgram {
8252        checked_context_program(
8253            r#"
8254context demo
8255
8256agent Ledger {
8257  key id: String
8258  store active: Cell[Bool] = true
8259  store balance: Cell[Int] = 0
8260  store entries: Map[String, Int]
8261
8262  invariant staysKnown: active
8263
8264  transition activeImplication: old.active implies new.active
8265
8266  on call peek() -> Effect[Int] {
8267    Effect.pure(balance)
8268  }
8269
8270  on call deposit(amount: Int) -> Effect[()] {
8271    balance := amount
8272    Effect.pure(())
8273  }
8274
8275  on call addEntry(k: String, v: Int) -> Effect[()] {
8276    let _ <- entries.put(k, v)
8277    Effect.pure(())
8278  }
8279}
8280"#,
8281        )
8282    }
8283
8284    #[test]
8285    fn agent_item_ir_assembles_key_state_and_threads_invariants_transitions_into_handlers() {
8286        let program = agent_item_fixture();
8287        let agent = find_agent(&program, "Ledger");
8288        let ir = lower_agent_item_ir(agent, &program);
8289        let IrItem::Agent {
8290            def,
8291            key,
8292            state,
8293            handlers,
8294            invariants,
8295            transitions,
8296        } = &ir
8297        else {
8298            panic!("expected IrItem::Agent, got {:?}", ir)
8299        };
8300
8301        assert_eq!(def, "Ledger");
8302        let string_ty = program
8303            .program()
8304            .ty_intern
8305            .intern(Ty::Base(bynk_syntax::ast::BaseType::String));
8306        assert_eq!(key, &("id".to_string(), string_ty));
8307
8308        assert_eq!(
8309            state.len(),
8310            3,
8311            "active, balance, entries — declaration order"
8312        );
8313        assert!(matches!(state[0].kind, StoreKindIr::Cell(_)));
8314        assert_eq!(state[0].field, "active");
8315        assert!(matches!(state[1].kind, StoreKindIr::Cell(_)));
8316        assert_eq!(state[1].field, "balance");
8317        assert!(matches!(state[2].kind, StoreKindIr::Map(_, _)));
8318        assert_eq!(state[2].field, "entries");
8319
8320        assert_eq!(invariants.len(), 1);
8321        assert_eq!(invariants[0].name, "staysKnown");
8322        assert_eq!(transitions.len(), 1);
8323        assert_eq!(transitions[0].name, "activeImplication");
8324
8325        assert_eq!(handlers.len(), 3, "peek, deposit, addEntry");
8326
8327        let peek = handlers
8328            .iter()
8329            .find(|h| h.method_name.as_deref() == Some("peek"))
8330            .expect("peek handler");
8331        assert!(matches!(peek.commit, CommitShape::ReadOnly));
8332
8333        // The load-bearing assertion: `deposit`'s own commit carries the
8334        // *agent's real* invariants/transitions, lowered once here and
8335        // threaded through — not the empty pair `lower_handler_ir` would
8336        // produce on its own if this function forgot to pass them.
8337        let deposit = handlers
8338            .iter()
8339            .find(|h| h.method_name.as_deref() == Some("deposit"))
8340            .expect("deposit handler");
8341        let CommitShape::Transactional {
8342            invariants: got_inv,
8343            transitions: got_tr,
8344        } = &deposit.commit
8345        else {
8346            panic!(
8347                "expected deposit's own commit to be Transactional, got {:?}",
8348                deposit.commit
8349            )
8350        };
8351        assert_eq!(got_inv.len(), 1);
8352        assert_eq!(got_inv[0].name, "staysKnown");
8353        assert_eq!(got_tr.len(), 1);
8354        assert_eq!(got_tr[0].name, "activeImplication");
8355
8356        // `addEntry` writes through `Callee::Store` (Map.put), not `:=` —
8357        // pins that a non-Cell-field write also reaches Transactional and
8358        // also carries the same real invariants/transitions, not just the
8359        // `:=` case `deposit` already covers.
8360        let add_entry = handlers
8361            .iter()
8362            .find(|h| h.method_name.as_deref() == Some("addEntry"))
8363            .expect("addEntry handler");
8364        let CommitShape::Transactional {
8365            invariants: got_inv,
8366            transitions: got_tr,
8367        } = &add_entry.commit
8368        else {
8369            panic!(
8370                "expected addEntry's own commit to be Transactional, got {:?}",
8371                add_entry.commit
8372            )
8373        };
8374        assert_eq!(got_inv.len(), 1);
8375        assert_eq!(got_tr.len(), 1);
8376
8377        // Every `IrHandler` this slice builds is still agent-only —
8378        // pinning Decision D's own guarantee one level up, through the
8379        // assembled `IrItem::Agent` rather than only through
8380        // `lower_handler_ir` directly.
8381        assert!(handlers.iter().all(|h| h.binder.is_none()));
8382    }
8383
8384    /// #1189's own named breakage point: `agent_item_fixture`'s own
8385    /// invariant/transition are deliberately comparison-free
8386    /// (`active`/`old.active implies new.active`) — hand-picked, per #1189's
8387    /// own finding, the same way every P6.4-P6.9 fixture happened to avoid
8388    /// the gap. This is the real shape #1189 named as blocked before this
8389    /// slice (`balance >= 0`, `bynkc/tests/fixtures/positive/248_history_property`'s
8390    /// own `nonneg` invariant) — pins that `lower_agent_item_ir` no longer
8391    /// panics on it.
8392    #[test]
8393    fn agent_invariant_with_a_real_comparison_lowers_without_panicking() {
8394        let program = checked_context_program(
8395            r#"
8396context demo
8397
8398agent Ledger {
8399  key id: String
8400  store balance: Cell[Int] = 0
8401
8402  invariant nonneg: balance >= 0
8403
8404  on call deposit(amount: Int) -> Effect[()] {
8405    balance := amount
8406    Effect.pure(())
8407  }
8408}
8409"#,
8410        );
8411        let agent = find_agent(&program, "Ledger");
8412        let ir = lower_agent_item_ir(agent, &program);
8413        let IrItem::Agent { invariants, .. } = &ir else {
8414            panic!("expected IrItem::Agent, got {:?}", ir)
8415        };
8416        assert_eq!(invariants.len(), 1);
8417        let IrExprKind::BinOp { op, .. } = &invariants[0].predicate.kind else {
8418            panic!("expected BinOp, got {:?}", invariants[0].predicate.kind)
8419        };
8420        assert_eq!(*op, IrBinOp::GtEq);
8421    }
8422
8423    /// P6.11's own `IrItem::Service` "Done when" case (#1171): a plain
8424    /// `call`-protocol service, three handlers — one bare, one with a real
8425    /// actor binder, one with a `given` capability actually called in the
8426    /// body. Every service handler's return type is protocol-mandated to a
8427    /// sum for HTTP/queue/cron fixtures elsewhere in this module — `Call`
8428    /// is the one protocol that isn't, so this fixture needs none of the
8429    /// free-`fn` indirection those do.
8430    fn call_service_fixture() -> CheckedProgram {
8431        checked_context_program(
8432            r#"
8433context demo
8434
8435type UserId = String
8436
8437actor Buyer { auth = Internal, identity = UserId }
8438
8439capability Clock {
8440  fn now() -> Effect[Int]
8441}
8442
8443provides Clock = FixedClock {
8444  fn now() -> Effect[Int] {
8445    42
8446  }
8447}
8448
8449service Api {
8450  on call(ping: String) -> Effect[String] {
8451    Effect.pure(ping)
8452  }
8453  on call(ping: String) -> Effect[String] by u: Buyer {
8454    Effect.pure(ping)
8455  }
8456  on call() -> Effect[Int] given Clock {
8457    let t <- Clock.now()
8458    Effect.pure(t)
8459  }
8460}
8461"#,
8462        )
8463    }
8464
8465    #[test]
8466    fn service_item_ir_assembles_a_call_protocol_service() {
8467        let program = call_service_fixture();
8468        let service = find_service(&program, "Api");
8469        let ir = lower_service_item_ir(service, &program);
8470        let IrItem::Service {
8471            def,
8472            protocol,
8473            handlers,
8474            policy,
8475        } = &ir
8476        else {
8477            panic!("expected IrItem::Service, got {:?}", ir)
8478        };
8479        assert_eq!(def, "Api");
8480        assert!(matches!(protocol, ProtocolIr::Call));
8481        assert!(
8482            policy.is_none(),
8483            "policy is only ever Some for a from http service"
8484        );
8485        assert_eq!(handlers.len(), 3, "declaration order preserved");
8486
8487        assert!(handlers[0].binder.is_none());
8488        assert!(handlers[0].given.is_empty());
8489        assert!(
8490            handlers[0].actors.is_empty(),
8491            "no `by` clause at all on this handler"
8492        );
8493        assert!(handlers[1].binder.is_some());
8494        assert_eq!(
8495            handlers[1].actors,
8496            vec!["Buyer".to_string()],
8497            "the gate itself, read straight off the `by` clause — see IrHandler::actors' own \
8498             doc comment for why this can't be recovered from `binder` alone"
8499        );
8500        assert_eq!(handlers[2].given, vec!["Clock".to_string()]);
8501
8502        for h in handlers {
8503            assert!(
8504                h.method_name.is_none(),
8505                "a service's on call handler carries no method_name"
8506            );
8507            assert!(h.effectful, "every service handler returns Effect[T]");
8508            assert!(matches!(h.commit, CommitShape::ReadOnly));
8509        }
8510    }
8511
8512    #[test]
8513    fn service_handler_binder_is_recorded_and_bound_into_the_body_scope() {
8514        // `Caller` (v0.54) is a prelude actor — no local `actor` declaration
8515        // needed — whose identity is the calling-context id, `String`.
8516        let program = checked_context_program(
8517            r#"
8518context demo
8519
8520service Api {
8521  on call(ping: String) -> Effect[String] by c: Caller {
8522    Effect.pure(c.identity)
8523  }
8524}
8525"#,
8526        );
8527        let service = find_service(&program, "Api");
8528        let handler = find_service_handler(service, &HandlerKind::Call);
8529        let ir = lower_service_handler_ir(handler, &service.protocol, &program);
8530
8531        let binder = ir
8532            .binder
8533            .as_ref()
8534            .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
8535        assert_eq!(binder.binder, "c");
8536        let tys = &program.program().ty_intern;
8537        let Ty::Actor(identity_ty) = &*tys.get(binder.ty) else {
8538            panic!("expected Ty::Actor, got {:?}", tys.get(binder.ty))
8539        };
8540        assert_eq!(identity_ty.display(tys), "String");
8541        assert_eq!(ir.actors, vec!["Caller".to_string()]);
8542
8543        // The load-bearing half: `binder` recorded on the struct is not the
8544        // same claim as `binder` actually reaching the body's own scope —
8545        // walk the lowered body and confirm a real `Local("c")` read
8546        // reached the tree where `c.identity` was written, rather than
8547        // `lower_ident_ir`'s own unresolved-ident `todo!()`.
8548        let IrExprKind::Block { tail, .. } = &ir.body.kind else {
8549            panic!("expected a Block, got {:?}", ir.body.kind)
8550        };
8551        let IrExprKind::Return { value } = &tail.kind else {
8552            panic!("expected Return, got {:?}", tail.kind)
8553        };
8554        let IrExprKind::Pure { value } = &value.kind else {
8555            panic!("expected Pure, got {:?}", value.kind)
8556        };
8557        let IrExprKind::Field { base, field } = &value.kind else {
8558            panic!(
8559                "expected `c.identity` to lower to Field, got {:?}",
8560                value.kind
8561            )
8562        };
8563        assert_eq!(field, "identity");
8564        assert!(
8565            matches!(&base.kind, IrExprKind::Local(n) if n == "c"),
8566            "expected the binder to be bound into the body's own scope as Local(\"c\"), got {:?}",
8567            base.kind
8568        );
8569    }
8570
8571    #[test]
8572    fn sum_actor_binder_lowers_an_actor_sum() {
8573        // Mirrors `bynk-check`'s own `sum_by_clause_persists_an_actor_sum_binding`
8574        // test: a sum needs distinguishable schemes, so it must be
8575        // `from http`. The free `fn ok` indirection (see
8576        // `http_service_fixture`'s own doc comment) was originally a
8577        // workaround for the `Ok`/`Err`/`Some`/`None` construction gap in
8578        // `lower_expr_ir`, closed as of #1225's own ADR — kept as the
8579        // fixture's own shape regardless. The body's own `match who { … }` was initially
8580        // planned as a separate, riskier claim — no existing test drove
8581        // pattern lowering over an `ActorSum` scrutinee — but verified
8582        // empirically during implementation to lower correctly (`User(_)`'s
8583        // own positional payload binding included), so it stays in rather
8584        // than being degraded away.
8585        let program = checked_context_program(
8586            r#"
8587context demo
8588
8589type UserId = String
8590
8591actor User { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
8592
8593fn ok(s: String) -> HttpResult[String] { Ok(s) }
8594
8595service Api from http {
8596  on GET("/whoami") () -> Effect[HttpResult[String]] by who: User | Visitor {
8597    match who {
8598      User(_) => Effect.pure(ok("user"))
8599      Visitor => Effect.pure(ok("visitor"))
8600    }
8601  }
8602}
8603"#,
8604        );
8605        let service = find_service(&program, "Api");
8606        let handler = &service.handlers[0];
8607        let ir = lower_service_handler_ir(handler, &service.protocol, &program);
8608        let binder = ir
8609            .binder
8610            .as_ref()
8611            .unwrap_or_else(|| panic!("expected a persisted actor binding for this handler"));
8612        assert_eq!(binder.binder, "who");
8613        let tys = &program.program().ty_intern;
8614        let Ty::ActorSum(members) = &*tys.get(binder.ty) else {
8615            panic!("expected Ty::ActorSum, got {:?}", tys.get(binder.ty))
8616        };
8617        assert_eq!(members.len(), 2);
8618        assert_eq!(members[0].0, "User");
8619        assert_eq!(members[0].1.display(tys), "UserId");
8620        assert_eq!(members[1].0, "Visitor");
8621        assert_eq!(
8622            members[1].1.display(tys),
8623            "()",
8624            "Visitor is a unit-identity prelude actor"
8625        );
8626        assert_eq!(
8627            ir.actors,
8628            vec!["User".to_string(), "Visitor".to_string()],
8629            "actors is redundant with Ty::ActorSum's own member names here, but present \
8630             uniformly regardless of shape — see IrHandler::actors' own doc comment"
8631        );
8632
8633        // The body itself really did lower — a real `Match` node, not the
8634        // simpler single-arm body an earlier draft of this test used
8635        // before the `ActorSum`-scrutinee path was verified.
8636        let IrExprKind::Block { tail, .. } = &ir.body.kind else {
8637            panic!("expected a Block, got {:?}", ir.body.kind)
8638        };
8639        let IrExprKind::Return { value } = &tail.kind else {
8640            panic!("expected Return, got {:?}", tail.kind)
8641        };
8642        assert!(
8643            matches!(&value.kind, IrExprKind::Match { .. }),
8644            "expected `match who {{ … }}` to lower to a real Match node, got {:?}",
8645            value.kind
8646        );
8647    }
8648
8649    /// Review of #1209: pins the one load-bearing ordering decision
8650    /// `ActorSeamIr`'s own doc comment argues for — `sum_members_for`
8651    /// ahead of `bearer_seam_for` — at `lower_actor_seam_ir` itself, not
8652    /// only four fixture-hops away via a full `emit_service`/`bless` run.
8653    /// `bearer_seam_for` has no `by.is_sum()` guard of its own and resolves
8654    /// off `by.primary()`, so a Bearer-first sum (`by who: User | Visitor`
8655    /// with `User`'s own scheme `Bearer`) would resolve as `ActorSeamIr::
8656    /// Bearer` instead of `ActorSeamIr::Sum` if the two resolvers were ever
8657    /// tried in the other order.
8658    #[test]
8659    fn lower_actor_seam_ir_tries_sum_ahead_of_bearer_for_a_bearer_first_sum() {
8660        let program = checked_context_program(
8661            r#"
8662context demo
8663
8664type UserId = String
8665
8666actor User { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
8667
8668service Api from http {
8669  on GET("/whoami") () -> Effect[HttpResult[String]] by who: User | Visitor {
8670    Effect.pure(Ok("ok"))
8671  }
8672}
8673"#,
8674        );
8675        let service = find_service(&program, "Api");
8676        let handler = &service.handlers[0];
8677        let actors = actors_map(&program);
8678        let seam = lower_actor_seam_ir(handler, &actors);
8679        let ActorSeamIr::Sum(members) = &seam else {
8680            panic!("expected ActorSeamIr::Sum for a Bearer-first sum `by` clause, got {seam:?}");
8681        };
8682        assert_eq!(members.len(), 2);
8683        assert_eq!(members[0].actor_name, "User");
8684        assert_eq!(members[1].actor_name, "Visitor");
8685    }
8686
8687    #[test]
8688    fn a_binderless_by_clause_still_records_the_actor_gate() {
8689        // Review of #1180's own core scenario: `binder` alone erases a
8690        // binder-less `by <Actor>` (verify-and-discard) entirely — nothing
8691        // in `kind`/`params`/`given`/`binder` would otherwise distinguish
8692        // this actor-gated handler from a public one with no `by` clause
8693        // at all. `actors` is what a consumer must read to recover the
8694        // gate.
8695        let program = checked_context_program(
8696            r#"
8697context demo
8698
8699type UserId = String
8700
8701actor Buyer { auth = Internal, identity = UserId }
8702
8703service Api {
8704  on call(ping: String) -> Effect[String] by Buyer {
8705    Effect.pure(ping)
8706  }
8707}
8708"#,
8709        );
8710        let service = find_service(&program, "Api");
8711        let handler = find_service_handler(service, &HandlerKind::Call);
8712        let ir = lower_service_handler_ir(handler, &service.protocol, &program);
8713        assert!(
8714            ir.binder.is_none(),
8715            "a binder-less `by <Actor>` verifies-and-discards — no identity is bound"
8716        );
8717        assert_eq!(
8718            ir.actors,
8719            vec!["Buyer".to_string()],
8720            "the gate itself survives even though no binder does"
8721        );
8722    }
8723
8724    /// Every `from http` policy test lives on one service — a full `cors`/
8725    /// `security`/`limits` block, asserted against the *interpreted*
8726    /// `PolicyIr`, not the raw AST. The handler body goes through a free
8727    /// `fn ok` rather than a bare `Ok(s)` construction — a holdover from
8728    /// when `Ok`/`Err`/`Some`/`None` construction was still `todo!()` in
8729    /// `lower_expr_ir` (closed as of #1225's own ADR; kept as-is here since
8730    /// it's still a correct, working fixture shape and every test built on
8731    /// it is unaffected either way, not because a bare `Ok(s)` would fail
8732    /// now).
8733    fn http_service_fixture() -> CheckedProgram {
8734        checked_context_program(
8735            r#"
8736context demo
8737
8738fn ok(s: String) -> HttpResult[String] { Ok(s) }
8739
8740service Api from http {
8741  cors { origins: ["https://app.example.com"], credentials: true, maxAge: 1.hours }
8742  security { hsts: 365.days }
8743  limits { maxBody: 1048576 }
8744  on GET("/ping") () -> Effect[HttpResult[String]] by v: Visitor {
8745    Effect.pure(ok("pong"))
8746  }
8747}
8748"#,
8749        )
8750    }
8751
8752    #[test]
8753    fn http_policy_lowers_every_accessor_to_interpreted_values() {
8754        let program = http_service_fixture();
8755        let service = find_service(&program, "Api");
8756        let ir = lower_service_item_ir(service, &program);
8757        let IrItem::Service {
8758            protocol,
8759            handlers,
8760            policy,
8761            ..
8762        } = &ir
8763        else {
8764            panic!("expected IrItem::Service, got {:?}", ir)
8765        };
8766        assert!(matches!(protocol, ProtocolIr::Http));
8767        assert_eq!(
8768            handlers[0].kind,
8769            IrHandlerKind::Http {
8770                method: IrHttpMethod::Get,
8771                path: "/ping".to_string(),
8772            },
8773            "the route binding lives per-handler — this is why ProtocolIr::Http itself \
8774             carries no payload"
8775        );
8776
8777        let policy = policy
8778            .as_ref()
8779            .unwrap_or_else(|| panic!("expected a real PolicyIr for a from http service"));
8780        let cors = policy
8781            .cors
8782            .as_ref()
8783            .unwrap_or_else(|| panic!("expected Some(CorsIr) — this fixture declares cors {{ }}"));
8784        assert_eq!(cors.origins, vec!["https://app.example.com".to_string()]);
8785        assert!(cors.credentials);
8786        assert_eq!(
8787            cors.allow_headers, None,
8788            "no `headers:` field written — the author-override distinction, not the \
8789             emitter's own smart default"
8790        );
8791        assert_eq!(cors.max_age_secs, Some(3600), "1.hours in whole seconds");
8792
8793        assert!(policy.security.nosniff);
8794        assert_eq!(
8795            policy.security.hsts_max_age_secs,
8796            Some(365 * 24 * 60 * 60),
8797            "365.days in whole seconds"
8798        );
8799        assert_eq!(policy.max_body_bytes, Some(1_048_576));
8800    }
8801
8802    #[test]
8803    fn an_http_service_with_no_security_block_still_lowers_the_safe_defaults() {
8804        // The single test pinning ADR 0164's own asymmetry: `security: None`
8805        // on the AST means *defaults*, not *no headers* — a real, easy
8806        // regression if `PolicyIr::security` were ever "simplified" to
8807        // `Option<SecurityIr>`.
8808        let program = checked_context_program(
8809            r#"
8810context demo
8811
8812fn ok(s: String) -> HttpResult[String] { Ok(s) }
8813
8814service Api from http {
8815  on GET("/ping") () -> Effect[HttpResult[String]] by v: Visitor {
8816    Effect.pure(ok("pong"))
8817  }
8818}
8819"#,
8820        );
8821        let service = find_service(&program, "Api");
8822        let ir = lower_service_item_ir(service, &program);
8823        let IrItem::Service { policy, .. } = &ir else {
8824            panic!("expected IrItem::Service, got {:?}", ir)
8825        };
8826        let policy = policy
8827            .as_ref()
8828            .unwrap_or_else(|| panic!("expected Some(PolicyIr) for a from http service"));
8829        assert!(policy.cors.is_none(), "no cors {{ }} block was declared");
8830        assert!(
8831            policy.security.nosniff,
8832            "the safe default, even with no security {{ }} block"
8833        );
8834        assert_eq!(policy.security.hsts_max_age_secs, None);
8835        assert_eq!(
8836            policy.max_body_bytes, None,
8837            "no limits {{ }} block was declared"
8838        );
8839    }
8840
8841    /// A queue consumer's own body is the one shape this module's own
8842    /// pre-existing gaps make genuinely unlowerable today, not just
8843    /// awkward to fixture around: `Effect[QueueResult]` is mandatory
8844    /// (`bynk.queue.return_not_https`-adjacent gate, `context_checks.rs:
8845    /// 3730-3744`), and every `QueueResult` value — `Ack`, `NotFound`,
8846    /// `Retry(reason)` — is a bare or qualified built-in-sum variant
8847    /// reference, the exact case `GlobalRef`'s own doc comment (`ir.rs`)
8848    /// already names as dropped from P6.1's Decision C on purpose
8849    /// (contextual, `expected`-type-driven disambiguation this pass has no
8850    /// sink to read back). Unlike `HttpResult`'s `Ok`/`Err`, `QueueResult`'s
8851    /// own variants also don't resolve inside an ordinary free `fn` body at
8852    /// all (confirmed empirically: `bynk.resolve.unknown_name`) — the
8853    /// checker's own special-case for them (`checker.rs:3507`) is reached
8854    /// only via a real handler body's own `Ctx::return_ty`, which the
8855    /// resolver's eager pass over an ordinary `fn` never sets up — so the
8856    /// `fn ok(s) -> HttpResult[String] { Ok(s) }` indirection every other
8857    /// HTTP/cron fixture in this module uses has no queue-shaped
8858    /// equivalent. Two tests, not one, cover what's actually true here.
8859    fn queue_service_fixture() -> CheckedProgram {
8860        checked_context_program(
8861            r#"
8862context demo
8863
8864type EmailJob = { to: String }
8865
8866service Outbox from queue("orders") {
8867  on message(m: EmailJob) -> Effect[QueueResult] {
8868    Ack
8869  }
8870}
8871"#,
8872        )
8873    }
8874
8875    #[test]
8876    fn a_queue_services_protocol_and_handler_signature_lower_correctly() {
8877        // Everything that *doesn't* need the handler body lowered: the
8878        // protocol descriptor (standalone, mirroring `lower_protocol_ir`'s
8879        // own precedent for `from websocket`) and the handler's own
8880        // `params`/`given`/`effectful` via `lower_handler_signature_ir`
8881        // directly, the same shared helper `lower_service_handler_ir`
8882        // itself calls before ever reaching the body.
8883        let program = queue_service_fixture();
8884        let service = find_service(&program, "Outbox");
8885        assert!(matches!(
8886            lower_protocol_ir(&service.protocol, &program),
8887            ProtocolIr::Queue { name } if name == "orders"
8888        ));
8889        let handler = find_service_handler(service, &HandlerKind::Message);
8890        let cx = LowerIrCtx::new(&program, HashSet::new());
8891        let (params, given, _ret, effectful) = lower_handler_signature_ir(handler, &cx);
8892        assert_eq!(params.len(), 1);
8893        assert_eq!(params[0].0, "m");
8894        assert!(given.is_empty());
8895        assert!(effectful, "every service handler returns Effect[T]");
8896    }
8897
8898    /// #1187's slice 5 (the `Service` emitter cutover, review of #1196):
8899    /// `lower_service_handler_signature_ir` is `emit_service`'s own real
8900    /// call site's entry point, not `lower_handler_signature_ir` directly —
8901    /// this pins it against the exact shape that motivated it: an ordinary
8902    /// `from http` handler body constructing `Ok(...)` directly (not routed
8903    /// through the `fn ok(s) -> HttpResult[String] { Ok(s) }` indirection
8904    /// every other fixture in this module uses). Originally chosen because
8905    /// building a real `IrHandler` here (`lower_service_handler_ir`) would
8906    /// panic on this exact body, on P6.2/P6.3's own `Ok`/`Err`/`Some`/`None`
8907    /// gap (#1143/#1145) — closed as of #1225's own ADR, so this specific
8908    /// body no longer panics `lower_service_handler_ir` either. The general
8909    /// claim this test's own name makes still holds regardless (a real
8910    /// `IrHandler` is still unsafe to build unconditionally at
8911    /// `emit_service`'s call site — correction, P6.25, 2026-08-19:
8912    /// `ExprKind::Question`/`ExprKind::Is` no longer among the reasons why,
8913    /// both landed as P6.15/ADR 0337 and P6.16/ADR 0338; `lower_expr_ir`'s
8914    /// two remaining production-reachable `todo!()`s are P6.2-territory
8915    /// `Callee`/free-fn gaps instead), so the fixture stays as-is rather than
8916    /// chasing a body shape that still panics today.
8917    #[test]
8918    fn service_handler_signature_lowers_without_touching_a_body_that_constructs_ok() {
8919        let program = checked_context_program(
8920            r#"
8921context demo
8922
8923service Api from http {
8924  on GET("/ping") () -> Effect[HttpResult[String]] by v: Visitor {
8925    Effect.pure(Ok("pong"))
8926  }
8927}
8928"#,
8929        );
8930        let service = find_service(&program, "Api");
8931        let handler = find_service_handler(
8932            service,
8933            &HandlerKind::Http {
8934                method: bynk_syntax::ast::HttpMethod::Get,
8935                path: "/ping".to_string(),
8936            },
8937        );
8938        let (params, _given, ret, effectful) =
8939            lower_service_handler_signature_ir(handler, &program);
8940        assert!(params.is_empty(), "`() -> ...` declares no parameters");
8941        assert!(effectful, "an `Effect[...]` return type");
8942        assert!(matches!(
8943            &*program.program().ty_intern.get(ret),
8944            Ty::Effect(_)
8945        ));
8946    }
8947
8948    #[test]
8949    fn a_queue_services_on_message_handler_reaches_ordinary_body_lowering_not_the_websocket_deferral()
8950     {
8951        // The highest-value protocol test: proves the WebSocket deferral
8952        // gate in `lower_service_handler_ir` keys on the `(kind, protocol)`
8953        // *pair*, not on `HandlerKind::Message` alone — the same literal
8954        // variant is also a WebSocket inbound frame. Originally proved by
8955        // this panicking on the bare-nullary-built-in-variant gap (`Ack`,
8956        // `lower_ident_ir`'s own final `todo!()`) rather than the
8957        // WebSocket-specific "synthetic leading" message — #1251/#1252/
8958        // review-of-#1252 closed that gap for real (`Callee::Intrinsic`),
8959        // so the body now lowers cleanly; the same property is proved
8960        // directly instead: `connection` is `None` (only a `from websocket`
8961        // lifecycle handler ever gets one, `lower_handler_ir`'s own doc
8962        // comment), and the body actually reaches and lowers the `Ack`
8963        // reference — a `Callee::Intrinsic { ns: QUEUE_RESULT, op: "Ack" }`
8964        // call with no args, the same shape a `Retry(reason)` call-form
8965        // sibling already lowers to.
8966        let program = queue_service_fixture();
8967        let service = find_service(&program, "Outbox");
8968        let handler = find_service_handler(service, &HandlerKind::Message);
8969        let ir = lower_service_handler_ir(handler, &service.protocol, &program);
8970        assert!(
8971            ir.connection.is_none(),
8972            "a queue on message handler is not a WebSocket lifecycle handler"
8973        );
8974        let IrExprKind::Block { tail, .. } = &ir.body.kind else {
8975            panic!("expected a Block, got {:?}", ir.body.kind)
8976        };
8977        let IrExprKind::Return { value } = &tail.kind else {
8978            panic!("expected the tail to wrap in Return, got {:?}", tail.kind)
8979        };
8980        let IrExprKind::Call { callee, args, .. } = &value.kind else {
8981            panic!("expected Ack to lower to a Call, got {:?}", value.kind)
8982        };
8983        assert!(args.is_empty());
8984        assert!(
8985            matches!(callee, Callee::Intrinsic { ns, op } if *ns == QUEUE_RESULT && op == "Ack"),
8986            "expected Callee::Intrinsic {{ ns: QUEUE_RESULT, op: \"Ack\" }}, got {callee:?}"
8987        );
8988    }
8989
8990    #[test]
8991    fn a_qualified_nullary_sum_variant_reference_lowers_to_variant_not_field_access() {
8992        // Review of #1253 (P6.23 root-cause pass): `Region.Domestic` parses
8993        // as `ExprKind::FieldAccess`, but the checker's own
8994        // `check_field_access` intercepts this shape (a bare-Ident receiver
8995        // naming a declared sum type owning a variant tagged `field.name`)
8996        // *before* ever independently type-checking the receiver — so
8997        // `receiver`'s own ExprId never gets a recorded type, and the naive
8998        // "always recurse into `lower_expr_ir(receiver, cx)`" reading
8999        // panicked on ADR 0334's own "no recorded type" guard. Pins the
9000        // real corpus shape that found this
9001        // (`966_event_field_default_cross_context`'s own
9002        // `Region.International` inside a record field value).
9003        let program = checked_context_program(
9004            r#"
9005context demo
9006
9007type Region = enum { Domestic, International }
9008
9009type Order = { id: String, region: Region }
9010
9011fn pack(id: String) -> Order {
9012  Order { id: id, region: Region.International }
9013}
9014"#,
9015        );
9016        let f = program.program().fns.get("pack").unwrap();
9017        let mut cx = LowerIrCtx::new(&program, HashSet::new());
9018        cx.bind(
9019            "id".to_string(),
9020            program
9021                .program()
9022                .ty_intern
9023                .intern(Ty::Base(BaseType::String)),
9024        );
9025        let body = lower_expr_ir(&f.body.tail, &mut cx);
9026        let IrExprKind::Record { fields, .. } = &body.kind else {
9027            panic!("expected a Record construction, got {:?}", body.kind)
9028        };
9029        let (_, region_value) = fields
9030            .iter()
9031            .find(|(name, _)| name == "region")
9032            .expect("Order has a `region` field");
9033        assert!(
9034            matches!(&region_value.kind, IrExprKind::Variant { tag, payload } if tag == "International" && payload.is_empty()),
9035            "expected `Region.International` to lower to a nullary Variant, got {:?}",
9036            region_value.kind
9037        );
9038    }
9039
9040    #[test]
9041    fn a_service_handler_param_with_an_unresolvable_type_falls_back_to_unit_not_a_panic() {
9042        // Review of #1253 (P6.23 root-cause pass): `lower_service_handler_ir`
9043        // called the agent-oriented `lower_handler_signature_ir` (which
9044        // rightly panics on a resolve miss — the checker does guarantee an
9045        // *agent* handler's own param type resolves) instead of its real
9046        // sibling, `lower_service_handler_signature_ir` — already graceful
9047        // (`cx.unit_ty()` on a miss) since it was written, but never reached
9048        // from this call site. `lower_service_handler_body_ir`'s own param
9049        // loop carried the identical panic independently. Both fixed;
9050        // pinned directly against `1199_service_handler_unresolvable_
9051        // param_type_no_ice`'s own real shape — an HTTP handler param
9052        // naming an undeclared type, which the checker accepts
9053        // (`check_http_handler` validates a param's name only).
9054        let program = checked_context_program(
9055            r#"
9056context demo
9057
9058service Api from http {
9059  on POST("/x") (body: Nope) -> Effect[HttpResult[String]] by v: Visitor {
9060    Effect.pure(Ok("hi"))
9061  }
9062}
9063"#,
9064        );
9065        let service = find_service(&program, "Api");
9066        let handler = &service.handlers[0];
9067        let ir = lower_service_handler_ir(handler, &service.protocol, &program);
9068        assert_eq!(ir.params.len(), 1);
9069        assert_eq!(ir.params[0].0, "body");
9070        assert_eq!(
9071            ir.params[0].1,
9072            program.program().ty_intern.intern(Ty::Unit),
9073            "an unresolvable param type falls back to Unit, matching lower_protocol_ir's own posture"
9074        );
9075    }
9076
9077    #[test]
9078    fn a_cron_service_lowers_its_schedule_from_the_handler_not_the_protocol() {
9079        let program = checked_context_program(
9080            r#"
9081context demo
9082
9083fn done() -> Result[(), String] { Ok(()) }
9084
9085service Sweeper from cron {
9086  on schedule("*/5 * * * *") () -> Effect[Result[(), String]] {
9087    Effect.pure(done())
9088  }
9089}
9090"#,
9091        );
9092        let service = find_service(&program, "Sweeper");
9093        let ir = lower_service_item_ir(service, &program);
9094        let IrItem::Service {
9095            protocol, handlers, ..
9096        } = &ir
9097        else {
9098            panic!("expected IrItem::Service, got {:?}", ir)
9099        };
9100        assert!(matches!(protocol, ProtocolIr::Cron));
9101        assert_eq!(
9102            handlers[0].kind,
9103            IrHandlerKind::Cron {
9104                expr: "*/5 * * * *".to_string()
9105            }
9106        );
9107    }
9108
9109    /// Mirrors `bynkc/tests/fixtures/positive/236_websocket_chatroom` in
9110    /// full — `on open`/`on message`/`on close` all present, the same
9111    /// shape the real fixture uses — so this fixture's own tests can cover
9112    /// both the owned (`on open`) and borrowed (`on message`/`on close`,
9113    /// P6.13, #1179) `connection` cases. A held `Connection` needs real
9114    /// disposal to certify (the linearity pass), so `on open` transfers it
9115    /// into a trivial `Room` agent rather than dropping it.
9116    fn websocket_service_fixture() -> CheckedProgram {
9117        checked_context_program(
9118            r#"
9119context demo
9120
9121type RoomId = String
9122type UserId = String
9123type ServerFrame = { text: String }
9124type ClientFrame = { text: String }
9125
9126actor Participant { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
9127
9128service ChatGateway from websocket(in: ClientFrame, out: ServerFrame) {
9129  on open (roomId: RoomId) -> Effect[()] by user: Participant {
9130    let _ <- connection.send(ServerFrame { text: "welcome" })
9131    let _ <- Room(roomId).join(user.identity, connection)
9132    ()
9133  }
9134
9135  on message (roomId: RoomId, frame: ClientFrame) -> Effect[()] by user: Participant {
9136    let _ <- connection.send(ServerFrame { text: frame.text })
9137    let _ <- Room(roomId).post(user.identity, frame.text)
9138    ()
9139  }
9140
9141  on close (roomId: RoomId) -> Effect[()] by user: Participant {
9142    let _ <- Room(roomId).leave(user.identity)
9143    ()
9144  }
9145}
9146
9147agent Room {
9148  key id: RoomId
9149  store members: Set[UserId]
9150  store conns: Map[UserId, Connection[ServerFrame]]
9151
9152  on call join(u: UserId, conn: Connection[ServerFrame]) -> Effect[()] {
9153    let _ <- members.add(u)
9154    let _ <- conns.put(u, conn)
9155    ()
9156  }
9157
9158  on call leave(u: UserId) -> Effect[()] {
9159    let _ <- members.remove(u)
9160    let _ <- conns.remove(u)
9161    ()
9162  }
9163
9164  on call post(sender: UserId, text: String) -> Effect[()] {
9165    let _ <- conns.parTraverse((c: Connection[ServerFrame]) => c.send(ServerFrame { text: text }))
9166    ()
9167  }
9168}
9169"#,
9170        )
9171    }
9172
9173    #[test]
9174    fn websocket_protocol_descriptor_lowers_its_frame_types() {
9175        let program = websocket_service_fixture();
9176        let service = find_service(&program, "ChatGateway");
9177        let ir = lower_protocol_ir(&service.protocol, &program);
9178        let ProtocolIr::WebSocket { in_ty, out_ty } = ir else {
9179            panic!("expected ProtocolIr::WebSocket, got {:?}", ir)
9180        };
9181        let tys = &program.program().ty_intern;
9182        assert_eq!(in_ty.display(tys), "ClientFrame");
9183        assert_eq!(out_ty.display(tys), "ServerFrame");
9184    }
9185
9186    #[test]
9187    fn websocket_open_handler_lowers_an_owned_connection_binding() {
9188        // P6.13 (#1179): the named deferral is gone — `on open` now
9189        // assembles a real `IrHandler` whose body's `connection.send(…)`
9190        // read resolves through the synthetic binding, and whose own
9191        // `connection` field records the owned (non-borrowed) case.
9192        let program = websocket_service_fixture();
9193        let service = find_service(&program, "ChatGateway");
9194        let handler = find_service_handler(service, &HandlerKind::Open);
9195        let ir = lower_service_handler_ir(handler, &service.protocol, &program);
9196        let conn = ir
9197            .connection
9198            .as_ref()
9199            .expect("on open must carry a ConnectionBinder");
9200        assert!(
9201            !conn.borrowed,
9202            "on open's connection is a fresh owned socket, not borrowed"
9203        );
9204        let tys = &program.program().ty_intern;
9205        assert_eq!(conn.ty.display(tys), "Connection[ServerFrame]");
9206        // `connection` is never in the AST-derived params — mirrors the
9207        // checker's own `handler.params`/`params_for_check` asymmetry.
9208        assert!(ir.params.iter().all(|(name, _)| name != "connection"));
9209        let IrExprKind::Block { stmts, .. } = &ir.body.kind else {
9210            panic!("expected a Block body, got {:?}", ir.body.kind)
9211        };
9212        let has_connection_local = stmts
9213            .iter()
9214            .any(|stmt| format!("{stmt:?}").contains("Local(\"connection\")"));
9215        assert!(
9216            has_connection_local,
9217            "expected the lowered body to resolve `connection` as a Local, got {stmts:?}"
9218        );
9219    }
9220
9221    #[test]
9222    fn websocket_message_and_close_handlers_lower_a_borrowed_connection_binding() {
9223        // P6.13 (#1179): `on message`/`on close` receive the same
9224        // `connection` binding as `on open`, but borrowed — no disposal
9225        // obligation, mirroring the checker's own `borrowed_held`.
9226        let program = websocket_service_fixture();
9227        let service = find_service(&program, "ChatGateway");
9228        for kind in [HandlerKind::Message, HandlerKind::Close] {
9229            let handler = find_service_handler(service, &kind);
9230            let ir = lower_service_handler_ir(handler, &service.protocol, &program);
9231            let conn = ir
9232                .connection
9233                .as_ref()
9234                .unwrap_or_else(|| panic!("{kind:?} must carry a ConnectionBinder"));
9235            assert!(
9236                conn.borrowed,
9237                "{kind:?}'s connection is the borrowed firing socket"
9238            );
9239        }
9240        // Review of #1185: `on open`'s own test already pins that a
9241        // `connection.…` read resolves to `Local("connection")` in the
9242        // lowered body — but that alone only proves the *owned* case
9243        // reaches scope. `on message`'s own non-consuming
9244        // `connection.send(…)` (`borrowed_held`'s whole reason to exist)
9245        // pins the borrowed case identically, so a future change that
9246        // gated `cx.bind` on `!borrowed` would fail here, not just leave
9247        // an unused synthetic binding.
9248        let handler = find_service_handler(service, &HandlerKind::Message);
9249        let ir = lower_service_handler_ir(handler, &service.protocol, &program);
9250        let IrExprKind::Block { stmts, .. } = &ir.body.kind else {
9251            panic!("expected a Block body, got {:?}", ir.body.kind)
9252        };
9253        let has_connection_local = stmts
9254            .iter()
9255            .any(|stmt| format!("{stmt:?}").contains("Local(\"connection\")"));
9256        assert!(
9257            has_connection_local,
9258            "expected on message's lowered body to resolve `connection` as a Local, got {stmts:?}"
9259        );
9260    }
9261
9262    #[test]
9263    fn websocket_service_item_assembles_with_every_lifecycle_handler_lowered() {
9264        // The named deferral used to make `lower_service_item_ir`
9265        // unbuildable for any real websocket service (issue #1179's own
9266        // framing) — this is that end-to-end assembly, now real.
9267        let program = websocket_service_fixture();
9268        let service = find_service(&program, "ChatGateway");
9269        let ir = lower_service_item_ir(service, &program);
9270        let IrItem::Service { handlers, .. } = &ir else {
9271            panic!("expected IrItem::Service, got {:?}", ir)
9272        };
9273        assert_eq!(handlers.len(), 3);
9274        assert!(handlers.iter().all(|h| h.connection.is_some()));
9275    }
9276
9277    #[test]
9278    fn service_level_by_and_given_defaults_are_already_injected_before_lowering() {
9279        // Pins the *harness* change (the `inject_service_defaults` call in
9280        // `checked_context_program`) as much as the lowering itself:
9281        // without it, this test would fail, silently pinning the wrong
9282        // fact instead of catching a real gap. `lower_service_item_ir`
9283        // itself contains no default-inheritance logic at all — see its
9284        // own doc comment.
9285        let program = checked_context_program(
9286            r#"
9287context demo
9288
9289type UserId = String
9290
9291actor Buyer { auth = Internal, identity = UserId }
9292
9293capability Clock {
9294  fn now() -> Effect[Int]
9295}
9296
9297provides Clock = FixedClock {
9298  fn now() -> Effect[Int] {
9299    42
9300  }
9301}
9302
9303service Api by u: Buyer given Clock {
9304  on call(ping: String) -> Effect[String] {
9305    Effect.pure(ping)
9306  }
9307}
9308"#,
9309        );
9310        let service = find_service(&program, "Api");
9311        let ir = lower_service_item_ir(service, &program);
9312        let IrItem::Service { handlers, .. } = &ir else {
9313            panic!("expected IrItem::Service, got {:?}", ir)
9314        };
9315        assert_eq!(handlers.len(), 1);
9316        let binder = handlers[0]
9317            .binder
9318            .as_ref()
9319            .unwrap_or_else(|| panic!("expected the service-level `by u: Buyer` to be inherited"));
9320        assert_eq!(binder.binder, "u");
9321        assert_eq!(handlers[0].given, vec!["Clock".to_string()]);
9322    }
9323
9324    #[test]
9325    fn lower_event_pattern_ir_reshapes_literal_and_variant_fields() {
9326        // Review of #1180: the `Events` protocol path had zero coverage.
9327        // `lower_protocol_ir`'s own `Events` arm genuinely can't be driven
9328        // through `checked_context_program` — a real `from Events(E)`
9329        // subscription needs `consumes bynk { Events }`, which this
9330        // reduced harness's `CrossContextInfo::default()` doesn't support
9331        // (the same limitation named in `checked_context_program`'s own
9332        // doc comment, and in #1169's own Risks for `CommitShape::
9333        // FlushEvents`). `lower_event_pattern_ir` itself has no such
9334        // excuse: it takes no `&CheckedProgram`, resolves nothing, and
9335        // cannot panic — pure AST reshaping — so it's pinned directly
9336        // against a parsed (not checked or certified) `EventPattern`.
9337        let source = r#"
9338context demo
9339
9340type Status = | Active | Inactive
9341
9342event OrderPlaced = {
9343  status: Status,
9344  count: Int,
9345}
9346
9347service Subscriber from Events(OrderPlaced { status: Active, count: 3, .. }) {
9348  on event(o: OrderPlaced) -> Effect[()] {
9349    Effect.pure(())
9350  }
9351}
9352"#;
9353        let tokens = lexer::tokenize(source).expect("lex");
9354        let unit = parser::parse_unit(&tokens, source).expect("parse");
9355        let SourceUnit::Context(ctx) = unit else {
9356            panic!("expected a context unit, got {unit:?}")
9357        };
9358        let service = ctx
9359            .items
9360            .iter()
9361            .find_map(|item| match item {
9362                CommonsItem::Service(s) if s.name.name == "Subscriber" => Some(s),
9363                _ => None,
9364            })
9365            .expect("no service named `Subscriber` in this fixture");
9366        let ServiceProtocol::Events { pattern, .. } = &service.protocol else {
9367            panic!(
9368                "expected ServiceProtocol::Events, got {:?}",
9369                service.protocol
9370            )
9371        };
9372        let pattern = pattern
9373            .as_ref()
9374            .expect("expected a structural pattern on this Events subscription");
9375
9376        let ir = lower_event_pattern_ir(pattern);
9377        assert_eq!(ir.fields.len(), 2, "declaration order preserved");
9378        assert_eq!(ir.fields[0].0, "status");
9379        assert!(
9380            matches!(&ir.fields[0].1, EventPatternValueIr::Variant { tag } if tag == "Active"),
9381            "expected a bare nullary variant tag (the AST's own optional qualifying type_name \
9382             dropped), got {:?}",
9383            ir.fields[0].1
9384        );
9385        assert_eq!(ir.fields[1].0, "count");
9386        assert!(
9387            matches!(
9388                &ir.fields[1].1,
9389                EventPatternValueIr::Const(ConstVal::Int(3))
9390            ),
9391            "expected a literal Int constant, got {:?}",
9392            ir.fields[1].1
9393        );
9394    }
9395
9396    #[test]
9397    fn lower_capability_item_ir_assembles_ops_in_declaration_order() {
9398        let program = checked_context_program(
9399            r#"
9400context demo
9401
9402capability Store {
9403  fn get(key: String) -> Effect[Int]
9404  fn put(key: String, value: Int) -> Effect[()]
9405}
9406"#,
9407        );
9408        let cap = find_capability(&program, "Store");
9409        let ir = lower_capability_item_ir(cap, &program);
9410        let IrItem::Capability { def, ops } = &ir else {
9411            panic!("expected IrItem::Capability, got {:?}", ir)
9412        };
9413        assert_eq!(def, "Store");
9414        assert_eq!(ops.len(), 2, "declaration order preserved");
9415
9416        assert_eq!(ops[0].name, "get");
9417        assert!(ops[0].type_params.is_empty());
9418        assert_eq!(ops[0].params.len(), 1);
9419        assert_eq!(ops[0].params[0].0, "key");
9420        assert!(matches!(
9421            &*program.program().ty_intern.get(ops[0].params[0].1),
9422            Ty::Base(bynk_syntax::ast::BaseType::String)
9423        ));
9424        // `return_ty` mirrors `IrItem::Fn::ret`'s own convention (Effect-
9425        // wrapped, not peeled) — `get`'s declared `Effect[Int]` resolves
9426        // whole, same as a fn's `ret`.
9427        assert!(matches!(
9428            &*program.program().ty_intern.get(ops[0].return_ty),
9429            Ty::Effect(inner) if matches!(
9430                &*program.program().ty_intern.get(*inner),
9431                Ty::Base(bynk_syntax::ast::BaseType::Int)
9432            )
9433        ));
9434
9435        assert_eq!(ops[1].name, "put");
9436        assert_eq!(ops[1].params.len(), 2);
9437        assert_eq!(ops[1].params[0].0, "key");
9438        assert_eq!(ops[1].params[1].0, "value");
9439        assert!(matches!(
9440            &*program.program().ty_intern.get(ops[1].params[1].1),
9441            Ty::Base(bynk_syntax::ast::BaseType::Int)
9442        ));
9443    }
9444
9445    /// P6.29 (design/tracks/the-ir.md §6a): pins `capability_op_sig_from_commons`
9446    /// against the same fixture as its `CheckedProgram`-driven sibling above —
9447    /// same param names/order, found by name alone from `TypedCommons`, no
9448    /// `CheckedProgram` needed at the call site (`emitter/lower.rs`'s
9449    /// `cap_op_param_names` only ever had one).
9450    #[test]
9451    fn capability_op_sig_from_commons_finds_the_named_op() {
9452        let program = checked_context_program(
9453            r#"
9454context demo
9455
9456capability Store {
9457  fn get(key: String) -> Effect[Int]
9458  fn put(key: String, value: Int) -> Effect[()]
9459}
9460"#,
9461        );
9462        let commons = program.program();
9463
9464        let get = capability_op_sig_from_commons(commons, "Store", "get")
9465            .expect("Store.get should resolve");
9466        assert_eq!(get.params.len(), 1);
9467        assert_eq!(get.params[0].0, "key");
9468
9469        let put = capability_op_sig_from_commons(commons, "Store", "put")
9470            .expect("Store.put should resolve");
9471        assert_eq!(put.params.len(), 2);
9472        assert_eq!(put.params[0].0, "key");
9473        assert_eq!(put.params[1].0, "value");
9474
9475        // Same fallthrough-to-`None` behaviour the by-hand AST walk it
9476        // replaces had, for both an unknown capability and a known
9477        // capability's unknown op — mirrors `cap_op_param_names`'s own prior
9478        // fallthrough-to-empty-`Vec` at the caller.
9479        assert!(capability_op_sig_from_commons(commons, "NoSuchCap", "get").is_none());
9480        assert!(capability_op_sig_from_commons(commons, "Store", "no_such_op").is_none());
9481    }
9482
9483    #[test]
9484    fn lower_op_sig_ir_resolves_generic_op_type_params_as_rigid_vars() {
9485        // Review-relevant: a capability op's own `[T, …]` list is scoped to
9486        // the op, not the capability (`CapabilityDecl` has no `type_params`
9487        // of its own) — pins that `lower_op_sig_ir` seeds a fresh rigid-var
9488        // scope per op rather than sharing one across every op on the same
9489        // capability, and that a wrong scope would show up as `Ty::Unit`
9490        // (ADR 0334's own silent-wrong-answer failure mode), not a panic.
9491        let program = checked_context_program(
9492            r#"
9493context demo
9494
9495capability Store {
9496  fn get[T](key: String) -> Effect[T]
9497  fn now() -> Effect[Int]
9498}
9499"#,
9500        );
9501        let cap = find_capability(&program, "Store");
9502        let ir = lower_capability_item_ir(cap, &program);
9503        let IrItem::Capability { ops, .. } = &ir else {
9504            panic!("expected IrItem::Capability, got {:?}", ir)
9505        };
9506
9507        let get = ops.iter().find(|o| o.name == "get").expect("op `get`");
9508        assert_eq!(get.type_params, vec!["T".to_string()]);
9509        assert!(matches!(
9510            &*program.program().ty_intern.get(get.params[0].1),
9511            Ty::Base(bynk_syntax::ast::BaseType::String)
9512        ));
9513        assert!(
9514            matches!(
9515                &*program.program().ty_intern.get(get.return_ty),
9516                Ty::Effect(inner) if matches!(
9517                    &*program.program().ty_intern.get(*inner),
9518                    Ty::Var(n) if n == "T"
9519                )
9520            ),
9521            "expected the op's own `T` to survive as Ty::Var, not collapse to Ty::Unit"
9522        );
9523
9524        let now = ops.iter().find(|o| o.name == "now").expect("op `now`");
9525        assert!(
9526            now.type_params.is_empty(),
9527            "a sibling non-generic op must not see `get`'s own `T` in scope"
9528        );
9529    }
9530
9531    #[test]
9532    fn lower_op_sig_ir_resolves_a_generic_op_type_param_inside_a_type_argument() {
9533        // Review of #1182: `Effect[T]` alone only exercises `TypeRef::Effect`'s
9534        // arm — a bare wrapper around the var. `Box[T]` exercises
9535        // `TypeRef::App`'s arm instead, where `T` is a *type argument*, not
9536        // the whole ref, and a wrong rigid-var scope fails differently
9537        // (`types.get(&name.name)?` on the *bare var itself*, not on the
9538        // outer `Box`) — the other half of the resolution surface
9539        // `lower_op_sig_ir`'s own doc comment claims to cover.
9540        let program = checked_context_program(
9541            r#"
9542context demo
9543
9544type Box[A] = { value: A }
9545
9546capability Store {
9547  fn get[T](box: Box[T]) -> Effect[T]
9548}
9549"#,
9550        );
9551        let cap = find_capability(&program, "Store");
9552        let ir = lower_capability_item_ir(cap, &program);
9553        let IrItem::Capability { ops, .. } = &ir else {
9554            panic!("expected IrItem::Capability, got {:?}", ir)
9555        };
9556        let get = &ops[0];
9557        assert!(
9558            matches!(
9559                &*program.program().ty_intern.get(get.params[0].1),
9560                Ty::Named { name, args, .. }
9561                    if name == "Box"
9562                        && args.len() == 1
9563                        && matches!(
9564                            &*program.program().ty_intern.get(args[0]),
9565                            Ty::Var(n) if n == "T"
9566                        )
9567            ),
9568            "expected Box[T] with T resolved as a rigid Ty::Var argument, got {:?}",
9569            program.program().ty_intern.get(get.params[0].1)
9570        );
9571    }
9572
9573    #[test]
9574    fn lower_op_sig_ir_falls_back_to_unit_on_an_unresolvable_type_like_the_checker_does() {
9575        // Review of #1182: a capability op's own param/return types are
9576        // never actually resolution-checked upstream (the resolver skips
9577        // `CommonsItem::Capability`, and `check_capability_decls` only
9578        // records refs, never errors on a miss) — so `Bogus` here
9579        // certifies today, and `lower_op_sig_ir` must mirror the checker's
9580        // own `build_capability_op_info` fallback (`Ty::Unit`) rather than
9581        // panic on a state that is, in fact, reachable from source.
9582        let program = checked_context_program(
9583            r#"
9584context demo
9585
9586capability Store {
9587  fn get(key: Bogus) -> Effect[Bogus]
9588}
9589"#,
9590        );
9591        let cap = find_capability(&program, "Store");
9592        // Would already have panicked inside `checked_context_program`'s own
9593        // `.expect("certify")` if this were rejected upstream — reaching
9594        // here at all is part of what this test pins.
9595        let ir = lower_capability_item_ir(cap, &program);
9596        let IrItem::Capability { ops, .. } = &ir else {
9597            panic!("expected IrItem::Capability, got {:?}", ir)
9598        };
9599        assert!(matches!(
9600            &*program.program().ty_intern.get(ops[0].params[0].1),
9601            Ty::Unit
9602        ));
9603        assert!(matches!(
9604            &*program.program().ty_intern.get(ops[0].return_ty),
9605            Ty::Unit
9606        ));
9607    }
9608
9609    #[test]
9610    fn lower_op_sig_ir_agrees_with_the_checkers_own_capability_op_info() {
9611        // Review of #1182: `OpSig`'s own doc comment states this mirrors
9612        // `CapabilityOpInfo` — same per-op var seeding, same `types` map,
9613        // same non-peeled `return_ty`. Checks the stated invariant directly
9614        // against the checker's own constructor rather than leaving it only
9615        // asserted in prose, so a later change to either side's rigid-var
9616        // seeding fails this test instead of silently drifting.
9617        let program = checked_context_program(
9618            r#"
9619context demo
9620
9621capability Store {
9622  fn get[T](key: String) -> Effect[T]
9623}
9624"#,
9625        );
9626        let cap = find_capability(&program, "Store");
9627        let ir = lower_capability_item_ir(cap, &program);
9628        let IrItem::Capability { ops, .. } = &ir else {
9629            panic!("expected IrItem::Capability, got {:?}", ir)
9630        };
9631        let op = &ops[0];
9632
9633        let info = context_checks::build_capability_op_info(
9634            &cap.ops[0],
9635            &program.program().types,
9636            &program.program().ty_intern,
9637        );
9638
9639        assert_eq!(op.name, info.name);
9640        assert_eq!(op.type_params, info.type_params);
9641        assert_eq!(
9642            op.params.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
9643            info.param_names
9644        );
9645        assert_eq!(
9646            op.params.iter().map(|(_, t)| *t).collect::<Vec<_>>(),
9647            info.params
9648        );
9649        assert_eq!(op.return_ty, info.return_ty);
9650    }
9651
9652    #[test]
9653    fn lower_provider_item_ir_assembles_bynk_ops_and_given_in_declaration_order() {
9654        // `given Random, Clock` (reverse-alphabetical, deliberately) pins
9655        // that `given`'s own declaration order survives — neither op body
9656        // below calls either capability, pinning review of #1186's point
9657        // that an *unused* `given` entry must still appear (it feeds R8.1's
9658        // own `deps` constructor, not anything the op bodies reference).
9659        let program = checked_context_program(
9660            r#"
9661context demo
9662
9663capability Clock {
9664  fn now() -> Effect[Int]
9665}
9666
9667capability Random {
9668  fn next() -> Effect[Int]
9669}
9670
9671capability Store {
9672  fn get(key: String) -> Effect[Int]
9673  fn put(key: String, value: Int) -> Effect[()]
9674}
9675
9676provides Store = MemStore given Random, Clock {
9677  fn get(key: String) -> Effect[Int] {
9678    Effect.pure(0)
9679  }
9680  fn put(key: String, value: Int) -> Effect[()] {
9681    Effect.pure(())
9682  }
9683}
9684"#,
9685        );
9686        let provider = find_provider(&program, "MemStore");
9687        let ir = lower_provider_item_ir(provider, &program);
9688        let IrItem::Provider { def, cap, body } = &ir else {
9689            panic!("expected IrItem::Provider, got {:?}", ir)
9690        };
9691        assert_eq!(def, "MemStore");
9692        assert_eq!(cap, "Store");
9693        let ProviderBody::Bynk { given, ops } = body else {
9694            panic!("expected ProviderBody::Bynk, got {:?}", body)
9695        };
9696        assert_eq!(
9697            given.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(),
9698            vec!["Random", "Clock"],
9699            "given's own declaration order preserved, unused entries included"
9700        );
9701        assert!(given.iter().all(|g| g.context.is_none()));
9702        assert_eq!(ops.len(), 2, "declaration order preserved");
9703
9704        assert_eq!(ops[0].name, "get");
9705        assert_eq!(ops[0].params.len(), 1);
9706        assert_eq!(ops[0].params[0].0, "key");
9707        assert!(matches!(
9708            &*program.program().ty_intern.get(ops[0].params[0].1),
9709            Ty::Base(bynk_syntax::ast::BaseType::String)
9710        ));
9711        // `return_ty` mirrors `OpSig::return_ty`'s own convention
9712        // (Effect-wrapped, not peeled).
9713        assert!(matches!(
9714            &*program.program().ty_intern.get(ops[0].return_ty),
9715            Ty::Effect(inner) if matches!(
9716                &*program.program().ty_intern.get(*inner),
9717                Ty::Base(bynk_syntax::ast::BaseType::Int)
9718            )
9719        ));
9720
9721        assert_eq!(ops[1].name, "put");
9722        assert_eq!(ops[1].params.len(), 2);
9723        assert_eq!(ops[1].params[0].0, "key");
9724        assert_eq!(ops[1].params[1].0, "value");
9725    }
9726
9727    #[test]
9728    fn lower_provider_item_ir_external_provider_has_no_ops() {
9729        // v0.17: an external (bodiless) provider is only legal inside an
9730        // `adapter` unit (`bynk-check/src/symbols.rs:438`), which this test
9731        // harness's own `checked_context_program` cannot build (it only
9732        // ever parses a `context` — feedback memory "bynk-emit test harness
9733        // scope"). `lower_provider_item_ir`'s own `External` branch never
9734        // reads `program` at all, so this hand-constructs the `ProviderDecl`
9735        // the parser would produce for `provides Store = ExternalStore`
9736        // inside an adapter, the same way `project_model.rs`'s own
9737        // `provider()` test helper does, rather than growing a second,
9738        // adapter-shaped fixture builder for a branch this pass never
9739        // touches `program` on.
9740        let program = checked_context_program("context demo\n");
9741        let provider = ProviderDecl {
9742            capability: bynk_syntax::ast::Ident {
9743                name: "Store".to_string(),
9744                span: Span::default(),
9745            },
9746            provider_name: bynk_syntax::ast::Ident {
9747                name: "ExternalStore".to_string(),
9748                span: Span::default(),
9749            },
9750            // Review of #1187's own Provider given/deps-wiring slice: an
9751            // external provider's own `given` is populated the same way a
9752            // Bynk one's is (nothing in the grammar or checker gates it on
9753            // `external`) — non-empty here specifically to pin that
9754            // `ProviderBody::External` now carries it, where it used to be
9755            // silently dropped (a bare-unit variant with nowhere to put it).
9756            given: vec![CapRef {
9757                context: None,
9758                name: bynk_syntax::ast::Ident {
9759                    name: "Clock".to_string(),
9760                    span: Span::default(),
9761                },
9762                span: Span::default(),
9763            }],
9764            ops: Vec::new(),
9765            external: true,
9766            documentation: None,
9767            span: Span::default(),
9768            trivia: Default::default(),
9769        };
9770
9771        let ir = lower_provider_item_ir(&provider, &program);
9772        let IrItem::Provider { def, cap, body } = &ir else {
9773            panic!("expected IrItem::Provider, got {:?}", ir)
9774        };
9775        assert_eq!(def, "ExternalStore");
9776        assert_eq!(cap, "Store");
9777        let ProviderBody::External { given } = body else {
9778            panic!("expected ProviderBody::External, got {:?}", body)
9779        };
9780        assert_eq!(given.len(), 1);
9781        assert_eq!(given[0].context, None);
9782        assert_eq!(given[0].name, "Clock");
9783    }
9784
9785    #[test]
9786    fn lower_provider_op_ir_lowers_a_given_capability_call_via_the_ordinary_callee_path() {
9787        // Pins `IrItem`'s own doc comment: a provider op's `given`
9788        // capabilities need no scope entry of their own — a
9789        // `Callee::Capability`-classified call already lowers correctly
9790        // through the ordinary `lower_block_ir`/`lower_expr_ir` path, the
9791        // same claim `lower_handler_body_ir`'s own doc comment makes for
9792        // handler bodies.
9793        let program = checked_context_program(
9794            r#"
9795context demo
9796
9797capability Clock {
9798  fn now() -> Effect[Int]
9799}
9800
9801capability Store {
9802  fn get(key: String) -> Effect[Int]
9803}
9804
9805provides Store = MemStore given Clock {
9806  fn get(key: String) -> Effect[Int] {
9807    Clock.now()
9808  }
9809}
9810"#,
9811        );
9812        let provider = find_provider(&program, "MemStore");
9813        let ir = lower_provider_item_ir(provider, &program);
9814        let IrItem::Provider { body, .. } = &ir else {
9815            panic!("expected IrItem::Provider, got {:?}", ir)
9816        };
9817        let ProviderBody::Bynk { given, ops } = body else {
9818            panic!("expected ProviderBody::Bynk, got {:?}", body)
9819        };
9820        assert_eq!(given.len(), 1);
9821        assert_eq!(given[0].name, "Clock");
9822        let get = &ops[0];
9823        let tail = fn_tail(&get.body);
9824        let IrExprKind::Call { callee, args, .. } = &tail.kind else {
9825            panic!("expected Call, got {:?}", tail.kind)
9826        };
9827        assert!(matches!(
9828            callee,
9829            Callee::Capability { cap, op } if cap == "Clock" && op == "now"
9830        ));
9831        assert!(args.is_empty());
9832    }
9833
9834    #[test]
9835    fn lower_cap_ref_ir_local_capability_has_no_context() {
9836        let cap_ref = CapRef {
9837            context: None,
9838            name: bynk_syntax::ast::Ident {
9839                name: "Clock".to_string(),
9840                span: Span::default(),
9841            },
9842            span: Span::default(),
9843        };
9844        let ir = lower_cap_ref_ir(&cap_ref);
9845        assert_eq!(ir.context, None);
9846        assert_eq!(ir.name, "Clock");
9847    }
9848
9849    #[test]
9850    fn lower_cap_ref_ir_preserves_a_cross_context_prefix() {
9851        // `given B.Cap` (v0.15) is out of `checked_context_program`'s own
9852        // fixture scope (no cross-context `uses`/`consumes`, feedback
9853        // memory "bynk-emit test harness scope") — pins `lower_cap_ref_ir`'s
9854        // own `QualifiedName -> String` flattening directly against a
9855        // hand-built `CapRef`, the same posture the external-provider test
9856        // above already takes for a branch the fixture cannot reach.
9857        let cap_ref = CapRef {
9858            context: Some(QualifiedName {
9859                parts: vec![bynk_syntax::ast::Ident {
9860                    name: "Billing".to_string(),
9861                    span: Span::default(),
9862                }],
9863                span: Span::default(),
9864            }),
9865            name: bynk_syntax::ast::Ident {
9866                name: "Ledger".to_string(),
9867                span: Span::default(),
9868            },
9869            span: Span::default(),
9870        };
9871        let ir = lower_cap_ref_ir(&cap_ref);
9872        assert_eq!(ir.context.as_deref(), Some("Billing"));
9873        assert_eq!(ir.name, "Ledger");
9874    }
9875
9876    #[test]
9877    fn lower_provider_op_ir_binds_its_own_param_into_scope() {
9878        // Review of #1186: every other provider-op test's own body ignores
9879        // its params, so `cx.bind` (the one line seeding the scope
9880        // `lower_ident_ir` needs — the reason this function is a sibling of
9881        // `lower_fn_body_ir` rather than a widening of it) could regress
9882        // silently. Pins a param reference resolving to `Local`, not the
9883        // unresolved-ident `todo!()` or a same-named-global misclassification.
9884        let program = checked_context_program(
9885            r#"
9886context demo
9887
9888capability Store {
9889  fn get(key: String) -> Effect[String]
9890}
9891
9892provides Store = MemStore {
9893  fn get(key: String) -> Effect[String] {
9894    Effect.pure(key)
9895  }
9896}
9897"#,
9898        );
9899        let provider = find_provider(&program, "MemStore");
9900        let ir = lower_provider_item_ir(provider, &program);
9901        let IrItem::Provider { body, .. } = &ir else {
9902            panic!("expected IrItem::Provider, got {:?}", ir)
9903        };
9904        let ProviderBody::Bynk { ops, .. } = body else {
9905            panic!("expected ProviderBody::Bynk, got {:?}", body)
9906        };
9907        let get = &ops[0];
9908        let key_ty = get.params[0].1;
9909        let tail = fn_tail(&get.body);
9910        // `Effect.pure(key)` desugars to `IrExprKind::Pure`, wrapping `key`
9911        // as its own sole value.
9912        let IrExprKind::Pure { value } = &tail.kind else {
9913            panic!("expected Pure, got {:?}", tail.kind)
9914        };
9915        assert!(
9916            matches!(
9917                &value.kind,
9918                IrExprKind::Local(name) if name == "key"
9919            ),
9920            "expected the op's own `key` param to resolve as a bound Local, got {:?}",
9921            value.kind
9922        );
9923        assert_eq!(
9924            value.ty, key_ty,
9925            "the resolved Local's type must be the same TyId bind() seeded"
9926        );
9927    }
9928
9929    /// Review of #1229 (#1228): `lower_route_cache_ir`/`lower_route_limit_ir`
9930    /// take no `&CheckedProgram`, resolve nothing, and cannot panic — the same
9931    /// posture `lower_event_pattern_ir`'s own test above already established a
9932    /// parsed-not-checked fixture for, and for the identical reason here: their
9933    /// defensive branches (a non-`GET` handler, a `maxAge`-less `@cache`, a
9934    /// non-positive `maxBody`) are exactly the shapes `bynk-check`'s
9935    /// `bynk.http.cache_on_non_get`/`cache_bad_max_age`/`limit_bad_max_body`
9936    /// (`bynk-check/src/context_checks.rs`) already reject, so a real checked
9937    /// program can never reach them and the fixture bless run never exercises
9938    /// them either.
9939    fn parsed_only_context(source: &str) -> bynk_syntax::ast::Context {
9940        let tokens = lexer::tokenize(source).expect("lex");
9941        let unit = parser::parse_unit(&tokens, source).expect("parse");
9942        let SourceUnit::Context(ctx) = unit else {
9943            panic!("expected a context unit, got {unit:?}")
9944        };
9945        ctx
9946    }
9947
9948    fn parsed_handler<'a>(
9949        ctx: &'a bynk_syntax::ast::Context,
9950        service: &str,
9951        index: usize,
9952    ) -> &'a Handler {
9953        let service = ctx
9954            .items
9955            .iter()
9956            .find_map(|item| match item {
9957                CommonsItem::Service(s) if s.name.name == service => Some(s),
9958                _ => None,
9959            })
9960            .unwrap_or_else(|| panic!("no service named `{service}` in this fixture"));
9961        &service.handlers[index]
9962    }
9963
9964    #[test]
9965    fn lower_route_cache_ir_reads_maxage_and_scope_off_a_get_handler() {
9966        let ctx = parsed_only_context(
9967            r#"
9968context demo
9969
9970service Api from http {
9971  @cache(maxAge: 5.minutes, scope: public)
9972  on GET("/config") () -> Effect[HttpResult[String]] by v: Visitor {
9973    Ok("cfg")
9974  }
9975
9976  @cache(maxAge: 30.seconds)
9977  on GET("/private") () -> Effect[HttpResult[String]] by v: Visitor {
9978    Ok("priv")
9979  }
9980
9981  on GET("/plain") () -> Effect[HttpResult[String]] by v: Visitor {
9982    Ok("plain")
9983  }
9984}
9985"#,
9986        );
9987        let public_cache = lower_route_cache_ir(parsed_handler(&ctx, "Api", 0))
9988            .unwrap_or_else(|| panic!("expected Some(CacheIr) for a well-formed @cache"));
9989        assert_eq!(public_cache.max_age_secs, 300, "5.minutes in whole seconds");
9990        assert_eq!(public_cache.scope, "public");
9991
9992        let default_scope_cache = lower_route_cache_ir(parsed_handler(&ctx, "Api", 1))
9993            .unwrap_or_else(|| panic!("expected Some(CacheIr) with no explicit scope:"));
9994        assert_eq!(default_scope_cache.max_age_secs, 30);
9995        assert_eq!(
9996            default_scope_cache.scope, "private",
9997            "no scope: argument written — must default to private"
9998        );
9999
10000        assert!(
10001            lower_route_cache_ir(parsed_handler(&ctx, "Api", 2)).is_none(),
10002            "no @cache annotation at all must yield None"
10003        );
10004    }
10005
10006    #[test]
10007    fn lower_route_cache_ir_returns_none_for_a_non_get_handler_even_with_a_cache_annotation() {
10008        // `bynk.http.cache_on_non_get` already rejects this at check time — this
10009        // pins the lowering function's own independent guard, not reachable
10010        // through a real certified program.
10011        let ctx = parsed_only_context(
10012            r#"
10013context demo
10014
10015service Api from http {
10016  @cache(maxAge: 5.minutes)
10017  on POST("/items") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
10018    Created(body)
10019  }
10020}
10021"#,
10022        );
10023        assert!(
10024            lower_route_cache_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
10025            "a @cache on a non-GET handler must not construct a CacheIr"
10026        );
10027    }
10028
10029    #[test]
10030    fn lower_route_cache_ir_discards_an_otherwise_well_formed_scope_when_maxage_is_missing() {
10031        // `bynk.http.cache_bad_max_age` already rejects a maxAge-less @cache at
10032        // check time — this pins that `scope`'s own well-formedness does not
10033        // rescue a missing `maxAge` into a partial CacheIr.
10034        let ctx = parsed_only_context(
10035            r#"
10036context demo
10037
10038service Api from http {
10039  @cache(scope: public)
10040  on GET("/broken") () -> Effect[HttpResult[String]] by v: Visitor {
10041    Ok("x")
10042  }
10043}
10044"#,
10045        );
10046        assert!(
10047            lower_route_cache_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
10048            "a well-formed scope: must not survive a missing maxAge:"
10049        );
10050    }
10051
10052    #[test]
10053    fn lower_route_limit_ir_reads_maxbody_off_a_route_annotation() {
10054        let ctx = parsed_only_context(
10055            r#"
10056context demo
10057
10058service Api from http {
10059  @limit(maxBody: 26_214_400)
10060  on POST("/bulk") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
10061    Created(body)
10062  }
10063
10064  on POST("/upload") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
10065    Created(body)
10066  }
10067}
10068"#,
10069        );
10070        assert_eq!(
10071            lower_route_limit_ir(parsed_handler(&ctx, "Api", 0)),
10072            Some(26_214_400)
10073        );
10074        assert!(
10075            lower_route_limit_ir(parsed_handler(&ctx, "Api", 1)).is_none(),
10076            "no @limit annotation at all must yield None — the caller applies the \
10077             service-wide default, this function does not know it"
10078        );
10079    }
10080
10081    #[test]
10082    fn lower_route_limit_ir_returns_none_for_a_non_positive_maxbody() {
10083        // `bynk.http.limit_bad_max_body` already rejects a non-positive maxBody
10084        // at check time — this pins the lowering function's own independent
10085        // guard. `None` here matters specifically because the caller's own
10086        // fallback composition (`effective_max_body`) treats it as "no
10087        // route-level override," falling through to the service-wide default —
10088        // not as "an explicit zero-byte cap."
10089        let ctx = parsed_only_context(
10090            r#"
10091context demo
10092
10093service Api from http {
10094  @limit(maxBody: 0)
10095  on POST("/zero") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
10096    Created(body)
10097  }
10098}
10099"#,
10100        );
10101        assert!(
10102            lower_route_limit_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
10103            "a non-positive maxBody must not construct Some(0)"
10104        );
10105    }
10106}