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    /// The same changes as a [`Stream`](futures_core::Stream) (#343).
287    ///
288    /// Borrows, so a caller can keep gating on `recv` elsewhere; the item is
289    /// the same projected `bool` rather than the raw `MatchingStatus`, because
290    /// what a caller acts on is "is anyone there", not the status object.
291    pub fn stream(&self) -> impl futures_core::Stream<Item = bool> + '_ {
292        futures_util::StreamExt::map(self.listener.stream(), |s| s.matching())
293    }
294}
295
296/// Who a call is addressed to. Typed — a fleet call is a deliberate variant,
297/// never a string that happens to contain `*` (RFC 08 §1.1's origin-argument
298/// rule for dynamic callers).
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum CallTarget {
301    /// One host, by validated origin id.
302    Host(HostId),
303    /// Every host serving the procedure — requires the RFC 05 §2.1 fan-in
304    /// discipline, which [`call`] applies.
305    Fleet,
306    /// A registered service origin (`@catalog`, …) — no producer chunk.
307    Service(ServiceOrigin),
308}
309
310impl CallTarget {
311    /// Parse a CLI-shaped target: `*` = fleet, `@name` = service, else a host
312    /// origin id (validated — a hostname here is the RFC 06 §6 bridge bug,
313    /// and it fails loudly instead of being string-glued into a key).
314    pub fn parse(s: &str) -> Result<CallTarget> {
315        if s == "*" {
316            return Ok(CallTarget::Fleet);
317        }
318        if s.starts_with('@') {
319            return Ok(CallTarget::Service(ServiceOrigin::new(s)?));
320        }
321        HostId::parse(s).map(CallTarget::Host).map_err(|e| {
322            Error::unaskable(
323                "origin",
324                format!("{e} — a hostname is not an origin; resolve it first (RFC 06 §6)"),
325            )
326        })
327    }
328}
329
330/// A reply attachment, projected for the report: JSON if it parses, UTF-8
331/// text if it decodes, else a size tag. Never schema-decoded — an attachment
332/// is outside the registry's vocabulary (#117) — and deliberately
333/// dependency-free: the report shapes are unconditional while the decode
334/// module is feature-gated.
335fn attachment_value(bytes: &[u8]) -> serde_json::Value {
336    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
337        v
338    } else if let Ok(s) = std::str::from_utf8(bytes) {
339        serde_json::Value::String(s.to_string())
340    } else {
341        serde_json::Value::String(format!("<{} bytes>", bytes.len()))
342    }
343}
344
345/// One procedure call, as a spec rather than nine positional arguments —
346/// the shape [`crate::BenchSpec`] already uses next door, for the same
347/// call.
348///
349/// `body` and `attachment` are owned because they are consumed: they ride
350/// the GET out and nothing reads them again.
351pub struct CallSpec<'a> {
352    pub target: &'a CallTarget,
353    pub producer: &'a str,
354    /// Slash-separated, as the registry spells it (`capture/trigger`).
355    pub procedure: &'a str,
356    /// Selector parameters, joined with `;` onto the key (RFC 05 §1).
357    pub params: &'a [String],
358    /// The request payload, already through the encode ladder.
359    pub body: Option<Vec<u8>>,
360    /// Verbatim, never schema-encoded — an attachment is outside the
361    /// registry's vocabulary (#117).
362    pub attachment: Option<Vec<u8>>,
363    pub timeout: Duration,
364    /// The loaded registry, for the fan-out guard below. `None` = none
365    /// loaded, and the guard says so rather than judging.
366    pub slices: Option<&'a SliceSet>,
367}
368
369/// Call a procedure and report every attributed answer.
370///
371/// - The key composes through the typed builders (never `format!`), lifted to
372///   the wire with the configured base.
373/// - `params` ride the selector (`?k=v;k=v`), the body rides the payload
374///   (RFC 05 §1).
375/// - **Fan-out guard**: a [`CallTarget::Fleet`] call is refused when the
376///   loaded slices declare the procedure `fanout = "forbidden"` — or when a
377///   `kind = "write"` procedure declares nothing, because RFC 08 §2 defaults
378///   a write to forbidden and introspect serves the TOML verbatim. With no
379///   slices loaded the registry layer cannot judge — the call proceeds, and
380///   the builder/ACL layers remain (documented, not silent: the report's key
381///   is the caller's audit trail).
382/// - Exit-code semantics stay on [`CallReport::exit_code`]: an error reply is
383///   a failure, zero replies stay a distinct non-verdict (RFC 05 §3.1).
384pub async fn call(fleet: &crate::Fleet<'_>, spec: CallSpec<'_>) -> Result<CallReport> {
385    let CallSpec {
386        target,
387        producer,
388        procedure,
389        params,
390        body,
391        attachment,
392        timeout,
393        slices,
394    } = spec;
395    if matches!(target, CallTarget::Fleet)
396        && let Some(slices) = slices
397        && let Some(slice) = slices.get(producer)
398        && let Some(proc_decl) = slice.procedures.iter().find(|p| p.path == procedure)
399    {
400        // Introspect serves the TOML verbatim, so an omitted `fanout` reaches
401        // this layer as `None` — and RFC 08 §2 *defaults* a `kind = "write"`
402        // procedure to forbidden. The default has to be applied here, or a
403        // dynamic caller fans out a write the generated builders refuse to
404        // spell.
405        let forbidden = match proc_decl.fanout.as_ref().and_then(Declared::known) {
406            Some(Fanout::Forbidden) => true,
407            Some(Fanout::Allowed) => false,
408            // An unrecognised token is not a licence: RFC 08 §2 defaults a
409            // `write` to forbidden, and a `fanout` spelling this build cannot
410            // read is exactly the case where guessing "allowed" would fan out
411            // a write the generated builders refuse to spell.
412            None => matches!(
413                proc_decl.kind.as_ref().and_then(Declared::known),
414                Some(ProcedureKind::Write)
415            ),
416        };
417        if forbidden {
418            // Three cases, because the guard above has three. Reading
419            // `fanout.is_some()` folded the middle one into the first and
420            // told the operator the slice "declares fanout = \"forbidden\""
421            // when it declared something this build cannot read — a claim
422            // that sends them grepping the registry for a string that is
423            // not in it.
424            let declared = match proc_decl.fanout.as_ref() {
425                Some(f) if f.is(&Fanout::Forbidden) => {
426                    "declares fanout = \"forbidden\"".to_string()
427                }
428                Some(f) => format!(
429                    "declares fanout = {:?}, a token this build does not know — \
430                     RFC 08 §2 defaults a write to forbidden and an unreadable \
431                     spelling is not a licence",
432                    f.token()
433                ),
434                None => "is a write with no declared fanout, which defaults to forbidden \
435                         (RFC 08 §2)"
436                    .to_string(),
437            };
438            return Err(Error::unaskable(
439                format!("procedure {producer}/{procedure}"),
440                format!(
441                    "{declared} — a fleet (`*`) call to it is refused \
442                     (RFC 05 §2.1); name one origin"
443                ),
444            ));
445        }
446    }
447
448    let segments: Vec<&str> = procedure.split('/').collect();
449    let relative = match target {
450        CallTarget::Host(id) => {
451            let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
452            zenkey::selector::rpc_at(&origin, producer, &segments).to_string()
453        }
454        CallTarget::Fleet => zenkey::selector::fleet_rpc(producer, &segments).to_string(),
455        CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
456    };
457    let mut key = fleet.wire(relative);
458    if !params.is_empty() {
459        key.push('?');
460        key.push_str(&params.join(";"));
461    }
462
463    let answers = crate::bus::query::fleet_get(
464        fleet,
465        &key,
466        &crate::bus::query::GetOpts::new(timeout)
467            .payload(body)
468            .attachment(attachment),
469    )
470    .await?;
471    Ok(CallReport {
472        key: key.clone(),
473        // The wait is part of the claim (R5): a silent call must be readable
474        // against how long it listened.
475        timeout_s: timeout.as_secs_f64(),
476        answers: answers
477            .iter()
478            .map(|a| {
479                // The reply attachment used to be visible at the fleet_get
480                // layer and dropped at this projection (#126) — carried now,
481                // present only when the wire carried one.
482                let (att, att_bytes) = match &a.attachment {
483                    Some(z) => {
484                        let bytes = z.to_bytes();
485                        (Some(attachment_value(&bytes)), Some(bytes.len()))
486                    }
487                    None => (None, None),
488                };
489                let outcome = match &a.answer {
490                    crate::bus::query::Answer::Value(bytes) => {
491                        let bytes = bytes.to_bytes();
492                        match serde_json::from_slice::<serde_json::Value>(&bytes) {
493                            Ok(v) => CallOutcome::Ok {
494                                value: Some(v),
495                                text: None,
496                            },
497                            Err(_) => CallOutcome::Ok {
498                                value: None,
499                                text: Some(String::from_utf8_lossy(&bytes).to_string()),
500                            },
501                        }
502                    }
503                    crate::bus::query::Answer::Error { name, message } => {
504                        CallOutcome::Err(CallError {
505                            name: name.clone(),
506                            message: message.clone(),
507                        })
508                    }
509                };
510                CallAnswer {
511                    origin: a.origin.clone(),
512                    outcome,
513                    attachment: att,
514                    attachment_bytes: att_bytes,
515                }
516            })
517            .collect(),
518    })
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use zenkey::slice::{ProcedureDecl, RegistrySlice, SubjectDecl};
525
526    fn slice_with_state_subject() -> SliceSet {
527        let mut health = SubjectDecl::new("health", zenkey::Class::State);
528        health.type_name = "Health".into();
529        health.ttl_s = Some(900);
530        let mut slice = RegistrySlice::new("1.0", "t", "sysinfo");
531        slice.subjects = vec![health];
532        SliceSet::from_slices(vec![slice])
533    }
534
535    /// The five outcomes of the retire guard (RFC 04 §1.2, v1.12), each
536    /// citing what it knows.
537    #[test]
538    fn a_wildcard_retire_is_refused_unconditionally() {
539        for force in [false, true] {
540            let err = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/**", None, force)
541                .unwrap_err()
542                .to_string();
543            assert!(err.contains("blast radius"), "{err}");
544        }
545    }
546
547    #[test]
548    fn a_state_key_retires_without_a_registry() {
549        // The class is written in the key itself — unlike bench's
550        // idempotence, a missing registry does not blind the guard.
551        let got = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/health", None, false).unwrap();
552        assert_eq!(
553            got,
554            RetireClass::State {
555                registered: false,
556                ttl_s: None
557            }
558        );
559        // With the registry loaded, the tombstone-visibility bound rides out.
560        let slices = slice_with_state_subject();
561        let got = check_retire(
562            "",
563            "v1/h-3fa9c2d41b7e/state/sysinfo/health",
564            Some(&slices),
565            false,
566        )
567        .unwrap();
568        assert_eq!(
569            got,
570            RetireClass::State {
571                registered: true,
572                ttl_s: Some(900)
573            }
574        );
575    }
576
577    #[test]
578    fn a_telemetry_retire_needs_i_know_and_cites_the_rfc() {
579        let key = "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage";
580        let err = check_retire("", key, None, false).unwrap_err().to_string();
581        assert!(err.contains("MUST NOT"), "{err}");
582        assert!(err.contains("v1.12"), "{err}");
583        assert!(err.contains("--i-know"), "{err}");
584        assert_eq!(
585            check_retire("", key, None, true).unwrap(),
586            RetireClass::NonState {
587                class: "telemetry".to_string()
588            }
589        );
590    }
591
592    #[test]
593    fn a_plane_retire_needs_i_know_too() {
594        let key = "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect";
595        let err = check_retire("", key, None, false).unwrap_err().to_string();
596        assert!(err.contains("plane"), "{err}");
597        assert!(matches!(
598            check_retire("", key, None, true).unwrap(),
599            RetireClass::NonState { class } if class == "@rpc"
600        ));
601    }
602
603    #[test]
604    fn an_unclassified_retire_needs_i_know_and_names_o4() {
605        // A foreign key under an empty base parses as... nothing v1.
606        let err = check_retire("", "some/foreign/key", None, false)
607            .unwrap_err()
608            .to_string();
609        assert!(err.contains("O4"), "{err}");
610        assert!(matches!(
611            check_retire("", "some/foreign/key", None, true).unwrap(),
612            RetireClass::Unclassified { .. }
613        ));
614        // And a key under another base is unclassified, not misclassified.
615        let err = check_retire("acme", "other/v1/h-3fa9c2d41b7e/state/x/y", None, false)
616            .unwrap_err()
617            .to_string();
618        assert!(err.contains("cannot be classified"), "{err}");
619    }
620
621    fn slice_with_proc(kind: &str, fanout: Option<&str>) -> SliceSet {
622        let mut trigger = ProcedureDecl::new("capture/trigger");
623        trigger.kind = Some(Declared::parse(kind));
624        trigger.reply = Some("Ack".into());
625        trigger.fanout = fanout.map(Declared::parse);
626        trigger.idempotent = Some(false);
627        let mut slice = RegistrySlice::new("1.0", "t", "netring");
628        slice.procedures = vec![trigger];
629        SliceSet::from_slices(vec![slice])
630    }
631
632    #[test]
633    fn call_targets_parse_and_validate() {
634        assert_eq!(CallTarget::parse("*").unwrap(), CallTarget::Fleet);
635        assert!(matches!(
636            CallTarget::parse("@catalog").unwrap(),
637            CallTarget::Service(_)
638        ));
639        assert!(matches!(
640            CallTarget::parse("h-3fa9c2d41b7e").unwrap(),
641            CallTarget::Host(_)
642        ));
643        // The RFC 06 §6 bridge bug fails loudly, with the pointer.
644        let err = CallTarget::parse("toolbx").unwrap_err().to_string();
645        assert!(err.contains("RFC 06 §6"), "{err}");
646    }
647
648    /// The registry layer of the three-layer refusal: a fleet call to a
649    /// declared forbidden-fanout write never leaves the process.
650    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
651    async fn fleet_calls_to_forbidden_fanout_are_refused() {
652        let session = crate::bus::session::open(&[], &[], false).await.unwrap();
653        let slices = slice_with_proc("write", Some("forbidden"));
654        let err = call(
655            &crate::Fleet::new(&session, ""),
656            CallSpec {
657                target: &CallTarget::Fleet,
658                producer: "netring",
659                procedure: "capture/trigger",
660                params: &[],
661                body: None,
662                attachment: None,
663                timeout: Duration::from_millis(100),
664                slices: Some(&slices),
665            },
666        )
667        .await
668        .unwrap_err()
669        .to_string();
670        assert!(err.contains("fanout"), "{err}");
671        assert!(err.contains("RFC 05 §2.1"), "{err}");
672
673        // A write whose TOML *omits* fanout is refused the same way: RFC 08
674        // §2 defaults `kind = "write"` to forbidden, and introspect serves
675        // the TOML verbatim — the default is this guard's to apply.
676        let err = call(
677            &crate::Fleet::new(&session, ""),
678            CallSpec {
679                target: &CallTarget::Fleet,
680                producer: "netring",
681                procedure: "capture/trigger",
682                params: &[],
683                body: None,
684                attachment: None,
685                timeout: Duration::from_millis(100),
686                slices: Some(&slice_with_proc("write", None)),
687            },
688        )
689        .await
690        .unwrap_err()
691        .to_string();
692        assert!(err.contains("defaults to forbidden"), "{err}");
693        assert!(err.contains("RFC 08 §2"), "{err}");
694        assert!(err.contains("RFC 05 §2.1"), "{err}");
695
696        // A token this build cannot read is refused too — and the refusal
697        // says which token, rather than claiming the slice declared
698        // "forbidden" and sending the operator to grep for a string that is
699        // not in their registry.
700        let err = call(
701            &crate::Fleet::new(&session, ""),
702            CallSpec {
703                target: &CallTarget::Fleet,
704                producer: "netring",
705                procedure: "capture/trigger",
706                params: &[],
707                body: None,
708                attachment: None,
709                timeout: Duration::from_millis(100),
710                slices: Some(&slice_with_proc("write", Some("per-iface"))),
711            },
712        )
713        .await
714        .unwrap_err()
715        .to_string();
716        assert!(err.contains("per-iface"), "{err}");
717        assert!(err.contains("does not know"), "{err}");
718        assert!(
719            !err.contains("declares fanout = \"forbidden\""),
720            "the slice declared no such thing: {err}"
721        );
722        assert!(err.contains("RFC 05 §2.1"), "{err}");
723
724        // An explicit `fanout = "allowed"` write still fans out, and a read
725        // with nothing declared keeps its allowed default (zero replies here
726        // — a non-verdict, not an error).
727        for slices in [
728            slice_with_proc("write", Some("allowed")),
729            slice_with_proc("read", None),
730        ] {
731            let report = call(
732                &crate::Fleet::new(&session, ""),
733                CallSpec {
734                    target: &CallTarget::Fleet,
735                    producer: "netring",
736                    procedure: "capture/trigger",
737                    params: &[],
738                    body: None,
739                    attachment: None,
740                    timeout: Duration::from_millis(100),
741                    slices: Some(&slices),
742                },
743            )
744            .await
745            .unwrap();
746            assert_eq!(report.exit_code(), 2, "silence stays exit 2");
747        }
748    }
749}