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