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