Skip to main content

bynk_check/
contract.rs

1//! v0.177 (#643): the canonical normal form of a cross-context contract, and
2//! its hash.
3//!
4//! A `workers` build compiles context A against context B's contract, and
5//! nothing at runtime checks that the *deployed* B still matches what A was
6//! compiled against — `deploy --context NAME` institutionalises the skew. The
7//! fix is to stamp a hash of the compiled contract beside `X-Bynk-Caller` and
8//! fail closed on mismatch (ADR 0092's pattern: a compile-time constant in a
9//! reserved header, metadata beside the payload, no crypto).
10//!
11//! The hash is only as good as the form it hashes. Two rules make it usable:
12//!
13//! 1. **Semantically-equal contracts must hash equal**, or a working deployment
14//!    409s spuriously — which is worse than no check at all, because it breaks
15//!    what worked and destroys trust in the mechanism. This is why the form is
16//!    canonical (predicates as a sorted set, record fields sorted by name)
17//!    rather than a rendering of source order.
18//! 2. **Both sides must canonicalise the *same* thing.** The callee's contract
19//!    is canonicalised **in the callee's own namespace**, from the callee's own
20//!    type table, on *both* sides — never in the caller's. The caller reaches
21//!    that table through `consumed_types[callee]` and the callee through its own
22//!    combined table; both are produced by the same `combined_types_for`, so the
23//!    two views cannot diverge by construction. A caller never canonicalises a
24//!    consumed type in its own namespace, where its rebranding would make the
25//!    same type render differently.
26
27use std::collections::{BTreeMap, HashMap, HashSet};
28use std::fmt::Write as _;
29use std::sync::Arc;
30
31use bynk_syntax::ast::{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}