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}