Skip to main content

ridl_diff/
classify.rs

1//! The breaking/compatible classifier.
2//!
3//! [`classify`] turns one structural [`Change`] from the walk into a directional
4//! [`Verdict`]. Direction is judged from the consumer's side (ADR-0008 decision
5//! 14): a change is breaking when it shifts or reuses a wire identity or narrows
6//! a consumer-visible guarantee, and compatible when it only relaxes or appends.
7//!
8//! The classifier reads the resolved IR of both sides, never source, so every
9//! bound it compares is the bound a backend sees. That is what makes the
10//! `[defaults].timing` rule need no special case: the manifest default is
11//! already resolved into every untimed interaction (ADR-0008 decision 12), so
12//! editing it arrives here as an ordinary [`Category::TimingChanged`] on each
13//! defaulted interaction and classifies by the bound rules below (ridl §9.1).
14//!
15//! Two properties are deliberate:
16//!
17//! - **No implication proving.** Contract clauses are carried as canonical
18//!   source text (ADR-0008 decision 14), so the classifier compares text. Any
19//!   `require` text change is breaking, and any `ensure` text change is
20//!   breaking; it never tries to show that one clause implies another.
21//! - **Unlisted is breaking.** A shape the table does not name is classified
22//!   breaking. A false "breaking" costs a maintainer one review; a false
23//!   "compatible" ships a wire break.
24
25use ridl_ir::v2;
26
27use crate::{Category, Change, Verdict, frozen};
28
29#[cfg(test)]
30mod classify_tests;
31
32/// Classifies one change against the two snapshots it was drawn from.
33///
34/// `old` and `new` are the matched packages the change belongs to; the change's
35/// path is resolved against them to recover the typed IR the direction is read
36/// from. For a package present on only one side the caller passes that package
37/// as both arguments — those changes classify on their category alone.
38///
39/// A reference to a declaration of another package is not resolved here, and a
40/// type the diff cannot resolve is reported as breaking: an appended struct
41/// field typed from another package classifies breaking.
42/// [`diff_sets_in`](crate::diff_sets_in) resolves such a reference against every
43/// package of the new snapshot and the context packages, such as `ridl.std`.
44pub fn classify(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
45    classify_in(change, old, new, &[])
46}
47
48/// [`classify`], with `scope` — every package of the new snapshot, and the
49/// context packages such as `ridl.std` — to resolve a reference to another
50/// package's declaration. Only the appended struct field reads it: whether its
51/// type is legal at 0 is decided by the declaration the new side names
52/// (driftsys/ridl#598).
53// A new variant must be given a real arm here, not swept into a
54// catch-all: rustc forces *an* arm, and the arm its `help:` text
55// proposes is `_ =>`, which classifies the new variant silently. The
56// two lints below reject a wildcard over `Category` — the first when
57// it covers several variants, the second when it covers exactly one,
58// which is the case one added variant creates.
59#[deny(
60    clippy::wildcard_enum_match_arm,
61    clippy::match_wildcard_for_single_variants
62)]
63pub(crate) fn classify_in(
64    change: &Change,
65    old: &v2::Package,
66    new: &v2::Package,
67    scope: &[&v2::Package],
68) -> Verdict {
69    match change.category {
70        // Shifts or reuses a wire identity, or replaces a wire-carrying type.
71        // Every one of these is breaking in either direction.
72        Category::InteractionInserted
73        | Category::InteractionReordered
74        | Category::MemberReordered
75        | Category::InteractionRemoved
76        | Category::ReservedNameRedeclared
77        | Category::KindChanged
78        | Category::PayloadChanged
79        | Category::ReturnChanged
80        | Category::ParamsChanged
81        | Category::WidthChanged
82        | Category::ServiceChanged
83        | Category::DeclRemoved => Verdict::Breaking,
84
85        // An init is part of the published contract (ridl §9.1): consumers read
86        // the pre-publish value. Neither reference states a compatible
87        // direction, so the change is breaking.
88        Category::InitChanged => Verdict::Breaking,
89
90        // Doc comments, labels, and deprecation notes reach no consumer's build.
91        // Visibility is not among them — it has its own category below.
92        //
93        // A tombstone in the retired interaction's own slot is the sanctioned
94        // retirement (ridl §11); the walk only emits `InteractionRetired` when
95        // the slot is preserved.
96        Category::DocOnly | Category::InteractionRetired => Verdict::Compatible,
97
98        // An interface's identity is its `interfaces.lock` number (lock
99        // design §7). A rename that keeps the number moves nothing on the
100        // wire — the routing key is the number — and is visible in source,
101        // which the text report's heading says of it. A number the new side's
102        // lock retires is the sanctioned removal: the entry keeps the number
103        // forever, so it is never allocated again.
104        Category::InterfaceRenamed | Category::InterfaceRetired => Verdict::Compatible,
105
106        // `ridl lock` freezing a provisional number in place keeps the
107        // routing key, so nothing moves on the wire; the catalog hash, which
108        // covers the flag, changes, which the text report's heading says of
109        // it. A number that differs between the two sides is a new routing
110        // key for the same interface.
111        Category::InterfaceFrozen => Verdict::Compatible,
112        Category::InterfaceNumberChanged => Verdict::Breaking,
113
114        // A payload limit is part of the catalog hash, so peers with different
115        // limits do not attach. Raising, lowering, adding and removing one are
116        // all breaking (ADR-0030 decision 13).
117        Category::LimitChanged => Verdict::Breaking,
118
119        // An enum value or enum-set bit takes its identity from its explicit
120        // number (typl §8, §9), so a body that differs only in order moves
121        // nothing on the wire. The catalog hash covers that order when an
122        // interface reaches the enum, which the text report's heading says of
123        // it.
124        Category::EnumReordered => Verdict::Compatible,
125
126        // A service's list is a set of interface references (ADR-0015
127        // decision 19 as amended on 2026-09-15). The routing key does not
128        // contain the service, so an interface joining or leaving the set
129        // moves no wire identity: both directions are compatible. A removal
130        // is still visible in source — the `service.member` addresses of that
131        // interface stop resolving under the service — which is what the text
132        // report's heading says of it.
133        Category::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,
134
135        Category::VisibilityChanged => visibility(change, old, new),
136        Category::InteractionAppended => appended(change, old, new),
137        Category::DeclAdded => added(change, old, new, scope),
138        Category::ConstraintChanged => constraint(change, old, new),
139        Category::TimingChanged => timing(change, old, new),
140        Category::RpcBoundChanged => rpc_bound(change, old, new),
141        Category::ContractChanged => contract(change, old, new),
142        // A family or shape is part of the contract a consumer compiled
143        // against; a change moves it in both directions (ADR-0012 decision
144        // 12).
145        Category::InteractionClassChanged => Verdict::Breaking,
146    }
147}
148
149// ==========================================================================
150// Visibility.
151// ==========================================================================
152
153/// A change to the visibility a declaration is published at.
154///
155/// Visibility rides the surface grammar next to doc comments, but it is not
156/// metadata to a consumer. `internal` maps to the target's package-private
157/// mechanism — Rust `pub(crate)`, a non-exported TypeScript member (ADR-0002
158/// §8) — so narrowing `public` to `internal` deletes the declaration from every
159/// out-of-package consumer's build. That is the plainest form of "narrows a
160/// consumer-visible guarantee" in ADR-0008 decision 14, even though the byte
161/// layout on the wire never moves.
162///
163/// Widening `internal` to `public` only offers more, so it is compatible. Any
164/// direction involving an unset visibility is breaking, following the module's
165/// unlisted-is-breaking rule.
166fn visibility(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
167    let (Some(old_visibility), Some(new_visibility)) = (
168        find_visibility(old, &change.path, |name| find_old_shape(old, new, name)),
169        find_visibility(new, &change.path, |name| find_shape(new, name)),
170    ) else {
171        return Verdict::Breaking;
172    };
173
174    match (
175        v2::Visibility::try_from(old_visibility),
176        v2::Visibility::try_from(new_visibility),
177    ) {
178        (Ok(v2::Visibility::Internal), Ok(v2::Visibility::Public)) => Verdict::Compatible,
179        _ => Verdict::Breaking,
180    }
181}
182
183/// The visibility published at a change's path: an interaction inside an
184/// interface or service shape, or a package-level declaration, interface, or
185/// service. `shape_of` resolves the container name the path carries to this
186/// side's shape — by name on the new side, and on the old side the way the
187/// walk matched it ([`find_old_shape`]), since a renamed interface's path
188/// carries only its new name.
189fn find_visibility<'a>(
190    package: &'a v2::Package,
191    path: &str,
192    shape_of: impl Fn(&str) -> Option<v2::InterfaceShape<'a>>,
193) -> Option<i32> {
194    let mut segments = path.split('/').skip(1);
195    let name = segments.next()?;
196
197    if let Some(member) = segments.next() {
198        return shape_of(name)?
199            .interface
200            .interactions
201            .iter()
202            .find(|decl| decl.name == member)
203            .map(|decl| decl.visibility);
204    }
205    if let Some(decl) = find_decl(package, name) {
206        return Some(decl.visibility);
207    }
208    // `Package::shapes` answers for a named interface and for a service with an
209    // inline shape, and `InterfaceShape::visibility` reads the authoritative
210    // field in each case — the owning service's for an inline shape, whose own
211    // `Interface.visibility` is `VISIBILITY_UNSPECIFIED` by construction.
212    if let Some(shape) = shape_of(name) {
213        return Some(shape.visibility());
214    }
215    // A service naming an interface after `:` carries no shape of its own, so
216    // it is not in `shapes()`; its visibility is still the service's.
217    package
218        .services
219        .iter()
220        .find(|service| service.name == name)
221        .map(|service| service.visibility)
222}
223
224// ==========================================================================
225// Interaction append — the identity-reuse guard.
226// ==========================================================================
227
228/// An interaction (or a freshly minted tombstone) added after every slot that
229/// existed before. Appending is compatible, but only when the slot it takes was
230/// never occupied: an ordinal freed by an untombstoned removal and handed to a
231/// new name **reuses a wire identity**, which ADR-0008 decision 14 lists first
232/// among breaking changes.
233///
234/// The walk labels that case `InteractionAppended` because the new name does sit
235/// after every surviving slot; the reuse is only visible by looking back at what
236/// the old snapshot held at that ordinal, which is why the check lives here and
237/// not in the walk.
238fn appended(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
239    let Some((container, member)) = member_path(change) else {
240        return Verdict::Breaking;
241    };
242    let (Some(old_iface), Some(new_iface)) = (
243        find_old_interface(old, new, container),
244        find_interface(new, container),
245    ) else {
246        return Verdict::Breaking;
247    };
248    let Some(ordinal) = slot_ordinal(new_iface, member) else {
249        return Verdict::Breaking;
250    };
251
252    for (name, old_ordinal) in slots(old_iface) {
253        if old_ordinal == ordinal && name != member {
254            return Verdict::Breaking;
255        }
256    }
257    Verdict::Compatible
258}
259
260// ==========================================================================
261// Additions — package level, and the append-only composite bodies.
262// ==========================================================================
263
264/// A declaration present only in the new snapshot.
265///
266/// A package-level addition — a new decl, interface, or service — is compatible:
267/// nothing that existed moved. A composite member addition is compatible only
268/// when it is a genuine append: typl §7.4 makes struct fields and union arms one
269/// append-only rule ("new fields are added at the end of the struct or union"),
270/// and an enum value appends by taking a number above every live and every
271/// retired one.
272///
273/// An appended struct field must also be readable from an old payload, which
274/// does not carry it ([`absence_reads_as_legal`], driftsys/ridl#598).
275fn added(change: &Change, old: &v2::Package, new: &v2::Package, scope: &[&v2::Package]) -> Verdict {
276    let Some((container, member)) = member_path(change) else {
277        // One or two segments: a whole package, or a package-level decl,
278        // interface, or service.
279        return Verdict::Compatible;
280    };
281    let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
282    else {
283        return Verdict::Breaking;
284    };
285
286    // The append test is a property of the whole body, and reading the body is
287    // what catches a *surviving* member whose slot moved — which the walk does
288    // not report alongside an addition. The member name is read only for a
289    // struct field, whose type decides whether an old payload stays readable.
290    use v2::decl::Kind;
291    let appended = match (&old_decl.kind, &new_decl.kind) {
292        (Some(Kind::StructDef(old_def)), Some(Kind::StructDef(new_def))) => {
293            appended_slot(
294                &struct_slots(old_def),
295                &struct_reserved(old_def),
296                &struct_slots(new_def),
297            ) && absence_reads_as_legal(new_def, member, new, scope)
298        }
299        (Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
300            // A result union's arms are its transport identity (ADR-0008
301            // decision 4): any arm change flips it.
302            !old_def.is_result
303                && !new_def.is_result
304                && appended_slot(
305                    &union_slots(old_def),
306                    &union_reserved(old_def),
307                    &union_slots(new_def),
308                )
309        }
310        (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => appended_slot(
311            &value_slots(&old_def.values),
312            &reserved_values(&old_def.reserved),
313            &value_slots(&new_def.values),
314        ),
315        (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => appended_slot(
316            &value_slots(&old_def.bits),
317            &[],
318            &value_slots(&new_def.bits),
319        ),
320        // A shape the table does not name classifies breaking.
321        _ => false,
322    };
323
324    if appended {
325        Verdict::Compatible
326    } else {
327        Verdict::Breaking
328    }
329}
330
331/// Whether a reader built against `new_set` refuses a payload in which the
332/// field `member` of struct `container`, in package `package`, is absent —
333/// the rule that makes an appended field breaking for its type
334/// (driftsys/ridl#598). `context` holds packages that references resolve
335/// against without being compared, such as `ridl.std`. `false` when
336/// `container` is not a struct of that package. The `ridl check` desk check
337/// reads it to say so in its RIDL-407 message for an append that also sits
338/// beside a moved sibling.
339pub fn absence_refused(
340    new_set: &[v2::Package],
341    context: &[v2::Package],
342    package: &str,
343    container: &str,
344    member: &str,
345) -> bool {
346    let scope: Vec<&v2::Package> = new_set.iter().chain(context).collect();
347    let Some(home) = new_set.iter().find(|candidate| candidate.name == package) else {
348        return false;
349    };
350    match find_decl(home, container).and_then(|decl| decl.kind.as_ref()) {
351        Some(v2::decl::Kind::StructDef(def)) => !absence_reads_as_legal(def, member, home, &scope),
352        _ => false,
353    }
354}
355
356/// Whether a reader built against the new struct body accepts a payload the old
357/// body wrote, in which the appended field `member` is absent
358/// (driftsys/ridl#598).
359///
360/// An optional field reads as absent. A non-optional field reads as the
361/// FlatBuffers default, 0, when its type is a scalar, an enum or an enum set and
362/// 0 is a legal value of that type. That is the condition under which the
363/// FlatBuffers codec of `ridl-backend-rust` reads the absent field instead of
364/// refusing it as a missing required field (driftsys/ridl#472), and the two
365/// share its definition, [`ridl_ir::zero`]. Every other absent non-optional
366/// field is refused: a string, bytes, struct, union, tuple, array or map field
367/// is an offset with no default, and a scalar or enum whose type excludes 0 has
368/// no legal value to read.
369///
370/// A type the classifier cannot resolve reads as refused, following the module's
371/// unlisted-is-breaking rule: a type the diff cannot resolve is reported as
372/// breaking. That includes a reference to another package when `scope` does
373/// not hold it. No snapshot carries `ridl.std`, so it resolves only when the
374/// caller passes it as context, as the `ridl` CLI does.
375fn absence_reads_as_legal(
376    def: &v2::StructDef,
377    member: &str,
378    home: &v2::Package,
379    scope: &[&v2::Package],
380) -> bool {
381    let Some(r#type) = def.members.iter().find_map(|slot| match &slot.member {
382        Some(v2::struct_member::Member::Field(field)) if field.name == member => {
383            field.r#type.as_ref()
384        }
385        _ => None,
386    }) else {
387        return false;
388    };
389    if r#type.optional {
390        return true;
391    }
392    use v2::field_type::Kind;
393    match &r#type.kind {
394        Some(Kind::Primitive(primitive)) => matches!(
395            v2::PrimitiveType::try_from(*primitive),
396            Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
397        ),
398        Some(Kind::InlineScalar(def)) => scalar_holds_zero(def),
399        Some(Kind::Named(reference)) => {
400            use v2::decl::Kind as Decl;
401            match resolve(home, scope, reference).and_then(|decl| decl.kind.as_ref()) {
402                Some(Decl::TypeDef(def)) => scalar_holds_zero(def),
403                Some(Decl::EnumDef(def)) => ridl_ir::zero::enum_zero_member(&def.values).is_some(),
404                // An enum set at 0 is the empty set.
405                Some(Decl::EnumSetDef(_)) => true,
406                _ => false,
407            }
408        }
409        _ => false,
410    }
411}
412
413/// Whether a named or inline scalar is legal at 0: a boolean, integer, float or
414/// unit backing whose constraint holds 0. A string or bytes backing has no
415/// default at all.
416fn scalar_holds_zero(def: &v2::TypeDef) -> bool {
417    let numeric = match def
418        .backing
419        .as_ref()
420        .and_then(|backing| backing.kind.as_ref())
421    {
422        Some(v2::backing::Kind::Unit(_)) => true,
423        Some(v2::backing::Kind::Primitive(primitive)) => matches!(
424            v2::PrimitiveType::try_from(*primitive),
425            Ok(v2::PrimitiveType::Boolean | v2::PrimitiveType::Integer | v2::PrimitiveType::Float)
426        ),
427        None => false,
428    };
429    numeric
430        && def.constraint.as_ref().is_none_or(|constraint| {
431            ridl_ir::zero::range_holds_zero(
432                constraint.min.as_deref(),
433                constraint.max.as_deref(),
434                constraint.step.as_deref(),
435            )
436        })
437}
438
439/// The declaration a type reference names on the new side, in the IR's
440/// canonical form: a bare `Name` in `home`, and `pkg.Name` in the package of
441/// that name — `home` itself or one of `scope`.
442fn resolve<'a>(
443    home: &'a v2::Package,
444    scope: &[&'a v2::Package],
445    reference: &str,
446) -> Option<&'a v2::Decl> {
447    match reference.rsplit_once('.') {
448        Some((package, name)) => std::iter::once(home)
449            .chain(scope.iter().copied())
450            .find(|candidate| candidate.name == package)
451            .and_then(|candidate| find_decl(candidate, name)),
452        None => find_decl(home, reference),
453    }
454}
455
456/// Whether the new body is the old body with every pre-existing slot untouched
457/// and every addition sitting above the highest slot ever used — live or
458/// retired.
459///
460/// Both halves matter. A member whose slot number moved has had its wire
461/// identity shifted even though its name survived, and the walk's composite
462/// comparison does not report that alongside an addition. A member taking a
463/// number at or below the old high-water mark is an insertion, or a reuse of a
464/// retired number, which typl §7.4 forbids so a wire value never carries a new
465/// meaning.
466fn appended_slot(old: &[(String, i64)], old_retired: &[i64], new: &[(String, i64)]) -> bool {
467    for (name, old_slot) in old {
468        match new.iter().find(|(new_name, _)| new_name == name) {
469            Some((_, new_slot)) if new_slot == old_slot => {}
470            // A surviving member moved, or vanished alongside the addition.
471            _ => return false,
472        }
473    }
474
475    let held = || {
476        old.iter()
477            .map(|(_, slot)| *slot)
478            .chain(old_retired.iter().copied())
479    };
480    let old_names: Vec<&String> = old.iter().map(|(name, _)| name).collect();
481    for (name, slot) in new {
482        if old_names.contains(&name) {
483            continue;
484        }
485        if !is_above_high_water(*slot, held()) {
486            return false;
487        }
488    }
489    true
490}
491
492/// Whether `slot` is above every slot in `held`, which is the append rule for
493/// one new member: the member keeps every existing ordinal or value, and it
494/// does not reuse a retired one (typl §7.4).
495///
496/// `held` lists every slot the old body assigns or retires, live and
497/// reserved together. When `held` is empty, any `slot` is above it. `ridl
498/// check --baseline` calls this function, so the desk check and the
499/// classifier apply one rule.
500pub fn is_above_high_water(slot: i64, held: impl IntoIterator<Item = i64>) -> bool {
501    held.into_iter().max().is_none_or(|mark| slot > mark)
502}
503
504// ==========================================================================
505// Constraints — narrowed versus widened.
506// ==========================================================================
507
508/// A scalar constraint change on a named type. Widening keeps every value the
509/// old contract admitted legal, so it is compatible; narrowing rejects values a
510/// consumer may already be sending.
511///
512/// A composite body changed in place reaches here with no member path and no
513/// rendered values — the walk does not say which member changed inside it — and
514/// classifies breaking.
515fn constraint(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
516    let mut segments = change.path.split('/').skip(1);
517    let (Some(name), None) = (segments.next(), segments.next()) else {
518        return Verdict::Breaking;
519    };
520    let (Some(old_decl), Some(new_decl)) = (find_decl(old, name), find_decl(new, name)) else {
521        return Verdict::Breaking;
522    };
523
524    use v2::decl::Kind;
525    let (Some(Kind::TypeDef(old_def)), Some(Kind::TypeDef(new_def))) =
526        (&old_decl.kind, &new_decl.kind)
527    else {
528        return Verdict::Breaking;
529    };
530
531    if narrows(old_def.constraint.as_ref(), new_def.constraint.as_ref()) {
532        Verdict::Breaking
533    } else {
534        Verdict::Compatible
535    }
536}
537
538/// Whether the constraint moved in the narrowing direction on any facet.
539///
540/// Each bound is judged independently and any narrowing decides the whole
541/// change, so a mixed edit — a lowered `min` with a lowered `max` — is breaking
542/// on the half that narrows. A stepped minimum also defines the grid origin,
543/// so a shifted grid is breaking even when the interval widens.
544fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
545    let (Some(old), Some(new)) = (old, new) else {
546        // A constraint appearing where there was none bounds a previously
547        // unbounded value; dropping one entirely only widens.
548        return old.is_none() && new.is_some();
549    };
550
551    // A raised floor or a lowered ceiling rejects values that were legal.
552    if raised(old.min.as_deref(), new.min.as_deref())
553        || lowered(old.max.as_deref(), new.max.as_deref())
554    {
555        return true;
556    }
557    // Length bounds are the same rule over character and byte counts.
558    if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
559        return true;
560    }
561    // A step quantizes: added or changed at all it can exclude values that were
562    // legal, and the classifier does not prove divisibility. Removed, it widens.
563    if new.step.is_some() && old.step != new.step {
564        return true;
565    }
566    // A minimum is also the origin of a stepped grid. Widening the interval
567    // only preserves old values when the origin moves by whole steps.
568    if let Some(step) = new.step.as_deref()
569        && old.min != new.min
570        && !same_grid(old.min.as_deref(), new.min.as_deref(), step)
571    {
572        return true;
573    }
574    // A pattern added or rewritten can reject strings that matched before.
575    // Removed, it widens.
576    if (new.pattern.is_some() && old.pattern != new.pattern)
577        || (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
578    {
579        return true;
580    }
581    false
582}
583
584/// Whether two origins differ by a whole positive step, in exact decimal
585/// arithmetic. An omitted minimum has origin zero (typl §4.3). Unknown
586/// spellings fail closed, as do invalid steps from foreign IR snapshots.
587fn same_grid(old: Option<&str>, new: Option<&str>, step: &str) -> bool {
588    use num_bigint::BigInt;
589
590    let read = |text: &str| -> Option<(BigInt, usize)> {
591        let (negative, digits) = split_sign(text)?;
592        let (whole, fraction) = digits.split_once('.').unwrap_or((&digits, ""));
593        if whole.is_empty() || (digits.contains('.') && fraction.is_empty()) {
594            return None;
595        }
596        let coefficient = BigInt::parse_bytes(format!("{whole}{fraction}").as_bytes(), 10)?;
597        Some((
598            if negative { -coefficient } else { coefficient },
599            fraction.len(),
600        ))
601    };
602    let (Some(old), Some(new), Some(step)) = (
603        read(old.unwrap_or("0")),
604        read(new.unwrap_or("0")),
605        read(step),
606    ) else {
607        return false;
608    };
609    let scale = old.1.max(new.1).max(step.1);
610    let rescale = |(coefficient, places): (BigInt, usize)| -> Option<BigInt> {
611        Some(coefficient * BigInt::from(10).pow(u32::try_from(scale - places).ok()?))
612    };
613    let (Some(old), Some(new), Some(step)) = (rescale(old), rescale(new), rescale(step)) else {
614        return false;
615    };
616    step > BigInt::from(0) && (old - new) % step == BigInt::from(0)
617}
618
619/// Whether a lower bound was added or moved up.
620fn raised(old: Option<&str>, new: Option<&str>) -> bool {
621    match (old, new) {
622        (None, Some(_)) => true,
623        (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_gt),
624        _ => false,
625    }
626}
627
628/// Whether an upper bound was added or moved down.
629fn lowered(old: Option<&str>, new: Option<&str>) -> bool {
630    match (old, new) {
631        (None, Some(_)) => true,
632        (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_lt),
633        _ => false,
634    }
635}
636
637fn raised_u64(old: Option<u64>, new: Option<u64>) -> bool {
638    match (old, new) {
639        (None, Some(_)) => true,
640        (Some(old), Some(new)) => new > old,
641        _ => false,
642    }
643}
644
645fn lowered_u64(old: Option<u64>, new: Option<u64>) -> bool {
646    match (old, new) {
647        (None, Some(_)) => true,
648        (Some(old), Some(new)) => new < old,
649        _ => false,
650    }
651}
652
653/// Orders two canonical decimal strings exactly, without going through a float
654/// (the IR carries exact decimals for precisely this reason, ADR-0007 decision
655/// 9). Returns `None` when either side is not a plain decimal, which callers
656/// read as "cannot prove this relaxes".
657///
658/// The `None` case is not dead. `ridlc` only ever writes canonical decimals, but
659/// [`load_ir_json`](crate::load_ir_json) deserializes a snapshot off disk and a
660/// bound is a plain string there, so a hand-edited or foreign `.ir.json` can
661/// carry an exponent form or any other spelling this function does not read.
662fn cmp_decimal(left: &str, right: &str) -> Option<std::cmp::Ordering> {
663    use std::cmp::Ordering;
664
665    let (left_negative, left_digits) = split_sign(left)?;
666    let (right_negative, right_digits) = split_sign(right)?;
667    if left_negative != right_negative {
668        // Zero is signless in canonical form, so a sign disagreement is a real
669        // ordering: the negative side is the smaller one.
670        return Some(if left_negative {
671            Ordering::Less
672        } else {
673            Ordering::Greater
674        });
675    }
676
677    let magnitude = cmp_magnitude(&left_digits, &right_digits)?;
678    Some(if left_negative {
679        magnitude.reverse()
680    } else {
681        magnitude
682    })
683}
684
685/// Splits an optional leading sign from a decimal, rejecting anything that is
686/// not sign-digits-optional-fraction.
687fn split_sign(text: &str) -> Option<(bool, String)> {
688    let (negative, rest) = match text.strip_prefix('-') {
689        Some(rest) => (true, rest),
690        None => (false, text.strip_prefix('+').unwrap_or(text)),
691    };
692    if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
693        return None;
694    }
695    if rest.matches('.').count() > 1 {
696        return None;
697    }
698    Some((negative, rest.to_string()))
699}
700
701/// Orders two unsigned decimal magnitudes by integer part then fraction, so
702/// `9` < `10` and `1.5` < `1.50001`.
703fn cmp_magnitude(left: &str, right: &str) -> Option<std::cmp::Ordering> {
704    use std::cmp::Ordering;
705
706    let (left_int, left_frac) = left.split_once('.').unwrap_or((left, ""));
707    let (right_int, right_frac) = right.split_once('.').unwrap_or((right, ""));
708
709    let left_int = left_int.trim_start_matches('0');
710    let right_int = right_int.trim_start_matches('0');
711    let by_length = left_int.len().cmp(&right_int.len());
712    if by_length != Ordering::Equal {
713        return Some(by_length);
714    }
715    let by_int = left_int.cmp(right_int);
716    if by_int != Ordering::Equal {
717        return Some(by_int);
718    }
719
720    // Compare fractions digit by digit, padding the shorter with zeros.
721    let width = left_frac.len().max(right_frac.len());
722    let pad = |frac: &str| format!("{frac:0<width$}");
723    Some(pad(left_frac).cmp(&pad(right_frac)))
724}
725
726// ==========================================================================
727// Timing.
728// ==========================================================================
729
730/// A resolved timing change on a signal or event (ADR-0008 decision 12).
731///
732/// `min` is the rate floor and `max` the staleness bound, so the consumer-facing
733/// guarantee strengthens when `min` rises or `max` falls and weakens when either
734/// moves the other way. A bound added or removed, or the strict-periodic/range
735/// mode flipped, changes what a consumer may assume at all — rmdl clocks key on
736/// strict — and is breaking in both directions.
737fn timing(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
738    let Some((container, member)) = member_path(change) else {
739        return Verdict::Breaking;
740    };
741    let (Some(old_decl), Some(new_decl)) = (
742        find_old_interaction(old, new, container, member),
743        find_interaction(new, container, member),
744    ) else {
745        return Verdict::Breaking;
746    };
747    let (Some(old_timing), Some(new_timing)) =
748        (interaction_timing(old_decl), interaction_timing(new_decl))
749    else {
750        // An interaction kind that carries no timing at all. The walk cannot
751        // produce this: it emits `TimingChanged` only inside its signal and
752        // event arms, so both sides are already a timed kind by the time a
753        // change reaches here. It is still reachable, because [`classify`] is
754        // public and takes any hand-built `Change` — so the arm stays, follows
755        // the module's unlisted-is-breaking rule, and carries a test of its own.
756        // It is deliberately not a `debug_assert!`: a caller passing a category
757        // the walk would not have emitted is asking a question, not committing
758        // a bug, and aborting a debug build over it would be wrong.
759        return Verdict::Breaking;
760    };
761
762    let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
763        // Timing appearing where there was none, or dropped, is a bound added
764        // or removed.
765        return Verdict::Breaking;
766    };
767
768    if old_timing.mode != new_timing.mode {
769        return Verdict::Breaking;
770    }
771    // A floor lowered, a ceiling raised, or either bound added or removed.
772    if lowered(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
773        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
774        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
775        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
776    {
777        return Verdict::Breaking;
778    }
779    // What remains is a floor raised, a ceiling lowered, or only
780    // `default_applied` flipped over identical bounds — a default made explicit.
781    Verdict::Compatible
782}
783
784/// A declared RPC-bound change on a command or query (ADR-0015 decision 8).
785///
786/// The two bounds keep their generic §9 meaning — `min` the rate floor, `max`
787/// the staleness bound — but on an RPC `min` is the **call throttle** and
788/// constrains the consumer, so its direction inverts against [`timing`]'s
789/// convention: raising it withdraws a call rate the caller was entitled to
790/// use (breaking), and lowering it leaves the caller less constrained
791/// (compatible). `max` is the response bound, a provider promise, and keeps
792/// the provider-side rule: raised is a weaker promise (breaking), lowered a
793/// stronger one (compatible). A bound added or removed — the whole annotation
794/// included — is breaking in both directions, exactly as in [`timing`]:
795/// callers size timeouts against a declared bound, so its appearance and its
796/// disappearance both change what a consumer may assume at all.
797///
798/// This is a distinct category rather than a kind-aware branch inside
799/// [`timing`] so that a missed branch fails closed (ADR-0012 decision 9): the
800/// deny lints above turn a missing arm into a compile error rather than a
801/// silently inherited signal rule that calls a raised RPC `min` compatible.
802fn rpc_bound(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
803    let Some((container, member)) = member_path(change) else {
804        return Verdict::Breaking;
805    };
806    let (Some(old_decl), Some(new_decl)) = (
807        find_old_interaction(old, new, container, member),
808        find_interaction(new, container, member),
809    ) else {
810        return Verdict::Breaking;
811    };
812    let (Some(old_timing), Some(new_timing)) = (rpc_timing(old_decl), rpc_timing(new_decl)) else {
813        // An interaction kind that is not an RPC. The walk cannot produce
814        // this — it emits `RpcBoundChanged` only inside its command and query
815        // arms — but [`classify`] is public and takes any hand-built
816        // [`Change`], so the arm stays and follows the module's
817        // unlisted-is-breaking rule (see [`timing`] for why it is not a
818        // `debug_assert!`).
819        return Verdict::Breaking;
820    };
821
822    let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
823        // The annotation appearing where there was none, or dropped, is a
824        // bound added or removed — breaking in both directions.
825        return Verdict::Breaking;
826    };
827
828    // The mode is always `Range` on an RPC (ADR-0015 decision 7); anything
829    // else in a snapshot is erroneous IR, and a flip is breaking regardless.
830    if old_timing.mode != new_timing.mode {
831        return Verdict::Breaking;
832    }
833    // A throttle raised, a response bound raised, or either bound added or
834    // removed. `raised` covers the added case (`None` → `Some`), `dropped` the
835    // removed one. This is [`timing`]'s predicate with the `min` direction
836    // inverted — `raised` where the signal rule reads `lowered`.
837    if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
838        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
839        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
840        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
841    {
842        return Verdict::Breaking;
843    }
844    // What remains is a throttle lowered, a response bound lowered, or only
845    // `default_applied` flipped over identical bounds — a default made
846    // explicit (ADR-0015 decision 7), the rule [`timing`] gives a signal. The
847    // caller is less constrained, or the provider promises more, or neither
848    // changed.
849    Verdict::Compatible
850}
851
852/// Whether a bound present before is absent now.
853fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
854    old.is_some() && new.is_none()
855}
856
857/// The resolved timing of a signal or event; `None` for interaction kinds that
858/// carry none, and `Some(None)` for a timed kind with the field unset.
859fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
860    use v2::decl::Kind;
861    match &decl.kind {
862        Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
863        Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
864        _ => None,
865    }
866}
867
868/// The declared timing of a command or query; `None` for other kinds, and
869/// `Some(None)` for an RPC with no bound at all. An RPC bound may come from a
870/// default (ADR-0015 decisions 4 and 7, as amended).
871fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
872    use v2::decl::Kind;
873    match &decl.kind {
874        Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
875        Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
876        _ => None,
877    }
878}
879
880// ==========================================================================
881// Contracts.
882// ==========================================================================
883
884/// A `require`/`ensure` clause-set change on a command or query.
885///
886/// A `require` is a precondition the caller must meet: adding one, or rewriting
887/// one, rejects callers that were legal. An `ensure` is a postcondition the
888/// caller may rely on: removing one, or rewriting one, withdraws a guarantee.
889/// Clause text is compared verbatim — the classifier never tries to prove that
890/// one clause implies another (ADR-0008 decision 14), so a rewrite reads as an
891/// addition on the `require` side and as a removal on the `ensure` side, and
892/// both are breaking.
893fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
894    let Some((container, member)) = member_path(change) else {
895        return Verdict::Breaking;
896    };
897    let (Some(old_decl), Some(new_decl)) = (
898        find_old_interaction(old, new, container, member),
899        find_interaction(new, container, member),
900    ) else {
901        return Verdict::Breaking;
902    };
903    let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
904        return Verdict::Breaking;
905    };
906
907    let kind = |want: v2::ContractKind| {
908        move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
909    };
910    let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
911        let mut out: Vec<String> = clauses
912            .iter()
913            .filter(kind(want))
914            .map(|clause| clause.source.clone())
915            .collect();
916        out.sort();
917        out
918    };
919
920    let old_require = sources(old_clauses, v2::ContractKind::Require);
921    let new_require = sources(new_clauses, v2::ContractKind::Require);
922    let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
923    let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);
924
925    if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
926        return Verdict::Breaking;
927    }
928    Verdict::Compatible
929}
930
931/// Whether every clause in `subset` appears in `superset`, counting duplicates.
932fn covers(superset: &[String], subset: &[String]) -> bool {
933    let mut remaining: Vec<&String> = superset.iter().collect();
934    for clause in subset {
935        match remaining.iter().position(|held| *held == clause) {
936            Some(index) => {
937                remaining.swap_remove(index);
938            }
939            None => return false,
940        }
941    }
942    true
943}
944
945/// The contract clauses of a command or query; `None` for kinds that carry none.
946fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
947    use v2::decl::Kind;
948    match &decl.kind {
949        Some(Kind::CommandDef(def)) => Some(&def.contracts),
950        Some(Kind::QueryDef(def)) => Some(&def.contracts),
951        _ => None,
952    }
953}
954
955// ==========================================================================
956// Path resolution and IR lookups.
957// ==========================================================================
958
959/// Splits a `package/container/member` path into its container and member.
960/// Returns `None` for the shorter package-level paths.
961fn member_path(change: &Change) -> Option<(&str, &str)> {
962    let mut segments = change.path.split('/').skip(1);
963    let container = segments.next()?;
964    let member = segments.next()?;
965    if segments.next().is_some() {
966        return None;
967    }
968    Some((container, member))
969}
970
971fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
972    package.decls.iter().find(|decl| decl.name == name)
973}
974
975/// The shape a name refers to — a top-level interface, or the inline shape of
976/// a service, which the walk descends into under the service's own dotted
977/// name. `Package::shapes` keys both on exactly that identity, so one lookup
978/// covers them.
979fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
980    package.shapes().find(|shape| shape.name == name)
981}
982
983/// The old side of the shape a diff path names, found the way the walk
984/// matched it (lock design §7; plan decision PD-14): by the frozen
985/// `interfaces.lock` number of the new side's shape when it has one, and by
986/// name otherwise — the new side has no identity, or does not hold the name
987/// at all. A renamed interface's member changes therefore classify as any
988/// other interface's, although their paths carry only the new name.
989fn find_old_shape<'a>(
990    old: &'a v2::Package,
991    new: &v2::Package,
992    name: &str,
993) -> Option<v2::InterfaceShape<'a>> {
994    let by_number = find_shape(new, name)
995        .filter(|shape| frozen(shape.interface))
996        .and_then(|shape| {
997            old.shapes().find(|candidate| {
998                frozen(candidate.interface) && candidate.interface.number == shape.interface.number
999            })
1000        });
1001    by_number.or_else(|| find_shape(old, name))
1002}
1003
1004fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
1005    find_shape(package, name).map(|shape| shape.interface)
1006}
1007
1008fn find_old_interface<'a>(
1009    old: &'a v2::Package,
1010    new: &v2::Package,
1011    name: &str,
1012) -> Option<&'a v2::Interface> {
1013    find_old_shape(old, new, name).map(|shape| shape.interface)
1014}
1015
1016fn find_interaction<'a>(
1017    package: &'a v2::Package,
1018    container: &str,
1019    member: &str,
1020) -> Option<&'a v2::Decl> {
1021    find_interface(package, container)?
1022        .interactions
1023        .iter()
1024        .find(|decl| decl.name == member)
1025}
1026
1027/// The old side's interaction under a `<package>/<container>/<member>` path,
1028/// with the container resolved by [`find_old_shape`].
1029fn find_old_interaction<'a>(
1030    old: &'a v2::Package,
1031    new: &v2::Package,
1032    container: &str,
1033    member: &str,
1034) -> Option<&'a v2::Decl> {
1035    find_old_interface(old, new, container)?
1036        .interactions
1037        .iter()
1038        .find(|decl| decl.name == member)
1039}
1040
1041/// Every slot of an interface body as (name, ordinal), tombstones included — a
1042/// tombstone occupies its ordinal exactly so the slot is never reused
1043/// (ridl §11).
1044///
1045/// A tombstone whose name is unset still holds its slot. The interface-body
1046/// `reserved` form accepts a bare ordinal or a string literal as well as a
1047/// name, and both lower to `Reserved { name: None }`; dropping those from the
1048/// slot list would leave their ordinals looking free, and a new interaction
1049/// taking one would classify as a clean append. The empty name never equals a
1050/// real interaction name, so the slot is held against every reuse without
1051/// matching anything.
1052fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
1053    interface
1054        .interactions
1055        .iter()
1056        .filter_map(|decl| match &decl.kind {
1057            Some(v2::decl::Kind::ReservedSlot(reserved)) => {
1058                Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
1059            }
1060            Some(_) => Some((decl.name.as_str(), decl.ordinal)),
1061            None => None,
1062        })
1063        .collect()
1064}
1065
1066fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
1067    slots(interface)
1068        .into_iter()
1069        .find(|(name, _)| *name == member)
1070        .map(|(_, ordinal)| ordinal)
1071}
1072
1073/// Every live struct field with its ordinal — the 1-based place in the body,
1074/// counting tombstones, that typl §7.4 makes the wire identity. Shared with the
1075/// walk, which reports a reorder by these ordinals.
1076pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
1077    def.members
1078        .iter()
1079        .filter_map(|member| match &member.member {
1080            Some(v2::struct_member::Member::Field(field)) => {
1081                Some((field.name.clone(), i64::from(field.ordinal)))
1082            }
1083            _ => None,
1084        })
1085        .collect()
1086}
1087
1088fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
1089    def.members
1090        .iter()
1091        .filter_map(|member| match &member.member {
1092            Some(v2::struct_member::Member::Reserved(reserved)) => {
1093                Some(i64::from(reserved.ordinal))
1094            }
1095            _ => None,
1096        })
1097        .collect()
1098}
1099
1100/// Every union arm with its ordinal, on the same rule as [`struct_slots`].
1101pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
1102    def.arms
1103        .iter()
1104        .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
1105        .collect()
1106}
1107
1108fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
1109    def.reserved
1110        .iter()
1111        .map(|reserved| i64::from(reserved.ordinal))
1112        .collect()
1113}
1114
1115/// Enum values and enum-set bits key on their integer value, not a declaration
1116/// ordinal: the number is the wire identity (typl §7.4, §8).
1117pub(crate) fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
1118    values
1119        .iter()
1120        .map(|value| (value.name.clone(), value.value))
1121        .collect()
1122}
1123
1124fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
1125    reserved.iter().filter_map(|entry| entry.value).collect()
1126}
1127
1128// ==========================================================================
1129// `--explain` — the rule row for one category.
1130// ==========================================================================
1131
1132/// Parses the snake_case word a report prints back into its category, so
1133/// `ridl diff --explain <category>` takes exactly what the report shows.
1134pub fn category_from_word(word: &str) -> Option<Category> {
1135    crate::CATEGORIES
1136        .into_iter()
1137        .find(|category| crate::category_word(*category) == word)
1138}
1139
1140/// The rule row for a category: the classification table of ADR-0008 decision
1141/// 14 as text. This is the CI-facing documentation of record until the E4 error
1142/// index publishes it.
1143// A new variant must be given a real arm here, not swept into a
1144// catch-all: rustc forces *an* arm, and the arm its `help:` text
1145// proposes is `_ =>`, which classifies the new variant silently. The
1146// two lints below reject a wildcard over `Category` — the first when
1147// it covers several variants, the second when it covers exactly one,
1148// which is the case one added variant creates.
1149#[deny(
1150    clippy::wildcard_enum_match_arm,
1151    clippy::match_wildcard_for_single_variants
1152)]
1153pub fn explain(category: Category) -> &'static str {
1154    match category {
1155        Category::DeclAdded => concat!(
1156            "A declaration, interface, or service present only in the new snapshot.\n",
1157            "  compatible  a new package-level decl, interface, or service; an enum\n",
1158            "              value appended above every live and retired number; a\n",
1159            "              union arm appended at the end of the body (typl 7.4,\n",
1160            "              append-only); a struct field appended at the end of the body\n",
1161            "              when it is optional, or when it is a scalar, enum, or enum set\n",
1162            "              whose type allows the value 0\n",
1163            "  breaking    a member inserted below the highest slot ever used, a member\n",
1164            "              taking a retired number, any addition that moves a surviving\n",
1165            "              member's slot, or any arm of a result union (ADR-0008 d4); a\n",
1166            "              non-optional struct field appended whose type does not allow\n",
1167            "              0 — a string, bytes, struct, union, tuple, array, or map, a\n",
1168            "              scalar whose range or step excludes 0, an enum with no zero\n",
1169            "              member, or a type the diff cannot resolve (ridl diff\n",
1170            "              resolves ridl.std types). A reader of the new version\n",
1171            "              refuses every payload of the old one, which does not carry\n",
1172            "              the field (typl 7.4). Declare the new field optional instead\n",
1173            "              (`field : T?`): an absent optional field reads as absent\n",
1174            "  note        an interface is matched by its interfaces.lock number (lock\n",
1175            "              design 7): a frozen number the old side never held is a new\n",
1176            "              interface, and so is every provisional one — a declaration\n",
1177            "              with no lock entry carries no identity, and a rename keeps its\n",
1178            "              number, so it is never a renamed interface (interface_renamed)"
1179        ),
1180        Category::DeclRemoved => concat!(
1181            "A declaration, interface, or service present only in the old snapshot.\n",
1182            "  breaking    a removed service, interface, decl, enum value, struct\n",
1183            "              field, or union arm withdraws something a consumer compiled\n",
1184            "              against\n",
1185            "  note        an interface is matched by its interfaces.lock number (lock\n",
1186            "              design 7), so an interface here is one whose number is gone\n",
1187            "              from the new side and not retired there — a lock line deleted\n",
1188            "              by hand, or a number changed by hand, which is this row plus\n",
1189            "              decl_added. A number the new side's lock retires is\n",
1190            "              interface_retired instead, and `ridl baseline` refuses to\n",
1191            "              publish the removal of a number it does not find retired\n",
1192            "              (RIDL-412)\n",
1193            "  caveat      a composite member retired the sanctioned way — replaced by\n",
1194            "              a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
1195            "              reported breaking today. The body comparison is keyed on\n",
1196            "              member names and does not read the `reserved` list, so it\n",
1197            "              cannot yet tell that retirement from a bare deletion. This\n",
1198            "              errs on the safe side; carried as debt, see the note on\n",
1199            "              `diff_composite`. The interaction-level tombstone IS\n",
1200            "              recognised — see interaction_retired"
1201        ),
1202        Category::InterfaceRenamed => concat!(
1203            "An interface whose interfaces.lock number is the same on both sides and\n",
1204            "whose name changed.\n",
1205            "  compatible  always — the number is the interface's identity and its\n",
1206            "              routing key (lock design 7, rsdl decision D-7), so nothing\n",
1207            "              moves on the wire. Visible in source: a rename changes the\n",
1208            "              generated identity-table names in both wire backends, which\n",
1209            "              is why the text report lists it under the heading\n",
1210            "              \"compatible on the wire, visible in source\". The path\n",
1211            "              carries the new name and the detail old -> new; changes\n",
1212            "              inside the interface are reported under the new name and\n",
1213            "              classified as any other interface's\n",
1214            "  note        a provisional number is no identity: a declaration with no\n",
1215            "              lock entry is never matched, so a rename the lock does not\n",
1216            "              record is decl_removed plus decl_added, breaking, until\n",
1217            "              `ridl lock <pkg> --rename Old=New` records it"
1218        ),
1219        Category::InterfaceRetired => concat!(
1220            "An interface whose number the old snapshot held is gone from the new\n",
1221            "snapshot, whose interfaces.lock retires it.\n",
1222            "  compatible  always — the sanctioned removal of an interface: the entry\n",
1223            "              keeps its number forever with the word `retired`, so the\n",
1224            "              number is never allocated again (lock design 4 and 7, rsdl\n",
1225            "              decision D-7). A number gone with no retired entry is\n",
1226            "              decl_removed, breaking, and `ridl baseline` refuses to publish\n",
1227            "              it (RIDL-412)"
1228        ),
1229        Category::InterfaceFrozen => concat!(
1230            "An interface matched by name whose number is the same on both sides and\n",
1231            "whose provisional flag went from true to false: `ridl lock` froze the\n",
1232            "number in place.\n",
1233            "  compatible  always — the number is the routing key and it did not\n",
1234            "              change, so nothing moves on the wire. The catalog hash\n",
1235            "              changes, because it covers each interface's number and its\n",
1236            "              provisional flag, and a generated face refuses a port whose\n",
1237            "              catalog hash is another one (ADR-0023 decision 8): a peer\n",
1238            "              built before the lock and a peer built after it refuse each\n",
1239            "              other. This is why the text report lists it under the\n",
1240            "              heading \"compatible on the wire, may change the catalog\n",
1241            "              hash\".\n",
1242            "              The detail carries the number before and after, marked\n",
1243            "              (provisional) on the old side\n",
1244            "  note        a baseline never holds a provisional number (`ridl baseline`\n",
1245            "              refuses one, RIDL-411), so this change is seen between two\n",
1246            "              builds, or in a reversed comparison whose old side is a\n",
1247            "              build. A number 0 on either side, from a snapshot written\n",
1248            "              before the lock existed, is not compared"
1249        ),
1250        Category::InterfaceNumberChanged => concat!(
1251            "An interface matched across the two sides whose number differs.\n",
1252            "  breaking    always — the number is the interface's routing key, so a\n",
1253            "              peer built against the old number cannot reach the\n",
1254            "              interface at the new one. Seen between two provisional\n",
1255            "              builds when a sibling interface added before this one in\n",
1256            "              byte order moves its provisional number, and when `ridl\n",
1257            "              lock` freezes a provisional number as another number. The\n",
1258            "              detail carries the old and the new number, each marked\n",
1259            "              (provisional) when it is\n",
1260            "  note        two frozen numbers are matched by number, so they never\n",
1261            "              differ here: a frozen number changed by hand is\n",
1262            "              decl_removed plus decl_added. A number 0 on either side,\n",
1263            "              from a snapshot written before the lock existed, is not\n",
1264            "              compared.\n",
1265            "              Run `ridl lock` to freeze the numbers, so that a later\n",
1266            "              sibling cannot move them"
1267        ),
1268        Category::MemberReordered => concat!(
1269            "A surviving composite member whose slot in the body changed.\n",
1270            "  breaking    always — a struct field or union arm takes its wire\n",
1271            "              identity from its ordinal, its 1-based place in the body\n",
1272            "              counting tombstones (typl 7.4), so a member whose ordinal\n",
1273            "              changed has a new wire identity; the detail carries the old\n",
1274            "              and new ordinal. An enum value or enum-set bit takes its\n",
1275            "              identity from the explicit number it declares (typl 8, 9),\n",
1276            "              so one whose number changed has a new wire identity; the\n",
1277            "              detail carries the old and new value or bit. An enum or\n",
1278            "              enum-set body reordered with no number changed is\n",
1279            "              enum_reordered, compatible, instead\n",
1280            "  note        reported only when both bodies hold the same member names:\n",
1281            "              a reorder in the same edit as an addition or a removal is\n",
1282            "              reported through decl_added or decl_removed alone. A reorder\n",
1283            "              in the same edit as an in-place change to the body is\n",
1284            "              reported with constraint_changed on the container as well.\n",
1285            "              An enum or enum-set body whose only differences are changed\n",
1286            "              numbers, in any order, reports member_reordered alone"
1287        ),
1288        Category::EnumReordered => concat!(
1289            "An enum or enum-set body whose difference is only order: its values or\n",
1290            "bits reordered with no number changed, the enum's reserved list\n",
1291            "reordered with no entry added, removed or changed, or both.\n",
1292            "  compatible  always — a value or bit takes its identity from the\n",
1293            "              explicit number it declares (typl 8, 9), so nothing moves\n",
1294            "              on the wire. The catalog hash covers only the declarations\n",
1295            "              an interface reaches. When an interface reaches the enum or\n",
1296            "              enum set, the order of its values or bits, and of an enum's\n",
1297            "              reserved list, is part of the hash, so the hash changes\n",
1298            "              then, and a generated face refuses a port whose catalog hash\n",
1299            "              is another one (ADR-0023 decision 8). This is why the text\n",
1300            "              report lists it under the heading \"compatible on the wire,\n",
1301            "              may change the catalog hash\". The path is the container's;\n",
1302            "              there is no detail\n",
1303            "  note        a value or bit whose number changed is member_reordered,\n",
1304            "              breaking, not enum_reordered"
1305        ),
1306        Category::InteractionAppended => concat!(
1307            "An interaction added after every slot that existed before.\n",
1308            "  compatible  the slot it takes was never occupied\n",
1309            "  breaking    the slot was freed by an untombstoned removal and is now\n",
1310            "              reused by a new name — a reused wire identity (ADR-0008 d14)"
1311        ),
1312        Category::InteractionInserted => concat!(
1313            "An interaction added before the end of the interface body.\n",
1314            "  breaking    always — every later ordinal shifts, and the ordinal is the\n",
1315            "              transport identity (ridl 11)"
1316        ),
1317        Category::InteractionReordered => concat!(
1318            "A surviving interaction whose relative order in the body changed.\n",
1319            "  breaking    always — a reorder shifts wire identities (ridl 11)"
1320        ),
1321        Category::InteractionRemoved => concat!(
1322            "An interaction removed without a `reserved` tombstone holding its slot.\n",
1323            "  breaking    always — the freed ordinal is reusable, so the wire identity\n",
1324            "              is no longer permanent (ridl 11)"
1325        ),
1326        Category::InteractionRetired => concat!(
1327            "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
1328            "  compatible  always — the sanctioned retirement: the slot stays occupied\n",
1329            "              and every later ordinal holds (ridl 11)"
1330        ),
1331        Category::KindChanged => concat!(
1332            "An interaction whose kind changed (signal, event, command, query, fixed).\n",
1333            "  breaking    any direction — the kind selects the transport shape"
1334        ),
1335        Category::PayloadChanged => concat!(
1336            "A signal, event, or fixed payload type changed.\n",
1337            "  breaking    any direction, a stream added or removed included"
1338        ),
1339        Category::ReturnChanged => concat!(
1340            "A query return shape changed.\n",
1341            "  breaking    any direction — an ok-arm change, an error arm added, removed,\n",
1342            "              or retyped, a stream added or removed, or any other change to\n",
1343            "              the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
1344            "              interface + interaction ordinal + ordered arm types)"
1345        ),
1346        Category::ParamsChanged => concat!(
1347            "A command or query parameter list changed.\n",
1348            "  breaking    any direction — a parameter added, removed, renamed, retyped,\n",
1349            "              or a stream added or removed on one"
1350        ),
1351        Category::TimingChanged => concat!(
1352            "A signal or event resolved timing changed (ADR-0008 d12).\n",
1353            "  compatible  min raised (a higher rate floor) or max lowered (a tighter\n",
1354            "              staleness bound) with the mode unchanged; default_applied\n",
1355            "              flipped over identical resolved bounds — a default made\n",
1356            "              explicit\n",
1357            "  breaking    min lowered, max raised, a bound added where none was, a bound\n",
1358            "              removed, or the strict-periodic/range mode flipped\n",
1359            "  note        editing `[defaults].timing` needs no special rule: diff\n",
1360            "              compares resolved bounds, so it surfaces here on every\n",
1361            "              defaulted interaction (ridl 9.1)"
1362        ),
1363        Category::RpcBoundChanged => concat!(
1364            "A command or query declared RPC bound changed (ADR-0015 d8).\n",
1365            "  compatible  min lowered (the caller may call more often) or max lowered\n",
1366            "              (a stronger provider promise), with the mode unchanged;\n",
1367            "              default_applied flipped over identical resolved bounds — a\n",
1368            "              default made explicit\n",
1369            "  breaking    min raised — on an RPC, min is the call throttle and\n",
1370            "              constrains the caller, so raising it withdraws a call rate\n",
1371            "              the caller was entitled to use; max raised (a weaker provider\n",
1372            "              promise); a bound added or removed, the whole annotation\n",
1373            "              included\n",
1374            "  note        the min direction is the inverse of timing_changed's, which\n",
1375            "              is why this is a category of its own rather than a branch:\n",
1376            "              a missed branch would inherit the signal rule and call a\n",
1377            "              raised RPC min compatible (ADR-0012 d9, fail closed).\n",
1378            "              an RPC bound may come from a default, and diff compares\n",
1379            "              resolved bounds, so editing a default surfaces here on\n",
1380            "              every defaulted command or query (ridl 9.1)"
1381        ),
1382        Category::LimitChanged => concat!(
1383            "The payload limit of the interaction changed; every change of a limit is\n",
1384            "breaking, ADR-0030 decision 13.\n",
1385            "  breaking    a limit raised, lowered, added where none was, or removed"
1386        ),
1387        Category::ContractChanged => concat!(
1388            "A command or query require/ensure clause set changed (ridl 13).\n",
1389            "  compatible  a require removed, or an ensure added\n",
1390            "  breaking    a require added or its text changed, or an ensure removed or\n",
1391            "              its text changed. Clause text is compared verbatim: the\n",
1392            "              classifier does not prove that one clause implies another\n",
1393            "              (ADR-0008 d14)"
1394        ),
1395        Category::InteractionClassChanged => concat!(
1396            "A name-paired interaction's family or operation shape changed\n",
1397            "(ADR-0012 d6).\n",
1398            "  breaking    any change, in either direction: the family selects the\n",
1399            "              obligations a consumer compiled against, and the shape\n",
1400            "              the gesture an intent command is. No source writes a\n",
1401            "              family other than dispatch yet; the walk compares the\n",
1402            "              fields from the start so the diff fails closed (ADR-0012 d9)"
1403        ),
1404        Category::WidthChanged => concat!(
1405            "A derived wire width or scalar backing changed.\n",
1406            "  breaking    any IntWidth or FloatWidth change, uint64 versus int64\n",
1407            "              included — the resolved width is part of the contract\n",
1408            "              (typl 4.2, 5.6)"
1409        ),
1410        Category::ConstraintChanged => concat!(
1411            "A scalar constraint changed, or a composite body changed in place.\n",
1412            "  compatible  widened — min lowered without shifting a stepped grid, max\n",
1413            "              raised, a length bound loosened,\n",
1414            "              a step removed, a match pattern removed (by literal or by\n",
1415            "              named constant), or the whole constraint dropped so the\n",
1416            "              value is unbounded again\n",
1417            "  breaking    narrowed — min raised, max lowered, a length bound\n",
1418            "              tightened, a stepped grid shifted, a step added or changed\n",
1419            "              (divisibility between different steps is never\n",
1420            "              proved), a match pattern added or rewritten (by literal or\n",
1421            "              by named constant), or a constraint appearing where there\n",
1422            "              was none, which bounds a previously unbounded value. A\n",
1423            "              composite body changed in place is breaking: the walk\n",
1424            "              does not say which member changed inside it\n",
1425            "  note        each facet is judged on its own and any one narrowing\n",
1426            "              decides the change, so a mixed edit is breaking on the half\n",
1427            "              that narrows. A widening that flips the resolved wire width\n",
1428            "              is separately reported as width_changed, which is always\n",
1429            "              breaking (typl 5.6)"
1430        ),
1431        Category::InitChanged => concat!(
1432            "A declared or resolved init value changed.\n",
1433            "  breaking    any direction — the init is the value a consumer reads before\n",
1434            "              the first publish, so it is part of the contract (ridl 9.1)"
1435        ),
1436        Category::ReservedNameRedeclared => concat!(
1437            "A name retired by a `reserved` tombstone is live again — an interaction\n",
1438            "inside an interface body.\n",
1439            "  breaking    always — a retired identity is never reused (ridl 11,\n",
1440            "              RIDL-401)"
1441        ),
1442        Category::ServiceChanged => concat!(
1443            "A service switched between the named list and an inline shape.\n",
1444            "  breaking    always — extraction rewrites the transport identity of every\n",
1445            "              fallible query in the shape: an inline shape derives it from\n",
1446            "              the service's dotted name, a named interface from its own name\n",
1447            "              (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
1448            "              category: it is read as a set by the service_interface_*\n",
1449            "              rows (ADR-0015 d19, as amended 2026-09-15)"
1450        ),
1451        Category::ServiceInterfaceAdded => concat!(
1452            "An interface joined a service's set of interfaces.\n",
1453            "  compatible  always — nothing that existed moved: an interface's number\n",
1454            "              comes from its unit's interfaces.lock, not from its place\n",
1455            "              in the list, and the routing key does not contain the service\n",
1456            "              (ADR-0015 d19, as amended 2026-09-15)"
1457        ),
1458        Category::ServiceInterfaceRemoved => concat!(
1459            "An interface left a service's set of interfaces.\n",
1460            "  compatible  always — on the wire: the routing key does not contain the\n",
1461            "              service, so no identity moves, and a split into two services\n",
1462            "              is a removal plus an addition. Visible in source: the\n",
1463            "              service.member addresses of that interface stop resolving\n",
1464            "              under this service, which is why the text report lists it\n",
1465            "              under the heading \"compatible on the wire, visible in\n",
1466            "              source\". A consumer that loses its only provider is a\n",
1467            "              wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
1468            "              amended 2026-09-15)"
1469        ),
1470        Category::DocOnly => concat!(
1471            "Only doc comment, labels, or deprecation metadata changed.\n",
1472            "  compatible  always — none of it reaches a consumer's build.\n",
1473            "              Visibility is NOT in this category: see\n",
1474            "              visibility_changed"
1475        ),
1476        Category::VisibilityChanged => concat!(
1477            "The visibility a declaration is published at changed.\n",
1478            "  compatible  internal -> public: the declaration is offered to more\n",
1479            "              consumers than before\n",
1480            "  breaking    public -> internal: `internal` maps to the target's\n",
1481            "              package-private mechanism — Rust `pub(crate)`, a\n",
1482            "              non-exported TypeScript member (ADR-0002 8) — so the\n",
1483            "              declaration disappears from every out-of-package\n",
1484            "              consumer's build. The wire layout does not move, but the\n",
1485            "              consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
1486            "              direction involving an unset visibility is breaking"
1487        ),
1488    }
1489}