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