Skip to main content

zenkey_fleet/
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 topic 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 anyhow::{Result, anyhow, bail};
22use zenkey::origin::{HostId, ServiceOrigin};
23use zenkey::qos::QosProfile;
24use zenoh::Session;
25
26use crate::registry::SliceSet;
27use crate::report::{CallAnswer, CallError, CallReport};
28
29/// A declared publisher with its QoS profile applied — the only publish path.
30pub struct Publication {
31    publisher: zenoh::pubsub::Publisher<'static>,
32    encoding: Option<String>,
33}
34
35impl std::fmt::Debug for Publication {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("Publication")
38            .field("key", &self.publisher.key_expr().as_str())
39            .finish_non_exhaustive()
40    }
41}
42
43/// Declare a publication on a **full wire key** (explorers are un-namespaced;
44/// compose with `with_base` first).
45///
46/// The profile maps to the wire in one place: reliability, congestion
47/// control, priority, and the express bit (RFC 04 §3 — `alert` and `frame`
48/// are the express profiles; nothing in the workspace ever set it before).
49pub async fn declare_publication(
50    session: &Session,
51    key: &str,
52    qos: QosProfile,
53    encoding: Option<&str>,
54) -> Result<Publication> {
55    let publisher = session
56        .declare_publisher(key.to_string())
57        .reliability(qos.reliability())
58        .congestion_control(qos.congestion_control())
59        .priority(qos.priority())
60        .express(qos.express())
61        .await
62        .map_err(|e| anyhow!("declare publisher {key}: {e}"))?;
63    Ok(Publication {
64        publisher,
65        encoding: encoding.map(str::to_string),
66    })
67}
68
69impl Publication {
70    /// Publish one payload, with an optional attachment riding beside it
71    /// (#117 — attachments are outside the registry's vocabulary and are
72    /// never schema-encoded). Sets the wire `Encoding` when one was declared
73    /// (RFC 04 v1.5's recommendation: publishers say what they carry).
74    pub async fn send(&self, payload: Vec<u8>, attachment: Option<Vec<u8>>) -> Result<()> {
75        let put = self.publisher.put(payload);
76        let put = match &self.encoding {
77            Some(e) => put.encoding(e.as_str()),
78            None => put,
79        };
80        let put = match attachment {
81            Some(a) => put.attachment(a),
82            None => put,
83        };
84        put.await
85            .map_err(|e| anyhow!("put {}: {e}", self.publisher.key_expr()))
86    }
87
88    /// Publish a tombstone — an authoritative retirement (RFC 04 §1.2),
89    /// never a payload marker. The only delete path: it rides the declared
90    /// publisher, and `Session::delete` stays unexposed for the same reason
91    /// there is no bare-put helper. Gate dynamic keys through
92    /// [`check_retire`] first — the class semantics live there.
93    pub async fn retire(&self) -> Result<()> {
94        self.publisher
95            .delete()
96            .await
97            .map_err(|e| anyhow!("delete {}: {e}", self.publisher.key_expr()))
98    }
99
100    /// Undeclare, acknowledged.
101    pub async fn undeclare(self) -> Result<()> {
102        self.publisher
103            .undeclare()
104            .await
105            .map_err(|e| anyhow!("undeclare publisher: {e}"))
106    }
107
108    /// Whether any subscriber currently matches **this publication** — a
109    /// routing fact about the publisher *this process declared* (RFC 12 §9's
110    /// allowed half). It says nothing about other publishers on the key, and
111    /// `false` is not a fleet verdict ("no subscriber matched *our*
112    /// publication", never "nobody listens here" — RFC 05 §3.1 applied to a
113    /// badge).
114    pub async fn matching_status(&self) -> Result<bool> {
115        self.publisher
116            .matching_status()
117            .await
118            .map(|s| s.matching())
119            .map_err(|e| anyhow!("matching status: {e}"))
120    }
121
122    /// Event-driven matching changes for this publication — the badge feed.
123    /// Same honesty bounds as [`matching_status`](Self::matching_status).
124    pub async fn matching_events(&self) -> Result<MatchingEvents> {
125        let listener = self
126            .publisher
127            .matching_listener()
128            .await
129            .map_err(|e| anyhow!("matching listener: {e}"))?;
130        Ok(MatchingEvents { listener })
131    }
132}
133
134/// What a key is, for the purpose of retiring it — the guard's positive
135/// verdict, so callers print facts instead of re-deriving them.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum RetireClass {
138    /// State-shaped: retirement is the class's own semantics (RFC 04 §1.2).
139    State {
140        /// Whether a loaded registry recognises the subject. An unregistered
141        /// state key still tombstones authoritatively — but no `ttl_s`
142        /// bounds how long the tombstone stays observable.
143        registered: bool,
144        /// The registry's `ttl_s`, when declared: storages keep the
145        /// tombstone observable at least this long (RFC 04 §1.2).
146        ttl_s: Option<i64>,
147    },
148    /// A v1 key off the state class (telemetry/events, or a verbatim
149    /// plane) — retired anyway, as a forced operator cleanup (v1.12).
150    NonState { class: String },
151    /// The grammar could not say what the key is — retired blind, forced.
152    Unclassified { reason: String },
153}
154
155/// Refuse a tombstone the class semantics do not license, unless forced.
156///
157/// The judgment mirrors `bench`'s idempotence guard: the refusal is
158/// grammar- and registry-driven, and the messages cite what they know.
159/// Unlike `bench`, a missing registry does not blind us on the happy path —
160/// the class is written in the key itself, so a state key passes with no
161/// slices loaded. The one unconditional refusal is a wildcard: a tombstone
162/// is addressed to one concrete key (RFC 04 §1.2, v1.12), and no `force`
163/// overrides a blast radius.
164pub fn check_retire(
165    base: &str,
166    key: &str,
167    slices: Option<&SliceSet>,
168    force: bool,
169) -> Result<RetireClass> {
170    if key.contains('*') || key.contains('$') {
171        bail!(
172            "{key} is a wildcard — a tombstone is addressed to one concrete key; \
173             a wildcard delete is not an operator act, it is a blast radius \
174             (RFC 04 §1.2, v1.12). Not overridable."
175        );
176    }
177    let facts = crate::facts::describe_key(base, key, slices).facts;
178    use crate::facts::{ClassKind, KeyShape, Registration};
179    match &facts.shape {
180        KeyShape::V1(v) if v.class_kind == ClassKind::State => {
181            let (registered, ttl_s) = match &facts.registration {
182                Registration::Registered(s) => (true, s.ttl_s),
183                _ => (false, None),
184            };
185            Ok(RetireClass::State { registered, ttl_s })
186        }
187        KeyShape::V1(v) if matches!(v.class_kind, ClassKind::Telemetry | ClassKind::Events) => {
188            if force {
189                return Ok(RetireClass::NonState {
190                    class: v.class.clone(),
191                });
192            }
193            bail!(
194                "{key} is {class}-shaped — RFC 04 §1: a delete there is meaningless \
195                 and MUST NOT be sent by the class's publisher. Retiring it anyway \
196                 is an operator cleanup (RFC 04 §1.2, v1.12) — pass --i-know to \
197                 mean it.",
198                class = v.class
199            );
200        }
201        KeyShape::V1(v) => {
202            if force {
203                return Ok(RetireClass::NonState {
204                    class: v.class.clone(),
205                });
206            }
207            bail!(
208                "{key} sits on the {class} plane — a plane key answers GETs or \
209                 carries frames; a tombstone there is at most a storage purge \
210                 (RFC 04 §1.2, v1.12) — pass --i-know to mean it.",
211                class = v.class
212            );
213        }
214        KeyShape::NotUnderBase | KeyShape::Unparsed { .. } => {
215            let reason = match &facts.shape {
216                KeyShape::Unparsed { reason } => reason.clone(),
217                _ => format!("not under base {base:?}"),
218            };
219            if force {
220                return Ok(RetireClass::Unclassified { reason });
221            }
222            bail!(
223                "cannot classify {key} under base {base:?} ({reason}) — 'not asked' \
224                 is not 'state' (RFC 09 §5.1 O4); pass --i-know to retire an \
225                 unclassified key."
226            );
227        }
228    }
229}
230
231/// A stream of matching changes for one **self-declared** entity (a
232/// [`Publication`] or a [`crate::RepeatingQuery`]). These are the only two
233/// places a matching claim can be made from — a foreign publisher's consumers
234/// are not observable without publishing on their key, and that half stays
235/// deferred (RFC 12 §9; #38/#80 adoption note).
236pub struct MatchingEvents {
237    listener: zenoh::matching::MatchingListener<
238        zenoh::handlers::FifoChannelHandler<zenoh::matching::MatchingStatus>,
239    >,
240}
241
242impl MatchingEvents {
243    pub(crate) async fn for_querier(querier: &zenoh::query::Querier<'_>) -> Result<Self> {
244        let listener = querier
245            .matching_listener()
246            .await
247            .map_err(|e| anyhow!("matching listener: {e}"))?;
248        Ok(MatchingEvents { listener })
249    }
250
251    /// The next change: `Some(true)` = at least one matcher appeared,
252    /// `Some(false)` = the last one left, `None` = the entity was undeclared.
253    pub async fn recv(&self) -> Option<bool> {
254        self.listener.recv_async().await.ok().map(|s| s.matching())
255    }
256}
257
258/// Who a call is addressed to. Typed — a fleet call is a deliberate variant,
259/// never a string that happens to contain `*` (RFC 08 §1.1's origin-argument
260/// rule for dynamic callers).
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub enum CallTarget {
263    /// One host, by validated origin id.
264    Host(HostId),
265    /// Every host serving the procedure — requires the RFC 05 §2.1 fan-in
266    /// discipline, which [`call`] applies.
267    Fleet,
268    /// A registered service origin (`@catalog`, …) — no producer chunk.
269    Service(ServiceOrigin),
270}
271
272impl CallTarget {
273    /// Parse a CLI-shaped target: `*` = fleet, `@name` = service, else a host
274    /// origin id (validated — a hostname here is the RFC 06 §6 bridge bug,
275    /// and it fails loudly instead of being string-glued into a key).
276    pub fn parse(s: &str) -> Result<CallTarget> {
277        if s == "*" {
278            return Ok(CallTarget::Fleet);
279        }
280        if s.starts_with('@') {
281            return Ok(CallTarget::Service(
282                ServiceOrigin::new(s).map_err(|e| anyhow!("{e}"))?,
283            ));
284        }
285        HostId::parse(s)
286            .map(CallTarget::Host)
287            .map_err(|e| anyhow!("{e} — a hostname is not an origin; resolve it first (RFC 06 §6)"))
288    }
289}
290
291/// A reply attachment, projected for the report: JSON if it parses, UTF-8
292/// text if it decodes, else a size tag. Never schema-decoded — an attachment
293/// is outside the registry's vocabulary (#117) — and deliberately
294/// dependency-free: the report shapes are unconditional while the decode
295/// module is feature-gated.
296fn attachment_value(bytes: &[u8]) -> serde_json::Value {
297    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
298        v
299    } else if let Ok(s) = std::str::from_utf8(bytes) {
300        serde_json::Value::String(s.to_string())
301    } else {
302        serde_json::Value::String(format!("<{} bytes>", bytes.len()))
303    }
304}
305
306/// Call a procedure and report every attributed answer.
307///
308/// - The key composes through the typed builders (never `format!`), lifted to
309///   the wire with the configured base.
310/// - `params` ride the selector (`?k=v;k=v`), the body rides the payload
311///   (RFC 05 §1).
312/// - **Fan-out guard**: a [`CallTarget::Fleet`] call is refused when the
313///   loaded slices declare the procedure `fanout = "forbidden"`. With no
314///   slices loaded the registry layer cannot judge — the call proceeds, and
315///   the builder/ACL layers remain (documented, not silent: the report's key
316///   is the caller's audit trail).
317/// - Exit-code semantics stay on [`CallReport::exit_code`]: an error reply is
318///   a failure, zero replies stay a distinct non-verdict (RFC 05 §3.1).
319#[allow(clippy::too_many_arguments)]
320pub async fn call(
321    session: &Session,
322    base: &str,
323    target: &CallTarget,
324    producer: &str,
325    procedure: &str,
326    params: &[String],
327    body: Option<Vec<u8>>,
328    attachment: Option<Vec<u8>>,
329    timeout: Duration,
330    slices: Option<&SliceSet>,
331) -> Result<CallReport> {
332    if matches!(target, CallTarget::Fleet)
333        && let Some(slices) = slices
334        && let Some(slice) = slices.get(producer)
335        && let Some(proc_decl) = slice.procedures.iter().find(|p| p.path == procedure)
336        && proc_decl.fanout.as_deref() == Some("forbidden")
337    {
338        bail!(
339            "procedure {producer}/{procedure} declares fanout = \"forbidden\" — a \
340             fleet (`*`) call to it is refused (RFC 05 §2.1); name one origin"
341        );
342    }
343
344    let segments: Vec<&str> = procedure.split('/').collect();
345    let relative = match target {
346        CallTarget::Host(id) => {
347            let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
348            zenkey::selector::rpc_at(&origin, producer, &segments).to_string()
349        }
350        CallTarget::Fleet => zenkey::selector::fleet_rpc(producer, &segments).to_string(),
351        CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
352    };
353    let mut key = zenkey::grammar::with_base(base, relative);
354    if !params.is_empty() {
355        key.push('?');
356        key.push_str(&params.join(";"));
357    }
358
359    let answers =
360        crate::query::fleet_get_call(session, base, &key, body, attachment, timeout).await?;
361    Ok(CallReport {
362        key: key.clone(),
363        answers: answers
364            .iter()
365            .map(|a| {
366                // The reply attachment used to be visible at the fleet_get
367                // layer and dropped at this projection (#126) — carried now,
368                // present only when the wire carried one.
369                let (att, att_bytes) = match &a.attachment {
370                    Some(z) => {
371                        let bytes = z.to_bytes();
372                        (Some(attachment_value(&bytes)), Some(bytes.len()))
373                    }
374                    None => (None, None),
375                };
376                match &a.answer {
377                    crate::query::Answer::Value(bytes) => {
378                        let bytes = bytes.to_bytes();
379                        match serde_json::from_slice::<serde_json::Value>(&bytes) {
380                            Ok(v) => CallAnswer {
381                                origin: a.origin.clone(),
382                                ok: true,
383                                value: Some(v),
384                                text: None,
385                                attachment: att,
386                                attachment_bytes: att_bytes,
387                                error: None,
388                            },
389                            Err(_) => CallAnswer {
390                                origin: a.origin.clone(),
391                                ok: true,
392                                value: None,
393                                text: Some(String::from_utf8_lossy(&bytes).to_string()),
394                                attachment: att,
395                                attachment_bytes: att_bytes,
396                                error: None,
397                            },
398                        }
399                    }
400                    crate::query::Answer::Error { name, message } => CallAnswer {
401                        origin: a.origin.clone(),
402                        ok: false,
403                        value: None,
404                        text: None,
405                        attachment: att,
406                        attachment_bytes: att_bytes,
407                        error: Some(CallError {
408                            name: name.clone(),
409                            message: message.clone(),
410                        }),
411                    },
412                }
413            })
414            .collect(),
415    })
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use zenkey::slice::{ProcedureDecl, RegistrySlice, SubjectDecl};
422
423    fn slice_with_state_subject() -> SliceSet {
424        SliceSet::from_slices(vec![RegistrySlice {
425            version: "1.0".into(),
426            app: "t".into(),
427            convention: 1,
428            name: "sysinfo".into(),
429            service_origin: None,
430            description: None,
431            subjects: vec![SubjectDecl {
432                path: "health".into(),
433                class: "state".into(),
434                type_name: "Health".into(),
435                common: None,
436                since: None,
437                description: None,
438                qos: None,
439                ttl_s: Some(900),
440                unit: None,
441                rate: None,
442                cardinality: None,
443                encoding: None,
444            }],
445            procedures: vec![],
446            blob: vec![],
447            media: vec![],
448            deprecated: vec![],
449        }])
450    }
451
452    /// The five outcomes of the retire guard (RFC 04 §1.2, v1.12), each
453    /// citing what it knows.
454    #[test]
455    fn a_wildcard_retire_is_refused_unconditionally() {
456        for force in [false, true] {
457            let err = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/**", None, force)
458                .unwrap_err()
459                .to_string();
460            assert!(err.contains("blast radius"), "{err}");
461        }
462    }
463
464    #[test]
465    fn a_state_key_retires_without_a_registry() {
466        // The class is written in the key itself — unlike bench's
467        // idempotence, a missing registry does not blind the guard.
468        let got = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/health", None, false).unwrap();
469        assert_eq!(
470            got,
471            RetireClass::State {
472                registered: false,
473                ttl_s: None
474            }
475        );
476        // With the registry loaded, the tombstone-visibility bound rides out.
477        let slices = slice_with_state_subject();
478        let got = check_retire(
479            "",
480            "v1/h-3fa9c2d41b7e/state/sysinfo/health",
481            Some(&slices),
482            false,
483        )
484        .unwrap();
485        assert_eq!(
486            got,
487            RetireClass::State {
488                registered: true,
489                ttl_s: Some(900)
490            }
491        );
492    }
493
494    #[test]
495    fn a_telemetry_retire_needs_i_know_and_cites_the_rfc() {
496        let key = "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage";
497        let err = check_retire("", key, None, false).unwrap_err().to_string();
498        assert!(err.contains("MUST NOT"), "{err}");
499        assert!(err.contains("v1.12"), "{err}");
500        assert!(err.contains("--i-know"), "{err}");
501        assert_eq!(
502            check_retire("", key, None, true).unwrap(),
503            RetireClass::NonState {
504                class: "telemetry".into()
505            }
506        );
507    }
508
509    #[test]
510    fn a_plane_retire_needs_i_know_too() {
511        let key = "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect";
512        let err = check_retire("", key, None, false).unwrap_err().to_string();
513        assert!(err.contains("plane"), "{err}");
514        assert!(matches!(
515            check_retire("", key, None, true).unwrap(),
516            RetireClass::NonState { class } if class == "@rpc"
517        ));
518    }
519
520    #[test]
521    fn an_unclassified_retire_needs_i_know_and_names_o4() {
522        // A foreign key under an empty base parses as... nothing v1.
523        let err = check_retire("", "some/foreign/key", None, false)
524            .unwrap_err()
525            .to_string();
526        assert!(err.contains("O4"), "{err}");
527        assert!(matches!(
528            check_retire("", "some/foreign/key", None, true).unwrap(),
529            RetireClass::Unclassified { .. }
530        ));
531        // And a key under another base is unclassified, not misclassified.
532        let err = check_retire("acme", "other/v1/h-3fa9c2d41b7e/state/x/y", None, false)
533            .unwrap_err()
534            .to_string();
535        assert!(err.contains("cannot classify"), "{err}");
536    }
537
538    fn slice_with_proc(fanout: Option<&str>) -> SliceSet {
539        SliceSet::from_slices(vec![RegistrySlice {
540            version: "1.0".into(),
541            app: "t".into(),
542            convention: 1,
543            name: "netring".into(),
544            service_origin: None,
545            description: None,
546            subjects: vec![],
547            procedures: vec![ProcedureDecl {
548                path: "capture/trigger".into(),
549                kind: "write".into(),
550                reply: Some("Ack".into()),
551                request: None,
552                encoding: None,
553                fanout: fanout.map(str::to_string),
554                idempotent: Some(false),
555                since: None,
556                description: None,
557            }],
558            blob: vec![],
559            media: vec![],
560            deprecated: vec![],
561        }])
562    }
563
564    #[test]
565    fn call_targets_parse_and_validate() {
566        assert_eq!(CallTarget::parse("*").unwrap(), CallTarget::Fleet);
567        assert!(matches!(
568            CallTarget::parse("@catalog").unwrap(),
569            CallTarget::Service(_)
570        ));
571        assert!(matches!(
572            CallTarget::parse("h-3fa9c2d41b7e").unwrap(),
573            CallTarget::Host(_)
574        ));
575        // The RFC 06 §6 bridge bug fails loudly, with the pointer.
576        let err = CallTarget::parse("toolbx").unwrap_err().to_string();
577        assert!(err.contains("RFC 06 §6"), "{err}");
578    }
579
580    /// The registry layer of the three-layer refusal: a fleet call to a
581    /// declared forbidden-fanout write never leaves the process.
582    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
583    async fn fleet_calls_to_forbidden_fanout_are_refused() {
584        let session = crate::session::open(&[], &[], false).await.unwrap();
585        let slices = slice_with_proc(Some("forbidden"));
586        let err = call(
587            &session,
588            "",
589            &CallTarget::Fleet,
590            "netring",
591            "capture/trigger",
592            &[],
593            None,
594            None,
595            Duration::from_millis(100),
596            Some(&slices),
597        )
598        .await
599        .unwrap_err()
600        .to_string();
601        assert!(err.contains("fanout"), "{err}");
602        assert!(err.contains("RFC 05 §2.1"), "{err}");
603
604        // Unconstrained procedures fan out fine (zero replies here — a
605        // non-verdict, not an error).
606        let report = call(
607            &session,
608            "",
609            &CallTarget::Fleet,
610            "netring",
611            "capture/trigger",
612            &[],
613            None,
614            None,
615            Duration::from_millis(100),
616            Some(&slice_with_proc(None)),
617        )
618        .await
619        .unwrap();
620        assert_eq!(report.exit_code(), 2, "silence stays exit 2");
621    }
622}