Skip to main content

ridl_diff/
classify.rs

1//! The breaking/compatible classifier (docs/ROADMAP.md epic E2.8b).
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// A new variant must be given a real arm here, not swept into a
39// catch-all: rustc forces *an* arm, and the arm its `help:` text
40// proposes is `_ =>`, which classifies the new variant silently. The
41// two lints below reject a wildcard over `Category` — the first when
42// it covers several variants, the second when it covers exactly one,
43// which is the case one added variant creates.
44#[deny(
45    clippy::wildcard_enum_match_arm,
46    clippy::match_wildcard_for_single_variants
47)]
48pub fn classify(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
49    match change.category {
50        // Shifts or reuses a wire identity, or replaces a wire-carrying type.
51        // Every one of these is breaking in either direction.
52        Category::InteractionInserted
53        | Category::InteractionReordered
54        | Category::MemberReordered
55        | Category::InteractionRemoved
56        | Category::ReservedNameRedeclared
57        | Category::KindChanged
58        | Category::PayloadChanged
59        | Category::ReturnChanged
60        | Category::ParamsChanged
61        | Category::WidthChanged
62        | Category::ServiceChanged
63        | Category::DeclRemoved => Verdict::Breaking,
64
65        // An init is part of the published contract (ridl §9.1): consumers read
66        // the pre-publish value. Neither reference states a compatible
67        // direction, so the change is breaking.
68        Category::InitChanged => Verdict::Breaking,
69
70        // Doc comments, labels, and deprecation notes reach no consumer's build.
71        // Visibility is not among them — it has its own category below.
72        //
73        // A tombstone in the retired interaction's own slot is the sanctioned
74        // retirement (ridl §11); the walk only emits `InteractionRetired` when
75        // the slot is preserved.
76        Category::DocOnly | Category::InteractionRetired => Verdict::Compatible,
77
78        // An interface's identity is its `interfaces.lock` number (lock
79        // design §7). A rename that keeps the number moves nothing on the
80        // wire — the routing key is the number — and is visible in source,
81        // which the text report's heading says of it. A number the new side's
82        // lock retires is the sanctioned removal: the entry keeps the number
83        // forever, so it is never allocated again.
84        Category::InterfaceRenamed | Category::InterfaceRetired => Verdict::Compatible,
85
86        // A service's list is a set of interface references (ADR-0015
87        // decision 19 as amended on 2026-09-15). The routing key does not
88        // contain the service, so an interface joining or leaving the set
89        // moves no wire identity: both directions are compatible. A removal
90        // is still visible in source — the `service.member` addresses of that
91        // interface stop resolving under the service — which is what the text
92        // report's heading says of it.
93        Category::ServiceInterfaceAdded | Category::ServiceInterfaceRemoved => Verdict::Compatible,
94
95        Category::VisibilityChanged => visibility(change, old, new),
96        Category::InteractionAppended => appended(change, old, new),
97        Category::DeclAdded => added(change, old, new),
98        Category::ConstraintChanged => constraint(change, old, new),
99        Category::TimingChanged => timing(change, old, new),
100        Category::RpcBoundChanged => rpc_bound(change, old, new),
101        Category::ContractChanged => contract(change, old, new),
102    }
103}
104
105// ==========================================================================
106// Visibility.
107// ==========================================================================
108
109/// A change to the visibility a declaration is published at.
110///
111/// Visibility rides the surface grammar next to doc comments, but it is not
112/// metadata to a consumer. `internal` maps to the target's package-private
113/// mechanism — Rust `pub(crate)`, a non-exported TypeScript member (ADR-0002
114/// §8) — so narrowing `public` to `internal` deletes the declaration from every
115/// out-of-package consumer's build. That is the plainest form of "narrows a
116/// consumer-visible guarantee" in ADR-0008 decision 14, even though the byte
117/// layout on the wire never moves.
118///
119/// Widening `internal` to `public` only offers more, so it is compatible. Any
120/// direction involving an unset visibility is breaking, following the module's
121/// unlisted-is-breaking rule.
122fn visibility(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
123    let (Some(old_visibility), Some(new_visibility)) = (
124        find_visibility(old, &change.path, |name| find_old_shape(old, new, name)),
125        find_visibility(new, &change.path, |name| find_shape(new, name)),
126    ) else {
127        return Verdict::Breaking;
128    };
129
130    match (
131        v2::Visibility::try_from(old_visibility),
132        v2::Visibility::try_from(new_visibility),
133    ) {
134        (Ok(v2::Visibility::Internal), Ok(v2::Visibility::Public)) => Verdict::Compatible,
135        _ => Verdict::Breaking,
136    }
137}
138
139/// The visibility published at a change's path: an interaction inside an
140/// interface or service shape, or a package-level declaration, interface, or
141/// service. `shape_of` resolves the container name the path carries to this
142/// side's shape — by name on the new side, and on the old side the way the
143/// walk matched it ([`find_old_shape`]), since a renamed interface's path
144/// carries only its new name.
145fn find_visibility<'a>(
146    package: &'a v2::Package,
147    path: &str,
148    shape_of: impl Fn(&str) -> Option<v2::InterfaceShape<'a>>,
149) -> Option<i32> {
150    let mut segments = path.split('/').skip(1);
151    let name = segments.next()?;
152
153    if let Some(member) = segments.next() {
154        return shape_of(name)?
155            .interface
156            .interactions
157            .iter()
158            .find(|decl| decl.name == member)
159            .map(|decl| decl.visibility);
160    }
161    if let Some(decl) = find_decl(package, name) {
162        return Some(decl.visibility);
163    }
164    // `Package::shapes` answers for a named interface and for a service with an
165    // inline shape, and `InterfaceShape::visibility` reads the authoritative
166    // field in each case — the owning service's for an inline shape, whose own
167    // `Interface.visibility` is `VISIBILITY_UNSPECIFIED` by construction.
168    if let Some(shape) = shape_of(name) {
169        return Some(shape.visibility());
170    }
171    // A service naming an interface after `:` carries no shape of its own, so
172    // it is not in `shapes()`; its visibility is still the service's.
173    package
174        .services
175        .iter()
176        .find(|service| service.name == name)
177        .map(|service| service.visibility)
178}
179
180// ==========================================================================
181// Interaction append — the identity-reuse guard.
182// ==========================================================================
183
184/// An interaction (or a freshly minted tombstone) added after every slot that
185/// existed before. Appending is compatible, but only when the slot it takes was
186/// never occupied: an ordinal freed by an untombstoned removal and handed to a
187/// new name **reuses a wire identity**, which ADR-0008 decision 14 lists first
188/// among breaking changes.
189///
190/// The walk labels that case `InteractionAppended` because the new name does sit
191/// after every surviving slot; the reuse is only visible by looking back at what
192/// the old snapshot held at that ordinal, which is why the check lives here and
193/// not in the walk.
194fn appended(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
195    let Some((container, member)) = member_path(change) else {
196        return Verdict::Breaking;
197    };
198    let (Some(old_iface), Some(new_iface)) = (
199        find_old_interface(old, new, container),
200        find_interface(new, container),
201    ) else {
202        return Verdict::Breaking;
203    };
204    let Some(ordinal) = slot_ordinal(new_iface, member) else {
205        return Verdict::Breaking;
206    };
207
208    for (name, old_ordinal) in slots(old_iface) {
209        if old_ordinal == ordinal && name != member {
210            return Verdict::Breaking;
211        }
212    }
213    Verdict::Compatible
214}
215
216// ==========================================================================
217// Additions — package level, and the append-only composite bodies.
218// ==========================================================================
219
220/// A declaration present only in the new snapshot.
221///
222/// A package-level addition — a new decl, interface, or service — is compatible:
223/// nothing that existed moved. A composite member addition is compatible only
224/// when it is a genuine append: typl §7.4 makes struct fields and union arms one
225/// append-only rule ("new fields are added at the end of the struct or union"),
226/// and an enum value appends by taking a number above every live and every
227/// retired one.
228fn added(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
229    let Some((container, member)) = member_path(change) else {
230        // One or two segments: a whole package, or a package-level decl,
231        // interface, or service.
232        return Verdict::Compatible;
233    };
234    let (Some(old_decl), Some(new_decl)) = (find_decl(old, container), find_decl(new, container))
235    else {
236        return Verdict::Breaking;
237    };
238
239    // The member name is not needed: the append test is a property of the whole
240    // body, and reading the body is what catches a *surviving* member whose slot
241    // moved — which the walk does not report alongside an addition.
242    let _ = member;
243
244    use v2::decl::Kind;
245    let appended = match (&old_decl.kind, &new_decl.kind) {
246        (Some(Kind::StructDef(old_def)), Some(Kind::StructDef(new_def))) => appended_slot(
247            &struct_slots(old_def),
248            &struct_reserved(old_def),
249            &struct_slots(new_def),
250        ),
251        (Some(Kind::UnionDef(old_def)), Some(Kind::UnionDef(new_def))) => {
252            // A result union's arms are its transport identity (ADR-0008
253            // decision 4): any arm change flips it.
254            !old_def.is_result
255                && !new_def.is_result
256                && appended_slot(
257                    &union_slots(old_def),
258                    &union_reserved(old_def),
259                    &union_slots(new_def),
260                )
261        }
262        (Some(Kind::EnumDef(old_def)), Some(Kind::EnumDef(new_def))) => appended_slot(
263            &value_slots(&old_def.values),
264            &reserved_values(&old_def.reserved),
265            &value_slots(&new_def.values),
266        ),
267        (Some(Kind::EnumSetDef(old_def)), Some(Kind::EnumSetDef(new_def))) => appended_slot(
268            &value_slots(&old_def.bits),
269            &[],
270            &value_slots(&new_def.bits),
271        ),
272        // A shape the table does not name classifies breaking.
273        _ => false,
274    };
275
276    if appended {
277        Verdict::Compatible
278    } else {
279        Verdict::Breaking
280    }
281}
282
283/// Whether the new body is the old body with every pre-existing slot untouched
284/// and every addition sitting above the highest slot ever used — live or
285/// retired.
286///
287/// Both halves matter. A member whose slot number moved has had its wire
288/// identity shifted even though its name survived, and the walk's composite
289/// comparison does not report that alongside an addition. A member taking a
290/// number at or below the old high-water mark is an insertion, or a reuse of a
291/// retired number, which typl §7.4 forbids so a wire value never carries a new
292/// meaning.
293fn appended_slot(old: &[(String, i64)], old_retired: &[i64], new: &[(String, i64)]) -> bool {
294    for (name, old_slot) in old {
295        match new.iter().find(|(new_name, _)| new_name == name) {
296            Some((_, new_slot)) if new_slot == old_slot => {}
297            // A surviving member moved, or vanished alongside the addition.
298            _ => return false,
299        }
300    }
301
302    let high_water = old
303        .iter()
304        .map(|(_, slot)| *slot)
305        .chain(old_retired.iter().copied())
306        .max();
307    let old_names: Vec<&String> = old.iter().map(|(name, _)| name).collect();
308    for (name, slot) in new {
309        if old_names.contains(&name) {
310            continue;
311        }
312        match high_water {
313            Some(mark) if *slot <= mark => return false,
314            _ => {}
315        }
316    }
317    true
318}
319
320// ==========================================================================
321// Constraints — narrowed versus widened.
322// ==========================================================================
323
324/// A scalar constraint change on a named type. Widening keeps every value the
325/// old contract admitted legal, so it is compatible; narrowing rejects values a
326/// consumer may already be sending.
327///
328/// A composite body changed in place reaches here with no member path and no
329/// rendered values — the walk does not say which member changed inside it — and
330/// classifies breaking.
331fn constraint(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
332    let mut segments = change.path.split('/').skip(1);
333    let (Some(name), None) = (segments.next(), segments.next()) else {
334        return Verdict::Breaking;
335    };
336    let (Some(old_decl), Some(new_decl)) = (find_decl(old, name), find_decl(new, name)) else {
337        return Verdict::Breaking;
338    };
339
340    use v2::decl::Kind;
341    let (Some(Kind::TypeDef(old_def)), Some(Kind::TypeDef(new_def))) =
342        (&old_decl.kind, &new_decl.kind)
343    else {
344        return Verdict::Breaking;
345    };
346
347    if narrows(old_def.constraint.as_ref(), new_def.constraint.as_ref()) {
348        Verdict::Breaking
349    } else {
350        Verdict::Compatible
351    }
352}
353
354/// Whether the constraint moved in the narrowing direction on any facet.
355///
356/// Each facet is judged independently and any narrowing decides the whole
357/// change, so a mixed edit — a lowered `min` with a lowered `max` — is breaking
358/// on the half that narrows.
359fn narrows(old: Option<&v2::Constraint>, new: Option<&v2::Constraint>) -> bool {
360    let (Some(old), Some(new)) = (old, new) else {
361        // A constraint appearing where there was none bounds a previously
362        // unbounded value; dropping one entirely only widens.
363        return old.is_none() && new.is_some();
364    };
365
366    // A raised floor or a lowered ceiling rejects values that were legal.
367    if raised(old.min.as_deref(), new.min.as_deref())
368        || lowered(old.max.as_deref(), new.max.as_deref())
369    {
370        return true;
371    }
372    // Length bounds are the same rule over character and byte counts.
373    if raised_u64(old.len_min, new.len_min) || lowered_u64(old.len_max, new.len_max) {
374        return true;
375    }
376    // A step quantizes: added or changed at all it can exclude values that were
377    // legal, and the classifier does not prove divisibility. Removed, it widens.
378    if new.step.is_some() && old.step != new.step {
379        return true;
380    }
381    // A pattern added or rewritten can reject strings that matched before.
382    // Removed, it widens.
383    if (new.pattern.is_some() && old.pattern != new.pattern)
384        || (new.pattern_const.is_some() && old.pattern_const != new.pattern_const)
385    {
386        return true;
387    }
388    false
389}
390
391/// Whether a lower bound was added or moved up.
392fn raised(old: Option<&str>, new: Option<&str>) -> bool {
393    match (old, new) {
394        (None, Some(_)) => true,
395        (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_gt),
396        _ => false,
397    }
398}
399
400/// Whether an upper bound was added or moved down.
401fn lowered(old: Option<&str>, new: Option<&str>) -> bool {
402    match (old, new) {
403        (None, Some(_)) => true,
404        (Some(old), Some(new)) => cmp_decimal(new, old).is_none_or(std::cmp::Ordering::is_lt),
405        _ => false,
406    }
407}
408
409fn raised_u64(old: Option<u64>, new: Option<u64>) -> bool {
410    match (old, new) {
411        (None, Some(_)) => true,
412        (Some(old), Some(new)) => new > old,
413        _ => false,
414    }
415}
416
417fn lowered_u64(old: Option<u64>, new: Option<u64>) -> bool {
418    match (old, new) {
419        (None, Some(_)) => true,
420        (Some(old), Some(new)) => new < old,
421        _ => false,
422    }
423}
424
425/// Orders two canonical decimal strings exactly, without going through a float
426/// (the IR carries exact decimals for precisely this reason, ADR-0007 decision
427/// 9). Returns `None` when either side is not a plain decimal, which callers
428/// read as "cannot prove this relaxes".
429///
430/// The `None` case is not dead. `ridlc` only ever writes canonical decimals, but
431/// [`load_ir_json`](crate::load_ir_json) deserializes a snapshot off disk and a
432/// bound is a plain string there, so a hand-edited or foreign `.ir.json` can
433/// carry an exponent form or any other spelling this function does not read.
434fn cmp_decimal(left: &str, right: &str) -> Option<std::cmp::Ordering> {
435    use std::cmp::Ordering;
436
437    let (left_negative, left_digits) = split_sign(left)?;
438    let (right_negative, right_digits) = split_sign(right)?;
439    if left_negative != right_negative {
440        // Zero is signless in canonical form, so a sign disagreement is a real
441        // ordering: the negative side is the smaller one.
442        return Some(if left_negative {
443            Ordering::Less
444        } else {
445            Ordering::Greater
446        });
447    }
448
449    let magnitude = cmp_magnitude(&left_digits, &right_digits)?;
450    Some(if left_negative {
451        magnitude.reverse()
452    } else {
453        magnitude
454    })
455}
456
457/// Splits an optional leading sign from a decimal, rejecting anything that is
458/// not sign-digits-optional-fraction.
459fn split_sign(text: &str) -> Option<(bool, String)> {
460    let (negative, rest) = match text.strip_prefix('-') {
461        Some(rest) => (true, rest),
462        None => (false, text.strip_prefix('+').unwrap_or(text)),
463    };
464    if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_digit() || c == '.') {
465        return None;
466    }
467    if rest.matches('.').count() > 1 {
468        return None;
469    }
470    Some((negative, rest.to_string()))
471}
472
473/// Orders two unsigned decimal magnitudes by integer part then fraction, so
474/// `9` < `10` and `1.5` < `1.50001`.
475fn cmp_magnitude(left: &str, right: &str) -> Option<std::cmp::Ordering> {
476    use std::cmp::Ordering;
477
478    let (left_int, left_frac) = left.split_once('.').unwrap_or((left, ""));
479    let (right_int, right_frac) = right.split_once('.').unwrap_or((right, ""));
480
481    let left_int = left_int.trim_start_matches('0');
482    let right_int = right_int.trim_start_matches('0');
483    let by_length = left_int.len().cmp(&right_int.len());
484    if by_length != Ordering::Equal {
485        return Some(by_length);
486    }
487    let by_int = left_int.cmp(right_int);
488    if by_int != Ordering::Equal {
489        return Some(by_int);
490    }
491
492    // Compare fractions digit by digit, padding the shorter with zeros.
493    let width = left_frac.len().max(right_frac.len());
494    let pad = |frac: &str| format!("{frac:0<width$}");
495    Some(pad(left_frac).cmp(&pad(right_frac)))
496}
497
498// ==========================================================================
499// Timing.
500// ==========================================================================
501
502/// A resolved timing change on a signal or event (ADR-0008 decision 12).
503///
504/// `min` is the rate floor and `max` the staleness bound, so the consumer-facing
505/// guarantee strengthens when `min` rises or `max` falls and weakens when either
506/// moves the other way. A bound added or removed, or the strict-periodic/range
507/// mode flipped, changes what a consumer may assume at all — rmdl clocks key on
508/// strict — and is breaking in both directions.
509fn timing(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
510    let Some((container, member)) = member_path(change) else {
511        return Verdict::Breaking;
512    };
513    let (Some(old_decl), Some(new_decl)) = (
514        find_old_interaction(old, new, container, member),
515        find_interaction(new, container, member),
516    ) else {
517        return Verdict::Breaking;
518    };
519    let (Some(old_timing), Some(new_timing)) =
520        (interaction_timing(old_decl), interaction_timing(new_decl))
521    else {
522        // An interaction kind that carries no timing at all. The walk cannot
523        // produce this: it emits `TimingChanged` only inside its signal and
524        // event arms, so both sides are already a timed kind by the time a
525        // change reaches here. It is still reachable, because [`classify`] is
526        // public and takes any hand-built `Change` — so the arm stays, follows
527        // the module's unlisted-is-breaking rule, and carries a test of its own.
528        // It is deliberately not a `debug_assert!`: a caller passing a category
529        // the walk would not have emitted is asking a question, not committing
530        // a bug, and aborting a debug build over it would be wrong.
531        return Verdict::Breaking;
532    };
533
534    let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
535        // Timing appearing where there was none, or dropped, is a bound added
536        // or removed.
537        return Verdict::Breaking;
538    };
539
540    if old_timing.mode != new_timing.mode {
541        return Verdict::Breaking;
542    }
543    // A floor lowered, a ceiling raised, or either bound added or removed.
544    if lowered(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
545        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
546        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
547        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
548    {
549        return Verdict::Breaking;
550    }
551    // What remains is a floor raised, a ceiling lowered, or only
552    // `default_applied` flipped over identical bounds — a default made explicit.
553    Verdict::Compatible
554}
555
556/// A declared RPC-bound change on a command or query (ADR-0015 decision 8).
557///
558/// The two bounds keep their generic §9 meaning — `min` the rate floor, `max`
559/// the staleness bound — but on an RPC `min` is the **call throttle** and
560/// constrains the consumer, so its direction inverts against [`timing`]'s
561/// convention: raising it withdraws a call rate the caller was entitled to
562/// use (breaking), and lowering it leaves the caller less constrained
563/// (compatible). `max` is the response bound, a provider promise, and keeps
564/// the provider-side rule: raised is a weaker promise (breaking), lowered a
565/// stronger one (compatible). A bound added or removed — the whole annotation
566/// included — is breaking in both directions, exactly as in [`timing`]:
567/// callers size timeouts against a declared bound, so its appearance and its
568/// disappearance both change what a consumer may assume at all.
569///
570/// This is a distinct category rather than a kind-aware branch inside
571/// [`timing`] so that a missed branch fails closed (ADR-0012 decision 9): the
572/// deny lints above turn a missing arm into a compile error rather than a
573/// silently inherited signal rule that calls a raised RPC `min` compatible.
574fn rpc_bound(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
575    let Some((container, member)) = member_path(change) else {
576        return Verdict::Breaking;
577    };
578    let (Some(old_decl), Some(new_decl)) = (
579        find_old_interaction(old, new, container, member),
580        find_interaction(new, container, member),
581    ) else {
582        return Verdict::Breaking;
583    };
584    let (Some(old_timing), Some(new_timing)) = (rpc_timing(old_decl), rpc_timing(new_decl)) else {
585        // An interaction kind that is not an RPC. The walk cannot produce
586        // this — it emits `RpcBoundChanged` only inside its command and query
587        // arms — but [`classify`] is public and takes any hand-built
588        // [`Change`], so the arm stays and follows the module's
589        // unlisted-is-breaking rule (see [`timing`] for why it is not a
590        // `debug_assert!`).
591        return Verdict::Breaking;
592    };
593
594    let (Some(old_timing), Some(new_timing)) = (old_timing, new_timing) else {
595        // The annotation appearing where there was none, or dropped, is a
596        // bound added or removed — breaking in both directions.
597        return Verdict::Breaking;
598    };
599
600    // The mode is always `Range` on an RPC (ADR-0015 decision 7); anything
601    // else in a snapshot is erroneous IR, and a flip is breaking regardless.
602    if old_timing.mode != new_timing.mode {
603        return Verdict::Breaking;
604    }
605    // `default_applied` is always false on an RPC, because an RPC bound is
606    // never defaulted (ADR-0015 decision 7); anything else in a snapshot is
607    // erroneous IR, and a flip is breaking regardless — never the "default
608    // made explicit" compatibility [`timing`] grants, which would report
609    // compatible on IR the classifier does not understand (ADR-0012
610    // decision 9).
611    if old_timing.default_applied != new_timing.default_applied {
612        return Verdict::Breaking;
613    }
614    // A throttle raised, a response bound raised, or either bound added or
615    // removed. `raised` covers the added case (`None` → `Some`), `dropped` the
616    // removed one. This is [`timing`]'s predicate with the `min` direction
617    // inverted — `raised` where the signal rule reads `lowered`.
618    if raised(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
619        || dropped(old_timing.min_us.as_deref(), new_timing.min_us.as_deref())
620        || raised(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
621        || dropped(old_timing.max_us.as_deref(), new_timing.max_us.as_deref())
622    {
623        return Verdict::Breaking;
624    }
625    // What remains is a throttle lowered or a response bound lowered — the
626    // caller is less constrained, or the provider promises more.
627    Verdict::Compatible
628}
629
630/// Whether a bound present before is absent now.
631fn dropped(old: Option<&str>, new: Option<&str>) -> bool {
632    old.is_some() && new.is_none()
633}
634
635/// The resolved timing of a signal or event; `None` for interaction kinds that
636/// carry none, and `Some(None)` for a timed kind with the field unset.
637fn interaction_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
638    use v2::decl::Kind;
639    match &decl.kind {
640        Some(Kind::SignalDef(def)) => Some(def.timing.as_ref()),
641        Some(Kind::EventDef(def)) => Some(def.timing.as_ref()),
642        _ => None,
643    }
644}
645
646/// The declared timing of a command or query; `None` for other kinds, and
647/// `Some(None)` for an RPC that declares no bounds (never defaulted,
648/// ADR-0015 decision 4).
649fn rpc_timing(decl: &v2::Decl) -> Option<Option<&v2::Timing>> {
650    use v2::decl::Kind;
651    match &decl.kind {
652        Some(Kind::CommandDef(def)) => Some(def.timing.as_ref()),
653        Some(Kind::QueryDef(def)) => Some(def.timing.as_ref()),
654        _ => None,
655    }
656}
657
658// ==========================================================================
659// Contracts.
660// ==========================================================================
661
662/// A `require`/`ensure` clause-set change on a command or query.
663///
664/// A `require` is a precondition the caller must meet: adding one, or rewriting
665/// one, rejects callers that were legal. An `ensure` is a postcondition the
666/// caller may rely on: removing one, or rewriting one, withdraws a guarantee.
667/// Clause text is compared verbatim — the classifier never tries to prove that
668/// one clause implies another (ADR-0008 decision 14), so a rewrite reads as an
669/// addition on the `require` side and as a removal on the `ensure` side, and
670/// both are breaking.
671fn contract(change: &Change, old: &v2::Package, new: &v2::Package) -> Verdict {
672    let Some((container, member)) = member_path(change) else {
673        return Verdict::Breaking;
674    };
675    let (Some(old_decl), Some(new_decl)) = (
676        find_old_interaction(old, new, container, member),
677        find_interaction(new, container, member),
678    ) else {
679        return Verdict::Breaking;
680    };
681    let (Some(old_clauses), Some(new_clauses)) = (contracts(old_decl), contracts(new_decl)) else {
682        return Verdict::Breaking;
683    };
684
685    let kind = |want: v2::ContractKind| {
686        move |clause: &&v2::Contract| v2::ContractKind::try_from(clause.kind) == Ok(want)
687    };
688    let sources = |clauses: &[v2::Contract], want: v2::ContractKind| -> Vec<String> {
689        let mut out: Vec<String> = clauses
690            .iter()
691            .filter(kind(want))
692            .map(|clause| clause.source.clone())
693            .collect();
694        out.sort();
695        out
696    };
697
698    let old_require = sources(old_clauses, v2::ContractKind::Require);
699    let new_require = sources(new_clauses, v2::ContractKind::Require);
700    let old_ensure = sources(old_clauses, v2::ContractKind::Ensure);
701    let new_ensure = sources(new_clauses, v2::ContractKind::Ensure);
702
703    if !covers(&old_require, &new_require) || !covers(&new_ensure, &old_ensure) {
704        return Verdict::Breaking;
705    }
706    Verdict::Compatible
707}
708
709/// Whether every clause in `subset` appears in `superset`, counting duplicates.
710fn covers(superset: &[String], subset: &[String]) -> bool {
711    let mut remaining: Vec<&String> = superset.iter().collect();
712    for clause in subset {
713        match remaining.iter().position(|held| *held == clause) {
714            Some(index) => {
715                remaining.swap_remove(index);
716            }
717            None => return false,
718        }
719    }
720    true
721}
722
723/// The contract clauses of a command or query; `None` for kinds that carry none.
724fn contracts(decl: &v2::Decl) -> Option<&[v2::Contract]> {
725    use v2::decl::Kind;
726    match &decl.kind {
727        Some(Kind::CommandDef(def)) => Some(&def.contracts),
728        Some(Kind::QueryDef(def)) => Some(&def.contracts),
729        _ => None,
730    }
731}
732
733// ==========================================================================
734// Path resolution and IR lookups.
735// ==========================================================================
736
737/// Splits a `package/container/member` path into its container and member.
738/// Returns `None` for the shorter package-level paths.
739fn member_path(change: &Change) -> Option<(&str, &str)> {
740    let mut segments = change.path.split('/').skip(1);
741    let container = segments.next()?;
742    let member = segments.next()?;
743    if segments.next().is_some() {
744        return None;
745    }
746    Some((container, member))
747}
748
749fn find_decl<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Decl> {
750    package.decls.iter().find(|decl| decl.name == name)
751}
752
753/// The shape a name refers to — a top-level interface, or the inline shape of
754/// a service, which the walk descends into under the service's own dotted
755/// name. `Package::shapes` keys both on exactly that identity, so one lookup
756/// covers them.
757fn find_shape<'a>(package: &'a v2::Package, name: &str) -> Option<v2::InterfaceShape<'a>> {
758    package.shapes().find(|shape| shape.name == name)
759}
760
761/// The old side of the shape a diff path names, found the way the walk
762/// matched it (lock design §7; plan decision PD-14): by the frozen
763/// `interfaces.lock` number of the new side's shape when it has one, and by
764/// name otherwise — the new side has no identity, or does not hold the name
765/// at all. A renamed interface's member changes therefore classify as any
766/// other interface's, although their paths carry only the new name.
767fn find_old_shape<'a>(
768    old: &'a v2::Package,
769    new: &v2::Package,
770    name: &str,
771) -> Option<v2::InterfaceShape<'a>> {
772    let by_number = find_shape(new, name)
773        .filter(|shape| frozen(shape.interface))
774        .and_then(|shape| {
775            old.shapes().find(|candidate| {
776                frozen(candidate.interface) && candidate.interface.number == shape.interface.number
777            })
778        });
779    by_number.or_else(|| find_shape(old, name))
780}
781
782fn find_interface<'a>(package: &'a v2::Package, name: &str) -> Option<&'a v2::Interface> {
783    find_shape(package, name).map(|shape| shape.interface)
784}
785
786fn find_old_interface<'a>(
787    old: &'a v2::Package,
788    new: &v2::Package,
789    name: &str,
790) -> Option<&'a v2::Interface> {
791    find_old_shape(old, new, name).map(|shape| shape.interface)
792}
793
794fn find_interaction<'a>(
795    package: &'a v2::Package,
796    container: &str,
797    member: &str,
798) -> Option<&'a v2::Decl> {
799    find_interface(package, container)?
800        .interactions
801        .iter()
802        .find(|decl| decl.name == member)
803}
804
805/// The old side's interaction under a `<package>/<container>/<member>` path,
806/// with the container resolved by [`find_old_shape`].
807fn find_old_interaction<'a>(
808    old: &'a v2::Package,
809    new: &v2::Package,
810    container: &str,
811    member: &str,
812) -> Option<&'a v2::Decl> {
813    find_old_interface(old, new, container)?
814        .interactions
815        .iter()
816        .find(|decl| decl.name == member)
817}
818
819/// Every slot of an interface body as (name, ordinal), tombstones included — a
820/// tombstone occupies its ordinal exactly so the slot is never reused
821/// (ridl §11).
822///
823/// A tombstone whose name is unset still holds its slot. The interface-body
824/// `reserved` form accepts a bare ordinal or a string literal as well as a
825/// name, and both lower to `Reserved { name: None }`; dropping those from the
826/// slot list would leave their ordinals looking free, and a new interaction
827/// taking one would classify as a clean append. The empty name never equals a
828/// real interaction name, so the slot is held against every reuse without
829/// matching anything.
830fn slots(interface: &v2::Interface) -> Vec<(&str, u32)> {
831    interface
832        .interactions
833        .iter()
834        .filter_map(|decl| match &decl.kind {
835            Some(v2::decl::Kind::ReservedSlot(reserved)) => {
836                Some((reserved.name.as_deref().unwrap_or(""), decl.ordinal))
837            }
838            Some(_) => Some((decl.name.as_str(), decl.ordinal)),
839            None => None,
840        })
841        .collect()
842}
843
844fn slot_ordinal(interface: &v2::Interface, member: &str) -> Option<u32> {
845    slots(interface)
846        .into_iter()
847        .find(|(name, _)| *name == member)
848        .map(|(_, ordinal)| ordinal)
849}
850
851/// Every live struct field with its ordinal — the 1-based place in the body,
852/// counting tombstones, that typl §7.4 makes the wire identity. Shared with the
853/// walk, which reports a reorder by these ordinals.
854pub(crate) fn struct_slots(def: &v2::StructDef) -> Vec<(String, i64)> {
855    def.members
856        .iter()
857        .filter_map(|member| match &member.member {
858            Some(v2::struct_member::Member::Field(field)) => {
859                Some((field.name.clone(), i64::from(field.ordinal)))
860            }
861            _ => None,
862        })
863        .collect()
864}
865
866fn struct_reserved(def: &v2::StructDef) -> Vec<i64> {
867    def.members
868        .iter()
869        .filter_map(|member| match &member.member {
870            Some(v2::struct_member::Member::Reserved(reserved)) => {
871                Some(i64::from(reserved.ordinal))
872            }
873            _ => None,
874        })
875        .collect()
876}
877
878/// Every union arm with its ordinal, on the same rule as [`struct_slots`].
879pub(crate) fn union_slots(def: &v2::UnionDef) -> Vec<(String, i64)> {
880    def.arms
881        .iter()
882        .map(|arm| (arm.name.clone(), i64::from(arm.ordinal)))
883        .collect()
884}
885
886fn union_reserved(def: &v2::UnionDef) -> Vec<i64> {
887    def.reserved
888        .iter()
889        .map(|reserved| i64::from(reserved.ordinal))
890        .collect()
891}
892
893/// Enum values and enum-set bits key on their integer value, not a declaration
894/// ordinal: the number is the wire identity (typl §7.4, §8).
895fn value_slots(values: &[v2::EnumValue]) -> Vec<(String, i64)> {
896    values
897        .iter()
898        .map(|value| (value.name.clone(), value.value))
899        .collect()
900}
901
902fn reserved_values(reserved: &[v2::Reserved]) -> Vec<i64> {
903    reserved.iter().filter_map(|entry| entry.value).collect()
904}
905
906// ==========================================================================
907// `--explain` — the rule row for one category.
908// ==========================================================================
909
910/// Parses the snake_case word a report prints back into its category, so
911/// `ridl diff --explain <category>` takes exactly what the report shows.
912pub fn category_from_word(word: &str) -> Option<Category> {
913    crate::CATEGORIES
914        .into_iter()
915        .find(|category| crate::category_word(*category) == word)
916}
917
918/// The rule row for a category: the classification table of ADR-0008 decision
919/// 14 as text. This is the CI-facing documentation of record until the E4 error
920/// index publishes it.
921// A new variant must be given a real arm here, not swept into a
922// catch-all: rustc forces *an* arm, and the arm its `help:` text
923// proposes is `_ =>`, which classifies the new variant silently. The
924// two lints below reject a wildcard over `Category` — the first when
925// it covers several variants, the second when it covers exactly one,
926// which is the case one added variant creates.
927#[deny(
928    clippy::wildcard_enum_match_arm,
929    clippy::match_wildcard_for_single_variants
930)]
931pub fn explain(category: Category) -> &'static str {
932    match category {
933        Category::DeclAdded => concat!(
934            "A declaration, interface, or service present only in the new snapshot.\n",
935            "  compatible  a new package-level decl, interface, or service; an enum\n",
936            "              value appended above every live and retired number; a\n",
937            "              struct field or union arm appended at the end of the body\n",
938            "              (typl 7.4, append-only)\n",
939            "  breaking    a member inserted below the highest slot ever used, a member\n",
940            "              taking a retired number, any addition that moves a surviving\n",
941            "              member's slot, or any arm of a result union (ADR-0008 d4)\n",
942            "  note        an interface is matched by its interfaces.lock number (lock\n",
943            "              design 7): a frozen number the old side never held is a new\n",
944            "              interface, and so is every provisional one — a declaration\n",
945            "              with no lock entry carries no identity, and a rename keeps its\n",
946            "              number, so it is never a renamed interface (interface_renamed)"
947        ),
948        Category::DeclRemoved => concat!(
949            "A declaration, interface, or service present only in the old snapshot.\n",
950            "  breaking    a removed service, interface, decl, enum value, struct\n",
951            "              field, or union arm withdraws something a consumer compiled\n",
952            "              against\n",
953            "  note        an interface is matched by its interfaces.lock number (lock\n",
954            "              design 7), so an interface here is one whose number is gone\n",
955            "              from the new side and not retired there — a lock line deleted\n",
956            "              by hand, or a number changed by hand, which is this row plus\n",
957            "              decl_added. A number the new side's lock retires is\n",
958            "              interface_retired instead, and `ridl baseline` refuses to\n",
959            "              publish the removal of a number it does not find retired\n",
960            "              (RIDL-412)\n",
961            "  caveat      a composite member retired the sanctioned way — replaced by\n",
962            "              a `reserved` tombstone in its own slot (typl 7.4) — is also\n",
963            "              reported breaking today. The body comparison is keyed on\n",
964            "              member names and does not read the `reserved` list, so it\n",
965            "              cannot yet tell that retirement from a bare deletion. This\n",
966            "              errs on the safe side; carried as debt, see the note on\n",
967            "              `diff_composite`. The interaction-level tombstone IS\n",
968            "              recognised — see interaction_retired"
969        ),
970        Category::InterfaceRenamed => concat!(
971            "An interface whose interfaces.lock number is the same on both sides and\n",
972            "whose name changed.\n",
973            "  compatible  always — the number is the interface's identity and its\n",
974            "              routing key (lock design 7, rsdl decision D-7), so nothing\n",
975            "              moves on the wire. Visible in source: a rename changes the\n",
976            "              generated identity-table names in both wire backends, which\n",
977            "              is why the text report lists it under the heading\n",
978            "              \"compatible on the wire, visible in source\". The path\n",
979            "              carries the new name and the detail old -> new; changes\n",
980            "              inside the interface are reported under the new name and\n",
981            "              classified as any other interface's\n",
982            "  note        a provisional number is no identity: a declaration with no\n",
983            "              lock entry is never matched, so a rename the lock does not\n",
984            "              record is decl_removed plus decl_added, breaking, until\n",
985            "              `ridl lock <pkg> --rename Old=New` records it"
986        ),
987        Category::InterfaceRetired => concat!(
988            "An interface whose number the old snapshot held is gone from the new\n",
989            "snapshot, whose interfaces.lock retires it.\n",
990            "  compatible  always — the sanctioned removal of an interface: the entry\n",
991            "              keeps its number forever with the word `retired`, so the\n",
992            "              number is never allocated again (lock design 4 and 7, rsdl\n",
993            "              decision D-7). A number gone with no retired entry is\n",
994            "              decl_removed, breaking, and `ridl baseline` refuses to publish\n",
995            "              it (RIDL-412)"
996        ),
997        Category::MemberReordered => concat!(
998            "A surviving composite member whose slot in the body changed.\n",
999            "  breaking    always — a struct field or union arm takes its wire\n",
1000            "              identity from its ordinal, its 1-based place in the body\n",
1001            "              counting tombstones (typl 7.4), so a member whose ordinal\n",
1002            "              changed has a new wire identity; the detail carries the old\n",
1003            "              and new ordinal. An enum value or enum-set bit carries an\n",
1004            "              explicit number instead (typl 8, 9), but the walk compares\n",
1005            "              positions, not those numbers, so a textual reorder of an\n",
1006            "              enum or enum-set body is reported breaking as well,\n",
1007            "              conservatively, even when no number changed; the detail\n",
1008            "              carries the old and new position\n",
1009            "  note        reported only when both bodies hold the same member names:\n",
1010            "              a reorder in the same edit as an addition or a removal is\n",
1011            "              reported through decl_added or decl_removed alone. A reorder\n",
1012            "              in the same edit as an in-place change to the body is\n",
1013            "              reported with constraint_changed on the container as well"
1014        ),
1015        Category::InteractionAppended => concat!(
1016            "An interaction added after every slot that existed before.\n",
1017            "  compatible  the slot it takes was never occupied\n",
1018            "  breaking    the slot was freed by an untombstoned removal and is now\n",
1019            "              reused by a new name — a reused wire identity (ADR-0008 d14)"
1020        ),
1021        Category::InteractionInserted => concat!(
1022            "An interaction added before the end of the interface body.\n",
1023            "  breaking    always — every later ordinal shifts, and the ordinal is the\n",
1024            "              transport identity (ridl 11)"
1025        ),
1026        Category::InteractionReordered => concat!(
1027            "A surviving interaction whose relative order in the body changed.\n",
1028            "  breaking    always — a reorder shifts wire identities (ridl 11)"
1029        ),
1030        Category::InteractionRemoved => concat!(
1031            "An interaction removed without a `reserved` tombstone holding its slot.\n",
1032            "  breaking    always — the freed ordinal is reusable, so the wire identity\n",
1033            "              is no longer permanent (ridl 11)"
1034        ),
1035        Category::InteractionRetired => concat!(
1036            "An interaction retired to a `reserved` tombstone in its own ordinal slot.\n",
1037            "  compatible  always — the sanctioned retirement: the slot stays occupied\n",
1038            "              and every later ordinal holds (ridl 11)"
1039        ),
1040        Category::KindChanged => concat!(
1041            "An interaction whose kind changed (signal, event, command, query, fixed).\n",
1042            "  breaking    any direction — the kind selects the transport shape"
1043        ),
1044        Category::PayloadChanged => concat!(
1045            "A signal, event, or fixed payload type changed.\n",
1046            "  breaking    any direction, a stream added or removed included"
1047        ),
1048        Category::ReturnChanged => concat!(
1049            "A query return shape changed.\n",
1050            "  breaking    any direction — an ok-arm change, an error arm added, removed,\n",
1051            "              or retyped, a stream added or removed, or any other change to\n",
1052            "              the synthesized inline `T | E` transport identity (ADR-0008 d4:\n",
1053            "              interface + interaction ordinal + ordered arm types)"
1054        ),
1055        Category::ParamsChanged => concat!(
1056            "A command or query parameter list changed.\n",
1057            "  breaking    any direction — a parameter added, removed, renamed, retyped,\n",
1058            "              or a stream added or removed on one"
1059        ),
1060        Category::TimingChanged => concat!(
1061            "A signal or event resolved timing changed (ADR-0008 d12).\n",
1062            "  compatible  min raised (a higher rate floor) or max lowered (a tighter\n",
1063            "              staleness bound) with the mode unchanged; default_applied\n",
1064            "              flipped over identical resolved bounds — a default made\n",
1065            "              explicit\n",
1066            "  breaking    min lowered, max raised, a bound added where none was, a bound\n",
1067            "              removed, or the strict-periodic/range mode flipped\n",
1068            "  note        editing `[defaults].timing` needs no special rule: diff\n",
1069            "              compares resolved bounds, so it surfaces here on every\n",
1070            "              defaulted interaction (ridl 9.1)"
1071        ),
1072        Category::RpcBoundChanged => concat!(
1073            "A command or query declared RPC bound changed (ADR-0015 d8).\n",
1074            "  compatible  min lowered (the caller may call more often) or max lowered\n",
1075            "              (a stronger provider promise), with the mode unchanged\n",
1076            "  breaking    min raised — on an RPC, min is the call throttle and\n",
1077            "              constrains the caller, so raising it withdraws a call rate\n",
1078            "              the caller was entitled to use; max raised (a weaker provider\n",
1079            "              promise); a bound added or removed, the whole annotation\n",
1080            "              included\n",
1081            "  note        the min direction is the inverse of timing_changed's, which\n",
1082            "              is why this is a category of its own rather than a branch:\n",
1083            "              a missed branch would inherit the signal rule and call a\n",
1084            "              raised RPC min compatible (ADR-0012 d9, fail closed).\n",
1085            "              RPC bounds are never defaulted (ridl 9.1 does not apply)"
1086        ),
1087        Category::ContractChanged => concat!(
1088            "A command or query require/ensure clause set changed (ridl 13).\n",
1089            "  compatible  a require removed, or an ensure added\n",
1090            "  breaking    a require added or its text changed, or an ensure removed or\n",
1091            "              its text changed. Clause text is compared verbatim: the\n",
1092            "              classifier does not prove that one clause implies another\n",
1093            "              (ADR-0008 d14)"
1094        ),
1095        Category::WidthChanged => concat!(
1096            "A derived wire width or scalar backing changed.\n",
1097            "  breaking    any IntWidth or FloatWidth change, uint64 versus int64\n",
1098            "              included — the resolved width is part of the contract\n",
1099            "              (typl 4.2, 5.6)"
1100        ),
1101        Category::ConstraintChanged => concat!(
1102            "A scalar constraint changed, or a composite body changed in place.\n",
1103            "  compatible  widened — min lowered, max raised, a length bound loosened,\n",
1104            "              a step removed, a match pattern removed (by literal or by\n",
1105            "              named constant), or the whole constraint dropped so the\n",
1106            "              value is unbounded again\n",
1107            "  breaking    narrowed — min raised, max lowered, a length bound\n",
1108            "              tightened, a step added or changed (divisibility is never\n",
1109            "              proved), a match pattern added or rewritten (by literal or\n",
1110            "              by named constant), or a constraint appearing where there\n",
1111            "              was none, which bounds a previously unbounded value. A\n",
1112            "              composite body changed in place is breaking: the walk\n",
1113            "              does not say which member changed inside it\n",
1114            "  note        each facet is judged on its own and any one narrowing\n",
1115            "              decides the change, so a mixed edit is breaking on the half\n",
1116            "              that narrows. A widening that flips the resolved wire width\n",
1117            "              is separately reported as width_changed, which is always\n",
1118            "              breaking (typl 5.6)"
1119        ),
1120        Category::InitChanged => concat!(
1121            "A declared or resolved init value changed.\n",
1122            "  breaking    any direction — the init is the value a consumer reads before\n",
1123            "              the first publish, so it is part of the contract (ridl 9.1)"
1124        ),
1125        Category::ReservedNameRedeclared => concat!(
1126            "A name retired by a `reserved` tombstone is live again — an interaction\n",
1127            "inside an interface body.\n",
1128            "  breaking    always — a retired identity is never reused (ridl 11,\n",
1129            "              RIDL-401)"
1130        ),
1131        Category::ServiceChanged => concat!(
1132            "A service switched between the named list and an inline shape.\n",
1133            "  breaking    always — extraction rewrites the transport identity of every\n",
1134            "              fallible query in the shape: an inline shape derives it from\n",
1135            "              the service's dotted name, a named interface from its own name\n",
1136            "              (ADR-0008 d4, ADR-0015 d15). A changed list is not this\n",
1137            "              category: it is read as a set by the service_interface_*\n",
1138            "              rows (ADR-0015 d19, as amended 2026-09-15)"
1139        ),
1140        Category::ServiceInterfaceAdded => concat!(
1141            "An interface joined a service's set of interfaces.\n",
1142            "  compatible  always — nothing that existed moved: an interface's number\n",
1143            "              comes from its package's interfaces.lock, not from its place\n",
1144            "              in the list, and the routing key does not contain the service\n",
1145            "              (ADR-0015 d19, as amended 2026-09-15)"
1146        ),
1147        Category::ServiceInterfaceRemoved => concat!(
1148            "An interface left a service's set of interfaces.\n",
1149            "  compatible  always — on the wire: the routing key does not contain the\n",
1150            "              service, so no identity moves, and a split into two services\n",
1151            "              is a removal plus an addition. Visible in source: the\n",
1152            "              service.member addresses of that interface stop resolving\n",
1153            "              under this service, which is why the text report lists it\n",
1154            "              under the heading \"compatible on the wire, visible in\n",
1155            "              source\". A consumer that loses its only provider is a\n",
1156            "              wiring error for rsdl, not a package diff (ADR-0015 d19, as\n",
1157            "              amended 2026-09-15)"
1158        ),
1159        Category::DocOnly => concat!(
1160            "Only doc comment, labels, or deprecation metadata changed.\n",
1161            "  compatible  always — none of it reaches a consumer's build.\n",
1162            "              Visibility is NOT in this category: see\n",
1163            "              visibility_changed"
1164        ),
1165        Category::VisibilityChanged => concat!(
1166            "The visibility a declaration is published at changed.\n",
1167            "  compatible  internal -> public: the declaration is offered to more\n",
1168            "              consumers than before\n",
1169            "  breaking    public -> internal: `internal` maps to the target's\n",
1170            "              package-private mechanism — Rust `pub(crate)`, a\n",
1171            "              non-exported TypeScript member (ADR-0002 8) — so the\n",
1172            "              declaration disappears from every out-of-package\n",
1173            "              consumer's build. The wire layout does not move, but the\n",
1174            "              consumer-visible guarantee narrows (ADR-0008 d14). Any\n",
1175            "              direction involving an unset visibility is breaking"
1176        ),
1177    }
1178}