Skip to main content

memstead_base/ops/
coverage.rs

1//! Axis-coverage declarations: no read surface reports `clean` over
2//! state it did not examine. Where it cannot examine, it says so.
3//!
4//! WHY: eight findings in one sweep shared a single shape, a surface
5//! emitting an all-clear that asserted less than it read as. Strict
6//! health promoted a hand-remembered subset of conditions, `status`
7//! defaulted to a clean rollup when nothing was declared, the
8//! conformance linter could not fail against a mem's own schema, and
9//! `workspace dump` silently dropped mounts whose config did not
10//! parse. Each instance was fixed; this module is the rule that keeps
11//! the class shut. A reader who is told `clean` stops looking, so a
12//! clean verdict must carry the set of axes it answers for.
13//!
14//! THE RULE, STATED ONCE: every surface a caller can read is declared
15//! in a per-consumer registry. A surface that emits a clean/ok
16//! verdict declares, for EVERY axis in the workspace vocabulary,
17//! either that its verdict examined the axis or that the axis is
18//! excluded with a stated reason. A surface that emits no verdict
19//! declares why not. The declaration states intent, which is exactly
20//! what cannot be derived from the code, since the defect the rule
21//! closes is surfaces doing less than they claim. Scoped statements
22//! are instances of this rule, not siblings of it: the anchor
23//! surface saying "reconciliation could not be performed" and the
24//! verify rollup's blind-spots list are the per-run refinement of
25//! the same obligation the static declaration carries per surface.
26//!
27//! The vocabulary reuses [`HEALTH_INCLUDE_KEYS`] rather than
28//! inventing a parallel axis roster: those keys are already the one
29//! shared statement of what the engine can examine, and only the
30//! verdict subjects no health include covers are added here.
31//!
32//! ENFORCEMENT: [`validate_coverage`] is pure and total over data,
33//! so the gate can be demonstrated red against synthetic fixtures
34//! (a surface clean over an unexamined axis, an axis added without a
35//! declaration update) without reconstructing any historical tree.
36//! Each consumer crate holds a test that walks its own live surface
37//! roster (the clap command tree, the MCP tool router), hands the
38//! walk's output to the validator, and fails on any finding. Those
39//! tests ride the ordinary `cargo nextest` legs of `run-tests.sh`,
40//! the same path every other permanent guard runs on. One surface is
41//! deliberately outside the rule: `check` records a caller's verdict
42//! about the caller's own work, so its registry entry is
43//! [`CoverageDisposition::NoVerdict`], not an examined-axes claim.
44
45use crate::ops::health::HEALTH_INCLUDE_KEYS;
46
47/// Verdict subjects no health include key covers. `projection` is the
48/// fidelity axis `status` and `projection verify` answer for;
49/// `mounts` is the roster axis `workspace dump` and `overview` answer
50/// for (which mounts exist, which serve nothing, and why).
51pub const EXTRA_VERDICT_AXES: &[&str] = &["projection", "mounts"];
52
53/// The workspace axis vocabulary: everything a clean verdict can
54/// answer for. Composed, never copied, so it cannot drift from the
55/// health roster.
56pub fn verdict_axes() -> Vec<&'static str> {
57    let mut axes: Vec<&'static str> = HEALTH_INCLUDE_KEYS.to_vec();
58    axes.extend_from_slice(EXTRA_VERDICT_AXES);
59    axes
60}
61
62/// One surface's static coverage claim: which axes its verdict
63/// answers for, and why the rest are outside its scope. The two
64/// lists must jointly name every axis in the vocabulary; a blanket
65/// "everything else" clause is deliberately impossible, because it
66/// would swallow a newly introduced axis silently, and the one
67/// permanent property this module owes is that a new axis fails
68/// every declaration that has not met it.
69#[derive(Debug, Clone, Copy)]
70pub struct AxisCoverage {
71    /// Axes the surface's clean verdict actually examined.
72    pub examined: &'static [&'static str],
73    /// Axes the verdict does not answer for, each with the reason a
74    /// reader needs (typically: which surface answers for it instead).
75    pub excluded: &'static [(&'static str, &'static str)],
76}
77
78/// What a declared surface claims about verdicts.
79#[derive(Debug, Clone, Copy)]
80pub enum CoverageDisposition {
81    /// The surface can emit a clean/ok verdict and declares its axes.
82    Verdict(AxisCoverage),
83    /// The surface emits no clean/ok verdict; the reason says why the
84    /// rule does not bind it (it returns data, it reports what a
85    /// mutation did, or its verdict belongs to the caller).
86    NoVerdict(&'static str),
87}
88
89/// One registry row: a surface name exactly as the consumer's own
90/// mechanical walk produces it, plus its disposition.
91#[derive(Debug, Clone, Copy)]
92pub struct SurfaceCoverage {
93    pub surface: &'static str,
94    pub disposition: CoverageDisposition,
95}
96
97impl AxisCoverage {
98    /// The examined set as it is stamped into surface output.
99    pub fn examined_wire(&self) -> Vec<&'static str> {
100        self.examined.to_vec()
101    }
102
103    /// The exclusions as they are stamped into surface output:
104    /// `(axis, reason)` pairs, so a reader can see which axes the
105    /// verdict does not cover without reading the source.
106    pub fn excluded_wire(&self) -> Vec<(&'static str, &'static str)> {
107        self.excluded.to_vec()
108    }
109
110    /// The declaration as it is stamped into surface output: one
111    /// compact line naming both axis sets, the same form on JSON,
112    /// markdown, and frontmatter surfaces. Axis names only: the
113    /// per-axis exclusion reasons stay a registry fact the gate test
114    /// enforces, because stamping static prose into every response
115    /// would tax each call's token budget, and the reader's question
116    /// the stamp answers is WHICH axes the verdict covers.
117    pub fn wire_line(&self) -> String {
118        self.wire_line_promoting(&[])
119    }
120
121    /// The wire line with the named excluded axes promoted into the
122    /// examined set — for a report that rendered an opt-in axis this
123    /// pass (`--include anchors`) and therefore did examine it. An axis
124    /// not in the excluded list is ignored; the static declaration is
125    /// untouched.
126    pub fn wire_line_promoting(&self, promoted: &[&str]) -> String {
127        let mut examined: Vec<&str> = self.examined.to_vec();
128        let mut not_examined: Vec<&str> = Vec::new();
129        for (a, _) in self.excluded {
130            if promoted.contains(a) {
131                examined.push(a);
132            } else {
133                not_examined.push(a);
134            }
135        }
136        format!(
137            "examined={}; not_examined={}",
138            examined.join(","),
139            not_examined.join(",")
140        )
141    }
142}
143
144impl SurfaceCoverage {
145    /// The verdict declaration, when this row carries one; the
146    /// stamping sites use it so a surface can only stamp what its
147    /// registry row declares.
148    pub fn axis_coverage(&self) -> Option<&AxisCoverage> {
149        match &self.disposition {
150            CoverageDisposition::Verdict(c) => Some(c),
151            CoverageDisposition::NoVerdict(_) => None,
152        }
153    }
154}
155
156/// The health surface's coverage claim, shared by every consumer
157/// that renders a health report (the CLI command, the full MCP
158/// server's composer, and the lean server's own assembly): the axes
159/// whose findings the report treats as defects, so an empty defect
160/// statement reads as an all-clear exactly over them. Everything
161/// descriptive or advisory is excluded by name.
162pub const HEALTH_COVERAGE: AxisCoverage = AxisCoverage {
163    examined: &[
164        "dangling_links",
165        "missing_required_outgoing",
166        "constraints",
167        "signals",
168        "integrity",
169        "config",
170        "mounts",
171    ],
172    excluded: &[
173        (
174            "orphans",
175            "descriptive list; the defect verdict polices orphan stubs through the integrity findings",
176        ),
177        (
178            "stubs",
179            "descriptive list; the defect verdict polices orphan stubs through the integrity findings",
180        ),
181        (
182            "most_connected",
183            "descriptive ranking with no pass/fail semantics",
184        ),
185        (
186            "missing_fields",
187            "advisory count, never part of the defect verdict",
188        ),
189        (
190            "stale",
191            "advisory freshness, never part of the defect verdict",
192        ),
193        (
194            "tags",
195            "descriptive distribution with no pass/fail semantics",
196        ),
197        (
198            "labelling",
199            "advisory audit, never part of the defect verdict",
200        ),
201        (
202            "conformance",
203            "reported per entity beside the verdict, never folded into it",
204        ),
205        (
206            "anchors",
207            "drifted anchors stay advisory; the verify surfaces carry the drift statement",
208        ),
209        ("friction", "descriptive ledger counts"),
210        ("open_questions", "descriptive listing of open questions"),
211        (
212            "vital_signs",
213            "descriptive model-truth counts; the remodel skill holds the thresholds",
214        ),
215        (
216            "stale_derivations",
217            "advisory freshness of derived artifacts",
218        ),
219        (
220            "checks",
221            "check states are derived views; the verdicts in them belong to their recording callers",
222        ),
223        ("ledger", "descriptive view of the check ledger"),
224        (
225            "projection",
226            "projection fidelity is answered by status and projection verify",
227        ),
228    ],
229};
230
231/// The overview surface's coverage claim, shared by every consumer
232/// that renders the composed overview (the CLI command and both MCP
233/// servers), and stamped into the composed frontmatter by
234/// `compose_overview` itself so the declaration and the output cannot
235/// diverge.
236pub const OVERVIEW_COVERAGE: AxisCoverage = AxisCoverage {
237    examined: &["mounts", "config"],
238    excluded: &[
239        ("orphans", OVERVIEW_SCOPE),
240        ("stubs", OVERVIEW_SCOPE),
241        ("most_connected", OVERVIEW_SCOPE),
242        ("missing_fields", OVERVIEW_SCOPE),
243        ("stale", OVERVIEW_SCOPE),
244        (
245            "dangling_links",
246            "rendered on request as a listing; the verdict over them is health's",
247        ),
248        ("tags", OVERVIEW_SCOPE),
249        ("missing_required_outgoing", OVERVIEW_SCOPE),
250        ("constraints", OVERVIEW_SCOPE),
251        ("signals", OVERVIEW_SCOPE),
252        ("labelling", OVERVIEW_SCOPE),
253        ("conformance", OVERVIEW_SCOPE),
254        ("integrity", OVERVIEW_SCOPE),
255        ("anchors", OVERVIEW_SCOPE),
256        ("friction", OVERVIEW_SCOPE),
257        ("open_questions", OVERVIEW_SCOPE),
258        (
259            "vital_signs",
260            "descriptive model-truth counts; the remodel skill holds the thresholds",
261        ),
262        ("stale_derivations", OVERVIEW_SCOPE),
263        ("checks", OVERVIEW_SCOPE),
264        ("ledger", OVERVIEW_SCOPE),
265        ("projection", OVERVIEW_SCOPE),
266    ],
267};
268
269const OVERVIEW_SCOPE: &str = "overview is a descriptive composition; its only \
270     all-clear claim is that the roster it renders is complete and its mounts serve";
271
272/// Hold a registry against the axis vocabulary and a mechanically
273/// discovered surface roster. Returns one finding per defect; an
274/// empty result is the only clean outcome. Pure and total: callers
275/// in tests pass the live vocabulary and their own live walk,
276/// fixtures pass synthetic ones.
277///
278/// The findings, each mapped to the failure it refuses:
279/// - a discovered surface with no registry row (a surface landed
280///   without declaring),
281/// - a registry row no walk discovers (a stale declaration reading
282///   as coverage),
283/// - a duplicate row (two claims, no single truth),
284/// - an axis named by a declaration that the vocabulary does not
285///   carry (a stale axis reading as coverage),
286/// - an axis in the vocabulary that a verdict declaration neither
287///   examines nor excludes (a new axis met by silence: the clean
288///   verdict would cover it by omission),
289/// - an axis both examined and excluded (a contradiction),
290/// - an exclusion or no-verdict claim with an empty reason (a
291///   declaration that declares nothing).
292pub fn validate_coverage(
293    vocab: &[&str],
294    registry: &[SurfaceCoverage],
295    discovered: &[&str],
296) -> Vec<String> {
297    let mut findings = Vec::new();
298
299    for d in discovered {
300        if !registry.iter().any(|r| r.surface == *d) {
301            findings.push(format!(
302                "surface `{d}` is discoverable and has no coverage declaration: \
303                 declare its verdict axes, or declare why it emits no verdict"
304            ));
305        }
306    }
307
308    let mut seen: Vec<&str> = Vec::new();
309    for row in registry {
310        if seen.contains(&row.surface) {
311            findings.push(format!(
312                "surface `{}` is declared more than once",
313                row.surface
314            ));
315            continue;
316        }
317        seen.push(row.surface);
318
319        if !discovered.contains(&row.surface) {
320            findings.push(format!(
321                "declared surface `{}` is not discoverable: a stale declaration \
322                 reads as coverage, remove it or fix the walk",
323                row.surface
324            ));
325        }
326
327        match row.disposition {
328            CoverageDisposition::NoVerdict(reason) => {
329                if reason.trim().is_empty() {
330                    findings.push(format!(
331                        "surface `{}` declares no verdict without a reason",
332                        row.surface
333                    ));
334                }
335            }
336            CoverageDisposition::Verdict(cov) => {
337                for axis in cov.examined {
338                    if !vocab.contains(axis) {
339                        findings.push(format!(
340                            "surface `{}` examines axis `{axis}`, which the \
341                             vocabulary does not carry",
342                            row.surface
343                        ));
344                    }
345                    if cov.excluded.iter().any(|(a, _)| a == axis) {
346                        findings.push(format!(
347                            "surface `{}` both examines and excludes axis `{axis}`",
348                            row.surface
349                        ));
350                    }
351                }
352                for (axis, reason) in cov.excluded {
353                    if !vocab.contains(axis) {
354                        findings.push(format!(
355                            "surface `{}` excludes axis `{axis}`, which the \
356                             vocabulary does not carry",
357                            row.surface
358                        ));
359                    }
360                    if reason.trim().is_empty() {
361                        findings.push(format!(
362                            "surface `{}` excludes axis `{axis}` without a reason",
363                            row.surface
364                        ));
365                    }
366                }
367                for axis in vocab {
368                    let examined = cov.examined.contains(axis);
369                    let excluded = cov.excluded.iter().any(|(a, _)| a == axis);
370                    if !examined && !excluded {
371                        findings.push(format!(
372                            "surface `{}` declares nothing for axis `{axis}`: \
373                             its clean verdict would cover the axis by omission, \
374                             examine it or exclude it with a reason",
375                            row.surface
376                        ));
377                    }
378                }
379            }
380        }
381    }
382
383    findings
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    const VOCAB: &[&str] = &["anchors", "mounts"];
391
392    fn full() -> SurfaceCoverage {
393        SurfaceCoverage {
394            surface: "verify",
395            disposition: CoverageDisposition::Verdict(AxisCoverage {
396                examined: &["anchors"],
397                excluded: &[("mounts", "the roster surface answers for mounts")],
398            }),
399        }
400    }
401
402    fn ledger() -> SurfaceCoverage {
403        SurfaceCoverage {
404            surface: "check",
405            disposition: CoverageDisposition::NoVerdict(
406                "records the caller's verdict about the caller's own work",
407            ),
408        }
409    }
410
411    /// The complement: a registry that declares everything, over a
412    /// walk that finds exactly the declared surfaces, is clean, and
413    /// the only burden it carried was the declaration itself.
414    #[test]
415    fn complete_registry_is_clean() {
416        let findings = validate_coverage(VOCAB, &[full(), ledger()], &["verify", "check"]);
417        assert!(findings.is_empty(), "{findings:?}");
418    }
419
420    /// The gate red, fixture one: a surface whose clean verdict
421    /// covers an axis by omission. This reproduces the sweep's
422    /// condition shape independently of whether the sweep happened,
423    /// since the fixture is synthetic.
424    #[test]
425    fn clean_over_an_unexamined_axis_fails() {
426        let silent = SurfaceCoverage {
427            surface: "verify",
428            disposition: CoverageDisposition::Verdict(AxisCoverage {
429                examined: &["anchors"],
430                excluded: &[],
431            }),
432        };
433        let findings = validate_coverage(VOCAB, &[silent, ledger()], &["verify", "check"]);
434        assert!(
435            findings.iter().any(|f| f.contains("`verify`")
436                && f.contains("`mounts`")
437                && f.contains("by omission")),
438            "{findings:?}"
439        );
440    }
441
442    /// The gate red, fixture two: an axis is introduced and an
443    /// existing declaration is not updated. This is the recurrence
444    /// case; a gate that passes here is a one-time sweep.
445    #[test]
446    fn axis_added_without_declaration_update_fails() {
447        let grown: &[&str] = &["anchors", "mounts", "fences"];
448        let findings = validate_coverage(grown, &[full(), ledger()], &["verify", "check"]);
449        assert!(
450            findings
451                .iter()
452                .any(|f| f.contains("`verify`") && f.contains("`fences`")),
453            "{findings:?}"
454        );
455    }
456
457    /// A surface that landed without any declaration fails.
458    #[test]
459    fn undeclared_surface_fails() {
460        let findings = validate_coverage(VOCAB, &[full(), ledger()], &["verify", "check", "dump"]);
461        assert!(
462            findings
463                .iter()
464                .any(|f| f.contains("`dump`") && f.contains("no coverage declaration")),
465            "{findings:?}"
466        );
467    }
468
469    /// A declaration whose surface departed fails rather than skips.
470    #[test]
471    fn stale_surface_declaration_fails() {
472        let findings = validate_coverage(VOCAB, &[full(), ledger()], &["check"]);
473        assert!(
474            findings
475                .iter()
476                .any(|f| f.contains("`verify`") && f.contains("not discoverable")),
477            "{findings:?}"
478        );
479    }
480
481    /// An axis dropped from the vocabulary turns the declarations
482    /// naming it into findings, so a stale axis cannot read as
483    /// coverage.
484    #[test]
485    fn stale_axis_in_declaration_fails() {
486        let shrunk: &[&str] = &["anchors"];
487        let findings = validate_coverage(shrunk, &[full(), ledger()], &["verify", "check"]);
488        assert!(
489            findings
490                .iter()
491                .any(|f| f.contains("`mounts`") && f.contains("does not carry")),
492            "{findings:?}"
493        );
494    }
495
496    /// Excluding an axis without a reason fails: an unexplained
497    /// exclusion is a silent drop with paperwork.
498    #[test]
499    fn exclusion_without_reason_fails() {
500        let bare = SurfaceCoverage {
501            surface: "verify",
502            disposition: CoverageDisposition::Verdict(AxisCoverage {
503                examined: &["anchors"],
504                excluded: &[("mounts", "  ")],
505            }),
506        };
507        let findings = validate_coverage(VOCAB, &[bare, ledger()], &["verify", "check"]);
508        assert!(
509            findings.iter().any(|f| f.contains("without a reason")),
510            "{findings:?}"
511        );
512    }
513
514    /// Examining and excluding the same axis is a contradiction, not
515    /// a double assurance.
516    #[test]
517    fn examined_and_excluded_fails() {
518        let both = SurfaceCoverage {
519            surface: "verify",
520            disposition: CoverageDisposition::Verdict(AxisCoverage {
521                examined: &["anchors", "mounts"],
522                excluded: &[("mounts", "also excluded")],
523            }),
524        };
525        let findings = validate_coverage(VOCAB, &[both, ledger()], &["verify", "check"]);
526        assert!(
527            findings
528                .iter()
529                .any(|f| f.contains("both examines and excludes")),
530            "{findings:?}"
531        );
532    }
533
534    /// Two rows for one surface fail: two claims, no single truth.
535    #[test]
536    fn duplicate_declaration_fails() {
537        let findings = validate_coverage(VOCAB, &[full(), full(), ledger()], &["verify", "check"]);
538        assert!(
539            findings
540                .iter()
541                .any(|f| f.contains("declared more than once")),
542            "{findings:?}"
543        );
544    }
545
546    /// The live vocabulary is the health roster plus the declared
547    /// extras, nothing more: composition, not a copy that can drift.
548    #[test]
549    fn vocabulary_composes_health_roster() {
550        let axes = verdict_axes();
551        for key in HEALTH_INCLUDE_KEYS {
552            assert!(axes.contains(key), "health include `{key}` missing");
553        }
554        for key in EXTRA_VERDICT_AXES {
555            assert!(axes.contains(key), "extra axis `{key}` missing");
556        }
557        assert_eq!(
558            axes.len(),
559            HEALTH_INCLUDE_KEYS.len() + EXTRA_VERDICT_AXES.len()
560        );
561    }
562}