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::{HashMap, HashSet};
28use std::fmt::Write as _;
29use std::sync::Arc;
30
31use bynk_syntax::ast::{PredKind, Refinement, TypeBody, TypeDecl, TypeRef};
32
33use crate::resolver::CrossContextService;
34
35/// The canonical normal form of one `on call` service contract.
36///
37/// Shape: `<service>(<param>: <type>, …) -> <type>`. Parameter **names** and
38/// **order** are both included, and both are load-bearing rather than cosmetic:
39/// a multi-argument call sends an object keyed by parameter name, and a
40/// single-argument call sends the bare value — so a rename or a reorder is a
41/// genuine wire change, not a refactor.
42pub fn service_normal_form(
43    svc: &CrossContextService,
44    types: &HashMap<String, Arc<TypeDecl>>,
45) -> String {
46    let mut out = String::new();
47    let _ = write!(out, "{}(", svc.name);
48    for (i, (pname, pty)) in svc.params.iter().enumerate() {
49        if i > 0 {
50            out.push_str(", ");
51        }
52        let _ = write!(
53            out,
54            "{pname}: {}",
55            canon_type(pty, types, &mut HashSet::new())
56        );
57    }
58    let _ = write!(
59        out,
60        ") -> {}",
61        canon_type(&svc.return_type, types, &mut HashSet::new())
62    );
63    out
64}
65
66/// The canonical form of a type *as it appears on the wire*.
67///
68/// A named type expands **structurally**, not by name alone: the wire carries
69/// the fields, so renaming a record field or changing a variant's payload is a
70/// contract change that a name-only form would miss entirely. The name is kept
71/// alongside the structure because Bynk's types are nominal — swapping
72/// `AuthId` for a structurally identical `SessionId` changes the contract even
73/// though the bytes are unchanged. Keeping the name costs nothing in false
74/// positives: a rename already breaks the consumer's *compile*, so it cannot
75/// reach a deploy without the consumer being rebuilt too.
76fn canon_type(
77    t: &TypeRef,
78    types: &HashMap<String, Arc<TypeDecl>>,
79    seen: &mut HashSet<String>,
80) -> String {
81    canon_type_in(t, types, seen, &HashMap::new())
82}
83
84/// `subst` binds a generic declaration's type-parameter **names** to the
85/// canonical form of the concrete argument supplied at the use site.
86///
87/// A generic body MUST expand with its parameters substituted, or the
88/// parameter's *name* leaks into the form: `type Page[T] = { items: List[T] }`
89/// and the same declaration spelled with `U` are the same type with the same
90/// wire shape, but would hash differently. Across a deploy that renames a type
91/// parameter — a pure refactor with no wire consequence — every call would 409.
92/// That is the same class of spurious failure the sorted fields and the
93/// predicate set exist to prevent, and it is the one the module's own standard
94/// ("semantically-equal contracts must hash equal") forbids.
95fn canon_type_in(
96    t: &TypeRef,
97    types: &HashMap<String, Arc<TypeDecl>>,
98    seen: &mut HashSet<String>,
99    subst: &HashMap<String, String>,
100) -> String {
101    match t {
102        TypeRef::Base(b, _) => b.name().to_string(),
103        TypeRef::Unit(_) => "()".to_string(),
104        // An `Effect` wraps the handler, not the payload — the caller awaits the
105        // promise, so it is not part of the wire contract.
106        TypeRef::Effect(inner, _) => canon_type_in(inner, types, seen, subst),
107        TypeRef::List(a, _) => format!("List[{}]", canon_type_in(a, types, seen, subst)),
108        TypeRef::Option(a, _) => format!("Option[{}]", canon_type_in(a, types, seen, subst)),
109        TypeRef::Result(a, b, _) => format!(
110            "Result[{}, {}]",
111            canon_type_in(a, types, seen, subst),
112            canon_type_in(b, types, seen, subst)
113        ),
114        TypeRef::Map(k, v, _) => format!(
115            "Map[{}, {}]",
116            canon_type_in(k, types, seen, subst),
117            canon_type_in(v, types, seen, subst)
118        ),
119        // Generic-record instantiation: the arguments are positional, so their
120        // order *is* semantic and is preserved (unlike a record's fields).
121        TypeRef::App { name, args, .. } => {
122            let inner: Vec<String> = args
123                .iter()
124                .map(|a| canon_type_in(a, types, seen, subst))
125                .collect();
126            // Bind the declaration's parameters to these arguments so the body
127            // expands over concrete types and the parameter's name never reaches
128            // the form.
129            let bound: HashMap<String, String> = types
130                .get(&name.name)
131                .map(|d| {
132                    d.type_params
133                        .iter()
134                        .zip(&inner)
135                        .map(|(p, a)| (p.name.name.clone(), a.clone()))
136                        .collect()
137                })
138                .unwrap_or_default();
139            let head = canon_named_in(&name.name, types, seen, &bound);
140            format!("{head}[{}]", inner.join(", "))
141        }
142        TypeRef::Named(id) => {
143            // A bound type parameter renders as the argument it stands for.
144            match subst.get(&id.name) {
145                Some(bound) => bound.clone(),
146                None => canon_named_in(&id.name, types, seen, subst),
147            }
148        }
149        TypeRef::HttpResult(a, _) => {
150            format!("HttpResult[{}]", canon_type_in(a, types, seen, subst))
151        }
152        TypeRef::ValidationError(_) => "ValidationError".to_string(),
153        TypeRef::JsonError(_) => "JsonError".to_string(),
154        TypeRef::QueueResult(_) => "QueueResult".to_string(),
155        // The confined family is rejected at every boundary, so it cannot appear
156        // in a contract. Render it rather than panic: the normal form is also a
157        // diagnostic surface, and a compiler bug should not become a crash here.
158        TypeRef::Fn(..)
159        | TypeRef::Query(..)
160        | TypeRef::Stream(..)
161        | TypeRef::Connection(..)
162        | TypeRef::History(..) => "<non-boundary>".to_string(),
163    }
164}
165
166fn canon_named_in(
167    name: &str,
168    types: &HashMap<String, Arc<TypeDecl>>,
169    seen: &mut HashSet<String>,
170    subst: &HashMap<String, String>,
171) -> String {
172    // A recursive record terminates on the data, so its codec is finite and it
173    // is a legal contract — but its *expansion* is not. Emit a back-reference on
174    // revisit. `type Node = { next: Option[Node] }` canonicalises as
175    // `Node{next: Option[@Node]}` — the cycle is named, so two different
176    // recursive shapes still differ.
177    if !seen.insert(name.to_string()) {
178        return format!("@{name}");
179    }
180    let Some(decl) = types.get(name) else {
181        // Not in the callee's table: a runtime- or compiler-known name with no
182        // declaration to expand. The name alone is the whole contract for it.
183        seen.remove(name);
184        return name.to_string();
185    };
186    let body = match &decl.body {
187        // Record fields sort by name: a JSON object is unordered, so field
188        // *order* is not wire-observable and must not perturb the hash — while
189        // field *presence* and type are exactly what the hash exists to pin.
190        TypeBody::Record(r) => {
191            let mut fields: Vec<String> = r
192                .fields
193                .iter()
194                .map(|f| {
195                    format!(
196                        "{}: {}",
197                        f.name.name,
198                        canon_type_in(&f.type_ref, types, seen, subst)
199                    )
200                })
201                .collect();
202            fields.sort();
203            format!("{{{}}}", fields.join(", "))
204        }
205        // Variants sort by name for the same reason: the wire carries a `kind`
206        // discriminant, so declaration order is invisible to it.
207        TypeBody::Sum(s) => {
208            let mut variants: Vec<String> = s
209                .variants
210                .iter()
211                .map(|v| {
212                    let payload: Vec<String> = v
213                        .payload
214                        .iter()
215                        .map(|p| canon_type_in(&p.type_ref, types, seen, subst))
216                        .collect();
217                    if payload.is_empty() {
218                        v.name.name.clone()
219                    } else {
220                        format!("{}({})", v.name.name, payload.join(", "))
221                    }
222                })
223                .collect();
224            variants.sort();
225            format!("|{}", variants.join("|"))
226        }
227        TypeBody::Refined {
228            base, refinement, ..
229        } => {
230            format!("{} {}", base.name(), canon_refinement(refinement.as_ref()))
231        }
232        // An **opaque** type's predicate is deliberately excluded — only its
233        // representation is part of the contract.
234        //
235        // The consumer cannot see the predicate by construction (that is what
236        // `exports opaque` means), so no consumer behaviour can depend on it: it
237        // can hold and pass an `AuthId`, never inspect or mint one. Including the
238        // predicate would therefore manufacture skew failures between two
239        // contexts that cannot disagree — the owner tightening `Matches(...)`
240        // would 409 every caller for a change none of them can observe. This is
241        // the same position ADR 0199 took on opacity, from the same premise.
242        TypeBody::Opaque { base, .. } => format!("{} opaque", base.name()),
243    };
244    seen.remove(name);
245    format!("{name}{body}")
246}
247
248/// Predicates canonicalise as a **sorted set**.
249///
250/// This is not a nicety adjacent to the hash; it is a precondition for it.
251/// Predicates are conjunctive and side-effect-free, so `String where NonEmpty,
252/// MaxLen(10)` and `String where MaxLen(10), NonEmpty` are the *same type* — and
253/// hashing them in source order would make two contexts that agree perfectly
254/// fail closed against each other. The same normal form also backs the checker's
255/// `refinements_match`, so the matcher and the hash cannot disagree about what
256/// "the same refinement" means.
257pub fn canon_refinement(r: Option<&Refinement>) -> String {
258    let Some(r) = r else {
259        return String::new();
260    };
261    let mut preds: Vec<String> = r
262        .predicates
263        .iter()
264        .map(|p| canon_predicate(&p.kind))
265        .collect();
266    preds.sort();
267    preds.dedup();
268    format!("where {}", preds.join(", "))
269}
270
271pub fn canon_predicate(p: &PredKind) -> String {
272    match p {
273        PredKind::Matches(s) => format!("Matches({s:?})"),
274        // Bounds keep their source lexemes elsewhere (byte-stable emission), but
275        // a contract is about *values*: `1` and `01` are the same bound, so the
276        // parsed value is what canonicalises.
277        PredKind::InRange(a, b) => format!("InRange({}, {})", a.value, b.value),
278        PredKind::InRangeF(a, b) => format!("InRangeF({}, {})", a.value, b.value),
279        PredKind::MinLength(n) => format!("MinLength({n})"),
280        PredKind::MaxLength(n) => format!("MaxLength({n})"),
281        PredKind::Length(n) => format!("Length({n})"),
282        // R12.2 names these as sugar for `InRange(0, ∞)`/`InRange(1, ∞)`, but the
283        // fold stays undone (#1049): neither base has a writable literal bound
284        // that stands for `∞` — the lexer rejects any float literal that would
285        // parse to infinity, and `Int`'s `i64::MAX` is a real, arbitrary finite
286        // bound, not the language's spelling of "unbounded". Folding onto an
287        // invented string nothing else can produce would only rename the
288        // literal, at the cost of a contract-hash change for every boundary
289        // type carrying one. Revisit alongside R12.3 (entailment), which is
290        // the actual consumer of a normalised Interval domain.
291        PredKind::NonNegative => "NonNegative".to_string(),
292        PredKind::Positive => "Positive".to_string(),
293        // `NonEmpty` is sugar for `MinLength(1)` (R12.2) — folding it here makes
294        // `String where NonEmpty` and `String where MinLength(1)` the same
295        // canonical form, so `service_contract_hash` and `refinements_match`
296        // agree that they are the same type. Unconditional: `NonEmpty` only
297        // ever applies to `BaseType::String` (`refinements.rs`'s
298        // `pred_applies_to`), so no base needs threading through here.
299        PredKind::NonEmpty => "MinLength(1)".to_string(),
300    }
301}
302
303/// FNV-1a (64-bit) over the canonical form, rendered as 16 lowercase hex chars.
304///
305/// **Why not a cryptographic hash.** Trust here is static and channel-based, and
306/// this increment does not change that (ADR 0092): `/_bynk/call/` is
307/// platform-dispatched and not externally routable, every context in a
308/// deployment is one trust domain, and a malicious first-party context is out of
309/// the threat model. This is a **skew detector, not a security control** — an
310/// accident detector. Forging it buys an attacker nothing they could not already
311/// do, so `sha2`'s ~6-crate dependency tree would buy nothing either. A
312/// collision degrades to *today's* behaviour for that one pair (an undetected
313/// skew), not to something worse, and at ~1e-14 for a 1000-contract project it
314/// is not the risk worth engineering against.
315///
316/// **Why hand-rolled.** `std::collections::hash_map::DefaultHasher` is
317/// explicitly not stable across Rust releases, so it cannot back a value that
318/// crosses a wire or is compared between two separately-compiled binaries. FNV-1a
319/// is fully specified, so two compilers agree forever.
320pub fn contract_hash(normal_form: &str) -> String {
321    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
322    const PRIME: u64 = 0x0000_0100_0000_01b3;
323    let mut h = OFFSET;
324    for b in normal_form.as_bytes() {
325        h ^= *b as u64;
326        h = h.wrapping_mul(PRIME);
327    }
328    format!("{h:016x}")
329}
330
331/// The stamped contract hash for one consumed service.
332pub fn service_contract_hash(
333    svc: &CrossContextService,
334    types: &HashMap<String, Arc<TypeDecl>>,
335) -> String {
336    contract_hash(&service_normal_form(svc, types))
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use bynk_syntax::ast::RefinementPred;
343
344    #[test]
345    fn predicate_order_does_not_change_the_form() {
346        // The precondition the whole increment rests on: `String where NonEmpty,
347        // MaxLen(10)` and the same predicates reordered are the *same type*, so
348        // they must canonicalise — and therefore hash — identically. Hashing
349        // source order would 409 two contexts that agree perfectly.
350        let a = Refinement {
351            predicates: vec![
352                RefinementPred {
353                    kind: PredKind::NonEmpty,
354                    span: sp(),
355                },
356                RefinementPred {
357                    kind: PredKind::MaxLength(10),
358                    span: sp(),
359                },
360            ],
361            span: sp(),
362        };
363        let b = Refinement {
364            predicates: vec![
365                RefinementPred {
366                    kind: PredKind::MaxLength(10),
367                    span: sp(),
368                },
369                RefinementPred {
370                    kind: PredKind::NonEmpty,
371                    span: sp(),
372                },
373            ],
374            span: sp(),
375        };
376        assert_eq!(canon_refinement(Some(&a)), canon_refinement(Some(&b)));
377        assert_eq!(
378            contract_hash(&canon_refinement(Some(&a))),
379            contract_hash(&canon_refinement(Some(&b)))
380        );
381    }
382
383    #[test]
384    fn a_different_predicate_set_changes_the_form() {
385        let a = Refinement {
386            predicates: vec![RefinementPred {
387                kind: PredKind::MaxLength(10),
388                span: sp(),
389            }],
390            span: sp(),
391        };
392        let b = Refinement {
393            predicates: vec![RefinementPred {
394                kind: PredKind::MaxLength(11),
395                span: sp(),
396            }],
397            span: sp(),
398        };
399        assert_ne!(canon_refinement(Some(&a)), canon_refinement(Some(&b)));
400    }
401
402    #[test]
403    fn fnv1a_matches_the_published_vectors() {
404        // FNV-1a 64-bit reference vectors. The point of hand-rolling a
405        // *specified* hash is that two compilers agree forever; pin it.
406        assert_eq!(contract_hash(""), "cbf29ce484222325");
407        assert_eq!(contract_hash("a"), "af63dc4c8601ec8c");
408        assert_eq!(contract_hash("foobar"), "85944171f73967e8");
409    }
410
411    /// Build a type table by parsing a `commons`, so these tests exercise real
412    /// declarations rather than hand-assembled AST.
413    fn types_of(src: &str) -> HashMap<String, Arc<TypeDecl>> {
414        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
415        let commons = bynk_syntax::parser::parse(&tokens, src).expect("parse");
416        commons
417            .items
418            .iter()
419            .filter_map(|i| match i {
420                bynk_syntax::ast::CommonsItem::Type(t) => {
421                    Some((t.name.name.clone(), Arc::new(t.clone())))
422                }
423                _ => None,
424            })
425            .collect()
426    }
427
428    fn named(n: &str) -> TypeRef {
429        TypeRef::Named(bynk_syntax::ast::Ident {
430            name: n.to_string(),
431            span: sp(),
432        })
433    }
434
435    fn svc(param_ty: TypeRef) -> CrossContextService {
436        CrossContextService {
437            name: "probe".to_string(),
438            params: vec![("p".to_string(), param_ty)],
439            return_type: TypeRef::Base(bynk_syntax::ast::BaseType::String, sp()),
440            span: sp(),
441        }
442    }
443
444    /// A record's **field order** is not wire-observable — a JSON object is
445    /// unordered — so it must not move the hash. This is the false-positive side,
446    /// and it is the one that matters most: a spurious 409 breaks a working
447    /// deployment.
448    #[test]
449    fn record_field_order_does_not_change_the_hash() {
450        let a = types_of("commons x\n\ntype P = { one: Int, two: String }\n");
451        let b = types_of("commons x\n\ntype P = { two: String, one: Int }\n");
452        assert_eq!(
453            service_contract_hash(&svc(named("P")), &a),
454            service_contract_hash(&svc(named("P")), &b),
455        );
456    }
457
458    /// Field **presence** and **type**, by contrast, are exactly what the hash
459    /// exists to pin. These are the cases a co-compiled build rejects
460    /// structurally — but a skewed *deploy* cannot, which is why the hash carries
461    /// them.
462    #[test]
463    fn field_presence_name_and_type_change_the_hash() {
464        let base = types_of("commons x\n\ntype P = { one: Int, two: String }\n");
465        let h = service_contract_hash(&svc(named("P")), &base);
466
467        let renamed = types_of("commons x\n\ntype P = { one: Int, three: String }\n");
468        assert_ne!(
469            h,
470            service_contract_hash(&svc(named("P")), &renamed),
471            "rename"
472        );
473
474        let dropped = types_of("commons x\n\ntype P = { two: String }\n");
475        assert_ne!(h, service_contract_hash(&svc(named("P")), &dropped), "drop");
476
477        let retyped = types_of("commons x\n\ntype P = { one: String, two: String }\n");
478        assert_ne!(
479            h,
480            service_contract_hash(&svc(named("P")), &retyped),
481            "retype"
482        );
483    }
484
485    /// A sum's **variant order** is invisible to the wire (the payload carries a
486    /// `kind` discriminant); its variant *set* is not.
487    #[test]
488    fn sum_variant_order_does_not_change_the_hash_but_the_set_does() {
489        let a = types_of("commons x\n\ntype E = enum { Alpha, Beta }\n");
490        let b = types_of("commons x\n\ntype E = enum { Beta, Alpha }\n");
491        assert_eq!(
492            service_contract_hash(&svc(named("E")), &a),
493            service_contract_hash(&svc(named("E")), &b),
494        );
495        let c = types_of("commons x\n\ntype E = enum { Alpha, Gamma }\n");
496        assert_ne!(
497            service_contract_hash(&svc(named("E")), &a),
498            service_contract_hash(&svc(named("E")), &c),
499        );
500    }
501
502    /// An **opaque** type's predicate is excluded: the consumer cannot see it by
503    /// construction, so no consumer behaviour can depend on it, and including it
504    /// would manufacture skew between two contexts that cannot disagree. Its
505    /// *representation* is still part of the contract.
506    #[test]
507    fn an_opaque_types_predicate_is_excluded_but_its_representation_is_not() {
508        let loose = types_of("commons x\n\ntype Id = opaque String where NonEmpty\n");
509        let tight = types_of("commons x\n\ntype Id = opaque String where MaxLength(4)\n");
510        assert_eq!(
511            service_contract_hash(&svc(named("Id")), &loose),
512            service_contract_hash(&svc(named("Id")), &tight),
513            "tightening an opaque predicate must not 409 a caller that cannot see it"
514        );
515
516        // A *transparent* refined type is the opposite: the consumer can see the
517        // predicate, so it is part of the contract.
518        let ra = types_of("commons x\n\ntype C = String where MaxLength(4)\n");
519        let rb = types_of("commons x\n\ntype C = String where MaxLength(5)\n");
520        assert_ne!(
521            service_contract_hash(&svc(named("C")), &ra),
522            service_contract_hash(&svc(named("C")), &rb),
523        );
524    }
525
526    /// `NonEmpty` is sugar for `MinLength(1)` (R12.2, T1.8, Decision A) —
527    /// pinned directly against the canonicaliser so a future edit to this arm
528    /// is a deliberate, visible change rather than a silent regression.
529    #[test]
530    fn non_empty_canonicalises_to_min_length_one() {
531        assert_eq!(canon_predicate(&PredKind::NonEmpty), "MinLength(1)");
532    }
533
534    /// The counterpart to `non_empty_canonicalises_to_min_length_one`: the
535    /// `Positive`/`NonNegative` → `InRange` fold is **declined** (#1049) —
536    /// neither base has a writable bound standing for `∞`. Pinned so
537    /// reversing that decision trips a named test rather than a fixture
538    /// hash.
539    #[test]
540    fn positive_and_non_negative_stay_their_own_canonical_literals() {
541        assert_eq!(canon_predicate(&PredKind::Positive), "Positive");
542        assert_eq!(canon_predicate(&PredKind::NonNegative), "NonNegative");
543    }
544
545    /// The consequence that matters: two boundary types spelling the same
546    /// refinement differently must hash identically, or two contexts that
547    /// agree perfectly 409 each other.
548    #[test]
549    fn non_empty_and_min_length_one_hash_identically() {
550        let a = types_of("commons x\n\ntype C = String where NonEmpty\n");
551        let b = types_of("commons x\n\ntype C = String where MinLength(1)\n");
552        assert_eq!(
553            service_contract_hash(&svc(named("C")), &a),
554            service_contract_hash(&svc(named("C")), &b),
555            "NonEmpty and MinLength(1) are the same refinement and must hash the same"
556        );
557    }
558
559    /// After the fold, `NonEmpty` and `MinLength(1)` are the same canonical
560    /// string, so a redundant `NonEmpty && MinLength(1)` conjunction must dedup
561    /// to one entry, not two — the same idempotence `canon_refinement`'s sort+
562    /// dedup already promises for any other repeated predicate.
563    #[test]
564    fn non_empty_and_min_length_one_together_dedup_to_one_entry() {
565        let r = Refinement {
566            predicates: vec![
567                RefinementPred {
568                    kind: PredKind::NonEmpty,
569                    span: sp(),
570                },
571                RefinementPred {
572                    kind: PredKind::MinLength(1),
573                    span: sp(),
574                },
575            ],
576            span: sp(),
577        };
578        assert_eq!(canon_refinement(Some(&r)), "where MinLength(1)");
579    }
580
581    /// A recursive record is a legal contract (its codec terminates on the data),
582    /// but its expansion is not — the walk must terminate rather than blow the
583    /// stack, and two different recursive shapes must still differ.
584    #[test]
585    fn a_recursive_record_terminates_and_stays_distinguishable() {
586        let a = types_of("commons x\n\ntype Node = { v: Int, next: Option[Node] }\n");
587        let b = types_of("commons x\n\ntype Node = { v: String, next: Option[Node] }\n");
588        let ha = service_contract_hash(&svc(named("Node")), &a);
589        assert_ne!(ha, service_contract_hash(&svc(named("Node")), &b));
590    }
591
592    /// A generic type's **parameter name** is not wire-observable: `Page[T]` and
593    /// the same declaration spelled with `U` are the same type with the same
594    /// shape. Renaming one is a refactor, and must not 409 a caller.
595    ///
596    /// Caught in review of #658: the body used to expand with the parameter
597    /// *unsubstituted*, so the name leaked into the form. Same class as record
598    /// field order and predicate order — the false-positive side the module's
599    /// standard exists to protect.
600    #[test]
601    fn a_generic_type_parameter_rename_does_not_change_the_hash() {
602        let t = types_of(
603            "commons x\n\ntype Order = { id: Int }\ntype Page[T] = { items: List[T], total: Int }\n",
604        );
605        let u = types_of(
606            "commons x\n\ntype Order = { id: Int }\ntype Page[U] = { items: List[U], total: Int }\n",
607        );
608        let app = TypeRef::App {
609            name: bynk_syntax::ast::Ident {
610                name: "Page".to_string(),
611                span: sp(),
612            },
613            args: vec![named("Order")],
614            span: sp(),
615        };
616        assert_eq!(
617            service_contract_hash(&svc(app.clone()), &t),
618            service_contract_hash(&svc(app), &u),
619            "renaming a generic type parameter must not change the contract hash"
620        );
621    }
622
623    /// The converse: the *argument* a generic is instantiated at is entirely
624    /// wire-observable, so it must still move the hash.
625    #[test]
626    fn a_generic_argument_change_does_change_the_hash() {
627        let t = types_of(
628            "commons x\n\ntype Order = { id: Int }\ntype Other = { id: String }\ntype Page[T] = { items: List[T] }\n",
629        );
630        let app = |arg: &str| TypeRef::App {
631            name: bynk_syntax::ast::Ident {
632                name: "Page".to_string(),
633                span: sp(),
634            },
635            args: vec![named(arg)],
636            span: sp(),
637        };
638        assert_ne!(
639            service_contract_hash(&svc(app("Order")), &t),
640            service_contract_hash(&svc(app("Other")), &t),
641        );
642    }
643
644    /// And the parameter is genuinely *substituted*, not merely ignored: the form
645    /// shows the instantiated shape rather than a dangling `T`.
646    #[test]
647    fn a_generic_body_expands_over_its_concrete_argument() {
648        let t = types_of("commons x\n\ntype Page[T] = { items: List[T] }\n");
649        let app = TypeRef::App {
650            name: bynk_syntax::ast::Ident {
651                name: "Page".to_string(),
652                span: sp(),
653            },
654            args: vec![TypeRef::Base(bynk_syntax::ast::BaseType::Int, sp())],
655            span: sp(),
656        };
657        let nf = service_normal_form(&svc(app), &t);
658        assert!(nf.contains("List[Int]"), "{nf}");
659        assert!(
660            !nf.contains("List[T]"),
661            "the parameter must not survive: {nf}"
662        );
663    }
664
665    fn sp() -> bynk_syntax::span::Span {
666        bynk_syntax::span::Span::new(0, 0)
667    }
668}