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