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::{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!(
59 out,
60 "{pname}: {}",
61 canon_type(pty, types, &mut HashSet::new())
62 );
63 }
64 let _ = write!(
65 out,
66 ") -> {}",
67 canon_type(&svc.return_type, types, &mut HashSet::new())
68 );
69 out
70}
71
72/// The canonical form of a type *as it appears on the wire*.
73///
74/// A named type expands **structurally**, not by name alone: the wire carries
75/// the fields, so renaming a record field or changing a variant's payload is a
76/// contract change that a name-only form would miss entirely. The name is kept
77/// alongside the structure because Bynk's types are nominal — swapping
78/// `AuthId` for a structurally identical `SessionId` changes the contract even
79/// though the bytes are unchanged. Keeping the name costs nothing in false
80/// positives: a rename already breaks the consumer's *compile*, so it cannot
81/// reach a deploy without the consumer being rebuilt too.
82fn canon_type(
83 t: &TypeRef,
84 types: &HashMap<String, Arc<TypeDecl>>,
85 seen: &mut HashSet<String>,
86) -> String {
87 canon_type_in(t, types, seen, &HashMap::new())
88}
89
90/// `subst` binds a generic declaration's type-parameter **names** to the
91/// canonical form of the concrete argument supplied at the use site.
92///
93/// A generic body MUST expand with its parameters substituted, or the
94/// parameter's *name* leaks into the form: `type Page[T] = { items: List[T] }`
95/// and the same declaration spelled with `U` are the same type with the same
96/// wire shape, but would hash differently. Across a deploy that renames a type
97/// parameter — a pure refactor with no wire consequence — every call would 409.
98/// That is the same class of spurious failure the sorted fields and the
99/// predicate set exist to prevent, and it is the one the module's own standard
100/// ("semantically-equal contracts must hash equal") forbids.
101fn canon_type_in(
102 t: &TypeRef,
103 types: &HashMap<String, Arc<TypeDecl>>,
104 seen: &mut HashSet<String>,
105 subst: &HashMap<String, String>,
106) -> String {
107 match t {
108 TypeRef::Base(b, _) => b.name().to_string(),
109 TypeRef::Unit(_) => "()".to_string(),
110 // An `Effect` wraps the handler, not the payload — the caller awaits the
111 // promise, so it is not part of the wire contract.
112 TypeRef::Effect(inner, _) => canon_type_in(inner, types, seen, subst),
113 TypeRef::List(a, _) => format!("List[{}]", canon_type_in(a, types, seen, subst)),
114 TypeRef::Option(a, _) => format!("Option[{}]", canon_type_in(a, types, seen, subst)),
115 TypeRef::Result(a, b, _) => format!(
116 "Result[{}, {}]",
117 canon_type_in(a, types, seen, subst),
118 canon_type_in(b, types, seen, subst)
119 ),
120 TypeRef::Map(k, v, _) => format!(
121 "Map[{}, {}]",
122 canon_type_in(k, types, seen, subst),
123 canon_type_in(v, types, seen, subst)
124 ),
125 // Generic-record instantiation: the arguments are positional, so their
126 // order *is* semantic and is preserved (unlike a record's fields).
127 TypeRef::App { name, args, .. } => {
128 let inner: Vec<String> = args
129 .iter()
130 .map(|a| canon_type_in(a, types, seen, subst))
131 .collect();
132 // Bind the declaration's parameters to these arguments so the body
133 // expands over concrete types and the parameter's name never reaches
134 // the form.
135 let bound: HashMap<String, String> = types
136 .get(&name.name)
137 .map(|d| {
138 d.type_params
139 .iter()
140 .zip(&inner)
141 .map(|(p, a)| (p.name.name.clone(), a.clone()))
142 .collect()
143 })
144 .unwrap_or_default();
145 let head = canon_named_in(&name.name, types, seen, &bound);
146 format!("{head}[{}]", inner.join(", "))
147 }
148 TypeRef::Named(id) => {
149 // A bound type parameter renders as the argument it stands for.
150 match subst.get(&id.name) {
151 Some(bound) => bound.clone(),
152 None => canon_named_in(&id.name, types, seen, subst),
153 }
154 }
155 TypeRef::HttpResult(a, _) => {
156 format!("HttpResult[{}]", canon_type_in(a, types, seen, subst))
157 }
158 TypeRef::ValidationError(_) => "ValidationError".to_string(),
159 TypeRef::JsonError(_) => "JsonError".to_string(),
160 TypeRef::QueueResult(_) => "QueueResult".to_string(),
161 // The confined family is rejected at every boundary, so it cannot appear
162 // in a contract. Render it rather than panic: the normal form is also a
163 // diagnostic surface, and a compiler bug should not become a crash here.
164 TypeRef::Fn(..)
165 | TypeRef::Query(..)
166 | TypeRef::Stream(..)
167 | TypeRef::Connection(..)
168 | TypeRef::History(..) => "<non-boundary>".to_string(),
169 }
170}
171
172fn canon_named_in(
173 name: &str,
174 types: &HashMap<String, Arc<TypeDecl>>,
175 seen: &mut HashSet<String>,
176 subst: &HashMap<String, String>,
177) -> String {
178 // A recursive record terminates on the data, so its codec is finite and it
179 // is a legal contract — but its *expansion* is not. Emit a back-reference on
180 // revisit. `type Node = { next: Option[Node] }` canonicalises as
181 // `Node{next: Option[@Node]}` — the cycle is named, so two different
182 // recursive shapes still differ.
183 if !seen.insert(name.to_string()) {
184 return format!("@{name}");
185 }
186 let Some(decl) = types.get(name) else {
187 // Not in the callee's table: a runtime- or compiler-known name with no
188 // declaration to expand. The name alone is the whole contract for it.
189 seen.remove(name);
190 return name.to_string();
191 };
192 let body = match &decl.body {
193 // Record fields sort by name: a JSON object is unordered, so field
194 // *order* is not wire-observable and must not perturb the hash — while
195 // field *presence* and type are exactly what the hash exists to pin.
196 TypeBody::Record(r) => {
197 let mut fields: Vec<String> = r
198 .fields
199 .iter()
200 .map(|f| {
201 format!(
202 "{}: {}",
203 f.name.name,
204 canon_type_in(&f.type_ref, types, seen, subst)
205 )
206 })
207 .collect();
208 fields.sort();
209 format!("{{{}}}", fields.join(", "))
210 }
211 // Variants sort by name for the same reason: the wire carries a `kind`
212 // discriminant, so declaration order is invisible to it.
213 TypeBody::Sum(s) => {
214 let mut variants: Vec<String> = s
215 .variants
216 .iter()
217 .map(|v| {
218 let payload: Vec<String> = v
219 .payload
220 .iter()
221 .map(|p| canon_type_in(&p.type_ref, types, seen, subst))
222 .collect();
223 if payload.is_empty() {
224 v.name.name.clone()
225 } else {
226 format!("{}({})", v.name.name, payload.join(", "))
227 }
228 })
229 .collect();
230 variants.sort();
231 format!("|{}", variants.join("|"))
232 }
233 TypeBody::Refined {
234 base, refinement, ..
235 } => {
236 format!("{} {}", base.name(), canon_refinement(refinement.as_ref()))
237 }
238 // An **opaque** type's predicate is deliberately excluded — only its
239 // representation is part of the contract.
240 //
241 // The consumer cannot see the predicate by construction (that is what
242 // `exports opaque` means), so no consumer behaviour can depend on it: it
243 // can hold and pass an `AuthId`, never inspect or mint one. Including the
244 // predicate would therefore manufacture skew failures between two
245 // contexts that cannot disagree — the owner tightening `Matches(...)`
246 // would 409 every caller for a change none of them can observe. This is
247 // the same position ADR 0199 took on opacity, from the same premise.
248 TypeBody::Opaque { base, .. } => format!("{} opaque", base.name()),
249 };
250 seen.remove(name);
251 format!("{name}{body}")
252}
253
254/// Predicates canonicalise as a **sorted set**.
255///
256/// This is not a nicety adjacent to the hash; it is a precondition for it.
257/// Predicates are conjunctive and side-effect-free, so `String where NonEmpty,
258/// MaxLen(10)` and `String where MaxLen(10), NonEmpty` are the *same type* — and
259/// hashing them in source order would make two contexts that agree perfectly
260/// fail closed against each other. The same normal form also backs the checker's
261/// `refinements_match`, so the matcher and the hash cannot disagree about what
262/// "the same refinement" means.
263pub fn canon_refinement(r: Option<&Refinement>) -> String {
264 let Some(r) = r else {
265 return String::new();
266 };
267 let mut preds: Vec<String> = r
268 .predicates
269 .iter()
270 .map(|p| canon_predicate(&p.kind))
271 .collect();
272 preds.sort();
273 preds.dedup();
274 format!("where {}", preds.join(", "))
275}
276
277pub fn canon_predicate(p: &PredKind) -> String {
278 match p {
279 PredKind::Matches(s) => format!("Matches({s:?})"),
280 // Bounds keep their source lexemes elsewhere (byte-stable emission), but
281 // a contract is about *values*: `1` and `01` are the same bound, so the
282 // parsed value is what canonicalises.
283 PredKind::InRange(a, b) => format!("InRange({}, {})", a.value, b.value),
284 PredKind::InRangeF(a, b) => format!("InRangeF({}, {})", a.value, b.value),
285 PredKind::MinLength(n) => format!("MinLength({n})"),
286 PredKind::MaxLength(n) => format!("MaxLength({n})"),
287 PredKind::Length(n) => format!("Length({n})"),
288 // R12.2 names these as sugar for `InRange(0, ∞)`/`InRange(1, ∞)`, but the
289 // fold stays undone (#1049): neither base has a writable literal bound
290 // that stands for `∞` — the lexer rejects any float literal that would
291 // parse to infinity, and `Int`'s `i64::MAX` is a real, arbitrary finite
292 // bound, not the language's spelling of "unbounded". Folding onto an
293 // invented string nothing else can produce would only rename the
294 // literal, at the cost of a contract-hash change for every boundary
295 // type carrying one. Revisit alongside R12.3 (entailment), which is
296 // the actual consumer of a normalised Interval domain.
297 PredKind::NonNegative => "NonNegative".to_string(),
298 PredKind::Positive => "Positive".to_string(),
299 // `NonEmpty` is sugar for `MinLength(1)` (R12.2) — folding it here makes
300 // `String where NonEmpty` and `String where MinLength(1)` the same
301 // canonical form, so `service_contract_hash` and `refinements_match`
302 // agree that they are the same type. Unconditional: `NonEmpty` only
303 // ever applies to `BaseType::String` (`refinements.rs`'s
304 // `pred_applies_to`), so no base needs threading through here.
305 PredKind::NonEmpty => "MinLength(1)".to_string(),
306 }
307}
308
309/// FNV-1a (64-bit) over the canonical form, rendered as 16 lowercase hex chars.
310///
311/// **Why not a cryptographic hash.** Trust here is static and channel-based, and
312/// this increment does not change that (ADR 0092): `/_bynk/call/` is
313/// platform-dispatched and not externally routable, every context in a
314/// deployment is one trust domain, and a malicious first-party context is out of
315/// the threat model. This is a **skew detector, not a security control** — an
316/// accident detector. Forging it buys an attacker nothing they could not already
317/// do, so `sha2`'s ~6-crate dependency tree would buy nothing either. A
318/// collision degrades to *today's* behaviour for that one pair (an undetected
319/// skew), not to something worse, and at ~1e-14 for a 1000-contract project it
320/// is not the risk worth engineering against.
321///
322/// **Why hand-rolled.** `std::collections::hash_map::DefaultHasher` is
323/// explicitly not stable across Rust releases, so it cannot back a value that
324/// crosses a wire or is compared between two separately-compiled binaries. FNV-1a
325/// is fully specified, so two compilers agree forever.
326pub fn contract_hash(normal_form: &str) -> String {
327 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
328 const PRIME: u64 = 0x0000_0100_0000_01b3;
329 let mut h = OFFSET;
330 for b in normal_form.as_bytes() {
331 h ^= *b as u64;
332 h = h.wrapping_mul(PRIME);
333 }
334 format!("{h:016x}")
335}
336
337/// The stamped contract hash for one consumed service.
338pub fn service_contract_hash(
339 svc: &CrossContextService,
340 types: &HashMap<String, Arc<TypeDecl>>,
341) -> String {
342 contract_hash(&service_normal_form(svc, types))
343}
344
345/// v0.177 (#643): a context's own `on call` contract hashes, keyed by service
346/// name — the constants its Worker entry compares an incoming
347/// `X-Bynk-Contract` against.
348///
349/// Built by projecting each local `on call` handler into the **same**
350/// [`CrossContextService`] shape [`crate::symbols::build_cross_context_info`]
351/// hands a *caller* for the same service, via the one shared projection
352/// [`cross_context_service_for`], and hashing it from the same combined type
353/// table. That symmetry is the whole correctness argument: a caller and a
354/// callee compiled from one source tree must agree, or the check fires on
355/// every call instead of only on real skew — sharing the projection makes the
356/// agreement structural rather than two hand-written copies staying in sync
357/// by convention.
358pub fn own_contract_hashes(
359 table: &UnitTable,
360 own_types: &HashMap<String, Arc<TypeDecl>>,
361) -> BTreeMap<String, String> {
362 let mut out = BTreeMap::new();
363 for (sname, sdecl) in &table.services {
364 let Some(svc) = cross_context_service_for(sname, sdecl) else {
365 continue;
366 };
367 out.insert(sname.clone(), service_contract_hash(&svc, own_types));
368 }
369 out
370}
371
372/// P8.1 (#1512, [DECISION C]): the canonical form of a body-free parameter
373/// list — shared by [`canon_fn_signature`] and [`canon_handler_signature`] so
374/// the two categories can't drift on how a parameter renders.
375fn canon_params(params: &[ParamSignature], types: &HashMap<String, Arc<TypeDecl>>) -> String {
376 let mut out = String::new();
377 for (i, p) in params.iter().enumerate() {
378 if i > 0 {
379 out.push_str(", ");
380 }
381 let _ = write!(
382 out,
383 "{}: {}",
384 p.name,
385 canon_type(&p.type_ref, types, &mut HashSet::new())
386 );
387 }
388 out
389}
390
391/// P8.1 (#1512, [DECISION C]): the canonical form of a [`FnSignature`] —
392/// extends this module's own `canon_type` (ADR 0200) to a shape it doesn't
393/// reach today. Body-free by construction ([DECISION B]): there is no
394/// `body`/`requires`/`ensures` field on `FnSignature` to accidentally
395/// include here.
396pub fn canon_fn_signature(f: &FnSignature, types: &HashMap<String, Arc<TypeDecl>>) -> String {
397 let mut out = String::new();
398 let _ = write!(out, "fn {}", f.name);
399 if !f.type_params.is_empty() {
400 let _ = write!(out, "[{}]", f.type_params.join(", "));
401 }
402 let _ = write!(out, "({})", canon_params(&f.params, types));
403 let _ = write!(
404 out,
405 " -> {}",
406 canon_type(&f.return_type, types, &mut HashSet::new())
407 );
408 if f.has_self {
409 out.push_str(" self");
410 }
411 out
412}
413
414/// P8.1 (#1512, [DECISION C]): the canonical form of a [`HandlerKindSignature`].
415/// Added alongside the rest of [`canon_handler_signature`] by PR #1517's own
416/// bot review (finding #1) — every field here is already a plain value
417/// (`HttpMethod`'s own rendered name, a route/cron `String`), so nothing
418/// needs excluding beyond the enum discriminant itself carrying no `Span`.
419fn canon_handler_kind(k: &HandlerKindSignature) -> String {
420 match k {
421 HandlerKindSignature::Call => "call".to_string(),
422 HandlerKindSignature::Http { method, path } => format!("http {method} {path:?}"),
423 HandlerKindSignature::Cron { expr } => format!("cron {expr:?}"),
424 HandlerKindSignature::Message => "message".to_string(),
425 HandlerKindSignature::Open => "open".to_string(),
426 HandlerKindSignature::Close => "close".to_string(),
427 HandlerKindSignature::Event => "event".to_string(),
428 }
429}
430
431/// P8.1 (#1512, [DECISION C]): the canonical form of a [`HandlerSignature`].
432/// `given` renders sorted — its rendered `CapRef` names have no meaningful
433/// order to preserve (it's a set, not a sequence). `kind` is rendered first
434/// (PR #1517's own bot review, finding #1) — two handlers that differ only
435/// in HTTP method or route, or in `on call` vs. `on message`, must not
436/// canonicalise identically.
437pub fn canon_handler_signature(
438 h: &HandlerSignature,
439 types: &HashMap<String, Arc<TypeDecl>>,
440) -> String {
441 let mut out = String::new();
442 let _ = write!(out, "on {}", canon_handler_kind(&h.kind));
443 if let Some(m) = &h.method_name {
444 let _ = write!(out, " {m}");
445 }
446 let _ = write!(out, "({})", canon_params(&h.params, types));
447 let _ = write!(
448 out,
449 " -> {}",
450 canon_type(&h.return_type, types, &mut HashSet::new())
451 );
452 if !h.given.is_empty() {
453 let mut given = h.given.clone();
454 given.sort();
455 let _ = write!(out, " given {}", given.join(", "));
456 }
457 out
458}
459
460/// P8.1 (#1512, [DECISION C]): the canonical form of a [`CapabilityOpSignature`]
461/// — added by PR #1517's own bot review (finding #3): a `capability`
462/// declaration's own ops are the abstract signature a consumer's
463/// `Cap.op(...)` call site compiles against.
464pub fn canon_capability_op_signature(
465 op: &CapabilityOpSignature,
466 types: &HashMap<String, Arc<TypeDecl>>,
467) -> String {
468 let mut out = String::new();
469 let _ = write!(out, "fn {}", op.name);
470 if !op.type_params.is_empty() {
471 let _ = write!(out, "[{}]", op.type_params.join(", "));
472 }
473 let _ = write!(out, "({})", canon_params(&op.params, types));
474 let _ = write!(
475 out,
476 " -> {}",
477 canon_type(&op.return_type, types, &mut HashSet::new())
478 );
479 out
480}
481
482/// P8.1 (#1512, [DECISION C]): the canonical form of a [`ProtocolSignature`]
483/// — added by PR #1517's own bot review ("worth a look"): `from http` vs.
484/// `from queue(...)` vs. `from websocket(...)` changes a service's entire
485/// external surface. `Events`' own `pattern`/`schema_dispatch` render only as
486/// presence (see `unit_signature.rs`'s own module doc comment for why).
487fn canon_protocol_signature(
488 p: &ProtocolSignature,
489 types: &HashMap<String, Arc<TypeDecl>>,
490) -> String {
491 match p {
492 ProtocolSignature::Call => "call".to_string(),
493 ProtocolSignature::Http => "http".to_string(),
494 ProtocolSignature::Cron => "cron".to_string(),
495 ProtocolSignature::Queue { name } => format!("queue {name:?}"),
496 ProtocolSignature::WebSocket { in_type, out_type } => format!(
497 "websocket in={} out={}",
498 canon_type(in_type, types, &mut HashSet::new()),
499 canon_type(out_type, types, &mut HashSet::new())
500 ),
501 ProtocolSignature::Events {
502 event_type,
503 has_pattern,
504 has_schema_dispatch,
505 } => format!(
506 "events {} pattern={has_pattern} schema_dispatch={has_schema_dispatch}",
507 canon_type(event_type, types, &mut HashSet::new())
508 ),
509 }
510}
511
512/// P8.1 (#1512, [DECISION C]): the canonical form of a [`StoreFieldSignature`].
513pub fn canon_store_field_signature(
514 f: &StoreFieldSignature,
515 types: &HashMap<String, Arc<TypeDecl>>,
516) -> String {
517 let mut out = String::new();
518 let _ = write!(out, "{}: {}", f.name, f.kind_head);
519 if !f.kind_args.is_empty() {
520 let args: Vec<String> = f
521 .kind_args
522 .iter()
523 .map(|a| canon_type(a, types, &mut HashSet::new()))
524 .collect();
525 let _ = write!(out, "[{}]", args.join(", "));
526 }
527 out
528}
529
530/// P8.1 (#1512, [DECISION C]): the canonical form of a unit's own combined
531/// type table — the cross-context-types category, `combined_types_for`'s
532/// output reused unchanged. Each named type expands structurally via
533/// [`canon_named_in`] (through a synthetic, zero-span [`TypeRef::Named`] —
534/// spans are erased by every canonical renderer in this module already, so a
535/// dummy span here carries no information loss), sorted by name so map
536/// iteration order can't perturb the rendering.
537fn canon_types_table(types: &HashMap<String, Arc<TypeDecl>>) -> String {
538 let mut names: Vec<&String> = types.keys().collect();
539 names.sort();
540 let mut out = String::new();
541 for name in names {
542 let named = TypeRef::Named(Ident {
543 name: name.clone(),
544 span: Span::new(0, 0),
545 });
546 let _ = write!(
547 out,
548 "{}={};",
549 name,
550 canon_type(&named, types, &mut HashSet::new())
551 );
552 }
553 out
554}
555
556/// P8.1 (#1512, [DECISION C]): [`UnitSignature::canonical`]'s own
557/// implementation — the single rendering R3.14's own stability proof
558/// compares. Every category is rendered sorted (by the caller's own
559/// `BTreeMap`/`BTreeSet` keys, or an explicit sort for `Vec`-shaped fields
560/// like a handler's own `given`), so construction order never perturbs the
561/// form — the same "compare the canonical string, not map/vec iteration
562/// order" discipline [`service_normal_form`]'s record-field sort already
563/// established for ADR 0200.
564pub fn canon_unit_signature(sig: &UnitSignature) -> String {
565 let types = &sig.combined_types;
566 let mut out = String::new();
567 let _ = writeln!(out, "unit {}", sig.id.0);
568 let _ = writeln!(out, "types: {}", canon_types_table(types));
569
570 out.push_str("fns:\n");
571 for f in sig.fns.values() {
572 let _ = writeln!(out, " {}", canon_fn_signature(f, types));
573 }
574
575 // Methods (PR #1517's own bot review, finding #2): rendered per owning
576 // type, instance and static kept in their own sections so a rename
577 // between the two buckets is visible too, not just a param/return change
578 // within one.
579 out.push_str("methods:\n");
580 for (owner, mt) in &sig.methods {
581 let MethodTableSignature { instance, statics } = mt;
582 for f in instance.values() {
583 let _ = writeln!(out, " {owner}.{}", canon_fn_signature(f, types));
584 }
585 for f in statics.values() {
586 let _ = writeln!(out, " {owner}::{}", canon_fn_signature(f, types));
587 }
588 }
589
590 out.push_str("service_handlers:\n");
591 for (owner, hs) in &sig.service_handlers {
592 for h in hs {
593 let _ = writeln!(out, " {owner}: {}", canon_handler_signature(h, types));
594 }
595 }
596
597 out.push_str("agent_handlers:\n");
598 for (owner, hs) in &sig.agent_handlers {
599 for h in hs {
600 let _ = writeln!(out, " {owner}: {}", canon_handler_signature(h, types));
601 }
602 }
603
604 out.push_str("service_protocols:\n");
605 for (owner, p) in &sig.service_protocols {
606 let _ = writeln!(out, " {owner}: {}", canon_protocol_signature(p, types));
607 }
608
609 out.push_str("store:\n");
610 for (owner, fs) in &sig.store_fields {
611 for f in fs {
612 let _ = writeln!(out, " {owner}: {}", canon_store_field_signature(f, types));
613 }
614 }
615
616 out.push_str("capabilities:\n");
617 for c in &sig.capabilities.exported {
618 let _ = writeln!(out, " exports {c}");
619 }
620 for (cap, ops) in &sig.capabilities.declared {
621 for op in ops {
622 let _ = writeln!(
623 out,
624 " declares {cap}.{}",
625 canon_capability_op_signature(op, types)
626 );
627 }
628 }
629 for (cap, given) in &sig.capabilities.provider_given {
630 let mut given = given.clone();
631 given.sort();
632 let _ = writeln!(out, " provides {cap} given {}", given.join(", "));
633 }
634 for (svc, given) in &sig.capabilities.service_given {
635 let mut given = given.clone();
636 given.sort();
637 let _ = writeln!(out, " service {svc} given {}", given.join(", "));
638 }
639
640 out
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use bynk_syntax::ast::RefinementPred;
647
648 #[test]
649 fn predicate_order_does_not_change_the_form() {
650 // The precondition the whole increment rests on: `String where NonEmpty,
651 // MaxLen(10)` and the same predicates reordered are the *same type*, so
652 // they must canonicalise — and therefore hash — identically. Hashing
653 // source order would 409 two contexts that agree perfectly.
654 let a = Refinement {
655 predicates: vec![
656 RefinementPred {
657 kind: PredKind::NonEmpty,
658 span: sp(),
659 },
660 RefinementPred {
661 kind: PredKind::MaxLength(10),
662 span: sp(),
663 },
664 ],
665 span: sp(),
666 };
667 let b = Refinement {
668 predicates: vec![
669 RefinementPred {
670 kind: PredKind::MaxLength(10),
671 span: sp(),
672 },
673 RefinementPred {
674 kind: PredKind::NonEmpty,
675 span: sp(),
676 },
677 ],
678 span: sp(),
679 };
680 assert_eq!(canon_refinement(Some(&a)), canon_refinement(Some(&b)));
681 assert_eq!(
682 contract_hash(&canon_refinement(Some(&a))),
683 contract_hash(&canon_refinement(Some(&b)))
684 );
685 }
686
687 #[test]
688 fn a_different_predicate_set_changes_the_form() {
689 let a = Refinement {
690 predicates: vec![RefinementPred {
691 kind: PredKind::MaxLength(10),
692 span: sp(),
693 }],
694 span: sp(),
695 };
696 let b = Refinement {
697 predicates: vec![RefinementPred {
698 kind: PredKind::MaxLength(11),
699 span: sp(),
700 }],
701 span: sp(),
702 };
703 assert_ne!(canon_refinement(Some(&a)), canon_refinement(Some(&b)));
704 }
705
706 #[test]
707 fn fnv1a_matches_the_published_vectors() {
708 // FNV-1a 64-bit reference vectors. The point of hand-rolling a
709 // *specified* hash is that two compilers agree forever; pin it.
710 assert_eq!(contract_hash(""), "cbf29ce484222325");
711 assert_eq!(contract_hash("a"), "af63dc4c8601ec8c");
712 assert_eq!(contract_hash("foobar"), "85944171f73967e8");
713 }
714
715 /// Build a type table by parsing a `commons`, so these tests exercise real
716 /// declarations rather than hand-assembled AST.
717 fn types_of(src: &str) -> HashMap<String, Arc<TypeDecl>> {
718 let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
719 let commons = bynk_syntax::parser::parse(&tokens, src).expect("parse");
720 commons
721 .items
722 .iter()
723 .filter_map(|i| match i {
724 bynk_syntax::ast::CommonsItem::Type(t) => {
725 Some((t.name.name.clone(), Arc::new(t.clone())))
726 }
727 _ => None,
728 })
729 .collect()
730 }
731
732 fn named(n: &str) -> TypeRef {
733 TypeRef::Named(bynk_syntax::ast::Ident {
734 name: n.to_string(),
735 span: sp(),
736 })
737 }
738
739 fn svc(param_ty: TypeRef) -> CrossContextService {
740 CrossContextService {
741 name: "probe".to_string(),
742 params: vec![("p".to_string(), param_ty)],
743 return_type: TypeRef::Base(bynk_syntax::ast::BaseType::String, sp()),
744 span: sp(),
745 }
746 }
747
748 /// A record's **field order** is not wire-observable — a JSON object is
749 /// unordered — so it must not move the hash. This is the false-positive side,
750 /// and it is the one that matters most: a spurious 409 breaks a working
751 /// deployment.
752 #[test]
753 fn record_field_order_does_not_change_the_hash() {
754 let a = types_of("commons x\n\ntype P = { one: Int, two: String }\n");
755 let b = types_of("commons x\n\ntype P = { two: String, one: Int }\n");
756 assert_eq!(
757 service_contract_hash(&svc(named("P")), &a),
758 service_contract_hash(&svc(named("P")), &b),
759 );
760 }
761
762 /// Field **presence** and **type**, by contrast, are exactly what the hash
763 /// exists to pin. These are the cases a co-compiled build rejects
764 /// structurally — but a skewed *deploy* cannot, which is why the hash carries
765 /// them.
766 #[test]
767 fn field_presence_name_and_type_change_the_hash() {
768 let base = types_of("commons x\n\ntype P = { one: Int, two: String }\n");
769 let h = service_contract_hash(&svc(named("P")), &base);
770
771 let renamed = types_of("commons x\n\ntype P = { one: Int, three: String }\n");
772 assert_ne!(
773 h,
774 service_contract_hash(&svc(named("P")), &renamed),
775 "rename"
776 );
777
778 let dropped = types_of("commons x\n\ntype P = { two: String }\n");
779 assert_ne!(h, service_contract_hash(&svc(named("P")), &dropped), "drop");
780
781 let retyped = types_of("commons x\n\ntype P = { one: String, two: String }\n");
782 assert_ne!(
783 h,
784 service_contract_hash(&svc(named("P")), &retyped),
785 "retype"
786 );
787 }
788
789 /// A sum's **variant order** is invisible to the wire (the payload carries a
790 /// `kind` discriminant); its variant *set* is not.
791 #[test]
792 fn sum_variant_order_does_not_change_the_hash_but_the_set_does() {
793 let a = types_of("commons x\n\ntype E = enum { Alpha, Beta }\n");
794 let b = types_of("commons x\n\ntype E = enum { Beta, Alpha }\n");
795 assert_eq!(
796 service_contract_hash(&svc(named("E")), &a),
797 service_contract_hash(&svc(named("E")), &b),
798 );
799 let c = types_of("commons x\n\ntype E = enum { Alpha, Gamma }\n");
800 assert_ne!(
801 service_contract_hash(&svc(named("E")), &a),
802 service_contract_hash(&svc(named("E")), &c),
803 );
804 }
805
806 /// An **opaque** type's predicate is excluded: the consumer cannot see it by
807 /// construction, so no consumer behaviour can depend on it, and including it
808 /// would manufacture skew between two contexts that cannot disagree. Its
809 /// *representation* is still part of the contract.
810 #[test]
811 fn an_opaque_types_predicate_is_excluded_but_its_representation_is_not() {
812 let loose = types_of("commons x\n\ntype Id = opaque String where NonEmpty\n");
813 let tight = types_of("commons x\n\ntype Id = opaque String where MaxLength(4)\n");
814 assert_eq!(
815 service_contract_hash(&svc(named("Id")), &loose),
816 service_contract_hash(&svc(named("Id")), &tight),
817 "tightening an opaque predicate must not 409 a caller that cannot see it"
818 );
819
820 // A *transparent* refined type is the opposite: the consumer can see the
821 // predicate, so it is part of the contract.
822 let ra = types_of("commons x\n\ntype C = String where MaxLength(4)\n");
823 let rb = types_of("commons x\n\ntype C = String where MaxLength(5)\n");
824 assert_ne!(
825 service_contract_hash(&svc(named("C")), &ra),
826 service_contract_hash(&svc(named("C")), &rb),
827 );
828 }
829
830 /// `NonEmpty` is sugar for `MinLength(1)` (R12.2, T1.8, Decision A) —
831 /// pinned directly against the canonicaliser so a future edit to this arm
832 /// is a deliberate, visible change rather than a silent regression.
833 #[test]
834 fn non_empty_canonicalises_to_min_length_one() {
835 assert_eq!(canon_predicate(&PredKind::NonEmpty), "MinLength(1)");
836 }
837
838 /// The counterpart to `non_empty_canonicalises_to_min_length_one`: the
839 /// `Positive`/`NonNegative` → `InRange` fold is **declined** (#1049) —
840 /// neither base has a writable bound standing for `∞`. Pinned so
841 /// reversing that decision trips a named test rather than a fixture
842 /// hash.
843 #[test]
844 fn positive_and_non_negative_stay_their_own_canonical_literals() {
845 assert_eq!(canon_predicate(&PredKind::Positive), "Positive");
846 assert_eq!(canon_predicate(&PredKind::NonNegative), "NonNegative");
847 }
848
849 /// The consequence that matters: two boundary types spelling the same
850 /// refinement differently must hash identically, or two contexts that
851 /// agree perfectly 409 each other.
852 #[test]
853 fn non_empty_and_min_length_one_hash_identically() {
854 let a = types_of("commons x\n\ntype C = String where NonEmpty\n");
855 let b = types_of("commons x\n\ntype C = String where MinLength(1)\n");
856 assert_eq!(
857 service_contract_hash(&svc(named("C")), &a),
858 service_contract_hash(&svc(named("C")), &b),
859 "NonEmpty and MinLength(1) are the same refinement and must hash the same"
860 );
861 }
862
863 /// After the fold, `NonEmpty` and `MinLength(1)` are the same canonical
864 /// string, so a redundant `NonEmpty && MinLength(1)` conjunction must dedup
865 /// to one entry, not two — the same idempotence `canon_refinement`'s sort+
866 /// dedup already promises for any other repeated predicate.
867 #[test]
868 fn non_empty_and_min_length_one_together_dedup_to_one_entry() {
869 let r = Refinement {
870 predicates: vec![
871 RefinementPred {
872 kind: PredKind::NonEmpty,
873 span: sp(),
874 },
875 RefinementPred {
876 kind: PredKind::MinLength(1),
877 span: sp(),
878 },
879 ],
880 span: sp(),
881 };
882 assert_eq!(canon_refinement(Some(&r)), "where MinLength(1)");
883 }
884
885 /// A recursive record is a legal contract (its codec terminates on the data),
886 /// but its expansion is not — the walk must terminate rather than blow the
887 /// stack, and two different recursive shapes must still differ.
888 #[test]
889 fn a_recursive_record_terminates_and_stays_distinguishable() {
890 let a = types_of("commons x\n\ntype Node = { v: Int, next: Option[Node] }\n");
891 let b = types_of("commons x\n\ntype Node = { v: String, next: Option[Node] }\n");
892 let ha = service_contract_hash(&svc(named("Node")), &a);
893 assert_ne!(ha, service_contract_hash(&svc(named("Node")), &b));
894 }
895
896 /// A generic type's **parameter name** is not wire-observable: `Page[T]` and
897 /// the same declaration spelled with `U` are the same type with the same
898 /// shape. Renaming one is a refactor, and must not 409 a caller.
899 ///
900 /// Caught in review of #658: the body used to expand with the parameter
901 /// *unsubstituted*, so the name leaked into the form. Same class as record
902 /// field order and predicate order — the false-positive side the module's
903 /// standard exists to protect.
904 #[test]
905 fn a_generic_type_parameter_rename_does_not_change_the_hash() {
906 let t = types_of(
907 "commons x\n\ntype Order = { id: Int }\ntype Page[T] = { items: List[T], total: Int }\n",
908 );
909 let u = types_of(
910 "commons x\n\ntype Order = { id: Int }\ntype Page[U] = { items: List[U], total: Int }\n",
911 );
912 let app = TypeRef::App {
913 name: bynk_syntax::ast::Ident {
914 name: "Page".to_string(),
915 span: sp(),
916 },
917 args: vec![named("Order")],
918 span: sp(),
919 };
920 assert_eq!(
921 service_contract_hash(&svc(app.clone()), &t),
922 service_contract_hash(&svc(app), &u),
923 "renaming a generic type parameter must not change the contract hash"
924 );
925 }
926
927 /// The converse: the *argument* a generic is instantiated at is entirely
928 /// wire-observable, so it must still move the hash.
929 #[test]
930 fn a_generic_argument_change_does_change_the_hash() {
931 let t = types_of(
932 "commons x\n\ntype Order = { id: Int }\ntype Other = { id: String }\ntype Page[T] = { items: List[T] }\n",
933 );
934 let app = |arg: &str| TypeRef::App {
935 name: bynk_syntax::ast::Ident {
936 name: "Page".to_string(),
937 span: sp(),
938 },
939 args: vec![named(arg)],
940 span: sp(),
941 };
942 assert_ne!(
943 service_contract_hash(&svc(app("Order")), &t),
944 service_contract_hash(&svc(app("Other")), &t),
945 );
946 }
947
948 /// And the parameter is genuinely *substituted*, not merely ignored: the form
949 /// shows the instantiated shape rather than a dangling `T`.
950 #[test]
951 fn a_generic_body_expands_over_its_concrete_argument() {
952 let t = types_of("commons x\n\ntype Page[T] = { items: List[T] }\n");
953 let app = TypeRef::App {
954 name: bynk_syntax::ast::Ident {
955 name: "Page".to_string(),
956 span: sp(),
957 },
958 args: vec![TypeRef::Base(bynk_syntax::ast::BaseType::Int, sp())],
959 span: sp(),
960 };
961 let nf = service_normal_form(&svc(app), &t);
962 assert!(nf.contains("List[Int]"), "{nf}");
963 assert!(
964 !nf.contains("List[T]"),
965 "the parameter must not survive: {nf}"
966 );
967 }
968
969 fn sp() -> bynk_syntax::span::Span {
970 bynk_syntax::span::Span::new(0, 0)
971 }
972}