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}