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::bus::query::FleetAnswer;
28use crate::model::registry::SliceSet;
29use crate::report::{
30    CallAnswer, CallError, CallOutcome, CallReport, ConcurrentLane, HlcReference, TRACE_CHAIN_RULE,
31    TRACE_EXCLUDED, TraceReport,
32};
33
34/// A declared publisher with its QoS profile applied — the only publish path.
35pub struct Publication {
36    publisher: zenoh::pubsub::Publisher<'static>,
37    encoding: Option<String>,
38}
39
40impl std::fmt::Debug for Publication {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("Publication")
43            .field("key", &self.publisher.key_expr().as_str())
44            .finish_non_exhaustive()
45    }
46}
47
48/// Declare a publication on a **full wire key** (explorers are un-namespaced;
49/// compose with `with_base` first).
50///
51/// The profile maps to the wire in one place: reliability, congestion
52/// control, priority, and the express bit (RFC 04 §3 — `alert` and `frame`
53/// are the express profiles; nothing in the workspace ever set it before).
54pub async fn declare_publication(
55    session: &Session,
56    key: &str,
57    qos: QosProfile,
58    encoding: Option<&str>,
59) -> Result<Publication> {
60    let publisher = session
61        .declare_publisher(key.to_string())
62        .reliability(qos.reliability())
63        .congestion_control(qos.congestion_control())
64        .priority(qos.priority())
65        .express(qos.express())
66        .await
67        .map_err(|e| Error::bus("declare publisher", key, e))?;
68    Ok(Publication {
69        publisher,
70        encoding: encoding.map(str::to_string),
71    })
72}
73
74impl Publication {
75    /// Publish one payload, with an optional attachment riding beside it
76    /// (#117 — attachments are outside the registry's vocabulary and are
77    /// never schema-encoded). Sets the wire `Encoding` when one was declared
78    /// (RFC 04 v1.5's recommendation: publishers say what they carry).
79    pub async fn send(&self, payload: Vec<u8>, attachment: Option<Vec<u8>>) -> Result<()> {
80        self.send_stamped(payload, attachment, None).await
81    }
82
83    /// [`send`](Self::send), with an explicit HLC timestamp when the caller
84    /// mints one (`session.new_timestamp()`). `None` leaves stamping to the
85    /// deployment's config — the default put behaviour. The generator uses
86    /// this to stamp state samples for LWW (RFC 04 §4) and, for its
87    /// `unstamped` fault (#163), to deliberately omit the stamp.
88    pub async fn send_stamped(
89        &self,
90        payload: Vec<u8>,
91        attachment: Option<Vec<u8>>,
92        timestamp: Option<zenoh::time::Timestamp>,
93    ) -> Result<()> {
94        let put = self.publisher.put(payload);
95        let put = match &self.encoding {
96            Some(e) => put.encoding(e.as_str()),
97            None => put,
98        };
99        let put = match attachment {
100            Some(a) => put.attachment(a),
101            None => put,
102        };
103        let put = match timestamp {
104            Some(ts) => put.timestamp(ts),
105            None => put,
106        };
107        put.await
108            .map_err(|e| Error::bus("put", self.publisher.key_expr().as_str(), e))
109    }
110
111    /// Publish a tombstone — an authoritative retirement (RFC 04 §1.2),
112    /// never a payload marker. The only delete path: it rides the declared
113    /// publisher, and `Session::delete` stays unexposed for the same reason
114    /// there is no bare-put helper. Gate dynamic keys through
115    /// [`check_retire`] first — the class semantics live there.
116    pub async fn retire(&self) -> Result<()> {
117        self.publisher
118            .delete()
119            .await
120            .map_err(|e| Error::bus("delete", self.publisher.key_expr().as_str(), e))
121    }
122
123    /// Undeclare, acknowledged.
124    pub async fn undeclare(self) -> Result<()> {
125        self.publisher
126            .undeclare()
127            .await
128            .map_err(|e| Error::bus("undeclare publisher", "", e))
129    }
130
131    /// Whether any subscriber currently matches **this publication** — a
132    /// routing fact about the publisher *this process declared* (RFC 12 §9's
133    /// allowed half). It says nothing about other publishers on the key, and
134    /// `false` is not a fleet verdict ("no subscriber matched *our*
135    /// publication", never "nobody listens here" — RFC 05 §3.1 applied to a
136    /// badge).
137    pub async fn matching_status(&self) -> Result<bool> {
138        self.publisher
139            .matching_status()
140            .await
141            .map(|s| s.matching())
142            .map_err(|e| Error::bus("matching status", "", e))
143    }
144
145    /// Event-driven matching changes for this publication — the badge feed.
146    /// Same honesty bounds as [`matching_status`](Self::matching_status).
147    pub async fn matching_events(&self) -> Result<MatchingEvents> {
148        let listener = self
149            .publisher
150            .matching_listener()
151            .await
152            .map_err(|e| Error::bus("matching listener", "", e))?;
153        Ok(MatchingEvents { listener })
154    }
155}
156
157/// What a key is, for the purpose of retiring it — the guard's positive
158/// verdict, so callers print facts instead of re-deriving them.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum RetireClass {
161    /// State-shaped: retirement is the class's own semantics (RFC 04 §1.2).
162    State {
163        /// Whether a loaded registry recognises the subject. An unregistered
164        /// state key still tombstones authoritatively — but no `ttl_s`
165        /// bounds how long the tombstone stays observable.
166        registered: bool,
167        /// The registry's `ttl_s`, when declared: storages keep the
168        /// tombstone observable at least this long (RFC 04 §1.2).
169        ttl_s: Option<i64>,
170    },
171    /// A v1 key off the state class (telemetry/events, or a verbatim
172    /// plane) — retired anyway, as a forced operator cleanup (v1.12).
173    NonState { class: String },
174    /// The grammar could not say what the key is — retired blind, forced.
175    Unclassified { reason: String },
176}
177
178/// Refuse a tombstone the class semantics do not license, unless forced.
179///
180/// The judgment mirrors `bench`'s idempotence guard: the refusal is
181/// grammar- and registry-driven, and the messages cite what they know.
182/// Unlike `bench`, a missing registry does not blind us on the happy path —
183/// the class is written in the key itself, so a state key passes with no
184/// slices loaded. The one unconditional refusal is a wildcard: a tombstone
185/// is addressed to one concrete key (RFC 04 §1.2, v1.12), and no `force`
186/// overrides a blast radius.
187pub fn check_retire(
188    base: &str,
189    key: &str,
190    slices: Option<&SliceSet>,
191    force: bool,
192) -> Result<RetireClass> {
193    if key.contains('*') || key.contains('$') {
194        return Err(Error::unaskable(
195            key,
196            "is a wildcard — a tombstone is addressed to one concrete key; a \
197             wildcard delete is not an operator act, it is a blast radius \
198             (RFC 04 §1.2, v1.12). Not overridable.",
199        ));
200    }
201    let facts = crate::model::facts::describe_key(base, key, slices).facts;
202    use crate::model::facts::{ClassKind, KeyShape, Registration};
203    match &facts.shape {
204        KeyShape::V1(v) if v.class_kind == ClassKind::State => {
205            let (registered, ttl_s) = match &facts.registration {
206                Registration::Registered(s) => (true, s.ttl_s),
207                _ => (false, None),
208            };
209            Ok(RetireClass::State { registered, ttl_s })
210        }
211        KeyShape::V1(v) if matches!(v.class_kind, ClassKind::Telemetry | ClassKind::Events) => {
212            if force {
213                return Ok(RetireClass::NonState {
214                    class: v.class.clone(),
215                });
216            }
217            Err(Error::unaskable(
218                key,
219                format!(
220                    "is {}-shaped — RFC 04 §1: a delete there is meaningless and \
221                     MUST NOT be sent by the class's publisher. Retiring it anyway \
222                     is an operator cleanup (RFC 04 §1.2, v1.12) — pass --i-know \
223                     to mean it.",
224                    v.class
225                ),
226            ))
227        }
228        KeyShape::V1(v) => {
229            if force {
230                return Ok(RetireClass::NonState {
231                    class: v.class.clone(),
232                });
233            }
234            Err(Error::unaskable(
235                key,
236                format!(
237                    "sits on the {} plane — a plane key answers GETs or carries \
238                     frames; a tombstone there is at most a storage purge \
239                     (RFC 04 §1.2, v1.12) — pass --i-know to mean it.",
240                    v.class
241                ),
242            ))
243        }
244        KeyShape::NotUnderBase | KeyShape::Unparsed { .. } => {
245            let reason = match &facts.shape {
246                KeyShape::Unparsed { reason } => reason.clone(),
247                _ => format!("not under base {base:?}"),
248            };
249            if force {
250                return Ok(RetireClass::Unclassified { reason });
251            }
252            Err(Error::unaskable(
253                key,
254                format!(
255                    "cannot be classified under base {base:?} ({reason}) — 'not \
256                     asked' is not 'state' (RFC 09 §5.1 O4); pass --i-know to \
257                     retire an unclassified key."
258                ),
259            ))
260        }
261    }
262}
263
264/// A stream of matching changes for one **self-declared** entity (a
265/// [`Publication`] or a [`crate::RepeatingQuery`]). These are the only two
266/// places a matching claim can be made from — a foreign publisher's consumers
267/// are not observable without publishing on their key, and that half stays
268/// deferred (RFC 12 §9; #38/#80 adoption note).
269pub struct MatchingEvents {
270    listener: zenoh::matching::MatchingListener<
271        zenoh::handlers::FifoChannelHandler<zenoh::matching::MatchingStatus>,
272    >,
273}
274
275impl MatchingEvents {
276    pub(crate) async fn for_querier(querier: &zenoh::query::Querier<'_>) -> Result<Self> {
277        let listener = querier
278            .matching_listener()
279            .await
280            .map_err(|e| Error::bus("matching listener", "", e))?;
281        Ok(MatchingEvents { listener })
282    }
283
284    /// The next change: `Some(true)` = at least one matcher appeared,
285    /// `Some(false)` = the last one left, `None` = the entity was undeclared.
286    pub async fn recv(&self) -> Option<bool> {
287        self.listener.recv_async().await.ok().map(|s| s.matching())
288    }
289
290    /// The same changes as a [`Stream`](futures_core::Stream) (#343).
291    ///
292    /// Borrows, so a caller can keep gating on `recv` elsewhere; the item is
293    /// the same projected `bool` rather than the raw `MatchingStatus`, because
294    /// what a caller acts on is "is anyone there", not the status object.
295    pub fn stream(&self) -> impl futures_core::Stream<Item = bool> + '_ {
296        futures_util::StreamExt::map(self.listener.stream(), |s| s.matching())
297    }
298}
299
300/// Who a call is addressed to. Typed — a fleet call is a deliberate variant,
301/// never a string that happens to contain `*` (RFC 08 §1.1's origin-argument
302/// rule for dynamic callers).
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub enum CallTarget {
305    /// One host, by validated origin id.
306    Host(HostId),
307    /// Every host serving the procedure — requires the RFC 05 §2.1 fan-in
308    /// discipline, which [`call`] applies.
309    Fleet,
310    /// A registered service origin (`@catalog`, …) — no producer chunk.
311    Service(ServiceOrigin),
312}
313
314impl CallTarget {
315    /// Parse a CLI-shaped target: `*` = fleet, `@name` = service, else a host
316    /// origin id (validated — a hostname here is the RFC 06 §6 bridge bug,
317    /// and it fails loudly instead of being string-glued into a key).
318    pub fn parse(s: &str) -> Result<CallTarget> {
319        if s == "*" {
320            return Ok(CallTarget::Fleet);
321        }
322        if s.starts_with('@') {
323            return Ok(CallTarget::Service(ServiceOrigin::new(s)?));
324        }
325        HostId::parse(s).map(CallTarget::Host).map_err(|e| {
326            Error::unaskable(
327                "origin",
328                format!("{e} — a hostname is not an origin; resolve it first (RFC 06 §6)"),
329            )
330        })
331    }
332}
333
334/// A reply attachment, projected for the report: JSON if it parses, UTF-8
335/// text if it decodes, else a size tag. Never schema-decoded — an attachment
336/// is outside the registry's vocabulary (#117) — and deliberately
337/// dependency-free: the report shapes are unconditional while the decode
338/// module is feature-gated.
339fn attachment_value(bytes: &[u8]) -> serde_json::Value {
340    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
341        v
342    } else if let Ok(s) = std::str::from_utf8(bytes) {
343        serde_json::Value::String(s.to_string())
344    } else {
345        serde_json::Value::String(format!("<{} bytes>", bytes.len()))
346    }
347}
348
349/// One procedure call, as a spec rather than nine positional arguments —
350/// the shape [`crate::BenchSpec`] already uses next door, for the same
351/// call.
352///
353/// `body` and `attachment` are owned because they are consumed: they ride
354/// the GET out and nothing reads them again.
355pub struct CallSpec<'a> {
356    pub target: &'a CallTarget,
357    pub producer: &'a str,
358    /// Slash-separated, as the registry spells it (`capture/trigger`).
359    pub procedure: &'a str,
360    /// Selector parameters, joined with `;` onto the key (RFC 05 §1).
361    pub params: &'a [String],
362    /// The request payload, already through the encode ladder.
363    pub body: Option<Vec<u8>>,
364    /// Verbatim, never schema-encoded — an attachment is outside the
365    /// registry's vocabulary (#117).
366    pub attachment: Option<Vec<u8>>,
367    pub timeout: Duration,
368    /// The loaded registry, for the fan-out guard below. `None` = none
369    /// loaded, and the guard says so rather than judging.
370    pub slices: Option<&'a SliceSet>,
371}
372
373/// Call a procedure and report every attributed answer.
374///
375/// - The key composes through the typed builders (never `format!`), lifted to
376///   the wire with the configured base.
377/// - `params` ride the selector (`?k=v;k=v`), the body rides the payload
378///   (RFC 05 §1).
379/// - **Fan-out guard**: a [`CallTarget::Fleet`] call is refused when the
380///   loaded slices declare the procedure `fanout = "forbidden"` — or when a
381///   `kind = "write"` procedure declares nothing, because RFC 08 §2 defaults
382///   a write to forbidden and introspect serves the TOML verbatim. With no
383///   slices loaded the registry layer cannot judge — the call proceeds, and
384///   the builder/ACL layers remain (documented, not silent: the report's key
385///   is the caller's audit trail).
386/// - Exit-code semantics stay on [`CallReport::exit_code`]: an error reply is
387///   a failure, zero replies stay a distinct non-verdict (RFC 05 §3.1).
388pub async fn call(fleet: &crate::Fleet<'_>, spec: CallSpec<'_>) -> Result<CallReport> {
389    let (key, timeout, answers) = call_answers(fleet, spec).await?;
390    Ok(project_call(key, timeout, &answers))
391}
392
393/// The GET half of [`call`]: the guard, the key, the fan-in — and the raw
394/// [`FleetAnswer`]s, which carry what the [`CallReport`] projection drops
395/// (the reply's HLC, for one). [`call_traced`] needs those; [`call`] does
396/// not, so the split keeps the report shape untouched.
397async fn call_answers(
398    fleet: &crate::Fleet<'_>,
399    spec: CallSpec<'_>,
400) -> Result<(String, Duration, Vec<FleetAnswer>)> {
401    let CallSpec {
402        target,
403        producer,
404        procedure,
405        params,
406        body,
407        attachment,
408        timeout,
409        slices,
410    } = spec;
411    if matches!(target, CallTarget::Fleet)
412        && let Some(slices) = slices
413        && let Some(slice) = slices.get(producer)
414        && let Some(proc_decl) = slice.procedures.iter().find(|p| p.path == procedure)
415    {
416        // Introspect serves the TOML verbatim, so an omitted `fanout` reaches
417        // this layer as `None` — and RFC 08 §2 *defaults* a `kind = "write"`
418        // procedure to forbidden. The default has to be applied here, or a
419        // dynamic caller fans out a write the generated builders refuse to
420        // spell.
421        let forbidden = match proc_decl.fanout.as_ref().and_then(Declared::known) {
422            Some(Fanout::Forbidden) => true,
423            Some(Fanout::Allowed) => false,
424            // An unrecognised token is not a licence: RFC 08 §2 defaults a
425            // `write` to forbidden, and a `fanout` spelling this build cannot
426            // read is exactly the case where guessing "allowed" would fan out
427            // a write the generated builders refuse to spell.
428            None => matches!(
429                proc_decl.kind.as_ref().and_then(Declared::known),
430                Some(ProcedureKind::Write)
431            ),
432        };
433        if forbidden {
434            // Three cases, because the guard above has three. Reading
435            // `fanout.is_some()` folded the middle one into the first and
436            // told the operator the slice "declares fanout = \"forbidden\""
437            // when it declared something this build cannot read — a claim
438            // that sends them grepping the registry for a string that is
439            // not in it.
440            let declared = match proc_decl.fanout.as_ref() {
441                Some(f) if f.is(&Fanout::Forbidden) => {
442                    "declares fanout = \"forbidden\"".to_string()
443                }
444                Some(f) => format!(
445                    "declares fanout = {:?}, a token this build does not know — \
446                     RFC 08 §2 defaults a write to forbidden and an unreadable \
447                     spelling is not a licence",
448                    f.token()
449                ),
450                None => "is a write with no declared fanout, which defaults to forbidden \
451                         (RFC 08 §2)"
452                    .to_string(),
453            };
454            return Err(Error::unaskable(
455                format!("procedure {producer}/{procedure}"),
456                format!(
457                    "{declared} — a fleet (`*`) call to it is refused \
458                     (RFC 05 §2.1); name one origin"
459                ),
460            ));
461        }
462    }
463
464    let segments: Vec<&str> = procedure.split('/').collect();
465    let relative = match target {
466        CallTarget::Host(id) => {
467            let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
468            zenkey::selector::rpc_at(&origin, producer, &segments).to_string()
469        }
470        CallTarget::Fleet => zenkey::selector::fleet_rpc(producer, &segments).to_string(),
471        CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
472    };
473    let mut key = fleet.wire(relative);
474    if !params.is_empty() {
475        key.push('?');
476        key.push_str(&params.join(";"));
477    }
478
479    let answers = crate::bus::query::fleet_get(
480        fleet,
481        &key,
482        &crate::bus::query::GetOpts::new(timeout)
483            .payload(body)
484            .attachment(attachment),
485    )
486    .await?;
487    Ok((key, timeout, answers))
488}
489
490/// The [`CallReport`] projection of a fan-in's answers.
491fn project_call(key: String, timeout: Duration, answers: &[FleetAnswer]) -> CallReport {
492    CallReport {
493        key,
494        // The wait is part of the claim (R5): a silent call must be readable
495        // against how long it listened.
496        timeout_s: timeout.as_secs_f64(),
497        answers: answers
498            .iter()
499            .map(|a| {
500                // The reply attachment used to be visible at the fleet_get
501                // layer and dropped at this projection (#126) — carried now,
502                // present only when the wire carried one.
503                let (att, att_bytes) = match &a.attachment {
504                    Some(z) => {
505                        let bytes = z.to_bytes();
506                        (Some(attachment_value(&bytes)), Some(bytes.len()))
507                    }
508                    None => (None, None),
509                };
510                let outcome = match &a.answer {
511                    crate::bus::query::Answer::Value(bytes) => {
512                        let bytes = bytes.to_bytes();
513                        match serde_json::from_slice::<serde_json::Value>(&bytes) {
514                            Ok(v) => CallOutcome::Ok {
515                                value: Some(v),
516                                text: None,
517                            },
518                            Err(_) => CallOutcome::Ok {
519                                value: None,
520                                text: Some(String::from_utf8_lossy(&bytes).to_string()),
521                            },
522                        }
523                    }
524                    crate::bus::query::Answer::Error { name, message } => {
525                        CallOutcome::Err(CallError {
526                            name: name.clone(),
527                            message: message.clone(),
528                        })
529                    }
530                };
531                CallAnswer {
532                    origin: a.origin.clone(),
533                    outcome,
534                    attachment: att,
535                    attachment_bytes: att_bytes,
536                }
537            })
538            .collect(),
539    }
540}
541
542/// How long to hold the window after a traced call (#215).
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub struct TraceSpec {
545    /// The passive window: how long to keep listening on the called origin
546    /// after the GET has returned. The attribution reads the same
547    /// [`CallSpec::slices`] the fan-out guard does — one registry per call,
548    /// so the guard and the chain cannot be judged against two.
549    pub window: Duration,
550}
551
552/// The broadcast bound of the origin watch: a single origin's data classes
553/// for one window, sized so that a producer's own burst after a write does
554/// not lag the drain — a drop here is a break in the *attributed* lane.
555const ORIGIN_CAPACITY: usize = 4096;
556/// The bound of the fleet-wide watch behind the concurrent lane. Its drops
557/// are counted on that lane alone; they never become breaks in the origin's.
558const FLEET_CAPACITY: usize = 8192;
559
560/// [`call`], with a window held open on the called origin **before, during
561/// and after** the GET, and everything seen there reported beside the reply
562/// (#215; RFC 05 §3's long-running idiom is a declared chain, and this is
563/// the observation of one).
564///
565/// The order is normative and the report pins it
566/// ([`TraceReport::subscribed_before_call`]): the watches are declared
567/// *first*, `t0` is taken, then the call goes out exactly as [`call`] would
568/// send it (fan-out guard included), then the window is held draining the
569/// monitors. A window opened after the call would convert "not asked" into
570/// "no" (RFC 09 §5.1 O4) for anything the producer published between the
571/// reply and the subscription.
572///
573/// Two watches, two monitors: `<base>/v1/<origin>/**` feeds the attributed
574/// and same-origin lanes, and `<base>/v1/**` feeds the concurrent count.
575/// Two rather than one so that a busy fleet's broadcast lag lands on the
576/// concurrent lane's own `dropped` and never as a break in the origin's
577/// lanes — the origin's samples reach both subscribers, and the fleet
578/// monitor ignores them. `**` never crosses an `@`-chunk (RFC 03 §4 D2), so
579/// the `@blob` bytes of the idiom's step 4 are outside both windows — stated
580/// in the report, deliberately not widened.
581///
582/// Refused: a [`CallTarget::Fleet`] target. A trace attributes effects to
583/// *one* origin; a fan-out has none to attribute to.
584pub async fn call_traced(
585    fleet: &crate::Fleet<'_>,
586    spec: CallSpec<'_>,
587    trace: TraceSpec,
588) -> Result<TraceReport> {
589    use crate::bus::monitor::{FleetEvent, Monitor, MonitorSpec, StreamItem};
590    use crate::model::examples::Examples;
591    use crate::model::facts::describe_key;
592    use crate::model::timeline::TimelineRow;
593    use crate::model::trace::{TraceTarget, idiom_of, trace_row};
594    use zenkey::selector::{Scope, all_under};
595
596    let (scope, producer) = match spec.target {
597        CallTarget::Fleet => {
598            return Err(Error::unaskable(
599                "--trace",
600                "a trace attributes what it sees to one origin, and a fleet (`*`) call \
601                 has none to attribute to — name one origin",
602            ));
603        }
604        CallTarget::Host(id) => (
605            Scope::origin(&zenkey::origin::RemoteOrigin::from_host(id.clone())),
606            Some(spec.producer.to_string()),
607        ),
608        CallTarget::Service(o) => (Scope::origin(o), None),
609    };
610    let origin = scope.chunk().to_string();
611    let slices = spec.slices;
612    let idiom = idiom_of(
613        slices
614            .and_then(|s| s.get(spec.producer))
615            .and_then(|s| s.procedures.iter().find(|p| p.path == spec.procedure)),
616    );
617    let target = TraceTarget {
618        origin: origin.clone(),
619        producer,
620        chain_chunk: spec
621            .procedure
622            .split('/')
623            .next()
624            .unwrap_or_default()
625            .to_string(),
626        registry_loaded: slices.is_some(),
627    };
628    let base = fleet.base();
629
630    // 1. Subscribe first. The origin's subtree through the typed scope, and
631    //    the fleet's through the same builder at fleet scope — never a
632    //    hand-glued `format!`.
633    let origin_scope = fleet.wire(all_under(scope));
634    let fleet_scope = fleet.wire(all_under(Scope::fleet()));
635    let session = fleet.session();
636    let origin_monitor = Monitor::start(
637        session,
638        MonitorSpec {
639            capacity: ORIGIN_CAPACITY,
640            ..MonitorSpec::default()
641        },
642    )
643    .await?;
644    let mut origin_events = origin_monitor.events();
645    origin_monitor.watch(&origin_scope).await?;
646    let fleet_monitor = Monitor::start(
647        session,
648        MonitorSpec {
649            capacity: FLEET_CAPACITY,
650            ..MonitorSpec::default()
651        },
652    )
653    .await?;
654    let mut fleet_events = fleet_monitor.events();
655    fleet_monitor.watch(&fleet_scope).await?;
656
657    // 2. t0, then the call — the guard, the key and the fan-in are `call`'s.
658    let t0 = std::time::Instant::now();
659    let t0_unix_s = std::time::SystemTime::now()
660        .duration_since(std::time::UNIX_EPOCH)
661        .map(|d| d.as_secs_f64())
662        .unwrap_or(0.0);
663    let (key, timeout, answers) = call_answers(fleet, spec).await?;
664    let call_returned_ms = t0.elapsed().as_secs_f64() * 1_000.0;
665    let reply_hlc = answers.iter().find_map(|a| a.timestamp);
666    let call = project_call(key, timeout, &answers);
667
668    // 3. Hold the window. Every sample becomes a timeline row (its clocks
669    //    and provenance are the timeline's), then a relation, then a lane.
670    let mut attributed = Vec::new();
671    let mut same_origin = Vec::new();
672    let mut pending_attributed = 0u64;
673    let mut pending_same_origin = 0u64;
674    let mut dropped = 0u64;
675    let mut concurrent_samples = 0u64;
676    let mut concurrent_dropped = 0u64;
677    let mut concurrent_keys = std::collections::HashSet::new();
678    let mut concurrent_examples = Examples::new(crate::judge::common::EXPANSION_CAP);
679    let reply_ntp64 = reply_hlc.map(|t| t.get_time().as_u64());
680    let deadline = tokio::time::sleep(trace.window);
681    tokio::pin!(deadline);
682    let mut origin_open = true;
683    let mut fleet_open = true;
684    while origin_open || fleet_open {
685        tokio::select! {
686            () = &mut deadline => break,
687            item = origin_events.recv(), if origin_open => match item {
688                None => origin_open = false,
689                Some(StreamItem::Dropped(n)) => {
690                    dropped += n;
691                    pending_attributed += n;
692                    pending_same_origin += n;
693                }
694                Some(StreamItem::Event(FleetEvent::Sample(view))) => {
695                    let desc = describe_key(base, &view.key, slices);
696                    let Some(relation) = target.relation_of(&desc) else {
697                        // The origin watch is the origin's subtree; anything
698                        // else here is a key the grammar could not place,
699                        // and it belongs to the concurrent count.
700                        continue;
701                    };
702                    let row = TimelineRow::from_view(&view, t0, base);
703                    let (lane, pending) = match relation {
704                        crate::report::TraceRelation::DeclaredChain =>
705                            (&mut attributed, &mut pending_attributed),
706                        _ => (&mut same_origin, &mut pending_same_origin),
707                    };
708                    let break_before = (*pending > 0).then_some(*pending);
709                    *pending = 0;
710                    lane.push(trace_row(&row, relation, reply_ntp64, break_before));
711                }
712                Some(StreamItem::Event(_)) => {}
713            },
714            item = fleet_events.recv(), if fleet_open => match item {
715                None => fleet_open = false,
716                Some(StreamItem::Dropped(n)) => concurrent_dropped += n,
717                Some(StreamItem::Event(FleetEvent::Sample(view))) => {
718                    let desc = describe_key(base, &view.key, None);
719                    if target.relation_of(&desc).is_some() {
720                        // The called origin's own sample, seen a second time
721                        // through the wider watch: the origin lanes have it.
722                        continue;
723                    }
724                    concurrent_samples += 1;
725                    if concurrent_keys.insert(view.key.clone()) {
726                        concurrent_examples.push_with(|| view.key.clone());
727                    }
728                }
729                Some(StreamItem::Event(_)) => {}
730            },
731        }
732    }
733    let keys_evicted = origin_monitor.core().keys_evicted();
734    origin_monitor.stop();
735    fleet_monitor.stop();
736
737    Ok(TraceReport {
738        call,
739        scopes: vec![origin_scope, fleet_scope],
740        excluded: TRACE_EXCLUDED,
741        window_s: trace.window.as_secs_f64(),
742        subscribed_before_call: true,
743        t0_unix_s,
744        call_returned_ms,
745        hlc_reference: if reply_hlc.is_some() {
746            HlcReference::Reply
747        } else {
748            HlcReference::None
749        },
750        reply_hlc: reply_hlc.map(|t| t.to_string()),
751        chain_rule: TRACE_CHAIN_RULE,
752        registry_loaded: slices.is_some(),
753        idiom,
754        attributed,
755        same_origin,
756        concurrent: ConcurrentLane {
757            samples: concurrent_samples,
758            keys: concurrent_keys.len() as u64,
759            examples: concurrent_examples.into_vec(),
760            dropped: concurrent_dropped,
761        },
762        dropped,
763        keys_evicted,
764    })
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770    use zenkey::slice::{ProcedureDecl, RegistrySlice, SubjectDecl};
771
772    fn slice_with_state_subject() -> SliceSet {
773        let mut health = SubjectDecl::new("health", zenkey::Class::State);
774        health.type_name = "Health".into();
775        health.ttl_s = Some(900);
776        let mut slice = RegistrySlice::new("1.0", "t", "sysinfo");
777        slice.subjects = vec![health];
778        SliceSet::from_slices(vec![slice])
779    }
780
781    /// The five outcomes of the retire guard (RFC 04 §1.2, v1.12), each
782    /// citing what it knows.
783    #[test]
784    fn a_wildcard_retire_is_refused_unconditionally() {
785        for force in [false, true] {
786            let err = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/**", None, force)
787                .unwrap_err()
788                .to_string();
789            assert!(err.contains("blast radius"), "{err}");
790        }
791    }
792
793    #[test]
794    fn a_state_key_retires_without_a_registry() {
795        // The class is written in the key itself — unlike bench's
796        // idempotence, a missing registry does not blind the guard.
797        let got = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/health", None, false).unwrap();
798        assert_eq!(
799            got,
800            RetireClass::State {
801                registered: false,
802                ttl_s: None
803            }
804        );
805        // With the registry loaded, the tombstone-visibility bound rides out.
806        let slices = slice_with_state_subject();
807        let got = check_retire(
808            "",
809            "v1/h-3fa9c2d41b7e/state/sysinfo/health",
810            Some(&slices),
811            false,
812        )
813        .unwrap();
814        assert_eq!(
815            got,
816            RetireClass::State {
817                registered: true,
818                ttl_s: Some(900)
819            }
820        );
821    }
822
823    #[test]
824    fn a_telemetry_retire_needs_i_know_and_cites_the_rfc() {
825        let key = "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage";
826        let err = check_retire("", key, None, false).unwrap_err().to_string();
827        assert!(err.contains("MUST NOT"), "{err}");
828        assert!(err.contains("v1.12"), "{err}");
829        assert!(err.contains("--i-know"), "{err}");
830        assert_eq!(
831            check_retire("", key, None, true).unwrap(),
832            RetireClass::NonState {
833                class: "telemetry".to_string()
834            }
835        );
836    }
837
838    #[test]
839    fn a_plane_retire_needs_i_know_too() {
840        let key = "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect";
841        let err = check_retire("", key, None, false).unwrap_err().to_string();
842        assert!(err.contains("plane"), "{err}");
843        assert!(matches!(
844            check_retire("", key, None, true).unwrap(),
845            RetireClass::NonState { class } if class == "@rpc"
846        ));
847    }
848
849    #[test]
850    fn an_unclassified_retire_needs_i_know_and_names_o4() {
851        // A foreign key under an empty base parses as... nothing v1.
852        let err = check_retire("", "some/foreign/key", None, false)
853            .unwrap_err()
854            .to_string();
855        assert!(err.contains("O4"), "{err}");
856        assert!(matches!(
857            check_retire("", "some/foreign/key", None, true).unwrap(),
858            RetireClass::Unclassified { .. }
859        ));
860        // And a key under another base is unclassified, not misclassified.
861        let err = check_retire("acme", "other/v1/h-3fa9c2d41b7e/state/x/y", None, false)
862            .unwrap_err()
863            .to_string();
864        assert!(err.contains("cannot be classified"), "{err}");
865    }
866
867    fn slice_with_proc(kind: &str, fanout: Option<&str>) -> SliceSet {
868        let mut trigger = ProcedureDecl::new("capture/trigger");
869        trigger.kind = Some(Declared::parse(kind));
870        trigger.reply = Some("Ack".into());
871        trigger.fanout = fanout.map(Declared::parse);
872        trigger.idempotent = Some(false);
873        let mut slice = RegistrySlice::new("1.0", "t", "netring");
874        slice.procedures = vec![trigger];
875        SliceSet::from_slices(vec![slice])
876    }
877
878    #[test]
879    fn call_targets_parse_and_validate() {
880        assert_eq!(CallTarget::parse("*").unwrap(), CallTarget::Fleet);
881        assert!(matches!(
882            CallTarget::parse("@catalog").unwrap(),
883            CallTarget::Service(_)
884        ));
885        assert!(matches!(
886            CallTarget::parse("h-3fa9c2d41b7e").unwrap(),
887            CallTarget::Host(_)
888        ));
889        // The RFC 06 §6 bridge bug fails loudly, with the pointer.
890        let err = CallTarget::parse("toolbx").unwrap_err().to_string();
891        assert!(err.contains("RFC 06 §6"), "{err}");
892    }
893
894    /// The registry layer of the three-layer refusal: a fleet call to a
895    /// declared forbidden-fanout write never leaves the process.
896    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
897    async fn fleet_calls_to_forbidden_fanout_are_refused() {
898        let session = crate::bus::session::open(&[], &[], false).await.unwrap();
899        let slices = slice_with_proc("write", Some("forbidden"));
900        let err = call(
901            &crate::Fleet::new(&session, ""),
902            CallSpec {
903                target: &CallTarget::Fleet,
904                producer: "netring",
905                procedure: "capture/trigger",
906                params: &[],
907                body: None,
908                attachment: None,
909                timeout: Duration::from_millis(100),
910                slices: Some(&slices),
911            },
912        )
913        .await
914        .unwrap_err()
915        .to_string();
916        assert!(err.contains("fanout"), "{err}");
917        assert!(err.contains("RFC 05 §2.1"), "{err}");
918
919        // A write whose TOML *omits* fanout is refused the same way: RFC 08
920        // §2 defaults `kind = "write"` to forbidden, and introspect serves
921        // the TOML verbatim — the default is this guard's to apply.
922        let err = call(
923            &crate::Fleet::new(&session, ""),
924            CallSpec {
925                target: &CallTarget::Fleet,
926                producer: "netring",
927                procedure: "capture/trigger",
928                params: &[],
929                body: None,
930                attachment: None,
931                timeout: Duration::from_millis(100),
932                slices: Some(&slice_with_proc("write", None)),
933            },
934        )
935        .await
936        .unwrap_err()
937        .to_string();
938        assert!(err.contains("defaults to forbidden"), "{err}");
939        assert!(err.contains("RFC 08 §2"), "{err}");
940        assert!(err.contains("RFC 05 §2.1"), "{err}");
941
942        // A token this build cannot read is refused too — and the refusal
943        // says which token, rather than claiming the slice declared
944        // "forbidden" and sending the operator to grep for a string that is
945        // not in their registry.
946        let err = call(
947            &crate::Fleet::new(&session, ""),
948            CallSpec {
949                target: &CallTarget::Fleet,
950                producer: "netring",
951                procedure: "capture/trigger",
952                params: &[],
953                body: None,
954                attachment: None,
955                timeout: Duration::from_millis(100),
956                slices: Some(&slice_with_proc("write", Some("per-iface"))),
957            },
958        )
959        .await
960        .unwrap_err()
961        .to_string();
962        assert!(err.contains("per-iface"), "{err}");
963        assert!(err.contains("does not know"), "{err}");
964        assert!(
965            !err.contains("declares fanout = \"forbidden\""),
966            "the slice declared no such thing: {err}"
967        );
968        assert!(err.contains("RFC 05 §2.1"), "{err}");
969
970        // An explicit `fanout = "allowed"` write still fans out, and a read
971        // with nothing declared keeps its allowed default (zero replies here
972        // — a non-verdict, not an error).
973        for slices in [
974            slice_with_proc("write", Some("allowed")),
975            slice_with_proc("read", None),
976        ] {
977            let report = call(
978                &crate::Fleet::new(&session, ""),
979                CallSpec {
980                    target: &CallTarget::Fleet,
981                    producer: "netring",
982                    procedure: "capture/trigger",
983                    params: &[],
984                    body: None,
985                    attachment: None,
986                    timeout: Duration::from_millis(100),
987                    slices: Some(&slices),
988                },
989            )
990            .await
991            .unwrap();
992            assert_eq!(report.exit_code(), 2, "silence stays exit 2");
993        }
994    }
995}