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