Skip to main content

bynk_check/
checker.rs

1//! Type checker and refinement validator (spec §§5–6, v0.1 §4.2, v0.2 §4.2).
2//!
3//! Operates on a [`ResolvedCommons`]. Walks declarations, validates each
4//! refinement against the spec's predicate-base compatibility and combination
5//! rules, then type-checks every function and method body.
6//!
7//! v0.2 extensions:
8//! - Record types (compatibility, field access, construction).
9//! - Sum types and variant construction (qualified and unqualified).
10//! - Methods (instance and static) with UFCS-style call resolution.
11//! - Pattern matching with exhaustiveness checking.
12//! - The `is` operator with binding flow into truthy contexts.
13//! - The built-in generic `Option[T]`.
14
15use std::collections::{HashMap, HashSet};
16#[cfg(debug_assertions)]
17use std::sync::atomic::{AtomicU32, Ordering};
18use std::sync::{Arc, Mutex};
19
20use crate::builtin_names::map_query;
21use crate::builtin_names::methods::*;
22use crate::builtin_names::types::*;
23use crate::hints::HintSink;
24use crate::index::{RefSink, SymbolKind};
25use crate::locals::LocalsSink;
26use crate::requirements::{
27    Materialize, Requirement, RequirementSink, RequirementSource, StoreKind,
28};
29use crate::resolver::{MethodTable, ResolvedCommons};
30use bynk_syntax::ast::*;
31use bynk_syntax::error::{Applicability, CompileError};
32use bynk_syntax::span::Span;
33
34/// P6.27 (design/tracks/the-ir.md §6a): re-exported so a checker consumer keying
35/// off `expr_types`/`Callee` (both `HashMap<ExprId, _>`, Q2's own settled totality
36/// story) can name `ExprId` through `bynk-check` alone, without also depending on
37/// `bynk_syntax::ast` directly just to spell this one identity type — the same
38/// public-dependency-already-exists shape as `Ty`/`TyId` below.
39pub use bynk_syntax::ast::ExprId;
40
41mod calls;
42mod expressions;
43mod kernels;
44mod linearity;
45mod refinements;
46
47use calls::*;
48use expressions::*;
49use kernels::*;
50use refinements::*;
51
52pub use calls::{check_event_field_default, check_state_initialiser};
53pub use refinements::{locale_tag_accepts, locale_tag_pattern, zero_value_ts};
54
55// ==== Type representation ====
56
57/// T3.6b (R4.1): the intern table `TyId` is minted from. Owned per
58/// `check_record` invocation (design settled in the identity-and-totality
59/// track doc §9 before this slice started): created fresh at `check_record`'s
60/// entry, threaded through `Ctx`, carried out on `TypedCommons`/`RecordCheck`
61/// alongside `expr_types`, and forwarded across the `bynk-check`→`bynk-emit`
62/// boundary on `CheckedProgram` (T3.7a/T3.7b already built that seam).
63/// Confirmed safe by checking how cross-unit type references actually flow:
64/// `compose_unit_symbols` merges `TypeDecl` (immutable AST declarations)
65/// across units, never an already-interned `Ty`/`TyId` — every unit
66/// re-interns its own `Ty` graph from shared declarations, so `TyId`s are
67/// never compared across two different `check_record` invocations.
68///
69/// **Why [`intern`](Self::intern) takes `&self`, not `&mut self`.** The table
70/// is reached from `Ctx`, whose other fields (`expr_types`, `errors`, the
71/// sinks) are themselves `&mut` and are routinely live across an interning
72/// call — `ctx.tys.intern(…)` inside a loop over `ctx.scopes` is the common
73/// shape, not the exception. A `&mut Types` would make the borrow checker,
74/// not the type system, the thing every one of the ~200 minting sites is
75/// written around. Interior mutability keeps `&'a Types` `Copy`, so a
76/// function that needs the table just reads `ctx.tys` once and is done.
77///
78/// **Why a `Mutex` and `Arc`, not a `RefCell` and `Rc`.** The compiler itself
79/// is single-threaded, so a cell would do for `bynk-check` and `bynk-emit` —
80/// but the table rides out on `TypedCommons`/`ProjectAnalysis` into
81/// `bynk-lsp`, whose `tower-lsp` handlers are `async` and therefore require
82/// `Send`. A non-atomic refcount is exactly what `Send` forbids, so the
83/// choice is made by the consumer, not by the compiler's own threading. The
84/// lock is uncontended in every current caller.
85pub struct Types {
86    inner: Mutex<TypesInner>,
87    /// Which table this is, so [`Types::get`] can reject a foreign `TyId`
88    /// whose index happens to be in range — see [`TyId`]'s own note.
89    #[cfg(debug_assertions)]
90    tag: u32,
91}
92
93/// Hands each [`Types`] a distinct [`Types::tag`]. Wrapping is not a
94/// correctness problem: it would take 2^32 tables in one process for two to
95/// collide, and the guard is a debug-build aid, not a soundness argument.
96#[cfg(debug_assertions)]
97static NEXT_TABLE_TAG: AtomicU32 = AtomicU32::new(0);
98
99impl Default for Types {
100    fn default() -> Self {
101        Self {
102            inner: Mutex::default(),
103            #[cfg(debug_assertions)]
104            tag: NEXT_TABLE_TAG.fetch_add(1, Ordering::Relaxed),
105        }
106    }
107}
108
109#[derive(Debug, Default)]
110struct TypesInner {
111    /// `TyId(i)` resolves to `table[i]`. `Arc` so [`Types::get`] hands back a
112    /// handle by refcount bump rather than cloning the node, and so the same
113    /// allocation backs both `table` and `index` without storing it twice.
114    table: Vec<Arc<Ty>>,
115    index: HashMap<Arc<Ty>, TyId>,
116}
117
118impl Types {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// Intern `ty`, returning its `TyId`. The same `Ty` value (by `Eq`)
124    /// always yields the same `TyId` — the property `ty_hash_eq_ord_tests`
125    /// (T3.6b's own settling-review prerequisite) pins directly. Dedup is by
126    /// the *shallow* `Ty`, which is sound precisely because every recursive
127    /// field is already a `TyId`: two structurally-equal types have equal
128    /// children ids by induction, so they hash and compare equal here.
129    pub fn intern(&self, ty: Ty) -> TyId {
130        let mut inner = self.lock();
131        if let Some(&id) = inner.index.get(&ty) {
132            return id;
133        }
134        let node = Arc::new(ty);
135        let id = TyId {
136            idx: inner.table.len() as u32,
137            #[cfg(debug_assertions)]
138            tag: self.tag,
139        };
140        inner.table.push(Arc::clone(&node));
141        inner.index.insert(node, id);
142        id
143    }
144
145    /// The node `id` was interned from.
146    ///
147    /// Panics on a `TyId` minted by a *different* table. That is the one new
148    /// failure mode interning introduces, and it is a wiring bug in the
149    /// compiler, never something a Bynk program can provoke — so it fails
150    /// loudly and by name rather than as a bare index-out-of-bounds. It was
151    /// worth the message: this fired twice while T3.6b was being built, both
152    /// times a synthesised `TypedCommons` that had been given a table of its
153    /// own while its `expr_types` was filled in from another.
154    ///
155    /// Both of those were the *shorter*-table shape, where a bounds check
156    /// alone catches it. The dangerous shape is the other one: a foreign id
157    /// that happens to be in range resolves to an unrelated `Ty` and the
158    /// caller mis-diagnoses or mis-emits in silence. So in debug builds the
159    /// check is identity, not length — [`TyId`] carries its table's tag and
160    /// this compares it. Release builds keep the bounds check only, which is
161    /// what indexing would have cost anyway.
162    pub fn get(&self, id: TyId) -> Arc<Ty> {
163        #[cfg(debug_assertions)]
164        assert!(
165            id.tag == self.tag,
166            "bynk internal error (T3.6b, R4.1): {id:?} resolved against a table it was not \
167             interned into (this is table {}). A `TyId` is only meaningful in its own `Types` — \
168             check that whatever produced this id and whatever is reading it share one table",
169            self.tag
170        );
171        let inner = self.lock();
172        match inner.table.get(id.idx as usize) {
173            Some(node) => Arc::clone(node),
174            None => panic!(
175                "bynk internal error (T3.6b, R4.1): {id:?} resolved against a table it was not \
176                 interned into (this table holds {}). A `TyId` is only meaningful in its own \
177                 `Types` — check that whatever produced this id and whatever is reading it share \
178                 one table",
179                inner.table.len()
180            ),
181        }
182    }
183
184    /// [`Ty::display`] for an already-interned type.
185    pub fn display(&self, id: TyId) -> String {
186        self.get(id).display(self)
187    }
188
189    /// Number of distinct types interned so far. Exposed for the interner's
190    /// own tests (dedup is observable only as "the table did not grow").
191    pub fn len(&self) -> usize {
192        self.lock().table.len()
193    }
194
195    /// The lock, recovered from poisoning. `intern` never panics while
196    /// holding it (it only pushes to a `Vec` and a `HashMap`), so a poisoned
197    /// lock can only mean an unrelated panic unwound past a live guard —
198    /// where the table is still structurally sound.
199    fn lock(&self) -> std::sync::MutexGuard<'_, TypesInner> {
200        self.inner.lock().unwrap_or_else(|e| e.into_inner())
201    }
202
203    pub fn is_empty(&self) -> bool {
204        self.len() == 0
205    }
206}
207
208impl std::fmt::Debug for Types {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        f.debug_struct("Types")
211            .field("len", &self.len())
212            .finish_non_exhaustive()
213    }
214}
215
216/// T3.6b (R4.1/R4.2): a `Ty`'s identity above the intern table — `Copy`,
217/// `Hash`, `Ord`, cheap to pass and compare. Resolved back to a `Ty` only
218/// via the [`Types`] table it was interned into (see that type's own doc).
219///
220/// In debug builds it also carries the tag of the table it came from, so
221/// [`Types::get`] can make good on its "interned into another table" promise
222/// for a foreign id whose index is merely *in range* — the case a bounds
223/// check cannot see, and the one that would otherwise resolve to an
224/// unrelated `Ty` in silence. `idx` is declared first so the derived `Ord`
225/// still orders by insertion within a table, exactly as it does in release.
226#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
227pub struct TyId {
228    idx: u32,
229    #[cfg(debug_assertions)]
230    tag: u32,
231}
232
233impl std::fmt::Debug for TyId {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        #[cfg(debug_assertions)]
236        return write!(f, "TyId({} of table {})", self.idx, self.tag);
237        #[cfg(not(debug_assertions))]
238        return write!(f, "TyId({})", self.idx);
239    }
240}
241
242impl TyId {
243    /// The interned node, for the (many) sites that need to look at the
244    /// type's shape. Sugar for [`Types::get`], so a `TyId` reads like the
245    /// `&Ty` it replaced.
246    pub fn get(self, tys: &Types) -> Arc<Ty> {
247        tys.get(self)
248    }
249
250    /// [`Ty::display`] for this id — the form nearly every diagnostic uses.
251    pub fn display(self, tys: &Types) -> String {
252        tys.display(self)
253    }
254
255    /// True if this type is `Effect[_]` (v0.5).
256    pub fn is_effect(self, tys: &Types) -> bool {
257        tys.get(self).is_effect()
258    }
259
260    /// v0.102: true if this type belongs to the closed `Held` kind.
261    pub fn is_held(self, tys: &Types) -> bool {
262        tys.get(self).is_held()
263    }
264
265    /// The underlying base type, if this type widens to one.
266    pub fn base(self, tys: &Types) -> Option<BaseType> {
267        tys.get(self).base()
268    }
269}
270
271/// A resolved type.
272#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
273pub enum Ty {
274    /// R4.3 measurement probe (not shipped): a real Error variant.
275    Error,
276    /// A base type (`Int`, `String`, `Bool`).
277    Base(BaseType),
278    /// A user-declared named type. `kind` records the declaration's shape
279    /// for compatibility / dispatch decisions. `args` holds the applied type
280    /// arguments of a generic type (`Paginated[String]` → `args = [String]`);
281    /// it is empty for a non-generic type (v0.157, ADR 0183). Substitution,
282    /// unification, and display recurse into `args`.
283    Named {
284        name: String,
285        kind: NamedKind,
286        args: Vec<TyId>,
287    },
288    /// `Result[T, E]`.
289    Result(TyId, TyId),
290    /// `Option[T]`.
291    Option(TyId),
292    /// `Effect[T]` (v0.5).
293    Effect(TyId),
294    /// `HttpResult[T]` (v0.9).
295    HttpResult(TyId),
296    /// `QueueResult` — the built-in queue verdict sum (v0.44). Non-generic.
297    QueueResult,
298    /// `List[T]` — built-in immutable list (v0.20b).
299    List(TyId),
300    /// `Map[K, V]` — built-in immutable map (v0.20b). The key type is
301    /// confined to value-keyable types at TypeRef resolution.
302    Map(TyId, TyId),
303    /// `Query[T]` — a lazy, by-reference description of a read over agent-local
304    /// storage (v0.91, ADR 0115). The inner type is the element a terminal
305    /// yields. Built by the lazy combinator vocabulary over a `store` field,
306    /// executed by a terminal (`-> Effect[…]`). Non-storable, non-boundary, and
307    /// not value-comparable — like `Effect`/`Fn` (ADRs 0031/0030).
308    Query(TyId),
309    /// `Stream[T]` — a lazy, pull-shaped sequence of values produced over time
310    /// (v0.100, real-time track slice 0). The inner type is the element a
311    /// terminal yields. Built from a runtime source (`Stream.of` at v1),
312    /// transformed by lazy builders (`map`/`take`), drained by a terminal
313    /// (`collect -> Effect[List[T]]`). Non-storable, non-boundary, and not
314    /// value-comparable — like `Query`/`Effect`/`Fn` (ADRs 0031/0030).
315    Stream(TyId),
316    /// `Connection[F]` — a held WebSocket connection (v0.102, real-time track
317    /// slice 2). `F` is the server→client frame type. The one concrete instance
318    /// of the closed `Held` kind (`is_held`). Governed by the linearity
319    /// discipline (§2.9): single-owner, mandatory disposal. Non-serialisable,
320    /// non-boundary, non-comparable; storable only in `Cell[Option[Connection]]`
321    /// / `Map[K, Connection]`.
322    Connection(TyId),
323    /// `ValidationError` — built-in error type.
324    ValidationError,
325    /// `JsonError` — built-in JSON-decode error type (v0.22b). A uniform
326    /// record: `kind`/`path`/`message`, all `String`.
327    JsonError,
328    /// `()` — the unit type (v0.5).
329    Unit,
330    /// v0.45: a verified actor binding (`by name: Actor`). The inner type is
331    /// the actor's identity, read as `name.identity`. A boundary-minted, sealed
332    /// value — only ever `.identity`-accessed, never constructed or passed.
333    Actor(TyId),
334    /// v0.52: a resolved multi-actor binding (`by who: A | B`) — an ordered sum
335    /// of peer actors. Each member is `(actor name, identity ty)`; the body
336    /// `match`es on the resolved actor, each non-unit member binding its
337    /// identity directly. Like `Actor`, a sealed boundary value — only ever
338    /// matched, never constructed or passed.
339    ActorSum(Vec<(String, TyId)>),
340    /// `A -> B` — a function type (v0.20a). Effectful iff `ret` is
341    /// `Effect[_]` (the structural rule); no separate flag, so there is a
342    /// single source of truth.
343    Fn { params: Vec<TyId>, ret: TyId },
344    /// A function type parameter (v0.20a). Two lives: *rigid* while checking
345    /// a generic function's own body (name-equality in `compatible`), and
346    /// *flexible* during call-site instantiation, where it is matched by
347    /// `unify` and fully eliminated by `substitute` before any `compatible`
348    /// runs against argument types. Vars never escape call checking into the
349    /// caller's expression types.
350    Var(String),
351}
352
353/// The shape of a named type — what its declaration looks like.
354///
355/// `Refined` widens to its base type when used in arithmetic, comparisons,
356/// and other operations on the base. `Opaque` does NOT widen — its identity
357/// is nominal and the base type is hidden outside the defining commons.
358#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
359pub enum NamedKind {
360    /// Refined-base type: widens to the recorded base.
361    Refined(BaseType),
362    /// Record type.
363    Record,
364    /// Sum type.
365    Sum,
366    /// Opaque base type. The base is hidden; identity is purely nominal.
367    /// The recorded base is used by the type checker (for `.raw`, `.of`,
368    /// `.unsafe`) and by the emitter, but not for compatibility widening.
369    Opaque(BaseType),
370}
371
372impl Ty {
373    /// Display name for diagnostics. Takes the table the type was interned
374    /// into (T3.6b): every recursive field is a `TyId` now, so rendering a
375    /// nested type is a table read rather than a pointer chase.
376    pub fn display(&self, types: &Types) -> String {
377        match self {
378            // R4.3: a resolution failure already has its own diagnostic at the
379            // site that produced this; this string exists only so a *second*
380            // diagnostic that happens to mention the type (e.g. a mismatch one
381            // level up) reads as "type error" rather than a blank or `unknown`.
382            Ty::Error => "<type error>".to_string(),
383            Ty::Base(b) => b.name().to_string(),
384            Ty::Named { name, args, .. } if args.is_empty() => name.clone(),
385            Ty::Named { name, args, .. } => format!(
386                "{}[{}]",
387                name,
388                args.iter()
389                    .map(|a| types.display(*a))
390                    .collect::<Vec<_>>()
391                    .join(", ")
392            ),
393            Ty::Result(t, e) => {
394                format!("Result[{}, {}]", types.display(*t), types.display(*e))
395            }
396            Ty::Option(t) => format!("Option[{}]", types.display(*t)),
397            Ty::Effect(t) => format!("Effect[{}]", types.display(*t)),
398            Ty::HttpResult(t) => format!("HttpResult[{}]", types.display(*t)),
399            Ty::QueueResult => "QueueResult".to_string(),
400            Ty::List(t) => format!("List[{}]", types.display(*t)),
401            Ty::Map(k, v) => format!("Map[{}, {}]", types.display(*k), types.display(*v)),
402            Ty::Query(t) => format!("Query[{}]", types.display(*t)),
403            Ty::Stream(t) => format!("Stream[{}]", types.display(*t)),
404            Ty::Connection(t) => format!("Connection[{}]", types.display(*t)),
405            Ty::ValidationError => "ValidationError".to_string(),
406            Ty::JsonError => "JsonError".to_string(),
407            Ty::Unit => "()".to_string(),
408            Ty::Actor(id) => format!("actor[{}]", types.display(*id)),
409            Ty::ActorSum(members) => members
410                .iter()
411                .map(|(name, _)| name.clone())
412                .collect::<Vec<_>>()
413                .join(" | "),
414            Ty::Fn { params, ret } => {
415                let params = match params.len() {
416                    0 => "()".to_string(),
417                    // A single Fn-typed param needs parens to stay readable
418                    // under right-associativity.
419                    1 if !matches!(&*types.get(params[0]), Ty::Fn { .. }) => {
420                        types.display(params[0])
421                    }
422                    _ => format!(
423                        "({})",
424                        params
425                            .iter()
426                            .map(|p| types.display(*p))
427                            .collect::<Vec<_>>()
428                            .join(", ")
429                    ),
430                };
431                format!("{params} -> {}", types.display(*ret))
432            }
433            Ty::Var(name) => name.clone(),
434        }
435    }
436
437    /// True if this type is `Effect[_]`.
438    pub fn is_effect(&self) -> bool {
439        matches!(self, Ty::Effect(_))
440    }
441
442    /// v0.102: true if this type belongs to the closed `Held` kind — a
443    /// runtime-managed resource governed by the linearity discipline (§2.9).
444    /// The one instance at v1 is `Connection[F]`; the single extension point
445    /// for future held types (file handles, DB connections).
446    pub fn is_held(&self) -> bool {
447        matches!(self, Ty::Connection(_))
448    }
449
450    /// v0.102: for a `Held` type, the held element it wraps (the frame type of a
451    /// `Connection[F]`). Used by the storage-admission rules to look through an
452    /// `Option[Connection]` value.
453    pub fn held_inner(&self) -> Option<TyId> {
454        match self {
455            Ty::Connection(t) => Some(*t),
456            _ => None,
457        }
458    }
459
460    /// The underlying base type, if this type widens to a base type.
461    /// Opaque types deliberately do NOT widen — that's the whole point of
462    /// the opacity — so `Ty::Named { kind: Opaque(_), .. }` returns None.
463    pub fn base(&self) -> Option<BaseType> {
464        match self {
465            Ty::Base(b) => Some(*b),
466            Ty::Named {
467                kind: NamedKind::Refined(b),
468                ..
469            } => Some(*b),
470            _ => None,
471        }
472    }
473}
474
475/// P6.0 (design/tracks/the-ir.md §6, #1139): a resolved classification of a
476/// call-shaped expression, recorded once by the checker's own dispatch
477/// (`checker::calls`) rather than re-derived by each later consumer —
478/// closing R6.10's duplicated-classification gap between `bynk-check` and
479/// `bynk-emit`'s `lower_method_call`/`lower_call`.
480///
481/// Adapted to the identity handles this checker already has (Decision A,
482/// ADR 0333, `the-ir-callee-in-bynk-check`) rather than the reference
483/// document's `DefId`/`LocalId`/`VariantId`/`OpId` arena — none of which
484/// exists here, since the `Resolve` phase that would mint them was never
485/// built (`project-model.md` §3.4 deferred it to phase 8).
486/// `Arc<FnDecl>`/`Arc<TypeDecl>` are already-cheap resolved handles
487/// (`ResolvedCommons::fns`/`types`); every other variant's identity is a
488/// name, exactly as the checker already keys capabilities, store fields,
489/// units, and agents.
490///
491/// Recorded at each dispatch decision as soon as it is known — including on
492/// an error sub-branch (an arity mismatch, an undeclared capability) — since
493/// the *kind* of call is fixed by dispatch, not by whether it went on to
494/// type-check cleanly.
495#[derive(Debug, Clone)]
496pub enum Callee {
497    /// A free function call.
498    Fn(Arc<FnDecl>),
499    /// Applying a function-typed local or parameter (`f(x)` where `f` is in
500    /// scope, not declared). No stable id exists for a local beyond its
501    /// name — the reference's `LocalId` presumes the same `Resolve` phase
502    /// Decision A declines to build here.
503    Value(String),
504    /// Sum-variant construction, bare (`Some(x)`) or qualified
505    /// (`Opt.Some(x)`).
506    Ctor { sum: Arc<TypeDecl>, tag: String },
507    /// `T.of(value)` — the refined/opaque runtime constructor.
508    Refine(Arc<TypeDecl>),
509    /// `T.unsafe(value)` — the opaque constructor, defining-unit only.
510    Unsafe(Arc<TypeDecl>),
511    /// A user-declared static method (`Type.method(...)`).
512    Static(Arc<FnDecl>),
513    /// A user-declared instance method (UFCS), generic or not.
514    Method(Arc<FnDecl>),
515    /// A built-in method on a value — the collection/query/stream/
516    /// connection/numeric/duration/instant/bytes/string/option/result/
517    /// effect kernels, including the refined-receiver fallback (ADR 0168).
518    /// `recv` is the receiver's own checked type; no `KernelOp` enum exists
519    /// yet in this crate (R6.11), so the operation is named, not typed.
520    Kernel { recv: TyId, op: String },
521    /// A built-in static constructor with no declaring type — `List.empty`,
522    /// `Map.empty`, `Int.parse`/`Float.parse`, `Duration.millis`,
523    /// `Instant.fromEpochMillis`, `Bytes.fromUtf8`/`fromBase64`/`empty`,
524    /// `Json.decode`/`encode`, `Stream.of`.
525    Intrinsic { ns: &'static str, op: String },
526    /// A same-context capability operation call (`Cap.op(...)`).
527    Capability { cap: String, op: String },
528    /// A cross-context capability operation call (`B.Cap.op(...)` /
529    /// `Alias.Cap.op(...)`).
530    CrossCap {
531        unit: String,
532        cap: String,
533        op: String,
534    },
535    /// A cross-context service call (`B.service(...)` / `Alias.service(...)`).
536    Cross { unit: String, service: String },
537    /// `AgentName(key)` — agent instance construction. No slot exists for
538    /// this in the reference's own `Callee` taxonomy (Part 6.5 only names
539    /// handler dispatch); added here since this slice covers every call
540    /// shape `check_call` dispatches, not only the ones the reference
541    /// anticipated.
542    AgentInit(String),
543    /// `agent.handler(args)` — agent handler dispatch.
544    Agent { agent: String, handler: String },
545    /// A test-body service address (`svc.call`/`svc.<VERB>("/path", …)`/
546    /// `svc.schedule(...)`/`svc.message(...)`). `check_test_service_address`
547    /// always returns `None` by design (the runner recovers the outcome
548    /// type at runtime) — this classification exists purely for a later
549    /// consumer (e.g. go-to-definition on the address), not typing.
550    TestService { service: String, address: String },
551    /// An effectful `<field>.<op>(…)` storage operation on a `store`
552    /// `Map`/`Set`/`Cache`/`Log`/`Cell` field — R6.5's own named target
553    /// (P6.2, #1143): a mutation detector keyed on this variant, not a
554    /// receiver's bare name, cannot miss a mutation reached through a
555    /// non-`Ident` receiver or false-negative on a shadowed local, the
556    /// defect class `block_writes_state`'s `mutating_op` still carries.
557    /// `field` is the store field's own name (no `FieldId` arena exists —
558    /// same adaptation `check_store_*_op`'s own lookups already use). Note
559    /// this is recorded *outside* `calls.rs`'s six functions — the
560    /// store-field ladder lives directly in `checker.rs`'s own `type_of`,
561    /// never reaching any of them — extending P6.0's own recording surface
562    /// past the boundary its "Done when" deliberately drew.
563    Store { field: String, op: String },
564    /// A query builder/terminal call that *lifts* a bare `store` `Map`/`Log`
565    /// field into a lazy `Query[V]` (`is_query_op`'s own gate,
566    /// `checker.rs`'s `type_of`) — R6.12's own named target (P6.2, #1143).
567    /// `field` names the store field being lifted — without it, a chain
568    /// rooted at this call (`orders.filter(p).count()`) would carry no
569    /// identity for `orders` anywhere in the classification, the same
570    /// information loss R6.5 exists to close on the write side. `role` is
571    /// read back from the checker's own typing decision for this exact call
572    /// (`Ty::Query(_)` result ⇒ `Builder`, anything else ⇒ `Terminal`), not
573    /// a second name-list classifier alongside `is_query_op`'s. A *chained*
574    /// builder/terminal call on an already-`Query`-typed receiver
575    /// (`.filter(p).count()`'s own `.count()`) is not this variant — it
576    /// reaches `check_method_call`'s ordinary kernel dispatch and is
577    /// `Callee::Kernel` already (P6.0); `Query` here exists only because the
578    /// lift call's own outer expression never passes through any of
579    /// `calls.rs`'s six functions to get one.
580    Query {
581        field: String,
582        op: String,
583        role: QueryRole,
584    },
585}
586
587/// Whether a [`Callee::Query`] call returns another `Query[T]` (chainable)
588/// or executes and returns `Effect[T]` — R6.12: "the builder/terminal split
589/// is a field on the callee, not a name list." Primarily read back from
590/// `check_query_kernel_method`'s own return type at the recording site
591/// (`query_role`, below) — falling back to `is_query_builder_name` only
592/// when the call didn't type at all (an arity mismatch, or a type error
593/// deeper inside the call — `map`'s own lambda body, say — both return
594/// `None` too, not just an arity failure), so a best-effort reader of an
595/// uncertified/erroring unit still gets the right role.
596#[derive(Debug, Clone, Copy, PartialEq, Eq)]
597pub enum QueryRole {
598    Builder,
599    Terminal,
600}
601
602/// The fallback `Callee::Query::role` classifier for when
603/// `check_query_kernel_method`'s own return type doesn't settle it (see
604/// `QueryRole`'s doc comment). Kept in sync by hand with
605/// `check_query_kernel_method`'s own match arms
606/// (`bynk-check/src/checker/kernels.rs:861-1118`) — the same "kept in sync
607/// by hand" risk R6.11 already names for `kernel_methods.rs`'s own
608/// registries, not a new class of drift this slice introduces.
609fn is_query_builder_name(name: &str) -> bool {
610    matches!(
611        name,
612        "map"
613            | "filter"
614            | "flatMap"
615            | "sortBy"
616            | "take"
617            | "skip"
618            | "distinct"
619            | "distinctBy"
620            | "joinOn"
621            | "leftJoin"
622            | "join"
623            | "groupBy"
624    )
625}
626
627/// P6.2 (#1143): `Callee::Query`'s `role` for a call whose op name is `op`
628/// and whose checked result (from `check_query_kernel_method`/
629/// `check_store_log_op`) is `result`.
630fn query_role(result: Option<TyId>, op: &str, tys: &Types) -> QueryRole {
631    match result.map(|t| tys.get(t)).as_deref() {
632        Some(Ty::Query(_)) => QueryRole::Builder,
633        Some(_) => QueryRole::Terminal,
634        None if is_query_builder_name(op) => QueryRole::Builder,
635        None => QueryRole::Terminal,
636    }
637}
638
639/// Output of type checking.
640pub struct TypedCommons {
641    pub commons: Commons,
642    pub types: HashMap<String, Arc<TypeDecl>>,
643    pub fns: HashMap<String, Arc<FnDecl>>,
644    pub methods: HashMap<String, MethodTable>,
645    /// T3.4 (R2.4/R2.5): keyed by [`ExprId`] — a node's identity, not its
646    /// position. The value carries its own `span` alongside `ty`, so
647    /// LSP-facing consumers that need "type at this cursor offset" (a
648    /// position-shaped question, asked at the editor boundary, not the
649    /// checker's own identity) can still answer it without a second map.
650    pub expr_types: HashMap<ExprId, TypedExpr>,
651    /// P6.0 (#1139): the call-shaped expressions this unit's checker
652    /// dispatched, classified once here rather than re-derived by
653    /// `bynk-emit`'s lowering (P6.2) or any other later consumer. Mirrors
654    /// `expr_types` exactly — same key, same "recorded during checking, read
655    /// afterward" shape.
656    pub callees: HashMap<ExprId, Callee>,
657    /// v0.89 (ADR 0117): non-failing warnings produced while checking this unit
658    /// — surfaced but not gating. Empty unless a warning-category diagnostic
659    /// (e.g. `bynk.given.unused_capability`) fired on an otherwise-clean check.
660    pub warnings: Vec<CompileError>,
661    /// T3.6b (R4.1): the intern table every `TyId` on this unit — in
662    /// `expr_types`, in a `Ty` node's own recursive fields — was minted from.
663    /// Named `ty_intern` rather than `types` only because `types` above is
664    /// already this struct's *declaration* table (`TypeDecl` by name); the two
665    /// are unrelated. `Rc` so [`RecordCheck`] can hand the same table out
666    /// alongside `partial_expr_types` on the error path, where no
667    /// `TypedCommons` is built to own it.
668    pub ty_intern: Arc<Types>,
669    /// #1170: a service handler's own resolved `by <binder>: <Actor>` actor
670    /// binding — `handler_actor_binding`'s own return value
671    /// (`context_checks.rs`), persisted here rather than discarded once
672    /// `check_service_decls`'s own per-handler loop moves on, the same
673    /// "recorded during checking, read afterward" shape `callees` (above)
674    /// already established. Keyed by the handler's own `span`: a `Handler`
675    /// has no arena identity of its own (no `DefId`/`ExprId` — it is a
676    /// declaration, not an expression), and `Span` is already this
677    /// codebase's established "no arena" substitute for exactly this kind
678    /// of identity (`Copy`/`Eq`/`Hash`, already used as a diagnostic anchor
679    /// throughout `context_checks.rs`). No entry for a handler
680    /// `handler_actor_binding` itself resolves to `None` for: a
681    /// binder-less `by <Actor>` clause, or no `by` clause at all —
682    /// including every agent handler, which cannot carry one
683    /// (`bynk.actor.by_on_agent`). As of P6.11 (#1171),
684    /// `bynk-emit::ir::lower`'s `lower_service_handler_ir` is the real
685    /// consumer that reads this back to build a real service-handler
686    /// `ActorBinder` — `lower_handler_ir` (agent-only, P6.9, #1167) never
687    /// does, deliberately (`bynk-emit::ir::IrHandler`'s own doc comment).
688    ///
689    /// **Unit-wide, not per-file** (review of #1170, unlike `callees`/
690    /// `expr_types`, which are genuinely per-file — keyed by `ExprId`s this
691    /// file's own checking pass minted): `check_service_decls` walks
692    /// `table.services`, the whole unit's own `UnitTable`, not just this
693    /// file's declarations, so every file of a multi-file `context` ends up
694    /// with the *entire unit's* bindings in its own `TypedCommons`. Harmless
695    /// for a by-span lookup (a span is only ever looked up in the file that
696    /// actually owns it), but a future consumer that *iterates* this map
697    /// rather than looking up one known `span` would see sibling files'
698    /// handlers too — worth knowing before writing that consumer, not
699    /// discovering it by surprise.
700    pub actor_bindings: HashMap<Span, (String, TyId)>,
701}
702
703impl TypedCommons {
704    /// A `TypedCommons` with no declarations — a pure-data test fixture, so a
705    /// consuming crate's own test exercising only the `TypedCommons`-shaped
706    /// part of some function doesn't need to hand-construct a
707    /// `Commons`/`QualifiedName` itself (`bynk_syntax::ast` types, invisible
708    /// to `bynk-check` but not to a consumer whose own probe tracks that
709    /// dependency — P6.38, `design/tracks/the-ir.md` §6a).
710    pub fn empty() -> Self {
711        TypedCommons {
712            commons: Commons {
713                name: QualifiedName {
714                    parts: Vec::new(),
715                    span: Default::default(),
716                },
717                items: Vec::new(),
718                uses: Vec::new(),
719                documentation: None,
720                form: CommonsForm::Fragment,
721                span: Default::default(),
722                trivia: Default::default(),
723                trailing_comments: Vec::new(),
724            },
725            types: HashMap::new(),
726            fns: HashMap::new(),
727            methods: HashMap::new(),
728            expr_types: HashMap::new(),
729            callees: HashMap::new(),
730            warnings: Vec::new(),
731            ty_intern: Arc::new(Types::new()),
732            actor_bindings: HashMap::new(),
733        }
734    }
735
736    /// T3.6b (R4.1): this unit's intern table — what every `TyId` reachable
737    /// from `expr_types` resolves against.
738    /// Returns the `Rc` handle rather than a bare `&Types` so a caller that
739    /// needs to *share* the table (the project path, which checks many units
740    /// into one `ExprTypeSink`) can clone it; `&Arc<Types>` deref-coerces to
741    /// `&Types` everywhere a plain borrow is wanted.
742    pub fn tys(&self) -> &Arc<Types> {
743        &self.ty_intern
744    }
745
746    /// The interned node an expression was typed to, resolved in one step.
747    /// The reader-side shape `bynk-emit`/the LSP want: they ask "what shape is
748    /// this expression?", never "which id is it?". `Rc` so the resolve is a
749    /// refcount bump, and `.as_deref()` gives back the `&Ty` these call sites
750    /// read before T3.6b.
751    pub fn expr_ty(&self, id: ExprId) -> Option<Arc<Ty>> {
752        self.expr_types.get(&id).map(|te| self.ty_intern.get(te.ty))
753    }
754
755    /// P6.0 (#1139): the resolved [`Callee`] classification for a
756    /// call-shaped expression, if this unit's checker dispatched one at
757    /// `id`. Mirrors [`Self::expr_ty`]'s shape.
758    pub fn callee(&self, id: ExprId) -> Option<&Callee> {
759        self.callees.get(&id)
760    }
761
762    /// #1170: a service handler's own resolved actor binding, if
763    /// `handler_actor_binding` (`context_checks.rs`) resolved one for the
764    /// handler at `span`. Mirrors [`Self::callee`]'s shape — the single
765    /// documented read point for `actor_bindings`, kept symmetric with
766    /// `expr_ty`/`callee` rather than leaving every future consumer to
767    /// reach into the `HashMap` directly. Real reader as of P6.11 (#1171):
768    /// `bynk-emit::ir::lower`'s `lower_service_handler_ir`.
769    pub fn actor_binding(&self, span: Span) -> Option<&(String, TyId)> {
770        self.actor_bindings.get(&span)
771    }
772}
773
774/// T3.4: an `expr_types` entry — the checked type, plus the span of the node
775/// it was computed for. `Deref`-free by design (`.ty`/`.span`, not `.0`/`.1`)
776/// so call sites read the same as they did against a bare `Ty` before this.
777///
778/// T3.6b (R4.1/R4.2): `ty` is a `TyId`, not a `Ty` — the whole entry is
779/// `Copy`-cheap, and resolving it needs the unit's `ty_intern` table.
780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
781pub struct TypedExpr {
782    pub span: Span,
783    pub ty: TyId,
784}
785
786/// The outcome of [`check_record`]: the typed model (`Err` if the file had any
787/// error) and, on the error path, the best-effort partial `expr_types` the
788/// checker computed before bailing. Analyse mode surfaces that partial map for
789/// `.`-member completion and signature help even on a broken buffer (ADR 0094);
790/// on the Ok path the types live in the `TypedCommons`, so this is empty.
791pub struct RecordCheck {
792    pub result: Result<TypedCommons, Vec<CompileError>>,
793    pub partial_expr_types: HashMap<ExprId, TypedExpr>,
794    /// T3.6b (R4.1): the table `partial_expr_types`' `TyId`s resolve against.
795    /// The same `Rc` the `Ok` path's `TypedCommons::ty_intern` carries, so a
796    /// caller that reads either map has the table either way.
797    pub ty_intern: Arc<Types>,
798}
799
800/// T3.7 (R3.10): the gate between analysis and emission, as a type rather
801/// than a control-flow decision — constructible only by [`certify`], so no
802/// unchecked or error-carrying `TypedCommons` can reach the emitter by
803/// construction (previously enforced only by every caller happening to check
804/// a `Result` first). `certify` rejects on any error-severity diagnostic;
805/// T3.3a's `Ty::Error` is what a diagnosed checker failure records into
806/// `expr_types`, so in practice a `Ty::Error` never reaches a `CheckedProgram`
807/// either — R4.3's "rejected by certify" already holds today via the same
808/// diagnostic-severity gate `certify` makes structural.
809///
810/// Scoped to the single-file compile path for now (`bynk-emit`'s
811/// `compile_with_warnings`). The project/batch path's per-unit `emit_project`
812/// call happens *before* that unit's build-wide gate is finally decided
813/// (cross-unit validation can still fail the whole build afterward), so
814/// wrapping it in `CheckedProgram` at today's call site would misrepresent an
815/// unfinished decision as a certified one — that path needs its own slice,
816/// not forced into this one.
817pub struct CheckedProgram(TypedCommons);
818
819impl CheckedProgram {
820    /// The certified program. No accessor exists that goes the other
821    /// direction — a `TypedCommons` is never recoverable-then-rewrapped
822    /// without going through `certify` again.
823    pub fn program(&self) -> &TypedCommons {
824        &self.0
825    }
826}
827
828/// The single place "may we emit?" is asked (R3.10). Rejects — returning
829/// every diagnostic, not just the error-severity ones, matching
830/// `check_record`'s own error-path convention — if `diagnostics` contains an
831/// error-severity entry; otherwise wraps `program` as certified.
832pub fn certify(
833    program: TypedCommons,
834    diagnostics: Vec<CompileError>,
835) -> Result<CheckedProgram, Vec<CompileError>> {
836    let (hard_errors, warnings) = bynk_syntax::partition_by_severity(diagnostics);
837    if hard_errors.is_empty() {
838        Ok(CheckedProgram(program))
839    } else {
840        let mut all = hard_errors;
841        all.extend(warnings);
842        Err(all)
843    }
844}
845
846// ==== Entry points ====
847
848pub fn check(input: ResolvedCommons) -> Result<TypedCommons, Vec<CompileError>> {
849    check_record(
850        input,
851        &mut RefSink::new(),
852        &mut HintSink::new(),
853        &mut LocalsSink::new(),
854        &mut RequirementSink::new(),
855    )
856    .result
857}
858
859/// [`check`], recording binding edges into `refs` at the checker's
860/// resolution sites (v0.25). A fresh sink records nothing.
861pub fn check_record(
862    input: ResolvedCommons,
863    refs: &mut RefSink,
864    hints: &mut HintSink,
865    locals: &mut LocalsSink,
866    requirements: &mut RequirementSink,
867) -> RecordCheck {
868    check_record_in(
869        input,
870        &Arc::new(Types::new()),
871        refs,
872        hints,
873        locals,
874        requirements,
875    )
876}
877
878/// [`check_record`] against a **caller-supplied** intern table (T3.6b, R4.1).
879///
880/// The per-invocation table `check_record` mints is the right default: one
881/// unit, one table, ids that never escape it. A *project* check is the case
882/// that needs more — it runs `check_record` once per unit but funnels every
883/// unit's `expr_types` into one `ExprTypeSink`, so a `TyId` recorded there
884/// would be ambiguous if each unit interned into a table of its own. Sharing
885/// one table across the whole analysis makes those ids mean one thing, and is
886/// strictly safer than the per-unit case the track doc argued for: ids are
887/// still only ever compared against ids from the same table.
888pub fn check_record_in(
889    input: ResolvedCommons,
890    ty_intern: &Arc<Types>,
891    refs: &mut RefSink,
892    hints: &mut HintSink,
893    locals: &mut LocalsSink,
894    requirements: &mut RequirementSink,
895) -> RecordCheck {
896    let ty_intern = Arc::clone(ty_intern);
897    let mut errors = Vec::new();
898    let mut expr_types: HashMap<ExprId, TypedExpr> = HashMap::new();
899    let mut callees: HashMap<ExprId, Callee> = HashMap::new();
900    // 1. Validate each type declaration.
901    for item in &input.commons.items {
902        if let CommonsItem::Type(t) = item {
903            check_type_decl(t, &input.types, &ty_intern, &mut errors);
904        }
905    }
906
907    // 2. Type-check each function and method body.
908    for item in &input.commons.items {
909        if let CommonsItem::Fn(f) = item {
910            refs.set_owner(f.name.display());
911            check_fn(
912                f,
913                &input,
914                &mut expr_types,
915                &mut callees,
916                &mut errors,
917                refs,
918                hints,
919                locals,
920                requirements,
921                &ty_intern,
922            );
923            refs.clear_owner();
924        }
925    }
926
927    // v0.89 (ADR 0117): split diagnostics by severity. A unit with no
928    // error-severity diagnostic *checks* — its warnings ride on `TypedCommons`,
929    // surfaced but non-gating. Only error-severity diagnostics fail the check;
930    // on that path the warnings are appended so a failed build still renders
931    // them.
932    // Finding #28 (debug-only): `Span` is `expr_types`'s key, but nothing
933    // enforces that no two AST nodes needing a type share one — bug #844 and
934    // the else-less-`if` synthesis both did, silently, before either was
935    // caught. Walk every checked function/method body with the total child
936    // iterator `ast::expr_children` and assert no two nodes recorded here
937    // collide; a release build doesn't pay for the walk. Scoped per item,
938    // not across the whole commons: a multi-file commons's merged item list
939    // legitimately re-walks the same function more than once (its own,
940    // separate redundancy, outside this finding's scope), and both known
941    // collisions (#844, the else-less-`if` synthesis) are contained within a
942    // single function/handler body regardless.
943    #[cfg(debug_assertions)]
944    for item in &input.commons.items {
945        if let CommonsItem::Fn(f) = item {
946            let mut seen: HashSet<ExprId> = HashSet::new();
947            assert_expr_types_disjoint_in_block(&f.body, &expr_types, &mut seen);
948        }
949    }
950
951    let (hard_errors, warnings) = bynk_syntax::partition_by_severity(errors);
952    if hard_errors.is_empty() {
953        RecordCheck {
954            result: Ok(TypedCommons {
955                commons: input.commons,
956                types: input.types,
957                fns: input.fns,
958                methods: input.methods,
959                expr_types,
960                callees,
961                warnings,
962                ty_intern: Arc::clone(&ty_intern),
963                actor_bindings: HashMap::new(),
964            }),
965            partial_expr_types: HashMap::new(),
966            ty_intern,
967        }
968    } else {
969        // Keep the best-effort types the checker already computed; Analyse mode
970        // surfaces them for `.`-member completion on a broken buffer (ADR 0094).
971        let mut all = hard_errors;
972        all.extend(warnings);
973        RecordCheck {
974            result: Err(all),
975            partial_expr_types: expr_types,
976            ty_intern,
977        }
978    }
979}
980
981/// Finding #28 (debug-only), T3.4: the block-level half of the `expr_types`
982/// identity-uniqueness walk — visits every statement expression and the tail.
983/// `ExprId` uniqueness is guaranteed by construction (`Parser::alloc_expr_id`
984/// is the sole allocation point), so this can no longer catch a *parser*
985/// collision the way it caught #844 on `Span`; it stays as the loud check
986/// that a synthetic node (`ExprId::SYNTHETIC`) never reaches the checker's
987/// own `expr_types` — the one way two entries could still collide.
988#[cfg(debug_assertions)]
989fn assert_expr_types_disjoint_in_block(
990    block: &Block,
991    expr_types: &HashMap<ExprId, TypedExpr>,
992    seen: &mut HashSet<ExprId>,
993) {
994    let mut roots: Vec<&Expr> = Vec::new();
995    for s in &block.statements {
996        bynk_syntax::ast::statement_exprs(s, &mut roots);
997    }
998    roots.push(&block.tail);
999    for e in roots {
1000        assert_expr_types_disjoint(e, expr_types, seen);
1001    }
1002}
1003
1004/// Finding #28 (debug-only), T3.4: recurses over an expression with the
1005/// total child iterator `ast::expr_children`, asserting no two nodes
1006/// recorded into `expr_types` share an [`ExprId`] — a collision means one
1007/// node's recorded type silently clobbered another's (bug #844's class of
1008/// bug, before `ExprId` made position-derived collisions structurally
1009/// impossible for parser-allocated nodes).
1010#[cfg(debug_assertions)]
1011fn assert_expr_types_disjoint(
1012    e: &Expr,
1013    expr_types: &HashMap<ExprId, TypedExpr>,
1014    seen: &mut HashSet<ExprId>,
1015) {
1016    assert!(
1017        e.id != ExprId::SYNTHETIC || !expr_types.contains_key(&e.id),
1018        "bynk internal error (finding #28): a synthetic node (ExprId::SYNTHETIC) reached the \
1019         checker's own `expr_types` at {:?} — synthetic nodes are built after checking and must \
1020         never be inserted here",
1021        e.span
1022    );
1023    if expr_types.contains_key(&e.id) {
1024        assert!(
1025            seen.insert(e.id),
1026            "bynk internal error (finding #28): two typed AST nodes share id {:?} (span {:?}) in \
1027             `expr_types` — one node's recorded type silently clobbered another's",
1028            e.id,
1029            e.span
1030        );
1031    }
1032    for child in bynk_syntax::ast::expr_children(e) {
1033        assert_expr_types_disjoint(child, expr_types, seen);
1034    }
1035}
1036
1037/// #522: the six output sinks a handler-body check writes into. One struct at
1038/// each call site instead of six positional `&mut` arguments.
1039pub struct CheckSinks<'a> {
1040    /// T3.6b (R4.1): the intern table the `TyId`s written into `expr_types`
1041    /// (and carried on [`HandlerBodyCheck`]) resolve against. Belongs with the
1042    /// sinks rather than the signature: it is the thing a body check *writes*
1043    /// types into, and a caller holding a `TypedCommons` passes its
1044    /// `ty_intern` straight through.
1045    pub tys: &'a Types,
1046    pub expr_types: &'a mut HashMap<ExprId, TypedExpr>,
1047    pub errors: &'a mut Vec<CompileError>,
1048    pub refs: &'a mut RefSink,
1049    pub hints: &'a mut HintSink,
1050    pub locals: &'a mut LocalsSink,
1051    pub requirements: &'a mut RequirementSink,
1052    /// P6.0 (#1139): the `Callee` classification sink — see [`Callee`].
1053    pub callees: &'a mut HashMap<ExprId, Callee>,
1054}
1055
1056/// #522: everything [`check_handler_body`] needs to know about the handler —
1057/// signature, capability scope, agent state, and held bindings. Replaces what
1058/// was 17 positional parameters (of a 24-parameter signature); [`Self::new`]
1059/// fills the agent/actor/store extras with empties, so a simple site
1060/// (provider op, test body) sets only the fields it actually uses.
1061pub struct HandlerBodyCheck<'a> {
1062    pub body: &'a Block,
1063    pub return_type: &'a TypeRef,
1064    pub params: &'a [Param],
1065    /// The capabilities the body may call (the handler's resolved `given`).
1066    pub capabilities: HashMap<String, CapabilityInfo>,
1067    /// Every declared capability, for "declared but not given" diagnostics.
1068    pub declared_capabilities: HashMap<String, CapabilityInfo>,
1069    pub given: &'a [CapRef],
1070    pub given_anchor: Option<Span>,
1071    pub report_unused: bool,
1072    /// An agent handler's synthetic state-record type, when one is in scope.
1073    pub agent_state_ty: Option<TyId>,
1074    pub agent_self_scope: Option<HashMap<String, TyId>>,
1075    /// v0.45/v0.52: the `by <binder>: <Actor(s)>` binding — the binder name and
1076    /// its fully-formed sealed type: `Ty::Actor(identity)` for a single actor
1077    /// (so `binder.identity` type-checks), or `Ty::ActorSum(members)` for a sum
1078    /// (so the body `match`es on it). `None` for handlers without a `by` binder.
1079    pub actor_binding: Option<(String, TyId)>,
1080    /// The agent's `store` fields, by name (finding #36 — see [`StoreField`]),
1081    /// so the `:=` write form, `<field>.<op>(…)`, and the map query accessors
1082    /// can resolve their target. Empty for service/test bodies and `state {
1083    /// }` agents.
1084    pub store_fields: HashMap<String, StoreField>,
1085    /// v0.106 (slice 3b-iii): held params that are **borrowed**, not owned —
1086    /// the firing `connection` of a `from websocket` `on message`/`on close`.
1087    /// Borrowed bindings admit non-consuming ops (`send`) but carry no disposal
1088    /// obligation. Empty for every other handler (including `on open`, whose
1089    /// connection is owned).
1090    pub borrowed_held: HashSet<String>,
1091}
1092
1093impl<'a> HandlerBodyCheck<'a> {
1094    /// A check of `body` against `return_type` with everything optional empty:
1095    /// no capabilities, no agent state, no actor binding, no store fields.
1096    pub fn new(
1097        body: &'a Block,
1098        return_type: &'a TypeRef,
1099        params: &'a [Param],
1100        given: &'a [CapRef],
1101    ) -> Self {
1102        Self {
1103            body,
1104            return_type,
1105            params,
1106            capabilities: HashMap::new(),
1107            declared_capabilities: HashMap::new(),
1108            given,
1109            given_anchor: None,
1110            report_unused: false,
1111            agent_state_ty: None,
1112            agent_self_scope: None,
1113            actor_binding: None,
1114            store_fields: HashMap::new(),
1115            borrowed_held: HashSet::new(),
1116        }
1117    }
1118}
1119
1120/// Check a single handler body (used for service and agent handlers).
1121pub fn check_handler_body(
1122    input: &ResolvedCommons,
1123    check: HandlerBodyCheck<'_>,
1124    sinks: CheckSinks<'_>,
1125) {
1126    let HandlerBodyCheck {
1127        body,
1128        return_type,
1129        params,
1130        capabilities,
1131        declared_capabilities,
1132        given,
1133        given_anchor,
1134        report_unused,
1135        agent_state_ty,
1136        agent_self_scope,
1137        actor_binding,
1138        store_fields,
1139        borrowed_held,
1140    } = check;
1141    let CheckSinks {
1142        tys,
1143        expr_types,
1144        errors,
1145        refs,
1146        hints,
1147        locals,
1148        requirements,
1149        callees,
1150    } = sinks;
1151    let return_ty_span = return_type.span();
1152    let Some(return_ty) = resolve_type_ref(return_type, &input.types, tys) else {
1153        return;
1154    };
1155    let no_vars = HashSet::new();
1156    record_type_refs(return_type, &input.types, &no_vars, refs);
1157    // Build the parameter scope.
1158    let mut param_scope: HashMap<String, TyId> = HashMap::new();
1159    for p in params {
1160        if let Some(t) = resolve_type_ref(&p.type_ref, &input.types, tys) {
1161            record_type_refs(&p.type_ref, &input.types, &no_vars, refs);
1162            // v0.31: a handler/op parameter is in scope over the whole body.
1163            if p.name.name != "_" {
1164                locals.record(
1165                    p.name.name.clone(),
1166                    p.name.span,
1167                    crate::locals::LocalKind::Param,
1168                    t.display(tys),
1169                    body.span,
1170                );
1171            }
1172            param_scope.insert(p.name.name.clone(), t);
1173        }
1174    }
1175    if let Some((binder, binder_ty)) = actor_binding {
1176        if binder != "_" {
1177            locals.record(
1178                binder.clone(),
1179                body.span,
1180                crate::locals::LocalKind::Param,
1181                "actor".to_string(),
1182                body.span,
1183            );
1184        }
1185        param_scope.insert(binder, binder_ty);
1186    }
1187    if let Some(self_scope) = agent_self_scope {
1188        param_scope.extend(self_scope);
1189    }
1190    let effectful = return_ty.is_effect(tys);
1191    let given_entries: Vec<(String, Span)> = given
1192        .iter()
1193        .map(|c| (c.key().to_string(), c.span))
1194        .collect();
1195    let given_remaining: HashSet<String> = given_entries.iter().map(|(k, _)| k.clone()).collect();
1196    let mut ctx = Ctx {
1197        input,
1198        tys,
1199        expr_types,
1200        errors,
1201        refs,
1202        hints,
1203        locals,
1204        requirements,
1205        callees,
1206        scopes: vec![param_scope],
1207        is_binding_cache: HashMap::new(),
1208        pattern_binding_types: HashMap::new(),
1209        return_ty,
1210        return_ty_span,
1211        effectful,
1212        agent_state_ty,
1213        commit_seen: false,
1214        caps: CapabilityCtx {
1215            capabilities,
1216            declared_capabilities,
1217            given_remaining,
1218            given_used: HashSet::new(),
1219            given_entries: given_entries.clone(),
1220            given_anchor,
1221        },
1222        in_test_body: false,
1223        test_services: HashMap::new(),
1224        test_actors: HashMap::new(),
1225        type_vars: HashSet::new(),
1226        store_fields,
1227    };
1228    // Check the body and validate it matches the return type.
1229    let Some(body_ty) = type_of_block(body, Some(return_ty), &mut ctx) else {
1230        return;
1231    };
1232    // v0.102 (§3 step 11): the held-resource linearity pass, now that
1233    // `expr_types` is fully populated by the body walk above.
1234    linearity::check(
1235        body,
1236        params,
1237        &input.types,
1238        ctx.expr_types,
1239        &ctx.pattern_binding_types,
1240        &borrowed_held,
1241        ctx.errors,
1242        tys,
1243    );
1244    // Finding #28 (debug-only), extended: `check_record`'s per-function walk
1245    // (43abc242) never reaches a handler body — `check_handler_body` is
1246    // `bynk-emit`'s own entry point for service/agent handlers, called
1247    // directly from `validate.rs`, not from `check_record`'s
1248    // `CommonsItem::Fn` loop. A fresh `seen` set per call, matching the
1249    // per-item (not per-commons) granularity 43abc242 chose, so a
1250    // multi-file commons re-checking the same handler doesn't false-positive.
1251    #[cfg(debug_assertions)]
1252    {
1253        let mut seen: HashSet<ExprId> = HashSet::new();
1254        assert_expr_types_disjoint_in_block(body, ctx.expr_types, &mut seen);
1255    }
1256    if !compatible(body_ty, return_ty, tys) {
1257        ctx.errors.push(
1258            CompileError::new(
1259                "bynk.types.return_mismatch",
1260                body.tail.span,
1261                format!(
1262                    "handler body has type `{}`, but the declared return type is `{}`",
1263                    body_ty.display(tys),
1264                    return_ty.display(tys)
1265                ),
1266            )
1267            .with_label(return_ty_span, "declared return type"),
1268        );
1269    }
1270    // Bidirectional `given` check.
1271    // 1) Every used capability is declared. (Handled in capability-call site.)
1272    // 2) Every declared capability is used — anything left in given_remaining
1273    //    minus given_used is unused. Emit as a warning-category error so the
1274    //    test harness can match it. Entries are walked in declaration order
1275    //    (deduplicated by key) so diagnostics and their fixes are stable.
1276    let mut reported: HashSet<&str> = HashSet::new();
1277    for (i, (c, _)) in given_entries.iter().enumerate() {
1278        if !report_unused {
1279            break;
1280        }
1281        if ctx.caps.given_used.contains(c) || !reported.insert(c) {
1282            continue;
1283        }
1284        ctx.errors.push(
1285            CompileError::new(
1286                "bynk.given.unused_capability",
1287                return_ty_span,
1288                format!("capability `{c}` is declared in `given` but never used in the body"),
1289            )
1290            // Finding #49: the CLI now renders `.with_suggestion` below, so
1291            // this note carries only the alternative fix the suggestion
1292            // doesn't (removing the capability from `given`).
1293            .with_note("alternatively, use the capability in the handler body")
1294            // v0.26 (ADR 0054): the removal is list-aware — only `report_unused`
1295            // sites are handlers, where the clause follows the return type, so
1296            // `return_ty_span` anchors the only-entry case.
1297            .with_suggestion(
1298                format!("remove `{c}` from the `given` clause"),
1299                vec![(
1300                    given_removal_span(&given_entries, i, return_ty_span),
1301                    String::new(),
1302                )],
1303                Applicability::MachineApplicable,
1304            ),
1305        );
1306    }
1307}
1308
1309/// Type-check a bare body against `return_ty` in `scope`, with `caps`
1310/// available as both in-scope and declared capabilities and (if non-empty)
1311/// `test_services`/`test_actors` in scope for a test-case body's `svc.call`/
1312/// `by <Actor>(...)` resolution (§32/#33: the one shape every hand-rolled
1313/// `Ctx` outside this crate needed, letting `Ctx` itself stay `pub(crate)`).
1314/// `where_pred`, if present, is checked first against `Bool` (a property's
1315/// optional `for all ... where` filter — `bynk.property.where_not_bool` on
1316/// mismatch), sharing `ctx` with the main body so both populate the same
1317/// `expr_types`/`errors` sinks. Unlike [`check_handler_body`], this skips
1318/// the linearity pass, the return-type-mismatch diagnostic, and the
1319/// unused-`given` diagnostic — nothing outside this crate that built its
1320/// own `Ctx` ran those either, and adding them here would be a behaviour
1321/// change, not a refactor.
1322#[allow(clippy::too_many_arguments)]
1323pub fn check_body(
1324    input: &ResolvedCommons,
1325    body: &Block,
1326    return_ty: TyId,
1327    return_ty_span: Span,
1328    scope: HashMap<String, TyId>,
1329    caps: CapabilityCtx,
1330    test_services: HashMap<String, TestServiceSig>,
1331    test_actors: HashMap<String, bynk_syntax::ast::ActorDecl>,
1332    where_pred: Option<&Expr>,
1333    sinks: CheckSinks<'_>,
1334) -> Option<TyId> {
1335    let CheckSinks {
1336        tys,
1337        expr_types,
1338        errors,
1339        refs,
1340        hints,
1341        locals,
1342        requirements,
1343        callees,
1344    } = sinks;
1345    let mut ctx = Ctx {
1346        input,
1347        tys,
1348        expr_types,
1349        errors,
1350        refs,
1351        hints,
1352        locals,
1353        requirements,
1354        callees,
1355        scopes: vec![scope],
1356        is_binding_cache: HashMap::new(),
1357        pattern_binding_types: HashMap::new(),
1358        return_ty,
1359        return_ty_span,
1360        effectful: return_ty.is_effect(tys),
1361        agent_state_ty: None,
1362        commit_seen: false,
1363        caps,
1364        in_test_body: true,
1365        test_services,
1366        test_actors,
1367        type_vars: HashSet::new(),
1368        store_fields: HashMap::new(),
1369    };
1370    if let Some(w) = where_pred {
1371        let bool_ty = tys.intern(Ty::Base(BaseType::Bool));
1372        if let Some(actual) = type_of(w, Some(bool_ty), &mut ctx)
1373            && actual.base(tys) != Some(BaseType::Bool)
1374        {
1375            ctx.errors.push(CompileError::new(
1376                "bynk.property.where_not_bool",
1377                w.span,
1378                format!(
1379                    "a `for all ... where` filter has type `{}`, but a `Bool` is required",
1380                    actual.display(tys)
1381                ),
1382            ));
1383        }
1384    }
1385    let result = type_of_block(body, Some(return_ty), &mut ctx);
1386    // Finding #28 (debug-only), extended: see the identical note in
1387    // `check_handler_body` — `check_body`'s test-case/property callers bypass
1388    // `check_record`'s walk the same way handler bodies do.
1389    #[cfg(debug_assertions)]
1390    {
1391        let mut seen: HashSet<ExprId> = HashSet::new();
1392        assert_expr_types_disjoint_in_block(body, ctx.expr_types, &mut seen);
1393    }
1394    result
1395}
1396
1397/// Check an agent's invariant declarations (v0.80 §14). Each predicate is a pure
1398/// `Bool`-typed expression over the agent's state fields (referenced by bare
1399/// name), plus `implies`/`is`. The pass enforces:
1400///
1401/// - `bynk.invariant.duplicate_name` — two invariants share a name.
1402/// - `bynk.invariant.cross_agent_reference` — a predicate names another agent
1403///   (§14 closes that door; sagas/scenarios are the cross-agent tools).
1404/// - `bynk.invariant.impure_predicate` — a predicate uses an effectful or
1405///   test-only construct (Effect, `?` propagation, `expect`, `Val`).
1406/// - `bynk.invariant.not_bool` — the predicate does not type to `Bool`.
1407///
1408/// Store `Cell` fields are placed in scope as the predicate's locals; invariants
1409/// read fields directly by bare name, mirroring the design-notes worked examples.
1410#[allow(clippy::too_many_arguments)]
1411pub fn check_invariants(
1412    invariants: &[Invariant],
1413    // A `store`-bearing agent's invariants reference its `Cell` fields by bare
1414    // name (a pure read of the staged value), so they form the predicate scope.
1415    store_cells: &HashMap<String, TyId>,
1416    agent_name: &str,
1417    input: &ResolvedCommons,
1418    tys: &Types,
1419    expr_types: &mut HashMap<ExprId, TypedExpr>,
1420    errors: &mut Vec<CompileError>,
1421    refs: &mut RefSink,
1422    hints: &mut HintSink,
1423    locals: &mut LocalsSink,
1424    requirements: &mut RequirementSink,
1425    callees: &mut HashMap<ExprId, Callee>,
1426) {
1427    // Duplicate-name check across the agent's invariants.
1428    let mut seen: HashMap<&str, ()> = HashMap::new();
1429    for inv in invariants {
1430        if seen.insert(inv.name.name.as_str(), ()).is_some() {
1431            errors.push(
1432                CompileError::new(
1433                    "bynk.invariant.duplicate_name",
1434                    inv.name.span,
1435                    format!(
1436                        "agent `{agent_name}` declares more than one invariant named `{}`",
1437                        inv.name.name
1438                    ),
1439                )
1440                .with_note("give each invariant a distinct name"),
1441            );
1442        }
1443    }
1444
1445    // Build the predicate scope once: each `store` `Cell` is in scope by bare
1446    // name (a `Cell` reads as its element type).
1447    let mut field_scope: HashMap<String, TyId> = HashMap::new();
1448    for (name, ty) in store_cells {
1449        field_scope.insert(name.clone(), *ty);
1450    }
1451
1452    for inv in invariants {
1453        // Reject cross-agent references and impure/effectful constructs before
1454        // type-checking, so the bespoke diagnostics win over any cascade.
1455        if let Some(span) = predicate_cross_agent_ref(&inv.predicate, input) {
1456            errors.push(
1457                CompileError::new(
1458                    "bynk.invariant.cross_agent_reference",
1459                    span,
1460                    format!(
1461                        "invariant `{}` references another agent; invariants constrain a \
1462                         single agent's reachable states",
1463                        inv.name.name
1464                    ),
1465                )
1466                .with_note(
1467                    "a property that genuinely spans agents belongs in a saga or a scenario, \
1468                     not an invariant — see §14",
1469                ),
1470            );
1471            continue;
1472        }
1473        if let Some(span) = predicate_impure_construct(&inv.predicate) {
1474            errors.push(
1475                CompileError::new(
1476                    "bynk.invariant.impure_predicate",
1477                    span,
1478                    format!(
1479                        "invariant `{}` uses an effectful or test-only construct; invariant \
1480                         predicates must be pure",
1481                        inv.name.name
1482                    ),
1483                )
1484                .with_note(
1485                    "an invariant predicate may read state fields and call pure value methods, \
1486                     but not perform effects",
1487                ),
1488            );
1489            continue;
1490        }
1491
1492        let bool_ty = tys.intern(Ty::Base(BaseType::Bool));
1493        let mut ctx = Ctx {
1494            input,
1495            tys,
1496            expr_types,
1497            errors,
1498            refs,
1499            hints,
1500            locals,
1501            requirements,
1502            callees,
1503            scopes: vec![field_scope.clone()],
1504            is_binding_cache: HashMap::new(),
1505            pattern_binding_types: HashMap::new(),
1506            return_ty: bool_ty,
1507            return_ty_span: inv.predicate.span,
1508            // A predicate is a pure expression — effectful operations (capability
1509            // calls, `<-`) are not permitted and are rejected as type errors.
1510            effectful: false,
1511            agent_state_ty: None,
1512            commit_seen: false,
1513            caps: CapabilityCtx {
1514                capabilities: HashMap::new(),
1515                declared_capabilities: HashMap::new(),
1516                given_remaining: HashSet::new(),
1517                given_used: HashSet::new(),
1518                given_entries: Vec::new(),
1519                given_anchor: None,
1520            },
1521            in_test_body: false,
1522            test_services: HashMap::new(),
1523            test_actors: HashMap::new(),
1524            type_vars: HashSet::new(),
1525            store_fields: HashMap::new(),
1526        };
1527        let pred_ty = type_of(&inv.predicate, Some(bool_ty), &mut ctx);
1528        if let Some(t) = pred_ty
1529            && t.base(tys) != Some(BaseType::Bool)
1530        {
1531            ctx.errors.push(
1532                CompileError::new(
1533                    "bynk.invariant.not_bool",
1534                    inv.predicate.span,
1535                    format!(
1536                        "invariant `{}` predicate has type `{}`, but an invariant must be `Bool`",
1537                        inv.name.name,
1538                        t.display(tys)
1539                    ),
1540                )
1541                .with_note("an invariant predicate is a `Bool`-valued property of the state"),
1542            );
1543        }
1544    }
1545}
1546
1547/// Check a function's contract clauses (v0.115 §, testing track slice 3). A
1548/// contract is the invariant predicate attached to a function (ADR 0144 — one
1549/// predicate surface): each `requires`/`ensures` is a pure `Bool`-typed
1550/// expression, `requires` over the parameters and `ensures` over the parameters
1551/// plus `result` (the return value; the awaited element for an `Effect`). The
1552/// pass enforces, mirroring [`check_invariants`]:
1553///
1554/// - `bynk.contract.duplicate_name` — two clauses (across `requires`/`ensures`)
1555///   share a name; the name rides the failure report and dedup.
1556/// - `bynk.contract.result_in_requires` — a precondition references `result`
1557///   (the return value is not yet bound on entry).
1558/// - `bynk.contract.impure_predicate` — a clause uses an effectful or test-only
1559///   construct (Effect, `?` propagation, `expect`, `Val`).
1560/// - `bynk.contract.not_bool` — a clause does not type to `Bool`.
1561///
1562/// Distinct from ADR 0127's capability `@requires` annotation.
1563#[allow(clippy::too_many_arguments)]
1564pub fn check_contracts(
1565    requires: &[Contract],
1566    ensures: &[Contract],
1567    // The function's parameters in scope by bare name (plus `self` for a
1568    // method), the shared predicate scope for both clause kinds.
1569    param_scope: &HashMap<String, TyId>,
1570    // The declared return type, awaited for an `Effect` — the type of `result`
1571    // inside an `ensures` predicate.
1572    result_ty: TyId,
1573    // True when a parameter is literally named `result`; then `result` in a
1574    // `requires` is that parameter, not the (unbound) return value.
1575    has_result_param: bool,
1576    fn_label: &str,
1577    input: &ResolvedCommons,
1578    expr_types: &mut HashMap<ExprId, TypedExpr>,
1579    errors: &mut Vec<CompileError>,
1580    refs: &mut RefSink,
1581    hints: &mut HintSink,
1582    locals: &mut LocalsSink,
1583    requirements: &mut RequirementSink,
1584    callees: &mut HashMap<ExprId, Callee>,
1585    type_vars: &HashSet<String>,
1586    tys: &Types,
1587) {
1588    // Duplicate-name check across *all* clauses — the name is the dedup key for
1589    // the failure report and the redundant-test flag, so it is unique per fn.
1590    let mut seen: HashMap<&str, ()> = HashMap::new();
1591    for c in requires.iter().chain(ensures.iter()) {
1592        if seen.insert(c.name.name.as_str(), ()).is_some() {
1593            errors.push(
1594                CompileError::new(
1595                    "bynk.contract.duplicate_name",
1596                    c.name.span,
1597                    format!(
1598                        "{fn_label} declares more than one contract clause named `{}`",
1599                        c.name.name
1600                    ),
1601                )
1602                .with_note("give each `requires`/`ensures` clause a distinct name"),
1603            );
1604        }
1605    }
1606
1607    // Type-check one clause predicate in the given scope, emitting the shared
1608    // impurity / non-`Bool` diagnostics.
1609    let check_clause = |c: &Contract,
1610                        scope: HashMap<String, TyId>,
1611                        expr_types: &mut HashMap<ExprId, TypedExpr>,
1612                        errors: &mut Vec<CompileError>,
1613                        refs: &mut RefSink,
1614                        hints: &mut HintSink,
1615                        locals: &mut LocalsSink,
1616                        requirements: &mut RequirementSink,
1617                        callees: &mut HashMap<ExprId, Callee>| {
1618        if let Some(span) = predicate_impure_construct(&c.predicate) {
1619            errors.push(
1620                CompileError::new(
1621                    "bynk.contract.impure_predicate",
1622                    span,
1623                    format!(
1624                        "contract clause `{}` uses an effectful or test-only construct; a \
1625                             contract predicate must be pure",
1626                        c.name.name
1627                    ),
1628                )
1629                .with_note(
1630                    "a contract predicate may read the parameters (and `result`) and call \
1631                         pure value methods, but not perform effects",
1632                ),
1633            );
1634            return;
1635        }
1636        let bool_ty = tys.intern(Ty::Base(BaseType::Bool));
1637        let mut ctx = Ctx {
1638            input,
1639            tys,
1640            expr_types,
1641            errors,
1642            refs,
1643            hints,
1644            locals,
1645            requirements,
1646            callees,
1647            scopes: vec![scope],
1648            is_binding_cache: HashMap::new(),
1649            pattern_binding_types: HashMap::new(),
1650            return_ty: bool_ty,
1651            return_ty_span: c.predicate.span,
1652            effectful: false,
1653            agent_state_ty: None,
1654            commit_seen: false,
1655            caps: CapabilityCtx {
1656                capabilities: HashMap::new(),
1657                declared_capabilities: HashMap::new(),
1658                given_remaining: HashSet::new(),
1659                given_used: HashSet::new(),
1660                given_entries: Vec::new(),
1661                given_anchor: None,
1662            },
1663            in_test_body: false,
1664            test_services: HashMap::new(),
1665            test_actors: HashMap::new(),
1666            type_vars: type_vars.clone(),
1667            store_fields: HashMap::new(),
1668        };
1669        let pred_ty = type_of(&c.predicate, Some(bool_ty), &mut ctx);
1670        if let Some(t) = pred_ty
1671            && t.base(tys) != Some(BaseType::Bool)
1672        {
1673            ctx.errors.push(
1674                CompileError::new(
1675                    "bynk.contract.not_bool",
1676                    c.predicate.span,
1677                    format!(
1678                        "contract clause `{}` predicate has type `{}`, but a contract clause \
1679                             must be `Bool`",
1680                        c.name.name,
1681                        t.display(tys)
1682                    ),
1683                )
1684                .with_note("a contract predicate is a `Bool`-valued claim over the arguments"),
1685            );
1686        }
1687    };
1688
1689    for c in requires {
1690        // `result` is the *return value* — not in scope on entry. A `requires`
1691        // that names it is a scope error with a bespoke diagnostic (unless a
1692        // parameter is literally named `result`, in which case it is that param).
1693        if !has_result_param && let Some(span) = predicate_references_result(&c.predicate) {
1694            errors.push(
1695                CompileError::new(
1696                    "bynk.contract.result_in_requires",
1697                    span,
1698                    format!(
1699                        "precondition `{}` references `result`, but the return value is not bound \
1700                         until the function returns",
1701                        c.name.name
1702                    ),
1703                )
1704                .with_note("`result` is only in scope inside an `ensures` clause"),
1705            );
1706            continue;
1707        }
1708        check_clause(
1709            c,
1710            param_scope.clone(),
1711            expr_types,
1712            errors,
1713            refs,
1714            hints,
1715            locals,
1716            requirements,
1717            callees,
1718        );
1719    }
1720
1721    for c in ensures {
1722        // `ensures` scope = parameters + `result` (the return value; awaited for
1723        // an `Effect`). A parameter named `result` is shadowed by the binding.
1724        let mut scope = param_scope.clone();
1725        scope.insert("result".to_string(), result_ty);
1726        check_clause(
1727            c,
1728            scope,
1729            expr_types,
1730            errors,
1731            refs,
1732            hints,
1733            locals,
1734            requirements,
1735            callees,
1736        );
1737    }
1738}
1739
1740/// Check an agent's step invariants (v0.116 §, testing track slice 4). A
1741/// `transition` is the invariant predicate widened to the *step* (ADR 0144 — one
1742/// predicate surface): a pure `Bool` predicate over the `old`/`new` state pair,
1743/// each bound to the agent's synthetic state record (`state_ty`), so `old.status`
1744/// / `new.status` resolve like any record field. The pass enforces, mirroring
1745/// [`check_invariants`]:
1746///
1747/// - `bynk.transition.duplicate_name` — two transitions share a name (the name
1748///   rides the `InvariantViolation` failure report).
1749/// - `bynk.transition.impure_predicate` — a predicate uses an effectful or
1750///   test-only construct.
1751/// - `bynk.transition.no_step_reference` — a predicate references neither `old`
1752///   nor `new`; it is a snapshot claim misfiled as a step (use `invariant`).
1753/// - `bynk.transition.not_bool` — a predicate does not type to `Bool`.
1754///
1755/// Placement is enforced structurally by the grammar (a `transition` is an
1756/// agent-body-only declaration), so there is no "transition on a non-agent"
1757/// diagnostic to raise here.
1758#[allow(clippy::too_many_arguments)]
1759pub fn check_transitions(
1760    transitions: &[Transition],
1761    // The agent's synthetic state record type — both `old` and `new` are bound to
1762    // it, so `old.field` / `new.field` read as the field's element type.
1763    state_ty: TyId,
1764    agent_name: &str,
1765    // Resolved commons carrying the synthetic `<Agent>State` record so field
1766    // access on `old`/`new` resolves.
1767    input: &ResolvedCommons,
1768    expr_types: &mut HashMap<ExprId, TypedExpr>,
1769    errors: &mut Vec<CompileError>,
1770    refs: &mut RefSink,
1771    hints: &mut HintSink,
1772    locals: &mut LocalsSink,
1773    requirements: &mut RequirementSink,
1774    callees: &mut HashMap<ExprId, Callee>,
1775    tys: &Types,
1776) {
1777    // Duplicate-name check across the agent's transitions.
1778    let mut seen: HashMap<&str, ()> = HashMap::new();
1779    for tr in transitions {
1780        if seen.insert(tr.name.name.as_str(), ()).is_some() {
1781            errors.push(
1782                CompileError::new(
1783                    "bynk.transition.duplicate_name",
1784                    tr.name.span,
1785                    format!(
1786                        "agent `{agent_name}` declares more than one transition named `{}`",
1787                        tr.name.name
1788                    ),
1789                )
1790                .with_note("give each transition a distinct name"),
1791            );
1792        }
1793    }
1794
1795    // Both `old` and `new` are in scope as the state record.
1796    let mut scope: HashMap<String, TyId> = HashMap::new();
1797    scope.insert("old".to_string(), state_ty);
1798    scope.insert("new".to_string(), state_ty);
1799
1800    for tr in transitions {
1801        // Reject cross-agent references and impure constructs before type-checking,
1802        // so the bespoke diagnostics win over any cascade.
1803        if let Some(span) = predicate_cross_agent_ref(&tr.predicate, input) {
1804            errors.push(
1805                CompileError::new(
1806                    "bynk.transition.cross_agent_reference",
1807                    span,
1808                    format!(
1809                        "transition `{}` references another agent; a step invariant \
1810                         constrains a single agent's own state move",
1811                        tr.name.name
1812                    ),
1813                )
1814                .with_note(
1815                    "a property that genuinely spans agents belongs in a saga or a scenario, \
1816                     not a transition",
1817                ),
1818            );
1819            continue;
1820        }
1821        if let Some(span) = predicate_impure_construct(&tr.predicate) {
1822            errors.push(
1823                CompileError::new(
1824                    "bynk.transition.impure_predicate",
1825                    span,
1826                    format!(
1827                        "transition `{}` uses an effectful or test-only construct; a step \
1828                         invariant predicate must be pure",
1829                        tr.name.name
1830                    ),
1831                )
1832                .with_note(
1833                    "a transition predicate may read the `old`/`new` state and call pure value \
1834                     methods, but not perform effects",
1835                ),
1836            );
1837            continue;
1838        }
1839        // A transition that mentions neither `old` nor `new` is not a step claim —
1840        // it is a snapshot invariant misfiled. Flag it conservatively.
1841        if predicate_references_old_or_new(&tr.predicate).is_none() {
1842            errors.push(
1843                CompileError::new(
1844                    "bynk.transition.no_step_reference",
1845                    tr.predicate.span,
1846                    format!(
1847                        "transition `{}` references neither `old` nor `new`, so it constrains a \
1848                         single state, not a step",
1849                        tr.name.name
1850                    ),
1851                )
1852                .with_note(
1853                    "a claim about one committed state is an `invariant`, not a `transition`",
1854                ),
1855            );
1856            continue;
1857        }
1858
1859        let bool_ty = tys.intern(Ty::Base(BaseType::Bool));
1860        let mut ctx = Ctx {
1861            input,
1862            tys,
1863            expr_types,
1864            errors,
1865            refs,
1866            hints,
1867            locals,
1868            requirements,
1869            callees,
1870            scopes: vec![scope.clone()],
1871            is_binding_cache: HashMap::new(),
1872            pattern_binding_types: HashMap::new(),
1873            return_ty: bool_ty,
1874            return_ty_span: tr.predicate.span,
1875            effectful: false,
1876            agent_state_ty: None,
1877            commit_seen: false,
1878            caps: CapabilityCtx {
1879                capabilities: HashMap::new(),
1880                declared_capabilities: HashMap::new(),
1881                given_remaining: HashSet::new(),
1882                given_used: HashSet::new(),
1883                given_entries: Vec::new(),
1884                given_anchor: None,
1885            },
1886            in_test_body: false,
1887            test_services: HashMap::new(),
1888            test_actors: HashMap::new(),
1889            type_vars: HashSet::new(),
1890            store_fields: HashMap::new(),
1891        };
1892        let pred_ty = type_of(&tr.predicate, Some(bool_ty), &mut ctx);
1893        if let Some(t) = pred_ty
1894            && t.base(tys) != Some(BaseType::Bool)
1895        {
1896            ctx.errors.push(
1897                CompileError::new(
1898                    "bynk.transition.not_bool",
1899                    tr.predicate.span,
1900                    format!(
1901                        "transition `{}` predicate has type `{}`, but a transition must be `Bool`",
1902                        tr.name.name,
1903                        t.display(tys)
1904                    ),
1905                )
1906                .with_note("a transition predicate is a `Bool`-valued property of the state move"),
1907            );
1908        }
1909    }
1910}
1911
1912/// If the predicate references `old` or `new` (a bare identifier) anywhere,
1913/// return the span of the first such reference. Used to flag a `transition` that
1914/// makes no step claim.
1915fn predicate_references_old_or_new(e: &Expr) -> Option<Span> {
1916    match &e.kind {
1917        ExprKind::Ident(id) if id.name == "old" || id.name == "new" => Some(id.span),
1918        _ => bynk_syntax::ast::expr_children(e)
1919            .into_iter()
1920            .find_map(predicate_references_old_or_new),
1921    }
1922}
1923
1924/// If the predicate references `result` (a bare identifier) anywhere, return the
1925/// span of the first such reference. Used to reject `result` in a `requires`.
1926fn predicate_references_result(e: &Expr) -> Option<Span> {
1927    match &e.kind {
1928        ExprKind::Ident(id) if id.name == "result" => Some(id.span),
1929        _ => bynk_syntax::ast::expr_children(e)
1930            .into_iter()
1931            .find_map(predicate_references_result),
1932    }
1933}
1934
1935/// If the predicate references another agent (by bare name, call, or qualified
1936/// constructor), return the span of the first such reference. Used by the
1937/// invariant well-formedness pass to forbid cross-agent predicates.
1938fn predicate_cross_agent_ref(e: &Expr, input: &ResolvedCommons) -> Option<Span> {
1939    let is_agent = |name: &str| input.agents.contains_key(name);
1940    match &e.kind {
1941        ExprKind::Ident(id) if is_agent(&id.name) => Some(id.span),
1942        ExprKind::Call { name, .. } if is_agent(&name.name) => Some(name.span),
1943        ExprKind::ConstructorCall { type_name, .. } if is_agent(&type_name.name) => {
1944            Some(type_name.span)
1945        }
1946        ExprKind::RecordConstruction { type_name, .. } if is_agent(&type_name.name) => {
1947            Some(type_name.span)
1948        }
1949        _ => bynk_syntax::ast::expr_children(e)
1950            .into_iter()
1951            .find_map(|c| predicate_cross_agent_ref(c, input)),
1952    }
1953}
1954
1955/// If the predicate contains an effectful or test-only construct, return its
1956/// span. Capability misuse (an effect operation in a pure context) is left to
1957/// the type checker; this catches the syntactically-impure surface.
1958pub(crate) fn predicate_impure_construct(e: &Expr) -> Option<Span> {
1959    match &e.kind {
1960        ExprKind::EffectPure(_)
1961        | ExprKind::Question(_)
1962        | ExprKind::Expect(_)
1963        | ExprKind::Val { .. }
1964        | ExprKind::Observation(_)
1965        | ExprKind::Trace { .. } => Some(e.span),
1966        _ => bynk_syntax::ast::expr_children(e)
1967            .into_iter()
1968            .find_map(predicate_impure_construct),
1969    }
1970}
1971
1972/// Whether `e` reads the identifier `name` anywhere — used by the `:=`
1973/// read-modify-write rule (a cell write whose RHS reads its own LHS).
1974fn expr_reads_ident(e: &Expr, name: &str) -> bool {
1975    match &e.kind {
1976        ExprKind::Ident(id) => id.name == name,
1977        _ => bynk_syntax::ast::expr_children(e)
1978            .into_iter()
1979            .any(|c| expr_reads_ident(c, name)),
1980    }
1981}
1982
1983// ==== Checking context and capability metadata ====
1984
1985/// v0.9.4: a compile-time-constant literal usable for static refinement
1986/// discharge during `T.of(...)` construction.
1987enum ConstLit {
1988    Int(i64),
1989    Float(f64),
1990    Str(String),
1991    Bool(bool),
1992    Unit,
1993}
1994
1995impl ConstLit {
1996    fn display(&self) -> String {
1997        match self {
1998            ConstLit::Int(n) => n.to_string(),
1999            ConstLit::Float(v) => v.to_string(),
2000            ConstLit::Str(s) => format!("{s:?}"),
2001            ConstLit::Bool(b) => b.to_string(),
2002            ConstLit::Unit => "()".to_string(),
2003        }
2004    }
2005}
2006
2007/// Mutable per-function context.
2008/// Capability bookkeeping for the checker — the `given`-clause lifecycle and
2009/// capability dispatch, grouped out of the checker's working context
2010/// (v0.29.10). Empty (`Default`) for pure functions / non-context code.
2011#[derive(Default)]
2012pub struct CapabilityCtx {
2013    /// Capabilities in scope for the current handler, as a name → CapabilityInfo
2014    /// map. Empty for pure functions and non-context code.
2015    pub capabilities: HashMap<String, CapabilityInfo>,
2016    /// All capabilities declared in the surrounding context (for diagnostic
2017    /// purposes — used to detect `<Cap>.op(...)` calls where the capability is
2018    /// declared in the context but not listed in `given`).
2019    pub declared_capabilities: HashMap<String, CapabilityInfo>,
2020    /// Names of capabilities the user listed in `given`, but haven't yet
2021    /// observed used. After checking the body, anything left here is
2022    /// unused — a warning.
2023    pub given_remaining: HashSet<String>,
2024    /// Names of capabilities actually used in the body so far.
2025    pub given_used: HashSet<String>,
2026    /// v0.26 (ADR 0054): the `given` clause's entries in declaration order —
2027    /// (deps key, source span) — so the `given` quick-fixes can author
2028    /// list-aware edits at the diagnosis site. Empty where no `given` clause
2029    /// applies (fns, mock ops, state initialisers).
2030    pub given_entries: Vec<(String, Span)>,
2031    /// v0.26: where the add-capability fix synthesises an *absent* `given`
2032    /// clause — the handler's return type (the clause follows it). `None`
2033    /// where the clause lives elsewhere (a provider's `provides … given`
2034    /// line); the fix is then offered only when entries already exist.
2035    pub given_anchor: Option<Span>,
2036}
2037
2038/// v0.178 (Slice 0, #662) / v0.182 (Slice A, #664): the shape a test body needs
2039/// to resolve a service invocation. Built by the project test pass from the
2040/// target unit's service declarations, so the checker can resolve the addressed
2041/// handler (`svc.call(...)` on an `on call` service, or — Slice A —
2042/// `svc.GET("/x")` / `svc.schedule("…")` / `svc.message(m)` on a `from http` /
2043/// `cron` / `queue` service) and check its arity, argument types, and principal.
2044#[derive(Debug, Clone)]
2045pub struct TestServiceSig {
2046    /// The service's protocol as an author-facing word (`"http"`, `"cron"`,
2047    /// `"queue"`, `"websocket"`), or `None` for a plain `service X { on call }`.
2048    pub protocol: Option<String>,
2049    /// Every handler the service declares, so the branch can resolve any address
2050    /// form. Slice 0 only reads the `on call` entry.
2051    pub handlers: Vec<TestHandler>,
2052}
2053
2054/// One service handler, as a test body sees it (v0.178 / v0.182).
2055#[derive(Debug, Clone)]
2056pub struct TestHandler {
2057    pub kind: bynk_syntax::ast::HandlerKind,
2058    pub params: Vec<bynk_syntax::ast::Param>,
2059    /// The handler's declared `by <Actor>` clause, if any — the actor a call-site
2060    /// principal is checked against. `None` inherits the protocol default actor.
2061    pub by_clause: Option<bynk_syntax::ast::ByClause>,
2062    pub span: Span,
2063}
2064
2065impl TestServiceSig {
2066    /// The `on call` handler, if the service declares one.
2067    pub fn call_handler(&self) -> Option<&TestHandler> {
2068        self.handlers
2069            .iter()
2070            .find(|h| matches!(h.kind, bynk_syntax::ast::HandlerKind::Call))
2071    }
2072}
2073
2074/// One agent `store` field's kind and shape (finding #36) — the checker's
2075/// dispatch keys off this instead of five separate per-kind maps, so a new
2076/// storage kind is one new variant rather than a sixth map threaded through
2077/// every constructor and lookup site.
2078#[derive(Debug, Clone, Copy)]
2079pub enum StoreField {
2080    /// `store <name>: Cell[T]` — element type.
2081    Cell(TyId),
2082    /// `store <name>: Map[K, V]` — key, value.
2083    Map(TyId, TyId),
2084    /// `store <name>: Set[T]` — element type.
2085    Set(TyId),
2086    /// `store <name>: Cache[K, V] @ttl(...)` — key, value, TTL in milliseconds.
2087    Cache(TyId, TyId, i64),
2088    /// `store <name>: Log[T]` — element type.
2089    Log(TyId),
2090}
2091
2092/// The checker's working context. `pub(crate)`: every caller outside this
2093/// crate goes through [`check_handler_body`] or [`check_body`] instead of
2094/// hand-building one — adding a field no longer needs auditing every
2095/// external construction site.
2096pub(crate) struct Ctx<'a> {
2097    pub input: &'a ResolvedCommons,
2098    /// T3.6b (R4.1): the unit's intern table. A shared `&` (the table is
2099    /// interior-mutable, see [`Types`]) so it stays `Copy` — a function that
2100    /// needs it reads `ctx.tys` once and is then free of the `ctx` borrow.
2101    /// Spelled `tys`, not `types`, because `input.types` next door is the
2102    /// unrelated `TypeDecl`-by-name declaration map.
2103    pub tys: &'a Types,
2104    pub expr_types: &'a mut HashMap<ExprId, TypedExpr>,
2105    pub errors: &'a mut Vec<CompileError>,
2106    /// v0.25 (ADR 0053): binding edges recorded at the checker's own
2107    /// resolution sites — capability/service dispatch, typed call dispatch,
2108    /// annotation resolution. Handler/test/provider bodies never pass
2109    /// through the resolver's reference walk, so the checker is their only
2110    /// recording point.
2111    pub refs: &'a mut RefSink,
2112    /// v0.27 (ADR 0056): inferred-type inlay hints recorded at the
2113    /// annotation-absent binding sites (`let` / `let <-` / lambda params)
2114    /// as the binding's final type is computed.
2115    pub hints: &'a mut HintSink,
2116    /// v0.31 (ADR 0064): local bindings recorded with their scope ranges at
2117    /// every binding site (`let`/`let <-`, params, match patterns), for the
2118    /// LSP's scope-at-offset query.
2119    pub locals: &'a mut LocalsSink,
2120    /// v0.99: the capability-requirement ledger — every capability-consuming
2121    /// site (direct call, store op), covered or not, recorded so the editor
2122    /// surfaces (the ghost `given` inlay hint, hover) can read it.
2123    pub requirements: &'a mut RequirementSink,
2124    /// P6.0 (#1139): the `Callee` classification sink — see [`Callee`].
2125    pub callees: &'a mut HashMap<ExprId, Callee>,
2126    /// Stack of in-scope name → type frames.
2127    pub scopes: Vec<HashMap<String, TyId>>,
2128    /// Memoised `is`-pattern bindings, keyed by the condition sub-expression's
2129    /// span. `collect_is_bindings` runs at every `&&`/`implies` node and, for a
2130    /// left-nested `&&` chain, would otherwise re-walk each lhs subtree once per
2131    /// enclosing node — O(N²) for an N-term chain. Because the collector is a
2132    /// pure read over `expr_types` (already populated by the time it runs) and
2133    /// spans are unique per body, caching each node's result collapses the walk
2134    /// to a single pass.
2135    pub is_binding_cache: HashMap<ExprId, Vec<(String, TyId)>>,
2136    /// T3.4: a pattern-bound name's resolved type, keyed by the binding
2137    /// `Ident`'s own span. Deliberately **not** `ExprId`-keyed and not
2138    /// folded into `expr_types` — a `Pattern::Binding` is not an `Expr` and
2139    /// giving `Ident` an id of its own would touch every identifier
2140    /// construction site in the workspace (field names, type names, params,
2141    /// …), not just the handful that bind. This is exactly the `PatId`
2142    /// reference draws as a *separate* identity from `ExprId` (Part 2) —
2143    /// out of this slice's scope on purpose, not overlooked.
2144    pub pattern_binding_types: HashMap<Span, TyId>,
2145    pub return_ty: TyId,
2146    pub return_ty_span: Span,
2147    /// True if the enclosing function/handler returns `Effect[T]` (v0.5).
2148    /// Determines whether `<-` and capability calls are permitted.
2149    pub effectful: bool,
2150    /// If inside an agent handler, the agent's state type and the agent's
2151    /// name. Used to validate `commit` statements.
2152    pub agent_state_ty: Option<TyId>,
2153    /// True if a `commit` has been seen on the current control-flow path.
2154    /// Used to detect "two reachable commits".
2155    pub commit_seen: bool,
2156    /// Capability bookkeeping — the `given`-clause lifecycle + dispatch,
2157    /// grouped (v0.29.10). Empty for pure functions / non-context code.
2158    pub caps: CapabilityCtx,
2159    /// True when the body being checked is a test case body. Permits
2160    /// `expect` statements (v0.7; renamed from `assert` in v0.112).
2161    pub in_test_body: bool,
2162    /// The target unit's services, populated for test case bodies (v0.25).
2163    /// `svc.call(args)` in a test invokes the target's service; the checker
2164    /// resolves the service's `on call` handler here to check the call's
2165    /// arity and argument types, and records the binding edge so test-file
2166    /// references index. A service with no `on call` handler (a `from http`
2167    /// / `cron` / `queue` service) carries `None` for `call_handler`, which
2168    /// makes `svc.call(...)` a diagnostic rather than a silent runtime crash.
2169    pub test_services: HashMap<String, TestServiceSig>,
2170    /// v0.182 (Slice A, #664): the target unit's actor declarations, so a
2171    /// call-site `by <Actor>(<identity>)` can resolve the actor and type the
2172    /// identity value against its declared identity type. Prelude actors
2173    /// (`Visitor`, `Caller`, …) are resolved separately. Empty outside test
2174    /// bodies.
2175    pub test_actors: HashMap<String, bynk_syntax::ast::ActorDecl>,
2176    /// v0.20a: the enclosing function's type parameters (rigid vars), so
2177    /// nested explicit type arguments (`identity[A](x)` inside a generic
2178    /// body) resolve. Empty outside generic fn bodies.
2179    pub type_vars: HashSet<String>,
2180    /// The agent's `store` fields, by name (finding #36: collapses the five
2181    /// former per-kind maps — `store_cells`/`store_maps`/`store_sets`/
2182    /// `store_caches`/`store_logs` — into one, since a field name can only
2183    /// ever be one kind). A `:=` write, a `<field>.<op>(…)` call, and the
2184    /// `.entries`/`.keys`/`.values` map accessors all resolve their target
2185    /// here, by receiver provenance. Empty outside `store`-bearing agent
2186    /// handlers.
2187    pub store_fields: HashMap<String, StoreField>,
2188}
2189
2190/// Per-capability info for checker dispatch within a handler body.
2191#[derive(Debug, Clone)]
2192pub struct CapabilityInfo {
2193    pub name: String,
2194    pub ops: Vec<CapabilityOpInfo>,
2195}
2196
2197#[derive(Debug, Clone)]
2198pub struct CapabilityOpInfo {
2199    pub name: String,
2200    /// #926: the op's own type parameters (empty for a non-generic op).
2201    /// `params`/`return_ty` below are *pattern* types resolved with these in
2202    /// scope, so a declared `T` survives as `Ty::Var("T")` rather than
2203    /// collapsing to `Ty::Unit` — a call site substitutes a concrete `Ty` for
2204    /// each before checking arguments/return.
2205    pub type_params: Vec<String>,
2206    pub params: Vec<TyId>,
2207    /// The operation's parameter names, positionally aligned with `params`
2208    /// (v0.117). Needed for observation: the `with <pred>` scope binds them by
2209    /// name and `trace(Cap.op)` yields records with these fields.
2210    pub param_names: Vec<String>,
2211    pub return_ty: TyId,
2212}
2213
2214/// The synthetic record type name for `trace(Cap.op)`'s call records (v0.117):
2215/// one record per capability operation, its fields the operation's parameters.
2216pub fn call_record_type_name(cap: &str, op: &str) -> String {
2217    format!("__{cap}_{op}_Call")
2218}
2219
2220impl<'a> Ctx<'a> {
2221    pub fn lookup(&self, name: &str) -> Option<TyId> {
2222        for scope in self.scopes.iter().rev() {
2223            if let Some(t) = scope.get(name) {
2224                return Some(*t);
2225            }
2226        }
2227        None
2228    }
2229
2230    /// Returns the type of an expression's "root identifier" — for `a.b.c`
2231    /// that's `a`; for a bare `a` it's `a`. Used to detect whether a chain's
2232    /// outermost name shadows an alias / consumed-context prefix.
2233    pub fn lookup_root_ident(&self, expr: &Expr) -> Option<TyId> {
2234        match &expr.kind {
2235            ExprKind::Ident(id) => self.lookup(&id.name),
2236            ExprKind::FieldAccess { receiver, .. } => self.lookup_root_ident(receiver),
2237            ExprKind::MethodCall { receiver, .. } => self.lookup_root_ident(receiver),
2238            _ => None,
2239        }
2240    }
2241
2242    /// v0.158 (ADR 0184): whether an expression's root ident names an agent
2243    /// `store` field. A store field is not in the value scope (so
2244    /// [`lookup_root_ident`](Self::lookup_root_ident) returns `None` for it),
2245    /// yet `<map>.entries.…` / `<map>.values.…` chains root in one — this
2246    /// distinguishes them from an un-consumed cross-context prefix so the
2247    /// `map.entries` query accessor is not mistaken for a service call.
2248    pub fn root_ident_is_store_field(&self, expr: &Expr) -> bool {
2249        match &expr.kind {
2250            ExprKind::Ident(id) => self.store_fields.contains_key(&id.name),
2251            ExprKind::FieldAccess { receiver, .. } | ExprKind::MethodCall { receiver, .. } => {
2252                self.root_ident_is_store_field(receiver)
2253            }
2254            _ => false,
2255        }
2256    }
2257
2258    pub fn push_scope(&mut self) {
2259        self.scopes.push(HashMap::new());
2260    }
2261    pub fn pop_scope(&mut self) {
2262        self.scopes.pop();
2263    }
2264    pub fn bind(&mut self, name: String, ty: TyId) {
2265        self.scopes.last_mut().unwrap().insert(name, ty);
2266    }
2267}
2268
2269// ==== Type-system core (resolution, unification, compatibility, inference) ====
2270
2271/// Build a `Ty` from a TypeDecl name reference.
2272pub fn type_from_decl(
2273    id: &Ident,
2274    types: &HashMap<String, Arc<TypeDecl>>,
2275    tys: &Types,
2276) -> Option<TyId> {
2277    let decl = types.get(&id.name)?;
2278    Some(named_ty(decl, tys))
2279}
2280
2281/// Build a `Ty::Named` for the given declaration with the given applied type
2282/// arguments (empty for a non-generic reference).
2283pub fn named_ty_with_args(decl: &TypeDecl, args: Vec<TyId>, tys: &Types) -> TyId {
2284    let kind = match &decl.body {
2285        TypeBody::Refined { base, .. } => NamedKind::Refined(*base),
2286        TypeBody::Record(_) => NamedKind::Record,
2287        TypeBody::Sum(_) => NamedKind::Sum,
2288        TypeBody::Opaque { base, .. } => NamedKind::Opaque(*base),
2289    };
2290    tys.intern(Ty::Named {
2291        name: decl.name.name.clone(),
2292        kind,
2293        args,
2294    })
2295}
2296
2297/// Build a `Ty::Named` for the given declaration (no applied type arguments).
2298pub fn named_ty(decl: &TypeDecl, tys: &Types) -> TyId {
2299    named_ty_with_args(decl, Vec::new(), tys)
2300}
2301
2302/// v0.158 (ADR 0184): the compiler-known `MapEntry[K, V]` record — the element
2303/// a `store Map[K, V]`'s `.entries` query yields. A nominal generic record
2304/// (`{ key: K, value: V }`), so it flows through `unify`/`compatible`/`display`
2305/// and the ADR 0183 non-boundary rule like any generic-record instantiation;
2306/// its fields are resolved by name in `check_field_access` (it has no
2307/// user-visible `TypeDecl`, like `JsonError`).
2308pub fn map_entry_ty(k: TyId, v: TyId, tys: &Types) -> TyId {
2309    tys.intern(Ty::Named {
2310        name: MAP_ENTRY.to_string(),
2311        kind: NamedKind::Record,
2312        args: vec![k, v],
2313    })
2314}
2315
2316/// v0.157 (ADR 0183): the substitution mapping a generic record's declared
2317/// type parameters onto a concrete instantiation's arguments. Empty when the
2318/// type is non-generic or `args` is empty (an under-applied reference — the
2319/// resolver reports that separately).
2320pub fn type_param_subst(decl: &TypeDecl, args: &[TyId]) -> HashMap<String, TyId> {
2321    decl.type_params
2322        .iter()
2323        .map(|p| p.name.name.clone())
2324        .zip(args.iter().copied())
2325        .collect()
2326}
2327
2328/// v0.157 (ADR 0183): the type of a generic record's field at a concrete
2329/// instantiation. The field's declared type is resolved with the declaration's
2330/// type parameters in scope as rigid vars, then those vars are replaced by the
2331/// instantiation's `args`. For a non-generic record this is a plain resolve.
2332pub fn instantiate_field_ty(
2333    decl: &TypeDecl,
2334    args: &[TyId],
2335    field_ref: &TypeRef,
2336    types: &HashMap<String, Arc<TypeDecl>>,
2337    tys: &Types,
2338) -> Option<TyId> {
2339    if decl.type_params.is_empty() {
2340        return resolve_type_ref(field_ref, types, tys);
2341    }
2342    // Without a full argument set the substitution is partial and would leave a
2343    // rigid `Ty::Var` in the field type; an under-/over-applied reference is an
2344    // error the resolver reports, so field access yields no type here.
2345    if decl.type_params.len() != args.len() {
2346        return None;
2347    }
2348    let vars: HashSet<String> = decl
2349        .type_params
2350        .iter()
2351        .map(|p| p.name.name.clone())
2352        .collect();
2353    let field_ty = resolve_type_ref_in(field_ref, types, &vars, tys)?;
2354    Some(substitute(field_ty, &type_param_subst(decl, args), tys))
2355}
2356
2357/// v0.20a: like [`resolve_type_ref`], with a set of in-scope **type
2358/// parameters**: a `Named` reference matching one resolves to [`Ty::Var`]
2359/// (checked before the type-table lookup — a type parameter shadows a
2360/// same-named declaration; the collision is diagnosed at the declaration).
2361pub fn resolve_type_ref_in(
2362    r: &TypeRef,
2363    types: &HashMap<String, Arc<TypeDecl>>,
2364    vars: &HashSet<String>,
2365    tys: &Types,
2366) -> Option<TyId> {
2367    let ty = match r {
2368        TypeRef::Named(id) if vars.contains(&id.name) => Ty::Var(id.name.clone()),
2369        TypeRef::Result(t, e, _) => Ty::Result(
2370            resolve_type_ref_in(t, types, vars, tys)?,
2371            resolve_type_ref_in(e, types, vars, tys)?,
2372        ),
2373        TypeRef::Option(t, _) => Ty::Option(resolve_type_ref_in(t, types, vars, tys)?),
2374        TypeRef::Effect(t, _) => Ty::Effect(resolve_type_ref_in(t, types, vars, tys)?),
2375        TypeRef::HttpResult(t, _) => Ty::HttpResult(resolve_type_ref_in(t, types, vars, tys)?),
2376        TypeRef::List(t, _) => Ty::List(resolve_type_ref_in(t, types, vars, tys)?),
2377        TypeRef::Query(t, _) => Ty::Query(resolve_type_ref_in(t, types, vars, tys)?),
2378        TypeRef::Stream(t, _) => Ty::Stream(resolve_type_ref_in(t, types, vars, tys)?),
2379        TypeRef::Connection(t, _) => Ty::Connection(resolve_type_ref_in(t, types, vars, tys)?),
2380        TypeRef::Map(k, v, _) => Ty::Map(
2381            resolve_type_ref_in(k, types, vars, tys)?,
2382            resolve_type_ref_in(v, types, vars, tys)?,
2383        ),
2384        TypeRef::Fn(params, ret, _) => {
2385            let params: Option<Vec<TyId>> = params
2386                .iter()
2387                .map(|p| resolve_type_ref_in(p, types, vars, tys))
2388                .collect();
2389            Ty::Fn {
2390                params: params?,
2391                ret: resolve_type_ref_in(ret, types, vars, tys)?,
2392            }
2393        }
2394        // v0.157 (ADR 0183): `Name[Arg, …]` — application of a user generic
2395        // type. Arguments resolve with the enclosing type parameters in scope;
2396        // existence/arity are validated in the resolver, so an unknown or
2397        // mis-applied name simply produces no type here.
2398        TypeRef::App { name, args, .. } => {
2399            let decl = types.get(&name.name)?;
2400            let args: Option<Vec<TyId>> = args
2401                .iter()
2402                .map(|a| resolve_type_ref_in(a, types, vars, tys))
2403                .collect();
2404            return Some(named_ty_with_args(decl, args?, tys));
2405        }
2406        _ => return resolve_type_ref(r, types, tys),
2407    };
2408    Some(tys.intern(ty))
2409}
2410
2411/// v0.20a: substitute type variables in `t` per `subst`. Must be total when
2412/// instantiating a call (the uninferable check runs first); an unbound Var
2413/// passes through unchanged for partial substitution during inference.
2414pub(crate) fn substitute(t: TyId, subst: &HashMap<String, TyId>, tys: &Types) -> TyId {
2415    let node = tys.get(t);
2416    let substituted = match &*node {
2417        Ty::Var(n) => return subst.get(n).copied().unwrap_or(t),
2418        Ty::Result(a, b) => Ty::Result(substitute(*a, subst, tys), substitute(*b, subst, tys)),
2419        Ty::Option(a) => Ty::Option(substitute(*a, subst, tys)),
2420        Ty::Effect(a) => Ty::Effect(substitute(*a, subst, tys)),
2421        Ty::HttpResult(a) => Ty::HttpResult(substitute(*a, subst, tys)),
2422        Ty::List(a) => Ty::List(substitute(*a, subst, tys)),
2423        Ty::Query(a) => Ty::Query(substitute(*a, subst, tys)),
2424        Ty::Stream(a) => Ty::Stream(substitute(*a, subst, tys)),
2425        Ty::Connection(a) => Ty::Connection(substitute(*a, subst, tys)),
2426        Ty::Map(k, v) => Ty::Map(substitute(*k, subst, tys), substitute(*v, subst, tys)),
2427        Ty::Fn { params, ret } => Ty::Fn {
2428            params: params.iter().map(|p| substitute(*p, subst, tys)).collect(),
2429            ret: substitute(*ret, subst, tys),
2430        },
2431        // v0.157 (ADR 0183): a generic named type's arguments may carry vars —
2432        // substitution recurses into them (an under-applied bare reference has
2433        // empty `args`, so this is a no-op there).
2434        Ty::Named { name, kind, args } => Ty::Named {
2435            name: name.clone(),
2436            kind: kind.clone(),
2437            args: args.iter().map(|a| substitute(*a, subst, tys)).collect(),
2438        },
2439        // Leaves (no inner type to ground) and the sealed actor bindings
2440        // (boundary-minted, never Var-bearing). Enumerated — no `_` — so a
2441        // new `Ty` variant must state whether substitution recurses into it.
2442        // `Ty::Error` is a leaf by construction (R4.3): it never carries a
2443        // `Var` to ground. T3.6b: a leaf substitutes to itself, and its `TyId`
2444        // is already that value — return it rather than re-interning.
2445        Ty::Error
2446        | Ty::Base(_)
2447        | Ty::QueueResult
2448        | Ty::ValidationError
2449        | Ty::JsonError
2450        | Ty::Unit
2451        | Ty::Actor(_)
2452        | Ty::ActorSum(_) => return t,
2453    };
2454    tys.intern(substituted)
2455}
2456
2457/// v0.20a: does `t` still contain a type variable?
2458pub(crate) fn contains_var(t: TyId, tys: &Types) -> bool {
2459    match &*tys.get(t) {
2460        Ty::Var(_) => true,
2461        // R4.3: `Ty::Error` is a leaf; it never carries a `Var`.
2462        Ty::Error => false,
2463        Ty::Result(a, b) | Ty::Map(a, b) => contains_var(*a, tys) || contains_var(*b, tys),
2464        Ty::Option(a)
2465        | Ty::Effect(a)
2466        | Ty::HttpResult(a)
2467        | Ty::List(a)
2468        | Ty::Query(a)
2469        | Ty::Stream(a)
2470        | Ty::Connection(a) => contains_var(*a, tys),
2471        Ty::Fn { params, ret } => {
2472            params.iter().any(|p| contains_var(*p, tys)) || contains_var(*ret, tys)
2473        }
2474        // v0.157 (ADR 0183): a generic named type's arguments may carry vars.
2475        Ty::Named { args, .. } => args.iter().any(|a| contains_var(*a, tys)),
2476        Ty::Base(_)
2477        | Ty::QueueResult
2478        | Ty::ValidationError
2479        | Ty::JsonError
2480        | Ty::Unit
2481        | Ty::Actor(_)
2482        | Ty::ActorSum(_) => false,
2483    }
2484}
2485
2486/// v0.20b: does `t` contain a type variable that is NOT one of the enclosing
2487/// function's rigid type parameters? Rigid vars are fully constrained inside
2488/// the body; only flexible (call-site instantiation) vars mean "still being
2489/// inferred".
2490fn contains_flexible_var(t: TyId, rigid: &HashSet<String>, tys: &Types) -> bool {
2491    match &*tys.get(t) {
2492        Ty::Var(n) => !rigid.contains(n),
2493        // R4.3: `Ty::Error` is a leaf; it never carries a `Var`.
2494        Ty::Error => false,
2495        Ty::Result(a, b) | Ty::Map(a, b) => {
2496            contains_flexible_var(*a, rigid, tys) || contains_flexible_var(*b, rigid, tys)
2497        }
2498        Ty::Option(a)
2499        | Ty::Effect(a)
2500        | Ty::HttpResult(a)
2501        | Ty::List(a)
2502        | Ty::Query(a)
2503        | Ty::Stream(a)
2504        | Ty::Connection(a) => contains_flexible_var(*a, rigid, tys),
2505        Ty::Fn { params, ret } => {
2506            params.iter().any(|p| contains_flexible_var(*p, rigid, tys))
2507                || contains_flexible_var(*ret, rigid, tys)
2508        }
2509        // v0.157 (ADR 0183): a generic named type's arguments may carry vars.
2510        Ty::Named { args, .. } => args.iter().any(|a| contains_flexible_var(*a, rigid, tys)),
2511        Ty::Base(_)
2512        | Ty::QueueResult
2513        | Ty::ValidationError
2514        | Ty::JsonError
2515        | Ty::Unit
2516        | Ty::Actor(_)
2517        | Ty::ActorSum(_) => false,
2518    }
2519}
2520
2521/// v0.20a: argument-directed unification. Walks `pattern` (possibly
2522/// Var-bearing) against the ground `actual`; a Var binds on first sight and
2523/// must match its prior binding **exactly** afterwards (keep inference dumb
2524/// and predictable — the explicit `name[T](…)` form is the pressure valve).
2525/// Returns false on a conflict; structural mismatches are NOT reported here —
2526/// the post-substitution `compatible` check owns those diagnostics.
2527pub(crate) fn unify(
2528    pattern: TyId,
2529    actual: TyId,
2530    subst: &mut HashMap<String, TyId>,
2531    tys: &Types,
2532) -> bool {
2533    // T3.6b: bind the two nodes first — the `Rc`s must outlive the `match`
2534    // they are destructured by, and a `TyId` pair is not itself matchable.
2535    let (p_node, a_node) = (tys.get(pattern), tys.get(actual));
2536    match (&*p_node, &*a_node) {
2537        (Ty::Var(n), _) => match subst.get(n) {
2538            // T3.6b (R4.1): "matches its prior binding exactly" is now a
2539            // `TyId` comparison — one `u32` equality, where it used to be a
2540            // recursive structural walk. Interning is what makes the two
2541            // equivalent.
2542            Some(bound) => *bound == actual,
2543            None => {
2544                subst.insert(n.clone(), actual);
2545                true
2546            }
2547        },
2548        (Ty::Result(a1, b1), Ty::Result(a2, b2)) | (Ty::Map(a1, b1), Ty::Map(a2, b2)) => {
2549            unify(*a1, *a2, subst, tys) && unify(*b1, *b2, subst, tys)
2550        }
2551        (Ty::Option(a1), Ty::Option(a2))
2552        | (Ty::Effect(a1), Ty::Effect(a2))
2553        | (Ty::HttpResult(a1), Ty::HttpResult(a2))
2554        | (Ty::List(a1), Ty::List(a2))
2555        | (Ty::Query(a1), Ty::Query(a2))
2556        | (Ty::Stream(a1), Ty::Stream(a2))
2557        | (Ty::Connection(a1), Ty::Connection(a2)) => unify(*a1, *a2, subst, tys),
2558        (
2559            Ty::Fn {
2560                params: p1,
2561                ret: r1,
2562            },
2563            Ty::Fn {
2564                params: p2,
2565                ret: r2,
2566            },
2567        ) => {
2568            p1.len() == p2.len()
2569                && p1
2570                    .iter()
2571                    .zip(p2)
2572                    .all(|(a, b)| unify(*a, *b, subst, tys))
2573                && unify(*r1, *r2, subst, tys)
2574        }
2575        // v0.157 (ADR 0183): a generic named type binds vars through its
2576        // arguments — `Paginated[T]` against `Paginated[User]` binds `T=User`.
2577        (
2578            Ty::Named {
2579                name: n1, args: a1, ..
2580            },
2581            Ty::Named {
2582                name: n2, args: a2, ..
2583            },
2584        ) if n1 == n2 && a1.len() == a2.len() && !a1.is_empty() => {
2585            a1.iter().zip(a2).all(|(x, y)| unify(*x, *y, subst, tys))
2586        }
2587        // Ground-vs-ground: any pair is fine here; `compatible` owns the
2588        // real check after substitution. The left side is enumerated — no
2589        // `_` — so a new inner-type-bearing `Ty` variant must add its
2590        // recursion arm above instead of silently skipping unification.
2591        (
2592            Ty::Base(_)
2593            | Ty::Named { .. }
2594            | Ty::Result(..)
2595            | Ty::Option(_)
2596            | Ty::Effect(_)
2597            | Ty::HttpResult(_)
2598            | Ty::QueueResult
2599            | Ty::List(_)
2600            | Ty::Map(..)
2601            | Ty::Query(_)
2602            | Ty::Stream(_)
2603            | Ty::Connection(_)
2604            | Ty::ValidationError
2605            | Ty::JsonError
2606            | Ty::Unit
2607            | Ty::Actor(_)
2608            | Ty::ActorSum(_)
2609            | Ty::Fn { .. }
2610            // R4.3: an already-diagnosed subtree unifies with anything —
2611            // the failure was reported once, at the site that produced
2612            // `Ty::Error`; unification is not where a second one belongs.
2613            | Ty::Error,
2614            _,
2615        ) => true,
2616    }
2617}
2618
2619/// v0.25 (ADR 0053): record a binding edge for every `Named` reference
2620/// inside a type-ref that resolved. Called alongside the `resolve_type_ref*`
2621/// annotation sites; `skip` holds the enclosing fn's type parameters (rigid
2622/// vars are not type symbols). Handler signatures and body annotations never
2623/// pass through the resolver's reference walk, so these sites are their only
2624/// recording point; where both passes run, assembly dedupes.
2625pub fn record_type_refs(
2626    r: &TypeRef,
2627    types: &HashMap<String, Arc<TypeDecl>>,
2628    skip: &HashSet<String>,
2629    refs: &mut RefSink,
2630) {
2631    match r {
2632        TypeRef::Named(id) => {
2633            if types.contains_key(&id.name) && !skip.contains(&id.name) {
2634                refs.record(id.span, SymbolKind::Type, &id.name);
2635            }
2636        }
2637        TypeRef::Fn(params, ret, _) => {
2638            for p in params {
2639                record_type_refs(p, types, skip, refs);
2640            }
2641            record_type_refs(ret, types, skip, refs);
2642        }
2643        TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
2644            record_type_refs(a, types, skip, refs);
2645            record_type_refs(b, types, skip, refs);
2646        }
2647        TypeRef::Option(t, _)
2648        | TypeRef::Effect(t, _)
2649        | TypeRef::HttpResult(t, _)
2650        | TypeRef::Query(t, _)
2651        | TypeRef::Stream(t, _)
2652        | TypeRef::Connection(t, _)
2653        | TypeRef::History(t, _)
2654        | TypeRef::List(t, _) => record_type_refs(t, types, skip, refs),
2655        // v0.157 (ADR 0183): a `Name[Arg, …]` application records the generic
2656        // type's name plus every argument.
2657        TypeRef::App { name, args, .. } => {
2658            if types.contains_key(&name.name) && !skip.contains(&name.name) {
2659                refs.record(name.span, SymbolKind::Type, &name.name);
2660            }
2661            for a in args {
2662                record_type_refs(a, types, skip, refs);
2663            }
2664        }
2665        TypeRef::Base(..)
2666        | TypeRef::QueueResult(_)
2667        | TypeRef::ValidationError(_)
2668        | TypeRef::JsonError(_)
2669        | TypeRef::Unit(_) => {}
2670    }
2671}
2672
2673/// #712: resolve a type reference that appears in an *expression* position the
2674/// resolver does not walk for handler bodies — explicit call type arguments
2675/// (`identity[T](x)`), `Json.decode[T]`, and lambda parameter annotations
2676/// (`(x: T) => …`). On failure the reference is silently dropped by the bare
2677/// `resolve_type_ref_in`, so an unknown type in a handler body would compile
2678/// clean; this reports `bynk.resolve.unknown_type` instead, and records the
2679/// resolved type's references for the IDE on success. The resolver still covers
2680/// `fn`/method bodies, and the checker runs only after the resolver returns Ok
2681/// (`bynk-emit`'s pipeline sequences `resolve(..)?` then `check(..)`), so this
2682/// never double-reports.
2683pub(crate) fn resolve_expr_type_ref(r: &TypeRef, ctx: &mut Ctx) -> Option<TyId> {
2684    let tys = ctx.tys;
2685    match resolve_type_ref_in(r, &ctx.input.types, &ctx.type_vars, tys) {
2686        Some(ty) => {
2687            record_type_refs(r, &ctx.input.types, &ctx.type_vars, ctx.refs);
2688            Some(ty)
2689        }
2690        None => {
2691            ctx.errors.push(unresolved_type_ref_error(
2692                r,
2693                &ctx.input.types,
2694                &ctx.type_vars,
2695            ));
2696            None
2697        }
2698    }
2699}
2700
2701/// #712: the diagnostic for a type reference that fails to resolve. Points at
2702/// the exact offending name when one can be identified (`identity[Missing](5)`
2703/// → the `Missing` span), falling back to the whole reference otherwise.
2704fn unresolved_type_ref_error(
2705    r: &TypeRef,
2706    types: &HashMap<String, Arc<TypeDecl>>,
2707    vars: &HashSet<String>,
2708) -> CompileError {
2709    match first_unresolved_type_name(r, types, vars) {
2710        Some(id) => CompileError::new(
2711            "bynk.resolve.unknown_type",
2712            id.span,
2713            format!("unknown type `{}`", id.name),
2714        )
2715        .with_note(
2716            "only base types (Int, String, Bool), types declared in this commons, \
2717             `Result[T, E]`, `Option[T]`, and `ValidationError` are in scope",
2718        ),
2719        None => CompileError::new(
2720            "bynk.resolve.unknown_type",
2721            r.span(),
2722            "this type does not resolve",
2723        ),
2724    }
2725}
2726
2727/// #712: the first type name in `r` that names neither a declared type nor an
2728/// in-scope type variable — the reason `resolve_type_ref_in` returned `None`.
2729fn first_unresolved_type_name<'a>(
2730    r: &'a TypeRef,
2731    types: &HashMap<String, Arc<TypeDecl>>,
2732    vars: &HashSet<String>,
2733) -> Option<&'a Ident> {
2734    match r {
2735        TypeRef::Named(id) => {
2736            (!types.contains_key(&id.name) && !vars.contains(&id.name)).then_some(id)
2737        }
2738        TypeRef::App { name, args, .. } => {
2739            if !types.contains_key(&name.name) && !vars.contains(&name.name) {
2740                return Some(name);
2741            }
2742            args.iter()
2743                .find_map(|a| first_unresolved_type_name(a, types, vars))
2744        }
2745        TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
2746            first_unresolved_type_name(a, types, vars)
2747                .or_else(|| first_unresolved_type_name(b, types, vars))
2748        }
2749        TypeRef::Option(t, _)
2750        | TypeRef::Effect(t, _)
2751        | TypeRef::HttpResult(t, _)
2752        | TypeRef::List(t, _)
2753        | TypeRef::Query(t, _)
2754        | TypeRef::Stream(t, _)
2755        | TypeRef::Connection(t, _)
2756        | TypeRef::History(t, _) => first_unresolved_type_name(t, types, vars),
2757        TypeRef::Fn(params, ret, _) => params
2758            .iter()
2759            .find_map(|p| first_unresolved_type_name(p, types, vars))
2760            .or_else(|| first_unresolved_type_name(ret, types, vars)),
2761        TypeRef::Base(..)
2762        | TypeRef::QueueResult(_)
2763        | TypeRef::ValidationError(_)
2764        | TypeRef::JsonError(_)
2765        | TypeRef::Unit(_) => None,
2766    }
2767}
2768
2769/// v0.154 (ADR 0178): the declared error embedding that converts `source_err`
2770/// into `target_err`, if one exists. When `target_err` is a sum declaring
2771/// `embeds E as V` with `E` compatible with `source_err`, returns
2772/// `(sum_type_name, variant_name)` — the variant a value of `source_err`
2773/// auto-wraps into. One level only: the source must match a declared embedding
2774/// directly. Used by `?` in the checker (to accept the conversion) and the
2775/// emitter (to lower the `Err`-wrap) from the **same** rule, so the two cannot
2776/// diverge.
2777pub fn embedding_for(
2778    target_err: TyId,
2779    source_err: TyId,
2780    types: &HashMap<String, Arc<TypeDecl>>,
2781    tys: &Types,
2782) -> Option<(String, String)> {
2783    let target_node = tys.get(target_err);
2784    let Ty::Named { name, .. } = &*target_node else {
2785        return None;
2786    };
2787    let decl = types.get(name)?;
2788    let TypeBody::Sum(sum) = &decl.body else {
2789        return None;
2790    };
2791    for clause in &sum.embeds {
2792        if let Some(src) = resolve_type_ref(&clause.source_type, types, tys)
2793            && compatible(source_err, src, tys)
2794        {
2795            return Some((name.clone(), clause.variant.name.clone()));
2796        }
2797    }
2798    None
2799}
2800
2801pub fn resolve_type_ref(
2802    r: &TypeRef,
2803    types: &HashMap<String, Arc<TypeDecl>>,
2804    tys: &Types,
2805) -> Option<TyId> {
2806    let ty = match r {
2807        TypeRef::Base(b, _) => Ty::Base(*b),
2808        TypeRef::Named(id) => return type_from_decl(id, types, tys),
2809        // v0.20a: a function type. Effectfulness is structural (ret is
2810        // Effect[_]); nothing extra to record.
2811        TypeRef::Fn(params, ret, _) => {
2812            let params: Option<Vec<TyId>> = params
2813                .iter()
2814                .map(|p| resolve_type_ref(p, types, tys))
2815                .collect();
2816            Ty::Fn {
2817                params: params?,
2818                ret: resolve_type_ref(ret, types, tys)?,
2819            }
2820        }
2821        TypeRef::Result(t, e, _) => Ty::Result(
2822            resolve_type_ref(t, types, tys)?,
2823            resolve_type_ref(e, types, tys)?,
2824        ),
2825        TypeRef::Option(t, _) => Ty::Option(resolve_type_ref(t, types, tys)?),
2826        TypeRef::Effect(t, _) => Ty::Effect(resolve_type_ref(t, types, tys)?),
2827        TypeRef::HttpResult(t, _) => Ty::HttpResult(resolve_type_ref(t, types, tys)?),
2828        TypeRef::List(t, _) => Ty::List(resolve_type_ref(t, types, tys)?),
2829        TypeRef::Query(t, _) => Ty::Query(resolve_type_ref(t, types, tys)?),
2830        TypeRef::Stream(t, _) => Ty::Stream(resolve_type_ref(t, types, tys)?),
2831        TypeRef::Connection(t, _) => Ty::Connection(resolve_type_ref(t, types, tys)?),
2832        TypeRef::Map(k, v, _) => Ty::Map(
2833            resolve_type_ref(k, types, tys)?,
2834            resolve_type_ref(v, types, tys)?,
2835        ),
2836        TypeRef::QueueResult(_) => Ty::QueueResult,
2837        // v0.119 (ADR 0155): `History[Agent]` is not a value type — it is a
2838        // test-only generator handled directly in `check_property_body`. It never
2839        // resolves as an ordinary type, so a stray `History[…]` in a value
2840        // position fails to resolve (the resolver reports `outside_property`).
2841        TypeRef::History(_, _) => return None,
2842        // v0.157 (ADR 0183): `Name[Arg, …]` — a user generic-type application.
2843        TypeRef::App { name, args, .. } => {
2844            let decl = types.get(&name.name)?;
2845            let args: Option<Vec<TyId>> = args
2846                .iter()
2847                .map(|a| resolve_type_ref(a, types, tys))
2848                .collect();
2849            return Some(named_ty_with_args(decl, args?, tys));
2850        }
2851        TypeRef::ValidationError(_) => Ty::ValidationError,
2852        TypeRef::JsonError(_) => Ty::JsonError,
2853        TypeRef::Unit(_) => Ty::Unit,
2854    };
2855    Some(tys.intern(ty))
2856}
2857
2858/// `t` is usable where `u` is expected.
2859///
2860/// T3.6b: deliberately **no** `t == u` fast path, tempting as interning makes
2861/// one. `compatible` is not reflexive — `Actor`/`ActorSum` are sealed boundary
2862/// values that fall through to the `false` arm below even against themselves
2863/// (they are matched, never assigned), so short-circuiting on id equality
2864/// would silently make them assignable.
2865pub fn compatible(t: TyId, u: TyId, tys: &Types) -> bool {
2866    let (t_node, u_node) = (tys.get(t), tys.get(u));
2867    match (&*t_node, &*u_node) {
2868        // R4.3: `Ty::Error` is compatible with everything, in both positions
2869        // — the failure that produced it was already diagnosed at its own
2870        // site, and a mismatch diagnostic naming it here would be a second
2871        // report of the same failure, not a new one. Ordered first so it
2872        // takes priority over the more specific arms below.
2873        (Ty::Error, _) | (_, Ty::Error) => true,
2874        (Ty::Base(a), Ty::Base(b)) => a == b,
2875        // v0.157 (ADR 0183): two named types are compatible when they share a
2876        // name and kind and their applied type arguments are pairwise
2877        // compatible. Records are immutable (`readonly` fields), so the
2878        // arguments are covariant — like `List`/`Option`.
2879        (
2880            Ty::Named {
2881                name: a,
2882                kind: ka,
2883                args: aa,
2884            },
2885            Ty::Named {
2886                name: b,
2887                kind: kb,
2888                args: ba,
2889            },
2890        ) => {
2891            a == b
2892                && ka == kb
2893                && aa.len() == ba.len()
2894                && aa.iter().zip(ba).all(|(x, y)| compatible(*x, *y, tys))
2895        }
2896        // Refined → base (widening).
2897        (
2898            Ty::Named {
2899                kind: NamedKind::Refined(b),
2900                ..
2901            },
2902            Ty::Base(target),
2903        ) => b == target,
2904        (Ty::Base(_), Ty::Named { .. }) => false,
2905        (Ty::Result(t1, e1), Ty::Result(t2, e2)) => {
2906            compatible(*t1, *t2, tys) && compatible(*e1, *e2, tys)
2907        }
2908        (Ty::Option(a), Ty::Option(b)) => compatible(*a, *b, tys),
2909        (Ty::Effect(a), Ty::Effect(b)) => compatible(*a, *b, tys),
2910        (Ty::HttpResult(a), Ty::HttpResult(b)) => compatible(*a, *b, tys),
2911        // v0.20b: collections are covariant in their element/value types;
2912        // Map keys must match exactly — key-position widening would split a
2913        // map's keys across refined/base identities at lookup time.
2914        (Ty::List(a), Ty::List(b)) => compatible(*a, *b, tys),
2915        // v0.100: `Stream[T]` is covariant in its element, like `List`/`Effect`.
2916        // (Assignability only — streams are not value-comparable for `==`.)
2917        (Ty::Stream(a), Ty::Stream(b)) => compatible(*a, *b, tys),
2918        // v0.91: `Query[T]` is covariant in its element, like `List`/`Stream`.
2919        // (Assignability only — queries are not value-comparable for `==`.)
2920        (Ty::Query(a), Ty::Query(b)) => compatible(*a, *b, tys),
2921        // v0.102: a `Connection[F]` is assignable to itself (the linearity pass
2922        // governs the move). Held values have identity, not value-equality, so
2923        // they are not `==`-comparable (guarded in the `Eq`/`NotEq` arm).
2924        (Ty::Connection(a), Ty::Connection(b)) => compatible(*a, *b, tys),
2925        (Ty::Map(k1, v1), Ty::Map(k2, v2)) => k1 == k2 && compatible(*v1, *v2, tys),
2926        (Ty::QueueResult, Ty::QueueResult) => true,
2927        (Ty::ValidationError, Ty::ValidationError) => true,
2928        (Ty::JsonError, Ty::JsonError) => true,
2929        (Ty::Unit, Ty::Unit) => true,
2930        // v0.20a: function types — **contravariant** in parameters, covariant
2931        // in the return type. `compatible(t, u, tys)` is "t usable where u is
2932        // expected" and is already asymmetric (refined → base widening), so
2933        // the per-position argument order flips for params: a function
2934        // expecting the *wider* param type is usable where one expecting the
2935        // narrower is required — and crucially, the covariant direction would
2936        // let unvalidated base values flow into a refined-typed body.
2937        (Ty::Fn { params: p, ret: r }, Ty::Fn { params: q, ret: s }) => {
2938            p.len() == q.len()
2939                && p.iter().zip(q).all(|(a, b)| compatible(*b, *a, tys))
2940                && compatible(*r, *s, tys)
2941        }
2942        // v0.20a: rigid type variables (a generic fn's own body) match by
2943        // name. Flexible vars never reach `compatible` — they are eliminated
2944        // by substitution during call-site instantiation.
2945        (Ty::Var(a), Ty::Var(b)) => a == b,
2946        // Everything else is incompatible: cross-variant pairs, and the
2947        // sealed boundary values (`Actor`/`ActorSum` are only ever matched,
2948        // never assigned). The left side is enumerated — no `_` — so adding
2949        // a `Ty` variant fails to compile here instead of silently making
2950        // the new type incompatible with itself (the trap `Query` fell into).
2951        (
2952            Ty::Base(_)
2953            | Ty::Named { .. }
2954            | Ty::Result(..)
2955            | Ty::Option(_)
2956            | Ty::Effect(_)
2957            | Ty::HttpResult(_)
2958            | Ty::QueueResult
2959            | Ty::List(_)
2960            | Ty::Map(..)
2961            | Ty::Query(_)
2962            | Ty::Stream(_)
2963            | Ty::Connection(_)
2964            | Ty::ValidationError
2965            | Ty::JsonError
2966            | Ty::Unit
2967            | Ty::Actor(_)
2968            | Ty::ActorSum(_)
2969            | Ty::Fn { .. }
2970            | Ty::Var(_),
2971            _,
2972        ) => false,
2973    }
2974}
2975
2976pub(crate) fn type_of_block(block: &Block, expected: Option<TyId>, ctx: &mut Ctx) -> Option<TyId> {
2977    let tys = ctx.tys;
2978    ctx.push_scope();
2979    for stmt in &block.statements {
2980        match stmt {
2981            Statement::Let(l) => {
2982                let annot_ty = l.type_annot.as_ref().and_then(|a| {
2983                    // v0.20b: the enclosing fn's type parameters are legal
2984                    // in body annotations (`let init: List[B] = …`).
2985                    let r = resolve_type_ref_in(a, &ctx.input.types, &ctx.type_vars, tys);
2986                    if r.is_none() {
2987                        ctx.errors.push(CompileError::new(
2988                            "bynk.resolve.unknown_type",
2989                            a.span(),
2990                            "type in `let` annotation does not resolve",
2991                        ));
2992                    } else {
2993                        record_type_refs(a, &ctx.input.types, &ctx.type_vars, ctx.refs);
2994                    }
2995                    r
2996                });
2997                let rhs_ty = type_of(&l.value, annot_ty, ctx);
2998                let final_ty = match (annot_ty, rhs_ty) {
2999                    (Some(annot), Some(rhs)) => {
3000                        if !compatible(rhs, annot, tys) {
3001                            ctx.errors.push(
3002                                CompileError::new(
3003                                    "bynk.types.let_annotation_mismatch",
3004                                    l.value.span,
3005                                    format!(
3006                                        "let binding's value has type `{}`, but the annotation declares `{}`",
3007                                        rhs.display(tys),
3008                                        annot.display(tys)
3009                                    ),
3010                                )
3011                                .with_label(
3012                                    l.type_annot.as_ref().unwrap().span(),
3013                                    "declared type annotation",
3014                                ),
3015                            );
3016                        }
3017                        annot
3018                    }
3019                    (Some(annot), None) => annot,
3020                    (None, Some(rhs)) => rhs,
3021                    (None, None) => continue,
3022                };
3023                if l.name.name != "_" {
3024                    // v0.27 (ADR 0056): an annotation-absent binding gets an
3025                    // inferred-type inlay hint at the binding name.
3026                    if l.type_annot.is_none() {
3027                        ctx.hints
3028                            .record(l.name.span, format!(": {}", final_ty.display(tys)));
3029                    }
3030                    // v0.31: in scope from after this statement to block end.
3031                    ctx.locals.record(
3032                        l.name.name.clone(),
3033                        l.name.span,
3034                        crate::locals::LocalKind::Let,
3035                        final_ty.display(tys),
3036                        Span {
3037                            file: l.span.file,
3038                            start: l.span.end,
3039                            end: block.span.end,
3040                        },
3041                    );
3042                    ctx.bind(l.name.name.clone(), final_ty);
3043                }
3044            }
3045            Statement::EffectLet(l) => {
3046                if !ctx.effectful {
3047                    ctx.errors.push(
3048                        CompileError::new(
3049                            "bynk.effect.bind_in_pure_context",
3050                            l.span,
3051                            "the `<-` operator can only be used inside an effectful body (one returning `Effect[T]`)",
3052                        )
3053                        .with_label(
3054                            ctx.return_ty_span,
3055                            format!("enclosing return type is `{}`", ctx.return_ty.display(tys)),
3056                        )
3057                        .with_note(
3058                            "change the enclosing function/handler's return type to `Effect[...]`, or use `let ... =` for a pure binding",
3059                        ),
3060                    );
3061                }
3062                // Determine the inner Effect[T] payload type for the binding.
3063                let annot_ty = l.type_annot.as_ref().and_then(|a| {
3064                    // v0.20b: the enclosing fn's type parameters are legal
3065                    // in body annotations (`let init: List[B] = …`).
3066                    let r = resolve_type_ref_in(a, &ctx.input.types, &ctx.type_vars, tys);
3067                    if r.is_none() {
3068                        ctx.errors.push(CompileError::new(
3069                            "bynk.resolve.unknown_type",
3070                            a.span(),
3071                            "type in `let` annotation does not resolve",
3072                        ));
3073                    } else {
3074                        record_type_refs(a, &ctx.input.types, &ctx.type_vars, ctx.refs);
3075                    }
3076                    r
3077                });
3078                // The expected type for the RHS is `Effect[annot]` if annot present.
3079                let rhs_expected = annot_ty.map(|t| tys.intern(Ty::Effect(t)));
3080                let rhs_ty = type_of(&l.value, rhs_expected, ctx);
3081                // v0.182 (#664): validate the call-site principal against the
3082                // addressed handler — including the *absent* case, where an
3083                // identity-carrying handler driven with no `by` would silently
3084                // drop the identity.
3085                calls::check_effect_let_principal(&l.value, l.principal.as_ref(), ctx);
3086                let inner_ty = match rhs_ty.map(|t| tys.get(t)).as_deref() {
3087                    Some(Ty::Effect(t)) => Some(*t),
3088                    Some(_) => {
3089                        ctx.errors.push(
3090                            CompileError::new(
3091                                "bynk.effect.bind_on_non_effect",
3092                                l.value.span,
3093                                format!(
3094                                    "the `<-` operator requires an `Effect[T]` value, but got `{}`",
3095                                    rhs_ty.expect("matched Some").display(tys)
3096                                ),
3097                            )
3098                            .with_note(
3099                                "use `let ... =` for a pure binding, or wrap the value with `Effect.pure(...)`",
3100                            ),
3101                        );
3102                        None
3103                    }
3104                    None => None,
3105                };
3106                let final_ty = match (annot_ty, inner_ty) {
3107                    (Some(annot), Some(rhs)) => {
3108                        if !compatible(rhs, annot, tys) {
3109                            ctx.errors.push(CompileError::new(
3110                                "bynk.types.let_annotation_mismatch",
3111                                l.value.span,
3112                                format!(
3113                                    "let-binding's value has type `Effect[{}]`, but the annotation declares `Effect[{}]`",
3114                                    rhs.display(tys),
3115                                    annot.display(tys)
3116                                ),
3117                            ));
3118                        }
3119                        annot
3120                    }
3121                    (Some(annot), None) => annot,
3122                    (None, Some(rhs)) => rhs,
3123                    (None, None) => continue,
3124                };
3125                if l.name.name != "_" {
3126                    // v0.27 (ADR 0056): as for `let =`, but `final_ty` here
3127                    // is the peeled `Effect[T]` payload — the binding's
3128                    // actual type, which is what the hint must show.
3129                    if l.type_annot.is_none() {
3130                        ctx.hints
3131                            .record(l.name.span, format!(": {}", final_ty.display(tys)));
3132                    }
3133                    ctx.locals.record(
3134                        l.name.name.clone(),
3135                        l.name.span,
3136                        crate::locals::LocalKind::Let,
3137                        final_ty.display(tys),
3138                        Span {
3139                            file: l.span.file,
3140                            start: l.span.end,
3141                            end: block.span.end,
3142                        },
3143                    );
3144                    ctx.bind(l.name.name.clone(), final_ty);
3145                }
3146            }
3147            Statement::Expect(a) => {
3148                if !ctx.in_test_body {
3149                    ctx.errors.push(
3150                        CompileError::new(
3151                            "bynk.expect.outside_case",
3152                            a.span,
3153                            "`expect` is only valid inside a `case` body",
3154                        )
3155                        .with_note(
3156                            "expectations verify predicates at test runtime; use them only inside `case \"...\" { ... }` blocks",
3157                        ),
3158                    );
3159                }
3160                let val_ty = type_of(&a.value, Some(tys.intern(Ty::Base(BaseType::Bool))), ctx);
3161                if let Some(actual) = val_ty
3162                    && !compatible(actual, tys.intern(Ty::Base(BaseType::Bool)), tys)
3163                {
3164                    ctx.errors.push(CompileError::new(
3165                        "bynk.expect.not_bool",
3166                        a.value.span,
3167                        format!(
3168                            "`expect` predicate has type `{}`, but a `Bool` is required",
3169                            actual.display(tys),
3170                        ),
3171                    ));
3172                }
3173            }
3174            Statement::Send(s) => {
3175                // v0.79: `~> e` — fire-and-forget. Effectful context only, like
3176                // `<-`; the reply is never awaited, so nothing is bound.
3177                if !ctx.effectful {
3178                    ctx.errors.push(
3179                        CompileError::new(
3180                            "bynk.send.in_pure_context",
3181                            s.span,
3182                            "the `~>` send can only be used inside an effectful body (one returning `Effect[T]`)",
3183                        )
3184                        .with_label(
3185                            ctx.return_ty_span,
3186                            format!("enclosing return type is `{}`", ctx.return_ty.display(tys)),
3187                        )
3188                        .with_note(
3189                            "change the enclosing function/handler's return type to `Effect[...]`",
3190                        ),
3191                    );
3192                }
3193                // The reply must be `Effect[()]`. A real payload (value or error)
3194                // would be silently dropped by a fire-and-forget send — the error
3195                // gate ([DECISION C/D]). `let _ <- e` is the honest spelling for
3196                // "await and discard".
3197                let unit = tys.intern(Ty::Unit);
3198                let expected = tys.intern(Ty::Effect(unit));
3199                let rhs_ty = type_of(&s.value, Some(expected), ctx);
3200                match rhs_ty.map(|t| tys.get(t)).as_deref() {
3201                    Some(Ty::Effect(inner)) if *inner == unit => {}
3202                    Some(Ty::Effect(inner)) => {
3203                        ctx.errors.push(
3204                            CompileError::new(
3205                                "bynk.send.requires_unit",
3206                                s.value.span,
3207                                format!(
3208                                    "`~>` requires an `Effect[()]` reply, but this send returns `Effect[{}]` — its result would be silently dropped",
3209                                    inner.display(tys)
3210                                ),
3211                            )
3212                            .with_note(
3213                                "a `~>` send never awaits a reply, so it is reserved for empty replies; to await and discard a real result, write `let _ <- ...` instead",
3214                            ),
3215                        );
3216                    }
3217                    Some(other) => {
3218                        ctx.errors.push(
3219                            CompileError::new(
3220                                "bynk.send.non_effect",
3221                                s.value.span,
3222                                format!(
3223                                    "the `~>` send requires an `Effect[()]` value, but got `{}`",
3224                                    other.display(tys)
3225                                ),
3226                            )
3227                            .with_note("`~>` sends an effectful call; the target must be a call returning `Effect[()]`"),
3228                        );
3229                    }
3230                    None => {}
3231                }
3232            }
3233            Statement::Do(d) => {
3234                // v0.146 (ADR 0170): `do e` — perform a unit effect as a
3235                // statement. Effectful context only, like `<-`; nothing is bound,
3236                // so the operand MUST be `Effect[()]`. A valued reply is rejected
3237                // (`bynk.effect.do_requires_unit`): throwing away a real result
3238                // stays explicit with `let _ <- e`.
3239                if !ctx.effectful {
3240                    ctx.errors.push(
3241                        CompileError::new(
3242                            "bynk.effect.do_in_pure_context",
3243                            d.span,
3244                            "the `do` statement can only be used inside an effectful body (one returning `Effect[T]`)",
3245                        )
3246                        .with_label(
3247                            ctx.return_ty_span,
3248                            format!("enclosing return type is `{}`", ctx.return_ty.display(tys)),
3249                        )
3250                        .with_note(
3251                            "change the enclosing function/handler's return type to `Effect[...]`",
3252                        ),
3253                    );
3254                }
3255                let unit = tys.intern(Ty::Unit);
3256                let expected = tys.intern(Ty::Effect(unit));
3257                let rhs_ty = type_of(&d.value, Some(expected), ctx);
3258                match rhs_ty.map(|t| tys.get(t)).as_deref() {
3259                    Some(Ty::Effect(inner)) if *inner == unit => {}
3260                    Some(Ty::Effect(inner)) => {
3261                        ctx.errors.push(
3262                            CompileError::new(
3263                                "bynk.effect.do_requires_unit",
3264                                d.value.span,
3265                                format!(
3266                                    "a `do` statement requires an `Effect[()]`, but this is `Effect[{}]` — its result would be silently dropped",
3267                                    inner.display(tys)
3268                                ),
3269                            )
3270                            .with_note(
3271                                "`do e` performs a unit effect; to await and discard a real result, write `let _ <- e` instead",
3272                            ),
3273                        );
3274                    }
3275                    Some(other) => {
3276                        ctx.errors.push(
3277                            CompileError::new(
3278                                "bynk.effect.do_on_non_effect",
3279                                d.value.span,
3280                                format!(
3281                                    "a `do` statement requires an `Effect[()]` value, but got `{}`",
3282                                    other.display(tys)
3283                                ),
3284                            )
3285                            .with_note("`do` performs an effect; its operand must be a call returning `Effect[()]`"),
3286                        );
3287                    }
3288                    None => {}
3289                }
3290            }
3291            Statement::Assign(a) => {
3292                // v0.81 (storage track): `cell := expr` — the unconditional `Cell`
3293                // write. The target must be a `store Cell` field; the value must
3294                // match the cell's element type; and (the §10 read-modify-write
3295                // rule) the RHS must not read the cell being written.
3296                match ctx.store_fields.get(&a.target.name).cloned() {
3297                    // A name that isn't a store field at all, or is one of a
3298                    // different kind, is the same "not a Cell" diagnostic.
3299                    None
3300                    | Some(
3301                        StoreField::Map(..)
3302                        | StoreField::Set(_)
3303                        | StoreField::Cache(..)
3304                        | StoreField::Log(_),
3305                    ) => {
3306                        ctx.errors.push(
3307                            CompileError::new(
3308                                "bynk.cell.invalid_target",
3309                                a.target.span,
3310                                format!(
3311                                    "`:=` writes a `Cell` store field, but `{}` is not one",
3312                                    a.target.name
3313                                ),
3314                            )
3315                            .with_note(
3316                                "the `:=` write form applies only to a `store <name>: Cell[T]` field",
3317                            ),
3318                        );
3319                        type_of(&a.value, None, ctx);
3320                    }
3321                    Some(StoreField::Cell(elem_ty)) => {
3322                        // §10: a `:=` whose RHS reads its own LHS is a hidden
3323                        // read-modify-write — require `.update(fn)` instead, so the
3324                        // dependency is visible (and retry-safe).
3325                        if expr_reads_ident(&a.value, &a.target.name) {
3326                            ctx.errors.push(
3327                                CompileError::new(
3328                                    "bynk.cell.self_reference",
3329                                    a.span,
3330                                    format!(
3331                                        "the `:=` right-hand side reads `{0}`, the cell being \
3332                                         written — this is a read-modify-write",
3333                                        a.target.name
3334                                    ),
3335                                )
3336                                .with_note(
3337                                    "use `<cell>.update(fn)` for a read-modify-write so the \
3338                                     dependency on the prior value is explicit",
3339                                ),
3340                            );
3341                        }
3342                        if let Some(vt) = type_of(&a.value, Some(elem_ty), ctx)
3343                            && !compatible(vt, elem_ty, tys)
3344                        {
3345                            ctx.errors.push(CompileError::new(
3346                                "bynk.types.type_mismatch",
3347                                a.value.span,
3348                                format!(
3349                                    "this `:=` writes `{}`, but the cell `{}` holds `{}`",
3350                                    vt.display(tys),
3351                                    a.target.name,
3352                                    elem_ty.display(tys)
3353                                ),
3354                            ));
3355                        }
3356                    }
3357                }
3358            }
3359        }
3360    }
3361    let ty = type_of(&block.tail, expected, ctx);
3362    let ty = maybe_auto_lift(ty, expected, tys);
3363    // T3.4: this block previously wrote its own auto-lifted type into
3364    // `expr_types` at `block.span` (bug #844's era — recording it only when
3365    // `block.span != block.tail.span`, to avoid clobbering a synthetic
3366    // single-expression block's more specific tail entry). `Block` has no
3367    // `ExprId` of its own to key that write with now, and — checked, not
3368    // assumed — nothing in the workspace ever read it: the only caller that
3369    // has a real enclosing expression to attribute it to (`ExprKind::Block`,
3370    // `checker.rs`'s own `type_of` dispatch) already gets an identical entry
3371    // for free from `type_of`'s own choke-point write on the way back out,
3372    // since the parser sets that expression's span to `block.span` exactly.
3373    // The other eight callers (function/handler bodies, `if` branches,
3374    // `match` arm bodies) never had a real position to attribute it to
3375    // either, span-keyed or not. Dropped rather than worked around.
3376    ctx.pop_scope();
3377    ty
3378}
3379
3380/// v0.7.1 tail-position auto-lift. If the expected type is `Effect[T]` and
3381/// the computed type is `T` (not itself an `Effect[_]`), lift it to
3382/// `Effect[T]`. Otherwise leave the type alone — the surrounding compatibility
3383/// check will report any genuine mismatch.
3384fn maybe_auto_lift(ty: Option<TyId>, expected: Option<TyId>, tys: &Types) -> Option<TyId> {
3385    if let Some(actual) = ty
3386        && let Some(exp) = expected
3387        && let Ty::Effect(et) = &*tys.get(exp)
3388        && !actual.is_effect(tys)
3389        && compatible(actual, *et, tys)
3390    {
3391        return Some(tys.intern(Ty::Effect(actual)));
3392    }
3393    ty
3394}
3395
3396/// Whether a value of type `ty` may fill an interpolation hole (v0.43, ADR
3397/// 0075): a base scalar, or a refinement of one (which widens to its base for
3398/// display). Opaque types are excluded — their base is hidden, so a value must
3399/// be `.raw`-ed out first.
3400fn interpolable(ty: TyId, tys: &Types) -> bool {
3401    matches!(
3402        &*tys.get(ty),
3403        Ty::Base(_)
3404            | Ty::Named {
3405                kind: NamedKind::Refined(_),
3406                ..
3407            }
3408    )
3409}
3410
3411pub(crate) fn type_of(expr: &Expr, expected: Option<TyId>, ctx: &mut Ctx) -> Option<TyId> {
3412    let tys = ctx.tys;
3413    let ty = match &expr.kind {
3414        // v0.9.4: a literal in a refined-expected position takes the refined
3415        // type (validated now); otherwise it keeps its base type.
3416        // v0.20a: a lambda. With an expected function type, params type
3417        // contextually and the body checks against the expected return; in an
3418        // unconstrained position, every param must be annotated and
3419        // effectfulness is inferred bottom-up by a syntactic pre-scan.
3420        ExprKind::Lambda(lambda) => check_lambda(lambda, expected, ctx),
3421        ExprKind::IntLit { .. } => {
3422            admit_refined_literal(expr, expected, ctx).or(Some(tys.intern(Ty::Base(BaseType::Int))))
3423        }
3424        ExprKind::FloatLit { .. } => admit_refined_literal(expr, expected, ctx)
3425            .or(Some(tys.intern(Ty::Base(BaseType::Float)))),
3426        // v0.86 (ADR 0112): a `Duration` literal always takes the base
3427        // `Duration` (no refined `Duration` types exist).
3428        ExprKind::DurationLit { .. } => Some(tys.intern(Ty::Base(BaseType::Duration))),
3429        ExprKind::StrLit(_) => admit_refined_literal(expr, expected, ctx)
3430            .or(Some(tys.intern(Ty::Base(BaseType::String)))),
3431        // An interpolated string (v0.43, ADR 0075). Each hole must type to a
3432        // base scalar (String/Int/Float/Bool) or a *refinement* of one — those
3433        // have a well-defined display form (Int/Float via the ADR 0074
3434        // `toString` contract, Bool as `true`/`false`; a refined value widens
3435        // to its base, e.g. `Subject` displays as its `String`). Records,
3436        // sums, opaque types (whose base is deliberately hidden — `.raw` it
3437        // first), and other types are rejected, foreclosing JS's
3438        // `[object Object]` footgun. The result is always a `String`.
3439        ExprKind::InterpStr(parts) => {
3440            for part in parts {
3441                let InterpPart::Hole(hole) = part else {
3442                    continue;
3443                };
3444                match type_of(hole, None, ctx) {
3445                    Some(ty) if interpolable(ty, tys) => {}
3446                    Some(other) => ctx.errors.push(
3447                        CompileError::new(
3448                            "bynk.types.interpolation_non_scalar",
3449                            hole.span,
3450                            format!("type `{}` has no string form here", other.display(tys)),
3451                        )
3452                        .with_note(
3453                            "interpolation holes accept the base scalar types (String, Int, Float, Bool) or a refinement of one; map other values to a String first",
3454                        ),
3455                    ),
3456                    // The hole already produced its own error — don't pile on.
3457                    None => {}
3458                }
3459            }
3460            Some(tys.intern(Ty::Base(BaseType::String)))
3461        }
3462        ExprKind::BoolLit(_) => Some(tys.intern(Ty::Base(BaseType::Bool))),
3463        // v0.20b: a list literal. Elements check against the expected
3464        // element type when one is supplied (so refined literals admit,
3465        // v0.9.4); an empty `[]` has no inferable element type without one.
3466        ExprKind::ListLit(elems) => {
3467            let expected_elem = expected.and_then(|t| peel_to_list(t, tys));
3468            if elems.is_empty() {
3469                match expected_elem {
3470                    Some(t) => Some(tys.intern(Ty::List(t))),
3471                    None => {
3472                        ctx.errors.push(
3473                            CompileError::new(
3474                                "bynk.types.uninferable_element_type",
3475                                expr.span,
3476                                "an empty `[]` has no inferable element type",
3477                            )
3478                            .with_note(
3479                                "annotate the binding (`let xs: List[T] = []`) or use the empty list where a `List[T]` is expected",
3480                            ),
3481                        );
3482                        None
3483                    }
3484                }
3485            } else {
3486                let mut elem_ty: Option<TyId> = expected_elem;
3487                for e in elems {
3488                    let Some(t) = type_of(e, elem_ty, ctx) else {
3489                        continue;
3490                    };
3491                    match &elem_ty {
3492                        Some(et) => {
3493                            if !compatible(t, *et, tys) {
3494                                ctx.errors.push(CompileError::new(
3495                                    "bynk.types.list_element_mismatch",
3496                                    e.span,
3497                                    format!(
3498                                        "list element has type `{}`, but the list's element type is `{}`",
3499                                        t.display(tys),
3500                                        et.display(tys)
3501                                    ),
3502                                ));
3503                            }
3504                        }
3505                        None => elem_ty = Some(t),
3506                    }
3507                }
3508                elem_ty.map(|t| tys.intern(Ty::List(t)))
3509            }
3510        }
3511        ExprKind::Ident(id) => {
3512            // v0.94 (ADR 0120): a bare `store Map` ident used as a **value** — not
3513            // a method receiver, which the `MethodCall` arm dispatches — is a lazy
3514            // `Query[V]` over the whole map (e.g. the `other` side of a join). It
3515            // is not in the value scope, so it never shadows a local.
3516            if ctx.lookup(id.name.as_str()).is_none()
3517                && let Some(StoreField::Map(_, v)) = ctx.store_fields.get(&id.name).cloned()
3518            {
3519                Some(tys.intern(Ty::Query(v)))
3520            }
3521            // v0.9: a bare ident may name an HttpResult variant. Resolve to
3522            // HttpResult only when (a) the surrounding type implies it, or
3523            // (b) no user sum-type variant of the same name exists. This
3524            // keeps `NotFound` resolving to a user `StockError` variant
3525            // when the caller expects a domain Result.
3526            else if ctx.lookup(id.name.as_str()).is_none()
3527                && let Some(v) = http_variant(&id.name)
3528            {
3529                let user_owns = ctx.input.types.values().any(|t| {
3530                    matches!(&t.body, TypeBody::Sum(s)
3531                        if s.variants.iter().any(|var| var.name.name == id.name))
3532                });
3533                let http_implied = expected
3534                    .map(|t| peel_to_http_result(t, tys).is_some())
3535                    .unwrap_or(false)
3536                    || peel_to_http_result(ctx.return_ty, tys).is_some();
3537                if http_implied || !user_owns {
3538                    // P6.21/P6.23 (review of #1244/#1247): `Callee::Intrinsic`
3539                    // recorded here — Decision C's own known-excluded shape
3540                    // (`ir.rs`'s `GlobalRef` doc comment names it) now has the
3541                    // sink that comment said a future slice would need to add.
3542                    ctx.callees.insert(
3543                        expr.id,
3544                        Callee::Intrinsic {
3545                            ns: HTTP_RESULT,
3546                            op: v.name.to_string(),
3547                        },
3548                    );
3549                    check_http_variant(id.span, v, &[], expected, ctx)
3550                } else {
3551                    check_ident(id, expected, ctx)
3552                }
3553            } else if ctx.lookup(id.name.as_str()).is_none()
3554                && let Some(qv) = queue_variant(&id.name)
3555                && (expected.is_some_and(|t| peel_to_queue_result(t, tys))
3556                    || peel_to_queue_result(ctx.return_ty, tys))
3557            {
3558                // v0.44: a bare QueueResult variant (`Ack`) in a queue handler.
3559                ctx.callees.insert(
3560                    expr.id,
3561                    Callee::Intrinsic {
3562                        ns: QUEUE_RESULT,
3563                        op: qv.name.to_string(),
3564                    },
3565                );
3566                check_queue_variant(id.span, qv, &[], ctx)
3567            } else {
3568                check_ident(id, expected, ctx)
3569            }
3570        }
3571        ExprKind::Paren(inner) => type_of(inner, expected, ctx),
3572        ExprKind::Call {
3573            name,
3574            type_args,
3575            args,
3576        } => {
3577            // v0.9: HttpResult variant call. Prefer HttpResult when the
3578            // surrounding type implies it; otherwise defer to fn/user-variant
3579            // resolution and only fall back to HttpResult when nothing else
3580            // owns the name.
3581            //
3582            // `http_variant`/`queue_variant` are cheap keyword lookups; gate
3583            // the expensive context peel and — above all — the O(types×variants)
3584            // scan for user sum-variant owners behind them. The common case is
3585            // an ordinary function call whose name is neither keyword, so it
3586            // must not pay for either. The owner scan is further deferred behind
3587            // `http_implied`, since `unowned` only matters when the surrounding
3588            // type does not already imply HttpResult.
3589            if let Some(v) = http_variant(&name.name) {
3590                let http_implied = expected
3591                    .map(|t| peel_to_http_result(t, tys).is_some())
3592                    .unwrap_or(false)
3593                    || peel_to_http_result(ctx.return_ty, tys).is_some();
3594                let owned_elsewhere = || {
3595                    ctx.input.fns.contains_key(&name.name)
3596                        || ctx.input.types.values().any(|t| {
3597                            matches!(&t.body, TypeBody::Sum(s)
3598                                if s.variants.iter().any(|var| var.name.name == name.name))
3599                        })
3600                };
3601                if http_implied || !owned_elsewhere() {
3602                    ctx.callees.insert(
3603                        expr.id,
3604                        Callee::Intrinsic {
3605                            ns: HTTP_RESULT,
3606                            op: v.name.to_string(),
3607                        },
3608                    );
3609                    check_http_variant(expr.span, v, args, expected, ctx)
3610                } else {
3611                    // Falling straight to `check_call` (rather than the
3612                    // `queue_variant` else-if below) relies on the http and
3613                    // queue variant keyword sets being disjoint, so an http
3614                    // name could never have taken the queue branch anyway.
3615                    check_call(name, type_args, args, expr.span, expected, expr.id, ctx)
3616                }
3617            } else if let Some(qv) = queue_variant(&name.name)
3618                && (expected.is_some_and(|t| peel_to_queue_result(t, tys))
3619                    || peel_to_queue_result(ctx.return_ty, tys))
3620            {
3621                // v0.44: a QueueResult variant call (`Retry(reason)`).
3622                ctx.callees.insert(
3623                    expr.id,
3624                    Callee::Intrinsic {
3625                        ns: QUEUE_RESULT,
3626                        op: qv.name.to_string(),
3627                    },
3628                );
3629                check_queue_variant(expr.span, qv, args, ctx)
3630            } else {
3631                check_call(name, type_args, args, expr.span, expected, expr.id, ctx)
3632            }
3633        }
3634        ExprKind::UnaryOp(op, inner) => check_unary(*op, inner, expr.span, ctx),
3635        ExprKind::BinOp(op, lhs, rhs) => check_binop(*op, lhs, rhs, ctx),
3636        ExprKind::Block(b) => type_of_block(b, expected, ctx),
3637        ExprKind::If {
3638            cond,
3639            then_block,
3640            else_block,
3641        } => check_if(cond, then_block, else_block, expr.span, expected, ctx),
3642        ExprKind::Ok(inner) => check_ok(inner, expr.span, expected, ctx),
3643        ExprKind::Err(inner) => check_err(inner, expr.span, expected, ctx),
3644        ExprKind::Some(inner) => check_some(inner, expr.span, expected, ctx),
3645        ExprKind::None => check_none(expr.span, expected, ctx),
3646        ExprKind::Question(inner) => check_question(inner, expr.span, ctx),
3647        ExprKind::ConstructorCall {
3648            type_name,
3649            method,
3650            args,
3651        } => {
3652            if type_name.name == HTTP_RESULT {
3653                if let Some(v) = http_variant(&method.name) {
3654                    ctx.callees.insert(
3655                        expr.id,
3656                        Callee::Intrinsic {
3657                            ns: HTTP_RESULT,
3658                            op: v.name.to_string(),
3659                        },
3660                    );
3661                    check_http_variant(expr.span, v, args, expected, ctx)
3662                } else {
3663                    ctx.errors.push(CompileError::new(
3664                        "bynk.types.unknown_static_member",
3665                        method.span,
3666                        format!("`HttpResult` has no variant named `{}`", method.name),
3667                    ));
3668                    None
3669                }
3670            } else if type_name.name == QUEUE_RESULT {
3671                if let Some(qv) = queue_variant(&method.name) {
3672                    ctx.callees.insert(
3673                        expr.id,
3674                        Callee::Intrinsic {
3675                            ns: QUEUE_RESULT,
3676                            op: qv.name.to_string(),
3677                        },
3678                    );
3679                    check_queue_variant(expr.span, qv, args, ctx)
3680                } else {
3681                    ctx.errors.push(CompileError::new(
3682                        "bynk.types.unknown_static_member",
3683                        method.span,
3684                        format!("`QueueResult` has no variant named `{}`", method.name),
3685                    ));
3686                    None
3687                }
3688            } else {
3689                // `ConstructorCall` has no type-argument slot — qualified
3690                // variant construction (`Opt.Some(x)`), never a capability
3691                // call, so `type_args` is always empty here.
3692                check_static_call(
3693                    type_name,
3694                    method,
3695                    &[],
3696                    args,
3697                    expr.span,
3698                    expected,
3699                    expr.id,
3700                    ctx,
3701                )
3702            }
3703        }
3704        ExprKind::RecordConstruction { type_name, fields } => {
3705            check_record_construction(type_name, fields, expected, expr.span, ctx)
3706        }
3707        ExprKind::FieldAccess { receiver, field } => {
3708            // v0.9: `HttpResult.Variant` qualified nullary variant access.
3709            if let ExprKind::Ident(id) = &receiver.kind
3710                && ctx.lookup(id.name.as_str()).is_none()
3711                && id.name == HTTP_RESULT
3712            {
3713                if let Some(v) = http_variant(&field.name) {
3714                    if !matches!(v.payload, HttpVariantPayload::None) {
3715                        ctx.errors.push(CompileError::new(
3716                            "bynk.types.variant_missing_payload",
3717                            field.span,
3718                            format!(
3719                                "`HttpResult.{}` has a payload — call it with an argument",
3720                                v.name
3721                            ),
3722                        ));
3723                        return None;
3724                    }
3725                    ctx.callees.insert(
3726                        expr.id,
3727                        Callee::Intrinsic {
3728                            ns: HTTP_RESULT,
3729                            op: v.name.to_string(),
3730                        },
3731                    );
3732                    check_http_variant(field.span, v, &[], expected, ctx)
3733                } else {
3734                    ctx.errors.push(CompileError::new(
3735                        "bynk.types.unknown_static_member",
3736                        field.span,
3737                        format!("`HttpResult` has no variant named `{}`", field.name),
3738                    ));
3739                    None
3740                }
3741            } else {
3742                check_field_access(receiver, field, expected, ctx)
3743            }
3744        }
3745        ExprKind::MethodCall {
3746            receiver,
3747            method,
3748            type_args,
3749            args,
3750        } => {
3751            // `<field>.<op>(…)` on a `store` field — effectful storage
3752            // operations, dispatched by receiver provenance (a bare ident
3753            // naming a store field). Finding #36: one lookup into the unified
3754            // `store_fields` map, then dispatch by kind, instead of five
3755            // sequential per-kind lookups.
3756            //
3757            // Note: unlike the other store kinds, a `Cell` field is
3758            // deliberately bound into scope by `self_scope` (v0.81: "each
3759            // `Cell` store field is a bare local of its element type") so a
3760            // bare read derefs it — so `ctx.lookup` legitimately finds it and
3761            // no `is_none()` guard belongs there; a local sharing a cell's
3762            // name is a scope-construction question, not a dispatch-order
3763            // one. Every other kind requires `ctx.lookup(...).is_none()` so a
3764            // local that happens to share a store field's name is not
3765            // shadowed by the store dispatch.
3766            if let ExprKind::Ident(id) = &receiver.kind
3767                && let Some(field) = ctx.store_fields.get(&id.name).cloned()
3768                && (matches!(field, StoreField::Cell(_)) || ctx.lookup(id.name.as_str()).is_none())
3769            {
3770                match field {
3771                    // v0.82 (ADR 0110): `<map>.<op>(…)` on a `store Map[K, V]`
3772                    // field — effectful storage-map operations.
3773                    StoreField::Map(k, v) => {
3774                        // v0.91 (ADR 0115): a query builder/terminal lifts the
3775                        // store map into a lazy `Query[V]` over its values; an
3776                        // entry op (`put`/`get`/…) stays the effectful map
3777                        // operation.
3778                        if is_query_op(&method.name) {
3779                            // v0.107 (slice 4): record the receiver's lifted
3780                            // `Query[V]` type (otherwise unrecorded — the
3781                            // dispatch keys off the store field, not a typed
3782                            // receiver). This is the receiver's true type for
3783                            // any query op; its load-bearing use is the
3784                            // linearity pass, which now sees a held-bearing
3785                            // collection and lends the closure parameter of
3786                            // `forEach`/`parTraverse` as borrowed —
3787                            // otherwise `ty_of(receiver)` is `None` and the
3788                            // no-consume-in-a-broadcast rule is silently
3789                            // unenforced.
3790                            ctx.expr_types.insert(
3791                                receiver.id,
3792                                TypedExpr {
3793                                    span: receiver.span,
3794                                    ty: tys.intern(Ty::Query(v)),
3795                                },
3796                            );
3797                            let result = check_query_kernel_method(method, args, v, expr.span, ctx);
3798                            // P6.2 (#1143, R6.12): role is read back from
3799                            // this call's own resolved type where possible —
3800                            // `is_query_op` above only decides "lift or
3801                            // not", never "builder or terminal" (`query_role`
3802                            // itself, not this call site).
3803                            let role = query_role(result, &method.name, tys);
3804                            ctx.callees.insert(
3805                                expr.id,
3806                                Callee::Query {
3807                                    field: id.name.clone(),
3808                                    op: method.name.clone(),
3809                                    role,
3810                                },
3811                            );
3812                            result
3813                        } else {
3814                            ctx.callees.insert(
3815                                expr.id,
3816                                Callee::Store {
3817                                    field: id.name.clone(),
3818                                    op: method.name.clone(),
3819                                },
3820                            );
3821                            check_store_map_op(method, args, k, v, expr.span, ctx)
3822                        }
3823                    }
3824                    // v0.83: `<set>.<op>(…)` on a `store Set[T]` field —
3825                    // effectful storage-set ops.
3826                    StoreField::Set(t) => {
3827                        ctx.callees.insert(
3828                            expr.id,
3829                            Callee::Store {
3830                                field: id.name.clone(),
3831                                op: method.name.clone(),
3832                            },
3833                        );
3834                        check_store_set_op(method, args, t, expr.span, ctx)
3835                    }
3836                    // v0.87 (ADR 0113): `<cache>.<op>(…)` on a `store
3837                    // Cache[K, V]` field — the storage-map ops plus a `given
3838                    // Clock` requirement (eviction).
3839                    StoreField::Cache(k, v, _ttl) => {
3840                        ctx.callees.insert(
3841                            expr.id,
3842                            Callee::Store {
3843                                field: id.name.clone(),
3844                                op: method.name.clone(),
3845                            },
3846                        );
3847                        check_store_cache_op(method, args, k, v, expr.span, ctx)
3848                    }
3849                    // v0.95 (ADR 0121): `<log>.<op>(…)` on a `store Log[T]`
3850                    // field — `append` is the effectful non-idempotent write
3851                    // (`given Clock`); the time-window roots and general
3852                    // builders lift the log into a lazy `Query[T]` over its
3853                    // entry values. Unlike `Map`, the store-vs-query split
3854                    // lives *inside* `check_store_log_op` itself (the
3855                    // window-root vocabulary `since`/`before`/`between`/
3856                    // `recent`/`reversed` plus its own `is_query_op`
3857                    // fallthrough, `calls.rs:1879-1913`) — mirrored here by
3858                    // name so `Callee::Query` is recorded only for the same
3859                    // vocabulary `check_store_log_op` itself treats as a
3860                    // query op, not for every non-`append` name (an unknown
3861                    // op — `check_store_log_op`'s own `other =>` arm reports
3862                    // `bynk.store.unknown_op` — gets neither `Callee`, since
3863                    // dispatch's own conclusion is that it is not a valid
3864                    // call at all).
3865                    StoreField::Log(t) => match method.name.as_str() {
3866                        "append" => {
3867                            ctx.callees.insert(
3868                                expr.id,
3869                                Callee::Store {
3870                                    field: id.name.clone(),
3871                                    op: method.name.clone(),
3872                                },
3873                            );
3874                            check_store_log_op(method, args, t, expr.span, ctx)
3875                        }
3876                        name if matches!(
3877                            name,
3878                            "since" | "before" | "between" | "recent" | "reversed"
3879                        ) || is_query_op(name) =>
3880                        {
3881                            let result = check_store_log_op(method, args, t, expr.span, ctx);
3882                            let role = query_role(result, &method.name, tys);
3883                            ctx.callees.insert(
3884                                expr.id,
3885                                Callee::Query {
3886                                    field: id.name.clone(),
3887                                    op: method.name.clone(),
3888                                    role,
3889                                },
3890                            );
3891                            result
3892                        }
3893                        _ => check_store_log_op(method, args, t, expr.span, ctx),
3894                    },
3895                    // v0.98 (ADR 0125): `<cell>.update(f)` on a `store
3896                    // Cell[T]` field — the one method-shaped cell op (read is
3897                    // the bare name, write is `:=`).
3898                    StoreField::Cell(t) => {
3899                        ctx.callees.insert(
3900                            expr.id,
3901                            Callee::Store {
3902                                field: id.name.clone(),
3903                                op: method.name.clone(),
3904                            },
3905                        );
3906                        check_store_cell_op(method, args, t, expr.span, ctx)
3907                    }
3908                }
3909            }
3910            // v0.9: `HttpResult.Variant(args)` — explicit HttpResult construction.
3911            else if let ExprKind::Ident(id) = &receiver.kind
3912                && ctx.lookup(id.name.as_str()).is_none()
3913                && id.name == HTTP_RESULT
3914            {
3915                if let Some(v) = http_variant(&method.name) {
3916                    ctx.callees.insert(
3917                        expr.id,
3918                        Callee::Intrinsic {
3919                            ns: HTTP_RESULT,
3920                            op: v.name.to_string(),
3921                        },
3922                    );
3923                    check_http_variant(expr.span, v, args, expected, ctx)
3924                } else {
3925                    ctx.errors.push(CompileError::new(
3926                        "bynk.types.unknown_static_member",
3927                        method.span,
3928                        format!("`HttpResult` has no variant named `{}`", method.name),
3929                    ));
3930                    None
3931                }
3932            } else {
3933                check_method_call(
3934                    receiver, method, type_args, args, expr.span, expected, expr.id, ctx,
3935                )
3936            }
3937        }
3938        ExprKind::Match { discriminant, arms } => {
3939            check_match(discriminant, arms, expr.span, expected, ctx)
3940        }
3941        ExprKind::Is { value, pattern } => check_is(value, pattern, expr.span, ctx),
3942        ExprKind::UnitLit => Some(tys.intern(Ty::Unit)),
3943        ExprKind::EffectPure(inner) => {
3944            let expected_inner = match expected.map(|e| tys.get(e)).as_deref() {
3945                Some(Ty::Effect(t)) => Some(*t),
3946                _ => None,
3947            };
3948            let inner_ty = type_of(inner, expected_inner, ctx)?;
3949            Some(tys.intern(Ty::Effect(inner_ty)))
3950        }
3951        ExprKind::RecordSpread {
3952            type_name,
3953            base,
3954            overrides,
3955        } => check_record_spread(
3956            type_name.as_ref(),
3957            base,
3958            overrides,
3959            expr.span,
3960            expected,
3961            ctx,
3962        ),
3963        ExprKind::Expect(inner) => check_expect(inner, expr.span, ctx),
3964        ExprKind::Val { type_ref, args } => check_val(type_ref, args, expr.span, ctx),
3965        ExprKind::Observation(o) => check_observation(o, expr.span, ctx),
3966        ExprKind::Trace { cap, op } => check_trace(cap, op, expr.span, ctx),
3967        // Slice C: a `Wire(<String>)` reached through the ordinary expression
3968        // checker is *misplaced* — a valid `Wire` is intercepted by the service-
3969        // address argument checker (`check_address_args`), which validates the
3970        // inner and the `system` tier. Anywhere else it is an error. The inner is
3971        // still typed so a mistake inside it is reported too.
3972        ExprKind::Wire(inner) => {
3973            let _ = type_of(inner, Some(tys.intern(Ty::Base(BaseType::String))), ctx);
3974            ctx.errors.push(
3975                CompileError::new(
3976                    "bynk.test.wire_needs_system",
3977                    expr.span,
3978                    "`Wire(...)` may only be passed as an argument to a service address in a `system`-tier case",
3979                )
3980                .with_note(
3981                    "`Wire` hands raw, pre-validation input to the boundary; there is no wire to be raw about at `unit`, and it is meaningless outside a service address",
3982                ),
3983            );
3984            None
3985        }
3986    };
3987    // T3.3b (R4.3, R2.5, R4.9): `expr_types` is total for every expression
3988    // `type_of` is called on — a `None` result (whether from a diagnosed
3989    // failure or a deliberate, undiagnosed non-type such as an untyped
3990    // test-body binding) records `Ty::Error` rather than leaving the span
3991    // unrecorded. This changes only what gets *written*; every caller of
3992    // `type_of` still sees its actual `Option<TyId>` return value and every
3993    // existing `?`/`.or(...)` control-flow site is unaffected — `Ty::Error`
3994    // only becomes observable to an external reader of `expr_types` (the
3995    // emitter, the LSP), never to internal checker logic.
3996    ctx.expr_types.insert(
3997        expr.id,
3998        TypedExpr {
3999            span: expr.span,
4000            ty: ty.unwrap_or_else(|| tys.intern(Ty::Error)),
4001        },
4002    );
4003    ty
4004}
4005
4006// ==== Peel helpers (unwrap Effect / Result / Option / List / Map) ====
4007
4008/// Peel one optional `Effect[_]` wrapper to expose an underlying `HttpResult[T]`.
4009pub(crate) fn peel_to_http_result(ty: TyId, tys: &Types) -> Option<TyId> {
4010    match &*tys.get(ty) {
4011        Ty::HttpResult(inner) => Some(*inner),
4012        Ty::Effect(inner) => peel_to_http_result(*inner, tys),
4013        _ => None,
4014    }
4015}
4016
4017/// v0.44: peel an optional `Effect[_]` to detect an underlying `QueueResult`.
4018fn peel_to_queue_result(ty: TyId, tys: &Types) -> bool {
4019    match &*tys.get(ty) {
4020        Ty::QueueResult => true,
4021        Ty::Effect(inner) => peel_to_queue_result(*inner, tys),
4022        _ => false,
4023    }
4024}
4025
4026fn surrounding_result(
4027    expected: Option<TyId>,
4028    return_ty: TyId,
4029    tys: &Types,
4030) -> Option<(TyId, TyId)> {
4031    if let Some(t) = expected
4032        && let Some(pair) = peel_to_result(t, tys)
4033    {
4034        return Some(pair);
4035    }
4036    peel_to_result(return_ty, tys)
4037}
4038
4039/// Peel one optional `Effect[_]` wrapper to expose an underlying `Result[T, E]`.
4040/// Used by `Ok` / `Err` checking in v0.7.1 so that bare constructors in
4041/// `Effect[Result[T, E]]` tail positions can pick up the surrounding type's
4042/// parameters via the auto-lift propagation.
4043fn peel_to_result(ty: TyId, tys: &Types) -> Option<(TyId, TyId)> {
4044    match &*tys.get(ty) {
4045        Ty::Result(t, e) => Some((*t, *e)),
4046        Ty::Effect(inner) => peel_to_result(*inner, tys),
4047        _ => None,
4048    }
4049}
4050
4051/// Companion to `peel_to_result` for `Option[T]`.
4052fn peel_to_option(ty: TyId, tys: &Types) -> Option<TyId> {
4053    match &*tys.get(ty) {
4054        Ty::Option(t) => Some(*t),
4055        Ty::Effect(inner) => peel_to_option(*inner, tys),
4056        _ => None,
4057    }
4058}
4059
4060/// Companion to `peel_to_result` for `List[T]` (v0.20b) — the expected
4061/// element type of a list literal, looking through `Effect[_]` so tail
4062/// auto-lift positions still propagate it.
4063fn peel_to_list(ty: TyId, tys: &Types) -> Option<TyId> {
4064    match &*tys.get(ty) {
4065        Ty::List(t) => Some(*t),
4066        Ty::Effect(inner) => peel_to_list(*inner, tys),
4067        _ => None,
4068    }
4069}
4070
4071/// Companion to `peel_to_list` for `Map[K, V]` (v0.20b).
4072fn peel_to_map(ty: TyId, tys: &Types) -> Option<(TyId, TyId)> {
4073    match &*tys.get(ty) {
4074        Ty::Map(k, v) => Some((*k, *v)),
4075        Ty::Effect(inner) => peel_to_map(*inner, tys),
4076        _ => None,
4077    }
4078}
4079
4080// ==== Structural compatibility and variant introspection ====
4081
4082/// A flattened view of a type's variants (name + payload types).
4083///
4084/// `pub` since P6.4 (design/tracks/the-ir.md §6, #1157, Decision A):
4085/// `bynk-emit::ir::lower`'s pattern-lowering needs the exact same uniform
4086/// view this function already gives the checker — a user sum, `Result`,
4087/// `Option`, `ActorSum` and `HttpResult` all flattened into one `name` +
4088/// `payload` shape, with no `Arc<TypeDecl>` required (`Callee::Ctor`'s own
4089/// identity scheme never fires for `Ok`/`Err`/`Some`/`None`, ADR 0333's
4090/// `#1145` Decision B). No behaviour change — a reachability change only,
4091/// the same shape ADR 0333 already gave `Callee`.
4092pub struct VariantInfo {
4093    pub name: String,
4094    pub payload: Vec<(String, TyId)>,
4095}
4096
4097/// Project a return type produced in the consumed context's namespace into
4098/// the caller's namespace by re-resolving named types that exist on both
4099/// sides. The structural shape stays the same; the brand changes.
4100fn rebrand_return_type(
4101    t: TyId,
4102    caller_types: &HashMap<String, Arc<TypeDecl>>,
4103    tys: &Types,
4104) -> TyId {
4105    let node = tys.get(t);
4106    match &*node {
4107        Ty::Named { name, kind, args } => {
4108            // If the caller's namespace has the same name, prefer the caller's
4109            // view (it carries the caller's brand at emission time). Otherwise
4110            // keep the consumed-context name; the caller can hold it opaquely.
4111            // Applied type arguments (a generic record) are preserved either
4112            // way — though a generic record is non-boundary, so this path only
4113            // ever sees the empty-args non-generic case in practice.
4114            if let Some(decl) = caller_types.get(name) {
4115                named_ty_with_args(decl, args.clone(), tys)
4116            } else {
4117                tys.intern(Ty::Named {
4118                    name: name.clone(),
4119                    kind: kind.clone(),
4120                    args: args.clone(),
4121                })
4122            }
4123        }
4124        Ty::Result(t, e) => tys.intern(Ty::Result(
4125            rebrand_return_type(*t, caller_types, tys),
4126            rebrand_return_type(*e, caller_types, tys),
4127        )),
4128        Ty::Option(t) => tys.intern(Ty::Option(rebrand_return_type(*t, caller_types, tys))),
4129        Ty::Effect(t) => tys.intern(Ty::Effect(rebrand_return_type(*t, caller_types, tys))),
4130        Ty::HttpResult(t) => tys.intern(Ty::HttpResult(rebrand_return_type(*t, caller_types, tys))),
4131        Ty::List(t) => tys.intern(Ty::List(rebrand_return_type(*t, caller_types, tys))),
4132        Ty::Query(t) => tys.intern(Ty::Query(rebrand_return_type(*t, caller_types, tys))),
4133        Ty::Stream(t) => tys.intern(Ty::Stream(rebrand_return_type(*t, caller_types, tys))),
4134        Ty::Connection(t) => tys.intern(Ty::Connection(rebrand_return_type(*t, caller_types, tys))),
4135        Ty::Map(k, v) => tys.intern(Ty::Map(
4136            rebrand_return_type(*k, caller_types, tys),
4137            rebrand_return_type(*v, caller_types, tys),
4138        )),
4139        // R4.3: `Ty::Error` carries no name to rebrand — pass it through.
4140        Ty::Error
4141        | Ty::Base(_)
4142        | Ty::QueueResult
4143        | Ty::ValidationError
4144        | Ty::JsonError
4145        | Ty::Unit
4146        | Ty::Actor(_)
4147        | Ty::ActorSum(_) => t,
4148        // v0.20a: function types are confined to non-boundary positions
4149        // (`bynk.types.function_at_boundary`), so a cross-context return can
4150        // never carry one; Vars never escape call checking.
4151        Ty::Fn { .. } | Ty::Var(_) => t,
4152    }
4153}
4154
4155/// Structural compatibility check for values crossing a context boundary
4156/// (v0.6 §4.3). The two types may be expressed in different namespaces
4157/// (caller-side / callee-side type tables), so we walk them in parallel
4158/// against their respective tables.
4159fn structurally_compatible(
4160    arg: TyId,
4161    param: TyId,
4162    arg_types: &HashMap<String, Arc<TypeDecl>>,
4163    param_types: &HashMap<String, Arc<TypeDecl>>,
4164    tys: &Types,
4165) -> bool {
4166    structurally_compatible_inner(arg, param, arg_types, param_types, tys, &mut HashSet::new())
4167}
4168
4169fn structurally_compatible_inner(
4170    arg: TyId,
4171    param: TyId,
4172    arg_types: &HashMap<String, Arc<TypeDecl>>,
4173    param_types: &HashMap<String, Arc<TypeDecl>>,
4174    tys: &Types,
4175    visited: &mut HashSet<(String, String)>,
4176) -> bool {
4177    let (arg_node, param_node) = (tys.get(arg), tys.get(param));
4178    match (&*arg_node, &*param_node) {
4179        // R4.3: as in `compatible` — an already-diagnosed side is compatible
4180        // with anything, so a cross-context signature check doesn't report
4181        // the same failure a second time as a signature mismatch.
4182        (Ty::Error, _) | (_, Ty::Error) => true,
4183        (Ty::Base(a), Ty::Base(b)) => a == b,
4184        (Ty::ValidationError, Ty::ValidationError) => true,
4185        (Ty::JsonError, Ty::JsonError) => true,
4186        (Ty::Unit, Ty::Unit) => true,
4187        (Ty::Result(t1, e1), Ty::Result(t2, e2)) => {
4188            structurally_compatible_inner(*t1, *t2, arg_types, param_types, tys, visited)
4189                && structurally_compatible_inner(*e1, *e2, arg_types, param_types, tys, visited)
4190        }
4191        (Ty::Option(a), Ty::Option(b)) => {
4192            structurally_compatible_inner(*a, *b, arg_types, param_types, tys, visited)
4193        }
4194        (Ty::Effect(a), Ty::Effect(b)) => {
4195            structurally_compatible_inner(*a, *b, arg_types, param_types, tys, visited)
4196        }
4197        (Ty::HttpResult(a), Ty::HttpResult(b)) => {
4198            structurally_compatible_inner(*a, *b, arg_types, param_types, tys, visited)
4199        }
4200        // The boundary-crossing collections walk their element types like
4201        // `Result`/`Option` — without these arms an identical `List[Int]`
4202        // was rejected against itself at a context boundary.
4203        (Ty::List(a), Ty::List(b)) => {
4204            structurally_compatible_inner(*a, *b, arg_types, param_types, tys, visited)
4205        }
4206        (Ty::Map(k1, v1), Ty::Map(k2, v2)) => {
4207            structurally_compatible_inner(*k1, *k2, arg_types, param_types, tys, visited)
4208                && structurally_compatible_inner(*v1, *v2, arg_types, param_types, tys, visited)
4209        }
4210        (Ty::QueueResult, Ty::QueueResult) => true,
4211        (
4212            Ty::Named {
4213                name: an, args: aa, ..
4214            },
4215            Ty::Named {
4216                name: bn, args: ba, ..
4217            },
4218        ) => {
4219            // v0.157 (ADR 0183): applied type arguments must match structurally
4220            // too — `Paginated[String]` and `Paginated[Int]` are not the same
4221            // brand (latent while generic records are boundary-rejected).
4222            if aa.len() != ba.len()
4223                || !aa.iter().zip(ba).all(|(x, y)| {
4224                    structurally_compatible_inner(*x, *y, arg_types, param_types, tys, visited)
4225                })
4226            {
4227                return false;
4228            }
4229            // Cycle break: once we've started comparing (an, bn) we trust
4230            // the recursive case to succeed.
4231            let key = (an.clone(), bn.clone());
4232            if !visited.insert(key.clone()) {
4233                return true;
4234            }
4235            let ok = structural_compare_named(an, bn, arg_types, param_types, tys, visited);
4236            visited.remove(&key);
4237            ok
4238        }
4239        // Refined-named widens to its base; tolerate one-sided widening only
4240        // when comparing within the same nominal name (handled above) or when
4241        // the param accepts a plain base.
4242        (
4243            Ty::Named {
4244                kind: NamedKind::Refined(b),
4245                ..
4246            },
4247            Ty::Base(target),
4248        ) => b == target,
4249        // Everything else cannot cross a context boundary: cross-variant
4250        // pairs, and the non-boundary types (`Effect` payloads are unwrapped
4251        // before this check; `Query`/`Stream`/`Connection`/`Fn`/`Var` and the
4252        // sealed actor bindings never cross). The left side is enumerated —
4253        // no `_` — so adding a `Ty` variant fails to compile here instead of
4254        // silently rejecting the new type against itself (the trap the
4255        // collections fell into).
4256        (
4257            Ty::Base(_)
4258            | Ty::Named { .. }
4259            | Ty::Result(..)
4260            | Ty::Option(_)
4261            | Ty::Effect(_)
4262            | Ty::HttpResult(_)
4263            | Ty::QueueResult
4264            | Ty::List(_)
4265            | Ty::Map(..)
4266            | Ty::Query(_)
4267            | Ty::Stream(_)
4268            | Ty::Connection(_)
4269            | Ty::ValidationError
4270            | Ty::JsonError
4271            | Ty::Unit
4272            | Ty::Actor(_)
4273            | Ty::ActorSum(_)
4274            | Ty::Fn { .. }
4275            | Ty::Var(_),
4276            _,
4277        ) => false,
4278    }
4279}
4280
4281fn structural_compare_named(
4282    arg_name: &str,
4283    param_name: &str,
4284    arg_types: &HashMap<String, Arc<TypeDecl>>,
4285    param_types: &HashMap<String, Arc<TypeDecl>>,
4286    tys: &Types,
4287    visited: &mut HashSet<(String, String)>,
4288) -> bool {
4289    // The "same nominal name" case is the most common: both sides derive
4290    // the same commons type. Compare their structural shapes.
4291    let Some(arg_decl) = arg_types.get(arg_name) else {
4292        return false;
4293    };
4294    let Some(param_decl) = param_types.get(param_name) else {
4295        return false;
4296    };
4297    match (&arg_decl.body, &param_decl.body) {
4298        (
4299            TypeBody::Refined {
4300                base: ab,
4301                refinement: ar,
4302                ..
4303            },
4304            TypeBody::Refined {
4305                base: bb,
4306                refinement: br,
4307                ..
4308            },
4309        ) => {
4310            if ab != bb {
4311                return false;
4312            }
4313            refinements_match(ar.as_ref(), br.as_ref())
4314        }
4315        (
4316            TypeBody::Opaque {
4317                base: ab,
4318                refinement: ar,
4319                ..
4320            },
4321            TypeBody::Opaque {
4322                base: bb,
4323                refinement: br,
4324                ..
4325            },
4326        ) => {
4327            // Opaque types must share a name to be compatible (a context's
4328            // opaque cannot be reinterpreted as a different context's opaque).
4329            if arg_name != param_name {
4330                return false;
4331            }
4332            if ab != bb {
4333                return false;
4334            }
4335            refinements_match(ar.as_ref(), br.as_ref())
4336        }
4337        (TypeBody::Record(a), TypeBody::Record(b)) => {
4338            if a.fields.len() != b.fields.len() {
4339                return false;
4340            }
4341            for af in &a.fields {
4342                let Some(bf) = b.fields.iter().find(|f| f.name.name == af.name.name) else {
4343                    return false;
4344                };
4345                let at = resolve_type_ref(&af.type_ref, arg_types, tys);
4346                let bt = resolve_type_ref(&bf.type_ref, param_types, tys);
4347                let (Some(at), Some(bt)) = (at, bt) else {
4348                    return false;
4349                };
4350                if !structurally_compatible_inner(at, bt, arg_types, param_types, tys, visited) {
4351                    return false;
4352                }
4353            }
4354            true
4355        }
4356        (TypeBody::Sum(a), TypeBody::Sum(b)) => {
4357            if a.variants.len() != b.variants.len() {
4358                return false;
4359            }
4360            for av in &a.variants {
4361                let Some(bv) = b.variants.iter().find(|v| v.name.name == av.name.name) else {
4362                    return false;
4363                };
4364                if av.payload.len() != bv.payload.len() {
4365                    return false;
4366                }
4367                for (af, bf) in av.payload.iter().zip(bv.payload.iter()) {
4368                    if af.name.name != bf.name.name {
4369                        return false;
4370                    }
4371                    let at = resolve_type_ref(&af.type_ref, arg_types, tys);
4372                    let bt = resolve_type_ref(&bf.type_ref, param_types, tys);
4373                    let (Some(at), Some(bt)) = (at, bt) else {
4374                        return false;
4375                    };
4376                    if !structurally_compatible_inner(at, bt, arg_types, param_types, tys, visited)
4377                    {
4378                        return false;
4379                    }
4380                }
4381            }
4382            true
4383        }
4384        _ => false,
4385    }
4386}
4387
4388/// v0.177 (#643): two refinements match when their **canonical forms** are
4389/// equal — a *set* comparison, not a positional one.
4390///
4391/// This retires the v0.6 §4.3 foot-gun the status doc named: predicates were
4392/// compared by `zip`, so `String where NonEmpty, MaxLen(10)` and
4393/// `String where MaxLen(10), NonEmpty` — the same type — spuriously failed to
4394/// match. Predicates are conjunctive and side-effect-free, so their order
4395/// carries no meaning and comparing it was always accidental.
4396///
4397/// The comparison routes through `contract::canon_refinement`, the same function
4398/// that feeds the cross-context contract hash, and deliberately so: if the
4399/// matcher and the hash disagreed about what "the same refinement" is, a
4400/// contract could type-check at compile time and 409 at runtime — the worst
4401/// failure available to this increment. One normal form, two consumers.
4402///
4403/// The asymmetry is unchanged: a *more* restrictive sending side is admitted
4404/// into a more permissive receiving one, but not the reverse.
4405///
4406/// One behavioural consequence of sharing the form: it de-duplicates, so
4407/// `where NonEmpty, NonEmpty` now matches `where NonEmpty`. That is correct — a
4408/// conjunction is idempotent, so they are the same type — and it must hold on
4409/// the hash side regardless, or two contexts spelling the same type differently
4410/// would fail closed against each other.
4411fn refinements_match(a: Option<&Refinement>, b: Option<&Refinement>) -> bool {
4412    match (a, b) {
4413        (None, None) => true,
4414        (Some(_), None) => true, // sending side is more restrictive — receiving is more permissive
4415        (None, Some(_)) => false,
4416        (Some(a), Some(b)) => {
4417            crate::contract::canon_refinement(Some(a)) == crate::contract::canon_refinement(Some(b))
4418        }
4419    }
4420}
4421
4422/// `pub` since P6.4 (#1157, Decision A) — see [`VariantInfo`]'s own doc
4423/// comment for why `bynk-emit` needs this exact function rather than a
4424/// re-derived copy (R5.11, for the IR side).
4425pub fn variants_of(
4426    ty: TyId,
4427    types: &HashMap<String, Arc<TypeDecl>>,
4428    tys: &Types,
4429) -> Option<Vec<VariantInfo>> {
4430    match &*tys.get(ty) {
4431        Ty::Named {
4432            kind: NamedKind::Sum,
4433            name,
4434            args,
4435        } => {
4436            let decl = types.get(name)?;
4437            if let TypeBody::Sum(s) = &decl.body {
4438                Some(
4439                    s.variants
4440                        .iter()
4441                        .map(|v| VariantInfo {
4442                            name: v.name.name.clone(),
4443                            payload: v
4444                                .payload
4445                                .iter()
4446                                .map(|f| {
4447                                    // #593: for a generic sum, substitute the
4448                                    // instantiation's arguments into each payload
4449                                    // type (`Some(v: T)` over `Opt[Int]` ⇒ `Int`),
4450                                    // exactly as a generic record's fields are read
4451                                    // at an instantiation. `instantiate_field_ty`
4452                                    // degrades to a plain resolve for a non-generic
4453                                    // sum (empty `args`).
4454                                    let t =
4455                                        instantiate_field_ty(decl, args, &f.type_ref, types, tys)
4456                                            .unwrap_or_else(|| tys.intern(Ty::Base(BaseType::Int)));
4457                                    (f.name.name.clone(), t)
4458                                })
4459                                .collect(),
4460                        })
4461                        .collect(),
4462                )
4463            } else {
4464                None
4465            }
4466        }
4467        Ty::Result(t, e) => Some(vec![
4468            VariantInfo {
4469                name: "Ok".to_string(),
4470                payload: vec![("value".to_string(), *t)],
4471            },
4472            VariantInfo {
4473                name: "Err".to_string(),
4474                payload: vec![("error".to_string(), *e)],
4475            },
4476        ]),
4477        Ty::Option(t) => Some(vec![
4478            VariantInfo {
4479                name: "Some".to_string(),
4480                payload: vec![("value".to_string(), *t)],
4481            },
4482            VariantInfo {
4483                name: "None".to_string(),
4484                payload: vec![],
4485            },
4486        ]),
4487        // v0.52: a multi-actor sum matches on the resolved actor. Each member's
4488        // variant is named by the actor and binds that actor's identity
4489        // *directly* (`User(u)` ⇒ `u : UserId` — the arm already names the
4490        // actor, so no `.identity` indirection). A unit-identity member
4491        // (`Visitor`, `Webhook`) binds nothing.
4492        Ty::ActorSum(members) => Some(
4493            members
4494                .iter()
4495                .map(|(name, id)| VariantInfo {
4496                    name: name.clone(),
4497                    payload: match &*tys.get(*id) {
4498                        Ty::Unit => vec![],
4499                        _ => vec![("identity".to_string(), *id)],
4500                    },
4501                })
4502                .collect(),
4503        ),
4504        Ty::HttpResult(t) => Some(
4505            HTTP_VARIANTS
4506                .iter()
4507                .map(|v| VariantInfo {
4508                    name: v.name.to_string(),
4509                    payload: match v.payload {
4510                        HttpVariantPayload::None => vec![],
4511                        HttpVariantPayload::Value => vec![("value".to_string(), *t)],
4512                        HttpVariantPayload::Message => {
4513                            vec![(
4514                                "message".to_string(),
4515                                tys.intern(Ty::Base(BaseType::String)),
4516                            )]
4517                        }
4518                        HttpVariantPayload::Location => {
4519                            vec![(
4520                                "location".to_string(),
4521                                tys.intern(Ty::Base(BaseType::String)),
4522                            )]
4523                        }
4524                        HttpVariantPayload::Streamed => {
4525                            let elem = tys.intern(Ty::Base(BaseType::String));
4526                            vec![("stream".to_string(), tys.intern(Ty::Stream(elem)))]
4527                        }
4528                        // v0.111: the first two-field payload. Field names are
4529                        // kept byte-identical to the runtime union in
4530                        // `bynk-emit/runtime/src/http.ts`. An `HttpResult` is
4531                        // construct-only in handler position (never scrutinised),
4532                        // so this binding exists for exhaustiveness, not a path.
4533                        HttpVariantPayload::Raw => vec![
4534                            ("body".to_string(), tys.intern(Ty::Base(BaseType::Bytes))),
4535                            (
4536                                "contentType".to_string(),
4537                                tys.intern(Ty::Base(BaseType::String)),
4538                            ),
4539                        ],
4540                    },
4541                })
4542                .collect(),
4543        ),
4544        _ => None,
4545    }
4546}
4547
4548// ── v0.9.2: agent state-field zeroability ──────────────────────────────────
4549//
4550// Fresh agent state is the zero-value record (finding #10): a never-seen key
4551// reads `0` / `false` / `""` / `None` rather than `undefined`. A type is
4552// *zeroable* when it has a defined zero; agent state fields must be zeroable,
4553// since a fresh key has no committed value to load. Non-zeroable fields (a
4554// non-Option sum, an opaque type, or a refined type whose refinement excludes
4555// the underlying zero) are a compile error until explicit-initialiser syntax
4556// lands.
4557
4558#[cfg(test)]
4559mod generics_tests {
4560    use super::*;
4561
4562    fn var(tys: &Types, n: &str) -> TyId {
4563        tys.intern(Ty::Var(n.to_string()))
4564    }
4565    fn int(tys: &Types) -> TyId {
4566        tys.intern(Ty::Base(BaseType::Int))
4567    }
4568    fn string(tys: &Types) -> TyId {
4569        tys.intern(Ty::Base(BaseType::String))
4570    }
4571
4572    #[test]
4573    fn unify_binds_and_holds() {
4574        let tys = &Types::new();
4575        let mut s = HashMap::new();
4576        assert!(unify(var(tys, "A"), int(tys), &mut s, tys));
4577        assert_eq!(s.get("A"), Some(&int(tys)));
4578        // Same binding again: fine. A different one: conflict.
4579        assert!(unify(var(tys, "A"), int(tys), &mut s, tys));
4580        assert!(!unify(var(tys, "A"), string(tys), &mut s, tys));
4581    }
4582
4583    #[test]
4584    fn unify_walks_structure() {
4585        let tys = &Types::new();
4586        let mut s = HashMap::new();
4587        let pattern = tys.intern(Ty::Fn {
4588            params: vec![var(tys, "A")],
4589            ret: tys.intern(Ty::Effect(var(tys, "B"))),
4590        });
4591        let actual = tys.intern(Ty::Fn {
4592            params: vec![int(tys)],
4593            ret: tys.intern(Ty::Effect(string(tys))),
4594        });
4595        assert!(unify(pattern, actual, &mut s, tys));
4596        assert_eq!(s.get("A"), Some(&int(tys)));
4597        assert_eq!(s.get("B"), Some(&string(tys)));
4598    }
4599
4600    #[test]
4601    fn substitute_grounds_fully() {
4602        let tys = &Types::new();
4603        let mut s = HashMap::new();
4604        s.insert("A".to_string(), int(tys));
4605        let inner = tys.intern(Ty::Fn {
4606            params: vec![var(tys, "A")],
4607            ret: var(tys, "A"),
4608        });
4609        let t = tys.intern(Ty::Option(inner));
4610        let g = substitute(t, &s, tys);
4611        assert!(!contains_var(g, tys));
4612    }
4613
4614    /// The §2 invariant (pinned per the plan): every expected-driven feature
4615    /// in `type_of` matches *concrete* `Ty` variants, so a Var-bearing
4616    /// expected imposes no constraint — `compatible` must simply reject
4617    /// Var-vs-ground pairs rather than panic or accept.
4618    #[test]
4619    fn var_bearing_expected_is_benign() {
4620        let tys = &Types::new();
4621        assert!(!compatible(int(tys), var(tys, "A"), tys));
4622        assert!(!compatible(var(tys, "A"), int(tys), tys));
4623        // Rigid vars: name equality only.
4624        assert!(compatible(var(tys, "A"), var(tys, "A"), tys));
4625        assert!(!compatible(var(tys, "A"), var(tys, "B"), tys));
4626    }
4627}
4628
4629/// T3.6b (R4.1/R4.2): the interner's dedup property, and the `Hash`/`Eq`/`Ord`
4630/// it rests on.
4631///
4632/// T3.6a shipped the derives; these tests (added while scoping T3.6b, see the
4633/// identity-and-totality track doc §9) pinned the property `intern` would
4634/// depend on before it existed. Now that it does, they pin it directly: two
4635/// types built through *different construction paths* but structurally
4636/// identical must intern to the **same** `TyId` (or the checker's `TyId`
4637/// equality — which `unify` and `Ty::Map`'s key comparison now rely on —
4638/// would be unsound), and structurally different types must never collide.
4639///
4640/// Dedup being by the *shallow* `Ty` is exactly what makes this work: every
4641/// recursive field is already a `TyId`, so equal children imply equal parents
4642/// by induction. The nested cases below are what test that induction.
4643#[cfg(test)]
4644mod ty_hash_eq_ord_tests {
4645    use super::*;
4646    use std::collections::HashSet;
4647    use std::collections::hash_map::DefaultHasher;
4648    use std::hash::{Hash, Hasher};
4649
4650    fn hash_of(ty: &Ty) -> u64 {
4651        let mut h = DefaultHasher::new();
4652        ty.hash(&mut h);
4653        h.finish()
4654    }
4655
4656    /// `map_entry_ty` (a real constructor) vs. the raw `Ty::Named` literal it
4657    /// builds — two different construction paths for the same type.
4658    #[test]
4659    fn map_entry_ty_matches_its_own_raw_literal() {
4660        let tys = &Types::new();
4661        let int = tys.intern(Ty::Base(BaseType::Int));
4662        let string = tys.intern(Ty::Base(BaseType::String));
4663        let via_constructor = map_entry_ty(int, string, tys);
4664        let via_literal = tys.intern(Ty::Named {
4665            name: MAP_ENTRY.to_string(),
4666            kind: NamedKind::Record,
4667            args: vec![int, string],
4668        });
4669        assert_eq!(via_constructor, via_literal);
4670        assert_eq!(
4671            hash_of(&tys.get(via_constructor)),
4672            hash_of(&tys.get(via_literal))
4673        );
4674    }
4675
4676    /// A deeply nested type built two separate times interns to one `TyId` —
4677    /// the property `unify`'s "matches its prior binding exactly" now rests on.
4678    #[test]
4679    fn structurally_identical_nested_types_intern_to_one_id() {
4680        let tys = &Types::new();
4681        let build = || {
4682            let int = tys.intern(Ty::Base(BaseType::Int));
4683            let opt = tys.intern(Ty::Option(int));
4684            let list = tys.intern(Ty::List(opt));
4685            let string = tys.intern(Ty::Base(BaseType::String));
4686            tys.intern(Ty::Map(string, list))
4687        };
4688        let a = build();
4689        let before = tys.len();
4690        let b = build();
4691        assert_eq!(a, b);
4692        // Re-building it added nothing: every node was already interned.
4693        assert_eq!(tys.len(), before);
4694        assert_eq!(hash_of(&tys.get(a)), hash_of(&tys.get(b)));
4695    }
4696
4697    /// Structurally different types must get distinct ids — the flip side of
4698    /// the dedup property above.
4699    #[test]
4700    fn structurally_different_types_get_distinct_ids() {
4701        let tys = &Types::new();
4702        let int = tys.intern(Ty::Base(BaseType::Int));
4703        let string = tys.intern(Ty::Base(BaseType::String));
4704        let ids = HashSet::from([
4705            tys.intern(Ty::List(int)),
4706            tys.intern(Ty::List(string)),
4707            tys.intern(Ty::Option(int)),
4708        ]);
4709        assert_eq!(ids.len(), 3);
4710    }
4711
4712    /// Interning the same type repeatedly grows the table exactly once.
4713    #[test]
4714    fn repeated_interning_grows_the_table_once() {
4715        let tys = &Types::new();
4716        let int = tys.intern(Ty::Base(BaseType::Int));
4717        let bool_ = tys.intern(Ty::Base(BaseType::Bool));
4718        let before = tys.len();
4719        let ids: HashSet<TyId> = (0..3).map(|_| map_entry_ty(int, bool_, tys)).collect();
4720        assert_eq!(ids.len(), 1);
4721        assert_eq!(tys.len(), before + 1);
4722    }
4723
4724    /// `resolve`-round-trip: an id resolves to the node it was minted from.
4725    #[test]
4726    fn an_id_resolves_to_the_node_it_was_interned_from() {
4727        let tys = &Types::new();
4728        let int = tys.intern(Ty::Base(BaseType::Int));
4729        assert_eq!(&*tys.get(int), &Ty::Base(BaseType::Int));
4730        let list = tys.intern(Ty::List(int));
4731        assert_eq!(&*tys.get(list), &Ty::List(int));
4732    }
4733
4734    /// A `TyId` is only meaningful in the table it was minted from — the one
4735    /// new failure mode interning introduces. It fails loudly and by name;
4736    /// pinned so the diagnosis stays cheap for the next reader who wires two
4737    /// tables together by accident.
4738    ///
4739    /// This is the *shorter*-table shape, which both migration bugs had and
4740    /// which a bounds check alone catches. Its sibling below is the shape
4741    /// that actually needs the tag.
4742    #[test]
4743    #[should_panic(expected = "resolved against a table it was not interned into")]
4744    fn an_id_from_another_table_is_a_named_panic_not_a_silent_wrong_answer() {
4745        let a = Types::new();
4746        let b = Types::new();
4747        let id = a.intern(Ty::Base(BaseType::Int));
4748        let _ = b.get(id);
4749    }
4750
4751    /// The sharp edge of the same guard: a foreign id whose index is merely
4752    /// *in range*. Before `TyId` carried its table's tag this returned an
4753    /// unrelated `Ty` — here, `Bool` for an id minted from `Int` — and the
4754    /// caller went on to mis-diagnose or mis-emit with no panic at all. The
4755    /// bounds check cannot see this one, so it is the case worth pinning.
4756    ///
4757    /// Debug-only, because that is where the tag exists; a release build
4758    /// still has the length check the sibling above covers.
4759    #[cfg(debug_assertions)]
4760    #[test]
4761    #[should_panic(expected = "resolved against a table it was not interned into")]
4762    fn an_in_range_id_from_another_table_panics_rather_than_resolving_wrongly() {
4763        let a = Types::new();
4764        let b = Types::new();
4765        let id = a.intern(Ty::Base(BaseType::Int));
4766        b.intern(Ty::Base(BaseType::Bool));
4767        assert_eq!(a.len(), b.len(), "the index must be in range for b");
4768        let _ = b.get(id);
4769    }
4770
4771    /// T3.6b's own soundness guard: `compatible` is deliberately **not**
4772    /// reflexive for the sealed boundary values, so interning must not be
4773    /// short-circuited on id equality. Pinned here because the fast path is
4774    /// the obvious "optimisation" a later reader would add.
4775    #[test]
4776    fn compatible_is_not_reflexive_for_sealed_actor_types() {
4777        let tys = &Types::new();
4778        let id_ty = tys.intern(Ty::Base(BaseType::String));
4779        let actor = tys.intern(Ty::Actor(id_ty));
4780        assert!(!compatible(actor, actor, tys));
4781        let sum = tys.intern(Ty::ActorSum(vec![("User".to_string(), id_ty)]));
4782        assert!(!compatible(sum, sum, tys));
4783        // …while an ordinary type still is.
4784        assert!(compatible(id_ty, id_ty, tys));
4785    }
4786}
4787
4788/// Characterization pins for `checker.rs`'s pure free functions (v0.29.10
4789/// slice 0). These pin *current* behaviour ahead of the upcoming module split
4790/// so the verbatim moves are verifiable. Any surprising behaviour is pinned
4791/// as-is, flagged with a comment — these are not specifications.
4792#[cfg(test)]
4793mod pure_helper_pins {
4794    use super::*;
4795    use bynk_syntax::ast::{FloatBound, RefinementPred};
4796
4797    // -- small constructors ------------------------------------------------
4798
4799    fn sp() -> Span {
4800        Span::new(0, 0)
4801    }
4802    fn ident(n: &str) -> Ident {
4803        Ident {
4804            name: n.to_string(),
4805            span: sp(),
4806        }
4807    }
4808    fn var(tys: &Types, n: &str) -> TyId {
4809        tys.intern(Ty::Var(n.to_string()))
4810    }
4811    fn int(tys: &Types) -> TyId {
4812        tys.intern(Ty::Base(BaseType::Int))
4813    }
4814    fn string(tys: &Types) -> TyId {
4815        tys.intern(Ty::Base(BaseType::String))
4816    }
4817    fn expr(kind: ExprKind) -> Expr {
4818        Expr {
4819            id: ExprId::SYNTHETIC,
4820            kind,
4821            span: sp(),
4822        }
4823    }
4824    fn pred(kind: PredKind) -> RefinementPred {
4825        RefinementPred { kind, span: sp() }
4826    }
4827    fn refinement(preds: Vec<PredKind>) -> Refinement {
4828        Refinement {
4829            predicates: preds.into_iter().map(pred).collect(),
4830            span: sp(),
4831        }
4832    }
4833    fn fbound(value: f64) -> FloatBound {
4834        FloatBound {
4835            value,
4836            lexeme: value.to_string(),
4837            span: Span::new(0, 0),
4838        }
4839    }
4840    fn ibound(value: i64) -> IntBound {
4841        IntBound {
4842            value,
4843            span: Span::new(0, 0),
4844        }
4845    }
4846    /// An `InRange` predicate from two int values (test convenience).
4847    fn in_range(lo: i64, hi: i64) -> PredKind {
4848        PredKind::InRange(ibound(lo), ibound(hi))
4849    }
4850    fn refined_decl(name: &str, base: BaseType, refinement: Option<Refinement>) -> TypeDecl {
4851        TypeDecl {
4852            name: ident(name),
4853            type_params: Vec::new(),
4854            body: TypeBody::Refined {
4855                base,
4856                base_span: sp(),
4857                refinement,
4858            },
4859            documentation: None,
4860            span: sp(),
4861            trivia: bynk_syntax::ast::Trivia::default(),
4862        }
4863    }
4864    fn record_decl(name: &str) -> TypeDecl {
4865        TypeDecl {
4866            name: ident(name),
4867            type_params: Vec::new(),
4868            body: TypeBody::Record(bynk_syntax::ast::RecordBody {
4869                fields: vec![],
4870                span: sp(),
4871            }),
4872            documentation: None,
4873            span: sp(),
4874            trivia: bynk_syntax::ast::Trivia::default(),
4875        }
4876    }
4877
4878    // -- unify -------------------------------------------------------------
4879
4880    #[test]
4881    fn unify_identical_concrete_types() {
4882        let tys = &Types::new();
4883        let mut s = HashMap::new();
4884        assert!(unify(int(tys), int(tys), &mut s, tys));
4885        assert!(s.is_empty());
4886    }
4887
4888    #[test]
4889    fn unify_var_binds_in_subst() {
4890        let tys = &Types::new();
4891        let mut s = HashMap::new();
4892        assert!(unify(var(tys, "A"), string(tys), &mut s, tys));
4893        assert_eq!(s.get("A"), Some(&string(tys)));
4894    }
4895
4896    #[test]
4897    fn unify_nested_generic_binds() {
4898        let tys = &Types::new();
4899        // List[A] vs List[Int] binds A := Int.
4900        let mut s = HashMap::new();
4901        let pat = tys.intern(Ty::List(var(tys, "A")));
4902        let act = tys.intern(Ty::List(int(tys)));
4903        assert!(unify(pat, act, &mut s, tys));
4904        assert_eq!(s.get("A"), Some(&int(tys)));
4905    }
4906
4907    #[test]
4908    fn unify_surprise_concrete_mismatch_returns_true() {
4909        let tys = &Types::new();
4910        // SURPRISING (pinned as-is): `unify`'s catch-all is `_ => true`, so a
4911        // ground-vs-ground mismatch (Int vs String) and a constructor mismatch
4912        // (List vs Option) both *succeed* here — `compatible` owns those
4913        // diagnostics post-substitution, not `unify`.
4914        let mut s = HashMap::new();
4915        assert!(unify(int(tys), string(tys), &mut s, tys));
4916        assert!(unify(
4917            tys.intern(Ty::List(int(tys))),
4918            tys.intern(Ty::Option(int(tys))),
4919            &mut s,
4920            tys
4921        ));
4922        // The only false paths: a Var rebind conflict and an Fn arity mismatch.
4923        let mut s2 = HashMap::new();
4924        assert!(unify(var(tys, "A"), int(tys), &mut s2, tys));
4925        assert!(!unify(var(tys, "A"), string(tys), &mut s2, tys));
4926        let mut s3 = HashMap::new();
4927        let f1 = tys.intern(Ty::Fn {
4928            params: vec![int(tys)],
4929            ret: int(tys),
4930        });
4931        let f2 = tys.intern(Ty::Fn {
4932            params: vec![int(tys), int(tys)],
4933            ret: int(tys),
4934        });
4935        assert!(!unify(f1, f2, &mut s3, tys));
4936    }
4937
4938    // -- substitute --------------------------------------------------------
4939
4940    #[test]
4941    fn substitute_replaces_bound_var() {
4942        let tys = &Types::new();
4943        let mut s = HashMap::new();
4944        s.insert("A".to_string(), int(tys));
4945        assert_eq!(substitute(var(tys, "A"), &s, tys), int(tys));
4946    }
4947
4948    #[test]
4949    fn substitute_recurses_into_nested() {
4950        let tys = &Types::new();
4951        let mut s = HashMap::new();
4952        s.insert("A".to_string(), string(tys));
4953        let t = tys.intern(Ty::Map(var(tys, "A"), int(tys)));
4954        assert_eq!(
4955            substitute(t, &s, tys),
4956            tys.intern(Ty::Map(string(tys), int(tys))),
4957        );
4958    }
4959
4960    #[test]
4961    fn substitute_leaves_unbound_var_alone() {
4962        let tys = &Types::new();
4963        let s = HashMap::new();
4964        assert_eq!(substitute(var(tys, "Z"), &s, tys), var(tys, "Z"));
4965    }
4966
4967    // -- contains_var / contains_flexible_var ------------------------------
4968
4969    #[test]
4970    fn contains_var_positive_and_negative() {
4971        let tys = &Types::new();
4972        assert!(contains_var(tys.intern(Ty::Option(var(tys, "A"))), tys));
4973        assert!(!contains_var(tys.intern(Ty::Option(int(tys))), tys));
4974        assert!(!contains_var(int(tys), tys));
4975    }
4976
4977    #[test]
4978    fn contains_flexible_var_respects_rigid_set() {
4979        let tys = &Types::new();
4980        let mut rigid = HashSet::new();
4981        rigid.insert("A".to_string());
4982        // A is rigid → not flexible.
4983        assert!(!contains_flexible_var(var(tys, "A"), &rigid, tys));
4984        // B is not rigid → flexible.
4985        assert!(contains_flexible_var(var(tys, "B"), &rigid, tys));
4986        // No vars at all → not flexible.
4987        assert!(!contains_flexible_var(int(tys), &rigid, tys));
4988    }
4989
4990    // -- peel_to_* ---------------------------------------------------------
4991
4992    #[test]
4993    fn peel_to_result_matches_and_misses() {
4994        let tys = &Types::new();
4995        let r = tys.intern(Ty::Result(int(tys), string(tys)));
4996        assert_eq!(peel_to_result(r, tys), Some((int(tys), string(tys))));
4997        assert_eq!(peel_to_result(int(tys), tys), None);
4998        // Pinned: peels through Effect[_].
4999        assert_eq!(
5000            peel_to_result(tys.intern(Ty::Effect(r)), tys),
5001            Some((int(tys), string(tys)))
5002        );
5003    }
5004
5005    #[test]
5006    fn peel_to_option_matches_and_misses() {
5007        let tys = &Types::new();
5008        assert_eq!(
5009            peel_to_option(tys.intern(Ty::Option(int(tys))), tys),
5010            Some(int(tys))
5011        );
5012        assert_eq!(peel_to_option(int(tys), tys), None);
5013    }
5014
5015    #[test]
5016    fn peel_to_list_matches_and_misses() {
5017        let tys = &Types::new();
5018        assert_eq!(
5019            peel_to_list(tys.intern(Ty::List(string(tys))), tys),
5020            Some(string(tys))
5021        );
5022        assert_eq!(peel_to_list(int(tys), tys), None);
5023    }
5024
5025    #[test]
5026    fn peel_to_map_matches_and_misses() {
5027        let tys = &Types::new();
5028        let m = tys.intern(Ty::Map(string(tys), int(tys)));
5029        assert_eq!(peel_to_map(m, tys), Some((string(tys), int(tys))));
5030        assert_eq!(peel_to_map(int(tys), tys), None);
5031    }
5032
5033    #[test]
5034    fn peel_to_http_result_matches_and_misses() {
5035        let tys = &Types::new();
5036        assert_eq!(
5037            peel_to_http_result(tys.intern(Ty::HttpResult(int(tys))), tys),
5038            Some(int(tys)),
5039        );
5040        assert_eq!(peel_to_http_result(int(tys), tys), None);
5041    }
5042
5043    // -- maybe_auto_lift ---------------------------------------------------
5044
5045    #[test]
5046    fn maybe_auto_lift_lifts_into_expected_effect() {
5047        let tys = &Types::new();
5048        // T lifts to Effect[T] when expected is Effect[T] and T is not effectful.
5049        let expected = tys.intern(Ty::Effect(int(tys)));
5050        let lifted = maybe_auto_lift(Some(int(tys)), Some(expected), tys);
5051        assert_eq!(lifted, Some(tys.intern(Ty::Effect(int(tys)))));
5052    }
5053
5054    #[test]
5055    fn maybe_auto_lift_leaves_non_matching_alone() {
5056        let tys = &Types::new();
5057        // Already Effect[_]: untouched.
5058        let expected = tys.intern(Ty::Effect(int(tys)));
5059        assert_eq!(
5060            maybe_auto_lift(Some(tys.intern(Ty::Effect(int(tys)))), Some(expected), tys),
5061            Some(tys.intern(Ty::Effect(int(tys)))),
5062        );
5063        // Expected not an Effect: untouched.
5064        assert_eq!(
5065            maybe_auto_lift(Some(int(tys)), Some(int(tys)), tys),
5066            Some(int(tys))
5067        );
5068        // None type: untouched.
5069        assert_eq!(maybe_auto_lift(None, Some(expected), tys), None);
5070    }
5071
5072    // -- const_literal -----------------------------------------------------
5073
5074    #[test]
5075    fn const_literal_extracts_literals() {
5076        assert!(matches!(
5077            const_literal(&expr(ExprKind::int_lit(7))),
5078            Some(ConstLit::Int(7)),
5079        ));
5080        assert!(matches!(
5081            const_literal(&expr(ExprKind::BoolLit(true))),
5082            Some(ConstLit::Bool(true)),
5083        ));
5084        assert!(matches!(
5085            const_literal(&expr(ExprKind::StrLit("hi".into()))),
5086            Some(ConstLit::Str(s)) if s == "hi",
5087        ));
5088        assert!(matches!(
5089            const_literal(&expr(ExprKind::FloatLit {
5090                value: 1.5,
5091                lexeme: "1.5".into(),
5092            })),
5093            Some(ConstLit::Float(_)),
5094        ));
5095        // Unary-neg on an int literal folds.
5096        let neg = expr(ExprKind::UnaryOp(
5097            UnaryOp::Neg,
5098            Box::new(expr(ExprKind::int_lit(3))),
5099        ));
5100        assert!(matches!(const_literal(&neg), Some(ConstLit::Int(-3))));
5101    }
5102
5103    #[test]
5104    fn const_literal_rejects_non_literals() {
5105        assert!(const_literal(&expr(ExprKind::Ident(ident("x")))).is_none());
5106    }
5107
5108    // -- eval_predicate ----------------------------------------------------
5109
5110    #[test]
5111    fn eval_predicate_int_and_float() {
5112        assert!(eval_predicate(&PredKind::NonNegative, &ConstLit::Int(0)));
5113        assert!(!eval_predicate(&PredKind::NonNegative, &ConstLit::Int(-1)));
5114        assert!(eval_predicate(&PredKind::Positive, &ConstLit::Int(1)));
5115        assert!(!eval_predicate(&PredKind::Positive, &ConstLit::Int(0)));
5116        assert!(eval_predicate(&in_range(1, 10), &ConstLit::Int(5),));
5117        assert!(!eval_predicate(&in_range(1, 10), &ConstLit::Int(11),));
5118    }
5119
5120    #[test]
5121    fn eval_predicate_string() {
5122        assert!(eval_predicate(
5123            &PredKind::MinLength(2),
5124            &ConstLit::Str("ab".into()),
5125        ));
5126        assert!(!eval_predicate(
5127            &PredKind::MinLength(3),
5128            &ConstLit::Str("ab".into()),
5129        ));
5130        assert!(eval_predicate(
5131            &PredKind::NonEmpty,
5132            &ConstLit::Str("x".into()),
5133        ));
5134        assert!(!eval_predicate(
5135            &PredKind::NonEmpty,
5136            &ConstLit::Str(String::new()),
5137        ));
5138        assert!(eval_predicate(
5139            &PredKind::Matches("[a-z]+".into()),
5140            &ConstLit::Str("abc".into()),
5141        ));
5142        assert!(!eval_predicate(
5143            &PredKind::Matches("[a-z]+".into()),
5144            &ConstLit::Str("ABC".into()),
5145        ));
5146    }
5147
5148    #[test]
5149    fn eval_predicate_base_mismatch_is_vacuously_true() {
5150        // SURPRISING (pinned as-is): a predicate/literal base mismatch returns
5151        // `true` — base/predicate mismatch is a declaration-time error reported
5152        // elsewhere, not by construction-time eval.
5153        assert!(eval_predicate(&PredKind::MinLength(5), &ConstLit::Int(0),));
5154    }
5155
5156    // -- literal_matches_base ----------------------------------------------
5157
5158    #[test]
5159    fn literal_matches_base_pairs() {
5160        assert!(literal_matches_base(&ConstLit::Int(1), BaseType::Int));
5161        assert!(literal_matches_base(
5162            &ConstLit::Str("x".into()),
5163            BaseType::String,
5164        ));
5165        assert!(!literal_matches_base(&ConstLit::Int(1), BaseType::String));
5166        assert!(!literal_matches_base(&ConstLit::Unit, BaseType::Int));
5167    }
5168
5169    // -- type_decl_base / type_decl_refinement -----------------------------
5170
5171    #[test]
5172    fn type_decl_base_refined_vs_record() {
5173        let refined = refined_decl("Age", BaseType::Int, None);
5174        assert_eq!(type_decl_base(&refined), Some(BaseType::Int));
5175        assert_eq!(type_decl_base(&record_decl("Pt")), None);
5176    }
5177
5178    #[test]
5179    fn type_decl_refinement_present_vs_absent() {
5180        let with = refined_decl(
5181            "Age",
5182            BaseType::Int,
5183            Some(refinement(vec![PredKind::Positive])),
5184        );
5185        assert!(type_decl_refinement(&with).is_some());
5186        let without = refined_decl("Raw", BaseType::Int, None);
5187        assert!(type_decl_refinement(&without).is_none());
5188        assert!(type_decl_refinement(&record_decl("Pt")).is_none());
5189    }
5190
5191    // -- check_*_refinement_consistency ------------------------------------
5192
5193    #[test]
5194    fn int_refinement_consistency() {
5195        // Consistent: 1..=10 with Positive — no error.
5196        let mut errs = vec![];
5197        check_int_refinement_consistency(
5198            &refinement(vec![PredKind::Positive, in_range(1, 10)]),
5199            &mut errs,
5200        );
5201        assert!(errs.is_empty());
5202        // Inconsistent: InRange(10, 1) is empty → exactly one error.
5203        let mut errs = vec![];
5204        check_int_refinement_consistency(&refinement(vec![in_range(10, 1)]), &mut errs);
5205        assert_eq!(errs.len(), 1);
5206        assert_eq!(errs[0].category, "bynk.types.empty_refinement");
5207    }
5208
5209    #[test]
5210    fn float_refinement_consistency() {
5211        // Consistent range.
5212        let mut errs = vec![];
5213        check_float_refinement_consistency(
5214            &refinement(vec![PredKind::InRangeF(fbound(0.0), fbound(1.0))]),
5215            &mut errs,
5216        );
5217        assert!(errs.is_empty());
5218        // Empty: 5.0..=1.0 → one error.
5219        let mut errs = vec![];
5220        check_float_refinement_consistency(
5221            &refinement(vec![PredKind::InRangeF(fbound(5.0), fbound(1.0))]),
5222            &mut errs,
5223        );
5224        assert_eq!(errs.len(), 1);
5225        assert_eq!(errs[0].category, "bynk.types.empty_refinement");
5226        // Degenerate-but-exclusive: Positive with InRangeF(0.0, 0.0) → lo==hi
5227        // and lo_exclusive → one error.
5228        let mut errs = vec![];
5229        check_float_refinement_consistency(
5230            &refinement(vec![
5231                PredKind::Positive,
5232                PredKind::InRangeF(fbound(0.0), fbound(0.0)),
5233            ]),
5234            &mut errs,
5235        );
5236        assert_eq!(errs.len(), 1);
5237    }
5238
5239    #[test]
5240    fn string_refinement_consistency() {
5241        // Consistent: MinLength(1), MaxLength(10).
5242        let mut errs = vec![];
5243        check_string_refinement_consistency(
5244            &refinement(vec![PredKind::MinLength(1), PredKind::MaxLength(10)]),
5245            &mut errs,
5246        );
5247        assert!(errs.is_empty());
5248        // min > max → one error.
5249        let mut errs = vec![];
5250        check_string_refinement_consistency(
5251            &refinement(vec![PredKind::MinLength(10), PredKind::MaxLength(2)]),
5252            &mut errs,
5253        );
5254        assert_eq!(errs.len(), 1);
5255        assert_eq!(errs[0].category, "bynk.types.empty_refinement");
5256        // Conflicting exact lengths → TWO errors (pinned as-is): the explicit
5257        // `Length(3)`/`Length(5)` conflict push, *plus* the subsequent
5258        // min_len(5) > max_len(3) empty-range push (each `Length` clamps both
5259        // bounds to itself).
5260        let mut errs = vec![];
5261        check_string_refinement_consistency(
5262            &refinement(vec![PredKind::Length(3), PredKind::Length(5)]),
5263            &mut errs,
5264        );
5265        assert_eq!(errs.len(), 2);
5266        assert!(
5267            errs.iter()
5268                .all(|e| e.category == "bynk.types.empty_refinement")
5269        );
5270    }
5271
5272    /// R12.2/T1.8: `NonEmpty` folds to `MinLength(1)` in `contract::canon_predicate`,
5273    /// so `refinements_match` — which routes through that same canonical form —
5274    /// must now treat the two spellings as the same refinement. Before the fold
5275    /// this was `false`.
5276    #[test]
5277    fn refinements_match_treats_non_empty_and_min_length_one_as_equal() {
5278        let a = refinement(vec![PredKind::NonEmpty]);
5279        let b = refinement(vec![PredKind::MinLength(1)]);
5280        assert!(refinements_match(Some(&a), Some(&b)));
5281        // …and the fold is exactly `MinLength(1)`, not a subsumption rule: a
5282        // strictly tighter refinement must still fail to match. Without this,
5283        // widening the fold into "`MinLength(2)` implies `MinLength(1)`, so
5284        // admit it" would leave every assertion in T1.8 passing while a
5285        // genuinely tighter callee refinement is accepted across a boundary.
5286        let c = refinement(vec![PredKind::MinLength(2)]);
5287        assert!(!refinements_match(Some(&a), Some(&c)));
5288        assert!(!refinements_match(Some(&c), Some(&a)));
5289    }
5290
5291    // -- numeric_mix -------------------------------------------------------
5292
5293    #[test]
5294    fn numeric_mix_int_float_pairs() {
5295        assert!(numeric_mix(Some(BaseType::Int), Some(BaseType::Float)));
5296        assert!(numeric_mix(Some(BaseType::Float), Some(BaseType::Int)));
5297        assert!(!numeric_mix(Some(BaseType::Int), Some(BaseType::Int)));
5298        assert!(!numeric_mix(Some(BaseType::Float), Some(BaseType::Float)));
5299        assert!(!numeric_mix(None, Some(BaseType::Int)));
5300    }
5301}