Skip to main content

bynk_lower/
lib.rs

1//! `bynk-lower`: the AST-analysis helpers `bynk-emit` reads resolved
2//! declaration-level facts through — handler kinds and `given` clauses,
3//! service protocols, store-field shapes, capability op and attached-method
4//! signatures, route cache/limit annotations, event-subscriber shapes, and
5//! the store-write walk behind `emit_agent`'s implicit-commit decision. Each
6//! takes a checked program (or its `TypedCommons`) and a syntax-tree node
7//! and returns a `bynk_ir` value; none lowers an expression body.
8//!
9//! **Reachability, corrected twice and now settled (Slice D0 of `#1542`,
10//! the IR cutover, closing summary in `design/archive/retired-tracks.md`).** Through the crate carve (Arc D,
11//! P7.12) this paragraph claimed nothing here was reached from `bynk-emit`'s
12//! emission path; that was false — `lower_event_subscriber_shapes_ir`
13//! (called from `bynk-emit/src/project.rs`) went through
14//! `lower_service_item_ir`, which lowers every handler's own *body*, so the
15//! whole recursive expression-lowering machinery (`lower_service_handler_ir`
16//! → `lower_service_handler_body_ir` → `lower_block_ir` → `lower_expr_ir`/
17//! `lower_stmt_ir`) ran in production for every `from Events(E)` service and
18//! had its result discarded. Slice D0 repointed that one caller at the
19//! shape-only helpers it actually needs (see its own doc comment), so the
20//! original claim is now *true* by construction rather than false by
21//! oversight: **every item constructor and the expression/statement/body
22//! lowering beneath it — `lower_service_item_ir`, `lower_agent_item_ir`,
23//! `lower_provider_item_ir`, `lower_fn_item_ir`, `lower_type_item_ir`,
24//! `lower_capability_item_ir`, the handler/store-field/commit-shape/
25//! invariant/transition helpers, `lower_fn_body_ir`, `lower_block_ir`,
26//! `lower_expr_ir` and everything they call — has no caller outside this
27//! crate's own test module.** The 30 August 2026 review's "zero callers"
28//! finding was right about the end state and wrong about the path; the
29//! track doc's §10.2 has the trace. Slice D1 of the same track then deleted
30//! that machinery outright — 48 functions, the lowering context's scope
31//! stack/temp counter/return-type/store-queryable fields, and every
32//! `todo!()` this crate ever carried — so `rustc`'s own dead-code analysis,
33//! not this paragraph, is the reachability record from here on. What stays
34//! is the AST-analysis helper vocabulary `bynk-emit` consumes today —
35//! `lower_handler_kind_ir`, `lower_handler_given_ir`,
36//! `lower_protocol_ir{,_from_commons}`, `lower_type_shape_ir`,
37//! `lower_service_handler_signature_ir`, `body_writes_state`, and their
38//! siblings — each with a production call site.
39//!
40//! **Totality discipline (ADR 0334, Q2), as it stands after Slice D1:** the
41//! rule was "every entry point takes a certified `&CheckedProgram`, so a
42//! per-expression type lookup that misses is a compiler bug, not a
43//! recoverable state." The per-expression lookup went with the expression
44//! lowering, so what the rule now governs is narrower: a helper that reads
45//! a *declaration's* own type references still takes `&CheckedProgram` by
46//! default (`lower_type_shape_ir`'s doc comment has the full argument — a
47//! bare `TypedCommons` is not certified by construction), and the four that
48//! take a bare `&TypedCommons` instead — `body_writes_state`,
49//! `lower_protocol_ir_from_commons`, `capability_op_sig_from_commons`,
50//! `lower_attached_fn_sig_ir_from_types` — each document why their reads
51//! cannot reach a certification-dependent panic. The same discipline
52//! `bynk-emit/src/emitter/emit.rs`'s `lower_workers_cross_context_call`
53//! applies to its own `bynk.emit.unresolved_cross_context_signature` panic.
54
55use std::collections::{HashMap, HashSet};
56use std::sync::Arc;
57
58use bynk_check::checker::{self, Callee, CheckedProgram, Ty, TyId, TypedCommons, Types};
59use bynk_check::resolver::MethodTable;
60use bynk_syntax::ast::{
61    ActorDecl, Block, CapRef, CapabilityDecl, CapabilityOp, CommonsItem, EventPattern,
62    EventPatternValue, Expr, ExprKind, FnDecl, FnName, Handler, HandlerKind, HttpMethod,
63    LiteralValue, MatchBody, ProviderDecl, QualifiedName, ServiceProtocol, Statement, StoreField,
64    TypeBody, TypeDecl, TypeRef, expr_children,
65};
66
67use bynk_ir::{
68    ActorSeamIr, CacheIr, CapRefIr, ConstVal, EventPatternIr, EventPatternValueIr,
69    EventSubscriberShape, FnSig, IrHandlerKind, IrHttpMethod, OpSig, ProtocolIr, StoreFieldIr,
70    StoreKindIr, TypeShape,
71};
72
73/// The resolution context the AST-analysis helpers below share: the checked
74/// program's own `TypedCommons` (for type interning and declared-type
75/// lookups) plus the enclosing fn/method's own rigid type variables
76/// (`fn identity[T](x: T)`, and a generic type's own params on one of its
77/// methods), needed by `resolve_type_ref_in` the same way `Ctx::type_vars`
78/// is (`bynk-check/src/checker.rs`); `resolve_type_ref` (no `vars` set)
79/// would otherwise resolve a rigid `T` as an unknown declared type and
80/// silently fail.
81///
82/// Slice D1 of `#1542` (the IR cutover, `design/archive/retired-tracks.md`) slimmed
83/// this from the expression lowerer's full context — the lexical scope
84/// stack, the synthetic-temp counter, the enclosing return type and the
85/// agent handler's store-queryable field set all went with the body
86/// lowering that was their only reader. What remains is exactly what a
87/// signature/shape reader needs.
88pub(crate) struct LowerIrCtx<'a> {
89    program: &'a TypedCommons,
90    type_vars: HashSet<String>,
91}
92
93impl<'a> LowerIrCtx<'a> {
94    fn new(program: &'a CheckedProgram, type_vars: HashSet<String>) -> Self {
95        Self::from_commons(program.program(), type_vars)
96    }
97
98    /// #1187's own closing scoping pass: a `TypedCommons`-only constructor,
99    /// for the call paths (`lower_op_sig_ir_from_commons` and
100    /// `lower_attached_fn_sig_ir_from_types`, below) that never have a
101    /// `&CheckedProgram` to unwrap. See `lower_op_sig_ir_from_commons`'s
102    /// own doc comment for why that is sound.
103    fn from_commons(commons: &'a TypedCommons, type_vars: HashSet<String>) -> Self {
104        Self {
105            program: commons,
106            type_vars,
107        }
108    }
109
110    /// A type reference resolved in this pass's own rigid-variable scope —
111    /// the `resolve_type_ref_in` counterpart to `Ctx::resolve_type_ref_in`
112    /// call sites (e.g. `checker.rs:2816,2897`), not the bare
113    /// `resolve_type_ref` (which has no `vars` set and cannot resolve a
114    /// generic fn/method's own type parameters).
115    fn resolve_type_ref(&self, r: &bynk_syntax::ast::TypeRef) -> Option<TyId> {
116        checker::resolve_type_ref_in(
117            r,
118            &self.program.types,
119            &self.type_vars,
120            &self.program.ty_intern,
121        )
122    }
123
124    fn unit_ty(&self) -> TyId {
125        self.program.ty_intern.intern(Ty::Unit)
126    }
127}
128
129/// `(params, given, ret, effectful)` — [`lower_service_handler_signature_ir`]'s
130/// return shape (#1187's slice 5), a named alias rather than a bare tuple
131/// because its consumer (`emit_service`, `bynk-emit/src/emitter/emit.rs`)
132/// has to spell it out in a function signature. (The agent-handler
133/// signature reader that shared it went with Slice D1 of `#1542`.)
134pub type HandlerSignatureIr = (Vec<(String, TyId)>, Vec<String>, TyId, bool);
135
136/// P6.50 (design/tracks/the-ir.md §6b): a return type's own syntactic
137/// `Effect[...]` wrapper — `TypeRef::Effect(_, _)`, not the *resolved*
138/// `Ty::Effect(_)` shape `lower_handler_signature_ir` reads above via
139/// `cx.program.ty_intern`. Relocated here from `emitter/emit.rs` (its
140/// original home, `#[allow(dead_code)]`-free and with eight call sites
141/// across `emit.rs`/`workers.rs`/`workers_entry.rs`) because
142/// [`lower_service_handler_signature_ir`] below was already calling *up*
143/// into it (`bynk_emit::emitter::is_effectful_return`) — the `Ast → Ir` boundary
144/// running backwards, an `Ir`-side lowering function reaching into the
145/// `emitter` module it should only ever be called *from*. `emit.rs` and
146/// friends now call `bynk_lower::is_effectful_return` instead (relocated
147/// again at the P7.12 crate carve — `emitter`/`ir::lower` are now separate
148/// crates, `bynk-emit`/`bynk-lower` respectively).
149pub fn is_effectful_return(r: &TypeRef) -> bool {
150    matches!(r, TypeRef::Effect(_, _))
151}
152
153/// A service handler's resolved *signature* — `params`/`given`/`ret`/
154/// `effectful` — and never its body. This is what `emit_service`
155/// (`bynk-emit/src/emitter/emit.rs`) reads per handler, and what
156/// `lower_event_subscriber_shapes_ir` reads for a subscriber's parameter
157/// count. Mirrors [`body_writes_state`]'s posture (#1196): a narrow,
158/// standalone reader of already-resolved data, applied to signature data
159/// instead of a body walk.
160///
161/// A service handler's own param type is *not* resolution-checked by the
162/// checker at all (`1199_service_handler_unresolvable_param_type_no_ice`
163/// pins it), so a resolve miss degrades to `Ty::Unit` here rather than
164/// panicking — the same fallback `lower_protocol_ir` documents for a
165/// WebSocket frame type, and for the same reason: an ADR 0334 panic may
166/// only assert a guarantee the checker actually gives. (The agent-handler
167/// signature reader that did panic on a miss was correct for *its* input,
168/// which the checker does guarantee resolves; it went with the body
169/// lowering in Slice D1 of `#1542`.)
170pub fn lower_service_handler_signature_ir(
171    h: &Handler,
172    program: &CheckedProgram,
173) -> HandlerSignatureIr {
174    let cx = LowerIrCtx::new(program, HashSet::new());
175    let params: Vec<(String, TyId)> = h
176        .params
177        .iter()
178        .map(|p| {
179            let ty = cx
180                .resolve_type_ref(&p.type_ref)
181                .unwrap_or_else(|| cx.unit_ty());
182            (p.name.name.clone(), ty)
183        })
184        .collect();
185    let given: Vec<String> = h.given.iter().map(|c| c.key().to_string()).collect();
186    let ret = cx
187        .resolve_type_ref(&h.return_type)
188        .unwrap_or_else(|| cx.unit_ty());
189    let effectful = is_effectful_return(&h.return_type);
190    (params, given, ret, effectful)
191}
192
193/// A `type` declaration's resolved structure as a [`TypeShape`] — the
194/// reader `emitter.rs`'s own `type_shape_for` calls directly. (Slice 1 of
195/// `#1542` split this out of a full `IrItem::Type` constructor whose single
196/// field it was, ending a build-then-`unreachable!`-discard round-trip;
197/// Slice D1 then deleted that constructor, leaving this as the type
198/// reader.)
199///
200/// Takes a certified `&CheckedProgram`, matching this module's own
201/// categorical discipline (this file's own header doc: "every entry point
202/// here takes a `&CheckedProgram`"), even though only
203/// `TypedCommons::types`/`ty_intern` are read — no per-expression
204/// `expr_types` lookup is involved (Q2, `design/tracks/the-ir.md` §3.2), but
205/// *which fields are read* isn't the discipline; *which failures are allowed
206/// to `panic!`* is. Every panic below asserts "the checker already accepted
207/// this declaration" — true only once `certify` has run: a bare
208/// `TypedCommons` is not certified by construction (`checker.rs`'s own
209/// `CheckedProgram` doc notes the project/batch path holds per-unit
210/// `TypedCommons` values *before* that unit's build-wide gate is decided),
211/// so accepting one here would make `resolve_type_ref_in` returning `None` a
212/// reachable, not just a buggy, outcome.
213pub fn lower_type_shape_ir(decl: &Arc<TypeDecl>, program: &CheckedProgram) -> TypeShape {
214    let program = program.program();
215    let type_vars: HashSet<String> = decl
216        .type_params
217        .iter()
218        .map(|tp| tp.name.name.clone())
219        .collect();
220    let resolve = |r: &bynk_syntax::ast::TypeRef| {
221        checker::resolve_type_ref_in(r, &program.types, &type_vars, &program.ty_intern)
222    };
223    match &decl.body {
224        TypeBody::Record(r) => TypeShape::Record {
225            fields: r
226                .fields
227                .iter()
228                .map(|f| {
229                    let ty = resolve(&f.type_ref).unwrap_or_else(|| {
230                        panic!(
231                            "bynk internal error (ADR 0334): field `{}` of type `{}` does not \
232                             resolve, but the checker already accepted this declaration",
233                            f.name.name, decl.name.name
234                        )
235                    });
236                    (f.name.name.clone(), ty)
237                })
238                .collect(),
239        },
240        TypeBody::Sum(s) => TypeShape::Sum {
241            variants: s
242                .variants
243                .iter()
244                .map(|v| {
245                    let payload = v
246                        .payload
247                        .iter()
248                        .map(|vf| {
249                            let ty = resolve(&vf.type_ref).unwrap_or_else(|| {
250                                panic!(
251                                    "bynk internal error (ADR 0334): field `{}` of variant `{}` \
252                                     of type `{}` does not resolve, but the checker already \
253                                     accepted this declaration",
254                                    vf.name.name, v.name.name, decl.name.name
255                                )
256                            });
257                            (vf.name.name.clone(), ty)
258                        })
259                        .collect();
260                    (v.name.name.clone(), payload)
261                })
262                .collect(),
263            embeds: s
264                .embeds
265                .iter()
266                .map(|e| {
267                    let source = resolve(&e.source_type).unwrap_or_else(|| {
268                        panic!(
269                            "bynk internal error (ADR 0334): `embeds` clause source type on \
270                             variant `{}` of type `{}` does not resolve, but the checker \
271                             already accepted this declaration",
272                            e.variant.name, decl.name.name
273                        )
274                    });
275                    (source, e.variant.name.clone())
276                })
277                .collect(),
278        },
279        TypeBody::Refined {
280            base, refinement, ..
281        } => TypeShape::Refined {
282            base: *base,
283            refinement: refinement.clone(),
284            opaque: false,
285        },
286        TypeBody::Opaque {
287            base, refinement, ..
288        } => TypeShape::Refined {
289            base: *base,
290            refinement: refinement.clone(),
291            opaque: true,
292        },
293    }
294}
295
296/// A store field's own element/key/value type, resolved with no rigid type
297/// variables in scope — `AgentDecl` carries no `type_params` of its own
298/// (its own struct shape, `bynk-syntax/src/ast.rs:908-934`), so
299/// [`lower_store_field_ir`] never needs the `fn_rigid_type_vars`-shaped
300/// seeding every fn/method-level constructor here does. Shared by every
301/// arm of that function's own kind dispatch, and by [`lower_store_field_shape_ir`].
302///
303/// **Not an ADR 0334 `.expect()`-style panic on a resolve miss, deliberately**
304/// — the same posture `lower_op_sig_ir`'s own doc comment already argues
305/// for a capability op's `params`/`return_type`, confirmed empirically for
306/// store fields specifically (#1187's Agent state-field slice, step 0):
307/// `store x: Cell[Bogus] = "hello"` certified then (exit 0, no diagnostic),
308/// so a `Bogus` store-field type reached this pass with no `expr_types`/
309/// `types` entry at all. Since #1679 the resolver rejects an unknown store
310/// field type (`bynk.resolve.unknown_type`), so this fallback is defensive:
311/// it keeps lowering total rather than asserting a guarantee it does not
312/// itself check.
313fn resolve_store_field_ty(cx: &LowerIrCtx, r: &bynk_syntax::ast::TypeRef) -> TyId {
314    cx.resolve_type_ref(r).unwrap_or_else(|| cx.unit_ty())
315}
316
317/// A `@name(<duration literal>)` annotation's own millisecond value — the
318/// shared "find the annotation, read its first argument's `DurationLit`"
319/// step both `@ttl` (`Cache`) and `@retain` (`Log`) need, factored out so
320/// the two [`lower_store_field_ir`] arms are one call each rather than two
321/// near-identical inline `find`/`and_then` chains. Mirrors, but does not
322/// call, `cache_ttl_millis` (`bynk-check/src/context_checks.rs`) and the
323/// shipped emitter's own equivalent extraction (`emit.rs`'s
324/// `store_cache_fields`/`store_log_fields`) — those thread a
325/// `&mut Vec<CompileError>` for a missing-`@ttl` diagnostic and are private
326/// to their own module, so reusing them directly here is not architecturally
327/// available; see [`lower_store_field_ir`]'s own doc comment for why this is
328/// an accepted, named duplication rather than a gap this slice closes.
329fn duration_millis_annotation(
330    annotations: &[bynk_syntax::ast::Annotation],
331    name: &str,
332) -> Option<i64> {
333    annotations
334        .iter()
335        .find(|a| a.name.name == name)
336        .and_then(|a| match a.args.first().map(|arg| &arg.value.kind) {
337            Some(ExprKind::DurationLit { millis, .. }) => Some(*millis),
338            _ => None,
339        })
340}
341
342/// The shape half of [`lower_store_field_ir`] — its `kind`/`indexed`
343/// computation, factored out so [`lower_store_field_shape_ir`] can share it
344/// without either duplicating the `Cell`/`Map`/`Set`/`Cache`/`Log` dispatch
345/// or paying for a `&mut LowerIrCtx` it never needs (nothing here lowers an
346/// expression).
347fn store_field_kind_and_indexed(f: &StoreField, cx: &LowerIrCtx) -> (StoreKindIr, Vec<String>) {
348    let head = f.kind.head.name.as_str();
349    let kind = match head {
350        "Cell" => StoreKindIr::Cell(resolve_store_field_ty(cx, &f.kind.args[0])),
351        "Map" => StoreKindIr::Map(
352            resolve_store_field_ty(cx, &f.kind.args[0]),
353            resolve_store_field_ty(cx, &f.kind.args[1]),
354        ),
355        "Set" => StoreKindIr::Set(resolve_store_field_ty(cx, &f.kind.args[0])),
356        "Cache" => {
357            let k = resolve_store_field_ty(cx, &f.kind.args[0]);
358            let v = resolve_store_field_ty(cx, &f.kind.args[1]);
359            let ttl = duration_millis_annotation(&f.annotations, "ttl").unwrap_or_else(|| {
360                panic!(
361                    "bynk internal error (ADR 0334): `Cache` field `{}` has no resolvable \
362                     `@ttl` millis, but the checker already accepted this declaration — \
363                     bynk.store.cache_ttl_required gates a missing or malformed `@ttl` \
364                     before certify",
365                    f.name.name
366                )
367            });
368            StoreKindIr::Cache(k, v, ttl)
369        }
370        "Log" => {
371            let elem = resolve_store_field_ty(cx, &f.kind.args[0]);
372            let retain = duration_millis_annotation(&f.annotations, "retain");
373            StoreKindIr::Log(elem, retain)
374        }
375        other => panic!(
376            "bynk internal error (ADR 0334): store field `{}` has storage kind `{other}`, which \
377             cannot reach a certified program — only Cell/Map/Set/Cache/Log are functional \
378             (Queue is gated by bynk.store.kind_unsupported before certify)",
379            f.name.name
380        ),
381    };
382    // [DECISION C]/[DECISION E]: one entry per distinct `by:` argument,
383    // declaration order, no sort — legal only on `Map` (`ANNOTATIONS`'s own
384    // registry), so this is empty by construction for every other kind.
385    // Deduplicated: `validate_indexed_keys` (`context_checks.rs`) validates
386    // each `by:` argument independently with no duplicate check, so
387    // `@indexed(by: k, by: k)` certifies — mirrors the shipped emitter's own
388    // `store_map_indexes` guard (`emit.rs`'s `!fields.contains(&k.name)`),
389    // grounded during P6.7's own review (#1163): dropping this would mean a
390    // duplicate `by:` produces the same sibling index table twice.
391    let mut indexed: Vec<String> = Vec::new();
392    for arg in f
393        .annotations
394        .iter()
395        .filter(|a| a.name.name == "indexed")
396        .flat_map(|a| &a.args)
397    {
398        if let (Some(l), ExprKind::Ident(k)) = (&arg.label, &arg.value.kind)
399            && l.name == "by"
400            && !indexed.contains(&k.name)
401        {
402            indexed.push(k.name.clone());
403        }
404    }
405    (kind, indexed)
406}
407
408/// A store field's storage *shape* — its `Cell`/`Map`/`Set`/`Cache`/`Log`
409/// kind and `@indexed` keys (via `store_field_kind_and_indexed`), with
410/// `init` always `None`. This is the entry point `emit_agent`'s own state
411/// section actually needs; a `Cell` field's zero/initial-value expression is
412/// rendered by the emitter from the AST, never lowered here. (An
413/// `init`-lowering sibling existed until Slice D1 of `#1542`; it lowered the
414/// initialiser through the deleted expression lowerer and had no caller.)
415///
416/// The field's own type reference falls back to `Ty::Unit` on a resolve
417/// miss rather than panicking — no checker pass validates a store field's
418/// type reference, only its shape and annotation legality, so `store x:
419/// Cell[Bogus]` certifies today and this reader must not turn that into an
420/// ICE (`store_field_falls_back_to_unit_on_an_unresolvable_type_like_the_checker_does`
421/// pins it).
422pub fn lower_store_field_shape_ir(f: &StoreField, program: &CheckedProgram) -> StoreFieldIr {
423    let cx = LowerIrCtx::new(program, HashSet::new());
424    let (kind, indexed) = store_field_kind_and_indexed(f, &cx);
425    StoreFieldIr {
426        field: f.name.name.clone(),
427        kind,
428        indexed,
429    }
430}
431
432/// Decision C (#1165): the closed sets of mutating storage-op names, one
433/// constant per kind group, read only by [`body_writes_state`]'s
434/// `Callee::Store`-keyed write-detection walk (P6.8, Decision B), which
435/// needs no receiver-name gate at all: a `Callee::Store` already carries the
436/// field's own resolved identity, not a name that could be shadowed. These
437/// lived in `bynk-ir` as `pub` tables from the P7.12 crate carve until Slice
438/// D3 of #1542, on the theory that a `bynk-emit`-side reader might want them
439/// again; none ever did, and the `unconsumed_ir_items` probe that slice
440/// added counts a `pub` item read only from this crate as unconsumed — so
441/// they now live beside their one reader, private. `Map`/`Cache` share one
442/// list — both support the same four entry ops — rather than two identical
443/// ones.
444const MUTATING_MAP_CACHE_OPS: &[&str] = &["put", "remove", "update", "upsert"];
445/// v0.83: `<set>.add`/`<set>.remove` mutate a `store Set[T]` field.
446const MUTATING_SET_OPS: &[&str] = &["add", "remove"];
447/// v0.95: `<log>.append` mutates the durable array (ADR 0121) — every other
448/// `Log` method is a query-lifting read.
449const MUTATING_LOG_OPS: &[&str] = &["append"];
450/// v0.98 (ADR 0125): `<cell>.update(f)` is a read-modify-write of the
451/// working state — the bare `:=` write form is `Statement::Assign`, checked
452/// separately and unconditionally, no method name involved.
453const MUTATING_CELL_OPS: &[&str] = &["update"];
454
455/// [DECISION B]/[DECISION C] (#1165): does `body` reach a mutating
456/// `Callee::Store` write, or an unconditional `Statement::Assign` (`:=`),
457/// anywhere — including inside a nested `if`/`match`/lambda? Drives, as of
458/// #1196 (the #1187 emitter-cutover track's own R6.5 stake), `emit_agent`'s
459/// (`bynk-emit/src/emitter/emit.rs`) real implicit-commit-wrapper decision
460/// (it also drove the IR-side `CommitShape::Transactional` decision until
461/// Slice D1 of `#1542` deleted that constructor) — its previous own
462/// name-matching `block_writes_state`
463/// (`emitter.rs`) is deleted, this function is its sole, direct
464/// replacement. The walk's own shape is that deleted function's own
465/// already-correct skeleton reused structurally, not re-derived:
466/// `Block`/`If`/`Match` are hand-matched so crossing a nested block
467/// re-enters the statement-aware case (an `expr_children` descent alone
468/// flattens a block straight to its statements' *values*, losing the
469/// `Statement::Assign` tag), everywhere else recurses over
470/// `expr_children`'s total child iterator.
471///
472/// Unlike the deleted function's own name-based `mutating_op`, this walk
473/// needs no per-kind receiver-name set: a `Callee::Store { op, .. }` already
474/// carries the field's own resolved identity (the checker only ever records
475/// one for a method the field's own kind actually declares), so `op`'s
476/// membership in the shared mutating-verb constants ([DECISION C],
477/// `emitter.rs`) is unambiguous checked flat, across all four kinds' lists
478/// at once — a locally-shadowed name that would false-positive
479/// `mutating_op` cannot false-positive here at all, the exact fix Decision
480/// B's own Risk names, and the exact defect `#1196_agent_write_detection_
481/// via_resolved_callee`'s own fixture pins at the emitted-output level.
482pub fn body_writes_state(body: &Block, program: &TypedCommons) -> bool {
483    fn is_mutating_store_write(e: &Expr, program: &TypedCommons) -> bool {
484        match program.callees.get(&e.id) {
485            Some(Callee::Store { op, .. }) => {
486                MUTATING_MAP_CACHE_OPS.contains(&op.as_str())
487                    || MUTATING_SET_OPS.contains(&op.as_str())
488                    || MUTATING_LOG_OPS.contains(&op.as_str())
489                    || MUTATING_CELL_OPS.contains(&op.as_str())
490            }
491            _ => false,
492        }
493    }
494    fn stmt(s: &Statement, program: &TypedCommons) -> bool {
495        match s {
496            Statement::Assign(_) => true,
497            Statement::Let(l) | Statement::EffectLet(l) => expr(&l.value, program),
498            Statement::Expect(a) => expr(&a.value, program),
499            Statement::Send(s) => expr(&s.value, program),
500            Statement::Do(d) => expr(&d.value, program),
501        }
502    }
503    fn expr(e: &Expr, program: &TypedCommons) -> bool {
504        if is_mutating_store_write(e, program) {
505            return true;
506        }
507        match &e.kind {
508            ExprKind::Block(b) => body_writes_state(b, program),
509            ExprKind::If {
510                cond,
511                then_block,
512                else_block,
513            } => {
514                expr(cond, program)
515                    || body_writes_state(then_block, program)
516                    || body_writes_state(else_block, program)
517            }
518            // #1800: a guard is evaluated like any other expression, so a
519            // write in one (`Some(x) if if x > 0 { hits := x; true } …`) runs
520            // and must be committed. Skipping it dropped the commit wrapper.
521            ExprKind::Match { discriminant, arms } => {
522                expr(discriminant, program)
523                    || arms.iter().any(|a| {
524                        a.guard.as_ref().is_some_and(|g| expr(g, program))
525                            || match &a.body {
526                                MatchBody::Expr(e) => expr(e, program),
527                                MatchBody::Block(b) => body_writes_state(b, program),
528                            }
529                    })
530            }
531            _ => expr_children(e).into_iter().any(|c| expr(c, program)),
532        }
533    }
534    body.statements.iter().any(|s| stmt(s, program)) || expr(&body.tail, program)
535}
536
537/// P6.11 ([DECISION C], #1171): reshape a `from Events(E { … })` pattern
538/// into a real [`EventPatternIr`] — pure structural reshaping, no
539/// `&CheckedProgram` parameter and no resolution: every field's own
540/// matched value is either already a literal or an unresolved variant tag,
541/// neither needing a `TyId`. `EventPatternValue::Literal` lowers through
542/// the same `LiteralValue -> ConstVal` match [`lower_pattern_ir`] already
543/// uses for `Pattern::Literal`'s identical closed set.
544fn lower_event_pattern_ir(pattern: &EventPattern) -> EventPatternIr {
545    EventPatternIr {
546        fields: pattern
547            .fields
548            .iter()
549            .map(|f| {
550                let value = match &f.value {
551                    EventPatternValue::Literal { value, .. } => {
552                        EventPatternValueIr::Const(match value {
553                            LiteralValue::Int(n) => ConstVal::Int(*n),
554                            LiteralValue::Str(s) => ConstVal::Str(s.clone()),
555                            LiteralValue::Bool(b) => ConstVal::Bool(*b),
556                        })
557                    }
558                    EventPatternValue::Variant { variant, .. } => EventPatternValueIr::Variant {
559                        tag: variant.name.clone(),
560                    },
561                };
562                (f.name.name.clone(), value)
563            })
564            .collect(),
565    }
566}
567
568/// P6.11 ([DECISION A], #1171): lower a service's own `from <protocol>`
569/// header into a real [`ProtocolIr`] — standalone, takes the sub-node
570/// rather than the owning `ServiceDecl` (mirrors
571/// [`lower_store_field_shape_ir`]), so a `from websocket`/`from Events`
572/// fixture can pin the descriptor by itself.
573///
574/// `WebSocket`/`Events`'s own type refs resolve through the `Ty::Unit`
575/// fallback, not an ADR 0334 panic — deliberately: `resolver.rs` skips
576/// `CommonsItem::Service` in every one of its own type-ref-resolution
577/// passes (`resolver.rs:303-304`/`493-494`/`577-578`), and the one checker
578/// site that does resolve a WebSocket frame type itself falls back to
579/// `Ty::Unit` on a miss rather than erroring (`context_checks.rs:775-778`).
580/// Panicking here would make this an ADR-0334 site asserting a guarantee
581/// the checker doesn't actually give (the agent `key_ty` site that once did,
582/// review of #1169, went with the item assembly in Slice D1 of `#1542`).
583pub fn lower_protocol_ir(protocol: &ServiceProtocol, program: &CheckedProgram) -> ProtocolIr {
584    lower_protocol_ir_from_commons(protocol, program.program())
585}
586
587/// P6.24a: a `TypedCommons`-only sibling of [`lower_protocol_ir`], the same
588/// split `lower_op_sig_ir`/`lower_op_sig_ir_from_commons` already
589/// established — for a call site holding only a unit's own `TypedCommons`,
590/// never a `&CheckedProgram` (`emitter.rs`'s `emit_project_imports`, a
591/// header-import-collection pass that runs well outside the per-declaration
592/// emission loop any `CheckedProgram` is threaded through). Sound for the
593/// identical reason: nothing here reads a per-expression type, the one
594/// lookup whose `.expect()`-panic needed a genuinely certified program.
595pub fn lower_protocol_ir_from_commons(
596    protocol: &ServiceProtocol,
597    commons: &TypedCommons,
598) -> ProtocolIr {
599    let cx = LowerIrCtx::from_commons(commons, HashSet::new());
600    match protocol {
601        ServiceProtocol::Call => ProtocolIr::Call,
602        ServiceProtocol::Http => ProtocolIr::Http,
603        ServiceProtocol::Cron => ProtocolIr::Cron,
604        ServiceProtocol::Queue { name } => ProtocolIr::Queue { name: name.clone() },
605        ServiceProtocol::WebSocket { in_type, out_type } => ProtocolIr::WebSocket {
606            in_ty: cx.resolve_type_ref(in_type).unwrap_or_else(|| cx.unit_ty()),
607            out_ty: cx
608                .resolve_type_ref(out_type)
609                .unwrap_or_else(|| cx.unit_ty()),
610        },
611        ServiceProtocol::Events {
612            event_type,
613            pattern,
614            schema_dispatch,
615        } => ProtocolIr::Events {
616            event: cx
617                .resolve_type_ref(event_type)
618                .unwrap_or_else(|| cx.unit_ty()),
619            pattern: pattern.as_ref().map(lower_event_pattern_ir),
620            schema_dispatch: schema_dispatch.as_ref().map(|d| {
621                let bynk_syntax::ast::SchemaVersionPattern::Literal(version) = d.pattern;
622                version
623            }),
624        },
625    }
626}
627
628/// #1228: a GET handler's own `@cache(maxAge:, scope:)` freshness policy —
629/// [`bynk_ir::CacheIr`]'s own doc comment has the full grounding for why
630/// this is a standalone per-route reader. Field-for-
631/// field the same extraction `emitter/workers_entry.rs`'s own (now
632/// superseded) `cache_policy_for` did: only a `GET` yields a policy;
633/// project validation (`bynk.http.cache_*`) has already rejected a
634/// `@cache` anywhere else, and a malformed `maxAge` there, so a missing or
635/// ill-formed annotation here simply yields `None` — no `&CheckedProgram`
636/// needed, the same posture `lower_policy_ir`'s own doc comment already
637/// argues for: `maxAge`/`scope` are already-resolved syntactic literals
638/// (`ExprKind::DurationLit`/`Ident`), not a type this pass would ever need
639/// to resolve.
640pub fn lower_route_cache_ir(h: &Handler) -> Option<CacheIr> {
641    if !matches!(
642        h.kind,
643        HandlerKind::Http {
644            method: HttpMethod::Get,
645            ..
646        }
647    ) {
648        return None;
649    }
650    let ann = h.annotations.iter().find(|a| a.name.name == "cache")?;
651    let mut max_age_millis: Option<i64> = None;
652    let mut scope = "private";
653    for arg in &ann.args {
654        match arg.label.as_ref().map(|l| l.name.as_str()) {
655            Some("maxAge") => {
656                if let ExprKind::DurationLit { millis, .. } = &arg.value.kind {
657                    max_age_millis = Some(*millis);
658                }
659            }
660            Some("scope") => {
661                if let ExprKind::Ident(id) = &arg.value.kind
662                    && id.name == "public"
663                {
664                    scope = "public";
665                }
666            }
667            _ => {}
668        }
669    }
670    Some(CacheIr {
671        max_age_secs: max_age_millis? / 1000,
672        scope,
673    })
674}
675
676/// #1228: a route's own `@limit(maxBody:)` annotation, if present — the
677/// override half of `emitter/workers_entry.rs`'s own (now superseded)
678/// `effective_max_body`; the service-wide `limits { maxBody }` fallback
679/// stays that function's own concern (read from a *service*'s `limits {}`
680/// block, not a per-route `Handler`, so it does not move here). Project validation
681/// (`bynk.http.limit_*`/`limits_*`) has already rejected a malformed or
682/// misplaced `@limit`, so an absent/ill-formed annotation here simply
683/// yields `None` — the caller's own service-default fallback still
684/// applies. No `&CheckedProgram` needed, same reasoning as
685/// [`lower_route_cache_ir`]: `maxBody` is an already-resolved
686/// `ExprKind::IntLit`, not a type.
687pub fn lower_route_limit_ir(h: &Handler) -> Option<i64> {
688    let ann = h.annotations.iter().find(|a| a.name.name == "limit")?;
689    for arg in &ann.args {
690        if arg.label.as_ref().map(|l| l.name.as_str()) == Some("maxBody")
691            && let ExprKind::IntLit { value: n, .. } = &arg.value.kind
692            && *n > 0
693        {
694            return Some(*n);
695        }
696    }
697    None
698}
699
700/// Every `from Events(E)` service in `program`'s own unit, captured as an
701/// [`bynk_ir::EventSubscriberShape`] keyed by service name — see that
702/// struct's own doc comment for why this is captured now rather than
703/// re-derived cross-unit at compose time (P6.47, `#1254`).
704///
705/// Slice D0 of `#1542` (the IR cutover, `design/archive/retired-tracks.md`; `#1574`):
706/// reads the two facts it returns from the shape-only helpers that own them —
707/// [`lower_protocol_ir`] for `schema_dispatch`, and [`lower_handler_kind_ir`]
708/// plus [`lower_service_handler_signature_ir`] for the `Event` handler's
709/// parameter count. Before D0 this function went through
710/// `lower_service_item_ir`, which lowers every handler's *body* to `IrExpr`
711/// through the expression lowerer and then discards it — the one production
712/// route into that lowerer, and the detour §10.2 of the track doc names (the
713/// body lowering's own `unreachable!()` safety argument covered
714/// `lower_fn_body_ir`'s callers only, never this path). The values are
715/// identical by construction: `lower_service_handler_ir` itself took its
716/// `params` from [`lower_service_handler_signature_ir`] and its `kind` from
717/// [`lower_handler_kind_ir`], and `lower_service_item_ir` its `protocol` from
718/// [`lower_protocol_ir`] — this function now calls those three directly and
719/// skips the body.
720///
721/// The `ServiceProtocol::Events` pre-filter stays first: a cheap, structural
722/// "which services even have a shape to capture" check (the same match
723/// [`lower_protocol_ir`] performs), not a raw-AST *read* of anything the IR
724/// side owns.
725pub fn lower_event_subscriber_shapes_ir(
726    program: &CheckedProgram,
727) -> HashMap<String, EventSubscriberShape> {
728    let mut out = HashMap::new();
729    for item in &program.program().commons.items {
730        if let CommonsItem::Service(s) = item
731            && matches!(&s.protocol, ServiceProtocol::Events { .. })
732        {
733            let ProtocolIr::Events {
734                schema_dispatch, ..
735            } = lower_protocol_ir(&s.protocol, program)
736            else {
737                panic!(
738                    "bynk internal error: lower_protocol_ir did not return \
739                     ProtocolIr::Events for a service whose own AST protocol is \
740                     ServiceProtocol::Events"
741                )
742            };
743            let two_param_handler = s
744                .handlers
745                .iter()
746                .find(|h| matches!(lower_handler_kind_ir(&h.kind), IrHandlerKind::Event))
747                .is_some_and(|h| lower_service_handler_signature_ir(h, program).0.len() == 2);
748            out.insert(
749                s.name.name.clone(),
750                EventSubscriberShape {
751                    two_param_handler,
752                    schema_dispatch: schema_dispatch.is_some(),
753                },
754            );
755        }
756    }
757    out
758}
759
760/// A capability declaration's resolved op signatures, in declaration order
761/// — what `emitter.rs`'s own capability-item loop reads (the caller already
762/// holds the capability's name from the AST declaration). Slice 1 of
763/// `#1542` split this out of a full `IrItem::Capability` constructor to end
764/// a build-then-discard-`def` round-trip; Slice D1 deleted that constructor.
765pub fn lower_capability_ops_ir(cap: &CapabilityDecl, program: &CheckedProgram) -> Vec<OpSig> {
766    cap.ops
767        .iter()
768        .map(|op| lower_op_sig_ir(op, program))
769        .collect()
770}
771
772/// P6.29 (design/tracks/the-ir.md §6a): the `TypedCommons`-only counterpart to
773/// [`lower_capability_ops_ir`], for call sites (`emitter/lower.rs`'s
774/// `cap_op_param_names`) that have a `TypedCommons` in hand but no
775/// `CheckedProgram` — `LowerCtx`/`ModuleCtx` never carry one (see
776/// `lower_op_sig_ir_from_commons`, this function's own single-op sibling,
777/// for the identical reason it exists as a separate entry point rather than a
778/// thin wrapper over the `CheckedProgram`-driven `lower_op_sig_ir`).
779///
780/// Resolves one capability operation's signature by name — "find the op
781/// named `op` on the capability named `cap`" has no IR-native replacement
782/// (nothing indexes capabilities by name once lowered), so this still walks
783/// `TypedCommons::commons.items` the same way the code it replaces did.
784/// First match in item order; `None` on no match, mirroring the caller's own
785/// prior fallthrough-to-empty behaviour exactly.
786pub fn capability_op_sig_from_commons(
787    commons: &TypedCommons,
788    cap: &str,
789    op: &str,
790) -> Option<OpSig> {
791    commons.commons.items.iter().find_map(|item| {
792        let CommonsItem::Capability(c) = item else {
793            return None;
794        };
795        if c.name.name != cap {
796            return None;
797        }
798        c.ops
799            .iter()
800            .find(|o| o.name.name == op)
801            .map(|o| lower_op_sig_ir_from_commons(o, commons))
802    })
803}
804
805/// P6.12 (#1173): lower one capability operation's own signature into a real
806/// [`bynk_ir::OpSig`] — the reference's own capability-item sketch
807/// names this type (`ops: Vec<OpSig>`) but never defines it. Resolves
808/// `params`/`return_ty` in the scope `op.type_params` names, the same
809/// per-op rigid-variable seeding `context_checks::build_capability_op_info`
810/// (`bynk-check/src/context_checks.rs`) already gives a generic op for the
811/// checker-facing `CapabilityOpInfo` — an op's own `[T, …]` list is scoped to
812/// the op itself, not the capability (`CapabilityDecl` carries no
813/// `type_params` of its own), so this seeds a fresh [`LowerIrCtx`] per op
814/// rather than reusing `fn_rigid_type_vars`'s fn/method-shaped
815/// receiver-widening, which does not apply here.
816///
817/// **Not an ADR 0334 `.expect()`-style panic on a resolve miss,
818/// deliberately** — the same posture [`lower_agent_item_ir`]'s own `key_ty`
819/// doc comment already argues for `agent.key_type`, and for the identical
820/// reason: a capability op's own `params`/`return_type` are never actually
821/// resolution-checked by the checker at all. The resolver skips
822/// `CommonsItem::Capability` outright (`resolver.rs:301/491/575`, "v0.5
823/// items are resolved via a separate context-level pass"); the context-level
824/// pass that replaces it, `check_capability_decls`, only calls
825/// `checker::record_type_refs`, which silently does nothing on a name absent
826/// from `types` rather than erroring (`checker.rs:2593-2597`); and
827/// `build_capability_op_info` itself, the checker-facing constructor this
828/// pass mirrors, degrades to `Ty::Unit` on the same miss rather than
829/// treating it as impossible (`context_checks.rs:36,40`). `capability Store
830/// { fn get(k: Bogus) -> Effect[Int] }` certifies today, silently. Panicking
831/// here on a state the checker itself accepts would make this the first ADR
832/// 0334 site in this module to assert a guarantee that does not actually
833/// hold — mirror the checker's own fallback instead (review of #1182).
834fn lower_op_sig_ir(op: &CapabilityOp, program: &CheckedProgram) -> OpSig {
835    lower_op_sig_ir_from_commons(op, program.program())
836}
837
838/// #1187's own closing scoping pass: a `TypedCommons`-only sibling of
839/// `lower_op_sig_ir`, for the one real call site that never has a
840/// `&CheckedProgram` — `emitter/lower.rs`'s `cap_op_param_names`, feeding
841/// `trace(Cap.op)`/`with`-predicate observation lowering
842/// (`bynk.test`'s DSL). That call path's own `TypedCommons` is a synthetic,
843/// hand-assembled project-wide view (`project/tests_emit.rs`'s
844/// `synthetic_typed_commons_for_target`, merging every consumed unit's own
845/// `capability` declarations into one scratch commons for lookup) — never
846/// itself the output of `certify`, so wrapping it as a `CheckedProgram`
847/// here would misrepresent an uncertified value as certified
848/// (`CheckedProgram`'s own doc comment, `bynk-check/src/checker.rs`, warns
849/// against exactly this). Splitting this out is sound precisely because
850/// this function never reads a per-expression type — the one lookup whose
851/// `.expect()`-panic needed a genuinely certified program, and the reason
852/// this module's own file-level doc comment gives for taking
853/// `&CheckedProgram` by default elsewhere. `resolve_type_ref`/`unit_ty()` (below) both degrade via
854/// `.unwrap_or_else` and read nothing `TypedCommons` doesn't already expose
855/// directly.
856fn lower_op_sig_ir_from_commons(op: &CapabilityOp, commons: &TypedCommons) -> OpSig {
857    let type_vars: HashSet<String> = op
858        .type_params
859        .iter()
860        .map(|tp| tp.name.name.clone())
861        .collect();
862    let cx = LowerIrCtx::from_commons(commons, type_vars);
863    let params: Vec<(String, TyId)> = op
864        .params
865        .iter()
866        .map(|p| {
867            let ty = cx
868                .resolve_type_ref(&p.type_ref)
869                .unwrap_or_else(|| cx.unit_ty());
870            (p.name.name.clone(), ty)
871        })
872        .collect();
873    let return_ty = cx
874        .resolve_type_ref(&op.return_type)
875        .unwrap_or_else(|| cx.unit_ty());
876    OpSig {
877        name: op.name.name.clone(),
878        type_params: op
879            .type_params
880            .iter()
881            .map(|tp| tp.name.name.clone())
882            .collect(),
883        params,
884        return_ty,
885    }
886}
887
888/// P6.18: [`bynk_ir::FnSig`]'s own constructor — a `fn`'s own resolved
889/// signature, for a call site holding only that fn's *declaring* unit's own
890/// combined types (`bynk_check::symbols::combined_types_for`'s return shape),
891/// never a `CheckedProgram`. The one real call site
892/// (`bynk-emit/src/project.rs`'s `build_emit_unit_ctx`) reads a `uses`-
893/// imported *foreign* unit's own attached methods, whose own `CheckedProgram`
894/// does not survive past that unit's own `check_unit_files` iteration — the
895/// same "dropped before any later, project-wide pass runs" shape
896/// `unit_callees` (#1202)/`EventSubscriberShape` (#1232) both work around,
897/// except here no project-wide accumulator is needed at all: unlike a
898/// `Callee`/event-subscriber-shape classification (checker-only facts, never
899/// re-derivable from raw declarations alone), a fn signature's own
900/// `params`/`return_type` are ordinary type references, resolvable from that
901/// unit's own declared types the same way [`lower_op_sig_ir_from_commons`]
902/// already resolves a capability op's — so a bare types map is sufficient,
903/// the same non-`CheckedProgram` scope that function already established.
904///
905/// Only the method's own `[T, …]` list seeds the rigid-variable scope, not
906/// its generic receiver's — the one real caller (`emit_forwarded_methods`)
907/// never renders `self`'s own type through this value at all (it takes the
908/// *consumer* context's own rebranded type name directly), so resolving
909/// `fn_receiver_ty` here would be dead work.
910fn lower_fn_sig_ir_from_types(
911    f: &FnDecl,
912    types: &HashMap<String, Arc<TypeDecl>>,
913    tys: &Types,
914) -> FnSig {
915    let type_vars: HashSet<String> = f
916        .type_params
917        .iter()
918        .map(|tp| tp.name.name.clone())
919        .collect();
920    let unit_ty = || tys.intern(Ty::Unit);
921    let params: Vec<(String, TyId)> = f
922        .params
923        .iter()
924        .map(|p| {
925            let ty = checker::resolve_type_ref_in(&p.type_ref, types, &type_vars, tys)
926                .unwrap_or_else(unit_ty);
927            (p.name.name.clone(), ty)
928        })
929        .collect();
930    let return_ty = checker::resolve_type_ref_in(&f.return_type, types, &type_vars, tys)
931        .unwrap_or_else(unit_ty);
932    let name = match &f.name {
933        FnName::Method { method_name, .. } => method_name.name.clone(),
934        FnName::Free(id) => id.name.clone(),
935    };
936    FnSig {
937        name,
938        has_self: f.has_self,
939        params,
940        return_ty,
941    }
942}
943
944/// P6.x (#1137): `lower_fn_sig_ir_from_types` over an entire
945/// [`MethodTable`]'s own instance + static entries — the attached-method
946/// gathering [`bynk-emit`'s `build_emit_unit_ctx`] needs for a `uses`-imported
947/// type. Filters to [`FnName::Method`] before lowering: `ResolverMethodTable`
948/// only ever collects attached methods in practice (`bynk-check/src/resolver.rs`'s
949/// own doc comment on [`MethodTable`]), but the filter stays as a defensive
950/// match rather than an assumption, matching the caller's own pre-existing
951/// posture one step earlier — this just moves that posture in front of the
952/// lowering call instead of behind it, so the `FnName` read (and the filter
953/// itself) never has to leave this module.
954pub fn lower_attached_fn_sig_ir_from_types(
955    mt: &MethodTable,
956    types: &HashMap<String, Arc<TypeDecl>>,
957    tys: &Types,
958) -> Vec<FnSig> {
959    mt.instance
960        .values()
961        .chain(mt.statics.values())
962        .filter(|f| matches!(f.name, FnName::Method { .. }))
963        .map(|f| lower_fn_sig_ir_from_types(f, types, tys))
964        .collect()
965}
966
967/// P6.24a: pure, unconditional [`HandlerKind`] → [`IrHandlerKind`]
968/// conversion — every field is already fully resolved at parse time, so
969/// unlike almost every other function in this module this one takes no
970/// `&CheckedProgram`/`&TypedCommons` at all and can never miss.
971pub fn lower_handler_kind_ir(k: &HandlerKind) -> IrHandlerKind {
972    match k {
973        HandlerKind::Call => IrHandlerKind::Call,
974        HandlerKind::Http { method, path } => IrHandlerKind::Http {
975            method: lower_http_method_ir(*method),
976            path: path.clone(),
977        },
978        HandlerKind::Cron { expr } => IrHandlerKind::Cron { expr: expr.clone() },
979        HandlerKind::Message => IrHandlerKind::Message,
980        HandlerKind::Open => IrHandlerKind::Open,
981        HandlerKind::Close => IrHandlerKind::Close,
982        HandlerKind::Event => IrHandlerKind::Event,
983    }
984}
985
986/// [`lower_handler_kind_ir`]'s own `HttpMethod` half.
987fn lower_http_method_ir(m: HttpMethod) -> IrHttpMethod {
988    match m {
989        HttpMethod::Get => IrHttpMethod::Get,
990        HttpMethod::Post => IrHttpMethod::Post,
991        HttpMethod::Put => IrHttpMethod::Put,
992        HttpMethod::Patch => IrHttpMethod::Patch,
993        HttpMethod::Delete => IrHttpMethod::Delete,
994    }
995}
996
997/// A provider's own `given` clause, resolved standalone — the entry point
998/// `bynk-emit/src/project.rs`'s `instantiate_provider_ts_expr` actually
999/// calls. This never touches the provider's `ops` or their bodies; the
1000/// full-provider assembly that did (and lowered every `Bynk` op body through
1001/// the expression lowerer) had no caller and went with Slice D1 of `#1542`.
1002pub fn lower_provider_given_ir(provider: &ProviderDecl) -> Vec<CapRefIr> {
1003    provider.given.iter().map(lower_cap_ref_ir).collect()
1004}
1005
1006/// #1187's slice 6 plumbing (sibling of [`lower_provider_given_ir`]): a
1007/// handler's own `given` clause, resolved standalone — the entry point for
1008/// `project.rs`'s `plan_agent_given_deps`, `EmitProjectCtx::
1009/// agent_method_givens`, and `emitter/workers.rs`'s own `given` collection.
1010/// Reuses `lower_cap_ref_ir` verbatim; a handler's `given` is syntactically
1011/// identical to a provider's (`bynk_syntax::ast::CapRef`), so this is the
1012/// same one-line adapter, not a new design.
1013pub fn lower_handler_given_ir(h: &Handler) -> Vec<CapRefIr> {
1014    h.given.iter().map(lower_cap_ref_ir).collect()
1015}
1016
1017/// #1187's slice 3: a handler's resolved actor-verification seam — the same
1018/// "narrow, standalone reader of already-resolved data" precedent
1019/// [`body_writes_state`]/[`lower_service_handler_signature_ir`] established,
1020/// applied to `bynk-check`'s own five actor-seam resolvers
1021/// (`bynk-check/src/actors.rs`) instead of a full handler assembly.
1022/// [`ActorSeamIr`]'s own doc comment has the full grounding for the
1023/// priority order and for the deliberately-missing `Signature` variant.
1024///
1025/// Replaces the hand-duplicated "try N resolvers, branch on which
1026/// returned `Some`" call sites this slice converts: `emit_service`
1027/// (`emitter/emit.rs`) and `emit_worker_compose`'s HTTP-dispatch match
1028/// (`emitter/workers.rs`). Deliberately does **not** yet replace every
1029/// caller of the five resolvers — `secrets.rs`'s `declared_secrets` unions
1030/// *all* matching seams' secrets rather than picking one (a different
1031/// shape this enum doesn't model), and the remaining call sites
1032/// (`emitter/workers_entry.rs`, `emitter/workers.rs`'s other two sites,
1033/// `emitter/emit.rs`'s `any_service_binds_caller`/`emit_make_surface`/
1034/// `ws_open_hosts_for`, `project/tests_emit.rs`) each call exactly one
1035/// resolver with nothing to collapse against — converting them to build a
1036/// five-variant enum just to immediately match out one arm would add
1037/// indirection without removing any real duplication.
1038pub fn lower_actor_seam_ir(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> ActorSeamIr {
1039    if let Some(members) = bynk_check::actors::sum_members_for(handler, actors) {
1040        return ActorSeamIr::Sum(members);
1041    }
1042    if let Some(seam) = bynk_check::actors::bearer_seam_for(handler, actors) {
1043        return ActorSeamIr::Bearer(seam);
1044    }
1045    if let Some(seam) = bynk_check::actors::oidc_seam_for(handler, actors) {
1046        return ActorSeamIr::Oidc(seam);
1047    }
1048    if let Some(binder) = bynk_check::actors::caller_binder_for(handler, actors) {
1049        return ActorSeamIr::Caller(binder);
1050    }
1051    ActorSeamIr::None
1052}
1053
1054/// P6.14 (#1174, review of #1186): adapt one `given` entry into a real
1055/// [`bynk_ir::CapRefIr`] — [`CapRefIr`]'s own doc comment has the full
1056/// grounding for the `QualifiedName -> String` flattening and for why a
1057/// `Some` prefix is preserved unresolved.
1058fn lower_cap_ref_ir(cap_ref: &CapRef) -> CapRefIr {
1059    CapRefIr {
1060        context: cap_ref.context.as_ref().map(QualifiedName::joined),
1061        name: cap_ref.name.name.clone(),
1062    }
1063}
1064
1065/// Decision E: targeted minimal fixtures, one per node kind this slice
1066/// covers, staying strictly inside the subset [`lower_expr_ir`]/
1067/// [`lower_stmt_ir`] actually implement — not a walk over the real
1068/// `bynkc/tests/fixtures/positive` corpus, which hits an unimplemented
1069/// `Match`/`Call` arm within a few lines of almost any real fixture.
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073    use bynk_check::checker::CheckedProgram;
1074    use bynk_check::hints::HintSink;
1075    use bynk_check::index::RefSink;
1076    use bynk_check::locals::LocalsSink;
1077    use bynk_check::requirements::RequirementSink;
1078    use bynk_check::{checker, context_checks, resolver, symbols};
1079    use bynk_project::UnitKind;
1080    use bynk_syntax::ast::{AgentDecl, Commons, CommonsItem, HandlerKind, ServiceDecl, SourceUnit};
1081    use bynk_syntax::span::Span;
1082    use bynk_syntax::{lexer, parser};
1083
1084    /// Like [`checked_program`], but for source that declares an `agent`
1085    /// and/or a `service` — both are only legal inside a `context`, not a
1086    /// bare `commons` (`bynk.agent.outside_context`/the service
1087    /// equivalent), so `source` is parsed as a context unit and its items
1088    /// re-wrapped into a [`Commons`] value before re-using the same
1089    /// `resolve`/`check` pipeline `checked_program` does.
1090    /// `resolver::resolve`/`checker::check` both already treat
1091    /// `CommonsItem::Agent`/`Service` as inert (v0.5 declaration kinds "go
1092    /// through the context-level v0.5 path", `resolver.rs`'s own comment)
1093    /// — real agent/service checking (`store` field kinds, handler bodies,
1094    /// `expr_types` for a `Cell` initialiser, actor bindings) only happens
1095    /// via `context_checks::check_context_declarations`, called here by
1096    /// hand with a [`symbols::UnitTable`] built directly from the checked
1097    /// commons' own agent/service/actor and local `capability` items (a
1098    /// handler's `given <Cap>` resolves against `table.capabilities` —
1099    /// populated here so a fixture can declare its own local capability,
1100    /// but still no cross-context `uses`/`consumes` in any fixture this
1101    /// helper is given, so `resolver::CrossContextInfo::default()` is
1102    /// exact, not an approximation — in particular, no `from Events(E)`
1103    /// fixture is possible here, since a real subscription needs
1104    /// `consumes bynk { Events }`).
1105    ///
1106    /// **P6.11 (#1171) adds `services`/`actors` to the table and a
1107    /// pre-`resolve` `inject_service_defaults` pass.** `table.actors`
1108    /// matters even for a fixture using only the prelude (`Caller`,
1109    /// `Visitor`): `actor_identity_ty` resolves a *local* `actor`
1110    /// declaration through `table.actors` and silently falls through to
1111    /// the prelude/`Ty::Unit` otherwise
1112    /// (`context_checks.rs::actor_identity_ty`), so a `by u: Buyer`
1113    /// fixture without this would assert against a wrong `TyId` with no
1114    /// error at all. `inject_service_defaults` stands in for
1115    /// `bynk-check/src/analysis.rs`'s own pipeline-phase-2b call — this
1116    /// reduced harness has no such phase — so a fixture relying on a
1117    /// service-level `by`/`given` default (rather than declaring one per
1118    /// handler) would otherwise silently see it un-inherited, pinning the
1119    /// wrong fact with no failure to signal it.
1120    fn checked_context_program(source: &str) -> CheckedProgram {
1121        let tokens = lexer::tokenize(source).expect("lex");
1122        let unit = parser::parse_unit(&tokens, source).expect("parse");
1123        let SourceUnit::Context(mut ctx) = unit else {
1124            panic!("expected a context unit, got {unit:?}")
1125        };
1126        for item in &mut ctx.items {
1127            if let CommonsItem::Service(svc) = item {
1128                bynk_check::project_model::inject_service_defaults(svc);
1129            }
1130        }
1131        let commons = Commons {
1132            name: ctx.name,
1133            items: ctx.items,
1134            uses: ctx.uses,
1135            documentation: ctx.documentation,
1136            form: ctx.form,
1137            span: ctx.span,
1138            trivia: ctx.trivia,
1139            trailing_comments: ctx.trailing_comments,
1140        };
1141        let resolved = resolver::resolve(commons).expect("resolve");
1142        let mut typed = checker::check(resolved).expect("check");
1143        let agents: HashMap<String, AgentDecl> = typed
1144            .commons
1145            .items
1146            .iter()
1147            .filter_map(|item| match item {
1148                CommonsItem::Agent(a) => Some((a.name.name.clone(), a.clone())),
1149                _ => None,
1150            })
1151            .collect();
1152        let services: HashMap<String, ServiceDecl> = typed
1153            .commons
1154            .items
1155            .iter()
1156            .filter_map(|item| match item {
1157                CommonsItem::Service(s) => Some((s.name.name.clone(), s.clone())),
1158                _ => None,
1159            })
1160            .collect();
1161        let actors: HashMap<String, bynk_syntax::ast::ActorDecl> = typed
1162            .commons
1163            .items
1164            .iter()
1165            .filter_map(|item| match item {
1166                CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
1167                _ => None,
1168            })
1169            .collect();
1170        // A `given <Cap>` clause on a handler resolves against
1171        // `table.capabilities` (`context_checks.rs`'s own `capability_info_map`
1172        // construction) — populated here from this fixture's own local
1173        // `capability` declarations so a handler can legitimately declare one
1174        // (e.g. `Log.append`'s own `given Clock` requirement), the same
1175        // "no cross-context uses/consumes" scope this helper's own doc
1176        // comment already names for `agents`/`types`.
1177        let capabilities: HashMap<String, bynk_syntax::ast::CapabilityDecl> = typed
1178            .commons
1179            .items
1180            .iter()
1181            .filter_map(|item| match item {
1182                CommonsItem::Capability(c) => Some((c.name.name.clone(), c.clone())),
1183                _ => None,
1184            })
1185            .collect();
1186        // P6.14 (#1174): `check_provider_decls` (the pass that actually
1187        // type-checks a `ProviderOp`'s own body via `check_handler_body`)
1188        // reads `table.providers`, keyed by capability name — same "one
1189        // provider per capability in v0.5" convention
1190        // `symbols::UnitTable::providers`'s own doc comment names. Populated
1191        // here for the same reason `capabilities` is (above): without it, a
1192        // fixture's own `provides` declaration is silently never checked at
1193        // all, not merely under-checked (feedback memory
1194        // "bynk-emit test harness scope").
1195        let providers: HashMap<String, ProviderDecl> = typed
1196            .commons
1197            .items
1198            .iter()
1199            .filter_map(|item| match item {
1200                CommonsItem::Provider(p) => Some((p.capability.name.clone(), p.clone())),
1201                _ => None,
1202            })
1203            .collect();
1204        let table = symbols::UnitTable {
1205            kind: Some(UnitKind::Context),
1206            types: typed.types.clone(),
1207            agents,
1208            services,
1209            actors,
1210            capabilities,
1211            providers,
1212            ..symbols::UnitTable::default()
1213        };
1214        let tys = typed.ty_intern.clone();
1215        let errors = context_checks::check_context_declarations(
1216            &mut typed,
1217            &table,
1218            &resolver::CrossContextInfo::default(),
1219            true,
1220            &HashSet::new(),
1221            &HashMap::new(),
1222            &mut RefSink::new(),
1223            &mut HintSink::new(),
1224            &mut LocalsSink::new(),
1225            &mut RequirementSink::new(),
1226            &tys,
1227        );
1228        checker::certify(typed, errors).expect("certify")
1229    }
1230
1231    fn find_agent<'a>(program: &'a CheckedProgram, name: &str) -> &'a AgentDecl {
1232        program
1233            .program()
1234            .commons
1235            .items
1236            .iter()
1237            .find_map(|item| match item {
1238                CommonsItem::Agent(a) if a.name.name == name => Some(a),
1239                _ => None,
1240            })
1241            .unwrap_or_else(|| panic!("no agent named `{name}` in this fixture"))
1242    }
1243
1244    /// `lower_actor_seam_ir`'s own `actors` map — rebuilt the same way
1245    /// `checked_context_program` builds its own throwaway `table.actors`
1246    /// (not itself exposed on `CheckedProgram`), since the resolvers it
1247    /// wraps take the same `HashMap<String, ActorDecl>` shape the real
1248    /// `table.actors`/`ctx.actors` emitter-side callers already carry.
1249    fn actors_map(program: &CheckedProgram) -> HashMap<String, ActorDecl> {
1250        program
1251            .program()
1252            .commons
1253            .items
1254            .iter()
1255            .filter_map(|item| match item {
1256                CommonsItem::Actor(a) => Some((a.name.name.clone(), a.clone())),
1257                _ => None,
1258            })
1259            .collect()
1260    }
1261
1262    fn find_service<'a>(program: &'a CheckedProgram, name: &str) -> &'a ServiceDecl {
1263        program
1264            .program()
1265            .commons
1266            .items
1267            .iter()
1268            .find_map(|item| match item {
1269                CommonsItem::Service(s) if s.name.name == name => Some(s),
1270                _ => None,
1271            })
1272            .unwrap_or_else(|| panic!("no service named `{name}` in this fixture"))
1273    }
1274
1275    /// `find_handler` (below) matches on `method_name`, which is always
1276    /// `None` for a service handler — useless here. Matches on
1277    /// `HandlerKind` equality instead; not a unique identity on its own (a
1278    /// service may declare several handlers sharing one `HandlerKind`, all
1279    /// `on call`), so a fixture with more than one same-kind handler must
1280    /// index `service.handlers`/the lowered `handlers` slice directly
1281    /// instead of calling this twice.
1282    fn find_service_handler<'a>(service: &'a ServiceDecl, kind: &HandlerKind) -> &'a Handler {
1283        service
1284            .handlers
1285            .iter()
1286            .find(|h| &h.kind == kind)
1287            .unwrap_or_else(|| {
1288                panic!(
1289                    "no handler of kind {kind:?} on service `{}`",
1290                    service.name.name
1291                )
1292            })
1293    }
1294
1295    fn find_store_field<'a>(agent: &'a AgentDecl, name: &str) -> &'a StoreField {
1296        agent
1297            .store_fields
1298            .iter()
1299            .find(|f| f.name.name == name)
1300            .unwrap_or_else(|| {
1301                panic!(
1302                    "no store field named `{name}` on agent `{}`",
1303                    agent.name.name
1304                )
1305            })
1306    }
1307
1308    /// [`lower_store_field_shape_ir`] reads a store field's shape and never
1309    /// its initialiser — pinned against the two initialiser forms that once
1310    /// tripped the (since deleted) `init`-lowering sibling: `= None`
1311    /// (`223_store_cell_agent`'s `store paymentRef: Cell[Option[AuthId]] =
1312    /// None`) and an `is`-expression (`1029_agent_static_init_hoist`'s
1313    /// `store active: Cell[Bool] = if true { 5 is PositiveInt } else {
1314    /// false }`). Both are real, certified fixtures; the shape reader lowers
1315    /// both fields cleanly because it does not touch `init` at all.
1316    #[test]
1317    fn store_field_shape_ir_does_not_panic_on_none_or_is_initialisers() {
1318        let program = checked_context_program(
1319            r#"
1320context demo
1321
1322type AuthId = String where NonEmpty
1323
1324agent Order {
1325  key id: String
1326  store paymentRef: Cell[Option[AuthId]] = None
1327
1328  on call touch() -> Effect[()] {
1329    Effect.pure(())
1330  }
1331}
1332"#,
1333        );
1334        let agent = find_agent(&program, "Order");
1335        let payment_ref = find_store_field(agent, "paymentRef");
1336        let ir = lower_store_field_shape_ir(payment_ref, &program);
1337        assert_eq!(ir.field, "paymentRef");
1338        assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
1339
1340        let program = checked_context_program(
1341            r#"
1342context demo
1343
1344type PositiveInt = Int where Positive
1345
1346agent Meter {
1347  key id: String
1348  store active: Cell[Bool] = if true { 5 is PositiveInt } else { false }
1349
1350  on call touch() -> Effect[()] {
1351    Effect.pure(())
1352  }
1353}
1354"#,
1355        );
1356        let agent = find_agent(&program, "Meter");
1357        let active = find_store_field(agent, "active");
1358        let ir = lower_store_field_shape_ir(active, &program);
1359        assert_eq!(ir.field, "active");
1360        assert!(matches!(ir.kind, StoreKindIr::Cell(_)));
1361    }
1362
1363    /// Review of #1209: pins the one load-bearing ordering decision
1364    /// `ActorSeamIr`'s own doc comment argues for — `sum_members_for`
1365    /// ahead of `bearer_seam_for` — at `lower_actor_seam_ir` itself, not
1366    /// only four fixture-hops away via a full `emit_service`/`bless` run.
1367    /// `bearer_seam_for` has no `by.is_sum()` guard of its own and resolves
1368    /// off `by.primary()`, so a Bearer-first sum (`by who: User | Visitor`
1369    /// with `User`'s own scheme `Bearer`) would resolve as `ActorSeamIr::
1370    /// Bearer` instead of `ActorSeamIr::Sum` if the two resolvers were ever
1371    /// tried in the other order.
1372    #[test]
1373    fn lower_actor_seam_ir_tries_sum_ahead_of_bearer_for_a_bearer_first_sum() {
1374        let program = checked_context_program(
1375            r#"
1376context demo
1377
1378type UserId = String
1379
1380actor User { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
1381
1382service Api from http {
1383  on GET("/whoami") () -> Effect[HttpResult[String]] by who: User | Visitor {
1384    Effect.pure(Ok("ok"))
1385  }
1386}
1387"#,
1388        );
1389        let service = find_service(&program, "Api");
1390        let handler = &service.handlers[0];
1391        let actors = actors_map(&program);
1392        let seam = lower_actor_seam_ir(handler, &actors);
1393        let ActorSeamIr::Sum(members) = &seam else {
1394            panic!("expected ActorSeamIr::Sum for a Bearer-first sum `by` clause, got {seam:?}");
1395        };
1396        assert_eq!(members.len(), 2);
1397        assert_eq!(members[0].actor_name, "User");
1398        assert_eq!(members[1].actor_name, "Visitor");
1399    }
1400
1401    /// [`lower_service_handler_signature_ir`] is `emit_service`'s entry
1402    /// point for a handler's resolved signature (#1187's slice 5, review of
1403    /// #1196) and never reads the body. Pinned against the shape that
1404    /// motivated it: an ordinary `from http` handler body constructing
1405    /// `Ok(...)` directly, not routed through the `fn ok(s) ->
1406    /// HttpResult[String] { Ok(s) }` indirection other fixtures in this
1407    /// module use — historically the body shape a full handler lowering
1408    /// panicked on, and still the clearest demonstration that this reader is
1409    /// signature-only.
1410    #[test]
1411    fn service_handler_signature_lowers_without_touching_a_body_that_constructs_ok() {
1412        let program = checked_context_program(
1413            r#"
1414context demo
1415
1416service Api from http {
1417  on GET("/ping") () -> Effect[HttpResult[String]] by v: Visitor {
1418    Effect.pure(Ok("pong"))
1419  }
1420}
1421"#,
1422        );
1423        let service = find_service(&program, "Api");
1424        let handler = find_service_handler(
1425            service,
1426            &HandlerKind::Http {
1427                method: bynk_syntax::ast::HttpMethod::Get,
1428                path: "/ping".to_string(),
1429            },
1430        );
1431        let (params, _given, ret, effectful) =
1432            lower_service_handler_signature_ir(handler, &program);
1433        assert!(params.is_empty(), "`() -> ...` declares no parameters");
1434        assert!(effectful, "an `Effect[...]` return type");
1435        assert!(matches!(
1436            &*program.program().ty_intern.get(ret),
1437            Ty::Effect(_)
1438        ));
1439    }
1440
1441    /// Mirrors `bynkc/tests/fixtures/positive/236_websocket_chatroom` in
1442    /// full — `on open`/`on message`/`on close` all present, the same
1443    /// shape the real fixture uses — so this fixture's own tests can cover
1444    /// both the owned (`on open`) and borrowed (`on message`/`on close`,
1445    /// P6.13, #1179) `connection` cases. A held `Connection` needs real
1446    /// disposal to certify (the linearity pass), so `on open` transfers it
1447    /// into a trivial `Room` agent rather than dropping it.
1448    fn websocket_service_fixture() -> CheckedProgram {
1449        checked_context_program(
1450            r#"
1451context demo
1452
1453type RoomId = String
1454type UserId = String
1455type ServerFrame = { text: String }
1456type ClientFrame = { text: String }
1457
1458actor Participant { auth = Bearer(secret = "AUTH_SECRET"), identity = UserId }
1459
1460service ChatGateway from websocket(in: ClientFrame, out: ServerFrame) {
1461  on open (roomId: RoomId) -> Effect[()] by user: Participant {
1462    let _ <- connection.send(ServerFrame { text: "welcome" })
1463    let _ <- Room(roomId).join(user.identity, connection)
1464    ()
1465  }
1466
1467  on message (roomId: RoomId, frame: ClientFrame) -> Effect[()] by user: Participant {
1468    let _ <- connection.send(ServerFrame { text: frame.text })
1469    let _ <- Room(roomId).post(user.identity, frame.text)
1470    ()
1471  }
1472
1473  on close (roomId: RoomId) -> Effect[()] by user: Participant {
1474    let _ <- Room(roomId).leave(user.identity)
1475    ()
1476  }
1477}
1478
1479agent Room {
1480  key id: RoomId
1481  store members: Set[UserId]
1482  store conns: Map[UserId, Connection[ServerFrame]]
1483
1484  on call join(u: UserId, conn: Connection[ServerFrame]) -> Effect[()] {
1485    let _ <- members.add(u)
1486    let _ <- conns.put(u, conn)
1487    ()
1488  }
1489
1490  on call leave(u: UserId) -> Effect[()] {
1491    let _ <- members.remove(u)
1492    let _ <- conns.remove(u)
1493    ()
1494  }
1495
1496  on call post(sender: UserId, text: String) -> Effect[()] {
1497    let _ <- conns.parTraverse((c: Connection[ServerFrame]) => c.send(ServerFrame { text: text }))
1498    ()
1499  }
1500}
1501"#,
1502        )
1503    }
1504
1505    #[test]
1506    fn websocket_protocol_descriptor_lowers_its_frame_types() {
1507        let program = websocket_service_fixture();
1508        let service = find_service(&program, "ChatGateway");
1509        let ir = lower_protocol_ir(&service.protocol, &program);
1510        let ProtocolIr::WebSocket { in_ty, out_ty } = ir else {
1511            panic!("expected ProtocolIr::WebSocket, got {:?}", ir)
1512        };
1513        let tys = &program.program().ty_intern;
1514        assert_eq!(in_ty.display(tys), "ClientFrame");
1515        assert_eq!(out_ty.display(tys), "ServerFrame");
1516    }
1517
1518    #[test]
1519    fn lower_event_pattern_ir_reshapes_literal_and_variant_fields() {
1520        // Review of #1180: the `Events` protocol path had zero coverage.
1521        // `lower_protocol_ir`'s own `Events` arm genuinely can't be driven
1522        // through `checked_context_program` — a real `from Events(E)`
1523        // subscription needs `consumes bynk { Events }`, which this
1524        // reduced harness's `CrossContextInfo::default()` doesn't support
1525        // (the same limitation named in `checked_context_program`'s own
1526        // doc comment). `lower_event_pattern_ir` itself has no such
1527        // excuse: it takes no `&CheckedProgram`, resolves nothing, and
1528        // cannot panic — pure AST reshaping — so it's pinned directly
1529        // against a parsed (not checked or certified) `EventPattern`.
1530        let source = r#"
1531context demo
1532
1533type Status = | Active | Inactive
1534
1535event OrderPlaced = {
1536  status: Status,
1537  count: Int,
1538}
1539
1540service Subscriber from Events(OrderPlaced { status: Active, count: 3, .. }) {
1541  on event(o: OrderPlaced) -> Effect[()] {
1542    Effect.pure(())
1543  }
1544}
1545"#;
1546        let tokens = lexer::tokenize(source).expect("lex");
1547        let unit = parser::parse_unit(&tokens, source).expect("parse");
1548        let SourceUnit::Context(ctx) = unit else {
1549            panic!("expected a context unit, got {unit:?}")
1550        };
1551        let service = ctx
1552            .items
1553            .iter()
1554            .find_map(|item| match item {
1555                CommonsItem::Service(s) if s.name.name == "Subscriber" => Some(s),
1556                _ => None,
1557            })
1558            .expect("no service named `Subscriber` in this fixture");
1559        let ServiceProtocol::Events { pattern, .. } = &service.protocol else {
1560            panic!(
1561                "expected ServiceProtocol::Events, got {:?}",
1562                service.protocol
1563            )
1564        };
1565        let pattern = pattern
1566            .as_ref()
1567            .expect("expected a structural pattern on this Events subscription");
1568
1569        let ir = lower_event_pattern_ir(pattern);
1570        assert_eq!(ir.fields.len(), 2, "declaration order preserved");
1571        assert_eq!(ir.fields[0].0, "status");
1572        assert!(
1573            matches!(&ir.fields[0].1, EventPatternValueIr::Variant { tag } if tag == "Active"),
1574            "expected a bare nullary variant tag (the AST's own optional qualifying type_name \
1575             dropped), got {:?}",
1576            ir.fields[0].1
1577        );
1578        assert_eq!(ir.fields[1].0, "count");
1579        assert!(
1580            matches!(
1581                &ir.fields[1].1,
1582                EventPatternValueIr::Const(ConstVal::Int(3))
1583            ),
1584            "expected a literal Int constant, got {:?}",
1585            ir.fields[1].1
1586        );
1587    }
1588
1589    /// P6.29 (design/tracks/the-ir.md §6a): pins `capability_op_sig_from_commons`
1590    /// against the same fixture as its `CheckedProgram`-driven sibling above —
1591    /// same param names/order, found by name alone from `TypedCommons`, no
1592    /// `CheckedProgram` needed at the call site (`emitter/lower.rs`'s
1593    /// `cap_op_param_names` only ever had one).
1594    #[test]
1595    fn capability_op_sig_from_commons_finds_the_named_op() {
1596        let program = checked_context_program(
1597            r#"
1598context demo
1599
1600capability Store {
1601  fn get(key: String) -> Effect[Int]
1602  fn put(key: String, value: Int) -> Effect[()]
1603}
1604"#,
1605        );
1606        let commons = program.program();
1607
1608        let get = capability_op_sig_from_commons(commons, "Store", "get")
1609            .expect("Store.get should resolve");
1610        assert_eq!(get.params.len(), 1);
1611        assert_eq!(get.params[0].0, "key");
1612
1613        let put = capability_op_sig_from_commons(commons, "Store", "put")
1614            .expect("Store.put should resolve");
1615        assert_eq!(put.params.len(), 2);
1616        assert_eq!(put.params[0].0, "key");
1617        assert_eq!(put.params[1].0, "value");
1618
1619        // Same fallthrough-to-`None` behaviour the by-hand AST walk it
1620        // replaces had, for both an unknown capability and a known
1621        // capability's unknown op — mirrors `cap_op_param_names`'s own prior
1622        // fallthrough-to-empty-`Vec` at the caller.
1623        assert!(capability_op_sig_from_commons(commons, "NoSuchCap", "get").is_none());
1624        assert!(capability_op_sig_from_commons(commons, "Store", "no_such_op").is_none());
1625    }
1626
1627    #[test]
1628    fn lower_cap_ref_ir_local_capability_has_no_context() {
1629        let cap_ref = CapRef {
1630            context: None,
1631            name: bynk_syntax::ast::Ident {
1632                name: "Clock".to_string(),
1633                span: Span::default(),
1634            },
1635            span: Span::default(),
1636        };
1637        let ir = lower_cap_ref_ir(&cap_ref);
1638        assert_eq!(ir.context, None);
1639        assert_eq!(ir.name, "Clock");
1640    }
1641
1642    #[test]
1643    fn lower_cap_ref_ir_preserves_a_cross_context_prefix() {
1644        // `given B.Cap` (v0.15) is out of `checked_context_program`'s own
1645        // fixture scope (no cross-context `uses`/`consumes`, feedback
1646        // memory "bynk-emit test harness scope") — pins `lower_cap_ref_ir`'s
1647        // own `QualifiedName -> String` flattening directly against a
1648        // hand-built `CapRef`, the same posture the external-provider test
1649        // above already takes for a branch the fixture cannot reach.
1650        let cap_ref = CapRef {
1651            context: Some(QualifiedName {
1652                parts: vec![bynk_syntax::ast::Ident {
1653                    name: "Billing".to_string(),
1654                    span: Span::default(),
1655                }],
1656                span: Span::default(),
1657            }),
1658            name: bynk_syntax::ast::Ident {
1659                name: "Ledger".to_string(),
1660                span: Span::default(),
1661            },
1662            span: Span::default(),
1663        };
1664        let ir = lower_cap_ref_ir(&cap_ref);
1665        assert_eq!(ir.context.as_deref(), Some("Billing"));
1666        assert_eq!(ir.name, "Ledger");
1667    }
1668
1669    /// Review of #1229 (#1228): `lower_route_cache_ir`/`lower_route_limit_ir`
1670    /// take no `&CheckedProgram`, resolve nothing, and cannot panic — the same
1671    /// posture `lower_event_pattern_ir`'s own test above already established a
1672    /// parsed-not-checked fixture for, and for the identical reason here: their
1673    /// defensive branches (a non-`GET` handler, a `maxAge`-less `@cache`, a
1674    /// non-positive `maxBody`) are exactly the shapes `bynk-check`'s
1675    /// `bynk.http.cache_on_non_get`/`cache_bad_max_age`/`limit_bad_max_body`
1676    /// (`bynk-check/src/context_checks.rs`) already reject, so a real checked
1677    /// program can never reach them and the fixture bless run never exercises
1678    /// them either.
1679    fn parsed_only_context(source: &str) -> bynk_syntax::ast::Context {
1680        let tokens = lexer::tokenize(source).expect("lex");
1681        let unit = parser::parse_unit(&tokens, source).expect("parse");
1682        let SourceUnit::Context(ctx) = unit else {
1683            panic!("expected a context unit, got {unit:?}")
1684        };
1685        ctx
1686    }
1687
1688    fn parsed_handler<'a>(
1689        ctx: &'a bynk_syntax::ast::Context,
1690        service: &str,
1691        index: usize,
1692    ) -> &'a Handler {
1693        let service = ctx
1694            .items
1695            .iter()
1696            .find_map(|item| match item {
1697                CommonsItem::Service(s) if s.name.name == service => Some(s),
1698                _ => None,
1699            })
1700            .unwrap_or_else(|| panic!("no service named `{service}` in this fixture"));
1701        &service.handlers[index]
1702    }
1703
1704    #[test]
1705    fn lower_route_cache_ir_reads_maxage_and_scope_off_a_get_handler() {
1706        let ctx = parsed_only_context(
1707            r#"
1708context demo
1709
1710service Api from http {
1711  @cache(maxAge: 5.minutes, scope: public)
1712  on GET("/config") () -> Effect[HttpResult[String]] by v: Visitor {
1713    Ok("cfg")
1714  }
1715
1716  @cache(maxAge: 30.seconds)
1717  on GET("/private") () -> Effect[HttpResult[String]] by v: Visitor {
1718    Ok("priv")
1719  }
1720
1721  on GET("/plain") () -> Effect[HttpResult[String]] by v: Visitor {
1722    Ok("plain")
1723  }
1724}
1725"#,
1726        );
1727        let public_cache = lower_route_cache_ir(parsed_handler(&ctx, "Api", 0))
1728            .unwrap_or_else(|| panic!("expected Some(CacheIr) for a well-formed @cache"));
1729        assert_eq!(public_cache.max_age_secs, 300, "5.minutes in whole seconds");
1730        assert_eq!(public_cache.scope, "public");
1731
1732        let default_scope_cache = lower_route_cache_ir(parsed_handler(&ctx, "Api", 1))
1733            .unwrap_or_else(|| panic!("expected Some(CacheIr) with no explicit scope:"));
1734        assert_eq!(default_scope_cache.max_age_secs, 30);
1735        assert_eq!(
1736            default_scope_cache.scope, "private",
1737            "no scope: argument written — must default to private"
1738        );
1739
1740        assert!(
1741            lower_route_cache_ir(parsed_handler(&ctx, "Api", 2)).is_none(),
1742            "no @cache annotation at all must yield None"
1743        );
1744    }
1745
1746    #[test]
1747    fn lower_route_cache_ir_returns_none_for_a_non_get_handler_even_with_a_cache_annotation() {
1748        // `bynk.http.cache_on_non_get` already rejects this at check time — this
1749        // pins the lowering function's own independent guard, not reachable
1750        // through a real certified program.
1751        let ctx = parsed_only_context(
1752            r#"
1753context demo
1754
1755service Api from http {
1756  @cache(maxAge: 5.minutes)
1757  on POST("/items") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
1758    Created(body)
1759  }
1760}
1761"#,
1762        );
1763        assert!(
1764            lower_route_cache_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
1765            "a @cache on a non-GET handler must not construct a CacheIr"
1766        );
1767    }
1768
1769    #[test]
1770    fn lower_route_cache_ir_discards_an_otherwise_well_formed_scope_when_maxage_is_missing() {
1771        // `bynk.http.cache_bad_max_age` already rejects a maxAge-less @cache at
1772        // check time — this pins that `scope`'s own well-formedness does not
1773        // rescue a missing `maxAge` into a partial CacheIr.
1774        let ctx = parsed_only_context(
1775            r#"
1776context demo
1777
1778service Api from http {
1779  @cache(scope: public)
1780  on GET("/broken") () -> Effect[HttpResult[String]] by v: Visitor {
1781    Ok("x")
1782  }
1783}
1784"#,
1785        );
1786        assert!(
1787            lower_route_cache_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
1788            "a well-formed scope: must not survive a missing maxAge:"
1789        );
1790    }
1791
1792    #[test]
1793    fn lower_route_limit_ir_reads_maxbody_off_a_route_annotation() {
1794        let ctx = parsed_only_context(
1795            r#"
1796context demo
1797
1798service Api from http {
1799  @limit(maxBody: 26_214_400)
1800  on POST("/bulk") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
1801    Created(body)
1802  }
1803
1804  on POST("/upload") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
1805    Created(body)
1806  }
1807}
1808"#,
1809        );
1810        assert_eq!(
1811            lower_route_limit_ir(parsed_handler(&ctx, "Api", 0)),
1812            Some(26_214_400)
1813        );
1814        assert!(
1815            lower_route_limit_ir(parsed_handler(&ctx, "Api", 1)).is_none(),
1816            "no @limit annotation at all must yield None — the caller applies the \
1817             service-wide default, this function does not know it"
1818        );
1819    }
1820
1821    #[test]
1822    fn lower_route_limit_ir_returns_none_for_a_non_positive_maxbody() {
1823        // `bynk.http.limit_bad_max_body` already rejects a non-positive maxBody
1824        // at check time — this pins the lowering function's own independent
1825        // guard. `None` here matters specifically because the caller's own
1826        // fallback composition (`effective_max_body`) treats it as "no
1827        // route-level override," falling through to the service-wide default —
1828        // not as "an explicit zero-byte cap."
1829        let ctx = parsed_only_context(
1830            r#"
1831context demo
1832
1833service Api from http {
1834  @limit(maxBody: 0)
1835  on POST("/zero") (body: String) -> Effect[HttpResult[String]] by v: Visitor {
1836    Created(body)
1837  }
1838}
1839"#,
1840        );
1841        assert!(
1842            lower_route_limit_ir(parsed_handler(&ctx, "Api", 0)).is_none(),
1843            "a non-positive maxBody must not construct Some(0)"
1844        );
1845    }
1846
1847    /// Every `body_writes_state` classification (#1165's [DECISION B]/
1848    /// [DECISION C], the shipped `emit_agent` implicit-commit decision) lives
1849    /// on one agent — each handler exercises exactly one case so a failing
1850    /// assertion names its own scenario unambiguously. Until Slice D1 of
1851    /// `#1542` these pinned the same function through the deleted IR-side
1852    /// `CommitShape` constructor; they now pin it directly.
1853    fn store_write_fixture() -> CheckedProgram {
1854        checked_context_program(
1855            r#"
1856context demo
1857
1858type Box = { n: Int }
1859
1860fn Box.put(self, x: Int) -> Effect[()] {
1861  Effect.pure(())
1862}
1863
1864capability Clock {
1865  fn now() -> Effect[Int]
1866}
1867
1868provides Clock = FixedClock {
1869  fn now() -> Effect[Int] {
1870    42
1871  }
1872}
1873
1874agent Widget {
1875  key id: String
1876  store items: Map[String, Int]
1877  store active: Cell[Bool] = true
1878  store tags: Set[String]
1879  store history: Log[String]
1880
1881  on call readOnlyPlain() -> Effect[()] {
1882    Effect.pure(())
1883  }
1884
1885  on call readOnlyQuery() -> Effect[Int] {
1886    items.size()
1887  }
1888
1889  on call nestedMutation(xs: List[String], flag: Bool) -> Effect[()] {
1890    if flag {
1891      match flag {
1892        true => xs.forEach((x) => items.put(x, 1))
1893        false => Effect.pure(())
1894      }
1895    } else {
1896      Effect.pure(())
1897    }
1898  }
1899
1900  on call bareAssign(v: Bool) -> Effect[()] {
1901    active := v
1902    Effect.pure(())
1903  }
1904
1905  on call shadowedName(items: Box, x: Int) -> Effect[()] {
1906    let _ <- items.put(x)
1907    Effect.pure(())
1908  }
1909
1910  on call cellUpdate() -> Effect[()] {
1911    let _ <- active.update((b) => !b)
1912    Effect.pure(())
1913  }
1914
1915  on call setAdd(t: String) -> Effect[()] {
1916    let _ <- tags.add(t)
1917    Effect.pure(())
1918  }
1919
1920  on call logAppend(t: String) -> Effect[()] given Clock {
1921    let _ <- history.append(t)
1922    Effect.pure(())
1923  }
1924}
1925"#,
1926        )
1927    }
1928
1929    fn find_handler<'a>(agent: &'a AgentDecl, name: &str) -> &'a Handler {
1930        agent
1931            .handlers
1932            .iter()
1933            .find(|h| h.method_name.as_ref().is_some_and(|m| m.name == name))
1934            .unwrap_or_else(|| panic!("no handler named `{name}` on agent `{}`", agent.name.name))
1935    }
1936
1937    /// A queue consumer's own body is the one shape this module's own
1938    /// pre-existing gaps make genuinely unlowerable today, not just
1939    /// awkward to fixture around: `Effect[QueueResult]` is mandatory
1940    /// (`bynk.queue.return_not_https`-adjacent gate, `context_checks.rs:
1941    /// 3730-3744`), and every `QueueResult` value — `Ack`, `NotFound`,
1942    /// `Retry(reason)` — is a bare or qualified built-in-sum variant
1943    /// reference (a contextual, `expected`-type-driven disambiguation).
1944    /// Unlike `HttpResult`'s `Ok`/`Err`, `QueueResult`'s
1945    /// own variants also don't resolve inside an ordinary free `fn` body at
1946    /// all (confirmed empirically: `bynk.resolve.unknown_name`) — the
1947    /// checker's own special-case for them (`checker.rs:3507`) is reached
1948    /// only via a real handler body's own `Ctx::return_ty`, which the
1949    /// resolver's eager pass over an ordinary `fn` never sets up — so the
1950    /// `fn ok(s) -> HttpResult[String] { Ok(s) }` indirection every other
1951    /// HTTP/cron fixture in this module uses has no queue-shaped
1952    /// equivalent. Two tests, not one, cover what's actually true here.
1953    fn queue_service_fixture() -> CheckedProgram {
1954        checked_context_program(
1955            r#"
1956context demo
1957
1958type EmailJob = { to: String }
1959
1960service Outbox from queue("orders") {
1961  on message(m: EmailJob) -> Effect[QueueResult] {
1962    Ack
1963  }
1964}
1965"#,
1966        )
1967    }
1968
1969    #[test]
1970    fn a_queue_services_protocol_and_handler_signature_lower_correctly() {
1971        // The protocol descriptor (standalone, mirroring `lower_protocol_ir`'s
1972        // own precedent for `from websocket`) and the handler's own
1973        // `params`/`given`/`effectful` via `lower_service_handler_signature_ir`
1974        // — the two shape readers `bynk-emit` consumes for a queue service.
1975        let program = queue_service_fixture();
1976        let service = find_service(&program, "Outbox");
1977        assert!(matches!(
1978            lower_protocol_ir(&service.protocol, &program),
1979            ProtocolIr::Queue { name } if name == "orders"
1980        ));
1981        let handler = find_service_handler(service, &HandlerKind::Message);
1982        let (params, given, _ret, effectful) =
1983            lower_service_handler_signature_ir(handler, &program);
1984        assert_eq!(params.len(), 1);
1985        assert_eq!(params[0].0, "m");
1986        assert!(given.is_empty());
1987        assert!(effectful, "every service handler returns Effect[T]");
1988    }
1989
1990    #[test]
1991    fn store_field_with_an_unresolvable_type_is_rejected_before_lowering() {
1992        // #1187's Agent state-field slice, step 0, found that no checker pass
1993        // validated a store field's own type reference, so `store x:
1994        // Cell[Bogus]` certified and reached `resolve_store_field_ty`, whose
1995        // `Ty::Unit` fallback this test used to pin. #1679 closed that gap: the
1996        // resolver now walks agent store field types, so the program is
1997        // rejected with `bynk.resolve.unknown_type` before lowering, and the
1998        // fallback is defensive only.
1999        let source = r#"
2000context demo
2001
2002agent Widget {
2003  key id: String
2004  store x: Cell[Bogus] = "hello"
2005
2006  on call touch() -> Effect[()] {
2007    Effect.pure(())
2008  }
2009}
2010"#;
2011        let tokens = lexer::tokenize(source).expect("lex");
2012        let unit = parser::parse_unit(&tokens, source).expect("parse");
2013        let SourceUnit::Context(ctx) = unit else {
2014            panic!("expected a context unit, got {unit:?}")
2015        };
2016        let commons = Commons {
2017            name: ctx.name,
2018            items: ctx.items,
2019            uses: ctx.uses,
2020            documentation: ctx.documentation,
2021            form: ctx.form,
2022            span: ctx.span,
2023            trivia: ctx.trivia,
2024            trailing_comments: ctx.trailing_comments,
2025        };
2026        let Err(errors) = resolver::resolve(commons) else {
2027            panic!("an unknown store type must be rejected by the resolver")
2028        };
2029        assert!(
2030            errors
2031                .iter()
2032                .any(|e| e.category == "bynk.resolve.unknown_type" && e.message.contains("Bogus")),
2033            "{errors:?}"
2034        );
2035    }
2036
2037    #[test]
2038    fn body_writes_state_is_false_for_a_plain_body() {
2039        let program = store_write_fixture();
2040        let handler = find_handler(find_agent(&program, "Widget"), "readOnlyPlain");
2041        assert!(!body_writes_state(&handler.body, program.program()));
2042    }
2043
2044    #[test]
2045    fn body_writes_state_is_false_for_a_non_mutating_store_read() {
2046        let program = store_write_fixture();
2047        let handler = find_handler(find_agent(&program, "Widget"), "readOnlyQuery");
2048        assert!(!body_writes_state(&handler.body, program.program()));
2049    }
2050
2051    #[test]
2052    fn body_writes_state_is_false_for_a_locally_shadowed_store_field_name() {
2053        let program = store_write_fixture();
2054        let handler = find_handler(find_agent(&program, "Widget"), "shadowedName");
2055        assert!(!body_writes_state(&handler.body, program.program()));
2056    }
2057
2058    #[test]
2059    fn body_writes_state_is_true_for_a_write_nested_in_if_match_lambda() {
2060        let program = store_write_fixture();
2061        let handler = find_handler(find_agent(&program, "Widget"), "nestedMutation");
2062        assert!(body_writes_state(&handler.body, program.program()));
2063    }
2064
2065    #[test]
2066    fn body_writes_state_is_true_for_a_bare_cell_assign() {
2067        let program = store_write_fixture();
2068        let handler = find_handler(find_agent(&program, "Widget"), "bareAssign");
2069        assert!(body_writes_state(&handler.body, program.program()));
2070    }
2071
2072    #[test]
2073    fn body_writes_state_is_true_for_a_cell_update_method_call() {
2074        let program = store_write_fixture();
2075        let handler = find_handler(find_agent(&program, "Widget"), "cellUpdate");
2076        assert!(body_writes_state(&handler.body, program.program()));
2077    }
2078
2079    #[test]
2080    fn body_writes_state_is_true_for_a_set_add_method_call() {
2081        let program = store_write_fixture();
2082        let handler = find_handler(find_agent(&program, "Widget"), "setAdd");
2083        assert!(body_writes_state(&handler.body, program.program()));
2084    }
2085
2086    #[test]
2087    fn body_writes_state_is_true_for_a_log_append_method_call() {
2088        let program = store_write_fixture();
2089        let handler = find_handler(find_agent(&program, "Widget"), "logAppend");
2090        assert!(body_writes_state(&handler.body, program.program()));
2091    }
2092
2093    fn checked_program(source: &str) -> CheckedProgram {
2094        let tokens = lexer::tokenize(source).expect("lex");
2095        let (commons, warnings) = parser::parse_with_warnings(&tokens, source).expect("parse");
2096        let resolved = resolver::resolve(commons).expect("resolve");
2097        let typed = checker::check(resolved).expect("check");
2098        checker::certify(typed, warnings).expect("certify")
2099    }
2100
2101    fn find_type<'a>(program: &'a CheckedProgram, name: &str) -> &'a Arc<TypeDecl> {
2102        program
2103            .program()
2104            .types
2105            .get(name)
2106            .unwrap_or_else(|| panic!("no type named `{name}` in this fixture"))
2107    }
2108
2109    fn find_capability<'a>(program: &'a CheckedProgram, name: &str) -> &'a CapabilityDecl {
2110        program
2111            .program()
2112            .commons
2113            .items
2114            .iter()
2115            .find_map(|item| match item {
2116                CommonsItem::Capability(c) if c.name.name == name => Some(c),
2117                _ => None,
2118            })
2119            .unwrap_or_else(|| panic!("no capability named `{name}` in this fixture"))
2120    }
2121
2122    fn find_provider<'a>(program: &'a CheckedProgram, name: &str) -> &'a ProviderDecl {
2123        program
2124            .program()
2125            .commons
2126            .items
2127            .iter()
2128            .find_map(|item| match item {
2129                CommonsItem::Provider(p) if p.provider_name.name == name => Some(p),
2130                _ => None,
2131            })
2132            .unwrap_or_else(|| panic!("no provider named `{name}` in this fixture"))
2133    }
2134
2135    // The six `type_shape_*` tests below, the capability/provider/handler-kind
2136    // tests after them, and the `body_writes_state_*` tests above were all
2137    // re-created by Slice D1 of `#1542`: each pinned a kept helper only
2138    // through a deleted item constructor (`IrItem::Type`/`Capability`/
2139    // `Provider`/`Service`) and now calls the helper directly, asserting the
2140    // same facts minus the constructor's own wrapper field.
2141
2142    #[test]
2143    fn type_shape_record_resolves_fields_and_generic_rigid_vars() {
2144        let program = checked_program(
2145            r#"
2146commons demo {
2147  type Box[T] = { value: T }
2148}
2149"#,
2150        );
2151        let shape = lower_type_shape_ir(find_type(&program, "Box"), &program);
2152        let TypeShape::Record { fields } = &shape else {
2153            panic!("expected TypeShape::Record, got {shape:?}")
2154        };
2155        assert_eq!(fields.len(), 1);
2156        assert_eq!(fields[0].0, "value");
2157        assert!(matches!(
2158            &*program.program().ty_intern.get(fields[0].1),
2159            Ty::Var(name) if name == "T"
2160        ));
2161    }
2162
2163    #[test]
2164    fn type_shape_sum_resolves_variant_payloads_and_embeds() {
2165        let program = checked_program(
2166            r#"
2167commons demo {
2168  type PaymentError = enum { Declined, InsufficientFunds }
2169
2170  type OrderError =
2171    | OutOfStock(sku: String, qty: Int)
2172    | Payment(reason: PaymentError)
2173    embeds PaymentError as Payment
2174}
2175"#,
2176        );
2177        let shape = lower_type_shape_ir(find_type(&program, "OrderError"), &program);
2178        let TypeShape::Sum { variants, embeds } = &shape else {
2179            panic!("expected TypeShape::Sum, got {shape:?}")
2180        };
2181        assert_eq!(variants.len(), 2);
2182        assert_eq!(variants[0].0, "OutOfStock");
2183        assert_eq!(variants[0].1.len(), 2);
2184        assert_eq!(variants[0].1[0].0, "sku");
2185        assert!(matches!(
2186            &*program.program().ty_intern.get(variants[0].1[0].1),
2187            Ty::Base(bynk_syntax::ast::BaseType::String)
2188        ));
2189        assert_eq!(variants[0].1[1].0, "qty");
2190        assert!(matches!(
2191            &*program.program().ty_intern.get(variants[0].1[1].1),
2192            Ty::Base(bynk_syntax::ast::BaseType::Int)
2193        ));
2194        assert_eq!(variants[1].0, "Payment");
2195        assert_eq!(variants[1].1.len(), 1);
2196        assert_eq!(variants[1].1[0].0, "reason");
2197
2198        assert_eq!(embeds.len(), 1);
2199        let (source, tag) = &embeds[0];
2200        assert_eq!(tag, "Payment");
2201        let Ty::Named { name, .. } = &*program.program().ty_intern.get(*source) else {
2202            panic!("expected embeds source to resolve to a named type")
2203        };
2204        assert_eq!(name, "PaymentError");
2205    }
2206
2207    #[test]
2208    fn type_shape_refined_and_opaque_cover_bare_and_predicated_and_opaque_forms() {
2209        let program = checked_program(
2210            r#"
2211commons demo {
2212  type Age = Int where Positive
2213  type UserId = opaque Int
2214  type Bare = Int
2215}
2216"#,
2217        );
2218        let TypeShape::Refined {
2219            base,
2220            refinement,
2221            opaque,
2222        } = lower_type_shape_ir(find_type(&program, "Age"), &program)
2223        else {
2224            panic!("expected TypeShape::Refined for Age")
2225        };
2226        assert_eq!(base, bynk_syntax::ast::BaseType::Int);
2227        assert!(refinement.is_some());
2228        assert!(!opaque);
2229
2230        let TypeShape::Refined {
2231            refinement, opaque, ..
2232        } = lower_type_shape_ir(find_type(&program, "UserId"), &program)
2233        else {
2234            panic!("expected TypeShape::Refined for UserId")
2235        };
2236        assert!(refinement.is_none());
2237        assert!(opaque);
2238
2239        let TypeShape::Refined {
2240            refinement, opaque, ..
2241        } = lower_type_shape_ir(find_type(&program, "Bare"), &program)
2242        else {
2243            panic!("expected TypeShape::Refined for Bare")
2244        };
2245        assert!(refinement.is_none());
2246        assert!(!opaque);
2247    }
2248
2249    #[test]
2250    fn type_shape_sum_covers_a_payload_less_variant() {
2251        let program = checked_program(
2252            r#"
2253commons demo {
2254  type PaymentError = enum { Declined, InsufficientFunds }
2255}
2256"#,
2257        );
2258        let shape = lower_type_shape_ir(find_type(&program, "PaymentError"), &program);
2259        let TypeShape::Sum { variants, embeds } = &shape else {
2260            panic!("expected TypeShape::Sum, got {shape:?}")
2261        };
2262        assert_eq!(variants.len(), 2);
2263        assert_eq!(variants[0].0, "Declined");
2264        assert!(
2265            variants[0].1.is_empty(),
2266            "a bare variant carries no payload"
2267        );
2268        assert_eq!(variants[1].0, "InsufficientFunds");
2269        assert!(variants[1].1.is_empty());
2270        assert!(embeds.is_empty());
2271    }
2272
2273    #[test]
2274    fn type_shape_record_drops_a_fields_own_inline_refinement() {
2275        // Decision B extension, `bynk-ir`'s own `TypeShape::Record` doc
2276        // comment: a field's inline `where` clause is a construction-time
2277        // constraint the checker already enforces, not part of the emitted
2278        // shape — pin that the field still lowers to `(name, ty)` with the
2279        // refinement silently absent, not that lowering rejects it.
2280        let program = checked_program(
2281            r#"
2282commons demo {
2283  type Account = { balance: Int where NonNegative }
2284}
2285"#,
2286        );
2287        let shape = lower_type_shape_ir(find_type(&program, "Account"), &program);
2288        let TypeShape::Record { fields } = &shape else {
2289            panic!("expected TypeShape::Record, got {shape:?}")
2290        };
2291        assert_eq!(fields.len(), 1);
2292        assert_eq!(fields[0].0, "balance");
2293        assert!(matches!(
2294            &*program.program().ty_intern.get(fields[0].1),
2295            Ty::Base(bynk_syntax::ast::BaseType::Int)
2296        ));
2297    }
2298
2299    #[test]
2300    fn type_shape_record_resolves_a_generic_type_application_field() {
2301        // The `TypeRef::App` arm of `resolve_type_ref_in` — the one arm
2302        // that returns `None` for an unknown/unapplied name, and so the one
2303        // most likely to silently hit this pass's own ADR 0334 panic if the
2304        // field's resolution were ever wired up wrong.
2305        let program = checked_program(
2306            r#"
2307commons demo {
2308  type Box[T] = { value: T }
2309  type Wrapper = { boxed: Box[Int] }
2310}
2311"#,
2312        );
2313        let shape = lower_type_shape_ir(find_type(&program, "Wrapper"), &program);
2314        let TypeShape::Record { fields } = &shape else {
2315            panic!("expected TypeShape::Record, got {shape:?}")
2316        };
2317        assert_eq!(fields.len(), 1);
2318        assert_eq!(fields[0].0, "boxed");
2319        let Ty::Named { name, args, .. } = &*program.program().ty_intern.get(fields[0].1) else {
2320            panic!("expected `boxed` to resolve to a named type")
2321        };
2322        assert_eq!(name, "Box");
2323        assert_eq!(args.len(), 1);
2324        assert!(matches!(
2325            &*program.program().ty_intern.get(args[0]),
2326            Ty::Base(bynk_syntax::ast::BaseType::Int)
2327        ));
2328    }
2329
2330    #[test]
2331    fn lower_capability_ops_ir_assembles_ops_in_declaration_order() {
2332        let program = checked_context_program(
2333            r#"
2334context demo
2335
2336capability Store {
2337  fn get(key: String) -> Effect[Int]
2338  fn put(key: String, value: Int) -> Effect[()]
2339}
2340"#,
2341        );
2342        let ops = lower_capability_ops_ir(find_capability(&program, "Store"), &program);
2343        assert_eq!(ops.len(), 2, "declaration order preserved");
2344
2345        assert_eq!(ops[0].name, "get");
2346        assert!(ops[0].type_params.is_empty());
2347        assert_eq!(ops[0].params.len(), 1);
2348        assert_eq!(ops[0].params[0].0, "key");
2349        assert!(matches!(
2350            &*program.program().ty_intern.get(ops[0].params[0].1),
2351            Ty::Base(bynk_syntax::ast::BaseType::String)
2352        ));
2353        // `return_ty` is Effect-wrapped, not peeled — `get`'s declared
2354        // `Effect[Int]` resolves whole, the same convention `FnSig::ret`
2355        // uses.
2356        assert!(matches!(
2357            &*program.program().ty_intern.get(ops[0].return_ty),
2358            Ty::Effect(inner) if matches!(
2359                &*program.program().ty_intern.get(*inner),
2360                Ty::Base(bynk_syntax::ast::BaseType::Int)
2361            )
2362        ));
2363
2364        assert_eq!(ops[1].name, "put");
2365        assert_eq!(ops[1].params.len(), 2);
2366        assert_eq!(ops[1].params[0].0, "key");
2367        assert_eq!(ops[1].params[1].0, "value");
2368        assert!(matches!(
2369            &*program.program().ty_intern.get(ops[1].params[1].1),
2370            Ty::Base(bynk_syntax::ast::BaseType::Int)
2371        ));
2372    }
2373
2374    #[test]
2375    fn lower_provider_given_ir_preserves_declaration_order_including_unused_entries() {
2376        // `given Random, Clock` (reverse-alphabetical, deliberately) pins
2377        // that `given`'s own declaration order survives — neither op body
2378        // below calls either capability, pinning review of #1186's point
2379        // that an *unused* `given` entry must still appear (it feeds R8.1's
2380        // own `deps` constructor, not anything the op bodies reference).
2381        let program = checked_context_program(
2382            r#"
2383context demo
2384
2385capability Clock {
2386  fn now() -> Effect[Int]
2387}
2388
2389capability Random {
2390  fn next() -> Effect[Int]
2391}
2392
2393capability Store {
2394  fn get(key: String) -> Effect[Int]
2395  fn put(key: String, value: Int) -> Effect[()]
2396}
2397
2398provides Store = MemStore given Random, Clock {
2399  fn get(key: String) -> Effect[Int] {
2400    Effect.pure(0)
2401  }
2402  fn put(key: String, value: Int) -> Effect[()] {
2403    Effect.pure(())
2404  }
2405}
2406"#,
2407        );
2408        let given = lower_provider_given_ir(find_provider(&program, "MemStore"));
2409        assert_eq!(
2410            given.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(),
2411            vec!["Random", "Clock"],
2412            "given's own declaration order preserved, unused entries included"
2413        );
2414        assert!(given.iter().all(|g| g.context.is_none()));
2415    }
2416
2417    #[test]
2418    fn lower_provider_given_ir_reads_an_external_providers_given_too() {
2419        // v0.17: an external (bodiless) provider is only legal inside an
2420        // `adapter` unit (`bynk-check/src/symbols.rs`), which this test
2421        // harness's own `checked_context_program` cannot build (it only
2422        // ever parses a `context`). `lower_provider_given_ir` never reads
2423        // `program` at all, so this hand-constructs the `ProviderDecl` the
2424        // parser would produce for `provides Store = ExternalStore given
2425        // Clock` inside an adapter. Nothing in the grammar or checker gates
2426        // `given` on `external`, so an external provider's `given` must come
2427        // through the same way a Bynk one's does (review of #1187's own
2428        // Provider given/deps-wiring slice).
2429        let provider = ProviderDecl {
2430            capability: bynk_syntax::ast::Ident {
2431                name: "Store".to_string(),
2432                span: Span::default(),
2433            },
2434            provider_name: bynk_syntax::ast::Ident {
2435                name: "ExternalStore".to_string(),
2436                span: Span::default(),
2437            },
2438            given: vec![CapRef {
2439                context: None,
2440                name: bynk_syntax::ast::Ident {
2441                    name: "Clock".to_string(),
2442                    span: Span::default(),
2443                },
2444                span: Span::default(),
2445            }],
2446            ops: Vec::new(),
2447            external: true,
2448            documentation: None,
2449            span: Span::default(),
2450            trivia: Default::default(),
2451        };
2452        let given = lower_provider_given_ir(&provider);
2453        assert_eq!(given.len(), 1);
2454        assert_eq!(given[0].context, None);
2455        assert_eq!(given[0].name, "Clock");
2456    }
2457
2458    #[test]
2459    fn an_http_service_lowers_its_protocol_and_per_handler_route_kind() {
2460        let program = checked_context_program(
2461            r#"
2462context demo
2463
2464fn ok(s: String) -> HttpResult[String] { Ok(s) }
2465
2466service Api from http {
2467  on GET("/ping") () -> Effect[HttpResult[String]] by v: Visitor {
2468    Effect.pure(ok("pong"))
2469  }
2470}
2471"#,
2472        );
2473        let service = find_service(&program, "Api");
2474        assert!(matches!(
2475            lower_protocol_ir(&service.protocol, &program),
2476            ProtocolIr::Http
2477        ));
2478        assert_eq!(
2479            lower_handler_kind_ir(&service.handlers[0].kind),
2480            IrHandlerKind::Http {
2481                method: IrHttpMethod::Get,
2482                path: "/ping".to_string(),
2483            },
2484            "the route binding lives per-handler — this is why ProtocolIr::Http itself \
2485             carries no payload"
2486        );
2487    }
2488
2489    #[test]
2490    fn a_cron_service_lowers_its_schedule_from_the_handler_not_the_protocol() {
2491        let program = checked_context_program(
2492            r#"
2493context demo
2494
2495fn done() -> Result[(), String] { Ok(()) }
2496
2497service Sweeper from cron {
2498  on schedule("*/5 * * * *") () -> Effect[Result[(), String]] {
2499    Effect.pure(done())
2500  }
2501}
2502"#,
2503        );
2504        let service = find_service(&program, "Sweeper");
2505        assert!(matches!(
2506            lower_protocol_ir(&service.protocol, &program),
2507            ProtocolIr::Cron
2508        ));
2509        assert_eq!(
2510            lower_handler_kind_ir(&service.handlers[0].kind),
2511            IrHandlerKind::Cron {
2512                expr: "*/5 * * * *".to_string()
2513            }
2514        );
2515    }
2516}