Skip to main content

bynk_check/
contract.rs

1//! v0.177 (#643): the canonical normal form of a cross-context contract, and
2//! its hash.
3//!
4//! A `workers` build compiles context A against context B's contract, and
5//! nothing at runtime checks that the *deployed* B still matches what A was
6//! compiled against — `deploy --context NAME` institutionalises the skew. The
7//! fix is to stamp a hash of the compiled contract beside `X-Bynk-Caller` and
8//! fail closed on mismatch (ADR 0092's pattern: a compile-time constant in a
9//! reserved header, metadata beside the payload, no crypto).
10//!
11//! The hash is only as good as the form it hashes. Two rules make it usable:
12//!
13//! 1. **Semantically-equal contracts must hash equal**, or a working deployment
14//!    409s spuriously — which is worse than no check at all, because it breaks
15//!    what worked and destroys trust in the mechanism. This is why the form is
16//!    canonical (predicates as a sorted set, record fields sorted by name)
17//!    rather than a rendering of source order.
18//! 2. **Both sides must canonicalise the *same* thing.** The callee's contract
19//!    is canonicalised **in the callee's own namespace**, from the callee's own
20//!    type table, on *both* sides — never in the caller's. The caller reaches
21//!    that table through `consumed_types[callee]` and the callee through its own
22//!    combined table; both are produced by the same `combined_types_for`, so the
23//!    two views cannot diverge by construction. A caller never canonicalises a
24//!    consumed type in its own namespace, where its rebranding would make the
25//!    same type render differently.
26
27use std::collections::{BTreeMap, HashMap};
28use std::fmt::Write as _;
29use std::sync::Arc;
30
31use bynk_syntax::ast::{Ident, PredKind, Refinement, TypeBody, TypeDecl, TypeRef};
32use bynk_syntax::span::Span;
33
34use crate::resolver::{CrossContextService, cross_context_service_for};
35use crate::symbols::UnitTable;
36use crate::unit_signature::{
37    CapabilityOpSignature, FnSignature, HandlerKindSignature, HandlerSignature,
38    MethodTableSignature, ParamSignature, ProtocolSignature, StoreFieldSignature, UnitSignature,
39};
40
41/// The canonical normal form of one `on call` service contract.
42///
43/// Shape: `<service>(<param>: <type>, …) -> <type>`. Parameter **names** and
44/// **order** are both included, and both are load-bearing rather than cosmetic:
45/// a multi-argument call sends an object keyed by parameter name, and a
46/// single-argument call sends the bare value — so a rename or a reorder is a
47/// genuine wire change, not a refactor.
48pub fn service_normal_form(
49    svc: &CrossContextService,
50    types: &HashMap<String, Arc<TypeDecl>>,
51) -> String {
52    let mut out = String::new();
53    let _ = write!(out, "{}(", svc.name);
54    for (i, (pname, pty)) in svc.params.iter().enumerate() {
55        if i > 0 {
56            out.push_str(", ");
57        }
58        let _ = write!(out, "{pname}: {}", canon_type(pty, types, &mut Vec::new()));
59    }
60    let _ = write!(
61        out,
62        ") -> {}",
63        canon_type(&svc.return_type, types, &mut Vec::new())
64    );
65    out
66}
67
68/// The canonical form of a type *as it appears on the wire*.
69///
70/// A named type expands **structurally**, not by name alone: the wire carries
71/// the fields, so renaming a record field or changing a variant's payload is a
72/// contract change that a name-only form would miss entirely. The name is kept
73/// alongside the structure because Bynk's types are nominal — swapping
74/// `AuthId` for a structurally identical `SessionId` changes the contract even
75/// though the bytes are unchanged. Keeping the name costs nothing in false
76/// positives: a rename already breaks the consumer's *compile*, so it cannot
77/// reach a deploy without the consumer being rebuilt too.
78fn canon_type(
79    t: &TypeRef,
80    types: &HashMap<String, Arc<TypeDecl>>,
81    seen: &mut Vec<String>,
82) -> String {
83    canon_type_in(t, types, seen, &HashMap::new(), Mode::Contract)
84}
85
86/// `subst` binds a generic declaration's type-parameter **names** to the
87/// canonical form of the concrete argument supplied at the use site.
88///
89/// A generic body MUST expand with its parameters substituted, or the
90/// parameter's *name* leaks into the form: `type Page[T] = { items: List[T] }`
91/// and the same declaration spelled with `U` are the same type with the same
92/// wire shape, but would hash differently. Across a deploy that renames a type
93/// parameter — a pure refactor with no wire consequence — every call would 409.
94/// That is the same class of spurious failure the sorted fields and the
95/// predicate set exist to prevent, and it is the one the module's own standard
96/// ("semantically-equal contracts must hash equal") forbids.
97fn canon_type_in(
98    t: &TypeRef,
99    types: &HashMap<String, Arc<TypeDecl>>,
100    seen: &mut Vec<String>,
101    subst: &HashMap<String, String>,
102    mode: Mode,
103) -> String {
104    match t {
105        TypeRef::Base(b, _) => b.name().to_string(),
106        TypeRef::Unit(_) => "()".to_string(),
107        // An `Effect` wraps the handler, not the payload — the caller awaits the
108        // promise, so it is not part of the wire contract.
109        TypeRef::Effect(inner, _) => canon_type_in(inner, types, seen, subst, mode),
110        TypeRef::List(a, _) => format!("List[{}]", canon_type_in(a, types, seen, subst, mode)),
111        TypeRef::Option(a, _) => format!("Option[{}]", canon_type_in(a, types, seen, subst, mode)),
112        TypeRef::Result(a, b, _) => format!(
113            "Result[{}, {}]",
114            canon_type_in(a, types, seen, subst, mode),
115            canon_type_in(b, types, seen, subst, mode)
116        ),
117        TypeRef::Map(k, v, _) => format!(
118            "Map[{}, {}]",
119            canon_type_in(k, types, seen, subst, mode),
120            canon_type_in(v, types, seen, subst, mode)
121        ),
122        // Generic-record instantiation: the arguments are positional, so their
123        // order *is* semantic and is preserved (unlike a record's fields).
124        TypeRef::App { name, args, .. } => {
125            let inner: Vec<String> = args
126                .iter()
127                .map(|a| canon_type_in(a, types, seen, subst, mode))
128                .collect();
129            // Bind the declaration's parameters to these arguments so the body
130            // expands over concrete types and the parameter's name never reaches
131            // the form.
132            let bound: HashMap<String, String> = types
133                .get(&name.name)
134                .map(|d| {
135                    d.type_params
136                        .iter()
137                        .zip(&inner)
138                        .map(|(p, a)| (p.name.name.clone(), a.clone()))
139                        .collect()
140                })
141                .unwrap_or_default();
142            let head = canon_named_in(&name.name, types, seen, &bound, mode);
143            format!("{head}[{}]", inner.join(", "))
144        }
145        TypeRef::Named(id) => {
146            // A bound type parameter renders as the argument it stands for.
147            match subst.get(&id.name) {
148                Some(bound) => bound.clone(),
149                None => canon_named_in(&id.name, types, seen, subst, mode),
150            }
151        }
152        TypeRef::HttpResult(a, _) => {
153            format!("HttpResult[{}]", canon_type_in(a, types, seen, subst, mode))
154        }
155        TypeRef::ValidationError(_) => "ValidationError".to_string(),
156        TypeRef::JsonError(_) => "JsonError".to_string(),
157        TypeRef::QueueResult(_) => "QueueResult".to_string(),
158        TypeRef::Asset(_) => "Asset".to_string(),
159        TypeRef::RequestHeaders(_) => "RequestHeaders".to_string(),
160        TypeRef::TokenClaims(_) => "TokenClaims".to_string(),
161        TypeRef::BoundaryRejection(_) => "BoundaryRejection".to_string(),
162        // The confined family is rejected at every boundary, so it cannot appear
163        // in a contract. Render it rather than panic: the normal form is also a
164        // diagnostic surface, and a compiler bug should not become a crash here.
165        TypeRef::Fn(..)
166        | TypeRef::Query(..)
167        | TypeRef::Stream(..)
168        | TypeRef::Connection(..)
169        | TypeRef::History(..) => "<non-boundary>".to_string(),
170    }
171}
172
173fn canon_named_in(
174    name: &str,
175    types: &HashMap<String, Arc<TypeDecl>>,
176    seen: &mut Vec<String>,
177    subst: &HashMap<String, String>,
178    mode: Mode,
179) -> String {
180    // A recursive record terminates on the data, so its codec is finite and it
181    // is a legal contract — but its *expansion* is not. Emit a back-reference on
182    // revisit. `type Node = { next: Option[Node] }` canonicalises as
183    // `Node{next: Option[@Node]}` — the cycle is named, so two different
184    // recursive shapes still differ.
185    //
186    // The storage renderings carry no type names (#2204), so their
187    // back-reference is positional instead: `@N` points N enclosing named types
188    // out (de Bruijn style). `Node` and a renamed `Link` then both render
189    // `{next: Option[@1], v: Int}`, while two different cycles still differ.
190    // `seen` is the stack of named types being expanded, innermost last.
191    if let Some(at) = seen.iter().position(|n| n == name) {
192        return match mode {
193            Mode::Contract => format!("@{name}"),
194            Mode::StorageStructure | Mode::StorageFull => format!("@{}", seen.len() - at),
195        };
196    }
197    seen.push(name.to_string());
198    let Some(decl) = types.get(name) else {
199        // Not in the callee's table: a runtime- or compiler-known name with no
200        // declaration to expand. The name alone is the whole contract for it.
201        seen.pop();
202        return name.to_string();
203    };
204    let body = match &decl.body {
205        // Record fields sort by name: a JSON object is unordered, so field
206        // *order* is not wire-observable and must not perturb the hash — while
207        // field *presence* and type are exactly what the hash exists to pin.
208        //
209        // #1969: an inline refinement is part of the field's type, so it is
210        // part of the form (`qty: Int where Positive`), canonicalised as a
211        // sorted set exactly like a named refined type's. The decoder checks
212        // it, so a callee that tightens it rejects values its callers send.
213        TypeBody::Record(r) => {
214            let mut fields: Vec<String> = r
215                .fields
216                .iter()
217                .map(|f| {
218                    let ty = canon_type_in(&f.type_ref, types, seen, subst, mode);
219                    match f.refinement.as_ref().filter(|_| mode.keeps_predicates()) {
220                        Some(r) => format!("{}: {ty} {}", f.name.name, canon_refinement(Some(r))),
221                        None => format!("{}: {ty}", f.name.name),
222                    }
223                })
224                .collect();
225            fields.sort();
226            format!("{{{}}}", fields.join(", "))
227        }
228        // Variants sort by name for the same reason: the wire carries a `kind`
229        // discriminant, so declaration order is invisible to it.
230        TypeBody::Sum(s) => {
231            let mut variants: Vec<String> = s
232                .variants
233                .iter()
234                .map(|v| {
235                    let mut payload: Vec<String> = v
236                        .payload
237                        .iter()
238                        .map(|p| {
239                            let ty = canon_type_in(&p.type_ref, types, seen, subst, mode);
240                            match mode {
241                                Mode::Contract => ty,
242                                // Stored payloads are keyed by field name
243                                // (`payload_prop`: "the wire key stays the
244                                // field's own name"), so in storage a payload
245                                // is a record: named, and unordered (#2204
246                                // review).
247                                Mode::StorageStructure | Mode::StorageFull => {
248                                    format!("{}: {ty}", p.name.name)
249                                }
250                            }
251                        })
252                        .collect();
253                    if mode != Mode::Contract {
254                        payload.sort();
255                    }
256                    if payload.is_empty() {
257                        v.name.name.clone()
258                    } else {
259                        format!("{}({})", v.name.name, payload.join(", "))
260                    }
261                })
262                .collect();
263            variants.sort();
264            format!("|{}", variants.join("|"))
265        }
266        TypeBody::Refined {
267            base, refinement, ..
268        } => {
269            if mode.keeps_predicates() {
270                format!("{} {}", base.name(), canon_refinement(refinement.as_ref()))
271            } else {
272                base.name().to_string()
273            }
274        }
275        // In the **contract** form, an opaque type's predicate is deliberately
276        // excluded — only its representation is part of the contract.
277        //
278        // The consumer cannot see the predicate by construction (that is what
279        // `exports opaque` means), so no consumer behaviour can depend on it: it
280        // can hold and pass an `AuthId`, never inspect or mint one. Including the
281        // predicate would therefore manufacture skew failures between two
282        // contexts that cannot disagree — the owner tightening `Matches(...)`
283        // would 409 every caller for a change none of them can observe. This is
284        // the same position ADR 0199 took on opacity, from the same premise.
285        //
286        // Stored state is the other way round (state-migrations S0, #2204): the
287        // owner re-validates its own opaque values on rehydration, so a
288        // tightened opaque predicate *is* observable there, and the full
289        // storage rendering keeps it. The stored bytes are just the base, so
290        // neither storage rendering marks opacity.
291        TypeBody::Opaque {
292            base, refinement, ..
293        } => match mode {
294            Mode::Contract => format!("{} opaque", base.name()),
295            Mode::StorageStructure => base.name().to_string(),
296            Mode::StorageFull => {
297                format!("{} {}", base.name(), canon_refinement(refinement.as_ref()))
298            }
299        },
300    };
301    seen.pop();
302    match mode {
303        Mode::Contract => format!("{name}{body}"),
304        // Stored bytes carry no type names (records are keyed by field, sums
305        // tagged by variant), so renaming a type must not read as a retyped
306        // field. Recursion is anchored positionally (`@N` above), not by name.
307        Mode::StorageStructure | Mode::StorageFull => body,
308    }
309}
310
311/// Which canonical form a walk renders.
312#[derive(Clone, Copy, PartialEq, Eq, Debug)]
313enum Mode {
314    /// The cross-context contract form: nominal, refinements kept, opaque
315    /// predicates elided. Backs [`contract_hash`].
316    Contract,
317    /// A stored field's structure (state-migrations S0): structural, every
318    /// predicate stripped. A change here changes what is on disk.
319    StorageStructure,
320    /// A stored field's structure *and* refinements, opaque ones included. A
321    /// change here alone leaves the stored bytes readable.
322    StorageFull,
323}
324
325impl Mode {
326    fn keeps_predicates(self) -> bool {
327        !matches!(self, Mode::StorageStructure)
328    }
329}
330
331/// State-migrations S0 (#2204, track §3.1): the two canonical renderings of a
332/// stored type that `bynk.schema.lock`'s `[agents]` table records — the
333/// structure (`.0`, predicates stripped) and the full form (`.1`, predicates
334/// kept, opaque ones included). Both expand named records and sums
335/// structurally and sort fields and variants by name, as the contract form
336/// does. Unlike it, they carry no type names, because stored bytes carry none:
337/// recursion closes with a positional `@N` back-reference instead of `@Name`,
338/// and a sum payload renders like a record (`name: type`, sorted), because a
339/// stored payload is keyed by field name.
340pub fn canon_storage_type(t: &TypeRef, types: &HashMap<String, Arc<TypeDecl>>) -> (String, String) {
341    let render = |mode| canon_type_in(t, types, &mut Vec::new(), &HashMap::new(), mode);
342    (render(Mode::StorageStructure), render(Mode::StorageFull))
343}
344
345/// Predicates canonicalise as a **sorted set**.
346///
347/// This is not a nicety adjacent to the hash; it is a precondition for it.
348/// Predicates are conjunctive and side-effect-free, so `String where NonEmpty,
349/// MaxLen(10)` and `String where MaxLen(10), NonEmpty` are the *same type* — and
350/// hashing them in source order would make two contexts that agree perfectly
351/// fail closed against each other. The same normal form also backs the checker's
352/// `refinements_match`, so the matcher and the hash cannot disagree about what
353/// "the same refinement" means.
354pub fn canon_refinement(r: Option<&Refinement>) -> String {
355    let Some(r) = r else {
356        return String::new();
357    };
358    let mut preds: Vec<String> = r
359        .predicates
360        .iter()
361        .map(|p| canon_predicate(&p.kind))
362        .collect();
363    preds.sort();
364    preds.dedup();
365    format!("where {}", preds.join(", "))
366}
367
368pub fn canon_predicate(p: &PredKind) -> String {
369    match p {
370        PredKind::Matches(s) => format!("Matches({s:?})"),
371        // Bounds keep their source lexemes elsewhere (byte-stable emission), but
372        // a contract is about *values*: `1` and `01` are the same bound, so the
373        // parsed value is what canonicalises.
374        PredKind::InRange(a, b) => format!("InRange({}, {})", a.value, b.value),
375        PredKind::InRangeF(a, b) => format!("InRangeF({}, {})", a.value, b.value),
376        PredKind::MinLength(n) => format!("MinLength({n})"),
377        PredKind::MaxLength(n) => format!("MaxLength({n})"),
378        PredKind::Length(n) => format!("Length({n})"),
379        // R12.2 names these as sugar for `InRange(0, ∞)`/`InRange(1, ∞)`, but the
380        // fold stays undone (#1049): neither base has a writable literal bound
381        // that stands for `∞` — the lexer rejects any float literal that would
382        // parse to infinity, and `Int`'s `i64::MAX` is a real, arbitrary finite
383        // bound, not the language's spelling of "unbounded". Folding onto an
384        // invented string nothing else can produce would only rename the
385        // literal, at the cost of a contract-hash change for every boundary
386        // type carrying one. Revisit alongside R12.3 (entailment), which is
387        // the actual consumer of a normalised Interval domain.
388        PredKind::NonNegative => "NonNegative".to_string(),
389        PredKind::Positive => "Positive".to_string(),
390        // `NonEmpty` is sugar for `MinLength(1)` (R12.2) — folding it here makes
391        // `String where NonEmpty` and `String where MinLength(1)` the same
392        // canonical form, so `service_contract_hash` and `refinements_match`
393        // agree that they are the same type. Unconditional: `NonEmpty` only
394        // ever applies to `BaseType::String` (`refinements.rs`'s
395        // `pred_applies_to`), so no base needs threading through here.
396        PredKind::NonEmpty => "MinLength(1)".to_string(),
397    }
398}
399
400/// FNV-1a (64-bit) over the canonical form, rendered as 16 lowercase hex chars.
401///
402/// **Why not a cryptographic hash.** Trust here is static and channel-based, and
403/// this increment does not change that (ADR 0092): `/_bynk/call/` is
404/// platform-dispatched and not externally routable, every context in a
405/// deployment is one trust domain, and a malicious first-party context is out of
406/// the threat model. This is a **skew detector, not a security control** — an
407/// accident detector. Forging it buys an attacker nothing they could not already
408/// do, so `sha2`'s ~6-crate dependency tree would buy nothing either. A
409/// collision degrades to *today's* behaviour for that one pair (an undetected
410/// skew), not to something worse, and at ~1e-14 for a 1000-contract project it
411/// is not the risk worth engineering against.
412///
413/// **Why hand-rolled.** `std::collections::hash_map::DefaultHasher` is
414/// explicitly not stable across Rust releases, so it cannot back a value that
415/// crosses a wire or is compared between two separately-compiled binaries. FNV-1a
416/// is fully specified, so two compilers agree forever.
417pub fn contract_hash(normal_form: &str) -> String {
418    fnv1a64_hex(normal_form.as_bytes())
419}
420
421/// FNV-1a 64-bit offset basis. Published in the wire-format reference (#1969).
422pub const FNV1A64_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
423/// FNV-1a 64-bit prime. Published in the wire-format reference (#1969).
424pub const FNV1A64_PRIME: u64 = 0x0000_0100_0000_01b3;
425
426/// FNV-1a (64-bit) over `bytes`, as 16 lowercase hex chars — [`contract_hash`]'s
427/// algorithm over raw bytes. #1883, slice 1b also uses it for an asset's
428/// content hash (`assets::served_path`), where the same reasoning holds: it
429/// only has to tell one project's own files apart, reproducibly.
430pub fn fnv1a64_hex(bytes: &[u8]) -> String {
431    let mut h = FNV1A64_OFFSET;
432    for b in bytes {
433        h ^= *b as u64;
434        h = h.wrapping_mul(FNV1A64_PRIME);
435    }
436    format!("{h:016x}")
437}
438
439/// The stamped contract hash for one consumed service.
440pub fn service_contract_hash(
441    svc: &CrossContextService,
442    types: &HashMap<String, Arc<TypeDecl>>,
443) -> String {
444    contract_hash(&service_normal_form(svc, types))
445}
446
447/// v0.177 (#643): a context's own `on call` contract hashes, keyed by service
448/// name — the constants its Worker entry compares an incoming
449/// `X-Bynk-Contract` against.
450///
451/// Built by projecting each local `on call` handler into the **same**
452/// [`CrossContextService`] shape [`crate::symbols::build_cross_context_info`]
453/// hands a *caller* for the same service, via the one shared projection
454/// [`cross_context_service_for`], and hashing it from the same combined type
455/// table. That symmetry is the whole correctness argument: a caller and a
456/// callee compiled from one source tree must agree, or the check fires on
457/// every call instead of only on real skew — sharing the projection makes the
458/// agreement structural rather than two hand-written copies staying in sync
459/// by convention.
460pub fn own_contract_hashes(
461    table: &UnitTable,
462    own_types: &HashMap<String, Arc<TypeDecl>>,
463) -> BTreeMap<String, String> {
464    let mut out = BTreeMap::new();
465    for (sname, sdecl) in &table.services {
466        let Some(svc) = cross_context_service_for(sname, sdecl) else {
467            continue;
468        };
469        out.insert(sname.clone(), service_contract_hash(&svc, own_types));
470    }
471    out
472}
473
474/// P8.1 (#1512, [DECISION C]): the canonical form of a body-free parameter
475/// list — shared by [`canon_fn_signature`] and [`canon_handler_signature`] so
476/// the two categories can't drift on how a parameter renders.
477fn canon_params(params: &[ParamSignature], types: &HashMap<String, Arc<TypeDecl>>) -> String {
478    let mut out = String::new();
479    for (i, p) in params.iter().enumerate() {
480        if i > 0 {
481            out.push_str(", ");
482        }
483        let _ = write!(
484            out,
485            "{}: {}",
486            p.name,
487            canon_type(&p.type_ref, types, &mut Vec::new())
488        );
489    }
490    out
491}
492
493/// P8.1 (#1512, [DECISION C]): the canonical form of a [`FnSignature`] —
494/// extends this module's own `canon_type` (ADR 0200) to a shape it doesn't
495/// reach today. Body-free by construction ([DECISION B]): there is no
496/// `body`/`requires`/`ensures` field on `FnSignature` to accidentally
497/// include here.
498pub fn canon_fn_signature(f: &FnSignature, types: &HashMap<String, Arc<TypeDecl>>) -> String {
499    let mut out = String::new();
500    let _ = write!(out, "fn {}", f.name);
501    if !f.type_params.is_empty() {
502        let _ = write!(out, "[{}]", f.type_params.join(", "));
503    }
504    let _ = write!(out, "({})", canon_params(&f.params, types));
505    let _ = write!(
506        out,
507        " -> {}",
508        canon_type(&f.return_type, types, &mut Vec::new())
509    );
510    if f.has_self {
511        out.push_str(" self");
512    }
513    out
514}
515
516/// P8.1 (#1512, [DECISION C]): the canonical form of a [`HandlerKindSignature`].
517/// Added alongside the rest of [`canon_handler_signature`] by PR #1517's own
518/// bot review (finding #1) — every field here is already a plain value
519/// (`HttpMethod`'s own rendered name, a route/cron `String`), so nothing
520/// needs excluding beyond the enum discriminant itself carrying no `Span`.
521fn canon_handler_kind(k: &HandlerKindSignature) -> String {
522    match k {
523        HandlerKindSignature::Call => "call".to_string(),
524        HandlerKindSignature::Http { method, path } => format!("http {method} {path:?}"),
525        HandlerKindSignature::Cron { expr } => format!("cron {expr:?}"),
526        HandlerKindSignature::Message => "message".to_string(),
527        HandlerKindSignature::Open => "open".to_string(),
528        HandlerKindSignature::Close => "close".to_string(),
529        HandlerKindSignature::Event => "event".to_string(),
530        HandlerKindSignature::Rejected => "rejected".to_string(),
531    }
532}
533
534/// P8.1 (#1512, [DECISION C]): the canonical form of a [`HandlerSignature`].
535/// `given` renders sorted — its rendered `CapRef` names have no meaningful
536/// order to preserve (it's a set, not a sequence). `kind` is rendered first
537/// (PR #1517's own bot review, finding #1) — two handlers that differ only
538/// in HTTP method or route, or in `on call` vs. `on message`, must not
539/// canonicalise identically.
540pub fn canon_handler_signature(
541    h: &HandlerSignature,
542    types: &HashMap<String, Arc<TypeDecl>>,
543) -> String {
544    let mut out = String::new();
545    let _ = write!(out, "on {}", canon_handler_kind(&h.kind));
546    if let Some(m) = &h.method_name {
547        let _ = write!(out, " {m}");
548    }
549    let _ = write!(out, "({})", canon_params(&h.params, types));
550    let _ = write!(
551        out,
552        " -> {}",
553        canon_type(&h.return_type, types, &mut Vec::new())
554    );
555    if !h.given.is_empty() {
556        let mut given = h.given.clone();
557        given.sort();
558        let _ = write!(out, " given {}", given.join(", "));
559    }
560    out
561}
562
563/// P8.1 (#1512, [DECISION C]): the canonical form of a [`CapabilityOpSignature`]
564/// — added by PR #1517's own bot review (finding #3): a `capability`
565/// declaration's own ops are the abstract signature a consumer's
566/// `Cap.op(...)` call site compiles against.
567pub fn canon_capability_op_signature(
568    op: &CapabilityOpSignature,
569    types: &HashMap<String, Arc<TypeDecl>>,
570) -> String {
571    let mut out = String::new();
572    let _ = write!(out, "fn {}", op.name);
573    if !op.type_params.is_empty() {
574        let _ = write!(out, "[{}]", op.type_params.join(", "));
575    }
576    let _ = write!(out, "({})", canon_params(&op.params, types));
577    let _ = write!(
578        out,
579        " -> {}",
580        canon_type(&op.return_type, types, &mut Vec::new())
581    );
582    out
583}
584
585/// P8.1 (#1512, [DECISION C]): the canonical form of a [`ProtocolSignature`]
586/// — added by PR #1517's own bot review ("worth a look"): `from http` vs.
587/// `from queue(...)` vs. `from websocket(...)` changes a service's entire
588/// external surface. `Events`' own `pattern`/`schema_dispatch` render only as
589/// presence (see `unit_signature.rs`'s own module doc comment for why).
590fn canon_protocol_signature(
591    p: &ProtocolSignature,
592    types: &HashMap<String, Arc<TypeDecl>>,
593) -> String {
594    match p {
595        ProtocolSignature::Call => "call".to_string(),
596        ProtocolSignature::Http => "http".to_string(),
597        ProtocolSignature::Cron => "cron".to_string(),
598        ProtocolSignature::Queue { name } => format!("queue {name:?}"),
599        ProtocolSignature::WebSocket { in_type, out_type } => format!(
600            "websocket in={} out={}",
601            canon_type(in_type, types, &mut Vec::new()),
602            canon_type(out_type, types, &mut Vec::new())
603        ),
604        ProtocolSignature::Events {
605            event_type,
606            has_pattern,
607            has_schema_dispatch,
608        } => format!(
609            "events {} pattern={has_pattern} schema_dispatch={has_schema_dispatch}",
610            canon_type(event_type, types, &mut Vec::new())
611        ),
612    }
613}
614
615/// P8.1 (#1512, [DECISION C]): the canonical form of a [`StoreFieldSignature`].
616pub fn canon_store_field_signature(
617    f: &StoreFieldSignature,
618    types: &HashMap<String, Arc<TypeDecl>>,
619) -> String {
620    let mut out = String::new();
621    let _ = write!(out, "{}: {}", f.name, f.kind_head);
622    if !f.kind_args.is_empty() {
623        let args: Vec<String> = f
624            .kind_args
625            .iter()
626            .map(|a| canon_type(a, types, &mut Vec::new()))
627            .collect();
628        let _ = write!(out, "[{}]", args.join(", "));
629    }
630    out
631}
632
633/// P8.1 (#1512, [DECISION C]): the canonical form of a unit's own combined
634/// type table — the cross-context-types category, `combined_types_for`'s
635/// output reused unchanged. Each named type expands structurally via
636/// [`canon_named_in`] (through a synthetic, zero-span [`TypeRef::Named`] —
637/// spans are erased by every canonical renderer in this module already, so a
638/// dummy span here carries no information loss), sorted by name so map
639/// iteration order can't perturb the rendering.
640fn canon_types_table(types: &HashMap<String, Arc<TypeDecl>>) -> String {
641    let mut names: Vec<&String> = types.keys().collect();
642    names.sort();
643    let mut out = String::new();
644    for name in names {
645        let named = TypeRef::Named(Ident {
646            name: name.clone(),
647            span: Span::new(0, 0),
648        });
649        let _ = write!(
650            out,
651            "{}={};",
652            name,
653            canon_type(&named, types, &mut Vec::new())
654        );
655    }
656    out
657}
658
659/// P8.1 (#1512, [DECISION C]): [`UnitSignature::canonical`]'s own
660/// implementation — the single rendering R3.14's own stability proof
661/// compares. Every category is rendered sorted (by the caller's own
662/// `BTreeMap`/`BTreeSet` keys, or an explicit sort for `Vec`-shaped fields
663/// like a handler's own `given`), so construction order never perturbs the
664/// form — the same "compare the canonical string, not map/vec iteration
665/// order" discipline [`service_normal_form`]'s record-field sort already
666/// established for ADR 0200.
667pub fn canon_unit_signature(sig: &UnitSignature) -> String {
668    let types = &sig.combined_types;
669    let mut out = String::new();
670    let _ = writeln!(out, "unit {}", sig.id.0);
671    let _ = writeln!(out, "types: {}", canon_types_table(types));
672
673    out.push_str("fns:\n");
674    for f in sig.fns.values() {
675        let _ = writeln!(out, "  {}", canon_fn_signature(f, types));
676    }
677
678    // Methods (PR #1517's own bot review, finding #2): rendered per owning
679    // type, instance and static kept in their own sections so a rename
680    // between the two buckets is visible too, not just a param/return change
681    // within one.
682    out.push_str("methods:\n");
683    for (owner, mt) in &sig.methods {
684        let MethodTableSignature { instance, statics } = mt;
685        for f in instance.values() {
686            let _ = writeln!(out, "  {owner}.{}", canon_fn_signature(f, types));
687        }
688        for f in statics.values() {
689            let _ = writeln!(out, "  {owner}::{}", canon_fn_signature(f, types));
690        }
691    }
692
693    out.push_str("service_handlers:\n");
694    for (owner, hs) in &sig.service_handlers {
695        for h in hs {
696            let _ = writeln!(out, "  {owner}: {}", canon_handler_signature(h, types));
697        }
698    }
699
700    out.push_str("agent_handlers:\n");
701    for (owner, hs) in &sig.agent_handlers {
702        for h in hs {
703            let _ = writeln!(out, "  {owner}: {}", canon_handler_signature(h, types));
704        }
705    }
706
707    out.push_str("service_protocols:\n");
708    for (owner, p) in &sig.service_protocols {
709        let _ = writeln!(out, "  {owner}: {}", canon_protocol_signature(p, types));
710    }
711
712    out.push_str("store:\n");
713    for (owner, fs) in &sig.store_fields {
714        for f in fs {
715            let _ = writeln!(out, "  {owner}: {}", canon_store_field_signature(f, types));
716        }
717    }
718
719    out.push_str("capabilities:\n");
720    for c in &sig.capabilities.exported {
721        let _ = writeln!(out, "  exports {c}");
722    }
723    for (cap, ops) in &sig.capabilities.declared {
724        for op in ops {
725            let _ = writeln!(
726                out,
727                "  declares {cap}.{}",
728                canon_capability_op_signature(op, types)
729            );
730        }
731    }
732    for (cap, given) in &sig.capabilities.provider_given {
733        let mut given = given.clone();
734        given.sort();
735        let _ = writeln!(out, "  provides {cap} given {}", given.join(", "));
736    }
737    for (svc, given) in &sig.capabilities.service_given {
738        let mut given = given.clone();
739        given.sort();
740        let _ = writeln!(out, "  service {svc} given {}", given.join(", "));
741    }
742
743    out
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use bynk_syntax::ast::RefinementPred;
750
751    #[test]
752    fn predicate_order_does_not_change_the_form() {
753        // The precondition the whole increment rests on: `String where NonEmpty,
754        // MaxLen(10)` and the same predicates reordered are the *same type*, so
755        // they must canonicalise — and therefore hash — identically. Hashing
756        // source order would 409 two contexts that agree perfectly.
757        let a = Refinement {
758            predicates: vec![
759                RefinementPred {
760                    kind: PredKind::NonEmpty,
761                    span: sp(),
762                },
763                RefinementPred {
764                    kind: PredKind::MaxLength(10),
765                    span: sp(),
766                },
767            ],
768            span: sp(),
769        };
770        let b = Refinement {
771            predicates: vec![
772                RefinementPred {
773                    kind: PredKind::MaxLength(10),
774                    span: sp(),
775                },
776                RefinementPred {
777                    kind: PredKind::NonEmpty,
778                    span: sp(),
779                },
780            ],
781            span: sp(),
782        };
783        assert_eq!(canon_refinement(Some(&a)), canon_refinement(Some(&b)));
784        assert_eq!(
785            contract_hash(&canon_refinement(Some(&a))),
786            contract_hash(&canon_refinement(Some(&b)))
787        );
788    }
789
790    #[test]
791    fn a_different_predicate_set_changes_the_form() {
792        let a = Refinement {
793            predicates: vec![RefinementPred {
794                kind: PredKind::MaxLength(10),
795                span: sp(),
796            }],
797            span: sp(),
798        };
799        let b = Refinement {
800            predicates: vec![RefinementPred {
801                kind: PredKind::MaxLength(11),
802                span: sp(),
803            }],
804            span: sp(),
805        };
806        assert_ne!(canon_refinement(Some(&a)), canon_refinement(Some(&b)));
807    }
808
809    #[test]
810    fn fnv1a_matches_the_published_vectors() {
811        // FNV-1a 64-bit reference vectors. The point of hand-rolling a
812        // *specified* hash is that two compilers agree forever; pin it.
813        assert_eq!(contract_hash(""), "cbf29ce484222325");
814        assert_eq!(contract_hash("a"), "af63dc4c8601ec8c");
815        assert_eq!(contract_hash("foobar"), "85944171f73967e8");
816    }
817
818    /// Build a type table by parsing a `commons`, so these tests exercise real
819    /// declarations rather than hand-assembled AST.
820    fn types_of(src: &str) -> HashMap<String, Arc<TypeDecl>> {
821        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
822        let commons = bynk_syntax::parser::parse(&tokens, src).expect("parse");
823        commons
824            .items
825            .iter()
826            .filter_map(|i| match i {
827                bynk_syntax::ast::CommonsItem::Type(t) => {
828                    Some((t.name.name.clone(), Arc::new(t.clone())))
829                }
830                _ => None,
831            })
832            .collect()
833    }
834
835    fn named(n: &str) -> TypeRef {
836        TypeRef::Named(bynk_syntax::ast::Ident {
837            name: n.to_string(),
838            span: sp(),
839        })
840    }
841
842    fn svc(param_ty: TypeRef) -> CrossContextService {
843        CrossContextService {
844            name: "probe".to_string(),
845            params: vec![("p".to_string(), param_ty)],
846            return_type: TypeRef::Base(bynk_syntax::ast::BaseType::String, sp()),
847            span: sp(),
848            foreign_callers: Vec::new(),
849        }
850    }
851
852    /// A record's **field order** is not wire-observable — a JSON object is
853    /// unordered — so it must not move the hash. This is the false-positive side,
854    /// and it is the one that matters most: a spurious 409 breaks a working
855    /// deployment.
856    #[test]
857    fn record_field_order_does_not_change_the_hash() {
858        let a = types_of("commons x\n\ntype P = { one: Int, two: String }\n");
859        let b = types_of("commons x\n\ntype P = { two: String, one: Int }\n");
860        assert_eq!(
861            service_contract_hash(&svc(named("P")), &a),
862            service_contract_hash(&svc(named("P")), &b),
863        );
864    }
865
866    /// Field **presence** and **type**, by contrast, are exactly what the hash
867    /// exists to pin. These are the cases a co-compiled build rejects
868    /// structurally — but a skewed *deploy* cannot, which is why the hash carries
869    /// them.
870    #[test]
871    fn field_presence_name_and_type_change_the_hash() {
872        let base = types_of("commons x\n\ntype P = { one: Int, two: String }\n");
873        let h = service_contract_hash(&svc(named("P")), &base);
874
875        let renamed = types_of("commons x\n\ntype P = { one: Int, three: String }\n");
876        assert_ne!(
877            h,
878            service_contract_hash(&svc(named("P")), &renamed),
879            "rename"
880        );
881
882        let dropped = types_of("commons x\n\ntype P = { two: String }\n");
883        assert_ne!(h, service_contract_hash(&svc(named("P")), &dropped), "drop");
884
885        let retyped = types_of("commons x\n\ntype P = { one: String, two: String }\n");
886        assert_ne!(
887            h,
888            service_contract_hash(&svc(named("P")), &retyped),
889            "retype"
890        );
891    }
892
893    /// #1969: an inline field refinement is part of the field's type, and the
894    /// decoder checks it, so adding, changing or dropping one changes the
895    /// hash. Its predicates canonicalise as a set, like a named refined type's.
896    #[test]
897    fn an_inline_field_refinement_changes_the_hash_as_a_set() {
898        let plain = types_of("commons x\n\ntype P = { qty: Int }\n");
899        let refined = types_of("commons x\n\ntype P = { qty: Int where Positive }\n");
900        let both =
901            types_of("commons x\n\ntype P = { name: String where NonEmpty && MaxLength(4) }\n");
902        let swapped =
903            types_of("commons x\n\ntype P = { name: String where MaxLength(4) && NonEmpty }\n");
904        assert_ne!(
905            service_contract_hash(&svc(named("P")), &plain),
906            service_contract_hash(&svc(named("P")), &refined),
907        );
908        assert_eq!(
909            service_normal_form(&svc(named("P")), &refined),
910            "probe(p: P{qty: Int where Positive}) -> String"
911        );
912        assert_eq!(
913            service_contract_hash(&svc(named("P")), &both),
914            service_contract_hash(&svc(named("P")), &swapped),
915        );
916    }
917
918    /// A sum's **variant order** is invisible to the wire (the payload carries a
919    /// `kind` discriminant); its variant *set* is not.
920    #[test]
921    fn sum_variant_order_does_not_change_the_hash_but_the_set_does() {
922        let a = types_of("commons x\n\ntype E = enum { Alpha, Beta }\n");
923        let b = types_of("commons x\n\ntype E = enum { Beta, Alpha }\n");
924        assert_eq!(
925            service_contract_hash(&svc(named("E")), &a),
926            service_contract_hash(&svc(named("E")), &b),
927        );
928        let c = types_of("commons x\n\ntype E = enum { Alpha, Gamma }\n");
929        assert_ne!(
930            service_contract_hash(&svc(named("E")), &a),
931            service_contract_hash(&svc(named("E")), &c),
932        );
933    }
934
935    /// An **opaque** type's predicate is excluded: the consumer cannot see it by
936    /// construction, so no consumer behaviour can depend on it, and including it
937    /// would manufacture skew between two contexts that cannot disagree. Its
938    /// *representation* is still part of the contract.
939    #[test]
940    fn an_opaque_types_predicate_is_excluded_but_its_representation_is_not() {
941        let loose = types_of("commons x\n\ntype Id = opaque String where NonEmpty\n");
942        let tight = types_of("commons x\n\ntype Id = opaque String where MaxLength(4)\n");
943        assert_eq!(
944            service_contract_hash(&svc(named("Id")), &loose),
945            service_contract_hash(&svc(named("Id")), &tight),
946            "tightening an opaque predicate must not 409 a caller that cannot see it"
947        );
948
949        // A *transparent* refined type is the opposite: the consumer can see the
950        // predicate, so it is part of the contract.
951        let ra = types_of("commons x\n\ntype C = String where MaxLength(4)\n");
952        let rb = types_of("commons x\n\ntype C = String where MaxLength(5)\n");
953        assert_ne!(
954            service_contract_hash(&svc(named("C")), &ra),
955            service_contract_hash(&svc(named("C")), &rb),
956        );
957    }
958
959    /// `NonEmpty` is sugar for `MinLength(1)` (R12.2, T1.8, Decision A) —
960    /// pinned directly against the canonicaliser so a future edit to this arm
961    /// is a deliberate, visible change rather than a silent regression.
962    #[test]
963    fn non_empty_canonicalises_to_min_length_one() {
964        assert_eq!(canon_predicate(&PredKind::NonEmpty), "MinLength(1)");
965    }
966
967    /// The counterpart to `non_empty_canonicalises_to_min_length_one`: the
968    /// `Positive`/`NonNegative` → `InRange` fold is **declined** (#1049) —
969    /// neither base has a writable bound standing for `∞`. Pinned so
970    /// reversing that decision trips a named test rather than a fixture
971    /// hash.
972    #[test]
973    fn positive_and_non_negative_stay_their_own_canonical_literals() {
974        assert_eq!(canon_predicate(&PredKind::Positive), "Positive");
975        assert_eq!(canon_predicate(&PredKind::NonNegative), "NonNegative");
976    }
977
978    /// The consequence that matters: two boundary types spelling the same
979    /// refinement differently must hash identically, or two contexts that
980    /// agree perfectly 409 each other.
981    #[test]
982    fn non_empty_and_min_length_one_hash_identically() {
983        let a = types_of("commons x\n\ntype C = String where NonEmpty\n");
984        let b = types_of("commons x\n\ntype C = String where MinLength(1)\n");
985        assert_eq!(
986            service_contract_hash(&svc(named("C")), &a),
987            service_contract_hash(&svc(named("C")), &b),
988            "NonEmpty and MinLength(1) are the same refinement and must hash the same"
989        );
990    }
991
992    /// After the fold, `NonEmpty` and `MinLength(1)` are the same canonical
993    /// string, so a redundant `NonEmpty && MinLength(1)` conjunction must dedup
994    /// to one entry, not two — the same idempotence `canon_refinement`'s sort+
995    /// dedup already promises for any other repeated predicate.
996    #[test]
997    fn non_empty_and_min_length_one_together_dedup_to_one_entry() {
998        let r = Refinement {
999            predicates: vec![
1000                RefinementPred {
1001                    kind: PredKind::NonEmpty,
1002                    span: sp(),
1003                },
1004                RefinementPred {
1005                    kind: PredKind::MinLength(1),
1006                    span: sp(),
1007                },
1008            ],
1009            span: sp(),
1010        };
1011        assert_eq!(canon_refinement(Some(&r)), "where MinLength(1)");
1012    }
1013
1014    /// A recursive record is a legal contract (its codec terminates on the data),
1015    /// but its expansion is not — the walk must terminate rather than blow the
1016    /// stack, and two different recursive shapes must still differ.
1017    #[test]
1018    fn a_recursive_record_terminates_and_stays_distinguishable() {
1019        let a = types_of("commons x\n\ntype Node = { v: Int, next: Option[Node] }\n");
1020        let b = types_of("commons x\n\ntype Node = { v: String, next: Option[Node] }\n");
1021        let ha = service_contract_hash(&svc(named("Node")), &a);
1022        assert_ne!(ha, service_contract_hash(&svc(named("Node")), &b));
1023    }
1024
1025    /// State-migrations S0 (#2204): the storage renderings are structural —
1026    /// stored bytes carry no type names — and recursion still terminates,
1027    /// with the back-reference as the only name left.
1028    #[test]
1029    fn storage_renderings_drop_type_names_and_terminate_on_recursion() {
1030        let t =
1031            types_of("commons x\n\ntype Node = { v: Int where NonNegative, next: Option[Node] }\n");
1032        let (structure, full) = canon_storage_type(&named("Node"), &t);
1033        assert_eq!(structure, "{next: Option[@1], v: Int}");
1034        assert_eq!(full, "{next: Option[@1], v: Int where NonNegative}");
1035
1036        let renamed =
1037            types_of("commons x\n\ntype Link = { v: Int where NonNegative, next: Option[Link] }\n");
1038        let (renamed_structure, _) = canon_storage_type(&named("Link"), &renamed);
1039        assert_eq!(
1040            renamed_structure, structure,
1041            "renaming a recursive type changes nothing on disk (#2204 review)"
1042        );
1043
1044        // The positional anchor still tells different cycles apart: here the
1045        // inner type points back to the *outer* one, two levels out.
1046        let mutual =
1047            types_of("commons x\n\ntype A = { b: Option[B] }\ntype B = { a: Option[A], v: Int }\n");
1048        let selfish =
1049            types_of("commons x\n\ntype A = { b: Option[B] }\ntype B = { a: Option[B], v: Int }\n");
1050        assert_eq!(
1051            canon_storage_type(&named("A"), &mutual).0,
1052            "{b: Option[{a: Option[@2], v: Int}]}"
1053        );
1054        assert_ne!(
1055            canon_storage_type(&named("A"), &mutual),
1056            canon_storage_type(&named("A"), &selfish)
1057        );
1058
1059        let flat_a = types_of("commons x\n\ntype A = { title: String }\n");
1060        let flat_b = types_of("commons x\n\ntype B = { title: String }\n");
1061        assert_eq!(
1062            canon_storage_type(&named("A"), &flat_a),
1063            canon_storage_type(&named("B"), &flat_b),
1064            "renaming a non-recursive type changes nothing on disk"
1065        );
1066
1067        // Nor does renaming a refined type, or one used as a map key or as a
1068        // sum variant's payload: no position in the walk is nominal.
1069        let qty = types_of("commons x\n\ntype Qty = Int where NonNegative\n");
1070        let count = types_of("commons x\n\ntype Count = Int where NonNegative\n");
1071        assert_eq!(
1072            canon_storage_type(&named("Qty"), &qty),
1073            canon_storage_type(&named("Count"), &count),
1074        );
1075        let map = |key: &str| TypeRef::Map(Box::new(named(key)), Box::new(named(key)), sp());
1076        assert_eq!(
1077            canon_storage_type(&map("Qty"), &qty),
1078            canon_storage_type(&map("Count"), &count),
1079        );
1080        let sum_a = types_of("commons x\n\ntype P = { n: Int }\ntype S = | One(p: P) | Two\n");
1081        let sum_b = types_of("commons x\n\ntype Q = { n: Int }\ntype S = | One(p: Q) | Two\n");
1082        assert_eq!(
1083            canon_storage_type(&named("S"), &sum_a),
1084            canon_storage_type(&named("S"), &sum_b),
1085        );
1086    }
1087
1088    /// Stored sum payloads are keyed by field name (`payload_prop`), so in
1089    /// storage a payload renders like a record: renaming a payload field is a
1090    /// change, reordering payload fields is not (#2204 review).
1091    #[test]
1092    fn storage_renderings_name_and_sort_sum_payload_fields() {
1093        let base = types_of("commons x\n\ntype S = | One(a: Int, b: String) | Two\n");
1094        let renamed = types_of("commons x\n\ntype S = | One(c: Int, b: String) | Two\n");
1095        let reordered = types_of("commons x\n\ntype S = | One(b: String, a: Int) | Two\n");
1096        let (structure, _) = canon_storage_type(&named("S"), &base);
1097        assert_eq!(structure, "|One(a: Int, b: String)|Two");
1098        assert_ne!(
1099            canon_storage_type(&named("S"), &base),
1100            canon_storage_type(&named("S"), &renamed)
1101        );
1102        assert_eq!(
1103            canon_storage_type(&named("S"), &base),
1104            canon_storage_type(&named("S"), &reordered)
1105        );
1106        // The contract form is untouched: payloads stay positional there.
1107        assert_ne!(
1108            service_contract_hash(&svc(named("S")), &base),
1109            service_contract_hash(&svc(named("S")), &reordered)
1110        );
1111    }
1112
1113    /// The stored owner re-validates its opaque values, so the full storage
1114    /// rendering keeps an opaque predicate the contract form elides — and the
1115    /// contract hash itself is unchanged by the new modes.
1116    #[test]
1117    fn storage_full_rendering_keeps_an_opaque_predicate_the_contract_elides() {
1118        let a = types_of("commons x\n\ntype Code = opaque String where MaxLength(10)\n");
1119        let b = types_of("commons x\n\ntype Code = opaque String where MaxLength(5)\n");
1120        let (sa, fa) = canon_storage_type(&named("Code"), &a);
1121        let (sb, fb) = canon_storage_type(&named("Code"), &b);
1122        assert_eq!(sa, "String");
1123        assert_eq!(sa, sb, "the structure ignores the predicate");
1124        assert_ne!(fa, fb, "the full form sees the tightened predicate");
1125        assert_eq!(
1126            service_contract_hash(&svc(named("Code")), &a),
1127            service_contract_hash(&svc(named("Code")), &b),
1128            "the contract still elides an opaque predicate"
1129        );
1130    }
1131
1132    /// A generic type's **parameter name** is not wire-observable: `Page[T]` and
1133    /// the same declaration spelled with `U` are the same type with the same
1134    /// shape. Renaming one is a refactor, and must not 409 a caller.
1135    ///
1136    /// Caught in review of #658: the body used to expand with the parameter
1137    /// *unsubstituted*, so the name leaked into the form. Same class as record
1138    /// field order and predicate order — the false-positive side the module's
1139    /// standard exists to protect.
1140    #[test]
1141    fn a_generic_type_parameter_rename_does_not_change_the_hash() {
1142        let t = types_of(
1143            "commons x\n\ntype Order = { id: Int }\ntype Page[T] = { items: List[T], total: Int }\n",
1144        );
1145        let u = types_of(
1146            "commons x\n\ntype Order = { id: Int }\ntype Page[U] = { items: List[U], total: Int }\n",
1147        );
1148        let app = TypeRef::App {
1149            name: bynk_syntax::ast::Ident {
1150                name: "Page".to_string(),
1151                span: sp(),
1152            },
1153            args: vec![named("Order")],
1154            span: sp(),
1155        };
1156        assert_eq!(
1157            service_contract_hash(&svc(app.clone()), &t),
1158            service_contract_hash(&svc(app), &u),
1159            "renaming a generic type parameter must not change the contract hash"
1160        );
1161    }
1162
1163    /// The converse: the *argument* a generic is instantiated at is entirely
1164    /// wire-observable, so it must still move the hash.
1165    #[test]
1166    fn a_generic_argument_change_does_change_the_hash() {
1167        let t = types_of(
1168            "commons x\n\ntype Order = { id: Int }\ntype Other = { id: String }\ntype Page[T] = { items: List[T] }\n",
1169        );
1170        let app = |arg: &str| TypeRef::App {
1171            name: bynk_syntax::ast::Ident {
1172                name: "Page".to_string(),
1173                span: sp(),
1174            },
1175            args: vec![named(arg)],
1176            span: sp(),
1177        };
1178        assert_ne!(
1179            service_contract_hash(&svc(app("Order")), &t),
1180            service_contract_hash(&svc(app("Other")), &t),
1181        );
1182    }
1183
1184    /// And the parameter is genuinely *substituted*, not merely ignored: the form
1185    /// shows the instantiated shape rather than a dangling `T`.
1186    #[test]
1187    fn a_generic_body_expands_over_its_concrete_argument() {
1188        let t = types_of("commons x\n\ntype Page[T] = { items: List[T] }\n");
1189        let app = TypeRef::App {
1190            name: bynk_syntax::ast::Ident {
1191                name: "Page".to_string(),
1192                span: sp(),
1193            },
1194            args: vec![TypeRef::Base(bynk_syntax::ast::BaseType::Int, sp())],
1195            span: sp(),
1196        };
1197        let nf = service_normal_form(&svc(app), &t);
1198        assert!(nf.contains("List[Int]"), "{nf}");
1199        assert!(
1200            !nf.contains("List[T]"),
1201            "the parameter must not survive: {nf}"
1202        );
1203    }
1204
1205    fn sp() -> bynk_syntax::span::Span {
1206        bynk_syntax::span::Span::new(0, 0)
1207    }
1208}