Skip to main content

zenkey_fleet/bus/
write.rs

1//! The write facade (issue #36): the only two ways an explorer writes to the
2//! bus — a declared publication, and a disciplined RPC call.
3//!
4//! Reading stayed the engine's whole job until now; both frontends need the
5//! same two write paths (`zenctl pub` / `service call`, the zengui
6//! publish/call pane), and the discipline they must share is exactly the kind
7//! that fails silently when duplicated:
8//!
9//! - **P7**: telemetry/state publishers are *declared*, never one-shot ad-hoc
10//!   puts — so [`Publication`] wraps a declared publisher, and there is no
11//!   bare-put helper here at all;
12//! - **QoS is the closed enum** (RFC 04 §3), mapped to the wire in one place,
13//!   including the v1.5 `express` axis (alert/frame) that nothing set before;
14//! - **fan-out refusal is layered** (RFC 05 §2.1): generated builders make a
15//!   forbidden-fanout write unspellable; this facade adds the *registry*
16//!   layer for dynamic callers — a `*`-origin call to a procedure whose slice
17//!   declares `fanout = "forbidden"` is refused before any GET leaves.
18
19use std::time::Duration;
20
21use crate::{Error, Result};
22use zenkey::origin::{HostId, ServiceOrigin};
23use zenkey::qos::QosProfile;
24use zenkey::{Declared, Fanout, ProcedureKind};
25use zenoh::Session;
26
27use crate::model::registry::SliceSet;
28use crate::report::{CallAnswer, CallError, CallOutcome, CallReport};
29
30/// A declared publisher with its QoS profile applied — the only publish path.
31pub struct Publication {
32    publisher: zenoh::pubsub::Publisher<'static>,
33    encoding: Option<String>,
34}
35
36impl std::fmt::Debug for Publication {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("Publication")
39            .field("key", &self.publisher.key_expr().as_str())
40            .finish_non_exhaustive()
41    }
42}
43
44/// Declare a publication on a **full wire key** (explorers are un-namespaced;
45/// compose with `with_base` first).
46///
47/// The profile maps to the wire in one place: reliability, congestion
48/// control, priority, and the express bit (RFC 04 §3 — `alert` and `frame`
49/// are the express profiles; nothing in the workspace ever set it before).
50pub async fn declare_publication(
51    session: &Session,
52    key: &str,
53    qos: QosProfile,
54    encoding: Option<&str>,
55) -> Result<Publication> {
56    let publisher = session
57        .declare_publisher(key.to_string())
58        .reliability(qos.reliability())
59        .congestion_control(qos.congestion_control())
60        .priority(qos.priority())
61        .express(qos.express())
62        .await
63        .map_err(|e| Error::bus("declare publisher", key, e))?;
64    Ok(Publication {
65        publisher,
66        encoding: encoding.map(str::to_string),
67    })
68}
69
70impl Publication {
71    /// Publish one payload, with an optional attachment riding beside it
72    /// (#117 — attachments are outside the registry's vocabulary and are
73    /// never schema-encoded). Sets the wire `Encoding` when one was declared
74    /// (RFC 04 v1.5's recommendation: publishers say what they carry).
75    pub async fn send(&self, payload: Vec<u8>, attachment: Option<Vec<u8>>) -> Result<()> {
76        self.send_stamped(payload, attachment, None).await
77    }
78
79    /// [`send`](Self::send), with an explicit HLC timestamp when the caller
80    /// mints one (`session.new_timestamp()`). `None` leaves stamping to the
81    /// deployment's config — the default put behaviour. The generator uses
82    /// this to stamp state samples for LWW (RFC 04 §4) and, for its
83    /// `unstamped` fault (#163), to deliberately omit the stamp.
84    pub async fn send_stamped(
85        &self,
86        payload: Vec<u8>,
87        attachment: Option<Vec<u8>>,
88        timestamp: Option<zenoh::time::Timestamp>,
89    ) -> Result<()> {
90        let put = self.publisher.put(payload);
91        let put = match &self.encoding {
92            Some(e) => put.encoding(e.as_str()),
93            None => put,
94        };
95        let put = match attachment {
96            Some(a) => put.attachment(a),
97            None => put,
98        };
99        let put = match timestamp {
100            Some(ts) => put.timestamp(ts),
101            None => put,
102        };
103        put.await
104            .map_err(|e| Error::bus("put", self.publisher.key_expr().as_str(), e))
105    }
106
107    /// Publish a tombstone — an authoritative retirement (RFC 04 §1.2),
108    /// never a payload marker. The only delete path: it rides the declared
109    /// publisher, and `Session::delete` stays unexposed for the same reason
110    /// there is no bare-put helper. Gate dynamic keys through
111    /// [`check_retire`] first — the class semantics live there.
112    pub async fn retire(&self) -> Result<()> {
113        self.publisher
114            .delete()
115            .await
116            .map_err(|e| Error::bus("delete", self.publisher.key_expr().as_str(), e))
117    }
118
119    /// Undeclare, acknowledged.
120    pub async fn undeclare(self) -> Result<()> {
121        self.publisher
122            .undeclare()
123            .await
124            .map_err(|e| Error::bus("undeclare publisher", "", e))
125    }
126
127    /// Whether any subscriber currently matches **this publication** — a
128    /// routing fact about the publisher *this process declared* (RFC 12 §9's
129    /// allowed half). It says nothing about other publishers on the key, and
130    /// `false` is not a fleet verdict ("no subscriber matched *our*
131    /// publication", never "nobody listens here" — RFC 05 §3.1 applied to a
132    /// badge).
133    pub async fn matching_status(&self) -> Result<bool> {
134        self.publisher
135            .matching_status()
136            .await
137            .map(|s| s.matching())
138            .map_err(|e| Error::bus("matching status", "", e))
139    }
140
141    /// Event-driven matching changes for this publication — the badge feed.
142    /// Same honesty bounds as [`matching_status`](Self::matching_status).
143    pub async fn matching_events(&self) -> Result<MatchingEvents> {
144        let listener = self
145            .publisher
146            .matching_listener()
147            .await
148            .map_err(|e| Error::bus("matching listener", "", e))?;
149        Ok(MatchingEvents { listener })
150    }
151}
152
153/// What a key is, for the purpose of retiring it — the guard's positive
154/// verdict, so callers print facts instead of re-deriving them.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum RetireClass {
157    /// State-shaped: retirement is the class's own semantics (RFC 04 §1.2).
158    State {
159        /// Whether a loaded registry recognises the subject. An unregistered
160        /// state key still tombstones authoritatively — but no `ttl_s`
161        /// bounds how long the tombstone stays observable.
162        registered: bool,
163        /// The registry's `ttl_s`, when declared: storages keep the
164        /// tombstone observable at least this long (RFC 04 §1.2).
165        ttl_s: Option<i64>,
166    },
167    /// A v1 key off the state class (telemetry/events, or a verbatim
168    /// plane) — retired anyway, as a forced operator cleanup (v1.12).
169    NonState { class: String },
170    /// The grammar could not say what the key is — retired blind, forced.
171    Unclassified { reason: String },
172}
173
174/// Refuse a tombstone the class semantics do not license, unless forced.
175///
176/// The judgment mirrors `bench`'s idempotence guard: the refusal is
177/// grammar- and registry-driven, and the messages cite what they know.
178/// Unlike `bench`, a missing registry does not blind us on the happy path —
179/// the class is written in the key itself, so a state key passes with no
180/// slices loaded. The one unconditional refusal is a wildcard: a tombstone
181/// is addressed to one concrete key (RFC 04 §1.2, v1.12), and no `force`
182/// overrides a blast radius.
183pub fn check_retire(
184    base: &str,
185    key: &str,
186    slices: Option<&SliceSet>,
187    force: bool,
188) -> Result<RetireClass> {
189    if key.contains('*') || key.contains('$') {
190        return Err(Error::unaskable(
191            key,
192            "is a wildcard — a tombstone is addressed to one concrete key; a \
193             wildcard delete is not an operator act, it is a blast radius \
194             (RFC 04 §1.2, v1.12). Not overridable.",
195        ));
196    }
197    let facts = crate::model::facts::describe_key(base, key, slices).facts;
198    use crate::model::facts::{ClassKind, KeyShape, Registration};
199    match &facts.shape {
200        KeyShape::V1(v) if v.class_kind == ClassKind::State => {
201            let (registered, ttl_s) = match &facts.registration {
202                Registration::Registered(s) => (true, s.ttl_s),
203                _ => (false, None),
204            };
205            Ok(RetireClass::State { registered, ttl_s })
206        }
207        KeyShape::V1(v) if matches!(v.class_kind, ClassKind::Telemetry | ClassKind::Events) => {
208            if force {
209                return Ok(RetireClass::NonState {
210                    class: v.class.clone(),
211                });
212            }
213            Err(Error::unaskable(
214                key,
215                format!(
216                    "is {}-shaped — RFC 04 §1: a delete there is meaningless and \
217                     MUST NOT be sent by the class's publisher. Retiring it anyway \
218                     is an operator cleanup (RFC 04 §1.2, v1.12) — pass --i-know \
219                     to mean it.",
220                    v.class
221                ),
222            ))
223        }
224        KeyShape::V1(v) => {
225            if force {
226                return Ok(RetireClass::NonState {
227                    class: v.class.clone(),
228                });
229            }
230            Err(Error::unaskable(
231                key,
232                format!(
233                    "sits on the {} plane — a plane key answers GETs or carries \
234                     frames; a tombstone there is at most a storage purge \
235                     (RFC 04 §1.2, v1.12) — pass --i-know to mean it.",
236                    v.class
237                ),
238            ))
239        }
240        KeyShape::NotUnderBase | KeyShape::Unparsed { .. } => {
241            let reason = match &facts.shape {
242                KeyShape::Unparsed { reason } => reason.clone(),
243                _ => format!("not under base {base:?}"),
244            };
245            if force {
246                return Ok(RetireClass::Unclassified { reason });
247            }
248            Err(Error::unaskable(
249                key,
250                format!(
251                    "cannot be classified under base {base:?} ({reason}) — 'not \
252                     asked' is not 'state' (RFC 09 §5.1 O4); pass --i-know to \
253                     retire an unclassified key."
254                ),
255            ))
256        }
257    }
258}
259
260/// A stream of matching changes for one **self-declared** entity (a
261/// [`Publication`] or a [`crate::RepeatingQuery`]). These are the only two
262/// places a matching claim can be made from — a foreign publisher's consumers
263/// are not observable without publishing on their key, and that half stays
264/// deferred (RFC 12 §9; #38/#80 adoption note).
265pub struct MatchingEvents {
266    listener: zenoh::matching::MatchingListener<
267        zenoh::handlers::FifoChannelHandler<zenoh::matching::MatchingStatus>,
268    >,
269}
270
271impl MatchingEvents {
272    pub(crate) async fn for_querier(querier: &zenoh::query::Querier<'_>) -> Result<Self> {
273        let listener = querier
274            .matching_listener()
275            .await
276            .map_err(|e| Error::bus("matching listener", "", e))?;
277        Ok(MatchingEvents { listener })
278    }
279
280    /// The next change: `Some(true)` = at least one matcher appeared,
281    /// `Some(false)` = the last one left, `None` = the entity was undeclared.
282    pub async fn recv(&self) -> Option<bool> {
283        self.listener.recv_async().await.ok().map(|s| s.matching())
284    }
285}
286
287/// Who a call is addressed to. Typed — a fleet call is a deliberate variant,
288/// never a string that happens to contain `*` (RFC 08 §1.1's origin-argument
289/// rule for dynamic callers).
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub enum CallTarget {
292    /// One host, by validated origin id.
293    Host(HostId),
294    /// Every host serving the procedure — requires the RFC 05 §2.1 fan-in
295    /// discipline, which [`call`] applies.
296    Fleet,
297    /// A registered service origin (`@catalog`, …) — no producer chunk.
298    Service(ServiceOrigin),
299}
300
301impl CallTarget {
302    /// Parse a CLI-shaped target: `*` = fleet, `@name` = service, else a host
303    /// origin id (validated — a hostname here is the RFC 06 §6 bridge bug,
304    /// and it fails loudly instead of being string-glued into a key).
305    pub fn parse(s: &str) -> Result<CallTarget> {
306        if s == "*" {
307            return Ok(CallTarget::Fleet);
308        }
309        if s.starts_with('@') {
310            return Ok(CallTarget::Service(ServiceOrigin::new(s)?));
311        }
312        HostId::parse(s).map(CallTarget::Host).map_err(|e| {
313            Error::unaskable(
314                "origin",
315                format!("{e} — a hostname is not an origin; resolve it first (RFC 06 §6)"),
316            )
317        })
318    }
319}
320
321/// A reply attachment, projected for the report: JSON if it parses, UTF-8
322/// text if it decodes, else a size tag. Never schema-decoded — an attachment
323/// is outside the registry's vocabulary (#117) — and deliberately
324/// dependency-free: the report shapes are unconditional while the decode
325/// module is feature-gated.
326fn attachment_value(bytes: &[u8]) -> serde_json::Value {
327    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
328        v
329    } else if let Ok(s) = std::str::from_utf8(bytes) {
330        serde_json::Value::String(s.to_string())
331    } else {
332        serde_json::Value::String(format!("<{} bytes>", bytes.len()))
333    }
334}
335
336/// One procedure call, as a spec rather than nine positional arguments —
337/// the shape [`crate::BenchSpec`] already uses next door, for the same
338/// call.
339///
340/// `body` and `attachment` are owned because they are consumed: they ride
341/// the GET out and nothing reads them again.
342pub struct CallSpec<'a> {
343    pub target: &'a CallTarget,
344    pub producer: &'a str,
345    /// Slash-separated, as the registry spells it (`capture/trigger`).
346    pub procedure: &'a str,
347    /// Selector parameters, joined with `;` onto the key (RFC 05 §1).
348    pub params: &'a [String],
349    /// The request payload, already through the encode ladder.
350    pub body: Option<Vec<u8>>,
351    /// Verbatim, never schema-encoded — an attachment is outside the
352    /// registry's vocabulary (#117).
353    pub attachment: Option<Vec<u8>>,
354    pub timeout: Duration,
355    /// The loaded registry, for the fan-out guard below. `None` = none
356    /// loaded, and the guard says so rather than judging.
357    pub slices: Option<&'a SliceSet>,
358}
359
360/// Call a procedure and report every attributed answer.
361///
362/// - The key composes through the typed builders (never `format!`), lifted to
363///   the wire with the configured base.
364/// - `params` ride the selector (`?k=v;k=v`), the body rides the payload
365///   (RFC 05 §1).
366/// - **Fan-out guard**: a [`CallTarget::Fleet`] call is refused when the
367///   loaded slices declare the procedure `fanout = "forbidden"` — or when a
368///   `kind = "write"` procedure declares nothing, because RFC 08 §2 defaults
369///   a write to forbidden and introspect serves the TOML verbatim. With no
370///   slices loaded the registry layer cannot judge — the call proceeds, and
371///   the builder/ACL layers remain (documented, not silent: the report's key
372///   is the caller's audit trail).
373/// - Exit-code semantics stay on [`CallReport::exit_code`]: an error reply is
374///   a failure, zero replies stay a distinct non-verdict (RFC 05 §3.1).
375pub async fn call(fleet: &crate::Fleet<'_>, spec: CallSpec<'_>) -> Result<CallReport> {
376    let CallSpec {
377        target,
378        producer,
379        procedure,
380        params,
381        body,
382        attachment,
383        timeout,
384        slices,
385    } = spec;
386    if matches!(target, CallTarget::Fleet)
387        && let Some(slices) = slices
388        && let Some(slice) = slices.get(producer)
389        && let Some(proc_decl) = slice.procedures.iter().find(|p| p.path == procedure)
390    {
391        // Introspect serves the TOML verbatim, so an omitted `fanout` reaches
392        // this layer as `None` — and RFC 08 §2 *defaults* a `kind = "write"`
393        // procedure to forbidden. The default has to be applied here, or a
394        // dynamic caller fans out a write the generated builders refuse to
395        // spell.
396        let forbidden = match proc_decl.fanout.as_ref().and_then(Declared::known) {
397            Some(Fanout::Forbidden) => true,
398            Some(Fanout::Allowed) => false,
399            // An unrecognised token is not a licence: RFC 08 §2 defaults a
400            // `write` to forbidden, and a `fanout` spelling this build cannot
401            // read is exactly the case where guessing "allowed" would fan out
402            // a write the generated builders refuse to spell.
403            None => matches!(
404                proc_decl.kind.as_ref().and_then(Declared::known),
405                Some(ProcedureKind::Write)
406            ),
407        };
408        if forbidden {
409            // Three cases, because the guard above has three. Reading
410            // `fanout.is_some()` folded the middle one into the first and
411            // told the operator the slice "declares fanout = \"forbidden\""
412            // when it declared something this build cannot read — a claim
413            // that sends them grepping the registry for a string that is
414            // not in it.
415            let declared = match proc_decl.fanout.as_ref() {
416                Some(f) if f.is(&Fanout::Forbidden) => {
417                    "declares fanout = \"forbidden\"".to_string()
418                }
419                Some(f) => format!(
420                    "declares fanout = {:?}, a token this build does not know — \
421                     RFC 08 §2 defaults a write to forbidden and an unreadable \
422                     spelling is not a licence",
423                    f.token()
424                ),
425                None => "is a write with no declared fanout, which defaults to forbidden \
426                         (RFC 08 §2)"
427                    .to_string(),
428            };
429            return Err(Error::unaskable(
430                format!("procedure {producer}/{procedure}"),
431                format!(
432                    "{declared} — a fleet (`*`) call to it is refused \
433                     (RFC 05 §2.1); name one origin"
434                ),
435            ));
436        }
437    }
438
439    let segments: Vec<&str> = procedure.split('/').collect();
440    let relative = match target {
441        CallTarget::Host(id) => {
442            let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
443            zenkey::selector::rpc_at(&origin, producer, &segments).to_string()
444        }
445        CallTarget::Fleet => zenkey::selector::fleet_rpc(producer, &segments).to_string(),
446        CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
447    };
448    let mut key = fleet.wire(relative);
449    if !params.is_empty() {
450        key.push('?');
451        key.push_str(&params.join(";"));
452    }
453
454    let answers = crate::bus::query::fleet_get(
455        fleet,
456        &key,
457        &crate::bus::query::GetOpts::new(timeout)
458            .payload(body)
459            .attachment(attachment),
460    )
461    .await?;
462    Ok(CallReport {
463        key: key.clone(),
464        // The wait is part of the claim (R5): a silent call must be readable
465        // against how long it listened.
466        timeout_s: timeout.as_secs_f64(),
467        answers: answers
468            .iter()
469            .map(|a| {
470                // The reply attachment used to be visible at the fleet_get
471                // layer and dropped at this projection (#126) — carried now,
472                // present only when the wire carried one.
473                let (att, att_bytes) = match &a.attachment {
474                    Some(z) => {
475                        let bytes = z.to_bytes();
476                        (Some(attachment_value(&bytes)), Some(bytes.len()))
477                    }
478                    None => (None, None),
479                };
480                let outcome = match &a.answer {
481                    crate::bus::query::Answer::Value(bytes) => {
482                        let bytes = bytes.to_bytes();
483                        match serde_json::from_slice::<serde_json::Value>(&bytes) {
484                            Ok(v) => CallOutcome::Ok {
485                                value: Some(v),
486                                text: None,
487                            },
488                            Err(_) => CallOutcome::Ok {
489                                value: None,
490                                text: Some(String::from_utf8_lossy(&bytes).to_string()),
491                            },
492                        }
493                    }
494                    crate::bus::query::Answer::Error { name, message } => {
495                        CallOutcome::Err(CallError {
496                            name: name.clone(),
497                            message: message.clone(),
498                        })
499                    }
500                };
501                CallAnswer {
502                    origin: a.origin.clone(),
503                    outcome,
504                    attachment: att,
505                    attachment_bytes: att_bytes,
506                }
507            })
508            .collect(),
509    })
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use zenkey::slice::{ProcedureDecl, RegistrySlice, SubjectDecl};
516
517    fn slice_with_state_subject() -> SliceSet {
518        let mut health = SubjectDecl::new("health", zenkey::Class::State);
519        health.type_name = "Health".into();
520        health.ttl_s = Some(900);
521        let mut slice = RegistrySlice::new("1.0", "t", "sysinfo");
522        slice.subjects = vec![health];
523        SliceSet::from_slices(vec![slice])
524    }
525
526    /// The five outcomes of the retire guard (RFC 04 §1.2, v1.12), each
527    /// citing what it knows.
528    #[test]
529    fn a_wildcard_retire_is_refused_unconditionally() {
530        for force in [false, true] {
531            let err = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/**", None, force)
532                .unwrap_err()
533                .to_string();
534            assert!(err.contains("blast radius"), "{err}");
535        }
536    }
537
538    #[test]
539    fn a_state_key_retires_without_a_registry() {
540        // The class is written in the key itself — unlike bench's
541        // idempotence, a missing registry does not blind the guard.
542        let got = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/health", None, false).unwrap();
543        assert_eq!(
544            got,
545            RetireClass::State {
546                registered: false,
547                ttl_s: None
548            }
549        );
550        // With the registry loaded, the tombstone-visibility bound rides out.
551        let slices = slice_with_state_subject();
552        let got = check_retire(
553            "",
554            "v1/h-3fa9c2d41b7e/state/sysinfo/health",
555            Some(&slices),
556            false,
557        )
558        .unwrap();
559        assert_eq!(
560            got,
561            RetireClass::State {
562                registered: true,
563                ttl_s: Some(900)
564            }
565        );
566    }
567
568    #[test]
569    fn a_telemetry_retire_needs_i_know_and_cites_the_rfc() {
570        let key = "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage";
571        let err = check_retire("", key, None, false).unwrap_err().to_string();
572        assert!(err.contains("MUST NOT"), "{err}");
573        assert!(err.contains("v1.12"), "{err}");
574        assert!(err.contains("--i-know"), "{err}");
575        assert_eq!(
576            check_retire("", key, None, true).unwrap(),
577            RetireClass::NonState {
578                class: "telemetry".to_string()
579            }
580        );
581    }
582
583    #[test]
584    fn a_plane_retire_needs_i_know_too() {
585        let key = "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect";
586        let err = check_retire("", key, None, false).unwrap_err().to_string();
587        assert!(err.contains("plane"), "{err}");
588        assert!(matches!(
589            check_retire("", key, None, true).unwrap(),
590            RetireClass::NonState { class } if class == "@rpc"
591        ));
592    }
593
594    #[test]
595    fn an_unclassified_retire_needs_i_know_and_names_o4() {
596        // A foreign key under an empty base parses as... nothing v1.
597        let err = check_retire("", "some/foreign/key", None, false)
598            .unwrap_err()
599            .to_string();
600        assert!(err.contains("O4"), "{err}");
601        assert!(matches!(
602            check_retire("", "some/foreign/key", None, true).unwrap(),
603            RetireClass::Unclassified { .. }
604        ));
605        // And a key under another base is unclassified, not misclassified.
606        let err = check_retire("acme", "other/v1/h-3fa9c2d41b7e/state/x/y", None, false)
607            .unwrap_err()
608            .to_string();
609        assert!(err.contains("cannot be classified"), "{err}");
610    }
611
612    fn slice_with_proc(kind: &str, fanout: Option<&str>) -> SliceSet {
613        let mut trigger = ProcedureDecl::new("capture/trigger");
614        trigger.kind = Some(Declared::parse(kind));
615        trigger.reply = Some("Ack".into());
616        trigger.fanout = fanout.map(Declared::parse);
617        trigger.idempotent = Some(false);
618        let mut slice = RegistrySlice::new("1.0", "t", "netring");
619        slice.procedures = vec![trigger];
620        SliceSet::from_slices(vec![slice])
621    }
622
623    #[test]
624    fn call_targets_parse_and_validate() {
625        assert_eq!(CallTarget::parse("*").unwrap(), CallTarget::Fleet);
626        assert!(matches!(
627            CallTarget::parse("@catalog").unwrap(),
628            CallTarget::Service(_)
629        ));
630        assert!(matches!(
631            CallTarget::parse("h-3fa9c2d41b7e").unwrap(),
632            CallTarget::Host(_)
633        ));
634        // The RFC 06 §6 bridge bug fails loudly, with the pointer.
635        let err = CallTarget::parse("toolbx").unwrap_err().to_string();
636        assert!(err.contains("RFC 06 §6"), "{err}");
637    }
638
639    /// The registry layer of the three-layer refusal: a fleet call to a
640    /// declared forbidden-fanout write never leaves the process.
641    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
642    async fn fleet_calls_to_forbidden_fanout_are_refused() {
643        let session = crate::bus::session::open(&[], &[], false).await.unwrap();
644        let slices = slice_with_proc("write", Some("forbidden"));
645        let err = call(
646            &crate::Fleet::new(&session, ""),
647            CallSpec {
648                target: &CallTarget::Fleet,
649                producer: "netring",
650                procedure: "capture/trigger",
651                params: &[],
652                body: None,
653                attachment: None,
654                timeout: Duration::from_millis(100),
655                slices: Some(&slices),
656            },
657        )
658        .await
659        .unwrap_err()
660        .to_string();
661        assert!(err.contains("fanout"), "{err}");
662        assert!(err.contains("RFC 05 §2.1"), "{err}");
663
664        // A write whose TOML *omits* fanout is refused the same way: RFC 08
665        // §2 defaults `kind = "write"` to forbidden, and introspect serves
666        // the TOML verbatim — the default is this guard's to apply.
667        let err = call(
668            &crate::Fleet::new(&session, ""),
669            CallSpec {
670                target: &CallTarget::Fleet,
671                producer: "netring",
672                procedure: "capture/trigger",
673                params: &[],
674                body: None,
675                attachment: None,
676                timeout: Duration::from_millis(100),
677                slices: Some(&slice_with_proc("write", None)),
678            },
679        )
680        .await
681        .unwrap_err()
682        .to_string();
683        assert!(err.contains("defaults to forbidden"), "{err}");
684        assert!(err.contains("RFC 08 §2"), "{err}");
685        assert!(err.contains("RFC 05 §2.1"), "{err}");
686
687        // A token this build cannot read is refused too — and the refusal
688        // says which token, rather than claiming the slice declared
689        // "forbidden" and sending the operator to grep for a string that is
690        // not in their registry.
691        let err = call(
692            &crate::Fleet::new(&session, ""),
693            CallSpec {
694                target: &CallTarget::Fleet,
695                producer: "netring",
696                procedure: "capture/trigger",
697                params: &[],
698                body: None,
699                attachment: None,
700                timeout: Duration::from_millis(100),
701                slices: Some(&slice_with_proc("write", Some("per-iface"))),
702            },
703        )
704        .await
705        .unwrap_err()
706        .to_string();
707        assert!(err.contains("per-iface"), "{err}");
708        assert!(err.contains("does not know"), "{err}");
709        assert!(
710            !err.contains("declares fanout = \"forbidden\""),
711            "the slice declared no such thing: {err}"
712        );
713        assert!(err.contains("RFC 05 §2.1"), "{err}");
714
715        // An explicit `fanout = "allowed"` write still fans out, and a read
716        // with nothing declared keeps its allowed default (zero replies here
717        // — a non-verdict, not an error).
718        for slices in [
719            slice_with_proc("write", Some("allowed")),
720            slice_with_proc("read", None),
721        ] {
722            let report = call(
723                &crate::Fleet::new(&session, ""),
724                CallSpec {
725                    target: &CallTarget::Fleet,
726                    producer: "netring",
727                    procedure: "capture/trigger",
728                    params: &[],
729                    body: None,
730                    attachment: None,
731                    timeout: Duration::from_millis(100),
732                    slices: Some(&slices),
733                },
734            )
735            .await
736            .unwrap();
737            assert_eq!(report.exit_code(), 2, "silence stays exit 2");
738        }
739    }
740}