Skip to main content

nros_node/
declared_qos.rs

1//! phase-454 W10 — the DECLARED QoS history depth, and what Rust does with it.
2//!
3//! # What this is
4//!
5//! The QoS history DEPTH of a subscription multiplies the executor arena:
6//! `sizeof(entry) + (depth + 1) * bound + (depth + 1) * 8`. A build that sizes
7//! from a declared depth and an image that registers a different one are an
8//! image sized for one number and running another — so the two have to be held
9//! to one. C++ holds them with a `static_assert` inside `NROS_SUBSCRIBE`, and C
10//! with a `_Static_assert` inside `NROS_ASSERT_DECLARED_DEPTH`.
11//!
12//! # Why Rust's half is at REGISTRATION and not at compile time
13//!
14//! Both C surfaces get a compile-time answer because a preprocessor macro sees
15//! the call site's TOPIC as a token. Rust has neither of the two things that
16//! would make the same answer possible:
17//!
18//!   * **No macro seam at the subscribe site.** `nros::main!` emits the boot
19//!     scaffold and knows nothing about subscriptions; there is no
20//!     `nros::subscribe!`. Every call site is an ordinary method call.
21//!   * **The topic is a runtime `&str`**, not a const generic. A `const`
22//!     assertion would need it in the type system.
23//!
24//! And the descriptor road differs too: a RUST component is registered as an
25//! empty cmake INTERFACE library (`NanoRosNodeRegister.cmake`), compiled by the
26//! workspace runtime crate rather than by that target, so there is no C
27//! preprocessor in the lane and no include path to put a header on. That is why
28//! `_nros_declared_qos_arm()` returns early for `RUST` — the header mechanism
29//! is not available to it, not merely unimplemented.
30//!
31//! # TWO facts arrive, on two roads
32//!
33//! The declared depths reach `nros-node/build.rs` from whichever carrier the
34//! road has, and it writes both into [`crate::config::DECLARED_QOS_ROWS`]:
35//!
36//!   * the **cmake road** (native C/C++, Zephyr west, NuttX) sets
37//!     `NROS_ENTITY_DECLARED_DEPTHS` (`type|topic=depth`), written by the entity
38//!     inventory from the resolved SystemModel;
39//!   * the **single-package cargo leaf road** — the one phase-454 W12 taught to
40//!     read a `system.contract.yaml` — sets no such variable and names a SIZING
41//!     DESCRIPTOR instead, whose `[[endpoint]]` rows carry the same fact
42//!     (RFC-0100 D4). W10 shipped reading only the first, so on exactly the road
43//!     that had a contract this table was `None` and nothing could be compared.
44//!
45//! # And two things are done with them, because the surfaces differ
46//!
47//!   * [`check`](crate::declared_qos::check) REFUSES a disagreement, with
48//!     [`NodeError::DeclaredDepthMismatch`] — the registration fails, the
49//!     subscription never exists, and the entry's `?` carries it out. Reached
50//!     from the C and C++ FFI seams, which took the declared depth at their own
51//!     call site already.
52//!   * [`honour`](crate::declared_qos::honour) is what a RUST registration calls: it TAKES a declared depth
53//!     shallower than the ask (the C++ three-argument `NROS_SUBSCRIBE`'s answer,
54//!     delivered one layer down because Rust has no call-site seam) and refuses
55//!     a declaration DEEPER than the ask. Its doc comment has the whole rule and
56//!     why the asymmetry is real.
57//!
58//! # ABSENCE IS NOT ZERO
59//!
60//! A `(type, topic)` with no row is "nobody declared this endpoint". Nothing is
61//! checked for it, nothing is taken, and nothing is defaulted. An image with no
62//! contract sidecar has `DECLARED_QOS_ROWS == None` and behaves exactly as it
63//! did before — measured, not asserted: on `examples/linux/rust/listener` with
64//! the sidecar removed, `.bss + .data` and every attributed symbol are
65//! identical to `origin/main`'s, and the image carries no `declared_qos` symbol
66//! at all.
67//!
68//! # issue 1256 -- RELIABILITY and DURABILITY too
69//!
70//! A contract can state three things about a subscription and this module now
71//! reads all three. The two policies are not a sizing fact the way depth is;
72//! they are an INTEROP one -- an incompatible-QoS match never delivers -- and
73//! until this they were declared, delivered to the build and never compared
74//! with the code. They arrive on the sizing DESCRIPTOR only (no env knob is
75//! added for them: re-carrying a fact the descriptor already states is the
76//! 0460/0491 shape), and [`check`](crate::declared_qos::check) / [`honour`](crate::declared_qos::honour) treat them by the same rules as
77//! the depth, with the order of the two values standing in for "deeper".
78//!
79//! # issue 1608 -- PUBLISHERS too, with the policy direction REVERSED
80//!
81//! A contract can state the same three things about a publisher, and
82//! `transient_local` is the one QoS fact that is publisher-side by nature: it is
83//! what a zenoh image spends a queryable slot on. The descriptor's publisher
84//! rows reach [`DECLARED_PUBLISHER_QOS_ROWS`](crate::declared_qos::DECLARED_PUBLISHER_QOS_ROWS)
85//! (a table of its own: a publisher and a subscription on one `(type, topic)`
86//! are two endpoints), and every publisher registration -- the Rust seams and
87//! the C/C++ FFI seams alike -- goes through
88//! [`honour_publisher`](crate::declared_qos::honour_publisher).
89//!
90//! Its rule is NOT the subscription's copied across. Reliability and durability
91//! are matched request-vs-offered, so the tolerant direction flips: a reader may
92//! ask for less than a writer offers, a writer may offer more than a reader asks.
93//! A publisher asking LESS than its declaration is therefore RAISED to it (the
94//! build reserved it, and every reader that matched the lower offer still
95//! matches); one asking MORE is REFUSED (lowering it could break a match, and
96//! keeping it rides storage the build did not count). The depth rule is shared.
97//! Measured on `examples/linux/rust/talker` with a contract declaring
98//! `/chatter` `transient_local`, depth 1: the default publisher registers
99//! transient_local, depth 1, and reports both takes; declaring `best_effort`
100//! instead refuses the registration and the image exits naming the topic.
101
102use crate::executor::NodeError;
103use nros_rmw::{QoSDurabilityPolicy, QoSProfile, QoSReliabilityPolicy};
104
105/// One subscription this image's contract declares something about.
106///
107/// Each column is `None` where nobody stated it -- a row exists when ANY of the
108/// three is stated, so any one of them may be silent. `type_name` appears once
109/// in the ROS spelling and once DDS-mangled, as two rows, because which one a
110/// generated message class carries is a property of the codegen that produced
111/// it.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct DeclaredEndpoint {
114    pub type_name: &'static str,
115    pub topic: &'static str,
116    /// The declared KEEP_LAST depth.
117    pub depth: Option<u32>,
118    /// `Reliable` / `BestEffort` -- never `SystemDefault`, which says "the
119    /// middleware chooses" and so cannot be disagreed with.
120    pub reliability: Option<QoSReliabilityPolicy>,
121    /// `Volatile` / `TransientLocal`, on the same rule.
122    pub durability: Option<QoSDurabilityPolicy>,
123}
124
125/// Every subscription this image's contract declares something about, or
126/// `None` when it declares nothing.
127pub use crate::config::DECLARED_QOS_ROWS;
128
129/// issue 1608 -- every PUBLISHER this image's contract declares something
130/// about, or `None`. Same row shape as [`DECLARED_QOS_ROWS`]; read by
131/// [`honour_publisher`].
132pub use crate::config::DECLARED_PUBLISHER_QOS_ROWS;
133
134/// Do two topic spellings name the same endpoint?
135///
136/// One leading `/` is ignored on each side: a contract states `/chatter` and a
137/// call site may write either `"/chatter"` or the relative `"chatter"` (
138/// `examples/workspaces/launch/src/listener_pkg` writes the second). Nothing
139/// deeper is normalised — a namespace is a different endpoint, and guessing at
140/// one here would make the check match rows nobody meant.
141fn topic_eq(a: &str, b: &str) -> bool {
142    let a = a.strip_prefix('/').unwrap_or(a);
143    let b = b.strip_prefix('/').unwrap_or(b);
144    a == b
145}
146
147/// The declared row for `(type_name, topic)` in `rows`, or `None`.
148///
149/// Split out so the lookup is testable against a table this build did not
150/// generate: in-tree, `DECLARED_QOS_ROWS` is `None` (no contract reaches a
151/// unit-test build), so a test that could only call the public form would
152/// assert nothing while passing.
153fn row_in<'r>(
154    rows: &'r [DeclaredEndpoint],
155    type_name: &str,
156    topic: &str,
157) -> Option<&'r DeclaredEndpoint> {
158    rows.iter()
159        .find(|r| r.type_name == type_name && topic_eq(r.topic, topic))
160}
161
162/// The declared depth for `(type_name, topic)` in `rows`, or `None`.
163fn depth_in(rows: &[DeclaredEndpoint], type_name: &str, topic: &str) -> Option<u32> {
164    row_in(rows, type_name, topic).and_then(|r| r.depth)
165}
166
167/// The declared depth for `(type_name, topic)`, or `None` when nobody declared
168/// that endpoint's depth.
169///
170/// Linear over a handful of rows, on a path that runs once per subscription at
171/// registration. `None` when this image declares nothing at all.
172pub fn declared_depth(type_name: &str, topic: &str) -> Option<u32> {
173    depth_in(DECLARED_QOS_ROWS?, type_name, topic)
174}
175
176/// issue 1256 -- the declared reliability for `(type_name, topic)`, or `None`.
177pub fn declared_reliability(type_name: &str, topic: &str) -> Option<QoSReliabilityPolicy> {
178    row_in(DECLARED_QOS_ROWS?, type_name, topic).and_then(|r| r.reliability)
179}
180
181/// issue 1256 -- the declared durability for `(type_name, topic)`, or `None`.
182pub fn declared_durability(type_name: &str, topic: &str) -> Option<QoSDurabilityPolicy> {
183    row_in(DECLARED_QOS_ROWS?, type_name, topic).and_then(|r| r.durability)
184}
185
186/// The contract's own spelling of a reliability, for a log line.
187fn reliability_spelling(r: QoSReliabilityPolicy) -> &'static str {
188    match r {
189        QoSReliabilityPolicy::Reliable => "reliable",
190        QoSReliabilityPolicy::BestEffort => "best_effort",
191        QoSReliabilityPolicy::SystemDefault => "system_default",
192        QoSReliabilityPolicy::Unknown => "unknown",
193    }
194}
195
196/// The contract's own spelling of a durability, for a log line.
197fn durability_spelling(d: QoSDurabilityPolicy) -> &'static str {
198    match d {
199        QoSDurabilityPolicy::Volatile => "volatile",
200        QoSDurabilityPolicy::TransientLocal => "transient_local",
201        QoSDurabilityPolicy::SystemDefault => "system_default",
202        QoSDurabilityPolicy::Unknown => "unknown",
203    }
204}
205
206/// How much a reliability ASKS FOR: the order [`honour`] reads "stronger" in.
207/// `None` is "states no opinion" -- `SystemDefault` hands the choice to the
208/// middleware and `Unknown` is a read-back sentinel nobody requests.
209fn reliability_rank(r: QoSReliabilityPolicy) -> Option<u8> {
210    match r {
211        QoSReliabilityPolicy::BestEffort => Some(0),
212        QoSReliabilityPolicy::Reliable => Some(1),
213        QoSReliabilityPolicy::SystemDefault | QoSReliabilityPolicy::Unknown => None,
214    }
215}
216
217/// How much a durability ASKS FOR. See [`reliability_rank`].
218fn durability_rank(d: QoSDurabilityPolicy) -> Option<u8> {
219    match d {
220        QoSDurabilityPolicy::Volatile => Some(0),
221        QoSDurabilityPolicy::TransientLocal => Some(1),
222        QoSDurabilityPolicy::SystemDefault | QoSDurabilityPolicy::Unknown => None,
223    }
224}
225
226/// Refuse a subscription whose QoS disagrees with what the contract declared
227/// for its topic -- its depth, and (issue 1256) its reliability and durability.
228///
229/// `Ok(())` when every declared column agrees AND when nothing was declared --
230/// an image that has not opted in is not an image in error. On a disagreement
231/// the TOPIC and BOTH values go to the log first (a `NodeError` is a unit
232/// variant and can carry none of the three), then the registration is
233/// refused: [`NodeError::DeclaredDepthMismatch`] for the depth,
234/// [`NodeError::DeclaredQosMismatch`] for a policy.
235///
236/// Reached from the **C and C++ FFI registration seams**, which have already
237/// taken the declared QoS at their own call site (`NROS_SUBSCRIBE`'s
238/// three-argument form → `qos_from_declared`; C's `_Static_assert`), so a
239/// disagreement arriving here is a real one. The Rust seams call [`honour`]
240/// instead — see its doc comment for why the two surfaces differ.
241///
242/// # Keep the message inside ONE log line
243///
244/// `nros_log`'s formatting buffer is 256 bytes by default (`NROS_LOG_BUFFER_SIZE`)
245/// and OVERFLOW TRUNCATES with a single `…`. This message used to run past 450
246/// characters, so on a real image the half a reader needs — which file to edit —
247/// was the half that got cut, and the diagnostic ended mid-sentence. The
248/// numbers and the topic come first for the same reason: a long type name may
249/// still push the tail off, and the tail is the least load-bearing part.
250pub fn check(type_name: &str, topic: &str, qos: &QoSProfile) -> Result<(), NodeError> {
251    match DECLARED_QOS_ROWS {
252        Some(rows) => check_in(rows, type_name, topic, qos),
253        None => Ok(()),
254    }
255}
256
257/// [`check`] against a table this build did not generate. See [`row_in`].
258fn check_in(
259    rows: &[DeclaredEndpoint],
260    type_name: &str,
261    topic: &str,
262    qos: &QoSProfile,
263) -> Result<(), NodeError> {
264    let Some(row) = row_in(rows, type_name, topic) else {
265        return Ok(());
266    };
267    if let Some(declared) = row.depth
268        && declared != qos.depth
269    {
270        nros_log::log_error!(
271            nros_log::get_logger("nros_node"),
272            "declared depth: `{}` registers KEEP_LAST({}) but this image's contract \
273             declares {}, and the arena was sized from {}. Fix the contract row or the \
274             QoS at the call site (type `{}`).",
275            topic,
276            qos.depth,
277            declared,
278            declared,
279            type_name
280        );
281        return Err(NodeError::DeclaredDepthMismatch);
282    }
283    if let Some(declared) = row.reliability
284        && declared != qos.reliability
285    {
286        nros_log::log_error!(
287            nros_log::get_logger("nros_node"),
288            "declared QoS: `{}` registers {} but this image's contract declares reliability \
289             {}; an incompatible match never delivers. Fix the contract row or the call site.",
290            topic,
291            reliability_spelling(qos.reliability),
292            reliability_spelling(declared)
293        );
294        return Err(NodeError::DeclaredQosMismatch);
295    }
296    if let Some(declared) = row.durability
297        && declared != qos.durability
298    {
299        nros_log::log_error!(
300            nros_log::get_logger("nros_node"),
301            "declared QoS: `{}` registers {} but this image's contract declares durability \
302             {}; an incompatible match never delivers. Fix the contract row or the call site.",
303            topic,
304            durability_spelling(qos.durability),
305            durability_spelling(declared)
306        );
307        return Err(NodeError::DeclaredQosMismatch);
308    }
309    Ok(())
310}
311
312/// The QoS a Rust subscription actually registers with, once the contract has
313/// had its say — phase-454 W13, the Rust half of W10, widened to the two
314/// policies by issue 1256.
315///
316/// # Why Rust TAKES the declared value where C and C++ ASSERT it
317///
318/// C++ settles this at the call site, by arity: `NROS_SUBSCRIBE(Msg, m, topic)`
319/// states no QoS, so `qos_from_declared` BUILDS one from the declaration
320/// ("nothing to assert — there is only ever one statement"), and the
321/// four-argument form, where the call site did state one, `static_assert`s that
322/// the two agree. C does the same with `NROS_ASSERT_DECLARED_*`.
323///
324/// Rust has neither half of that seam: there is no `nros::subscribe!`, and the
325/// topic is a runtime `&str`, so nothing at the call site can be told apart
326/// from anything else at compile time — and by the time a QoS reaches a
327/// registration, "the caller wrote this" and "a constructor defaulted it" are
328/// the same `QoSProfile`. `Node::create_subscription` hands
329/// `QoSProfile::default()` — `rmw_qos_profile_default`, KEEP_LAST(10),
330/// RELIABLE, VOLATILE — to the same `_with_qos` entry point a caller with an
331/// opinion reaches, and the declarative road folds both into one
332/// `EntityMetadata::qos` field long before this point.
333///
334/// So the distinction C++ makes is not available here, and the two candidate
335/// rules that remain each fail on their own:
336///
337/// * **Check only.** Every `QoSProfile::default()` call site in the tree would
338///   be refused the moment its endpoint is declared at anything but the
339///   default — which is the entire point of declaring one. A declaration would
340///   become a way to stop an image booting.
341/// * **Take, always.** A caller who narrowed a queue on purpose
342///   (`create_subscription_viewable` requires KEEP_LAST(1)) or chose
343///   `best_effort` for a sensor would be silently widened by a contract that
344///   over-declared.
345///
346/// # The rule, and why the asymmetry is real
347///
348/// **The contract states what the BUILD RESERVED.** W12 made that literal for
349/// the depth: the executor arena, the zenoh receive ring and both payload pools
350/// on a declared image are all sized from the declared depth. The policies are
351/// the same kind of statement: XRCE drops both reliable stream buffers to the
352/// protocol floor when every endpoint declares `best_effort` (RFC-0100 D3), and
353/// a `transient_local` endpoint is what the build counts a late-joiner slot
354/// for. So for each column, ordered by how much it ASKS FOR (deeper; reliable
355/// over best_effort; transient_local over volatile):
356///
357/// * `asked > declared` — the registration wants more than was bought, and
358///   asking for it would ride storage the build did not reserve. **TAKEN**, and
359///   reported. For the two policies it is also the direction RxO tolerates: a
360///   `best_effort` or `volatile` reader still matches a `reliable` or
361///   `transient_local` writer.
362/// * `asked < declared` — taking would WIDEN what the code narrowed, and the
363///   image paid for something nothing wants. Two statements about one fact
364///   disagreeing, with no default that explains either (RFC-0100 D8).
365///   **REFUSED**, naming both.
366/// * equal, or nothing declared — returned unchanged. An image with no contract
367///   is byte-identical to every build before this wave.
368/// * a policy asked as `SystemDefault` — the code stated no opinion, so the
369///   declaration is TAKEN, silently: there is nothing to report a divergence
370///   from.
371///
372/// # And it says so
373///
374/// A take is REPORTED, once per endpoint that diverges — the C++ surface's
375/// value is visible in the source at the call site and Rust's is not, so a
376/// registration that quietly stopped being what `lib.rs` reads would be the lie
377/// this whole model exists to remove. Every take here narrows what the code
378/// asked for (a shallower queue, no retransmission, no late-joiner history),
379/// any of which can drop a sample, so each is `WARN`.
380///
381/// Called instead of [`check`] at every Rust subscription registration. The C
382/// and C++ FFI seams keep [`check`] — they have already taken the declared QoS
383/// at their own call site, so a disagreement reaching THEM is a real refusal
384/// and not a default nobody wrote.
385///
386/// # `#[inline]` is load-bearing, and it was MEASURED
387///
388/// An image with no contract must be byte-identical to every build before this
389/// wave, and "the table is empty so the work folds away" is a claim about the
390/// optimiser, not a fact. It was FALSE as first written: with the table
391/// threaded in as an `unwrap_or(&[])` argument, `honour` stayed out of line —
392/// a real `T nros_node::declared_qos::honour` symbol, +144 bytes of text and
393/// +8 of data on the no-contract listener against `origin/main`, because a
394/// 40-byte `QoSProfile` moving in and out by value is past LLVM's inlining
395/// budget. Matching the `Option` HERE, behind `#[inline]`, is what lets a
396/// `None` table fold the call to `Ok(qos)` at every one of the fourteen
397/// registration sites and leave `honour_in` unreferenced. Verified by
398/// rebuilding both trees and comparing the linked binary byte for byte.
399#[inline]
400pub fn honour(type_name: &str, topic: &str, qos: QoSProfile) -> Result<QoSProfile, NodeError> {
401    let Some(rows) = DECLARED_QOS_ROWS else {
402        return Ok(qos);
403    };
404    honour_in(rows, type_name, topic, qos)
405}
406
407/// [`honour`] against a table this build did not generate.
408///
409/// Split out for the reason [`row_in`] is: in-tree `DECLARED_QOS_ROWS` is
410/// `None`, so a test that could only call the public form would exercise the
411/// "nothing declared" arm and assert nothing about the take or the refusal
412/// while reading as coverage of both.
413fn honour_in(
414    rows: &[DeclaredEndpoint],
415    type_name: &str,
416    topic: &str,
417    qos: QoSProfile,
418) -> Result<QoSProfile, NodeError> {
419    honour_role_in(rows, type_name, topic, qos, Role::Subscription)
420}
421
422/// Which side of a match an endpoint is on -- issue 1608.
423///
424/// It decides ONE thing: which direction of a POLICY disagreement
425/// [`honour_role_in`] may take. Depth is not a matched policy and is treated
426/// the same on both sides. Reliability and durability are request-vs-offered
427/// (RxO) matched, and the tolerant direction is OPPOSITE for the two roles: a
428/// reader may ask for LESS than a writer offers, a writer may offer MORE than a
429/// reader asks.
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431enum Role {
432    Subscription,
433    Publisher,
434}
435
436impl Role {
437    fn noun(self) -> &'static str {
438        match self {
439            Role::Subscription => "subscription",
440            Role::Publisher => "publisher",
441        }
442    }
443
444    /// Why a [`PolicyVerdict::Refuse`] refuses, in the log line's words.
445    fn refusal_reason(self) -> &'static str {
446        match self {
447            Role::Subscription => "widen what the code narrowed",
448            Role::Publisher => {
449                "weaken the offer below the call site's, and a reader asking for it stops matching"
450            }
451        }
452    }
453}
454
455/// What [`honour_role_in`] does with one POLICY column that disagrees.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457enum PolicyVerdict {
458    /// Take the declaration and report the divergence.
459    Take,
460    /// Take the declaration silently: the call site stated no opinion.
461    TakeSilently,
462    /// Refuse the registration.
463    Refuse,
464}
465
466/// THE rule for a policy disagreement, for both roles, in rank space (`None`
467/// is "no opinion" -- see [`reliability_rank`]).
468///
469/// * **Subscription** -- refuse when the code asked for LESS than was declared
470///   (taking would widen what the code narrowed); otherwise take, which lowers
471///   the request into what the build reserved and is the direction RxO
472///   tolerates for a reader.
473/// * **Publisher** -- refuse when the code asked for MORE than was declared.
474///   Taking would WEAKEN the offer below what the code asked, so a reader that
475///   requests what the code offered stops matching -- an incompatible-QoS pair
476///   never delivers -- while keeping the ask rides storage the build did not
477///   reserve: a `transient_local` writer is a zenoh queryable slot plus its
478///   retained samples, and a `reliable` one is XRCE's reliable stream history,
479///   both counted from the declaration. Otherwise take: RAISING a writer's
480///   offer keeps every reader that matched the lower one, and it is what the
481///   build reserved.
482///
483/// So the two roles share the asymmetry's REASON -- never ride storage the
484/// build did not reserve, never silently break a match -- and get opposite
485/// directions from it, because RxO is directional.
486fn policy_verdict(role: Role, asked: Option<u8>, declared: Option<u8>) -> PolicyVerdict {
487    let (Some(asked), Some(declared)) = (asked, declared) else {
488        return PolicyVerdict::TakeSilently;
489    };
490    let refuse = match role {
491        Role::Subscription => asked < declared,
492        Role::Publisher => asked > declared,
493    };
494    if refuse {
495        PolicyVerdict::Refuse
496    } else {
497        PolicyVerdict::Take
498    }
499}
500
501/// [`honour`] / [`honour_publisher`] for either [`Role`].
502fn honour_role_in(
503    rows: &[DeclaredEndpoint],
504    type_name: &str,
505    topic: &str,
506    qos: QoSProfile,
507    role: Role,
508) -> Result<QoSProfile, NodeError> {
509    let Some(row) = row_in(rows, type_name, topic) else {
510        return Ok(qos);
511    };
512    let mut granted = qos;
513    // The DEPTH rule is the same for both roles: a subscription's depth
514    // multiplies the executor arena, a publisher's is its history cache (the
515    // samples a transient_local writer retains for late joiners), and in both
516    // the build reserved the DECLARED number.
517    if let Some(declared) = row.depth
518        && declared != qos.depth
519    {
520        if declared > qos.depth {
521            nros_log::log_error!(
522                nros_log::get_logger("nros_node"),
523                "declared depth: {} `{}` registers KEEP_LAST({}) and the contract declares {} \
524                 -- DEEPER. Taking it would widen a queue the code narrowed; fix the contract \
525                 row or the call site (type `{}`).",
526                role.noun(),
527                topic,
528                qos.depth,
529                declared,
530                type_name
531            );
532            return Err(NodeError::DeclaredDepthMismatch);
533        }
534        granted.depth = declared;
535        nros_log::log_warn!(
536            nros_log::get_logger("nros_node"),
537            "declared depth: taking KEEP_LAST({}) on {} `{}`; the call site asked {} and the \
538             build reserved {}. Raise the contract row to keep more (type `{}`).",
539            declared,
540            role.noun(),
541            topic,
542            qos.depth,
543            declared,
544            type_name
545        );
546    }
547    if let Some(declared) = row.reliability
548        && declared != qos.reliability
549    {
550        match policy_verdict(
551            role,
552            reliability_rank(qos.reliability),
553            reliability_rank(declared),
554        ) {
555            PolicyVerdict::Refuse => {
556                nros_log::log_error!(
557                    nros_log::get_logger("nros_node"),
558                    "declared QoS: {} `{}` registers {} and the contract declares reliability \
559                     {}; taking it would {}. Fix the contract row or the call site.",
560                    role.noun(),
561                    topic,
562                    reliability_spelling(qos.reliability),
563                    reliability_spelling(declared),
564                    role.refusal_reason()
565                );
566                return Err(NodeError::DeclaredQosMismatch);
567            }
568            PolicyVerdict::Take => {
569                nros_log::log_warn!(
570                    nros_log::get_logger("nros_node"),
571                    "declared QoS: taking reliability {} on {} `{}`; the call site asked {}. \
572                     Change the contract row to keep the call site's.",
573                    reliability_spelling(declared),
574                    role.noun(),
575                    topic,
576                    reliability_spelling(qos.reliability)
577                );
578            }
579            // The call site stated no opinion: nothing diverged, so nothing is
580            // reported.
581            PolicyVerdict::TakeSilently => {}
582        }
583        granted.reliability = declared;
584    }
585    if let Some(declared) = row.durability
586        && declared != qos.durability
587    {
588        match policy_verdict(
589            role,
590            durability_rank(qos.durability),
591            durability_rank(declared),
592        ) {
593            PolicyVerdict::Refuse => {
594                nros_log::log_error!(
595                    nros_log::get_logger("nros_node"),
596                    "declared QoS: {} `{}` registers {} and the contract declares durability \
597                     {}; taking it would {}. Fix the contract row or the call site.",
598                    role.noun(),
599                    topic,
600                    durability_spelling(qos.durability),
601                    durability_spelling(declared),
602                    role.refusal_reason()
603                );
604                return Err(NodeError::DeclaredQosMismatch);
605            }
606            PolicyVerdict::Take => {
607                nros_log::log_warn!(
608                    nros_log::get_logger("nros_node"),
609                    "declared QoS: taking durability {} on {} `{}`; the call site asked {}. \
610                     Change the contract row to keep the call site's.",
611                    durability_spelling(declared),
612                    role.noun(),
613                    topic,
614                    durability_spelling(qos.durability)
615                );
616            }
617            PolicyVerdict::TakeSilently => {}
618        }
619        granted.durability = declared;
620    }
621    Ok(granted)
622}
623
624/// issue 1608 -- the PUBLISHER counterpart of [`honour`]: the QoS a publisher
625/// registers with once the contract has had its say.
626///
627/// The rule is `policy_verdict`'s, with the POLICY direction reversed from a
628/// subscription's because RxO is directional: a publisher that asked for less
629/// than the contract declares is RAISED to the declaration (the build reserved
630/// it, and every reader that matched the lower offer still matches), and one
631/// that asked for more is REFUSED (lowering it could break a match, keeping it
632/// rides storage nobody reserved). The depth rule is the subscription's.
633///
634/// Called at every publisher registration -- the Rust seams AND the C/C++ FFI
635/// seams. Unlike a subscription, a publisher has no call-site seam in C or C++
636/// that already took the declaration (`NROS_SUBSCRIBE`'s three-argument form
637/// has no publisher counterpart, and the compile-time table carries no
638/// publisher rows), so the FFI seams honour too rather than strict-checking: a
639/// strict check there would refuse every `create_publisher_in<M>(topic)` whose
640/// default profile the contract refines.
641///
642/// `#[inline]` for [`honour`]'s measured reason: a `None` table must fold the
643/// call to `Ok(qos)` so an image with no contract is byte-identical.
644#[inline]
645pub fn honour_publisher(
646    type_name: &str,
647    topic: &str,
648    qos: QoSProfile,
649) -> Result<QoSProfile, NodeError> {
650    let Some(rows) = DECLARED_PUBLISHER_QOS_ROWS else {
651        return Ok(qos);
652    };
653    honour_role_in(rows, type_name, topic, qos, Role::Publisher)
654}
655
656/// issue 1608 -- the declared row for a PUBLISHER on `(type_name, topic)`, or
657/// `None`. A table of its own because a publisher and a subscription on one
658/// `(type, topic)` are two endpoints with two declarations.
659pub fn declared_publisher(type_name: &str, topic: &str) -> Option<DeclaredEndpoint> {
660    row_in(DECLARED_PUBLISHER_QOS_ROWS?, type_name, topic).copied()
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    /// A row with only a depth stated -- the shape every road has carried
668    /// since phase-454 W10.
669    const fn depth_row(type_name: &'static str, topic: &'static str, d: u32) -> DeclaredEndpoint {
670        DeclaredEndpoint {
671            type_name,
672            topic,
673            depth: Some(d),
674            reliability: None,
675            durability: None,
676        }
677    }
678
679    /// The shape `build.rs` writes: the ROS spelling and the DDS-mangled one,
680    /// for each declared endpoint.
681    const ROWS: &[DeclaredEndpoint] = &[
682        depth_row("std_msgs/msg/Int32", "/chatter", 1),
683        depth_row("std_msgs::msg::dds_::Int32_", "/chatter", 1),
684    ];
685
686    /// issue 1256 -- a subscription that declares BOTH policies and no depth,
687    /// which is a row the depth-only table could not carry at all.
688    const POLICY_ROWS: &[DeclaredEndpoint] = &[DeclaredEndpoint {
689        type_name: "std_msgs/msg/Int32",
690        topic: "/sensor",
691        depth: None,
692        reliability: Some(QoSReliabilityPolicy::BestEffort),
693        durability: Some(QoSDurabilityPolicy::Volatile),
694    }];
695
696    /// ...and one that declares the STRONG value of each.
697    const STRONG_ROWS: &[DeclaredEndpoint] = &[DeclaredEndpoint {
698        type_name: "std_msgs/msg/Int32",
699        topic: "/latched",
700        depth: None,
701        reliability: Some(QoSReliabilityPolicy::Reliable),
702        durability: Some(QoSDurabilityPolicy::TransientLocal),
703    }];
704
705    #[test]
706    fn either_type_spelling_finds_the_row() {
707        assert_eq!(depth_in(ROWS, "std_msgs/msg/Int32", "/chatter"), Some(1));
708        assert_eq!(
709            depth_in(ROWS, "std_msgs::msg::dds_::Int32_", "/chatter"),
710            Some(1),
711            "a generated Rust message class reports the DDS-mangled TYPE_NAME; a table that \
712             carried only the ROS spelling would match nothing and check nothing"
713        );
714    }
715
716    #[test]
717    fn a_relative_topic_matches_the_declared_absolute_one() {
718        assert_eq!(depth_in(ROWS, "std_msgs/msg/Int32", "chatter"), Some(1));
719    }
720
721    #[test]
722    fn absence_is_not_a_depth() {
723        assert_eq!(
724            depth_in(ROWS, "std_msgs/msg/Int32", "/undeclared"),
725            None,
726            "an endpoint nobody declared has no depth -- not 0, and not the ROS default of 10"
727        );
728        assert_eq!(
729            depth_in(ROWS, "std_msgs/msg/Bool", "/chatter"),
730            None,
731            "the table is keyed on the PAIR: a declared topic under another type is not a hit, \
732             or one declaration would size every type carried on that topic"
733        );
734        assert_eq!(depth_in(&[], "std_msgs/msg/Int32", "/chatter"), None);
735        assert_eq!(
736            depth_in(POLICY_ROWS, "std_msgs/msg/Int32", "/sensor"),
737            None,
738            "a row that declares only policies has declared no depth"
739        );
740    }
741
742    #[test]
743    fn a_namespace_is_a_different_endpoint() {
744        assert_eq!(
745            depth_in(ROWS, "std_msgs/msg/Int32", "/ns/chatter"),
746            None,
747            "only ONE leading slash is ignored; matching a namespaced topic against a bare \
748             declaration would check a row nobody meant"
749        );
750    }
751
752    /// phase-454 W13 — the TAKE. An unstated call site asks the ROS default 10; a
753    /// contract that declares 1 is the size the build reserved, so the
754    /// registration comes back at 1 and every other policy is untouched.
755    ///
756    /// Untouched matters as much as the depth: `honour` rebuilds the profile a
757    /// backend is handed, so a reliability or durability quietly reset here
758    /// would change the wire behaviour of every declared endpoint.
759    #[test]
760    fn an_endpoint_asking_deeper_than_its_declaration_registers_at_the_declared_depth() {
761        let asked = QoSProfile::QOS_PROFILE_DEFAULT;
762        assert_eq!(
763            asked.depth, 10,
764            "the ROS default an unstated call site gets"
765        );
766        let granted = honour_in(ROWS, "std_msgs/msg/Int32", "/chatter", asked)
767            .expect("taking the declared depth is not an error");
768        assert_eq!(
769            granted.depth, 1,
770            "the contract declares KEEP_LAST(1) and the build reserved for it; a registration \
771             that kept its 10 would claim a receive region the arena never bought"
772        );
773        assert_eq!(granted.reliability, asked.reliability);
774        assert_eq!(granted.durability, asked.durability);
775        assert_eq!(granted.history, asked.history);
776        assert_eq!(granted.deadline_ms, asked.deadline_ms);
777        assert_eq!(granted.lifespan_ms, asked.lifespan_ms);
778    }
779
780    /// The DDS-mangled spelling is the one a generated Rust message class
781    /// carries, so it is the one a typed call site actually hands `honour` —
782    /// a take that only matched the ROS spelling would fire for nothing.
783    #[test]
784    fn the_take_matches_the_spelling_a_typed_call_site_carries() {
785        let granted = honour_in(
786            ROWS,
787            "std_msgs::msg::dds_::Int32_",
788            "/chatter",
789            QoSProfile::QOS_PROFILE_DEFAULT,
790        )
791        .expect("admissible");
792        assert_eq!(granted.depth, 1);
793    }
794
795    /// phase-454 W13 — the REFUSAL, and the reason the rule is asymmetric.
796    ///
797    /// A registration asking for LESS than the declaration fits inside what the
798    /// build reserved, so nothing overruns — but taking would WIDEN a queue the
799    /// code narrowed on purpose (`create_subscription_viewable` requires
800    /// KEEP_LAST(1)), and the image has paid for slots nothing wants. Two
801    /// statements about one fact disagreeing, with no default that explains
802    /// either: RFC-0100 D8.
803    #[test]
804    fn a_declaration_deeper_than_the_registration_is_refused_not_taken() {
805        const DEEP: &[DeclaredEndpoint] = &[depth_row("std_msgs/msg/Int32", "/chatter", 20)];
806        let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
807        asked.depth = 10;
808        assert_eq!(
809            honour_in(DEEP, "std_msgs/msg/Int32", "/chatter", asked),
810            Err(NodeError::DeclaredDepthMismatch),
811            "THE control for the other direction: declared 20, registered 10. If this returns \
812             Ok the rule has collapsed into an unconditional take, and a contract can silently \
813             deepen any queue in the image."
814        );
815    }
816
817    /// Equal is not a divergence, and an undeclared endpoint is not one either
818    /// — the two arms that must stay silent, or every image in the tree gains
819    /// a report it cannot act on.
820    #[test]
821    fn agreement_and_absence_both_pass_the_profile_through_unchanged() {
822        let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
823        asked.depth = 1;
824        assert_eq!(
825            honour_in(ROWS, "std_msgs/msg/Int32", "/chatter", asked),
826            Ok(asked)
827        );
828        let deep = QoSProfile::QOS_PROFILE_DEFAULT;
829        assert_eq!(
830            honour_in(ROWS, "std_msgs/msg/Int32", "/undeclared", deep),
831            Ok(deep),
832            "absence is not zero and it is not ten either -- an endpoint nobody declared keeps \
833             exactly the profile the call site built"
834        );
835        assert_eq!(
836            honour_in(&[], "std_msgs/msg/Int32", "/chatter", deep),
837            Ok(deep)
838        );
839    }
840
841    /// issue 1256 — the policy TAKE. The default profile asks RELIABLE and
842    /// VOLATILE; a contract declaring `best_effort` is what the build reserved
843    /// (no reliable stream history on XRCE), so an unstated call site comes back
844    /// best-effort. Durability agrees, so it is untouched, and so is the depth
845    /// the row does not state.
846    #[test]
847    fn a_declared_best_effort_is_taken_by_a_call_site_that_asked_reliable() {
848        let asked = QoSProfile::QOS_PROFILE_DEFAULT;
849        assert_eq!(asked.reliability, QoSReliabilityPolicy::Reliable);
850        let granted = honour_in(POLICY_ROWS, "std_msgs/msg/Int32", "/sensor", asked)
851            .expect("a weaker declared reliability is taken, not refused");
852        assert_eq!(granted.reliability, QoSReliabilityPolicy::BestEffort);
853        assert_eq!(granted.durability, QoSDurabilityPolicy::Volatile);
854        assert_eq!(
855            granted.depth, asked.depth,
856            "the row states no depth, so the depth the call site asked stands"
857        );
858    }
859
860    /// issue 1256 — the policy REFUSAL, the control for the take above. A call
861    /// site that asked `best_effort` against a contract that declares
862    /// `reliable` narrowed on purpose; taking would widen it.
863    #[test]
864    fn a_declared_reliable_is_refused_to_a_call_site_that_asked_best_effort() {
865        let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
866        asked.reliability = QoSReliabilityPolicy::BestEffort;
867        assert_eq!(
868            honour_in(STRONG_ROWS, "std_msgs/msg/Int32", "/latched", asked),
869            Err(NodeError::DeclaredQosMismatch),
870            "declared reliable, asked best_effort. If this returns Ok the policy rule has \
871             collapsed into an unconditional take."
872        );
873        // And durability on its own: reliability agrees, durability is weaker.
874        let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
875        asked.durability = QoSDurabilityPolicy::Volatile;
876        assert_eq!(
877            honour_in(STRONG_ROWS, "std_msgs/msg/Int32", "/latched", asked),
878            Err(NodeError::DeclaredQosMismatch),
879            "declared transient_local, asked volatile"
880        );
881    }
882
883    /// issue 1256 — a call site that stated NO opinion takes the declaration.
884    #[test]
885    fn a_system_default_policy_takes_the_declaration() {
886        let mut asked = QoSProfile::QOS_PROFILE_DEFAULT;
887        asked.reliability = QoSReliabilityPolicy::SystemDefault;
888        asked.durability = QoSDurabilityPolicy::SystemDefault;
889        let granted = honour_in(STRONG_ROWS, "std_msgs/msg/Int32", "/latched", asked)
890            .expect("no opinion is not a disagreement");
891        assert_eq!(granted.reliability, QoSReliabilityPolicy::Reliable);
892        assert_eq!(granted.durability, QoSDurabilityPolicy::TransientLocal);
893    }
894
895    /// issue 1256 — the FFI seams' strict check covers the policies too. C and
896    /// C++ already took the declaration at their call site, so ANY disagreement
897    /// reaching here is real, in either direction.
898    #[test]
899    fn the_strict_check_refuses_a_policy_disagreement_in_either_direction() {
900        let agrees = {
901            let mut q = QoSProfile::QOS_PROFILE_DEFAULT;
902            q.reliability = QoSReliabilityPolicy::BestEffort;
903            q
904        };
905        assert_eq!(
906            check_in(POLICY_ROWS, "std_msgs/msg/Int32", "/sensor", &agrees),
907            Ok(())
908        );
909        assert_eq!(
910            check_in(
911                POLICY_ROWS,
912                "std_msgs/msg/Int32",
913                "/sensor",
914                &QoSProfile::QOS_PROFILE_DEFAULT
915            ),
916            Err(NodeError::DeclaredQosMismatch),
917            "declared best_effort, registered reliable"
918        );
919        let mut tl = agrees;
920        tl.durability = QoSDurabilityPolicy::TransientLocal;
921        assert_eq!(
922            check_in(POLICY_ROWS, "std_msgs/msg/Int32", "/sensor", &tl),
923            Err(NodeError::DeclaredQosMismatch),
924            "declared volatile, registered transient_local"
925        );
926        assert_eq!(
927            check_in(
928                ROWS,
929                "std_msgs/msg/Int32",
930                "/chatter",
931                &QoSProfile::QOS_PROFILE_DEFAULT
932            ),
933            Err(NodeError::DeclaredDepthMismatch),
934            "and the depth is still refused as a DEPTH"
935        );
936    }
937
938    /// issue 1608 -- a PUBLISHER row declaring the STRONG value of each
939    /// policy: a latched topic, which is what a zenoh image spends a queryable
940    /// slot on.
941    const PUB_LATCHED: &[DeclaredEndpoint] = &[DeclaredEndpoint {
942        type_name: "std_msgs/msg/Int32",
943        topic: "/latched",
944        depth: Some(1),
945        reliability: Some(QoSReliabilityPolicy::Reliable),
946        durability: Some(QoSDurabilityPolicy::TransientLocal),
947    }];
948
949    /// ...and one declaring the WEAK value of each: a sensor stream.
950    const PUB_SENSOR: &[DeclaredEndpoint] = &[DeclaredEndpoint {
951        type_name: "std_msgs/msg/Int32",
952        topic: "/sensor",
953        depth: None,
954        reliability: Some(QoSReliabilityPolicy::BestEffort),
955        durability: Some(QoSDurabilityPolicy::Volatile),
956    }];
957
958    fn honour_pub(
959        rows: &[DeclaredEndpoint],
960        topic: &str,
961        qos: QoSProfile,
962    ) -> Result<QoSProfile, NodeError> {
963        honour_role_in(rows, "std_msgs/msg/Int32", topic, qos, Role::Publisher)
964    }
965
966    /// issue 1608 -- the publisher TAKE. A default-profile publisher (VOLATILE,
967    /// KEEP_LAST(10)) on a topic the contract declares `transient_local` with
968    /// depth 1 registers as transient_local, depth 1: the offer is RAISED to
969    /// what the build reserved (the queryable slot and one retained sample),
970    /// and every reader that matched a volatile writer still matches.
971    #[test]
972    fn a_publisher_is_raised_to_a_stronger_declared_policy() {
973        let asked = QoSProfile::QOS_PROFILE_DEFAULT;
974        assert_eq!(asked.durability, QoSDurabilityPolicy::Volatile);
975        let granted = honour_pub(PUB_LATCHED, "/latched", asked)
976            .expect("a publisher asking LESS than its declaration is raised, not refused");
977        assert_eq!(granted.durability, QoSDurabilityPolicy::TransientLocal);
978        assert_eq!(granted.reliability, QoSReliabilityPolicy::Reliable);
979        assert_eq!(granted.depth, 1, "the depth rule is the subscription's");
980        let mut be = asked;
981        be.reliability = QoSReliabilityPolicy::BestEffort;
982        assert_eq!(
983            honour_pub(PUB_LATCHED, "/latched", be).map(|q| q.reliability),
984            Ok(QoSReliabilityPolicy::Reliable),
985            "a best_effort writer is raised to the declared reliable"
986        );
987    }
988
989    /// issue 1608 -- the publisher REFUSAL, and THE control for the direction.
990    ///
991    /// A publisher asking for MORE than the contract declares is refused: taking
992    /// the declaration would WEAKEN its offer (a reader requesting what the code
993    /// offered stops matching) and keeping the ask rides storage the build did
994    /// not reserve. This is the OPPOSITE direction from a subscription -- the
995    /// last assertion runs the same row and profile through the subscription
996    /// rule and gets a take, so a rule copied across without reversing fails
997    /// here rather than in an image.
998    #[test]
999    fn a_publisher_asking_more_than_its_declaration_is_refused() {
1000        let asked = QoSProfile::QOS_PROFILE_DEFAULT;
1001        assert_eq!(asked.reliability, QoSReliabilityPolicy::Reliable);
1002        assert_eq!(
1003            honour_pub(PUB_SENSOR, "/sensor", asked),
1004            Err(NodeError::DeclaredQosMismatch),
1005            "declared best_effort, asked reliable: lowering the offer could break a match"
1006        );
1007        let mut tl = asked;
1008        tl.reliability = QoSReliabilityPolicy::BestEffort;
1009        tl.durability = QoSDurabilityPolicy::TransientLocal;
1010        assert_eq!(
1011            honour_pub(PUB_SENSOR, "/sensor", tl),
1012            Err(NodeError::DeclaredQosMismatch),
1013            "declared volatile, asked transient_local: the build counted no queryable slot"
1014        );
1015        let granted = honour_in(PUB_SENSOR, "std_msgs/msg/Int32", "/sensor", asked)
1016            .expect("the SUBSCRIPTION rule takes the same disagreement");
1017        assert_eq!(granted.reliability, QoSReliabilityPolicy::BestEffort);
1018    }
1019
1020    /// issue 1608 -- agreement, no opinion, and absence stay silent and
1021    /// unchanged for a publisher too.
1022    #[test]
1023    fn a_publisher_that_agrees_or_has_no_opinion_or_no_row_passes() {
1024        let mut agrees = QoSProfile::QOS_PROFILE_DEFAULT;
1025        agrees.reliability = QoSReliabilityPolicy::BestEffort;
1026        assert_eq!(honour_pub(PUB_SENSOR, "/sensor", agrees), Ok(agrees));
1027        let mut none = QoSProfile::QOS_PROFILE_DEFAULT;
1028        none.reliability = QoSReliabilityPolicy::SystemDefault;
1029        none.durability = QoSDurabilityPolicy::SystemDefault;
1030        let granted = honour_pub(PUB_LATCHED, "/latched", none).expect("no opinion");
1031        assert_eq!(granted.durability, QoSDurabilityPolicy::TransientLocal);
1032        let deep = QoSProfile::QOS_PROFILE_DEFAULT;
1033        assert_eq!(honour_pub(PUB_LATCHED, "/undeclared", deep), Ok(deep));
1034        assert_eq!(
1035            honour_pub(PUB_LATCHED, "/latched", {
1036                let mut q = deep;
1037                q.durability = QoSDurabilityPolicy::TransientLocal;
1038                q.depth = 0;
1039                q
1040            }),
1041            Err(NodeError::DeclaredDepthMismatch),
1042            "a declaration DEEPER than the ask is refused for a publisher as for a subscription"
1043        );
1044    }
1045
1046    /// The public entry point over THIS build's table. In-tree that table is
1047    /// `None` (no contract reaches a unit-test build), so this asserts the
1048    /// no-declaration behaviour rather than a lookup -- and says so, because a
1049    /// reader would otherwise take it for coverage of the lookup above.
1050    #[cfg(not(nros_declared_qos_table))]
1051    #[test]
1052    fn an_image_that_declares_nothing_checks_nothing() {
1053        assert!(
1054            DECLARED_QOS_ROWS.is_none(),
1055            "`nros_declared_qos_table` is unset, so the build wrote no table. If this fires, \
1056             the cfg and the const have come apart and the declared-image test below is \
1057             being skipped on a build that HAS a table."
1058        );
1059        assert_eq!(declared_depth("std_msgs/msg/Int32", "/chatter"), None);
1060        assert_eq!(
1061            check(
1062                "std_msgs/msg/Int32",
1063                "/chatter",
1064                &QoSProfile::QOS_PROFILE_DEFAULT
1065            ),
1066            Ok(())
1067        );
1068        // phase-454 W13 — and the take is inert too, which is acceptance 4: an
1069        // image with no contract registers exactly the profile it built.
1070        let asked = QoSProfile::QOS_PROFILE_DEFAULT;
1071        assert_eq!(honour("std_msgs/msg/Int32", "/chatter", asked), Ok(asked));
1072        // issue 1608 -- and so is the publisher take.
1073        assert!(DECLARED_PUBLISHER_QOS_ROWS.is_none());
1074        assert_eq!(
1075            honour_publisher("std_msgs/msg/Int32", "/chatter", asked),
1076            Ok(asked)
1077        );
1078    }
1079
1080    /// THE NEGATIVE CONTROL, over the build's OWN table.
1081    ///
1082    /// Everything above tests `depth_in` against a table this module wrote,
1083    /// which proves the search and nothing about DELIVERY: a carrier cmake
1084    /// never forwards, a `build.rs` parse that drops every row, and a table
1085    /// keyed on a spelling no call site writes all look exactly like "no
1086    /// mismatch here". Six mechanisms in this campaign have been correct and
1087    /// unreachable for precisely that reason.
1088    ///
1089    /// So this module is compiled only where `build.rs` wrote rows, and
1090    /// `just check declared-qos-registration` builds this crate with
1091    /// `NROS_ENTITY_DECLARED_DEPTHS` set to make that happen. It also asserts
1092    /// that the test does NOT exist without the variable -- otherwise a cfg
1093    /// that was always on would make the lane pass while proving nothing.
1094    #[cfg(nros_declared_qos_table)]
1095    mod declared_image {
1096        use super::*;
1097
1098        #[test]
1099        fn refuses_a_disagreeing_depth_and_accepts_an_agreeing_one() {
1100            let rows = DECLARED_QOS_ROWS.expect("the cfg says this build wrote a table");
1101            assert!(
1102                !rows.is_empty(),
1103                "an empty table with the cfg set is a parse that dropped every row -- which \
1104                 would disable the check while reading as if it were on"
1105            );
1106            assert_eq!(
1107                declared_depth("std_msgs/msg/Int32", "/chatter"),
1108                Some(1),
1109                "the lane forwards `std_msgs/msg/Int32|/chatter=1`; if this is None the \
1110                 carrier, the parse or the key spelling is wrong, and every registration in \
1111                 a real image would go unchecked"
1112            );
1113            assert_eq!(
1114                declared_depth("std_msgs::msg::dds_::Int32_", "/chatter"),
1115                Some(1),
1116                "build.rs must emit the DDS-mangled spelling too -- it is what a generated \
1117                 Rust message class reports as TYPE_NAME, and the only spelling a typed call \
1118                 site ever hands `check`"
1119            );
1120            let mut one = QoSProfile::QOS_PROFILE_DEFAULT;
1121            one.depth = 1;
1122            assert_eq!(check("std_msgs/msg/Int32", "/chatter", &one), Ok(()));
1123            assert_eq!(
1124                check(
1125                    "std_msgs::msg::dds_::Int32_",
1126                    "/chatter",
1127                    &QoSProfile::QOS_PROFILE_DEFAULT
1128                ),
1129                Err(NodeError::DeclaredDepthMismatch),
1130                "THE control: declared 1, registered 10. If this passes, the registration \
1131                 check does nothing on a real image and every lane above it stays green."
1132            );
1133            assert_eq!(
1134                check(
1135                    "std_msgs/msg/Int32",
1136                    "/undeclared",
1137                    &QoSProfile::QOS_PROFILE_DEFAULT
1138                ),
1139                Ok(()),
1140                "and an endpoint nobody declared is still not an error"
1141            );
1142        }
1143
1144        /// phase-454 W13 — the TAKE, over the build's own table.
1145        ///
1146        /// The sibling above proves the ROWS arrived. This proves the thing a
1147        /// Rust image actually depends on: that an unstated call site's
1148        /// `QoSProfile::default()` comes back at the DECLARED depth. A table
1149        /// that is delivered and never consulted by the registration would pass
1150        /// every assertion above it, which is the shape this campaign keeps
1151        /// finding.
1152        ///
1153        /// `just check declared-qos-registration` runs this against BOTH
1154        /// carriers — the cmake road's `NROS_ENTITY_DECLARED_DEPTHS` and the
1155        /// cargo leaf road's sizing descriptor — because a carrier that reaches
1156        /// one road and not the other is exactly how W10 shipped with its
1157        /// registration check unable to compare anything on the road W12 taught
1158        /// to read a contract.
1159        #[test]
1160        fn an_unstated_call_site_registers_at_the_declared_depth() {
1161            let asked = QoSProfile::QOS_PROFILE_DEFAULT;
1162            assert_eq!(
1163                asked.depth, 10,
1164                "the profile `Node::create_subscription` hands a registration when the call \
1165                 site states none"
1166            );
1167            let granted = honour("std_msgs::msg::dds_::Int32_", "/chatter", asked)
1168                .expect("the declared depth is shallower than the ask, so it is TAKEN");
1169            assert_eq!(
1170                granted.depth, 1,
1171                "this build's table declares KEEP_LAST(1) for `/chatter`; if this is 10 the \
1172                 registration ignored the declaration and the image runs a depth the build did \
1173                 not reserve for"
1174            );
1175        }
1176
1177        /// issue 1256 — the two POLICIES, over the build's own table.
1178        ///
1179        /// Compiled only where the build's table states a policy, which is the
1180        /// DESCRIPTOR carrier (`tests/fixtures/declared-qos-descriptor.toml`
1181        /// declares `/status` best-effort and volatile): the env carrier
1182        /// `NROS_ENTITY_DECLARED_DEPTHS` is depth-only by design, and no knob is
1183        /// added for the policies. `just check declared-qos-registration`
1184        /// therefore expects THREE tests on the descriptor step and two on the
1185        /// env step -- so a descriptor whose policies stopped reaching the table
1186        /// fails that lane instead of passing two of three.
1187        #[cfg(nros_declared_qos_policy_table)]
1188        #[test]
1189        fn a_declared_policy_reaches_the_registration_from_the_descriptor() {
1190            assert_eq!(
1191                declared_reliability("std_msgs::msg::dds_::Int32_", "/status"),
1192                Some(QoSReliabilityPolicy::BestEffort),
1193                "the descriptor states `reliability = \"best_effort\"` for `/status`; if this \
1194                 is None the policies are not reaching DECLARED_QOS_ROWS and no Rust image \
1195                 compares them with its code"
1196            );
1197            assert_eq!(
1198                declared_durability("std_msgs/msg/Int32", "/status"),
1199                Some(QoSDurabilityPolicy::Volatile)
1200            );
1201            let granted = honour(
1202                "std_msgs::msg::dds_::Int32_",
1203                "/status",
1204                QoSProfile::QOS_PROFILE_DEFAULT,
1205            )
1206            .expect("a declared best_effort is TAKEN by a call site that asked reliable");
1207            assert_eq!(granted.reliability, QoSReliabilityPolicy::BestEffort);
1208            assert_eq!(
1209                check(
1210                    "std_msgs/msg/Int32",
1211                    "/status",
1212                    &QoSProfile::QOS_PROFILE_DEFAULT
1213                ),
1214                Err(NodeError::DeclaredQosMismatch),
1215                "and the FFI seams' strict check refuses the same disagreement"
1216            );
1217        }
1218
1219        /// issue 1608 -- a declared PUBLISHER, over the build's own table.
1220        ///
1221        /// Compiled only where the descriptor delivered a publisher row
1222        /// (`tests/fixtures/declared-qos-descriptor.toml` declares `/latched`
1223        /// transient_local, depth 1). `just check declared-qos-registration`
1224        /// expects FOUR tests on the descriptor step -- so a descriptor whose
1225        /// publisher rows stopped reaching `DECLARED_PUBLISHER_QOS_ROWS` fails
1226        /// the lane instead of passing three of four.
1227        #[cfg(nros_declared_publisher_qos_table)]
1228        #[test]
1229        fn a_declared_publisher_reaches_the_registration_from_the_descriptor() {
1230            let row = declared_publisher("std_msgs::msg::dds_::Int32_", "/latched").expect(
1231                "the descriptor declares a publisher on `/latched`; if this is None its \
1232                 publisher rows are not reaching DECLARED_PUBLISHER_QOS_ROWS",
1233            );
1234            assert_eq!(row.durability, Some(QoSDurabilityPolicy::TransientLocal));
1235            assert!(
1236                declared_publisher("std_msgs/msg/Int32", "/chatter").is_none(),
1237                "`/chatter` is declared for a SUBSCRIPTION only; a publisher table that \
1238                 carried it would hold every publisher to a reader's declaration"
1239            );
1240            let granted = honour_publisher(
1241                "std_msgs::msg::dds_::Int32_",
1242                "/latched",
1243                QoSProfile::QOS_PROFILE_DEFAULT,
1244            )
1245            .expect("a default publisher is raised to the declared transient_local");
1246            assert_eq!(granted.durability, QoSDurabilityPolicy::TransientLocal);
1247            assert_eq!(granted.depth, 1);
1248            let mut be = QoSProfile::QOS_PROFILE_DEFAULT;
1249            be.durability = QoSDurabilityPolicy::TransientLocal;
1250            assert_eq!(
1251                honour_publisher("std_msgs/msg/Int32", "/plain", be),
1252                Err(NodeError::DeclaredQosMismatch),
1253                "the descriptor declares `/plain` volatile; a transient_local publisher there \
1254                 asks for a queryable slot the build never counted"
1255            );
1256        }
1257    }
1258}