Skip to main content

zenkey_fleet/
admin.rs

1//! Zenoh admin-space access (issue #14): browse `@/**` — the middleware's
2//! own introspection — from the same un-namespaced session the convention
3//! tooling already holds (a namespaced session's admin selector would be
4//! rewritten and match nothing, RFC 09 §5).
5//!
6//! Admin key layouts vary between zenoh versions (report §3.1's caveat), so
7//! this module stays a thin, honest transport: keys + JSON values, no
8//! hardcoded schema. `routers` extracts the few fields every 1.x layout
9//! carries, and leaves the rest visible in `raw`.
10
11use std::time::Duration;
12
13use anyhow::{Result, anyhow};
14use zenoh::Session;
15use zenoh::query::{ConsolidationMode, QueryTarget};
16
17/// One admin-space entry.
18#[derive(Debug, Clone)]
19pub struct AdminEntry {
20    pub key: String,
21    pub value: serde_json::Value,
22}
23
24/// GET an admin selector (default `@/**`). Fans to every node (target All,
25/// consolidation None — several routers may answer).
26pub async fn admin_get(
27    session: &Session,
28    selector: &str,
29    timeout: Duration,
30) -> Result<Vec<AdminEntry>> {
31    let replies = session
32        .get(selector)
33        .target(QueryTarget::All)
34        .consolidation(ConsolidationMode::None)
35        .timeout(timeout)
36        .await
37        .map_err(|e| anyhow!("admin get {selector}: {e}"))?;
38    let mut out = Vec::new();
39    while let Ok(reply) = replies.recv_async().await {
40        let Ok(sample) = reply.result() else { continue };
41        let bytes = sample.payload().to_bytes();
42        let value = serde_json::from_slice(&bytes).unwrap_or_else(|_| {
43            serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string())
44        });
45        out.push(AdminEntry {
46            key: sample.key_expr().as_str().to_string(),
47            value,
48        });
49    }
50    out.sort_by(|a, b| a.key.cmp(&b.key));
51    Ok(out)
52}
53
54/// A router (or peer) as the admin space reports it.
55#[derive(Debug, Clone, serde::Serialize)]
56pub struct RouterInfo {
57    pub zid: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub version: Option<String>,
60    #[serde(skip_serializing_if = "Vec::is_empty")]
61    pub locators: Vec<String>,
62    /// The full admin document, untrimmed — layouts vary by version.
63    pub raw: serde_json::Value,
64}
65
66/// Enumerate routers/peers from `@/*/router` (and the fields every layout
67/// carries).
68pub async fn routers(session: &Session, timeout: Duration) -> Result<Vec<RouterInfo>> {
69    let entries = admin_get(session, "@/*/router", timeout).await?;
70    Ok(entries
71        .into_iter()
72        .map(|e| {
73            let zid = e
74                .value
75                .get("zid")
76                .and_then(|v| v.as_str())
77                .map(str::to_string)
78                .unwrap_or_else(|| {
79                    // Fall back to the key's zid chunk: @/<zid>/router.
80                    e.key.split('/').nth(1).unwrap_or("?").to_string()
81                });
82            let version = e
83                .value
84                .get("version")
85                .and_then(|v| v.as_str())
86                .map(str::to_string);
87            let locators = e
88                .value
89                .get("locators")
90                .and_then(|v| v.as_array())
91                .map(|a| {
92                    a.iter()
93                        .filter_map(|l| l.as_str().map(str::to_string))
94                        .collect()
95                })
96                .unwrap_or_default();
97            RouterInfo {
98                zid,
99                version,
100                locators,
101                raw: e.value,
102            }
103        })
104        .collect())
105}
106
107/// One configured storage, as the admin space reports it.
108#[derive(Debug, Clone, serde::Serialize)]
109pub struct StorageInfo {
110    pub zid: String,
111    pub name: String,
112    /// The key expression the storage captures, when the layout exposes it.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub key_expr: Option<String>,
115    /// The literal prefix stripped before the volume sees a key (RFC 09 §2 —
116    /// zenoh requires a wildcard-free prefix here). Absent when the layout
117    /// does not say, which is not the same as "none configured".
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub strip_prefix: Option<String>,
120    /// The backing volume's id — `memory` is volatile and loses late-joiner
121    /// seeds on a router restart, `fs`/`rocksdb` are the durable LWW stores
122    /// (RFC 09 §2). Spelled either as a bare string or as `{ id: "fs", … }`
123    /// depending on version; both are absorbed here.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub volume: Option<String>,
126    /// The full admin document, untrimmed — layouts vary by version.
127    pub raw: serde_json::Value,
128}
129
130/// Extract a storage from one admin entry, tolerantly: the key shape is
131/// `@/<zid>/router/…/storage_manager/storages/<name>[…]`, the value a config
132/// document whose `key_expr` field names what it captures. Pure — the
133/// version-variance lives here, unit-tested.
134pub fn storage_from_admin_entry(key: &str, value: &serde_json::Value) -> Option<StorageInfo> {
135    let chunks: Vec<&str> = key.split('/').collect();
136    let storages_pos = chunks.iter().position(|c| *c == "storages")?;
137    // Only storage_manager subtrees qualify (volumes etc. share the plugin).
138    if chunks.get(storages_pos.checked_sub(1)?) != Some(&"storage_manager") {
139        return None;
140    }
141    let name = chunks.get(storages_pos + 1)?;
142    let zid = chunks.get(1).unwrap_or(&"?");
143    let text = |field: &str| {
144        value
145            .get(field)
146            .and_then(|v| v.as_str())
147            .map(str::to_string)
148    };
149    // The volume is a bare string in some layouts and an object with an `id`
150    // in others. Absorbing both here is what this pure parser is for.
151    let volume = text("volume").or_else(|| {
152        value
153            .get("volume")
154            .and_then(|v| v.get("id"))
155            .and_then(|v| v.as_str())
156            .map(str::to_string)
157    });
158    Some(StorageInfo {
159        zid: (*zid).to_string(),
160        name: (*name).to_string(),
161        key_expr: text("key_expr"),
162        strip_prefix: text("strip_prefix"),
163        volume,
164        raw: value.clone(),
165    })
166}
167
168/// One row per `(zid, name)`, merging field by field.
169///
170/// The config and status subtrees both answer a storage sweep, and neither is
171/// reliably the richer one — so a row that names a `key_expr` and a row that
172/// names a `volume` must combine rather than one winning outright. Pure, and
173/// separated from [`storages`] because with three optional fields a hand-rolled
174/// `dedup_by` is where a quietly-dropped field would hide.
175pub fn merge_storage_rows(mut rows: Vec<StorageInfo>) -> Vec<StorageInfo> {
176    rows.sort_by(|a, b| (&a.zid, &a.name).cmp(&(&b.zid, &b.name)));
177    let mut out: Vec<StorageInfo> = Vec::with_capacity(rows.len());
178    for row in rows {
179        match out.last_mut() {
180            Some(prev) if prev.zid == row.zid && prev.name == row.name => {
181                prev.key_expr = prev.key_expr.take().or(row.key_expr);
182                prev.strip_prefix = prev.strip_prefix.take().or(row.strip_prefix);
183                prev.volume = prev.volume.take().or(row.volume);
184                // Keep the document that said more, so the raw disclosure is
185                // the useful one.
186                if prev.raw.as_object().map(|o| o.len()).unwrap_or(0)
187                    < row.raw.as_object().map(|o| o.len()).unwrap_or(0)
188                {
189                    prev.raw = row.raw;
190                }
191            }
192            _ => out.push(row),
193        }
194    }
195    out
196}
197
198/// Enumerate configured storages across the mesh (issue #14). Zero routers
199/// (peer mesh, admin disabled) is an empty vec, never an error.
200pub async fn storages(session: &Session, timeout: Duration) -> Result<Vec<StorageInfo>> {
201    let entries = admin_get(
202        session,
203        "@/*/router/**/storage_manager/storages/**",
204        timeout,
205    )
206    .await?;
207    let rows: Vec<StorageInfo> = entries
208        .iter()
209        .filter_map(|e| storage_from_admin_entry(&e.key, &e.value))
210        .collect();
211    Ok(merge_storage_rows(rows))
212}
213
214/// How a declared state family relates to the configured storages.
215#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
216#[serde(tag = "coverage", content = "storage")]
217pub enum Coverage {
218    /// Some storage's key expression includes every key of the family.
219    Covered(String),
220    /// A storage overlaps the family but does not include all of it.
221    Partial(String),
222    /// No storage touches the family. For volatile (ttl'd) state this can be
223    /// legitimate — advanced-pub/sub cache seeding (RFC 04 §3.5); storage is
224    /// authoritative for durable data.
225    Uncovered,
226}
227
228#[derive(Debug, Clone, serde::Serialize)]
229pub struct CoverageRow {
230    pub producer: String,
231    pub path: String,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub ttl_s: Option<i64>,
234    #[serde(flatten)]
235    pub coverage: Coverage,
236}
237
238/// Judge every declared **state** family against the configured storages
239/// (issue #14): the family's wire selector vs each storage's key expression,
240/// by key algebra (`includes` ⇒ covered, `intersects` ⇒ partial). Pure.
241pub fn state_coverage(
242    slices: &crate::registry::SliceSet,
243    base: &str,
244    storages: &[StorageInfo],
245) -> Vec<CoverageRow> {
246    use zenoh::key_expr::keyexpr;
247    let storage_kes: Vec<(&StorageInfo, &keyexpr)> = storages
248        .iter()
249        .filter_map(|s| {
250            let ke = s.key_expr.as_deref()?;
251            keyexpr::new(ke).ok().map(|ke| (s, ke))
252        })
253        .collect();
254    let mut rows = Vec::new();
255    for slice in slices.slices() {
256        for subject in &slice.subjects {
257            if subject.class != "state" {
258                continue;
259            }
260            let Ok(pattern) = zenkey::pattern::SubjectPattern::parse(&subject.path) else {
261                continue;
262            };
263            // Composed via `with_base` so the empty base stays a valid
264            // keyexpr (`format!("{base}/…")` would grow a leading slash and
265            // silently drop every family below).
266            let selector = match &slice.service_origin {
267                Some(origin) => zenkey::grammar::with_base(
268                    base,
269                    format!("v1/{origin}/state/{}", pattern.selector_tail()),
270                ),
271                None => zenkey::grammar::with_base(
272                    base,
273                    format!("v1/*/state/{}/{}", slice.name, pattern.selector_tail()),
274                ),
275            };
276            let Ok(family) = keyexpr::new(selector.as_str()) else {
277                continue;
278            };
279            let mut coverage = Coverage::Uncovered;
280            for (info, ke) in &storage_kes {
281                if ke.includes(family) {
282                    coverage = Coverage::Covered(format!("{}@{}", info.name, info.zid));
283                    break;
284                }
285                if ke.intersects(family) && coverage == Coverage::Uncovered {
286                    coverage = Coverage::Partial(format!("{}@{}", info.name, info.zid));
287                }
288            }
289            rows.push(CoverageRow {
290                producer: slice.name.clone(),
291                path: subject.path.clone(),
292                ttl_s: subject.ttl_s,
293                coverage,
294            });
295        }
296    }
297    rows
298}
299
300/// What kind of declared entity an admin reply describes.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
302#[serde(rename_all = "snake_case")]
303pub enum EntityKind {
304    Subscriber,
305    Publisher,
306    Queryable,
307    Querier,
308    Token,
309}
310
311impl EntityKind {
312    fn from_chunk(chunk: &str) -> Option<EntityKind> {
313        Some(match chunk {
314            "subscriber" => EntityKind::Subscriber,
315            "publisher" => EntityKind::Publisher,
316            "queryable" => EntityKind::Queryable,
317            "querier" => EntityKind::Querier,
318            "token" => EntityKind::Token,
319            _ => return None,
320        })
321    }
322
323    fn chunk(self) -> &'static str {
324        match self {
325            EntityKind::Subscriber => "subscriber",
326            EntityKind::Publisher => "publisher",
327            EntityKind::Queryable => "queryable",
328            EntityKind::Querier => "querier",
329            EntityKind::Token => "token",
330        }
331    }
332}
333
334/// One declared entity, as the admin space reports it: the reply key is
335/// `@/<zid>/<whatami>/<kind>/<declared-keyexpr...>`, so the keyexpr every
336/// session declared is readable **without subscribing to any data** — the
337/// payload-free discovery leg of issue #84.
338#[derive(Debug, Clone, serde::Serialize)]
339pub struct DeclaredEntity {
340    pub kind: EntityKind,
341    /// The declared key expression, verbatim.
342    pub keyexpr: String,
343    /// The node whose admin space answered.
344    pub node_zid: String,
345    /// The raw payload (`Sources { routers, peers, clients }`-shaped in
346    /// zenoh 1.9) — kept as-is; layouts vary by version.
347    pub sources: serde_json::Value,
348}
349
350/// The declared-entity sweep result.
351#[derive(Debug, Clone, Default, serde::Serialize)]
352pub struct DeclaredEntities {
353    pub entities: Vec<DeclaredEntity>,
354}
355
356/// Parse one admin entry (`@/<zid>/<whatami>/<kind>/<keyexpr...>`) into a
357/// declared entity. Pure; unknown shapes yield `None` (the admin space is
358/// version-dependent surface — tolerate, never fail).
359pub fn declared_from_admin_entry(key: &str, value: &serde_json::Value) -> Option<DeclaredEntity> {
360    let mut chunks = key.split('/');
361    if chunks.next()? != "@" {
362        return None;
363    }
364    let zid = chunks.next()?;
365    let _whatami = chunks.next()?;
366    let kind = EntityKind::from_chunk(chunks.next()?)?;
367    let keyexpr: Vec<&str> = chunks.collect();
368    if keyexpr.is_empty() {
369        return None;
370    }
371    Some(DeclaredEntity {
372        kind,
373        keyexpr: keyexpr.join("/"),
374        node_zid: zid.to_string(),
375        sources: value.clone(),
376    })
377}
378
379/// Enumerate declared subscribers/publishers/queryables/tokens from every
380/// reachable admin space.
381///
382/// `Ok(None)` when **nothing answered at all**: zenoh's `adminspace.enabled`
383/// defaults to *false* (routers ship with it on; a pure peer mesh has none),
384/// so an empty sweep means "not available", never "nothing declared" —
385/// callers MUST render the difference (RFC 09 §5.1 O4). A *publisher* is
386/// visible only if it was declared (P7's rule for the data planes); a bare
387/// `session.put()` never appears here.
388pub async fn declared_entities(
389    session: &Session,
390    timeout: Duration,
391) -> Result<Option<DeclaredEntities>> {
392    let mut entities = Vec::new();
393    let mut any_reply = false;
394    for kind in [
395        EntityKind::Subscriber,
396        EntityKind::Publisher,
397        EntityKind::Queryable,
398        EntityKind::Querier,
399        EntityKind::Token,
400    ] {
401        let selector = format!("@/*/*/{}/**", kind.chunk());
402        let entries = admin_get(session, &selector, timeout).await?;
403        any_reply |= !entries.is_empty();
404        entities.extend(
405            entries
406                .iter()
407                .filter_map(|e| declared_from_admin_entry(&e.key, &e.value)),
408        );
409    }
410    if !any_reply {
411        return Ok(None);
412    }
413    Ok(Some(DeclaredEntities { entities }))
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn storage_extraction_tolerates_layouts() {
422        // 1.x config-subtree shape.
423        let v = serde_json::json!({"key_expr": "zs/v1/*/state/**", "volume": "fs"});
424        let s = storage_from_admin_entry(
425            "@/abc123/router/config/plugins/storage_manager/storages/latest",
426            &v,
427        )
428        .unwrap();
429        assert_eq!(s.zid, "abc123");
430        assert_eq!(s.name, "latest");
431        assert_eq!(s.key_expr.as_deref(), Some("zs/v1/*/state/**"));
432        assert_eq!(s.volume.as_deref(), Some("fs"), "a bare-string volume");
433        // The other spelling of the same field.
434        let v = serde_json::json!({
435            "key_expr": "zs/v1/*/state/**",
436            "strip_prefix": "zs/v1",
437            "volume": {"id": "rocksdb", "dir": "latest"},
438        });
439        let s = storage_from_admin_entry(
440            "@/abc123/router/config/plugins/storage_manager/storages/durable",
441            &v,
442        )
443        .unwrap();
444        assert_eq!(s.volume.as_deref(), Some("rocksdb"), "an object volume");
445        assert_eq!(s.strip_prefix.as_deref(), Some("zs/v1"));
446        // Status-subtree shape without key_expr still names the storage.
447        let s = storage_from_admin_entry(
448            "@/abc123/router/status/plugins/storage_manager/storages/latest/info",
449            &serde_json::json!("ok"),
450        )
451        .unwrap();
452        assert_eq!(s.name, "latest");
453        assert!(s.key_expr.is_none());
454        // Non-storage subtrees do not match.
455        assert!(
456            storage_from_admin_entry(
457                "@/abc123/router/config/plugins/storage_manager/volumes/fs",
458                &serde_json::json!({}),
459            )
460            .is_none()
461        );
462    }
463
464    /// Config and status subtrees both answer, neither is reliably richer, and
465    /// a field named by only one of them must survive either arrival order.
466    #[test]
467    fn merging_a_storage_keeps_every_field_either_side_named() {
468        let config = StorageInfo {
469            zid: "z1".into(),
470            name: "latest".into(),
471            key_expr: Some("zs/**".into()),
472            strip_prefix: Some("zs".into()),
473            volume: None,
474            raw: serde_json::json!({"key_expr": "zs/**", "strip_prefix": "zs"}),
475        };
476        let status = StorageInfo {
477            zid: "z1".into(),
478            name: "latest".into(),
479            key_expr: None,
480            strip_prefix: None,
481            volume: Some("fs".into()),
482            raw: serde_json::json!({"volume": "fs"}),
483        };
484        for rows in [
485            vec![config.clone(), status.clone()],
486            vec![status, config.clone()],
487        ] {
488            let merged = merge_storage_rows(rows);
489            assert_eq!(merged.len(), 1, "one row per (zid, name)");
490            assert_eq!(merged[0].key_expr.as_deref(), Some("zs/**"));
491            assert_eq!(merged[0].strip_prefix.as_deref(), Some("zs"));
492            assert_eq!(merged[0].volume.as_deref(), Some("fs"));
493        }
494
495        // Two names under one zid stay two rows.
496        let other = StorageInfo {
497            zid: "z1".into(),
498            name: "history".into(),
499            key_expr: None,
500            strip_prefix: None,
501            volume: None,
502            raw: serde_json::Value::Null,
503        };
504        assert_eq!(merge_storage_rows(vec![config, other]).len(), 2);
505    }
506
507    fn slices_with_state() -> crate::registry::SliceSet {
508        let toml = r#"
509            [registry]
510            version = "1.0"
511            app = "t"
512            convention = 1
513            [producer]
514            name = "tc"
515            [[subject]]
516            path = "health"
517            class = "state"
518            type = "Health"
519            ttl_s = 60
520            [[subject]]
521            path = "config/{iface}"
522            class = "state"
523            type = "Config"
524            ttl_s = 120
525            [[subject]]
526            path = "bandwidth"
527            class = "telemetry"
528            type = "Point"
529        "#;
530        crate::registry::SliceSet::from_toml_for_tests(toml)
531    }
532
533    fn storage(name: &str, key_expr: &str) -> StorageInfo {
534        StorageInfo {
535            zid: "z1".into(),
536            name: name.into(),
537            key_expr: Some(key_expr.into()),
538            strip_prefix: None,
539            volume: None,
540            raw: serde_json::Value::Null,
541        }
542    }
543
544    #[test]
545    fn coverage_judges_covered_partial_uncovered() {
546        let slices = slices_with_state();
547        // Full state storage: everything covered; telemetry not judged.
548        let rows = state_coverage(&slices, "zs", &[storage("latest", "zs/v1/*/state/**")]);
549        assert_eq!(rows.len(), 2);
550        assert!(
551            rows.iter()
552                .all(|r| matches!(r.coverage, Coverage::Covered(_)))
553        );
554
555        // A one-interface storage: config/{iface} is partial, health uncovered.
556        let rows = state_coverage(
557            &slices,
558            "zs",
559            &[storage("one", "zs/v1/*/state/tc/config/eth0")],
560        );
561        let health = rows.iter().find(|r| r.path == "health").unwrap();
562        assert_eq!(health.coverage, Coverage::Uncovered);
563        let config = rows.iter().find(|r| r.path == "config/{iface}").unwrap();
564        assert!(matches!(config.coverage, Coverage::Partial(_)));
565
566        // No storages at all.
567        let rows = state_coverage(&slices, "zs", &[]);
568        assert!(rows.iter().all(|r| r.coverage == Coverage::Uncovered));
569
570        // The empty base composes a valid selector (`v1/…`, no leading
571        // slash) instead of silently dropping every family.
572        let rows = state_coverage(&slices, "", &[storage("latest", "v1/*/state/**")]);
573        assert_eq!(rows.len(), 2);
574        assert!(
575            rows.iter()
576                .all(|r| matches!(r.coverage, Coverage::Covered(_)))
577        );
578    }
579
580    /// The zenoh 1.9 admin key shape (`@/<zid>/<whatami>/<kind>/<keyexpr...>`)
581    /// parses into a declared entity; foreign shapes are tolerated as None.
582    #[test]
583    fn declared_entities_parse_the_admin_key_shape() {
584        let v = serde_json::json!({"routers": [], "peers": ["p1"], "clients": []});
585        let e = declared_from_admin_entry("@/a1b2c3/router/subscriber/zensight/v1/*/state/**", &v)
586            .unwrap();
587        assert_eq!(e.kind, EntityKind::Subscriber);
588        assert_eq!(e.keyexpr, "zensight/v1/*/state/**");
589        assert_eq!(e.node_zid, "a1b2c3");
590
591        let e = declared_from_admin_entry("@/z/peer/publisher/v1/h-a/telemetry/x/m", &v).unwrap();
592        assert_eq!(e.kind, EntityKind::Publisher);
593        assert_eq!(e.keyexpr, "v1/h-a/telemetry/x/m");
594
595        // Tolerated, never fatal:
596        assert!(declared_from_admin_entry("@/z/router/config/x", &v).is_none());
597        assert!(declared_from_admin_entry("@/z/router/subscriber", &v).is_none());
598        assert!(declared_from_admin_entry("not/admin/at/all", &v).is_none());
599    }
600}
601
602/// One liveliness origin attached to the session that declared its token —
603/// the #131 join, evidence-first: an attachment is made only from what the
604/// admin space actually said, never guessed (a guessed attachment would be
605/// the O4 failure on a picture).
606#[derive(Debug, Clone, serde::Serialize)]
607pub struct OriginAttachment {
608    /// The origin the token names (`h-…` or `@service`).
609    pub origin: String,
610    /// The declaring session's zid, when the token's admin `sources` names
611    /// exactly one. `None` = the sources were absent or ambiguous — the
612    /// origin is then only *reported by* the answering admin space, and a
613    /// renderer says so instead of drawing a line it cannot back.
614    #[serde(skip_serializing_if = "Option::is_none")]
615    pub session_zid: Option<String>,
616    /// The admin space that reported the token: the origin's own session in
617    /// a peer mesh serving its admin space, a router in a routed one.
618    pub reporter_zid: String,
619    /// The token key the evidence rode — the audit trail.
620    pub token_key: String,
621}
622
623/// Collect every zid string under the zenoh 1.9 `Sources` shape
624/// (`{ routers: [...], peers: [...], clients: [...] }`) — tolerant of the
625/// layout varying by version: unknown shapes yield nothing, never an error.
626fn source_zids(sources: &serde_json::Value) -> Vec<String> {
627    let mut out = Vec::new();
628    for kind in ["routers", "peers", "clients"] {
629        if let Some(list) = sources.get(kind).and_then(|v| v.as_array()) {
630            out.extend(list.iter().filter_map(|z| z.as_str().map(str::to_string)));
631        }
632    }
633    out.sort();
634    out.dedup();
635    out
636}
637
638/// Join the admin space's declared liveliness tokens against the keyspace:
639/// which origin hangs off which session (#131).
640///
641/// One `@/*/*/token/**` sweep; each token whose keyexpr parses under `base`
642/// as an `alive` leaf yields an attachment. The session zid is taken from
643/// the token's `sources` **only when they name exactly one** — several
644/// candidates or none degrade to reporter-only, stated rather than guessed.
645/// An empty result means the admin space served no tokens (or none parse
646/// under this base) — an observation, not an empty fleet (O4).
647pub async fn origin_attachments(
648    session: &Session,
649    base: &str,
650    timeout: Duration,
651) -> Result<Vec<OriginAttachment>> {
652    let entries = admin_get(session, "@/*/*/token/**", timeout).await?;
653    let mut out: Vec<OriginAttachment> = Vec::new();
654    for e in &entries {
655        let Some(decl) = declared_from_admin_entry(&e.key, &e.value) else {
656            continue;
657        };
658        if decl.kind != EntityKind::Token {
659            continue;
660        }
661        let Some(parsed) = zenkey::grammar::parse_full(base, &decl.keyexpr) else {
662            continue;
663        };
664        // The framework liveliness shape: an `alive` leaf on the state
665        // class (RFC 04 §5). Anything else declared as a token is not an
666        // origin claim and is left alone.
667        if parsed.subject.last().copied() != Some("alive") {
668            continue;
669        }
670        let origin = parsed.origin.chunk().to_string();
671        let zids = source_zids(&decl.sources);
672        let session_zid = match zids.as_slice() {
673            [only] => Some(only.clone()),
674            _ => None,
675        };
676        let attachment = OriginAttachment {
677            origin,
678            session_zid,
679            reporter_zid: decl.node_zid.clone(),
680            token_key: decl.keyexpr.clone(),
681        };
682        // One origin can hold several sessions (one per producer process);
683        // dedup only exact repeats.
684        if !out.iter().any(|a| {
685            a.origin == attachment.origin
686                && a.session_zid == attachment.session_zid
687                && a.reporter_zid == attachment.reporter_zid
688        }) {
689            out.push(attachment);
690        }
691    }
692    Ok(out)
693}
694
695/// One node of the mesh, as the topology join sees it (#118).
696#[derive(Debug, Clone, serde::Serialize)]
697pub struct TopologyNode {
698    pub zid: String,
699    /// `router` | `peer` | `client`, as the admin key (or a neighbour's
700    /// session list) spells it.
701    pub whatami: String,
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub version: Option<String>,
704    #[serde(skip_serializing_if = "Vec::is_empty")]
705    pub locators: Vec<String>,
706    /// `true` = this node's own admin space answered; `false` = only heard
707    /// of via a neighbour's session list — "heard of, not queryable",
708    /// rendered as such rather than omitted (the issue's honesty rule).
709    pub answered: bool,
710}
711
712/// One reported link. Kept per-reporter — a renderer that wants an
713/// undirected mesh dedups by unordered zid pair, and reciprocal reports
714/// are corroboration, not duplication.
715#[derive(Debug, Clone, serde::Serialize)]
716pub struct TopologyEdge {
717    /// The zid whose admin doc reported this session.
718    pub reporter: String,
719    /// The far end's zid.
720    pub peer: String,
721    /// The far end's whatami, as the reporter says it.
722    pub whatami: String,
723    /// Link endpoints, `src -> dst`, protocol included.
724    #[serde(skip_serializing_if = "Vec::is_empty")]
725    pub links: Vec<String>,
726}
727
728/// The mesh as the admin space answered it, joined with nothing invented
729/// (#118): what answered, what was only mentioned, and who we are.
730#[derive(Debug, Clone, serde::Serialize)]
731pub struct TopologyReport {
732    pub nodes: Vec<TopologyNode>,
733    pub edges: Vec<TopologyEdge>,
734    /// The selector the sweep asked.
735    pub asked: String,
736    /// Root docs that answered. Zero is "the admin space did not answer" —
737    /// a reading about reachability, never an empty mesh.
738    pub answered: usize,
739    /// This session's own zid — the "you are here" marker.
740    pub self_zid: String,
741}
742
743/// Join the admin root docs (`@/<zid>/<whatami>`) into a topology: every
744/// answering node with its locators and version, every session it reports
745/// as an edge, and every zid that is *only* mentioned as a
746/// heard-of-not-queryable node.
747pub async fn topology(session: &Session, timeout: Duration) -> Result<TopologyReport> {
748    const ASKED: &str = "@/*/*";
749    let entries = admin_get(session, ASKED, timeout).await?;
750    let mut nodes: Vec<TopologyNode> = Vec::new();
751    let mut edges: Vec<TopologyEdge> = Vec::new();
752    for e in &entries {
753        // Root docs only: @/<zid>/<whatami>. Anything deeper is a
754        // different handler and not a node document.
755        let mut chunks = e.key.split('/');
756        let (Some("@"), Some(zid), Some(whatami), None) =
757            (chunks.next(), chunks.next(), chunks.next(), chunks.next())
758        else {
759            continue;
760        };
761        let doc = &e.value;
762        nodes.push(TopologyNode {
763            zid: doc
764                .get("zid")
765                .and_then(|v| v.as_str())
766                .unwrap_or(zid)
767                .to_string(),
768            whatami: whatami.to_string(),
769            version: doc
770                .get("version")
771                .and_then(|v| v.as_str())
772                .map(str::to_string),
773            locators: doc
774                .get("locators")
775                .and_then(|v| v.as_array())
776                .map(|a| {
777                    a.iter()
778                        .filter_map(|l| l.as_str().map(str::to_string))
779                        .collect()
780                })
781                .unwrap_or_default(),
782            answered: true,
783        });
784        for s in doc
785            .get("sessions")
786            .and_then(|v| v.as_array())
787            .map(|a| a.as_slice())
788            .unwrap_or_default()
789        {
790            let Some(peer) = s.get("peer").and_then(|v| v.as_str()) else {
791                continue;
792            };
793            edges.push(TopologyEdge {
794                reporter: zid.to_string(),
795                peer: peer.to_string(),
796                whatami: s
797                    .get("whatami")
798                    .and_then(|v| v.as_str())
799                    .unwrap_or("unknown")
800                    .to_string(),
801                links: s
802                    .get("links")
803                    .and_then(|v| v.as_array())
804                    .map(|a| {
805                        a.iter()
806                            .filter_map(|l| {
807                                Some(format!(
808                                    "{} -> {}",
809                                    l.get("src")?.as_str()?,
810                                    l.get("dst")?.as_str()?
811                                ))
812                            })
813                            .collect()
814                    })
815                    .unwrap_or_default(),
816            });
817        }
818    }
819    let answered = nodes.len();
820    // Heard-of nodes: mentioned as a session peer, but no root doc answered
821    // for them (admin space off, or out of reach). Shown, never omitted.
822    for e in &edges {
823        if !nodes.iter().any(|n| n.zid == e.peer) {
824            nodes.push(TopologyNode {
825                zid: e.peer.clone(),
826                whatami: e.whatami.clone(),
827                version: None,
828                locators: Vec::new(),
829                answered: false,
830            });
831        }
832    }
833    nodes.sort_by(|a, b| a.zid.cmp(&b.zid));
834    nodes.dedup_by(|a, b| a.zid == b.zid);
835    Ok(TopologyReport {
836        nodes,
837        edges,
838        asked: ASKED.to_string(),
839        answered,
840        self_zid: session.zid().to_string(),
841    })
842}