Skip to main content

bynk_check/
wire.rs

1//! #855: the wire-contract IR — the shape a Bynk type takes crossing a
2//! context boundary, derived once from the AST + type table so both the
3//! emitter's codec generation and the editor's "wire contract" peek render
4//! the *same* derivation instead of two hand-synchronised ones.
5//!
6//! **The seam:** *`bynk-check` says what the boundary is. `bynk-emit` says
7//! how that reads as TypeScript.*
8//!
9//! Crossing this module boundary is legal for `BaseType`, `PredKind`,
10//! `TypeRef`, `Expr`, `TypeDecl` (all `bynk-syntax`) — the vocabulary a
11//! boundary type is built from. It is **not** legal for any TS-token string,
12//! `pred_condition_and_message` (`bynk-emit/src/emitter.rs`, stays put),
13//! `lower_field_default_wire` (`bynk-emit/src/emitter/serialisation.rs`,
14//! stays put — a default's *wire literal* is a rendering, not a boundary
15//! fact), or the `Qual` type-name→TS-namespace-prefix map (`bynk-emit`'s
16//! `serialisation.rs`; see the [`Provenance`] doc for why it cannot move
17//! here). `bynk-emit` renders this IR into TypeScript; it does not re-derive
18//! it.
19//!
20//! **Derived from AST + type table, not `checker::Ty`.** `Ty::Named { name,
21//! kind, args }` carries no refinement predicates, so a `Ty`-based
22//! derivation would still need this same type-table lookup for `PredKind`s —
23//! `Ty` would buy only generic substitution, which the moved walks below
24//! already implement directly over `TypeRef`. `contract.rs` (this crate)
25//! also derives straight from the AST + type table and must not depend on
26//! checker output being available; this module keeps the same shape of
27//! dependency.
28//!
29//! **This IR is *not* `contract.rs`'s canonical form**, and the two must
30//! never be unified — they disagree on purpose, on every axis that matters:
31//!
32//! | | `contract.rs` (hash) | `wire.rs` (this module) |
33//! |---|---|---|
34//! | predicates | sorted, deduped | **declaration order**, not deduped |
35//! | record fields | sorted by name | declaration order (emitted key order) |
36//! | sum variants | sorted by name | declaration order (`switch` arm order) |
37//! | opaque predicate | **elided** — unobservable to the consumer | **present** — the owner still re-validates |
38//!
39//! `contract.rs:248` documents the sorting as a *precondition* for hash
40//! correctness — hashing predicates in source order "would make two contexts
41//! that agree perfectly fail closed against each other." The hash is a
42//! **type identity** (order-insensitive by design); this module's shapes are
43//! an **emission order** (order-sensitive by necessity, since a JS `switch`
44//! and an inlined `if` chain both have a literal source order a reader can
45//! see). Merging them would create the exact spurious-409 failure the hash
46//! exists to prevent. What the two genuinely share — and what a cross-check
47//! test elsewhere asserts — is *boundary-type reachability*, not shape.
48
49use std::collections::{BTreeSet, HashMap};
50use std::sync::Arc;
51
52use bynk_syntax::ast::*;
53
54/// I4 (#1970): the declaration shapes this module's public API is
55/// parameterised by ([`collect_codec_closure`] walks `TypeRef` roots over a
56/// `TypeDecl` table, whose `body` is a `TypeBody`), re-exported so a renderer
57/// over that API (`bynk-emit`'s TypeScript client) names them from here — the
58/// P6.27 / P6.49 precedent `project_model`'s `TypeDecl` re-export follows.
59pub use bynk_syntax::ast::{TypeBody, TypeRef};
60
61// ---------------------------------------------------------------------
62// Core vocabulary
63// ---------------------------------------------------------------------
64
65/// The JSON value shape a [`BaseType`] occupies on the wire. Replaces
66/// `bynk-emit`'s `ts_base_for_serialisation` *classification* — the TS-token
67/// spelling of each kind (`"number"`, `"string"`, …) stays in `bynk-emit`,
68/// since a TS token is exactly what this seam excludes.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum JsonKind {
71    Number,
72    String,
73    Boolean,
74    Object,
75    Array,
76    Null,
77}
78
79/// The JSON kind a base type wires as. Same mapping as the `ts_base_for_serialisation`
80/// it replaces: `Int`/`Float`/`Duration`/`Instant` → `Number` (all four erase to a TS
81/// `number`), `String`/`Bytes` → `String` (a `Bytes` wires as a base64 string, ADR
82/// 0142 D5), `Bool` → `Boolean`.
83pub fn json_kind_of(b: BaseType) -> JsonKind {
84    match b {
85        BaseType::Int | BaseType::Float | BaseType::Duration | BaseType::Instant => {
86            JsonKind::Number
87        }
88        BaseType::String | BaseType::Bytes => JsonKind::String,
89        BaseType::Bool => JsonKind::Boolean,
90    }
91}
92
93/// An extra structural guard a base-typed wire value must pass beyond its
94/// `typeof`: an `Int`/`Instant` must be whole, a `Float` must be finite.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum BaseGuard {
97    Integral,
98    Finite,
99}
100
101/// What a structural-mismatch error reports as `expected` — the vocabulary a
102/// renderer needs to explain *why* a wire value was rejected, independent of
103/// the TS spelling of the check that rejected it.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Expected {
106    Json(JsonKind),
107    Integer,
108    FiniteNumber,
109    Base64String,
110    SumVariantKind,
111}
112
113/// Where a boundary type's declaration lives relative to the module doing
114/// the crossing.
115///
116/// Carries the owner's *qualified unit name*, never a TS namespace prefix —
117/// the IR picks the re-validation *strategy* ([`Revalidation`]); `bynk-emit`
118/// alone knows how to spell that owner as an `import type * as <ns>` alias
119/// (`bynk-emit/src/emitter/serialisation.rs`'s `Qual`/`qual_prefix`, built
120/// per-emission from build-mode facts this crate does not have). Unifying
121/// the two would drag `BuildTarget` into the checker.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum Provenance {
124    /// The emitting module's own declaration: re-validation routes through
125    /// the type's own constructor.
126    Owned,
127    /// A type declared by another unit and reached through it — `owner_unit`
128    /// is that unit's qualified name (`commerce.payment`, never a TS
129    /// namespace spelling).
130    Consumed { owner_unit: String },
131}
132
133/// #661 Decisions C/D plus the owner path, stated once: how a boundary
134/// scalar's refinement is re-checked on the way in, given who declared it
135/// and where the check is happening.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum Revalidation {
138    /// Owner's own module: run the type's own `.of`.
139    ViaConstructor,
140    /// Consumed + transparent (Decision D): re-check each predicate inline,
141    /// since the consumer knows the shape by declaration even without an
142    /// importable constructor.
143    Inline,
144    /// Consumed + opaque (Decision C): the predicate is the owner's secret —
145    /// cast structurally after the base check; skew is caught by the
146    /// contract hash instead.
147    StructuralOnly,
148    /// `Bytes`: decoded, not cast — the one base type whose wire value is
149    /// not a direct cast of its erased representation. Applies regardless of
150    /// provenance, mirroring `emit_refined`'s early return to the dedicated
151    /// `Bytes` codec before the owned/consumed split is even considered.
152    Base64Decode,
153}
154
155/// A named boundary type's declared shape, refinement-independent of who is
156/// asking. `owner`-relative facts ([`Revalidation`]) live one level down, in
157/// [`WireScalar`] — the shape itself does not vary with provenance, only how
158/// hard the receiver re-checks it.
159#[derive(Debug, Clone)]
160pub struct WireType {
161    pub name: String,
162    /// The codec-name suffix this type resolves to (`Order`, or a
163    /// monomorphised generic's `Paginated_User`). Equal to `name` for every
164    /// non-generic declaration `wire_type` produces; kept as its own field
165    /// because a future generic-instantiation `WireType` (Phase 2+) needs
166    /// the two to diverge the same way `serialisation.rs`'s `fn_suffix` /
167    /// `ts_type` pair already does.
168    pub codec_suffix: String,
169    pub provenance: Provenance,
170    pub body: WireBody,
171}
172
173#[derive(Debug, Clone)]
174pub enum WireBody {
175    Scalar(WireScalar),
176    Record { fields: Vec<WireField> },
177    Sum(WireSum),
178}
179
180/// A refined- or opaque-base-type boundary scalar.
181#[derive(Debug, Clone)]
182pub struct WireScalar {
183    pub base: BaseType,
184    pub json: JsonKind,
185    /// `true` for `opaque BaseType`, `false` for a transparent refined type.
186    pub opaque: bool,
187    /// **Declaration order**, not sorted, not deduped — this is the
188    /// highest-risk drift trap between this IR and `contract.rs`'s canonical
189    /// form (see the module doc's comparison table). `Inline` revalidation
190    /// emits one check per predicate in exactly this order.
191    pub predicates: Vec<PredKind>,
192    /// The base-type guards this scalar's `Inline` revalidation applies,
193    /// *before* the declared predicates. Mirrors
194    /// `emit_inline_refinement_checks` exactly: `Int` guards `Integral`,
195    /// `Float` guards `Finite` — deliberately **not** `Instant`, which is
196    /// guarded at record-*field* position (`emit_field_deserialise`'s
197    /// `TypeRef::Base` arm) but not here. That asymmetry already exists in
198    /// the emitter this IR was extracted from; it is preserved verbatim
199    /// rather than "fixed" by this move, since fixing it would be a
200    /// behaviour change this phase must not make. (See `field_base_guards`
201    /// for the field-position set, which does include `Instant`.)
202    pub base_guards: Vec<BaseGuard>,
203    pub revalidation: Revalidation,
204}
205
206/// One field of a boundary record, in **declaration order** (the emitted
207/// JSON key order).
208#[derive(Debug, Clone)]
209pub struct WireField {
210    pub name: String,
211    pub shape: WireRef,
212    /// The JSON path segment this field contributes to a validation error's
213    /// `path` (`` `${path}.<segment>` ``) — equal to `name` today; kept
214    /// distinct because a future field-rename annotation would move the wire
215    /// key without moving the path segment a hover/error should still name.
216    pub path_segment: String,
217    /// The field's default initialiser, if any, carried as raw AST — its
218    /// *wire-JSON literal* rendering is `lower_field_default_wire`
219    /// (`bynk-emit`), which stays in `bynk-emit` per the module doc: a
220    /// default's lowered literal is a rendering, not a boundary fact.
221    pub default: Option<(Expr, TypeRef)>,
222    /// #1969: the field's inline refinement (`qty: Int where Positive`), in
223    /// declaration order, checked on decode after the field's own codec. Empty
224    /// for an unrefined field and for every sum-variant payload field (the
225    /// grammar gives those no `where`).
226    pub predicates: Vec<PredKind>,
227}
228
229/// #1969: the JSON key a sum value's variant name travels under (`{"kind":
230/// "Circle", …}`). Part of the published wire format, version 1
231/// ([`crate::wire_format`]); the in-memory discriminant is `tag`.
232pub const WIRE_DISCRIMINANT: &str = "kind";
233
234/// A boundary sum type. The wire and in-memory discriminants are carried
235/// side by side because the codec's whole job is translating between them —
236/// `memory_discriminant` is the softest part of this seam (a host
237/// representation choice, not a wire fact); moving it back into `bynk-emit`
238/// alone is a one-field change if a reviewer objects.
239#[derive(Debug, Clone)]
240pub struct WireSum {
241    /// Always [`WIRE_DISCRIMINANT`].
242    pub wire_discriminant: &'static str,
243    pub memory_discriminant: &'static str,
244    pub variants: Vec<WireVariant>,
245}
246
247/// One variant of a [`WireSum`], in **declaration order** (the emitted
248/// `switch` arm order).
249#[derive(Debug, Clone)]
250pub struct WireVariant {
251    pub name: String,
252    pub payload: Vec<WireField>,
253}
254
255/// Why a [`WireRef`] carries no generated codec — the runtime-owned error
256/// family, which has no `TypeDecl` to derive a shape from.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub enum UncheckedReason {
259    /// An `Effect[T]` reached in a field/element position (never a top-level
260    /// handler return, which strips it before it gets here).
261    Effect,
262    ValidationError,
263    JsonError,
264    HttpResult,
265    QueueResult,
266    /// #1883, slice 1a: an `Asset` (rejected at boundaries by the checker).
267    Asset,
268}
269
270/// The resolved shape of one `TypeRef` occurrence — a field's type, a sum
271/// variant payload's type, a generic instantiation's argument. Mirrors the
272/// exhaustive dispatch `bynk-emit`'s `emit_field_deserialise` performs today,
273/// one level removed from any TS string.
274#[derive(Debug, Clone)]
275pub enum WireRef {
276    Base {
277        base: BaseType,
278        json: JsonKind,
279        guards: Vec<BaseGuard>,
280        expected: Expected,
281    },
282    /// `Bytes`: base64 string on the wire, decoded (never cast) into a
283    /// `Uint8Array`-shaped value.
284    Bytes,
285    /// A named boundary type — resolve further via the model's own
286    /// `WireType` table.
287    Named { name: String },
288    /// A generic instantiation — `Result[A, B]`, `Option[A]`, `List[A]`,
289    /// `Map[K, V]`, or a generic record/sum application (`Paginated[User]`).
290    /// `key` is the same codec-suffix string in every case
291    /// (`Result_Int_String`, `Paginated_User`, …) — resolve further via the
292    /// model's `instantiations` list, keyed by [`WireInst::ts_name`].
293    Inst { key: String },
294    /// `()` — no wire content; the wire slot is `null`, the value `undefined`.
295    Unit,
296    /// The runtime-owned error family (plus a stray field-position `Effect`)
297    /// — cast through unchecked, since there is no `TypeDecl` to derive a
298    /// shape from. See [`UncheckedReason`].
299    Unchecked { reason: UncheckedReason },
300}
301
302/// The set of `TypeRef`s a boundary walk resolved and their re-derived
303/// structural shapes — the single source of truth both the emitter's codec
304/// generation and the wire-contract peek render from.
305#[derive(Debug, Clone)]
306pub struct WireModel {
307    pub types: Vec<WireType>,
308    pub instantiations: Vec<WireInst>,
309    /// The generic-record names (v0.174 #592) that are transitively
310    /// self-referential and therefore have no finite monomorphised codec set
311    /// — rejected at the boundary before emit, carried here as the same
312    /// membership set the codec walks use to short-circuit.
313    pub recursive: BTreeSet<String>,
314}
315
316// ---------------------------------------------------------------------
317// Moved verbatim from `bynk-emit/src/emitter/serialisation.rs` (pure,
318// AST-only walks — no TS string ever touches these). `pub(crate)` in the
319// original became `pub` here; everything else is unchanged apart from the
320// renames the plan calls for (`inner_ts_name` → `codec_suffix`, `app_ts_name`
321// → `inst_codec_suffix`).
322//
323// `GenericInst` moved as `WireInst` but **keeps its original variant names**
324// (`ResultInst`/`OptionInst`/…, not the plan's bare `Result`/`Option`/…):
325// `bynk-emit/src/emitter/serialisation.rs`'s `emit_generic_helpers_qualified`
326// — a codec-*emission* function, out of scope for this phase — pattern-matches
327// those variants directly, and this phase must not touch codec-emission call
328// sites.
329//
330// #855 (Phase 2 step 9, decided): the plan allowed renaming to the bare
331// spelling here since this step touches `emit_generic_helpers_qualified`
332// anyway — but the RecordInst/SumInst arms it touches already consume the
333// IR's `WireField`/`WireSum` shapes (steps 7/8's byte-identical rewrite of
334// `emit_record_codec`/`emit_sum_codec` covers them), and the other four arms
335// (`ResultInst`/`OptionInst`/`ListInst`/`MapInst`) build their TS inline —
336// they were never going to move to a `Result`/`Option`/`List`/`Map` spelling
337// of *this* enum either way. A pure rename would touch six match arms here
338// and this module's doc/tests for zero behavioural or architectural gain, so
339// the `*Inst`-suffixed names are kept. Revisit only if a future consumer
340// (e.g. the Phase 3+ peek) finds the bare spelling actually reads better at
341// its own call sites.
342// ---------------------------------------------------------------------
343
344/// Compute the set of type names (transitively reachable) that need
345/// serialise/deserialise helpers for this context: any type used in the
346/// argument or return position of a service handler exposed by this
347/// context, walked through record fields, sum payloads, and the generic
348/// type parameters of Result/Option/Effect.
349pub fn collect_boundary_types(
350    types: &HashMap<String, Arc<TypeDecl>>,
351    services: &HashMap<String, ServiceDecl>,
352    // v0.96 (ADR 0124): rehydration is a trust boundary — an agent's persisted
353    // `store`-field types are validated on load, so they need their deserialisers
354    // emitted. Register every store field's kind-argument types (the element /
355    // key / value types of `Cell`/`Map`/`Set`/`Cache`/`Log`).
356    agents: &HashMap<String, AgentDecl>,
357    // #1678: further boundary positions to root the walk at. On the `workers`
358    // target an agent call crosses a Durable Object `fetch`, so the emitter
359    // passes every agent handler's parameter and return types here.
360    extra_roots: &[TypeRef],
361) -> Vec<String> {
362    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
363    let mut out: Vec<String> = Vec::new();
364    let mut stack: Vec<String> = Vec::new();
365    let recursive_set = recursive_generic_names(types);
366    let recursive = &recursive_set;
367    for r in extra_roots {
368        collect_type_names(r, &mut stack, types, recursive);
369    }
370
371    let mut svc_names: Vec<&String> = services.keys().collect();
372    svc_names.sort();
373    for name in svc_names {
374        let service = &services[name];
375        for h in &service.handlers {
376            for p in &h.params {
377                collect_type_names(&p.type_ref, &mut stack, types, recursive);
378            }
379            collect_type_names(&h.return_type, &mut stack, types, recursive);
380        }
381    }
382
383    let mut agent_names: Vec<&String> = agents.keys().collect();
384    agent_names.sort();
385    for name in agent_names {
386        for f in &agents[name].store_fields {
387            for arg in &f.kind.args {
388                collect_type_names(arg, &mut stack, types, recursive);
389            }
390        }
391        // State-migrations S2 (#2224): a `migrate` step decodes stored bytes
392        // through its old record type, so that type needs a deserialiser too.
393        for m in &agents[name].migrations {
394            if let Some(old) = &m.old {
395                collect_type_names(&old.type_ref, &mut stack, types, recursive);
396            }
397        }
398    }
399
400    while let Some(name) = stack.pop() {
401        if !seen.insert(name.clone()) {
402            continue;
403        }
404        out.push(name.clone());
405        let Some(decl) = types.get(&name) else {
406            continue;
407        };
408        match &decl.body {
409            TypeBody::Record(r) => {
410                for f in &r.fields {
411                    collect_type_names(&f.type_ref, &mut stack, types, recursive);
412                }
413            }
414            TypeBody::Sum(s) => {
415                for v in &s.variants {
416                    for p in &v.payload {
417                        collect_type_names(&p.type_ref, &mut stack, types, recursive);
418                    }
419                }
420            }
421            TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
422        }
423    }
424
425    out.sort();
426    out
427}
428
429/// v0.174 (#592): the set of generic-record names that are *recursive* — they
430/// transitively contain themselves, so they have no finite monomorphised codec
431/// (rejected at the boundary by the checker before emit). Precomputed once per
432/// collector so the per-`App` guard in the codec walks is an O(1) membership test
433/// rather than a fresh graph reachability walk at every occurrence.
434fn recursive_generic_names(
435    types: &HashMap<String, Arc<TypeDecl>>,
436) -> std::collections::HashSet<String> {
437    types
438        .iter()
439        .filter(|(_, d)| !d.type_params.is_empty())
440        .map(|(n, _)| n.clone())
441        .filter(|n| generic_record_is_recursive(n, types))
442        .collect()
443}
444
445fn collect_type_names(
446    t: &TypeRef,
447    stack: &mut Vec<String>,
448    types: &HashMap<String, Arc<TypeDecl>>,
449    recursive: &std::collections::HashSet<String>,
450) {
451    match t {
452        TypeRef::Named(id) => stack.push(id.name.clone()),
453        // Query/Stream/Connection types carry no boundary-collectable user
454        // types (non-boundary).
455        TypeRef::Query(..)
456        | TypeRef::Stream(..)
457        | TypeRef::Connection(..)
458        | TypeRef::History(..) => {}
459        // v0.174 (#592): a generic-record instantiation is boundary-serialisable
460        // through its monomorphised codec (`serialise_Paginated_User`). The
461        // *named* helpers that codec calls come from its concrete field types —
462        // the type arguments (`User`) and any non-parameter named field types
463        // (`Envelope[T] = { meta: Metadata, … }`) — so walk the substituted
464        // fields. A *recursive* generic record has no finite codec set and is
465        // rejected at the boundary before emit; the guard here is defence in
466        // depth so this walk can never fail to terminate.
467        TypeRef::App { name, args, .. } => {
468            if recursive.contains(&name.name) {
469                return;
470            }
471            // #593: a generic-sum instantiation's codec (`serialise_ApiResult_User`)
472            // likewise calls the named helpers of its *substituted variant
473            // payloads* (`serialise_User` for `Loaded(value: T)` at `T = User`),
474            // so walk those the same way records walk their fields.
475            if let Some(fields) = record_inst_fields(&name.name, args, types) {
476                for (_, ft) in &fields {
477                    collect_type_names(ft, stack, types, recursive);
478                }
479            } else if let Some(variants) = sum_inst_variants(&name.name, args, types) {
480                for (_, payload) in &variants {
481                    for (_, ft) in payload {
482                        collect_type_names(ft, stack, types, recursive);
483                    }
484                }
485            }
486        }
487        // v0.20a: function types carry no user-named types to collect and are
488        // rejected at boundaries anyway.
489        TypeRef::Fn(..) => {}
490        TypeRef::Result(a, b, _) => {
491            collect_type_names(a, stack, types, recursive);
492            collect_type_names(b, stack, types, recursive);
493        }
494        TypeRef::Option(a, _) => collect_type_names(a, stack, types, recursive),
495        TypeRef::Effect(a, _) => collect_type_names(a, stack, types, recursive),
496        TypeRef::HttpResult(a, _) => collect_type_names(a, stack, types, recursive),
497        // v0.20b: collections serialise element-/entry-wise; their inner
498        // named types need helpers.
499        TypeRef::List(a, _) => collect_type_names(a, stack, types, recursive),
500        TypeRef::Map(k, v, _) => {
501            collect_type_names(k, stack, types, recursive);
502            collect_type_names(v, stack, types, recursive);
503        }
504        TypeRef::Base(_, _)
505        | TypeRef::QueueResult(_)
506        | TypeRef::Asset(_)
507        | TypeRef::RequestHeaders(_)
508        | TypeRef::TokenClaims(_)
509        | TypeRef::BoundaryRejection(_)
510        | TypeRef::ValidationError(_)
511        | TypeRef::JsonError(_)
512        | TypeRef::Unit(_) => {}
513    }
514}
515
516/// v0.174 (#592): substitute a generic record's declared field type — replacing
517/// each type-parameter name with the concrete argument type-ref — so a
518/// per-instantiation codec sees fully concrete field types.
519/// `Paginated[User]`'s `items: List[T]` becomes `items: List[User]`.
520fn subst_type_ref(t: &TypeRef, subst: &HashMap<String, TypeRef>) -> TypeRef {
521    match t {
522        TypeRef::Named(id) => match subst.get(&id.name) {
523            Some(replacement) => replacement.clone(),
524            None => t.clone(),
525        },
526        TypeRef::App { name, args, span } => TypeRef::App {
527            name: name.clone(),
528            args: args.iter().map(|a| subst_type_ref(a, subst)).collect(),
529            span: *span,
530        },
531        TypeRef::Result(a, b, s) => TypeRef::Result(
532            Box::new(subst_type_ref(a, subst)),
533            Box::new(subst_type_ref(b, subst)),
534            *s,
535        ),
536        TypeRef::Option(a, s) => TypeRef::Option(Box::new(subst_type_ref(a, subst)), *s),
537        TypeRef::Effect(a, s) => TypeRef::Effect(Box::new(subst_type_ref(a, subst)), *s),
538        TypeRef::HttpResult(a, s) => TypeRef::HttpResult(Box::new(subst_type_ref(a, subst)), *s),
539        TypeRef::List(a, s) => TypeRef::List(Box::new(subst_type_ref(a, subst)), *s),
540        TypeRef::Map(k, v, s) => TypeRef::Map(
541            Box::new(subst_type_ref(k, subst)),
542            Box::new(subst_type_ref(v, subst)),
543            *s,
544        ),
545        TypeRef::Query(a, s) => TypeRef::Query(Box::new(subst_type_ref(a, subst)), *s),
546        TypeRef::Stream(a, s) => TypeRef::Stream(Box::new(subst_type_ref(a, subst)), *s),
547        TypeRef::Connection(a, s) => TypeRef::Connection(Box::new(subst_type_ref(a, subst)), *s),
548        TypeRef::History(a, s) => TypeRef::History(Box::new(subst_type_ref(a, subst)), *s),
549        TypeRef::Fn(ps, r, s) => TypeRef::Fn(
550            ps.iter().map(|p| subst_type_ref(p, subst)).collect(),
551            Box::new(subst_type_ref(r, subst)),
552            *s,
553        ),
554        TypeRef::Base(..)
555        | TypeRef::QueueResult(_)
556        | TypeRef::Asset(_)
557        | TypeRef::RequestHeaders(_)
558        | TypeRef::TokenClaims(_)
559        | TypeRef::BoundaryRejection(_)
560        | TypeRef::ValidationError(_)
561        | TypeRef::JsonError(_)
562        | TypeRef::Unit(_) => t.clone(),
563    }
564}
565
566/// #1846: `t` with each type `renames` names renamed, wherever it is named:
567/// a bare name, and the base of a generic application (`Envelope[Int]`),
568/// which `subst_type_ref` leaves alone because a type parameter is never
569/// applied. `bynk-emit` renames a consumed context's unexported types with it.
570pub fn rename_type_ref(t: &TypeRef, renames: &HashMap<String, String>) -> TypeRef {
571    match t {
572        TypeRef::Named(id) => match renames.get(&id.name) {
573            Some(to) => TypeRef::Named(Ident {
574                name: to.clone(),
575                span: id.span,
576            }),
577            None => t.clone(),
578        },
579        TypeRef::App { name, args, span } => TypeRef::App {
580            name: match renames.get(&name.name) {
581                Some(to) => Ident {
582                    name: to.clone(),
583                    span: name.span,
584                },
585                None => name.clone(),
586            },
587            args: args.iter().map(|a| rename_type_ref(a, renames)).collect(),
588            span: *span,
589        },
590        TypeRef::Result(a, b, s) => TypeRef::Result(
591            Box::new(rename_type_ref(a, renames)),
592            Box::new(rename_type_ref(b, renames)),
593            *s,
594        ),
595        TypeRef::Option(a, s) => TypeRef::Option(Box::new(rename_type_ref(a, renames)), *s),
596        TypeRef::Effect(a, s) => TypeRef::Effect(Box::new(rename_type_ref(a, renames)), *s),
597        TypeRef::HttpResult(a, s) => TypeRef::HttpResult(Box::new(rename_type_ref(a, renames)), *s),
598        TypeRef::List(a, s) => TypeRef::List(Box::new(rename_type_ref(a, renames)), *s),
599        TypeRef::Map(k, v, s) => TypeRef::Map(
600            Box::new(rename_type_ref(k, renames)),
601            Box::new(rename_type_ref(v, renames)),
602            *s,
603        ),
604        TypeRef::Query(a, s) => TypeRef::Query(Box::new(rename_type_ref(a, renames)), *s),
605        TypeRef::Stream(a, s) => TypeRef::Stream(Box::new(rename_type_ref(a, renames)), *s),
606        TypeRef::Connection(a, s) => TypeRef::Connection(Box::new(rename_type_ref(a, renames)), *s),
607        TypeRef::History(a, s) => TypeRef::History(Box::new(rename_type_ref(a, renames)), *s),
608        TypeRef::Fn(ps, r, s) => TypeRef::Fn(
609            ps.iter().map(|p| rename_type_ref(p, renames)).collect(),
610            Box::new(rename_type_ref(r, renames)),
611            *s,
612        ),
613        TypeRef::Base(..)
614        | TypeRef::QueueResult(_)
615        | TypeRef::Asset(_)
616        | TypeRef::RequestHeaders(_)
617        | TypeRef::TokenClaims(_)
618        | TypeRef::BoundaryRejection(_)
619        | TypeRef::ValidationError(_)
620        | TypeRef::JsonError(_)
621        | TypeRef::Unit(_) => t.clone(),
622    }
623}
624
625/// v0.174 (#592): the concrete `(field-name, field-type)` list for a generic
626/// record instantiation `Name[args…]` — the declared fields with every type
627/// parameter substituted by the matching argument. Returns `None` if `name` is
628/// not a declared generic record or the arity does not match (both guaranteed
629/// impossible by the checker, so this is purely defensive).
630pub fn record_inst_fields(
631    name: &str,
632    args: &[TypeRef],
633    types: &HashMap<String, Arc<TypeDecl>>,
634) -> Option<Vec<(String, TypeRef)>> {
635    let decl = types.get(name)?;
636    let TypeBody::Record(r) = &decl.body else {
637        return None;
638    };
639    if decl.type_params.len() != args.len() {
640        return None;
641    }
642    let subst: HashMap<String, TypeRef> = decl
643        .type_params
644        .iter()
645        .map(|p| p.name.name.clone())
646        .zip(args.iter().cloned())
647        .collect();
648    Some(
649        r.fields
650            .iter()
651            .map(|f| (f.name.name.clone(), subst_type_ref(&f.type_ref, &subst)))
652            .collect(),
653    )
654}
655
656/// #593: the concrete `(variant-name, [(field-name, field-type)])` list for a
657/// generic sum instantiation `Name[args…]` — the declared variants with every
658/// type parameter substituted by the matching argument. The sum analogue of
659/// [`record_inst_fields`]; `None` (defensively) if `name` is not a declared
660/// generic sum or the arity does not match.
661#[allow(clippy::type_complexity)]
662pub fn sum_inst_variants(
663    name: &str,
664    args: &[TypeRef],
665    types: &HashMap<String, Arc<TypeDecl>>,
666) -> Option<Vec<(String, Vec<(String, TypeRef)>)>> {
667    let decl = types.get(name)?;
668    let TypeBody::Sum(s) = &decl.body else {
669        return None;
670    };
671    if decl.type_params.len() != args.len() {
672        return None;
673    }
674    let subst: HashMap<String, TypeRef> = decl
675        .type_params
676        .iter()
677        .map(|p| p.name.name.clone())
678        .zip(args.iter().cloned())
679        .collect();
680    Some(
681        s.variants
682            .iter()
683            .map(|v| {
684                (
685                    v.name.name.clone(),
686                    v.payload
687                        .iter()
688                        .map(|f| (f.name.name.clone(), subst_type_ref(&f.type_ref, &subst)))
689                        .collect(),
690                )
691            })
692            .collect(),
693    )
694}
695
696/// v0.174 (#592): the monomorphised codec suffix for a generic-record
697/// instantiation — `Paginated[User]` → `Paginated_User`,
698/// `Pair[User, String]` → `Pair_User_String`. #593: shared with generic sums.
699/// Renamed from `app_ts_name` in the move from `bynk-emit`.
700pub fn inst_codec_suffix(name: &str, args: &[TypeRef]) -> String {
701    let mut s = name.to_string();
702    for a in args {
703        s.push('_');
704        s.push_str(&codec_suffix(a));
705    }
706    s
707}
708
709/// The codec-name suffix a `TypeRef` resolves to — `Int`, `Order`,
710/// `Result_Int_String`, `Paginated_User`. Renamed from `inner_ts_name` in the
711/// move from `bynk-emit`; used both to key a [`WireInst`] and, unqualified,
712/// as the bare codec function suffix a same-module call reaches.
713pub fn codec_suffix(t: &TypeRef) -> String {
714    match t {
715        TypeRef::Base(b, _) => b.name().to_string(),
716        // v0.20a: function types are confined to non-boundary positions
717        // (`bynk.types.function_at_boundary`), so the serialisation machinery
718        // can never legally see one.
719        TypeRef::Fn(..)
720        | TypeRef::Query(..)
721        | TypeRef::Stream(..)
722        | TypeRef::Connection(..)
723        | TypeRef::History(..) => {
724            unreachable!("function/query/stream types are rejected at boundaries")
725        }
726        // v0.174 (#592): the codec suffix for a generic-record instantiation —
727        // `Paginated[User]` → `Paginated_User`.
728        TypeRef::App { name, args, .. } => inst_codec_suffix(&name.name, args),
729        TypeRef::Named(id) => id.name.clone(),
730        TypeRef::Result(a, b, _) => format!("Result_{}_{}", codec_suffix(a), codec_suffix(b)),
731        TypeRef::Option(a, _) => format!("Option_{}", codec_suffix(a)),
732        TypeRef::Effect(a, _) => format!("Effect_{}", codec_suffix(a)),
733        TypeRef::HttpResult(a, _) => format!("HttpResult_{}", codec_suffix(a)),
734        TypeRef::List(a, _) => format!("List_{}", codec_suffix(a)),
735        TypeRef::Map(k, v, _) => format!("Map_{}_{}", codec_suffix(k), codec_suffix(v)),
736        TypeRef::QueueResult(_) => "QueueResult".to_string(),
737        TypeRef::Asset(_) => "Asset".to_string(),
738        TypeRef::RequestHeaders(_) => "RequestHeaders".to_string(),
739        TypeRef::TokenClaims(_) => "TokenClaims".to_string(),
740        TypeRef::BoundaryRejection(_) => "BoundaryRejection".to_string(),
741        TypeRef::ValidationError(_) => "ValidationError".to_string(),
742        TypeRef::JsonError(_) => "JsonError".to_string(),
743        TypeRef::Unit(_) => "Unit".to_string(),
744    }
745}
746
747/// v0.22b: the codec closure for a set of `Json.encode`/`Json.decode[T]`
748/// target type-refs — the named types needing per-type helpers (transitively
749/// through record fields and sum payloads) plus the generic instantiations
750/// needing specialised helpers. The same closure logic as the boundary
751/// collectors, rooted at expressions instead of service signatures.
752pub fn collect_codec_closure(
753    roots: &[TypeRef],
754    types: &HashMap<String, Arc<TypeDecl>>,
755) -> (Vec<String>, Vec<WireInst>) {
756    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
757    let mut names: Vec<String> = Vec::new();
758    let mut stack: Vec<String> = Vec::new();
759    let recursive_set = recursive_generic_names(types);
760    let recursive = &recursive_set;
761    for r in roots {
762        collect_type_names(r, &mut stack, types, recursive);
763    }
764    while let Some(name) = stack.pop() {
765        if !seen.insert(name.clone()) {
766            continue;
767        }
768        names.push(name.clone());
769        let Some(decl) = types.get(&name) else {
770            continue;
771        };
772        match &decl.body {
773            TypeBody::Record(r) => {
774                for f in &r.fields {
775                    collect_type_names(&f.type_ref, &mut stack, types, recursive);
776                }
777            }
778            TypeBody::Sum(s) => {
779                for v in &s.variants {
780                    for p in &v.payload {
781                        collect_type_names(&p.type_ref, &mut stack, types, recursive);
782                    }
783                }
784            }
785            TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
786        }
787    }
788    names.sort();
789
790    let mut insts: Vec<WireInst> = Vec::new();
791    let mut inst_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
792    for r in roots {
793        walk_generic_inst(r, &mut insts, &mut inst_seen, types, recursive);
794    }
795    for name in &names {
796        let Some(decl) = types.get(name) else {
797            continue;
798        };
799        match &decl.body {
800            TypeBody::Record(r) => {
801                for f in &r.fields {
802                    walk_generic_inst(&f.type_ref, &mut insts, &mut inst_seen, types, recursive);
803                }
804            }
805            TypeBody::Sum(s) => {
806                for v in &s.variants {
807                    for p in &v.payload {
808                        walk_generic_inst(
809                            &p.type_ref,
810                            &mut insts,
811                            &mut inst_seen,
812                            types,
813                            recursive,
814                        );
815                    }
816                }
817            }
818            TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
819        }
820    }
821    (names, insts)
822}
823
824/// Collect the set of `Result<A, B>` / `Option<A>` instantiations used in
825/// boundary positions so the emitter can synthesise the specialised
826/// helpers. v0.18: an instantiation may also appear in the *fields* of a
827/// boundary record or sum payload (e.g. the bynk surface's
828/// `Request.contentType: Option[String]`) — the per-type serialisers
829/// delegate to the specialised generic helpers, so walk those too.
830pub fn collect_generic_instantiations(
831    services: &HashMap<String, ServiceDecl>,
832    // v0.96 (ADR 0124): an agent's `store`-field element types are validated on
833    // rehydration, so a `Cell[Option[Int]]` / `Log[List[T]]` needs its specialised
834    // generic helper emitted just like a boundary signature does.
835    agents: &HashMap<String, AgentDecl>,
836    boundary_type_names: &[String],
837    types: &HashMap<String, Arc<TypeDecl>>,
838    // #1678: further boundary positions, as for `collect_boundary_types`. Walked
839    // after the services and agent stores, so existing emission order is kept.
840    extra_roots: &[TypeRef],
841) -> Vec<WireInst> {
842    let mut out: Vec<WireInst> = Vec::new();
843    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
844    let recursive_set = recursive_generic_names(types);
845    let recursive = &recursive_set;
846    // Iterate services in name order: `HashMap::values()` order varies per
847    // process, and the *emission order* of the specialised helpers follows
848    // first-encounter order here. Surfaced by the first fixture with
849    // multiple same-file services carrying different instantiations (v0.23
850    // #35 CI); latent since v0.8.
851    let mut svc_names: Vec<&String> = services.keys().collect();
852    svc_names.sort();
853    for name in svc_names {
854        let s = &services[name];
855        for h in &s.handlers {
856            for p in &h.params {
857                walk_generic_inst(&p.type_ref, &mut out, &mut seen, types, recursive);
858            }
859            walk_generic_inst(&h.return_type, &mut out, &mut seen, types, recursive);
860        }
861    }
862    let mut agent_names: Vec<&String> = agents.keys().collect();
863    agent_names.sort();
864    for name in agent_names {
865        for f in &agents[name].store_fields {
866            for arg in &f.kind.args {
867                walk_generic_inst(arg, &mut out, &mut seen, types, recursive);
868            }
869        }
870        for m in &agents[name].migrations {
871            if let Some(old) = &m.old {
872                walk_generic_inst(&old.type_ref, &mut out, &mut seen, types, recursive);
873            }
874        }
875    }
876    for r in extra_roots {
877        walk_generic_inst(r, &mut out, &mut seen, types, recursive);
878    }
879    for name in boundary_type_names {
880        let Some(decl) = types.get(name) else {
881            continue;
882        };
883        // v0.174 (#592): never walk a *generic* declaration's own fields — they
884        // are the declared, unsubstituted body (`Paginated[T] = { items: List[T]
885        // }`), so walking `List[T]` would emit a bogus `serialise_List_T` over the
886        // unbound type variable `T`. The instantiations a generic record needs
887        // come from its *use* sites (`Paginated[User]`, a `TypeRef::App`), which
888        // `walk_generic_inst` expands with concrete arguments. This mirrors the
889        // `emit_helpers_for_owner` skip that keeps a bare `serialise_Paginated`
890        // from being emitted.
891        if !decl.type_params.is_empty() {
892            continue;
893        }
894        match &decl.body {
895            TypeBody::Record(r) => {
896                for f in &r.fields {
897                    walk_generic_inst(&f.type_ref, &mut out, &mut seen, types, recursive);
898                }
899            }
900            TypeBody::Sum(s) => {
901                for v in &s.variants {
902                    for p in &v.payload {
903                        walk_generic_inst(&p.type_ref, &mut out, &mut seen, types, recursive);
904                    }
905                }
906            }
907            TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
908        }
909    }
910    out
911}
912
913/// A boundary occurrence of a generic instantiation needing its own
914/// specialised codec — `Result<A, B>`, `Option<A>`, `List<A>`, `Map<K, V>`,
915/// or a generic user record/sum applied to concrete arguments
916/// (`Paginated[User]`, `ApiResult[User]`).
917///
918/// Moved from `bynk-emit/src/emitter/serialisation.rs`'s `GenericInst` as
919/// part of the #855 IR extraction. Variant names are kept exactly as they
920/// were (`ResultInst`, not the plan's bare `Result`, …): `bynk-emit`'s
921/// `emit_generic_helpers_qualified` pattern-matches these variants directly.
922/// Phase 2 step 9 ("Generic helpers") revisited the plan's bare
923/// `Result`/`Option`/`List`/`Map`/`Record`/`Sum` spelling and **kept** the
924/// `*Inst` suffix — see the "Moved verbatim" comment block above this
925/// module's walk functions for the reasoning (a pure rename of six match
926/// arms with no behavioural or architectural payoff).
927#[derive(Debug, Clone)]
928#[allow(clippy::enum_variant_names)]
929pub enum WireInst {
930    ResultInst {
931        ok: TypeRef,
932        err: TypeRef,
933    },
934    OptionInst {
935        inner: TypeRef,
936    },
937    /// v0.20b: a `List[T]` boundary instantiation — element-wise wire format.
938    ListInst {
939        elem: TypeRef,
940    },
941    /// v0.20b: a `Map[K, V]` boundary instantiation — entries-array wire
942    /// format (`[[k, v], …]`), insertion-ordered.
943    MapInst {
944        key: TypeRef,
945        val: TypeRef,
946    },
947    /// v0.174 (#592): a generic user-record instantiation `Name[args…]` — a
948    /// monomorphised per-instantiation record codec (`serialise_Paginated_User`)
949    /// specialised to the concrete arguments (ADR 0183 Decision C's follow-on).
950    RecordInst {
951        name: String,
952        args: Vec<TypeRef>,
953    },
954    /// #593: a generic user-sum instantiation `Name[args…]` — a monomorphised
955    /// per-instantiation discriminated-union codec (`serialise_ApiResult_User`),
956    /// the sum analogue of [`WireInst::RecordInst`].
957    SumInst {
958        name: String,
959        args: Vec<TypeRef>,
960    },
961}
962
963impl WireInst {
964    pub fn ts_name(&self) -> String {
965        match self {
966            WireInst::ResultInst { ok, err } => {
967                format!("Result_{}_{}", codec_suffix(ok), codec_suffix(err))
968            }
969            WireInst::OptionInst { inner } => {
970                format!("Option_{}", codec_suffix(inner))
971            }
972            WireInst::ListInst { elem } => format!("List_{}", codec_suffix(elem)),
973            WireInst::MapInst { key, val } => {
974                format!("Map_{}_{}", codec_suffix(key), codec_suffix(val))
975            }
976            WireInst::RecordInst { name, args } => inst_codec_suffix(name, args),
977            WireInst::SumInst { name, args } => inst_codec_suffix(name, args),
978        }
979    }
980}
981
982fn walk_generic_inst(
983    t: &TypeRef,
984    out: &mut Vec<WireInst>,
985    seen: &mut std::collections::HashSet<String>,
986    types: &HashMap<String, Arc<TypeDecl>>,
987    recursive: &std::collections::HashSet<String>,
988) {
989    match t {
990        // v0.174 (#592): a generic-record instantiation needs a monomorphised
991        // codec, and so do the generic instantiations reachable through its
992        // concrete field types (`Paginated[User]` → `List[User]` →
993        // `serialise_List_User`, `Envelope[Box[User]]` → `Box[User]` →
994        // `serialise_Box_User`). Substitute the fields and walk them. A recursive
995        // generic record (no finite codec set) is rejected at the boundary before
996        // emit; the guard here is defence in depth so this walk always terminates
997        // (the `seen` dedup alone cannot bound *polymorphic* recursion, whose
998        // instantiations each carry a distinct name).
999        TypeRef::App { name, args, .. } => {
1000            if recursive.contains(&name.name) {
1001                return;
1002            }
1003            // #593: an `App` names a generic record OR a generic sum; dispatch on
1004            // the declaration's body so the right monomorphised codec is emitted,
1005            // and walk the reachable instantiations through its concrete member
1006            // types (a sum's variant payloads, a record's fields).
1007            let is_sum = matches!(
1008                types.get(&name.name).map(|d| &d.body),
1009                Some(TypeBody::Sum(_))
1010            );
1011            let inst = if is_sum {
1012                WireInst::SumInst {
1013                    name: name.name.clone(),
1014                    args: args.clone(),
1015                }
1016            } else {
1017                WireInst::RecordInst {
1018                    name: name.name.clone(),
1019                    args: args.clone(),
1020                }
1021            };
1022            let key = inst.ts_name();
1023            if !seen.insert(key) {
1024                return;
1025            }
1026            out.push(inst);
1027            for a in args {
1028                walk_generic_inst(a, out, seen, types, recursive);
1029            }
1030            if is_sum {
1031                if let Some(variants) = sum_inst_variants(&name.name, args, types) {
1032                    for (_, payload) in &variants {
1033                        for (_, ft) in payload {
1034                            walk_generic_inst(ft, out, seen, types, recursive);
1035                        }
1036                    }
1037                }
1038            } else if let Some(fields) = record_inst_fields(&name.name, args, types) {
1039                for (_, ft) in &fields {
1040                    walk_generic_inst(ft, out, seen, types, recursive);
1041                }
1042            }
1043        }
1044        TypeRef::Result(a, b, _) => {
1045            let inst = WireInst::ResultInst {
1046                ok: (**a).clone(),
1047                err: (**b).clone(),
1048            };
1049            let key = inst.ts_name();
1050            if seen.insert(key) {
1051                out.push(inst);
1052            }
1053            walk_generic_inst(a, out, seen, types, recursive);
1054            walk_generic_inst(b, out, seen, types, recursive);
1055        }
1056        TypeRef::Option(a, _) => {
1057            let inst = WireInst::OptionInst {
1058                inner: (**a).clone(),
1059            };
1060            let key = inst.ts_name();
1061            if seen.insert(key) {
1062                out.push(inst);
1063            }
1064            walk_generic_inst(a, out, seen, types, recursive);
1065        }
1066        TypeRef::Effect(a, _) => walk_generic_inst(a, out, seen, types, recursive),
1067        TypeRef::HttpResult(a, _) => walk_generic_inst(a, out, seen, types, recursive),
1068        TypeRef::List(a, _) => {
1069            let inst = WireInst::ListInst {
1070                elem: (**a).clone(),
1071            };
1072            let key = inst.ts_name();
1073            if seen.insert(key) {
1074                out.push(inst);
1075            }
1076            walk_generic_inst(a, out, seen, types, recursive);
1077        }
1078        TypeRef::Map(k, v, _) => {
1079            let inst = WireInst::MapInst {
1080                key: (**k).clone(),
1081                val: (**v).clone(),
1082            };
1083            let key = inst.ts_name();
1084            if seen.insert(key) {
1085                out.push(inst);
1086            }
1087            walk_generic_inst(k, out, seen, types, recursive);
1088            walk_generic_inst(v, out, seen, types, recursive);
1089        }
1090        _ => {}
1091    }
1092}
1093
1094// ---------------------------------------------------------------------
1095// New: resolving a `TypeRef` / `TypeDecl` into the IR above. Not yet called
1096// by any codec path — `bynk-emit` still derives its own shape inline
1097// (Phase 2 switches it over). Exercised only by this module's own tests
1098// until then.
1099// ---------------------------------------------------------------------
1100
1101/// The base-type guards a bare **field-position** value (a record field, a
1102/// sum-variant payload field, a collection element) is checked against,
1103/// mirroring `emit_field_deserialise`'s `TypeRef::Base` arm: `Int`/`Duration`/
1104/// `Instant` must be whole, `Float` must be finite. #1969: `Duration` joined the
1105/// first group; a top-level `Duration` was already guarded, a field was not. Contrast [`WireScalar::base_guards`]
1106/// (a **named-type** consumed-inline revalidation), which guards `Int`/`Float`
1107/// only — the two positions are not the same check in the code this was
1108/// extracted from, and that asymmetry is preserved rather than merged.
1109pub fn field_base_guards(b: BaseType) -> Vec<BaseGuard> {
1110    match b {
1111        BaseType::Int | BaseType::Duration | BaseType::Instant => vec![BaseGuard::Integral],
1112        BaseType::Float => vec![BaseGuard::Finite],
1113        _ => Vec::new(),
1114    }
1115}
1116
1117/// The base-type guards a named type's `Inline` (consumed, transparent)
1118/// revalidation applies, mirroring `emit_inline_refinement_checks` exactly:
1119/// `Int` → `Integral`, `Float` → `Finite`. See [`WireScalar::base_guards`]'s
1120/// doc for why `Instant` is deliberately absent here despite being guarded
1121/// at field position.
1122fn scalar_base_guards(b: BaseType) -> Vec<BaseGuard> {
1123    match b {
1124        BaseType::Int => vec![BaseGuard::Integral],
1125        BaseType::Float => vec![BaseGuard::Finite],
1126        _ => Vec::new(),
1127    }
1128}
1129
1130/// Resolve one `TypeRef` occurrence (a field's type, a variant payload
1131/// field's type, a generic argument) into its [`WireRef`] shape — the same
1132/// dispatch `emit_field_deserialise` performs today, one level removed from
1133/// any TS string.
1134///
1135/// `types` is threaded through for signature symmetry with the walks above
1136/// and forward compatibility with a future disambiguation need; this
1137/// resolution is single-level (unlike `collect_type_names`'s transitive
1138/// walk) and does not currently consult it.
1139///
1140/// **This encodes the *deserialise*-side dispatch only** (mirroring
1141/// `emit_field_deserialise`'s arms one-for-one, including its `Effect` /
1142/// `HttpResult` arm, which it folds into the same unchecked cast as
1143/// `ValidationError`/`JsonError`/`QueueResult`). The emitter does not
1144/// actually agree with itself on those two shapes across its other three
1145/// dispatches: `serialise_field_expr_via` *recurses* through `Effect` rather
1146/// than casting it unchecked, `deserialise_expr_via` also recurses through
1147/// `Effect`, and `codec_suffix` gives both `Effect` and `HttpResult` their
1148/// own composed names (`Effect_<inner>`, `HttpResult_<inner>`) rather than
1149/// treating them as opaque. A future serialise-side or codec-suffix-side
1150/// `WireRef` derivation must **not** assume this function's `Unchecked`
1151/// answer already covers it — that would silently change `Effect`-at-field-
1152/// position from "recurse into the inner type" to "cast unchecked" the
1153/// moment Phase 2 routes the serialise path through this same resolver.
1154/// Reconciling (or deliberately keeping two resolvers) is a Phase 2 decision,
1155/// not implied by this function's existence.
1156pub fn wire_ref(t: &TypeRef, _types: &HashMap<String, Arc<TypeDecl>>) -> WireRef {
1157    match t {
1158        // v0.20a: function types are confined to non-boundary positions
1159        // (`bynk.types.function_at_boundary`), so the serialisation machinery
1160        // can never legally see one — mirrors `emit_field_deserialise`'s
1161        // `unreachable!` on this same arm.
1162        TypeRef::Fn(..)
1163        | TypeRef::Query(..)
1164        | TypeRef::Stream(..)
1165        | TypeRef::Connection(..)
1166        | TypeRef::History(..) => {
1167            unreachable!(
1168                "function/query/stream/connection/history types are rejected at boundaries"
1169            )
1170        }
1171        // v0.110 (ADR 0142 D5): a bare `Bytes` field is a base64 JSON string,
1172        // decoded (not cast) into a `Uint8Array`.
1173        TypeRef::Base(BaseType::Bytes, _) => WireRef::Bytes,
1174        TypeRef::Base(b, _) => {
1175            let json = json_kind_of(*b);
1176            WireRef::Base {
1177                base: *b,
1178                json,
1179                guards: field_base_guards(*b),
1180                expected: Expected::Json(json),
1181            }
1182        }
1183        TypeRef::Named(id) => WireRef::Named {
1184            name: id.name.clone(),
1185        },
1186        // Every generic instantiation — App/Result/Option/List/Map — resolves
1187        // to the same codec-suffix key; `codec_suffix` already builds exactly
1188        // that string for each of these shapes.
1189        TypeRef::App { .. }
1190        | TypeRef::Result(..)
1191        | TypeRef::Option(..)
1192        | TypeRef::List(..)
1193        | TypeRef::Map(..) => WireRef::Inst {
1194            key: codec_suffix(t),
1195        },
1196        TypeRef::Effect(..) => WireRef::Unchecked {
1197            reason: UncheckedReason::Effect,
1198        },
1199        TypeRef::HttpResult(..) => WireRef::Unchecked {
1200            reason: UncheckedReason::HttpResult,
1201        },
1202        TypeRef::ValidationError(_) => WireRef::Unchecked {
1203            reason: UncheckedReason::ValidationError,
1204        },
1205        TypeRef::JsonError(_) => WireRef::Unchecked {
1206            reason: UncheckedReason::JsonError,
1207        },
1208        TypeRef::QueueResult(_) => WireRef::Unchecked {
1209            reason: UncheckedReason::QueueResult,
1210        },
1211        // #1883, slice 1a: never reached through a well-checked program — an
1212        // `Asset` at a boundary is `bynk.types.asset_at_boundary` — but the
1213        // model still needs a total answer.
1214        TypeRef::Asset(_)
1215        | TypeRef::RequestHeaders(_)
1216        | TypeRef::TokenClaims(_)
1217        | TypeRef::BoundaryRejection(_) => WireRef::Unchecked {
1218            reason: UncheckedReason::Asset,
1219        },
1220        TypeRef::Unit(_) => WireRef::Unit,
1221    }
1222}
1223
1224fn wire_fields(fields: &[RecordField], types: &HashMap<String, Arc<TypeDecl>>) -> Vec<WireField> {
1225    fields
1226        .iter()
1227        .map(|f| WireField {
1228            name: f.name.name.clone(),
1229            shape: wire_ref(&f.type_ref, types),
1230            path_segment: f.name.name.clone(),
1231            default: f.init.as_ref().map(|e| (e.clone(), f.type_ref.clone())),
1232            predicates: field_predicates(f),
1233        })
1234        .collect()
1235}
1236
1237/// #1969: a record field's inline refinement predicates, in declaration order
1238/// (`qty: Int where Positive` → `[Positive]`). The decoder checks them after
1239/// the field's own codec, so a refined field reads the same from the wire as a
1240/// field of a named refined type.
1241pub fn field_predicates(f: &RecordField) -> Vec<PredKind> {
1242    f.refinement
1243        .as_ref()
1244        .map(|r| r.predicates.iter().map(|p| p.kind.clone()).collect())
1245        .unwrap_or_default()
1246}
1247
1248fn wire_sum(body: &SumBody, types: &HashMap<String, Arc<TypeDecl>>) -> WireSum {
1249    WireSum {
1250        wire_discriminant: WIRE_DISCRIMINANT,
1251        memory_discriminant: "tag",
1252        variants: body
1253            .variants
1254            .iter()
1255            .map(|v| WireVariant {
1256                name: v.name.name.clone(),
1257                payload: v
1258                    .payload
1259                    .iter()
1260                    .map(|f| WireField {
1261                        name: f.name.name.clone(),
1262                        shape: wire_ref(&f.type_ref, types),
1263                        path_segment: f.name.name.clone(),
1264                        // Sum-variant payload fields carry no default in the
1265                        // surface grammar (`VariantField` has no `init`).
1266                        default: None,
1267                        predicates: Vec::new(),
1268                    })
1269                    .collect(),
1270            })
1271            .collect(),
1272    }
1273}
1274
1275/// Build one [`WireScalar`], choosing [`Revalidation`] per #661 Decisions
1276/// C/D plus the owner path: `Owned` → `ViaConstructor`; consumed + opaque →
1277/// `StructuralOnly`; consumed + transparent → `Inline`; a `Bytes` base →
1278/// `Base64Decode` regardless of provenance, mirroring `emit_refined`'s early
1279/// return to the dedicated `Bytes` codec before the owned/consumed split is
1280/// even considered.
1281fn wire_scalar(
1282    base: BaseType,
1283    opaque: bool,
1284    refinement: Option<&Refinement>,
1285    prov: &Provenance,
1286) -> WireScalar {
1287    let json = json_kind_of(base);
1288    let predicates: Vec<PredKind> = refinement
1289        .map(|r| r.predicates.iter().map(|p| p.kind.clone()).collect())
1290        .unwrap_or_default();
1291    let revalidation = if base == BaseType::Bytes {
1292        Revalidation::Base64Decode
1293    } else {
1294        match prov {
1295            Provenance::Owned => Revalidation::ViaConstructor,
1296            Provenance::Consumed { .. } if opaque => Revalidation::StructuralOnly,
1297            Provenance::Consumed { .. } => Revalidation::Inline,
1298        }
1299    };
1300    WireScalar {
1301        base,
1302        json,
1303        opaque,
1304        predicates,
1305        base_guards: scalar_base_guards(base),
1306        revalidation,
1307    }
1308}
1309
1310/// Build a [`WireType`] from a `TypeDecl`, using the same base/predicate/
1311/// opaque logic currently inline in `bynk-emit`'s `emit_refined` and
1312/// `emit_bytes_named_codec` — without any TS string emission. Returns `None`
1313/// for a **generic** declaration (`decl.type_params` non-empty): a generic
1314/// record/sum has no single bare `WireType` of its own, only per-instantiation
1315/// shapes (`WireInst`), mirroring the skip in `emit_helpers_for_owner_qualified`.
1316pub fn wire_type(
1317    name: &str,
1318    decl: &TypeDecl,
1319    types: &HashMap<String, Arc<TypeDecl>>,
1320    prov: Provenance,
1321) -> Option<WireType> {
1322    if !decl.type_params.is_empty() {
1323        return None;
1324    }
1325    let body = match &decl.body {
1326        TypeBody::Refined {
1327            base, refinement, ..
1328        } => WireBody::Scalar(wire_scalar(*base, false, refinement.as_ref(), &prov)),
1329        TypeBody::Opaque {
1330            base, refinement, ..
1331        } => WireBody::Scalar(wire_scalar(*base, true, refinement.as_ref(), &prov)),
1332        TypeBody::Record(r) => WireBody::Record {
1333            fields: wire_fields(&r.fields, types),
1334        },
1335        TypeBody::Sum(s) => WireBody::Sum(wire_sum(s, types)),
1336    };
1337    Some(WireType {
1338        name: name.to_string(),
1339        codec_suffix: name.to_string(),
1340        provenance: prov,
1341        body,
1342    })
1343}
1344
1345/// Build the full [`WireModel`] for a boundary: every non-generic named type
1346/// in `type_names` resolved through `types`, plus the generic instantiations
1347/// the caller already collected (`collect_generic_instantiations` /
1348/// `collect_codec_closure`). `provenance` is supplied by the caller —
1349/// `bynk-emit` knows the build target and the consumed set; this crate must
1350/// not (see the [`Provenance`] doc).
1351pub fn boundary_model(
1352    type_names: &[String],
1353    types: &HashMap<String, Arc<TypeDecl>>,
1354    instantiations: Vec<WireInst>,
1355    provenance: impl Fn(&str) -> Provenance,
1356) -> WireModel {
1357    let recursive: BTreeSet<String> = recursive_generic_names(types).into_iter().collect();
1358    let mut wtypes = Vec::with_capacity(type_names.len());
1359    for name in type_names {
1360        let Some(decl) = types.get(name) else {
1361            continue;
1362        };
1363        if let Some(wt) = wire_type(name, decl, types, provenance(name)) {
1364            wtypes.push(wt);
1365        }
1366    }
1367    WireModel {
1368        types: wtypes,
1369        instantiations,
1370        recursive,
1371    }
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376    use super::*;
1377
1378    fn types_of(src: &str) -> HashMap<String, Arc<TypeDecl>> {
1379        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
1380        let commons = bynk_syntax::parser::parse(&tokens, src).expect("parse");
1381        commons
1382            .items
1383            .iter()
1384            .filter_map(|i| match i {
1385                CommonsItem::Type(t) => Some((t.name.name.clone(), Arc::new(t.clone()))),
1386                _ => None,
1387            })
1388            .collect()
1389    }
1390
1391    /// Parse a `context` unit and split it into its `types` and `services`
1392    /// tables, mirroring `bynk-emit`'s own boundary-collection call sites.
1393    fn context_of(src: &str) -> (HashMap<String, Arc<TypeDecl>>, HashMap<String, ServiceDecl>) {
1394        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
1395        let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
1396        let SourceUnit::Context(ctx) = unit else {
1397            panic!("expected a context unit");
1398        };
1399        let mut types = HashMap::new();
1400        let mut services = HashMap::new();
1401        for item in &ctx.items {
1402            match item {
1403                CommonsItem::Type(t) => {
1404                    types.insert(t.name.name.clone(), Arc::new(t.clone()));
1405                }
1406                CommonsItem::Service(s) => {
1407                    services.insert(s.name.name.clone(), s.clone());
1408                }
1409                _ => {}
1410            }
1411        }
1412        (types, services)
1413    }
1414
1415    #[test]
1416    fn json_kind_of_matches_the_ts_base_for_serialisation_mapping() {
1417        assert_eq!(json_kind_of(BaseType::Int), JsonKind::Number);
1418        assert_eq!(json_kind_of(BaseType::Float), JsonKind::Number);
1419        assert_eq!(json_kind_of(BaseType::Duration), JsonKind::Number);
1420        assert_eq!(json_kind_of(BaseType::Instant), JsonKind::Number);
1421        assert_eq!(json_kind_of(BaseType::String), JsonKind::String);
1422        // v0.110 (ADR 0142 D5): a `Bytes` wires as a base64 JSON string.
1423        assert_eq!(json_kind_of(BaseType::Bytes), JsonKind::String);
1424        assert_eq!(json_kind_of(BaseType::Bool), JsonKind::Boolean);
1425    }
1426
1427    #[test]
1428    fn collect_boundary_types_walks_params_return_and_record_fields() {
1429        let src = r#"
1430context test
1431
1432type ClientId = String where NonEmpty
1433type Rate = { count: Int, limit: Int }
1434
1435service Api {
1436  on call(client: ClientId) -> Effect[Rate] {
1437    client
1438  }
1439}
1440"#;
1441        let (types, services) = context_of(src);
1442        let agents: HashMap<String, AgentDecl> = HashMap::new();
1443        let names = collect_boundary_types(&types, &services, &agents, &[]);
1444        assert_eq!(names, vec!["ClientId".to_string(), "Rate".to_string()]);
1445    }
1446
1447    #[test]
1448    fn collect_boundary_types_ignores_types_unreachable_from_any_handler() {
1449        let src = r#"
1450context test
1451
1452type Used = String where NonEmpty
1453type Unused = { n: Int }
1454
1455service Api {
1456  on call(x: Used) -> Effect[Used] {
1457    x
1458  }
1459}
1460"#;
1461        let (types, services) = context_of(src);
1462        let agents: HashMap<String, AgentDecl> = HashMap::new();
1463        let names = collect_boundary_types(&types, &services, &agents, &[]);
1464        assert_eq!(names, vec!["Used".to_string()]);
1465    }
1466
1467    /// The highest-risk drift trap the plan flags: `contract.rs` sorts
1468    /// predicates (a precondition for hash correctness), but this IR must
1469    /// carry **declaration order** — `Inline` revalidation emits one `if`
1470    /// per predicate in source order. `NonEmpty` before `MaxLength` sorts
1471    /// (alphabetically, as `contract.rs`'s `canon_refinement` would — folding
1472    /// `NonEmpty` to `MinLength(1)` there does not change this: `MaxLength` <
1473    /// `MinLength` alphabetically, same relative order as `MaxLength` <
1474    /// `NonEmpty` before the fold) to `MaxLength` first, so this fixture's
1475    /// declaration order visibly differs from the sorted order — a positive
1476    /// assertion that the trap stays closed.
1477    #[test]
1478    fn wire_scalar_predicates_preserve_declaration_order_not_sorted() {
1479        let types = types_of("commons x\n\ntype Id = String where NonEmpty && MaxLength(20)\n");
1480        let decl = &types["Id"];
1481        let wt = wire_type("Id", decl, &types, Provenance::Owned).expect("scalar wire type");
1482        let WireBody::Scalar(scalar) = &wt.body else {
1483            panic!("expected a scalar body, got {:?}", wt.body);
1484        };
1485        assert_eq!(scalar.predicates.len(), 2);
1486        assert!(
1487            matches!(scalar.predicates[0], PredKind::NonEmpty),
1488            "predicates[0] must be the declared-first NonEmpty, got {:?}",
1489            scalar.predicates[0]
1490        );
1491        assert!(
1492            matches!(scalar.predicates[1], PredKind::MaxLength(20)),
1493            "predicates[1] must be the declared-second MaxLength(20), got {:?}",
1494            scalar.predicates[1]
1495        );
1496        // The sorted (contract.rs-style) order would put MaxLength first —
1497        // assert this IR does not match that order.
1498        assert!(
1499            !matches!(scalar.predicates[0], PredKind::MaxLength(_)),
1500            "predicates must be in declaration order, not sorted"
1501        );
1502    }
1503
1504    #[test]
1505    fn wire_type_selects_revalidation_by_provenance_and_opacity() {
1506        let types = types_of(
1507            "commons x\n\ntype Refined = String where NonEmpty\ntype Opaque = opaque String where NonEmpty\ntype Raw = Bytes\n",
1508        );
1509
1510        let refined = wire_type("Refined", &types["Refined"], &types, Provenance::Owned)
1511            .expect("owned scalar");
1512        let WireBody::Scalar(s) = &refined.body else {
1513            panic!("expected scalar")
1514        };
1515        assert_eq!(s.revalidation, Revalidation::ViaConstructor);
1516
1517        let consumed = Provenance::Consumed {
1518            owner_unit: "other".to_string(),
1519        };
1520        let refined_consumed =
1521            wire_type("Refined", &types["Refined"], &types, consumed.clone()).expect("consumed");
1522        let WireBody::Scalar(s) = &refined_consumed.body else {
1523            panic!("expected scalar")
1524        };
1525        assert_eq!(s.revalidation, Revalidation::Inline);
1526
1527        let opaque_consumed =
1528            wire_type("Opaque", &types["Opaque"], &types, consumed.clone()).expect("consumed");
1529        let WireBody::Scalar(s) = &opaque_consumed.body else {
1530            panic!("expected scalar")
1531        };
1532        assert_eq!(s.revalidation, Revalidation::StructuralOnly);
1533
1534        // Bytes: Base64Decode regardless of provenance.
1535        let raw_owned =
1536            wire_type("Raw", &types["Raw"], &types, Provenance::Owned).expect("owned bytes");
1537        let WireBody::Scalar(s) = &raw_owned.body else {
1538            panic!("expected scalar")
1539        };
1540        assert_eq!(s.revalidation, Revalidation::Base64Decode);
1541        let raw_consumed = wire_type("Raw", &types["Raw"], &types, consumed).expect("consumed");
1542        let WireBody::Scalar(s) = &raw_consumed.body else {
1543            panic!("expected scalar")
1544        };
1545        assert_eq!(s.revalidation, Revalidation::Base64Decode);
1546    }
1547
1548    #[test]
1549    fn wire_type_returns_none_for_a_generic_declaration() {
1550        let types = types_of("commons x\n\ntype Page[T] = { items: List[T] }\n");
1551        assert!(wire_type("Page", &types["Page"], &types, Provenance::Owned).is_none());
1552    }
1553
1554    /// Part 1.4's cross-check: for an `on call` handler, `wire.rs`'s codec
1555    /// walk (`collect_boundary_types`, restricted to that one handler) and
1556    /// `contract.rs`'s canonical hash form (`service_normal_form`, over the
1557    /// same handler projected as a `CrossContextService`) must reach the
1558    /// exact same *set* of named boundary types — even though the module
1559    /// doc's comparison table says the two disagree on purpose about
1560    /// *ordering* (declaration order vs sorted) and about whether an opaque
1561    /// predicate is shown. This test asserts REACHABILITY only: the set of
1562    /// type names each derivation's walk visits from the same root, not any
1563    /// string equality or order between the two renderings.
1564    ///
1565    /// The type names in the fixture are chosen so none is a substring of
1566    /// another (`ClientId`, `Address`, `Profile`, `Unrelated`) —
1567    /// `contract.rs`'s `canon_type`/`canon_named_in` are private to that
1568    /// module, so rather than duplicating their traversal here, this test
1569    /// recovers the set of types `service_normal_form`'s rendered string
1570    /// reaches by substring containment against every declared type name.
1571    /// With non-overlapping names that containment test is unambiguous.
1572    ///
1573    /// `Unrelated` is declared but reachable from no handler, so the
1574    /// equality assertion below is not vacuously true merely because this
1575    /// fixture happens to make every *other* declared type reachable — a
1576    /// regression that made `collect_boundary_types` return every declared
1577    /// name instead of the reachable closure would leak `Unrelated` into
1578    /// `boundary_names`, and this test would catch it.
1579    #[test]
1580    fn boundary_reachability_agrees_with_contract_normal_form() {
1581        let src = r#"
1582context test
1583
1584type ClientId = String where NonEmpty
1585type Address = { street: String, city: String }
1586type Profile = { id: ClientId, home: Address }
1587
1588// Declared, but reachable from no handler below (see the doc comment on
1589// this test for why).
1590type Unrelated = { n: Int }
1591
1592service Api {
1593  on call(client: ClientId) -> Effect[Profile] {
1594    Profile { id: client, home: Address { street: "x", city: "y" } }
1595  }
1596}
1597"#;
1598        let (types, services) = context_of(src);
1599        let service = &services["Api"];
1600        let handler = &service.handlers[0];
1601
1602        // Restrict `collect_boundary_types`'s walk to exactly this handler —
1603        // the same synthetic-single-handler-service narrowing
1604        // `bynk-ide`'s `wire_contract_for_service` performs, so a service
1605        // with more than one handler could not leak an unrelated handler's
1606        // types into either side of this comparison.
1607        let mut narrowed = service.clone();
1608        narrowed.handlers = vec![handler.clone()];
1609        let narrowed_services: HashMap<String, ServiceDecl> =
1610            HashMap::from([("Api".to_string(), narrowed)]);
1611        let agents: HashMap<String, AgentDecl> = HashMap::new();
1612        let boundary_names: std::collections::HashSet<String> =
1613            collect_boundary_types(&types, &narrowed_services, &agents, &[])
1614                .into_iter()
1615                .collect();
1616
1617        // The same handler, projected as a `CrossContextService` exactly as
1618        // `bynk-emit`'s `own_contract_hashes` / `bynk-ide`'s
1619        // `wire_contract::contract_form` project it, canonicalised through
1620        // the same type table.
1621        let svc = crate::resolver::CrossContextService {
1622            name: "Api".to_string(),
1623            params: handler
1624                .params
1625                .iter()
1626                .map(|p| (p.name.name.clone(), p.type_ref.clone()))
1627                .collect(),
1628            return_type: handler.return_type.clone(),
1629            span: handler.span,
1630            foreign_callers: Vec::new(),
1631        };
1632        let normal_form = crate::contract::service_normal_form(&svc, &types);
1633
1634        let contract_reachable: std::collections::HashSet<String> = types
1635            .keys()
1636            .filter(|name| normal_form.contains(name.as_str()))
1637            .cloned()
1638            .collect();
1639
1640        assert_eq!(
1641            boundary_names, contract_reachable,
1642            "wire::collect_boundary_types and contract::service_normal_form must \
1643             agree on *which* named types a handler's contract reaches, even though \
1644             they render/order that set completely differently (see this module's \
1645             doc comparison table):\n\
1646             wire.rs reached:     {boundary_names:?}\n\
1647             contract.rs reached: {contract_reachable:?}\n\
1648             normal form:         {normal_form:?}"
1649        );
1650        assert!(
1651            !boundary_names.contains("Unrelated") && !contract_reachable.contains("Unrelated"),
1652            "`Unrelated` is declared but reachable from no handler — both derivations \
1653             must exclude it, not merely agree on every declared type by coincidence:\n\
1654             wire.rs reached: {boundary_names:?}\n\
1655             contract.rs reached: {contract_reachable:?}"
1656        );
1657    }
1658
1659    /// `wire_ref` is the plan's flagged highest-risk function ("its two
1660    /// functions must stay in exact agreement about which `WireRef` arm a
1661    /// `TypeRef` resolves to"). Pin the arm each shape lands on before Phase 2
1662    /// starts consuming it.
1663    #[test]
1664    fn wire_ref_dispatches_every_type_ref_shape_to_the_expected_arm() {
1665        let types: HashMap<String, Arc<TypeDecl>> = HashMap::new();
1666        let sp = || bynk_syntax::span::Span::new(0, 0);
1667        let named = |n: &str| {
1668            TypeRef::Named(Ident {
1669                name: n.to_string(),
1670                span: sp(),
1671            })
1672        };
1673        let base = |b: BaseType| TypeRef::Base(b, sp());
1674
1675        assert!(matches!(
1676            wire_ref(&base(BaseType::Int), &types),
1677            WireRef::Base {
1678                base: BaseType::Int,
1679                json: JsonKind::Number,
1680                ..
1681            }
1682        ));
1683        assert!(matches!(
1684            wire_ref(&base(BaseType::Bytes), &types),
1685            WireRef::Bytes
1686        ));
1687        assert!(
1688            matches!(wire_ref(&named("ClientId"), &types), WireRef::Named { name } if name == "ClientId")
1689        );
1690        assert!(matches!(
1691            wire_ref(
1692                &TypeRef::Result(Box::new(base(BaseType::Int)), Box::new(base(BaseType::String)), sp()),
1693                &types
1694            ),
1695            WireRef::Inst { key } if key == "Result_Int_String"
1696        ));
1697        assert!(matches!(
1698            wire_ref(&TypeRef::Option(Box::new(base(BaseType::Int)), sp()), &types),
1699            WireRef::Inst { key } if key == "Option_Int"
1700        ));
1701        assert!(matches!(
1702            wire_ref(&TypeRef::List(Box::new(base(BaseType::Int)), sp()), &types),
1703            WireRef::Inst { key } if key == "List_Int"
1704        ));
1705        assert!(matches!(
1706            wire_ref(
1707                &TypeRef::Map(Box::new(base(BaseType::String)), Box::new(base(BaseType::Int)), sp()),
1708                &types
1709            ),
1710            WireRef::Inst { key } if key == "Map_String_Int"
1711        ));
1712        assert!(matches!(
1713            wire_ref(
1714                &TypeRef::App {
1715                    name: Ident { name: "Paginated".to_string(), span: sp() },
1716                    args: vec![named("User")],
1717                    span: sp(),
1718                },
1719                &types
1720            ),
1721            WireRef::Inst { key } if key == "Paginated_User"
1722        ));
1723        assert!(matches!(
1724            wire_ref(&TypeRef::Unit(sp()), &types),
1725            WireRef::Unit
1726        ));
1727        assert!(matches!(
1728            wire_ref(
1729                &TypeRef::Effect(Box::new(base(BaseType::Int)), sp()),
1730                &types
1731            ),
1732            WireRef::Unchecked {
1733                reason: UncheckedReason::Effect
1734            }
1735        ));
1736        assert!(matches!(
1737            wire_ref(
1738                &TypeRef::HttpResult(Box::new(base(BaseType::Int)), sp()),
1739                &types
1740            ),
1741            WireRef::Unchecked {
1742                reason: UncheckedReason::HttpResult
1743            }
1744        ));
1745        assert!(matches!(
1746            wire_ref(&TypeRef::ValidationError(sp()), &types),
1747            WireRef::Unchecked {
1748                reason: UncheckedReason::ValidationError
1749            }
1750        ));
1751        assert!(matches!(
1752            wire_ref(&TypeRef::JsonError(sp()), &types),
1753            WireRef::Unchecked {
1754                reason: UncheckedReason::JsonError
1755            }
1756        ));
1757        assert!(matches!(
1758            wire_ref(&TypeRef::QueueResult(sp()), &types),
1759            WireRef::Unchecked {
1760                reason: UncheckedReason::QueueResult
1761            }
1762        ));
1763    }
1764}