Skip to main content

zenkey_fleet/report/
conform.rs

1//! The conformance plane (#222): a producer's registry executed as a test
2//! suite, one assertion per declared surface, and the verdict that follows.
3//!
4//! Three states per assertion, never two (RFC 13 §3, "a conformance suite
5//! over the registry", v1.35): *met*, *not met*, and *unknowable* with its
6//! reason. The third is never folded into the second — a window proves
7//! presence, never absence, so a declared subject that did not speak is
8//! unknowable, not failed — and in the one test-report medium this crate
9//! writes ([`ConformReport::junit`]) an unknowable assertion is *skipped*,
10//! so a build cannot go red on one.
11//!
12//! An exemption is a **field** ([`Assertion::exempt`]), not a word inside
13//! the evidence: a `when` surface that answered `error/gated`, a rest-var
14//! family with no bound, a conditional subject the window did not see. It
15//! rides a met assertion and says so out loud (RFC 13 §3: "never a pass by
16//! omission"), and a script branches on its presence rather than grepping.
17
18use serde::Serialize;
19
20use super::doctor::ObservationSummary;
21
22/// One assertion's answer (RFC 13 §3, v1.35).
23///
24/// Serialized flat into its [`Assertion`] under a `state` tag —
25/// `{"state": "met"}`, `{"state": "not_met"}`,
26/// `{"state": "unknowable", "reason": …}` — and only the third carries a
27/// reason, because it is the only one whose answer is "I could not say":
28/// the other two are answers, and their evidence is the assertion's own.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30#[serde(tag = "state", rename_all = "snake_case")]
31pub enum AssertionState {
32    /// The surface honours its declaration, on the evidence named.
33    Met,
34    /// It does not — the finding, on the evidence named.
35    NotMet,
36    /// The question was put and the observation could not answer it, with
37    /// what stood in the way. Never a failure (RFC 13 §3).
38    Unknowable { reason: String },
39}
40
41impl AssertionState {
42    /// The fold's order: not met outranks unknowable outranks met, so one
43    /// origin's finding is never hidden behind another origin's silence.
44    fn rank(&self) -> u8 {
45        match self {
46            AssertionState::Met => 0,
47            AssertionState::Unknowable { .. } => 1,
48            AssertionState::NotMet => 2,
49        }
50    }
51
52    /// The worse of two answers about one surface: not met, then unknowable,
53    /// then met.
54    #[must_use]
55    pub fn worst(self, other: AssertionState) -> AssertionState {
56        if other.rank() > self.rank() {
57            other
58        } else {
59            self
60        }
61    }
62
63    pub fn is_met(&self) -> bool {
64        matches!(self, AssertionState::Met)
65    }
66
67    pub fn is_not_met(&self) -> bool {
68        matches!(self, AssertionState::NotMet)
69    }
70
71    pub fn is_unknowable(&self) -> bool {
72        matches!(self, AssertionState::Unknowable { .. })
73    }
74}
75
76/// One declared surface, judged.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub struct Assertion {
79    /// `"<check>/<path>"` — stable, and carrying **no origin**: the same
80    /// surface on three hosts is one assertion folded over the three, so a
81    /// CI history keyed on the id survives a fleet that moves.
82    pub id: String,
83    /// The surface in wire terms: `@rpc/<path>`, `<class>/<path>`, or the
84    /// producer itself for a producer-level check.
85    pub subject: String,
86    #[serde(flatten)]
87    pub state: AssertionState,
88    /// What was observed, per origin where origins differ.
89    pub evidence: String,
90    /// The clause that makes the assertion an obligation.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub citation: Option<String>,
93    /// Present only on a met assertion excused by its declaration — the
94    /// predicates of a `when`, or the rest variable of an unbounded family.
95    /// Absent is "not exempt", never "exemption not considered".
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub exempt: Option<String>,
98}
99
100/// How many assertions landed where. `exempt` counts inside `met`: an
101/// exemption is a met assertion that says why.
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
103pub struct ConformSummary {
104    pub met: usize,
105    pub not_met: usize,
106    pub unknowable: usize,
107    pub exempt: usize,
108}
109
110impl ConformSummary {
111    /// Count a set of assertions.
112    pub fn of(assertions: &[Assertion]) -> ConformSummary {
113        let mut s = ConformSummary::default();
114        for a in assertions {
115            match a.state {
116                AssertionState::Met => s.met += 1,
117                AssertionState::NotMet => s.not_met += 1,
118                AssertionState::Unknowable { .. } => s.unknowable += 1,
119            }
120            if a.exempt.is_some() {
121                s.exempt += 1;
122            }
123        }
124        s
125    }
126}
127
128/// The `check conform` verdict (#222) — "does this build honour the
129/// contract it ships?".
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
131#[serde(rename_all = "snake_case")]
132pub enum ConformVerdict {
133    /// Every assertion met.
134    Conforms,
135    /// At least one assertion not met — the finding.
136    Violates,
137    /// Nothing not met, and at least one assertion unknowable: the suite
138    /// could not prove conformance, and does not claim it.
139    Unproven,
140}
141
142impl ConformVerdict {
143    /// The fold (RFC 13 §3): any not-met is a violation; otherwise any
144    /// unknowable leaves the suite unproven; otherwise it conforms.
145    pub fn of(summary: &ConformSummary) -> ConformVerdict {
146        if summary.not_met > 0 {
147            ConformVerdict::Violates
148        } else if summary.unknowable > 0 {
149            ConformVerdict::Unproven
150        } else {
151            ConformVerdict::Conforms
152        }
153    }
154
155    /// The [`Judgement`](crate::report::Judgement) mapping (RFC 13 §1.1).
156    /// The question is "does the build violate its contract?", named for
157    /// the sought answer — so the finding is `Violates`, and it is the
158    /// established pole:
159    ///
160    /// | verdict | judgement | exit (RFC 13 §1.2) |
161    /// |---|---|---|
162    /// | `Violates` | `Established` (finding) | 1 |
163    /// | `Conforms` | `NotEstablished` (clean) | 0 |
164    /// | `Unproven` | `Unobservable` | 2 |
165    ///
166    /// `Unproven` is 2, not 0: an idle host under a `--for` window has
167    /// proven nothing about its subjects, and a script must not read that
168    /// as a pass. The JUnit rendering is where "unknowable never fails a
169    /// build" is honoured — its unknowables are skipped.
170    pub fn to_judgement(self) -> crate::report::Judgement {
171        use crate::report::Judgement;
172        match self {
173            ConformVerdict::Violates => Judgement::Established,
174            ConformVerdict::Conforms => Judgement::NotEstablished {
175                reason: "every declared surface met its declaration".into(),
176            },
177            ConformVerdict::Unproven => Judgement::Unobservable {
178                reason: "no assertion was not met, and at least one could not be \
179                         established (RFC 13 §3)"
180                    .into(),
181            },
182        }
183    }
184}
185
186/// Where the suite's registry came from — the declaration every assertion
187/// is judged against, so it is stated rather than implied.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
189#[serde(rename_all = "snake_case")]
190pub enum ConformSource {
191    /// The live bus's `introspect` (RFC 08 §6): the fleet judged against
192    /// what it serves.
193    Bus,
194    /// Local `--registry` files: the build judged against its source.
195    Dirs,
196    /// Both, served winning per producer.
197    Union,
198}
199
200/// The `zenctl check conform` report (#222).
201#[derive(Debug, Clone, Serialize)]
202pub struct ConformReport {
203    /// The producer whose registry is the suite.
204    pub producer: String,
205    pub slice_source: ConformSource,
206    /// The origins the run-time half called — the roster's, or the one
207    /// `--origin` named. Empty means nothing could be called, and every
208    /// run-time assertion says so.
209    pub origins_asked: Vec<String>,
210    pub assertions: Vec<Assertion>,
211    pub summary: ConformSummary,
212    pub verdict: ConformVerdict,
213    /// The listen window (`--for`), when one ran — its scopes, and its
214    /// drops, which weaken every `observed/…` answer to a lower bound
215    /// (RFC 13 §3 O6).
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub observation: Option<ObservationSummary>,
218    /// Whether the `--deep` checks (freshness, budget) ran.
219    pub deep: bool,
220    /// The questions this run did not put, one sentence each (O4) — absent
221    /// when it put every one it could.
222    #[serde(skip_serializing_if = "Vec::is_empty")]
223    pub not_asked: Vec<String>,
224}
225
226impl ConformReport {
227    /// The report as JUnit XML, the medium a consumer's CI already reads.
228    ///
229    /// One `<testsuite>` named for the producer, one `<testcase>` per
230    /// assertion (classname the producer, name the id). A not-met assertion
231    /// is a `<failure>`; an **unknowable** one is `<skipped>`, never a
232    /// failure (RFC 13 §3: "a build MUST NOT go red on one"); an exemption
233    /// rides `<system-out>`. No timestamp and no duration: two runs over
234    /// the same fleet write the same bytes, which is what lets a CI diff
235    /// the artifact.
236    pub fn junit(&self) -> String {
237        let s = &self.summary;
238        let mut out = String::new();
239        out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
240        out.push_str(&format!(
241            "<testsuites name=\"zenctl check conform\" tests=\"{}\" failures=\"{}\" \
242             errors=\"0\" skipped=\"{}\">\n",
243            self.assertions.len(),
244            s.not_met,
245            s.unknowable
246        ));
247        out.push_str(&format!(
248            "  <testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" errors=\"0\" \
249             skipped=\"{}\">\n",
250            xml_escape(&self.producer),
251            self.assertions.len(),
252            s.not_met,
253            s.unknowable
254        ));
255        for a in &self.assertions {
256            out.push_str(&format!(
257                "    <testcase classname=\"{}\" name=\"{}\"",
258                xml_escape(&self.producer),
259                xml_escape(&a.id)
260            ));
261            let body = match &a.state {
262                AssertionState::Met => None,
263                AssertionState::NotMet => Some(format!(
264                    "      <failure message=\"{}\" type=\"not_met\">{}</failure>\n",
265                    xml_escape(&a.evidence),
266                    xml_escape(a.citation.as_deref().unwrap_or(""))
267                )),
268                AssertionState::Unknowable { reason } => Some(format!(
269                    "      <skipped message=\"unknowable: {}\"/>\n",
270                    xml_escape(reason)
271                )),
272            };
273            let exempt = a.exempt.as_ref().map(|e| {
274                format!(
275                    "      <system-out>exempt: {} — {}</system-out>\n",
276                    xml_escape(e),
277                    xml_escape(&a.evidence)
278                )
279            });
280            if body.is_none() && exempt.is_none() {
281                out.push_str("/>\n");
282                continue;
283            }
284            out.push_str(">\n");
285            out.push_str(&body.unwrap_or_default());
286            out.push_str(&exempt.unwrap_or_default());
287            out.push_str("    </testcase>\n");
288        }
289        out.push_str("  </testsuite>\n");
290        out.push_str("</testsuites>\n");
291        out
292    }
293}
294
295/// The five XML escapes, and nothing else: attribute and text content alike.
296fn xml_escape(s: &str) -> String {
297    let mut out = String::with_capacity(s.len());
298    for c in s.chars() {
299        match c {
300            '&' => out.push_str("&amp;"),
301            '<' => out.push_str("&lt;"),
302            '>' => out.push_str("&gt;"),
303            '"' => out.push_str("&quot;"),
304            '\'' => out.push_str("&apos;"),
305            c => out.push(c),
306        }
307    }
308    out
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn assertion(id: &str, state: AssertionState, exempt: Option<&str>) -> Assertion {
316        Assertion {
317            id: id.into(),
318            subject: format!("@rpc/{}", id.rsplit('/').next().unwrap()),
319            state,
320            evidence: "h-3fa9c2d41b7e: a value reply".into(),
321            citation: Some("RFC 08 §6.1".into()),
322            exempt: exempt.map(str::to_string),
323        }
324    }
325
326    fn report(assertions: Vec<Assertion>) -> ConformReport {
327        let summary = ConformSummary::of(&assertions);
328        ConformReport {
329            producer: "demo".into(),
330            slice_source: ConformSource::Dirs,
331            origins_asked: vec!["h-3fa9c2d41b7e".into()],
332            assertions,
333            verdict: ConformVerdict::of(&summary),
334            summary,
335            observation: None,
336            deep: false,
337            not_asked: vec![],
338        }
339    }
340
341    /// `zenctl check conform --format json` is what a CI script keys on:
342    /// the three states flat under `state`, the reason only on the third,
343    /// `exempt` and `citation` absent rather than null.
344    #[test]
345    fn conform_report_json_shape_is_pinned() {
346        let r = report(vec![
347            assertion("procedure/status", AssertionState::Met, None),
348            assertion(
349                "procedure/dns",
350                AssertionState::Met,
351                Some("when: config:collect.dns"),
352            ),
353            assertion("procedure/reset", AssertionState::NotMet, None),
354            assertion(
355                "procedure/peer/{id}",
356                AssertionState::Unknowable {
357                    reason: "a `{var}` path names no concrete key to call".into(),
358                },
359                None,
360            ),
361        ]);
362        let json = serde_json::to_value(&r).unwrap();
363        assert_eq!(
364            json,
365            serde_json::json!({
366                "producer": "demo",
367                "slice_source": "dirs",
368                "origins_asked": ["h-3fa9c2d41b7e"],
369                "assertions": [
370                    {
371                        "id": "procedure/status",
372                        "subject": "@rpc/status",
373                        "state": "met",
374                        "evidence": "h-3fa9c2d41b7e: a value reply",
375                        "citation": "RFC 08 §6.1",
376                    },
377                    {
378                        "id": "procedure/dns",
379                        "subject": "@rpc/dns",
380                        "state": "met",
381                        "evidence": "h-3fa9c2d41b7e: a value reply",
382                        "citation": "RFC 08 §6.1",
383                        "exempt": "when: config:collect.dns",
384                    },
385                    {
386                        "id": "procedure/reset",
387                        "subject": "@rpc/reset",
388                        "state": "not_met",
389                        "evidence": "h-3fa9c2d41b7e: a value reply",
390                        "citation": "RFC 08 §6.1",
391                    },
392                    {
393                        "id": "procedure/peer/{id}",
394                        "subject": "@rpc/{id}",
395                        "state": "unknowable",
396                        "reason": "a `{var}` path names no concrete key to call",
397                        "evidence": "h-3fa9c2d41b7e: a value reply",
398                        "citation": "RFC 08 §6.1",
399                    },
400                ],
401                "summary": {"met": 2, "not_met": 1, "unknowable": 1, "exempt": 1},
402                "verdict": "violates",
403                "deep": false,
404            }),
405            "no window: `observation` absent; nothing unasked: `not_asked` absent"
406        );
407    }
408
409    /// The fold, once: not met > unknowable > met, per surface and per run.
410    #[test]
411    fn the_fold_never_hides_a_finding_behind_silence() {
412        let unknowable = || AssertionState::Unknowable { reason: "r".into() };
413        assert!(AssertionState::Met.worst(unknowable()).is_unknowable());
414        assert!(unknowable().worst(AssertionState::NotMet).is_not_met());
415        assert!(AssertionState::NotMet.worst(unknowable()).is_not_met());
416        assert!(AssertionState::Met.worst(AssertionState::Met).is_met());
417
418        let met = report(vec![assertion("a", AssertionState::Met, None)]);
419        assert_eq!(met.verdict, ConformVerdict::Conforms);
420        let unproven = report(vec![
421            assertion("a", AssertionState::Met, None),
422            assertion("b", unknowable(), None),
423        ]);
424        assert_eq!(unproven.verdict, ConformVerdict::Unproven);
425        let violates = report(vec![
426            assertion("b", unknowable(), None),
427            assertion("c", AssertionState::NotMet, None),
428        ]);
429        assert_eq!(violates.verdict, ConformVerdict::Violates);
430    }
431
432    /// The exit projection (RFC 13 §1.2): the finding is 1, clean is 0, and
433    /// unproven is the reserved 2 — never the 0 of a pass.
434    #[test]
435    fn the_verdict_projects_violates_to_the_finding_exit() {
436        use crate::report::judgement_exit_code;
437        assert_eq!(
438            judgement_exit_code(&ConformVerdict::Violates.to_judgement()),
439            1
440        );
441        assert_eq!(
442            judgement_exit_code(&ConformVerdict::Conforms.to_judgement()),
443            0
444        );
445        assert_eq!(
446            judgement_exit_code(&ConformVerdict::Unproven.to_judgement()),
447            2
448        );
449    }
450
451    /// The JUnit rendering, pinned byte for byte: not met is a failure,
452    /// unknowable is **skipped** (never failed — RFC 13 §3), an exemption
453    /// rides system-out, and nothing in the document depends on the clock.
454    #[test]
455    fn junit_skips_the_unknowable_and_never_fails_on_it() {
456        let r = report(vec![
457            assertion("procedure/status", AssertionState::Met, None),
458            assertion(
459                "procedure/dns",
460                AssertionState::Met,
461                Some("when: config:collect.dns"),
462            ),
463            assertion("procedure/reset", AssertionState::NotMet, None),
464            assertion(
465                "observed/boom/{id}",
466                AssertionState::Unknowable {
467                    reason: "a window proves presence, never absence".into(),
468                },
469                None,
470            ),
471        ]);
472        assert_eq!(
473            r.junit(),
474            r#"<?xml version="1.0" encoding="UTF-8"?>
475<testsuites name="zenctl check conform" tests="4" failures="1" errors="0" skipped="1">
476  <testsuite name="demo" tests="4" failures="1" errors="0" skipped="1">
477    <testcase classname="demo" name="procedure/status"/>
478    <testcase classname="demo" name="procedure/dns">
479      <system-out>exempt: when: config:collect.dns — h-3fa9c2d41b7e: a value reply</system-out>
480    </testcase>
481    <testcase classname="demo" name="procedure/reset">
482      <failure message="h-3fa9c2d41b7e: a value reply" type="not_met">RFC 08 §6.1</failure>
483    </testcase>
484    <testcase classname="demo" name="observed/boom/{id}">
485      <skipped message="unknowable: a window proves presence, never absence"/>
486    </testcase>
487  </testsuite>
488</testsuites>
489"#
490        );
491        assert_eq!(r.junit(), r.junit(), "no clock in the artifact");
492    }
493
494    #[test]
495    fn xml_escape_covers_attribute_and_text() {
496        assert_eq!(
497            xml_escape(r#"<a href="x">&'</a>"#),
498            "&lt;a href=&quot;x&quot;&gt;&amp;&apos;&lt;/a&gt;"
499        );
500    }
501}