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