Skip to main content

zenkey_fleet/model/
decode.rs

1//! The schema-aware decode seam (issues #11/#15): wire key → type name →
2//! served schema → named-field JSON, with the honest fallbacks a generic
3//! tool owes its user.
4//!
5//! [`SchemaStore`] caches each producer's served `describe` reply (RFC 08
6//! §7) and fetches on first miss through a declared
7//! [`crate::bus::query::RepeatingQuery`] (the RFC 05 §2.1 discipline, kept warm
8//! across the negative-TTL re-asks — #37). [`decode_sample`] is the whole
9//! pipeline in one call; encoding resolution is **sample > registry > sniff**
10//! and the sniff never goes away.
11
12use std::sync::Mutex;
13use std::sync::atomic::Ordering;
14use std::time::Duration;
15
16use crate::model::bounded::BoundedLru;
17
18use crate::Result;
19use zenkey::schema::decode::{DecodeError, DecodedPayload, DecoderRegistry};
20use zenkey::schema::validate::{NotValidated, Verdict};
21use zenkey::schema::{SchemaSet, TypeSchema, WireEncoding};
22use zenoh::Session;
23
24use crate::model::registry::SliceSet;
25use crate::report::{DriftVerdict, SchemaDrift, SchemaServer, TotalityGap};
26
27/// How many producers one store remembers anything about (#340).
28///
29/// The keys these maps are built from come off the wire —
30/// `parse_full(base, key)` over whatever traffic an explorer happens to
31/// watch — not from a trusted enumeration, so "a fleet's producer set is
32/// small" is an assumption about well-behaved traffic and not a bound. 1024
33/// is far past any fleet the reference application has, and far short of
34/// what a runaway key family could mint in an overnight session.
35pub const DEFAULT_MAX_PRODUCERS: usize = 1_024;
36
37/// What one store's bounds have cost, as of one read (#340, RFC 13 §3 O6).
38///
39/// Three numbers, not one, because they are three different facts and only
40/// the first hides anything: an evicted **set** is a schema the next sample
41/// of that producer must re-ask for; an evicted **querier** is routing state
42/// that gets re-declared; an evicted **gate** is at worst one duplicate GET.
43/// Folding them would report a re-declared querier as lost knowledge.
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub struct StoreBounds {
46    /// The producer bound in force.
47    pub max_producers: usize,
48    /// Producers currently answered-for — the length of [`SchemaStore::known`].
49    pub producers: usize,
50    /// Cached `describe` answers dropped under the bound. Non-zero means a
51    /// decode may re-ask for something this store had already learned.
52    pub sets_evicted: u64,
53    /// Declared queriers dropped under the bound.
54    pub queriers_evicted: u64,
55    /// Single-flight gates dropped under the bound.
56    pub gates_evicted: u64,
57}
58
59/// One entry with the recency `BoundedLru` orders by.
60///
61/// A monotone counter rather than a clock, exactly as
62/// [`FactsCache`](crate::model::facts::FactsCache) does it: these entries have
63/// no timestamp of their own, and "least recently *used*" is the property
64/// that matters — a producer being decoded right now must outlive one seen
65/// once an hour ago.
66#[derive(Debug)]
67struct Entry<V> {
68    value: V,
69    seen: u64,
70}
71
72/// Per-producer schema sets, fetched lazily and cached for the process.
73///
74/// **Bounded** (#340). All three maps are keyed by a producer name lifted out
75/// of arbitrary bus traffic, so all three are `BoundedLru` at
76/// [`DEFAULT_MAX_PRODUCERS`], and each keeps its own eviction count —
77/// [`SchemaStore::bounds`], beside [`SchemaStore::known`].
78pub struct SchemaStore {
79    base: String,
80    timeout: Duration,
81    /// producer → what we know about its `describe` (see [`Cached`]).
82    ///
83    /// A served set is behind an `Arc` because it is read **per sample**:
84    /// handing out a deep clone of every type's document to answer "what is
85    /// the schema for this one type" was the other half of issue #100's cost,
86    /// and the quieter half — a descriptor pool rebuild at least looks
87    /// expensive.
88    sets: Mutex<BoundedLru<String, Entry<Cached>>>,
89    /// One declared querier per producer's describe key (#37), reused across
90    /// the negative-TTL re-asks.
91    queriers: Mutex<BoundedLru<String, Entry<std::sync::Arc<crate::bus::query::RepeatingQuery>>>>,
92    /// One in-flight `describe` per producer. A hot bus misses on many
93    /// samples of the same producer at once — the first sample's GET is
94    /// still on the wire when the second arrives — and the store used to
95    /// fan one GET per miss at a producer that had been asked microseconds
96    /// earlier. The losers wait on the winner's gate and then read its
97    /// answer out of `sets`, so the fleet sees exactly one ask.
98    inflight: Mutex<BoundedLru<String, Entry<std::sync::Arc<tokio::sync::Mutex<()>>>>>,
99    /// The recency clock all three maps order by, and their three ledgers.
100    clock: std::sync::atomic::AtomicU64,
101    sets_evicted: std::sync::atomic::AtomicU64,
102    queriers_evicted: std::sync::atomic::AtomicU64,
103    gates_evicted: std::sync::atomic::AtomicU64,
104    /// Behind a lock because registration is a `&self` act: the store is
105    /// shared through an `Arc` by every frontend that has one, and a
106    /// `&mut self` setter on it is unreachable by construction. Read-locked
107    /// per decode, which is the same order of cost as the `sets` lookup that
108    /// preceded it.
109    decoders: std::sync::RwLock<DecoderRegistry>,
110    /// While set, a **decode** answers from the cache or not at all — see
111    /// [`SchemaStore::seal`] (#337).
112    sealed: std::sync::atomic::AtomicBool,
113}
114
115/// A sealed store, for as long as this guard lives ([`SchemaStore::seal`]).
116///
117/// A guard rather than a pair of calls because every judging window has
118/// `?`-shaped ways out, and a store left sealed by an early return would
119/// answer `NoSchema` for the rest of the process.
120pub struct Sealed<'a> {
121    store: &'a SchemaStore,
122}
123
124impl Drop for Sealed<'_> {
125    fn drop(&mut self) {
126        self.store
127            .sealed
128            .store(false, std::sync::atomic::Ordering::Release);
129    }
130}
131
132/// How long "asked, and answered with nothing usable" stays authoritative
133/// before re-asking. A producer that genuinely serves no `describe` must not
134/// be re-asked per sample, and 60s is the bound for that.
135const NOT_SERVED_TTL: Duration = Duration::from_secs(60);
136
137/// The first backoff after a GET that drew **zero replies** (issue #101).
138///
139/// Zero replies is the RFC 05 §3.1 non-verdict this codebase refuses to treat
140/// as an answer anywhere else, and it is what an explorer started before its
141/// fleet sees. Doubling from here, capped at [`NOT_SERVED_TTL`], means a
142/// routing race resolves in well under a second while a producer that is
143/// simply absent still converges on the same 60s bound.
144const NO_REPLY_BACKOFF: Duration = Duration::from_millis(250);
145
146/// Why a producer has no cached set, which decides how soon we re-ask.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148enum MissReason {
149    /// The GET returned no replies at all. Nobody said anything — including
150    /// "no". Could be a producer that does not exist, or a connector whose
151    /// GET went out before the producer's queryable was routable.
152    NoReplies,
153    /// Somebody replied, and nothing in the replies parsed as a `SchemaSet`.
154    /// That *is* an answer about this producer, and it earns the full TTL.
155    AnsweredUnusable,
156}
157
158/// A producer we asked and got nothing usable from.
159#[derive(Debug, Clone, Copy)]
160struct Missing {
161    reason: MissReason,
162    asked: std::time::Instant,
163    /// Consecutive zero-reply asks, driving the backoff.
164    attempts: u32,
165}
166
167impl Missing {
168    /// How long this miss stays authoritative before the next ask.
169    fn backoff(&self) -> Duration {
170        match self.reason {
171            MissReason::AnsweredUnusable => NOT_SERVED_TTL,
172            MissReason::NoReplies => NO_REPLY_BACKOFF
173                .saturating_mul(1u32 << self.attempts.saturating_sub(1).min(16))
174                .min(NOT_SERVED_TTL),
175        }
176    }
177
178    fn may_reask(&self) -> bool {
179        self.asked.elapsed() >= self.backoff()
180    }
181}
182
183/// What the store knows about one producer's `describe`.
184enum Cached {
185    Served(std::sync::Arc<SchemaSet>),
186    Missing(Missing),
187}
188
189/// What the cached state answers on its own, before any GET.
190enum Lookup {
191    /// The cache is authoritative: the served set, or `None` for a miss
192    /// still inside its backoff.
193    Answered(Option<std::sync::Arc<SchemaSet>>),
194    /// Nothing authoritative — ask, carrying this many consecutive
195    /// zero-reply asks into the backoff.
196    Ask(u32),
197}
198
199/// What one `describe` GET produced — the distinction issue #101 exists for.
200enum Fetched {
201    Served(SchemaSet),
202    NoReplies,
203    AnsweredUnusable,
204}
205
206impl SchemaStore {
207    pub fn new(base: impl Into<String>, timeout: Duration) -> Self {
208        SchemaStore::bounded(base, timeout, DEFAULT_MAX_PRODUCERS)
209    }
210
211    /// A store that remembers at most `max_producers` producers (#340).
212    pub fn bounded(base: impl Into<String>, timeout: Duration, max_producers: usize) -> Self {
213        SchemaStore {
214            base: base.into(),
215            timeout,
216            sets: Mutex::new(BoundedLru::with_capacity(max_producers)),
217            queriers: Mutex::new(BoundedLru::with_capacity(max_producers)),
218            inflight: Mutex::new(BoundedLru::with_capacity(max_producers)),
219            decoders: std::sync::RwLock::new(DecoderRegistry::new()),
220            sealed: std::sync::atomic::AtomicBool::new(false),
221            clock: std::sync::atomic::AtomicU64::new(0),
222            sets_evicted: std::sync::atomic::AtomicU64::new(0),
223            queriers_evicted: std::sync::atomic::AtomicU64::new(0),
224            gates_evicted: std::sync::atomic::AtomicU64::new(0),
225        }
226    }
227
228    /// The next recency stamp. Monotone and shared by all three maps: they
229    /// are three views of the same producer set, and ordering them on one
230    /// clock keeps "least recently used" meaning the same thing in each.
231    fn tick(&self) -> u64 {
232        self.clock.fetch_add(1, Ordering::Relaxed)
233    }
234
235    /// What the bounds hold and what they have cost (#340, RFC 13 §3 O6).
236    ///
237    /// Read it beside [`known`](Self::known): that says what the store can
238    /// answer for, this says what it stopped being able to answer for.
239    pub fn bounds(&self) -> StoreBounds {
240        let sets = self.sets.lock().expect("store lock");
241        StoreBounds {
242            max_producers: sets.max_keys(),
243            producers: sets.len(),
244            sets_evicted: self.sets_evicted.load(Ordering::Relaxed),
245            queriers_evicted: self.queriers_evicted.load(Ordering::Relaxed),
246            gates_evicted: self.gates_evicted.load(Ordering::Relaxed),
247        }
248    }
249
250    /// Stop **decodes** from going to the bus until the guard drops (#337).
251    ///
252    /// A judging window's drain loop calls [`decode_sample`] per sample, and
253    /// on a cache miss that used to be a `describe` GET, awaited inside the
254    /// loop, bounded by this store's timeout. Nobody drains the monitor's
255    /// bounded broadcast while it is in flight, so the window loses samples
256    /// to its own decode — and loses them twice over, because the window's
257    /// deadline does not extend to cover the wait. Self-inflicted
258    /// `Dropped(n)` in the one place where the whole product is a verdict
259    /// about a window (RFC 13 §3 O6).
260    ///
261    /// Sealed, a miss is simply a miss: [`set_for`](Self::set_for) answers
262    /// from the cache or returns `None`, which reads through as
263    /// `NotValidated(NoSchema)` — "asked, none served" — and records nothing,
264    /// because a seal is a fact about the observer, not about the producer.
265    ///
266    /// It does **not** stop the store talking to the fleet: [`prewarm`] still
267    /// asks. That is the distinction — a deliberate ask, made where the
268    /// caller has decided it is safe to wait, is fine; an incidental one from
269    /// inside a drain loop is not.
270    pub fn seal(&self) -> Sealed<'_> {
271        self.sealed
272            .store(true, std::sync::atomic::Ordering::Release);
273        Sealed { store: self }
274    }
275
276    /// Register a custom kind's codec (RFC 08 §7 is open to kinds beyond the
277    /// built-ins; later registrations win on conflict).
278    ///
279    /// Takes `&self`, unlike the `decoders_mut` it replaces: every frontend
280    /// shares one store through an `Arc`, so a `&mut self` setter could only
281    /// be called before the store was shared — which is to say, not by the
282    /// code that has the store.
283    pub fn register_decoder(&self, decoder: Box<dyn zenkey::schema::decode::PayloadDecoder>) {
284        self.decoders
285            .write()
286            .expect("decoder lock")
287            .register(decoder);
288    }
289
290    /// Pre-warm one producer's served set with a `describe` reply the caller
291    /// already holds (RFC 08 §7).
292    ///
293    /// The doctor fetches every producer's describe document in its GET
294    /// phase and then opens a listen window; without this the window's store
295    /// starts empty and re-asks the fleet, mid-window, for documents the
296    /// same run already has — load this tool put on the fleet for nothing.
297    ///
298    /// Authoritative, not a hint: it overwrites whatever the store held,
299    /// including a negative entry still inside its backoff.
300    pub fn insert(&self, producer: impl Into<String>, set: SchemaSet) {
301        self.remember(producer.into(), Cached::Served(std::sync::Arc::new(set)));
302    }
303
304    /// Put one producer's cache entry in, under the bound, counting what the
305    /// bound refused (#340).
306    fn remember(&self, producer: String, cached: Cached) {
307        let seen = self.tick();
308        let mut sets = self.sets.lock().expect("store lock");
309        // Only a *new* producer needs room made: overwriting one that is
310        // already held does not grow the map, and evicting for it would drop
311        // a stranger's entry to make space that was never needed.
312        if sets.get(producer.as_str()).is_none() {
313            let dropped = sets.admit(|e| e.seen) as u64;
314            if dropped > 0 {
315                self.sets_evicted.fetch_add(dropped, Ordering::Relaxed);
316            }
317        }
318        sets.insert(
319            producer,
320            Entry {
321                value: cached,
322                seen,
323            },
324        );
325    }
326
327    /// The schema for `type_name` as served by `producer`, fetching
328    /// `@rpc/<producer>/describe` on first miss. `None` = the producer does
329    /// not serve describe or does not describe this type — render
330    /// structurally (never an error; RFC 08 §7 is a SHOULD for
331    /// self-describing encodings).
332    ///
333    /// A bare `&Session` rather than a [`crate::Fleet`]: the store was
334    /// constructed with the base and composes the `Fleet` itself, so a
335    /// caller cannot hand it a *second* base for the two to disagree over.
336    /// Same for [`set_for`](Self::set_for) and everything built on them.
337    pub async fn schema_for(
338        &self,
339        session: &Session,
340        producer: &str,
341        type_name: &str,
342    ) -> Option<TypeSchema> {
343        self.set_for(session, producer)
344            .await
345            .and_then(|set| set.get(type_name).cloned())
346    }
347
348    /// The producer's **whole** served set, on the same fetch-and-cache path
349    /// as [`schema_for`](Self::schema_for) (issue #51: `zenctl schema show
350    /// <producer>` dumps the inventory, and asking type-by-type would be a
351    /// different question than the one `describe` answers).
352    ///
353    /// `None` = the producer does not serve `describe` — an honest
354    /// degradation, never an error.
355    pub async fn set_for(
356        &self,
357        session: &Session,
358        producer: &str,
359    ) -> Option<std::sync::Arc<SchemaSet>> {
360        let may_ask = !self.sealed.load(std::sync::atomic::Ordering::Acquire);
361        self.set_for_within(session, producer, may_ask).await
362    }
363
364    /// [`set_for`](Self::set_for), stating whether this caller is allowed to
365    /// go to the bus. The seal is a caller-level policy (#337), so the one
366    /// path that is *meant* to ask — [`prewarm`] — passes `true` regardless.
367    async fn set_for_within(
368        &self,
369        session: &Session,
370        producer: &str,
371        may_ask: bool,
372    ) -> Option<std::sync::Arc<SchemaSet>> {
373        if let Lookup::Answered(hit) = self.lookup(producer) {
374            return hit;
375        }
376        if !may_ask {
377            // Sealed: a miss stays a miss, and nothing is recorded — the
378            // store learned nothing about this producer, and a negative entry
379            // would outlive the window that refused to ask.
380            return None;
381        }
382        // Singleflight: hold the producer's gate for the duration of the ask.
383        let gate = self.gate_for(producer);
384        let _held = gate.lock().await;
385        // Whoever held the gate before us has already written its answer —
386        // served or missing — so ask only if the cache is still undecided.
387        // This is the whole point of the gate: the waiters pay a lock, not a
388        // GET.
389        let attempts = match self.lookup(producer) {
390            Lookup::Answered(hit) => return hit,
391            Lookup::Ask(attempts) => attempts,
392        };
393        let entry = match self.fetch(session, producer).await {
394            Fetched::Served(set) => Cached::Served(std::sync::Arc::new(set)),
395            Fetched::NoReplies => Cached::Missing(Missing {
396                reason: MissReason::NoReplies,
397                asked: std::time::Instant::now(),
398                attempts: attempts.saturating_add(1),
399            }),
400            // An answer resets the streak: this is a verdict about the
401            // producer, not a routing race.
402            Fetched::AnsweredUnusable => Cached::Missing(Missing {
403                reason: MissReason::AnsweredUnusable,
404                asked: std::time::Instant::now(),
405                attempts: 0,
406            }),
407        };
408        let served = match &entry {
409            Cached::Served(set) => Some(std::sync::Arc::clone(set)),
410            Cached::Missing(_) => None,
411        };
412        self.remember(producer.to_string(), entry);
413        served
414    }
415
416    /// This producer's single-flight gate, admitted under the bound (#340).
417    ///
418    /// An evicted gate costs at most one duplicate `describe` GET: whoever
419    /// still holds the old `Arc` is still gated by it, and a newcomer simply
420    /// makes a new one. That is why its ledger is separate from the sets' —
421    /// it is not lost knowledge.
422    fn gate_for(&self, producer: &str) -> std::sync::Arc<tokio::sync::Mutex<()>> {
423        let seen = self.tick();
424        let mut inflight = self.inflight.lock().expect("inflight lock");
425        if let Some(entry) = inflight.get_mut(producer) {
426            entry.seen = seen;
427            return std::sync::Arc::clone(&entry.value);
428        }
429        let dropped = inflight.admit(|e| e.seen) as u64;
430        if dropped > 0 {
431            self.gates_evicted.fetch_add(dropped, Ordering::Relaxed);
432        }
433        let gate = std::sync::Arc::new(tokio::sync::Mutex::new(()));
434        inflight.insert(
435            producer.to_string(),
436            Entry {
437                value: std::sync::Arc::clone(&gate),
438                seen,
439            },
440        );
441        gate
442    }
443
444    /// What the cache alone can say about `producer`: a verdict, or how many
445    /// consecutive zero-reply asks precede the next one (carried across so
446    /// the backoff actually grows).
447    ///
448    /// A hit is a *use*, so it refreshes the entry's recency: the producers
449    /// being decoded right now are the ones the bound must keep (#340).
450    fn lookup(&self, producer: &str) -> Lookup {
451        let seen = self.tick();
452        let mut sets = self.sets.lock().expect("store lock");
453        let Some(entry) = sets.get_mut(producer) else {
454            return Lookup::Ask(0);
455        };
456        entry.seen = seen;
457        match &entry.value {
458            Cached::Served(set) => Lookup::Answered(Some(std::sync::Arc::clone(set))),
459            Cached::Missing(m) if !m.may_reask() => Lookup::Answered(None),
460            Cached::Missing(m) => Lookup::Ask(m.attempts),
461        }
462    }
463
464    /// Forget what we learned about one producer, so the next question goes
465    /// to the bus (issue #101).
466    ///
467    /// The queriers are kept: they are idle routing state, and re-declaring
468    /// them is exactly the cost #37 removed.
469    pub fn forget(&self, producer: &str) {
470        self.sets.lock().expect("store lock").remove(producer);
471    }
472
473    /// Forget every producer — the "re-ask schemas" action a frontend offers.
474    ///
475    /// Covers the case the backoff cannot: a *positive* entry never expires,
476    /// so a producer that changes its served set mid-session is otherwise
477    /// read with the schemas it had at first contact.
478    pub fn forget_all(&self) {
479        self.sets.lock().expect("store lock").clear();
480    }
481
482    /// Producers currently answered-for, and whether each served a set —
483    /// what a frontend shows next to its re-ask button.
484    pub fn known(&self) -> Vec<(String, bool)> {
485        let sets = self.sets.lock().expect("store lock");
486        let mut out: Vec<(String, bool)> = sets
487            .iter()
488            .map(|(p, e)| (p.clone(), matches!(e.value, Cached::Served(_))))
489            .collect();
490        out.sort();
491        out
492    }
493
494    async fn fetch(&self, session: &Session, producer: &str) -> Fetched {
495        let cached = {
496            let seen = self.tick();
497            let mut queriers = self.queriers.lock().expect("querier lock");
498            queriers.get_mut(producer).map(|e| {
499                e.seen = seen;
500                std::sync::Arc::clone(&e.value)
501            })
502        };
503        let querier = match cached {
504            Some(q) => q,
505            None => {
506                let key = zenkey::grammar::with_base(
507                    &self.base,
508                    zenkey::selector::fleet_rpc(producer, &["describe"]),
509                );
510                // The store carries the base already, so it composes the
511                // `Fleet` rather than taking one — two bases in scope is a
512                // chance for them to disagree.
513                let fleet = crate::Fleet::new(session, &self.base);
514                let declared =
515                    match crate::bus::query::declare_repeating(&fleet, &key, self.timeout).await {
516                        Ok(q) => std::sync::Arc::new(q),
517                        // We could not even ask. Nobody said anything about
518                        // this producer, so this is the non-verdict case, not
519                        // a 60s verdict.
520                        Err(_) => return Fetched::NoReplies,
521                    };
522                // A concurrent miss may have declared first; keep whichever
523                // landed (the loser undeclares itself on drop — idle state,
524                // not a leak).
525                let seen = self.tick();
526                let mut queriers = self.queriers.lock().expect("querier lock");
527                if let Some(entry) = queriers.get_mut(producer) {
528                    entry.seen = seen;
529                    std::sync::Arc::clone(&entry.value)
530                } else {
531                    // Room, under the bound (#340). An evicted querier is
532                    // routing state, not knowledge — it is re-declared on the
533                    // next miss, which is why its ledger is its own.
534                    let dropped = queriers.admit(|e| e.seen);
535                    if dropped > 0 {
536                        self.queriers_evicted
537                            .fetch_add(dropped as u64, Ordering::Relaxed);
538                    }
539                    queriers.insert(
540                        producer.to_string(),
541                        Entry {
542                            value: std::sync::Arc::clone(&declared),
543                            seen,
544                        },
545                    );
546                    declared
547                }
548            }
549        };
550        let Ok(answers) = querier.fetch().await else {
551            return Fetched::NoReplies;
552        };
553        if answers.is_empty() {
554            return Fetched::NoReplies;
555        }
556        // Any well-formed reply will do; hashes make same-name drift a
557        // doctor finding, not a decode concern.
558        for a in answers {
559            if let crate::bus::query::Answer::Value(bytes) = a.answer {
560                let cow = bytes.to_bytes();
561                if let Ok(text) = std::str::from_utf8(&cow)
562                    && let Ok(set) = SchemaSet::parse(text)
563                {
564                    return Fetched::Served(set);
565                }
566            }
567        }
568        // Somebody answered — with an error, or with something that is not a
569        // SchemaSet. That is a statement about this producer.
570        Fetched::AnsweredUnusable
571    }
572
573    /// Decode `bytes` under a schema, if one resolves.
574    pub fn decode(
575        &self,
576        schema: &TypeSchema,
577        encoding: &WireEncoding,
578        bytes: &[u8],
579    ) -> Result<DecodedPayload, DecodeError> {
580        self.decoders
581            .read()
582            .expect("decoder lock")
583            .decode(schema, encoding, bytes)
584    }
585
586    /// The other direction (issue #97): a JSON value framed for the wire.
587    /// The store owns the decoder table, so the write path resolves its codec
588    /// exactly where the read path does — one registration, both directions.
589    pub fn encode(
590        &self,
591        schema: &TypeSchema,
592        value: &serde_json::Value,
593        target: &WireEncoding,
594    ) -> Result<Vec<u8>, DecodeError> {
595        self.decoders
596            .read()
597            .expect("decoder lock")
598            .encode(schema, value, target)
599    }
600}
601
602/// The registry type names one producer's slice references — RFC 08 §7's
603/// totality set for that producer.
604fn referenced_types(slice: &zenkey::slice::RegistrySlice) -> Vec<String> {
605    let mut names: Vec<&str> = slice
606        .subjects
607        .iter()
608        .map(|s| s.type_name.as_str())
609        .filter(|t| !t.is_empty())
610        .collect();
611    for p in &slice.procedures {
612        names.extend(p.request.as_deref());
613        names.extend(p.reply.as_deref());
614    }
615    for b in &slice.blob {
616        names.extend(b.reference.as_deref());
617    }
618    // Media frames are opaque, but their per-frame attachment sidecar is a
619    // registry type like any other (RFC 08 §2) — the build-side §7 totality
620    // check includes it, and this set must not be the smaller one (G-08g).
621    for m in &slice.media {
622        names.extend(m.attachment.as_deref());
623    }
624    names.sort_unstable();
625    names.dedup();
626    names.into_iter().map(str::to_string).collect()
627}
628
629/// One type's schema, as a report row.
630fn row(
631    producer: &str,
632    type_name: &str,
633    schema: &TypeSchema,
634    full: bool,
635) -> crate::report::SchemaRow {
636    crate::report::SchemaRow {
637        producer: producer.to_string(),
638        type_name: type_name.to_string(),
639        kind: schema.kind_str().to_string(),
640        hash: schema.hash().unwrap_or_default().to_string(),
641        document: full.then(|| schema_document(schema)),
642    }
643}
644
645/// A schema's document in a renderable form. `json-schema` has one natively;
646/// every other kind is summarised structurally rather than faked — a codec
647/// this build cannot read still gets to say what it is.
648fn schema_document(schema: &TypeSchema) -> serde_json::Value {
649    if let Some(doc) = schema.json_document() {
650        return doc.clone();
651    }
652    let mut obj = serde_json::Map::new();
653    obj.insert(
654        "kind".into(),
655        serde_json::Value::String(schema.kind_str().to_string()),
656    );
657    if let Some(m) = schema.protobuf_message() {
658        obj.insert("message".into(), serde_json::Value::String(m.to_string()));
659    }
660    if let Some(bytes) = schema.protobuf_descriptor_set() {
661        obj.insert(
662            "descriptor_set_bytes".into(),
663            serde_json::Value::from(bytes.len()),
664        );
665    }
666    if let Some(fields) = schema.cdr_fields() {
667        obj.insert("fields".into(), fields.clone());
668    }
669    if let Some(types) = schema.cdr_types() {
670        obj.insert("types".into(), serde_json::Value::Object(types.clone()));
671    }
672    serde_json::Value::Object(obj)
673}
674
675/// Dump one producer's served `describe` reply (issue #51), joined against
676/// its registry slice so the RFC 08 §7 totality gap is visible where the user
677/// is already looking.
678///
679/// A producer serving no `describe` yields `served: false` — the honest
680/// degradation, never an error: §7 is a SHOULD, and silence about a type is
681/// not a claim about it.
682///
683/// `slices: None` means no registry was loaded, and `missing` comes back
684/// `None` with it: a totality gap computed against nothing is vacuously
685/// empty, and rendering that as "nothing missing" would report a verdict
686/// never obtained (RFC 09 §5.1 O4; #246).
687pub async fn schema_dump(
688    store: &SchemaStore,
689    session: &Session,
690    slices: Option<&SliceSet>,
691    producer: &str,
692    type_filter: Option<&str>,
693    full: bool,
694) -> crate::report::SchemaDump {
695    let set = store.set_for(session, producer).await;
696
697    let Some(set) = set else {
698        return crate::report::SchemaDump {
699            producer: producer.to_string(),
700            served: false,
701            app: None,
702            types: Vec::new(),
703            // No served set to check against — totality is unaskable here,
704            // not clean.
705            missing: crate::report::Asked::NotAsked,
706        };
707    };
708    let types: Vec<crate::report::SchemaRow> = set
709        .iter()
710        .filter(|(name, _)| type_filter.is_none_or(|f| f == *name))
711        .map(|(name, schema)| row(producer, name, schema, full || type_filter.is_some()))
712        .collect();
713    // Checked only when a registry answered: a loaded registry with no slice
714    // for this producer declares nothing, so `Asked(vec![])` is a real clean
715    // bill; no registry at all stays `NotAsked` (RFC 09 §5.1 O4).
716    let missing = slices.map(|slices| {
717        slices
718            .get(producer)
719            .map(|slice| {
720                referenced_types(slice)
721                    .into_iter()
722                    .filter(|n| set.get(n).is_none())
723                    .collect()
724            })
725            .unwrap_or_default()
726    });
727    crate::report::SchemaDump {
728        producer: producer.to_string(),
729        served: true,
730        app: Some(set.app().to_string()),
731        types,
732        missing: missing.into(),
733    }
734}
735
736/// Every producer's schema for one type name (issue #51's `interface show
737/// --schema`). Asking all of them is the point: same name, different hash is
738/// RFC 08 §7's drift finding, and the type's own page is where it is worth
739/// seeing.
740pub async fn schemas_for_type(
741    store: &SchemaStore,
742    session: &Session,
743    producers: &[String],
744    type_name: &str,
745    full: bool,
746) -> Vec<crate::report::SchemaRow> {
747    let mut out = Vec::new();
748
749    for producer in producers {
750        if let Some(schema) = store.schema_for(session, producer, type_name).await {
751            out.push(row(producer, type_name, &schema, full));
752        }
753    }
754    out
755}
756
757/// Compute drift across a described fleet. Pure — feed it whatever describe
758/// replies were gathered (the store's cache, or a fresh sweep).
759///
760/// Reports a name **only when more than one producer serves it**, because
761/// with one server there is nothing to compare; a lone producer that served no
762/// identity is degraded caching (RFC 08 §7), not a disagreement.
763///
764/// Two producers that each served *no* identity used to compare equal — both
765/// flattened to `""` — and were reported as agreeing: a "no drift" verdict on
766/// a question nobody answered (#370, RFC 09 §5.1 O4). They are
767/// [`DriftVerdict::Unjudgeable`] now, which is neither agreement nor a defect.
768pub fn schema_drift(described: &[(String, SchemaSet)]) -> Vec<SchemaDrift> {
769    use std::collections::BTreeMap;
770    let mut by_name: BTreeMap<&str, Vec<SchemaServer>> = BTreeMap::new();
771    for (producer, set) in described {
772        for (name, schema) in set.iter() {
773            by_name.entry(name).or_default().push(SchemaServer {
774                producer: producer.clone(),
775                hash: schema.hash().map(str::to_string).into(),
776            });
777        }
778    }
779    by_name
780        .into_iter()
781        .filter(|(_, servers)| servers.len() > 1)
782        .filter_map(|(name, servers)| {
783            let claimed: Vec<&String> = servers.iter().filter_map(|s| s.hash.as_option()).collect();
784            let verdict = if claimed.len() < servers.len() {
785                // Somebody did not say. Whatever the rest agree on, agreement
786                // across the fleet is not established.
787                DriftVerdict::Unjudgeable
788            } else if claimed.iter().any(|h| *h != claimed[0]) {
789                DriftVerdict::Disagree
790            } else {
791                return None;
792            };
793            Some(SchemaDrift {
794                type_name: name.to_string(),
795                servers,
796                verdict,
797            })
798        })
799        .collect()
800}
801
802/// Totality per producer: every type name the slice references (subjects,
803/// procedure request/reply, blob references) must appear in the served set
804/// (RFC 08 §7). A producer that served no describe at all is NOT a gap here —
805/// that is "describe absent", a different finding with a different fix.
806pub fn totality_gaps(described: &[(String, SchemaSet)], slices: &SliceSet) -> Vec<TotalityGap> {
807    let mut gaps = Vec::new();
808    for (producer, set) in described {
809        let Some(slice) = slices.get(producer) else {
810            continue;
811        };
812        let mut names: Vec<&str> = Vec::new();
813        // An untyped subject (empty `type`) references nothing — without this
814        // filter it would demand a schema for "" and report a phantom gap.
815        names.extend(
816            slice
817                .subjects
818                .iter()
819                .map(|s| s.type_name.as_str())
820                .filter(|t| !t.is_empty()),
821        );
822        for p in &slice.procedures {
823            names.extend(p.request.as_deref());
824            names.extend(p.reply.as_deref());
825        }
826        for b in &slice.blob {
827            names.extend(b.reference.as_deref());
828        }
829        names.sort();
830        names.dedup();
831        let missing: Vec<String> = names
832            .into_iter()
833            .filter(|n| set.get(n).is_none())
834            .map(str::to_string)
835            .collect();
836        if !missing.is_empty() {
837            gaps.push(TotalityGap {
838                producer: producer.clone(),
839                missing,
840            });
841        }
842    }
843    gaps
844}
845
846/// How a rendered payload was produced — a tool surfaces this honestly
847/// instead of letting decoded and sniffed output look alike.
848#[derive(Debug, Clone, PartialEq, Eq)]
849pub enum Rendering {
850    /// Schema-decoded into named fields.
851    Typed(DecodedPayload),
852    /// No schema (or an undecodable kind): structural sniff — JSON if it
853    /// parses, CBOR diagnostic, UTF-8 text, else a byte count.
854    Structural(String),
855}
856
857/// Resolve the wire encoding: sample `Encoding` > registry `encoding` > sniff
858/// (RFC 08 §7).
859pub fn resolve_encoding(
860    sample_encoding: Option<&str>,
861    registry_encoding: Option<&WireEncoding>,
862    bytes: &[u8],
863) -> WireEncoding {
864    // Zenoh's default when a publisher sets nothing is the opaque
865    // `zenoh/bytes` — that is "unsaid", not "bytes on purpose".
866    if let Some(e) = sample_encoding
867        && e != "zenoh/bytes"
868    {
869        return WireEncoding::from_encoding_str(e);
870    }
871    if let Some(e) = registry_encoding {
872        return e.clone();
873    }
874    // The sniff: JSON text starts with a JSON-ish byte; otherwise call it
875    // CBOR (the reference profile default) and let the decoder's error path
876    // fall through to structural rendering.
877    match bytes.first() {
878        Some(b'{' | b'[' | b'"') => WireEncoding::Json,
879        _ => WireEncoding::Cbor,
880    }
881}
882
883/// How many bytes an *observation* path will structurally decode.
884///
885/// `structural_value` parses the whole payload into a `serde_json::Value`, and
886/// the observation paths call it **per sample** on a drain loop — field
887/// intelligence has to, because a field that stopped moving is only visible
888/// sample by sample. Unbounded, a multi-megabyte payload spends that parse on
889/// every one of them, on the loop whose whole job is to keep up (#337's
890/// lesson, applied to CPU rather than to I/O).
891///
892/// The number and the doctrine are `zengui`'s, from #345 — *"past it the size
893/// is reported and the decode is skipped, which is stated, never silently
894/// empty"* — moved here because both frontends and the engine's own judges
895/// need it, and three copies of one limit would be three answers to one
896/// question (the #353 lesson).
897pub const OBSERVE_LIMIT: usize = 64 * 1024;
898
899/// The structural sniff as a **value** rather than as text — the same ladder
900/// [`structural`] renders, stopped one step earlier.
901///
902/// `Some` means the bytes carry a self-describing document (JSON, or CBOR that
903/// accounts for every byte and is not the text-vs-scalar ambiguity below).
904/// `None` means they do not: plain text, or opaque bytes. That distinction is
905/// what lets a caller diff two payloads field-by-field when it can, and say so
906/// honestly — a byte comparison — when it cannot.
907///
908/// Deliberately sync and schema-free: this runs on render paths, where the
909/// async [`decode_sample`] (which may GET a `describe` on a miss) must never
910/// sit.
911pub fn structural_value(bytes: &[u8]) -> Option<serde_json::Value> {
912    let looks_json = bytes.first().is_some_and(|b| {
913        matches!(
914            b,
915            b'{' | b'[' | b'"' | b'-' | b'0'..=b'9' | b't' | b'f' | b'n'
916        )
917    });
918    if looks_json && let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
919        return Some(v);
920    }
921    let is_text = std::str::from_utf8(bytes).is_ok_and(|t| !t.is_empty());
922    if let Some(v) = cbor_whole(bytes)
923        // A bare CBOR scalar over bytes that are *also* valid text is the
924        // ambiguous case, and plain text is the likelier reading on a bus that
925        // carries anything. Structured CBOR (a map, an array) is unambiguous
926        // and still wins.
927        && !(is_text && is_scalar(&v))
928        // A CBOR map keyed by anything but strings has no JSON form; that is a
929        // failure of the *rendering*, not of the payload, so it degrades to
930        // text like any other unreadable shape rather than being invented.
931        && let Ok(value) = serde_json::to_value(&v)
932    {
933        return Some(value);
934    }
935    None
936}
937
938/// Structural fallback rendering — what the wire honestly says when no
939/// schema resolves.
940pub fn structural(bytes: &[u8]) -> String {
941    if let Some(v) = structural_value(bytes) {
942        return serde_json::to_string(&v).unwrap_or_default();
943    }
944    match std::str::from_utf8(bytes).ok().filter(|t| !t.is_empty()) {
945        Some(text) => text.to_string(),
946        None => format!("<{} bytes>", bytes.len()),
947    }
948}
949
950/// Decode CBOR only if it accounts for **every** byte.
951///
952/// `ciborium::from_reader` decodes one value from the front and ignores the
953/// rest, which makes it a false-positive machine on plain text: `j` is `0x6A`,
954/// "text string of length 10", so `just a plain string` decodes as the CBOR
955/// text `"ust a plai"` with eight bytes left over — and an explorer that shows
956/// that has silently corrupted the payload it was asked to display. Any
957/// lowercase-initial ASCII text is a candidate. Requiring total consumption is
958/// what makes the sniff honest (RFC 08 §7 — sniffing is the last resort, so it
959/// must at least be self-consistent).
960fn cbor_whole(bytes: &[u8]) -> Option<ciborium::Value> {
961    let mut cursor = std::io::Cursor::new(bytes);
962    let value = ciborium::from_reader::<ciborium::Value, _>(&mut cursor).ok()?;
963    (cursor.position() as usize == bytes.len()).then_some(value)
964}
965
966/// A single scalar, as opposed to a map or array.
967fn is_scalar(v: &ciborium::Value) -> bool {
968    !matches!(v, ciborium::Value::Map(_) | ciborium::Value::Array(_))
969}
970
971/// One sample, fully decoded — the pipeline's answer plus its honesty (#159).
972#[derive(Debug, Clone, PartialEq, Eq)]
973pub struct DecodedSample {
974    /// The registered type name, when the key refined to one.
975    pub type_name: Option<String>,
976    /// What to show: typed fields, or the structural fallback.
977    pub rendering: Rendering,
978    /// Conformance of the payload to its declared schema — three states,
979    /// never a boolean ([`zenkey::schema::validate::Verdict`]).
980    pub verdict: Verdict,
981    /// The decode failure under a *present* schema, verbatim — the evidence
982    /// behind `NotValidated(Undecodable)`. `None` everywhere else; before
983    /// #159 this error was swallowed into the structural fallback.
984    pub decode_error: Option<String>,
985}
986
987impl DecodedSample {
988    fn structural(type_name: Option<String>, reason: NotValidated, bytes: &[u8]) -> DecodedSample {
989        DecodedSample {
990            type_name,
991            rendering: Rendering::Structural(structural(bytes)),
992            verdict: Verdict::NotValidated(reason),
993            decode_error: None,
994        }
995    }
996}
997
998/// The whole decode pipeline for one sample: refine the key against the
999/// slices, resolve the schema through the store, decode — or fall back
1000/// structurally, tagged with whatever we did learn and why it was not more.
1001///
1002/// `slices: None` means no registry was loaded at all, and the verdict is
1003/// [`NotValidated::NoRegistry`] — nobody looked a type up, which must not
1004/// masquerade as [`NotValidated::NoSchema`]'s "asked, and no schema is
1005/// served/known for this type" (RFC 09 §5.1 O4; #246). Mirrors
1006/// [`schema_dump`]'s `Option<&SliceSet>`.
1007///
1008/// The argument order is *where*, then *what we know*, then *what arrived*:
1009/// the fleet the sample came off, the two knowledge sources consulted about
1010/// it (the schema store, the registry), then the sample itself — key,
1011/// declared encoding, bytes. It used to open `(store, session, slices, base,
1012/// …)`, which put the deployment fourth and split it from its session.
1013/// Ask every producer the loaded registry names for its `describe`, before
1014/// a judging window opens (#337). Returns how many now have a served set.
1015///
1016/// **This is exhaustive, not a heuristic.** [`decode_sample`] refines a key
1017/// against the slices *first* and only then asks the store, so the only
1018/// producers it can ever miss on are the ones the registry names — the set
1019/// this walks. After a pre-warm, every decode inside the window is a cache
1020/// hit or a cached miss, and neither touches the bus.
1021///
1022/// Pair it with [`SchemaStore::seal`], which covers what warming cannot: a
1023/// producer that answered nothing is cached as a *miss with a backoff*, and
1024/// the backoff would expire mid-window and put the GET back inside the drain
1025/// loop.
1026///
1027/// With no registry loaded there is nothing to warm and nothing to miss on —
1028/// `decode_sample` returns `NoRegistry` before it reaches the store.
1029///
1030/// Sequential, like the doctor's own describe sweep: each ask is bounded by
1031/// the store's timeout, and the phase is deliberately *before* anything is
1032/// watched, so its cost is latency to the window's start rather than samples
1033/// lost inside it.
1034pub async fn prewarm(
1035    fleet: &crate::Fleet<'_>,
1036    store: &SchemaStore,
1037    slices: Option<&SliceSet>,
1038) -> usize {
1039    let Some(slices) = slices else { return 0 };
1040    let mut served = 0;
1041    for slice in slices.slices() {
1042        if store
1043            .set_for_within(fleet.session(), &slice.name, true)
1044            .await
1045            .is_some()
1046        {
1047            served += 1;
1048        }
1049    }
1050    served
1051}
1052
1053pub async fn decode_sample(
1054    fleet: &crate::Fleet<'_>,
1055    store: &SchemaStore,
1056    slices: Option<&SliceSet>,
1057    wire_key: &str,
1058    sample_encoding: Option<&str>,
1059    bytes: &[u8],
1060) -> DecodedSample {
1061    use zenkey::grammar::ClassOrPlane;
1062
1063    let (session, base) = (fleet.session(), fleet.base());
1064
1065    let Some(slices) = slices else {
1066        // Not asked is not answered no: with no registry there was never a
1067        // lookup to fail, so the reason names the missing registry, not the
1068        // type (RFC 09 §5.1 O4; #246).
1069        return DecodedSample::structural(None, NotValidated::NoRegistry, bytes);
1070    };
1071    let refined = zenkey::grammar::parse_full(base, wire_key).and_then(|parsed| {
1072        let producer = match (parsed.producer(), &parsed.origin) {
1073            (Some(p), _) => p.name().to_string(),
1074            (None, zenkey::grammar::Origin::Service(s)) => {
1075                slices.by_service_origin(s.as_str())?.name.clone()
1076            }
1077            _ => return None,
1078        };
1079        let ClassOrPlane::Class(class) = parsed.class else {
1080            return None;
1081        };
1082        let (subject, _) = slices.refine(&producer, class.chunk(), &parsed.subject)?;
1083        Some((
1084            producer,
1085            subject.type_name.clone(),
1086            subject.encoding.clone(),
1087        ))
1088    });
1089    let Some((producer, type_name, registry_encoding)) = refined else {
1090        // The loaded registry was consulted and names no type for this key —
1091        // there is no schema to conform to (O4: this is "no contract", not
1092        // "checked and passed", and not `NoRegistry`'s "nobody looked").
1093        return DecodedSample::structural(None, NotValidated::NoSchema, bytes);
1094    };
1095    let encoding = resolve_encoding(sample_encoding, registry_encoding.as_ref(), bytes);
1096    match store.schema_for(session, &producer, &type_name).await {
1097        Some(schema) => match store.decode(&schema, &encoding, bytes) {
1098            Ok(decoded) => {
1099                let verdict = decoded.verdict.clone();
1100                DecodedSample {
1101                    type_name: Some(type_name),
1102                    rendering: Rendering::Typed(decoded),
1103                    verdict,
1104                    decode_error: None,
1105                }
1106            }
1107            // Wrong schema/encoding is a finding for the *user*, not a crash:
1108            // fall back to structure, keep the type tag — and keep the error,
1109            // which is exactly the payload-undecodable evidence (#161).
1110            Err(e) => DecodedSample {
1111                type_name: Some(type_name),
1112                rendering: Rendering::Structural(structural(bytes)),
1113                verdict: Verdict::NotValidated(NotValidated::Undecodable),
1114                decode_error: Some(e.to_string()),
1115            },
1116        },
1117        None => DecodedSample::structural(Some(type_name), NotValidated::NoSchema, bytes),
1118    }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    /// #340: the store's maps are bounded, and each bound counts what it
1126    /// dropped — the discipline every other accumulating structure in this
1127    /// crate already keeps (`StatsTable::evicted`, `Retention::evicted`,
1128    /// `FactsCache::evicted`).
1129    ///
1130    /// The keys come from `parse_full` over arbitrary bus traffic, so "a
1131    /// fleet's producer set is small" was never a bound — it was a hope about
1132    /// what an explorer happens to be pointed at.
1133    #[test]
1134    fn the_store_is_bounded_and_says_what_the_bound_cost() {
1135        const PRODUCERS: usize = 200;
1136        let set = || {
1137            SchemaSet::parse(
1138                r#"{"schema_version":1,"app":"t",
1139                    "types":{"W":{"kind":"cddl","hash":"sha256:00","spec":"x = int"}}}"#,
1140            )
1141            .expect("fixture parses")
1142        };
1143        let store = SchemaStore::bounded("", Duration::from_millis(1), 16);
1144        for i in 0..PRODUCERS {
1145            store.insert(format!("p{i:04}"), set());
1146        }
1147
1148        let bounds = store.bounds();
1149        assert_eq!(bounds.max_producers, 16);
1150        assert!(bounds.producers <= 16, "the bound bit: {bounds:?}");
1151        assert_eq!(
1152            bounds.producers as u64 + bounds.sets_evicted,
1153            PRODUCERS as u64,
1154            "every producer is held or counted: {bounds:?}"
1155        );
1156        assert_eq!(store.known().len(), bounds.producers, "known() agrees");
1157        // The three ledgers are three facts: nothing was declared and nothing
1158        // was gated here, so only the sets' bound has a cost to report.
1159        assert_eq!(bounds.queriers_evicted, 0);
1160        assert_eq!(bounds.gates_evicted, 0);
1161    }
1162
1163    /// Eviction is least-recently-**used**, not least-recently-inserted: the
1164    /// producer being decoded right now outlives one seen once (#340).
1165    #[test]
1166    fn a_producer_still_being_read_survives_the_bound() {
1167        let set = || {
1168            SchemaSet::parse(
1169                r#"{"schema_version":1,"app":"t",
1170                    "types":{"W":{"kind":"cddl","hash":"sha256:00","spec":"x = int"}}}"#,
1171            )
1172            .expect("fixture parses")
1173        };
1174        let store = SchemaStore::bounded("", Duration::from_millis(1), 8);
1175        store.insert("hot", set());
1176        for i in 0..7 {
1177            store.insert(format!("cold{i}"), set());
1178        }
1179        // Read `hot` between every further insert — a decode's cache hit.
1180        for i in 7..64 {
1181            assert!(
1182                matches!(store.lookup("hot"), Lookup::Answered(Some(_))),
1183                "the hot producer was evicted at insert {i}"
1184            );
1185            store.insert(format!("cold{i}"), set());
1186        }
1187        assert!(store.bounds().sets_evicted > 0, "the bound did bite");
1188        assert!(
1189            store
1190                .known()
1191                .iter()
1192                .any(|(p, served)| p == "hot" && *served),
1193            "the producer in use survived: {:?}",
1194            store.known()
1195        );
1196    }
1197
1198    /// RFC 08 §7's totality set for one producer: every type the slice
1199    /// references — subject types, procedure request/reply, blob references,
1200    /// **and media attachment sidecars**. The build-side check has counted
1201    /// media since v1.16; the fleet side must not be the smaller set (G-08g).
1202    #[test]
1203    fn the_totality_set_counts_every_referenced_type() {
1204        let slice = zenkey::slice::parse_slice(
1205            r#"
1206            [registry]
1207            version = "1.0"
1208            app = "acme"
1209            convention = 1
1210            [producer]
1211            name = "netring"
1212            [[subject]]
1213            path = "health"
1214            class = "state"
1215            type = "Health"
1216            [[procedure]]
1217            path = "capture/trigger"
1218            kind = "write"
1219            request = "CaptureSpec"
1220            reply = "Ack"
1221            [[blob]]
1222            tier = "artifact"
1223            endpoints = ["manifest"]
1224            reference = "PcapRef"
1225            [[media]]
1226            path = "front/video/h264"
1227            encoding = "video/h264"
1228            attachment = "FrameMeta"
1229            "#,
1230        )
1231        .unwrap();
1232        assert_eq!(
1233            referenced_types(&slice),
1234            ["Ack", "CaptureSpec", "FrameMeta", "Health", "PcapRef"]
1235        );
1236    }
1237
1238    #[test]
1239    fn encoding_resolution_order() {
1240        // Sample wins…
1241        assert_eq!(
1242            resolve_encoding(Some("application/json"), Some(&WireEncoding::Cbor), b"x"),
1243            WireEncoding::Json
1244        );
1245        // …but the opaque default is "unsaid", so the registry speaks…
1246        assert_eq!(
1247            resolve_encoding(Some("zenoh/bytes"), Some(&WireEncoding::Cbor), b"{"),
1248            WireEncoding::Cbor
1249        );
1250        // …and with neither, the sniff.
1251        assert_eq!(
1252            resolve_encoding(None, None, b"{\"a\":1}"),
1253            WireEncoding::Json
1254        );
1255        assert_eq!(resolve_encoding(None, None, &[0xa1]), WireEncoding::Cbor);
1256    }
1257
1258    #[test]
1259    fn structural_rendering_is_honest() {
1260        assert_eq!(structural(b"{\"a\":1}"), "{\"a\":1}");
1261        // CBOR map {1: 2} renders as structure.
1262        let mut cbor = Vec::new();
1263        ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
1264        assert!(structural(&cbor).contains("\"x\""));
1265        assert_eq!(structural(&[0xff, 0xfe, 0x00]), "<3 bytes>");
1266    }
1267
1268    /// The value form answers the question a diff actually asks: is there a
1269    /// document here to compare field by field, or only bytes?
1270    #[test]
1271    fn structural_value_yields_documents_and_nothing_else() {
1272        assert_eq!(
1273            structural_value(br#"{"value":42.0}"#),
1274            Some(serde_json::json!({"value": 42.0}))
1275        );
1276        let mut cbor = Vec::new();
1277        ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
1278        assert_eq!(structural_value(&cbor), Some(serde_json::json!({"x": 1})));
1279        // Plain text and opaque bytes are not documents — the caller falls
1280        // back to a byte comparison rather than being handed a fake one.
1281        assert_eq!(structural_value(b"just a plain string"), None);
1282        assert_eq!(structural_value(&[0xff, 0xfe, 0x00]), None);
1283        assert_eq!(structural_value(b""), None);
1284    }
1285
1286    /// The two must not drift: `structural` is the rendering of
1287    /// `structural_value` wherever one exists.
1288    #[test]
1289    fn the_rendering_agrees_with_the_value() {
1290        for payload in [
1291            &br#"{"a":1}"#[..],
1292            &b"[1,2,3]"[..],
1293            &b"just a plain string"[..],
1294            &[0xff, 0xfe, 0x00][..],
1295        ] {
1296            if let Some(v) = structural_value(payload) {
1297                assert_eq!(structural(payload), serde_json::to_string(&v).unwrap());
1298            }
1299        }
1300    }
1301
1302    /// Regression: plain text must not be eaten by the CBOR sniff.
1303    ///
1304    /// `ciborium` decodes one value from the front and ignores trailing bytes,
1305    /// so `just a plain string` used to render as `"ust a plai"` — `j` is
1306    /// `0x6A`, "text string of length 10". Every lowercase-initial ASCII
1307    /// payload was a candidate, which on an arbitrary bus is most of them.
1308    #[test]
1309    fn plain_text_is_not_mistaken_for_cbor() {
1310        assert_eq!(structural(b"just a plain string"), "just a plain string");
1311        assert_eq!(
1312            structural(b"a v2 key: not this convention"),
1313            "a v2 key: not this convention"
1314        );
1315        // The whole lowercase range is the danger zone (0x60..=0x7b).
1316        for first in b'a'..=b'z' {
1317            let mut payload = vec![first];
1318            payload.extend_from_slice(b" some trailing words here");
1319            let text = String::from_utf8(payload.clone()).unwrap();
1320            assert_eq!(structural(&payload), text, "mangled {text:?}");
1321        }
1322    }
1323
1324    /// The ambiguous case: bytes that are *both* a complete CBOR text string
1325    /// and valid UTF-8. Plain text is the likelier reading on a bus that
1326    /// carries anything, and it is the lossless one.
1327    #[test]
1328    fn an_exact_cbor_text_string_still_reads_as_text() {
1329        // 0x6A = text(10), followed by exactly 10 bytes: fully consumed CBOR.
1330        let payload = b"just a plai";
1331        assert!(cbor_whole(payload).is_some(), "setup: this is valid CBOR");
1332        assert_eq!(structural(payload), "just a plai");
1333    }
1334
1335    /// …but structured CBOR is unambiguous and must still win, even when the
1336    /// bytes happen to be valid UTF-8.
1337    #[test]
1338    fn structured_cbor_still_wins_over_text() {
1339        let mut cbor = Vec::new();
1340        ciborium::into_writer(&serde_json::json!({"ok": true}), &mut cbor).unwrap();
1341        let rendered = structural(&cbor);
1342        assert!(rendered.contains("\"ok\""), "{rendered}");
1343        assert!(rendered.starts_with('{'), "{rendered}");
1344    }
1345
1346    /// Trailing bytes mean the buffer is not one CBOR value, whatever the
1347    /// front of it looks like.
1348    #[test]
1349    fn cbor_must_account_for_every_byte() {
1350        let mut cbor = Vec::new();
1351        ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
1352        assert!(cbor_whole(&cbor).is_some());
1353        cbor.push(0x00);
1354        assert!(cbor_whole(&cbor).is_none(), "trailing byte must reject");
1355    }
1356
1357    fn set_with(name: &str, schema: serde_json::Value) -> SchemaSet {
1358        SchemaSet::builder("app")
1359            .entry(name, zenkey::schema::TypeSchema::json_schema(schema))
1360            .build()
1361    }
1362
1363    /// RFC 08 §7: same name, different hash, across producers — one finding
1364    /// listing every server; agreement is silent.
1365    #[test]
1366    fn drift_findings_name_every_server() {
1367        let a = SchemaSet::builder("app")
1368            .entry(
1369                "T",
1370                zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
1371            )
1372            .build();
1373        let b = SchemaSet::builder("app")
1374            .entry(
1375                "T",
1376                zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"string"})),
1377            )
1378            .build();
1379        let c = SchemaSet::builder("app")
1380            .entry(
1381                "T",
1382                zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
1383            )
1384            .build();
1385        let described = vec![
1386            ("p1".to_string(), a),
1387            ("p2".to_string(), b),
1388            ("p3".to_string(), c),
1389        ];
1390        let drift = schema_drift(&described);
1391        assert_eq!(drift.len(), 1);
1392        assert_eq!(drift[0].type_name, "T");
1393        assert_eq!(drift[0].servers.len(), 3, "every server is named");
1394        assert_eq!(drift[0].verdict, DriftVerdict::Disagree);
1395        // p1 and p3 agree; p2 is the odd one out — the caller can see which.
1396        assert_eq!(drift[0].servers[0].hash, drift[0].servers[2].hash);
1397        assert_ne!(drift[0].servers[0].hash, drift[0].servers[1].hash);
1398
1399        // Two producers that each served *no* identity are not agreeing —
1400        // they answered nothing, and "no drift" would be a verdict on a
1401        // question nobody put (#370, RFC 09 §5.1 O4).
1402        let unhashed = |app: &str| {
1403            SchemaSet::parse(&format!(
1404                r#"{{"schema_version":1,"app":"{app}","types":{{"T":{{"kind":"json-schema","hash":"","schema":{{}}}}}}}}"#
1405            ))
1406            .unwrap()
1407        };
1408        let silent = vec![
1409            ("p1".to_string(), unhashed("app")),
1410            ("p2".to_string(), unhashed("app")),
1411        ];
1412        let drift = schema_drift(&silent);
1413        assert_eq!(drift.len(), 1, "silence is reported, not read as agreement");
1414        assert_eq!(drift[0].verdict, DriftVerdict::Unjudgeable);
1415        assert!(
1416            drift[0].servers.iter().all(|s| s.hash.is_not_asked()),
1417            "and it names who did not say"
1418        );
1419
1420        // One that says and one that does not is likewise unjudgeable — the
1421        // half that answered cannot establish fleet-wide agreement alone.
1422        let mixed = vec![
1423            ("p1".to_string(), unhashed("app")),
1424            (
1425                "p2".to_string(),
1426                SchemaSet::builder("app")
1427                    .entry(
1428                        "T",
1429                        zenkey::schema::TypeSchema::json_schema(
1430                            serde_json::json!({"type":"object"}),
1431                        ),
1432                    )
1433                    .build(),
1434            ),
1435        ];
1436        assert_eq!(schema_drift(&mixed)[0].verdict, DriftVerdict::Unjudgeable);
1437
1438        // A *lone* producer with no identity is nothing to compare against,
1439        // so it is not a drift question at all.
1440        assert!(schema_drift(&[("p1".to_string(), unhashed("app"))]).is_empty());
1441
1442        // All agreeing: no finding.
1443        let described = vec![
1444            (
1445                "p1".to_string(),
1446                set_with("T", serde_json::json!({"type":"object"})),
1447            ),
1448            (
1449                "p3".to_string(),
1450                set_with("T", serde_json::json!({"type":"object"})),
1451            ),
1452        ];
1453        assert!(schema_drift(&described).is_empty());
1454    }
1455
1456    /// Totality: a slice-referenced type absent from the served describe is a
1457    /// gap; a producer that served no describe is not judged here.
1458    #[test]
1459    fn totality_gaps_check_only_served_producers() {
1460        use zenkey::slice::{RegistrySlice, SubjectDecl};
1461        let mut subject = SubjectDecl::new("cpu", zenkey::Class::Telemetry);
1462        subject.type_name = "TelemetryPoint".into();
1463        let mut slice = RegistrySlice::new("1", "a", "sysinfo");
1464        slice.subjects = vec![subject];
1465        let slices = crate::model::registry::SliceSet::from_slices(vec![slice]);
1466
1467        // Served describe missing the referenced type: one gap.
1468        let incomplete = SchemaSet::builder("a")
1469            .entry(
1470                "Other",
1471                zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
1472            )
1473            .build();
1474        let gaps = totality_gaps(&[("sysinfo".to_string(), incomplete)], &slices);
1475        assert_eq!(gaps.len(), 1);
1476        assert_eq!(gaps[0].missing, ["TelemetryPoint"]);
1477
1478        // No describe served at all: not judged by totality.
1479        assert!(totality_gaps(&[], &slices).is_empty());
1480    }
1481
1482    /// An untyped subject (empty `type`) references nothing — it must not
1483    /// demand a schema for `""` (regression: phantom gap found while
1484    /// consolidating doctor's totality check onto this function, #55).
1485    #[test]
1486    fn an_untyped_subject_is_not_a_totality_gap() {
1487        use zenkey::slice::{RegistrySlice, SubjectDecl};
1488        let mut subject = SubjectDecl::new("raw", zenkey::Class::Telemetry);
1489        subject.type_name = String::new();
1490        let mut slice = RegistrySlice::new("1", "a", "sysinfo");
1491        slice.subjects = vec![subject];
1492        let slices = crate::model::registry::SliceSet::from_slices(vec![slice]);
1493        let served = SchemaSet::builder("a").build();
1494        assert!(
1495            totality_gaps(&[("sysinfo".to_string(), served)], &slices).is_empty(),
1496            "empty type names must be filtered, not reported as gaps"
1497        );
1498    }
1499
1500    /// Issue #101: the two ways of learning nothing are different facts and
1501    /// must not share a bound. Zero replies is the RFC 05 §3.1 non-verdict —
1502    /// it backs off in milliseconds and grows; an answer that served nothing
1503    /// usable keeps the full 60s.
1504    #[test]
1505    fn a_zero_reply_ask_backs_off_fast_and_an_answered_one_does_not() {
1506        let now = std::time::Instant::now();
1507        let no_reply = |attempts| Missing {
1508            reason: MissReason::NoReplies,
1509            asked: now,
1510            attempts,
1511        };
1512        assert_eq!(no_reply(1).backoff(), NO_REPLY_BACKOFF);
1513        assert_eq!(no_reply(2).backoff(), NO_REPLY_BACKOFF * 2);
1514        assert_eq!(no_reply(3).backoff(), NO_REPLY_BACKOFF * 4);
1515        // …and it converges on the same bound a genuinely absent producer
1516        // deserves, rather than re-asking forever.
1517        assert_eq!(no_reply(30).backoff(), NOT_SERVED_TTL);
1518
1519        let answered = Missing {
1520            reason: MissReason::AnsweredUnusable,
1521            asked: now,
1522            attempts: 0,
1523        };
1524        assert_eq!(
1525            answered.backoff(),
1526            NOT_SERVED_TTL,
1527            "a producer that answered and served nothing is asked once per TTL"
1528        );
1529    }
1530
1531    /// The first zero-reply backoff must be short enough that an explorer
1532    /// started before its fleet is not blind for a human-noticeable time.
1533    #[test]
1534    fn the_first_reask_is_sub_second() {
1535        let m = Missing {
1536            reason: MissReason::NoReplies,
1537            asked: std::time::Instant::now(),
1538            attempts: 1,
1539        };
1540        assert!(m.backoff() < Duration::from_secs(1));
1541        assert!(!m.may_reask(), "and not before it elapses");
1542    }
1543}