Skip to main content

zenkey_fleet/judge/
common.rs

1//! The vocabulary every judge is written in.
2//!
3//! Before this module existed, each of these lived wherever it was first
4//! needed and the others reached across for it: `doctor` reached into
5//! `field` for `producer_of`, `expect` and `condition` reached into
6//! `doctor` for `is_synthetic_marker` and the check ids, `retired` reached
7//! into `cutover` for the new-plane prefix, and `doctor` reached into
8//! `budget` for a cap. Each reach was individually reasonable and
9//! collectively said that the judging layer had no shared vocabulary — only
10//! a first-mover for every word in it.
11//!
12//! What belongs here: the things **more than one judge must agree about**.
13//! A marker that decides whether traffic is real, the prefix that defines
14//! "the new plane", the ceilings on how many offenders a report names. What
15//! does not: the id vocabularies, which went to [`crate::report`] with #347
16//! because they are serde-pinned wire shapes and that is where those live;
17//! any check's own logic; and
18//! any sentence written in one judge's voice — `cutover::scope_note` and
19//! `retired::scope_note` stay where they are, because they are two different
20//! O5 statements about two different windows, not one statement said twice.
21
22use zenkey::grammar::{Class, with_base};
23
24use crate::SliceSet;
25#[cfg(feature = "decode")]
26use crate::model::facts::{KeyFacts, KeyShape, OriginKind};
27
28// The stable id vocabularies used to live here as two `[&str; N]`. They are
29// `report::CheckId` and `report::RungId` now (#347) — serde-pinned wire
30// shapes, so `CLAUDE.md`'s placement rule puts them under `report/`, with
31// their stability tests beside them.
32
33// ─── the caps ───────────────────────────────────────────────────────────────
34//
35// Three ceilings over three different populations. They are here because
36// more than one judge uses each — not because they are one number: the
37// values are three separate policies that today happen to be 20, 5 and 3,
38// and a future change to one must not drag the others. The *mechanism*
39// (count everything offered, keep the first `cap`) is
40// [`Examples`](crate::model::examples::Examples)'s and always was.
41
42/// How many offending keys a check names before it says "… and N more".
43///
44/// Shared by the doctor's per-check findings, `field`'s per-path ones,
45/// `expect`'s violations and `cutover`'s leaked keys — four judges that had
46/// four constants of the same value under two different names.
47pub(crate) const FINDING_CAP: usize = 20;
48
49/// How many evidence lines one `why` rung carries. Smaller than
50/// [`FINDING_CAP`] on purpose: a rung's evidence is read as prose under the
51/// answer, not scanned as a table.
52pub(crate) const EVIDENCE_CAP: usize = 5;
53
54/// How many example expansions a budget finding or cell carries — enough to
55/// recognise the family member that exploded, without pasting the
56/// population.
57pub const EXPANSION_CAP: usize = 3;
58
59// ─── the shared judgements ──────────────────────────────────────────────────
60
61/// Does an attachment carry the RFC 09 §5.3 synthetic-traffic marker
62/// (`{"synthetic": true, …}`, #162)?
63///
64/// Generated traffic judged as real would be a self-inflicted finding, so
65/// every judge that watches a window counts it separately — the doctor's
66/// listen phase (#161) and the watchdog's windows (#227) alike. One
67/// spelling, because two would eventually disagree about what a rehearsal
68/// looks like.
69///
70/// Gated with its callers: every judge that watches a window is
71/// `decode`-gated, so without the feature this is dead code and says so.
72#[cfg(feature = "decode")]
73pub(crate) fn is_synthetic_marker(attachment: &[u8]) -> bool {
74    serde_json::from_slice::<serde_json::Value>(attachment)
75        .ok()
76        .and_then(|v| v.get("synthetic").and_then(serde_json::Value::as_bool))
77        .unwrap_or(false)
78}
79
80/// The producer name behind a key's facts — a service origin's slice is
81/// found by the origin it serves (RFC 03 §1.5).
82///
83/// Used by `field` to attribute a path and by `doctor` to attribute a
84/// finding, and the two must attribute identically or the same key gets two
85/// producers in one report. Gated with them.
86#[cfg(feature = "decode")]
87pub(crate) fn producer_of(facts: &KeyFacts, slices: Option<&SliceSet>) -> Option<String> {
88    let KeyShape::V1(v) = &facts.shape else {
89        return None;
90    };
91    match v.origin_kind {
92        OriginKind::Host => v.producer.clone(),
93        OriginKind::Service => {
94            slices.and_then(|s| s.by_service_origin(&v.origin).map(|s| s.name.clone()))
95        }
96    }
97}
98
99/// The stated meaning of "the new plane": keys under `<base>/v1/`.
100///
101/// `cutover` asserts traffic on it; `retired` asserts a replacement under
102/// it. Two judges, one definition — a second spelling would let a migration
103/// pass one check and fail the other over the same bus.
104pub fn new_prefix(base: &str) -> String {
105    format!("{}/", with_base(base, "v1"))
106}
107
108/// The data-plane scopes a passive observation must watch: the three data
109/// classes for host origins, plus each declared service origin's three —
110/// `**` never crosses an `@` chunk (RFC 03 §4 D2), so the service planes
111/// must be named to be seen. This is the O5 scope statement the doctor's
112/// listen phase and the `--budget` observation share (#161, #221).
113pub fn data_plane_scopes(base: &str, slices: &SliceSet) -> Vec<String> {
114    let mut scopes = Vec::new();
115    for class in Class::ALL {
116        let class = class.chunk();
117        scopes.push(with_base(base, format!("v1/*/{class}/**")));
118    }
119    for slice in slices.slices() {
120        if let Some(origin) = &slice.service_origin {
121            let origin = origin.token();
122            for class in Class::ALL {
123                let class = class.chunk();
124                scopes.push(with_base(base, format!("v1/{origin}/{class}/**")));
125            }
126        }
127    }
128    scopes
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    /// #162's marker as #161 reads it: a JSON object with `"synthetic": true`.
136    /// Anything else — other attachments, non-JSON bytes — is real traffic.
137    #[cfg(feature = "decode")]
138    #[test]
139    fn the_synthetic_marker_is_recognised_and_nothing_else_is() {
140        assert!(is_synthetic_marker(
141            br#"{"synthetic":true,"tool":"zenctl gen"}"#
142        ));
143        assert!(!is_synthetic_marker(br#"{"synthetic":false}"#));
144        assert!(!is_synthetic_marker(br#"{"tool":"zenctl gen"}"#));
145        assert!(!is_synthetic_marker(b"meta"));
146        assert!(!is_synthetic_marker(b""));
147    }
148
149    /// One definition of "the new plane", so `cutover` and `retired` cannot
150    /// disagree about what a migration moved to.
151    #[test]
152    fn the_new_plane_is_v1_under_the_base() {
153        assert_eq!(new_prefix("acme"), "acme/v1/");
154        assert_eq!(new_prefix(""), "v1/");
155        assert_eq!(new_prefix("a/b"), "a/b/v1/");
156    }
157
158    #[test]
159    fn scopes_name_the_service_planes_explicitly() {
160        let toml = r#"
161            [registry]
162            version = "1.0"
163            app = "t"
164            convention = 1
165            [service]
166            name = "catalog"
167            origin = "@catalog"
168        "#;
169        let slices = SliceSet::from_toml_for_tests(toml);
170        let scopes = data_plane_scopes("zs", &slices);
171        assert!(scopes.contains(&"zs/v1/*/telemetry/**".to_string()));
172        assert!(
173            scopes.contains(&"zs/v1/@catalog/state/**".to_string()),
174            "`*` never matches `@catalog` (D4), so it must be named: {scopes:?}"
175        );
176        assert_eq!(scopes.len(), 6);
177    }
178}