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, which it reports beside the verdict without folding
64/// them in, and which lie outside its scope altogether. The three
65/// lists must jointly name every axis in the vocabulary; a blanket
66/// "everything else" clause is deliberately impossible, because it
67/// would swallow a newly introduced axis silently, and the one
68/// permanent property this module owes is that a new axis fails
69/// every declaration that has not met it.
70///
71/// The three buckets, as they read on the wire:
72/// - `examined`: the verdict answers for the axis; a finding there
73/// makes the verdict fail.
74/// - `advisory`: the surface renders the axis (always, or on request)
75/// beside the verdict and never folds it in; a reader sees the
76/// figures, the verdict says nothing about them. Filing these under
77/// "not examined" read, to every independent reader so far, as "not
78/// looked at", and a gate reading the word would refuse a report
79/// that had done the work.
80/// - `not_examined`: the surface does not look at the axis at all;
81/// the reason names the surface that answers for it.
82#[derive(Debug, Clone, Copy)]
83pub struct AxisCoverage {
84 /// Axes the surface's clean verdict actually examined.
85 pub examined: &'static [&'static str],
86 /// Axes the surface reports beside its verdict without folding
87 /// them in, each with the reason a reader needs.
88 pub advisory: &'static [(&'static str, &'static str)],
89 /// Axes the surface does not examine, each with the reason a
90 /// reader needs (typically: which surface answers for it instead).
91 pub not_examined: &'static [(&'static str, &'static str)],
92}
93
94/// What a declared surface claims about verdicts.
95#[derive(Debug, Clone, Copy)]
96pub enum CoverageDisposition {
97 /// The surface can emit a clean/ok verdict and declares its axes.
98 Verdict(AxisCoverage),
99 /// The surface emits no clean/ok verdict; the reason says why the
100 /// rule does not bind it (it returns data, it reports what a
101 /// mutation did, or its verdict belongs to the caller).
102 NoVerdict(&'static str),
103}
104
105/// One registry row: a surface name exactly as the consumer's own
106/// mechanical walk produces it, plus its disposition.
107#[derive(Debug, Clone, Copy)]
108pub struct SurfaceCoverage {
109 pub surface: &'static str,
110 pub disposition: CoverageDisposition,
111}
112
113impl AxisCoverage {
114 /// The examined set as it is stamped into surface output.
115 pub fn examined_wire(&self) -> Vec<&'static str> {
116 self.examined.to_vec()
117 }
118
119 /// The advisory axes as they are stamped into surface output:
120 /// `(axis, reason)` pairs, so a reader can see which axes the
121 /// surface reports beside its verdict without reading the source.
122 pub fn advisory_wire(&self) -> Vec<(&'static str, &'static str)> {
123 self.advisory.to_vec()
124 }
125
126 /// The unexamined axes as they are stamped into surface output:
127 /// `(axis, reason)` pairs, so a reader can see which axes the
128 /// verdict does not cover without reading the source.
129 pub fn not_examined_wire(&self) -> Vec<(&'static str, &'static str)> {
130 self.not_examined.to_vec()
131 }
132
133 /// The declaration as it is stamped into surface output: one
134 /// compact line naming both axis sets, the same form on JSON,
135 /// markdown, and frontmatter surfaces. Axis names only: the
136 /// per-axis exclusion reasons stay a registry fact the gate test
137 /// enforces, because stamping static prose into every response
138 /// would tax each call's token budget, and the reader's question
139 /// the stamp answers is WHICH axes the verdict covers.
140 pub fn wire_line(&self) -> String {
141 self.wire_line_promoting(&[])
142 }
143
144 /// The wire line with the named advisory or unexamined axes
145 /// promoted into the examined set — for a report that rendered an
146 /// opt-in axis this pass (`--include anchors`) and therefore did
147 /// examine it. An axis in neither list is ignored; the static
148 /// declaration is untouched.
149 pub fn wire_line_promoting(&self, promoted: &[&str]) -> String {
150 let mut examined: Vec<&str> = self.examined.to_vec();
151 let mut advisory: Vec<&str> = Vec::new();
152 let mut not_examined: Vec<&str> = Vec::new();
153 for (a, _) in self.advisory {
154 if promoted.contains(a) {
155 examined.push(a);
156 } else {
157 advisory.push(a);
158 }
159 }
160 for (a, _) in self.not_examined {
161 if promoted.contains(a) {
162 examined.push(a);
163 } else {
164 not_examined.push(a);
165 }
166 }
167 format!(
168 "examined={}; advisory={}; not_examined={}",
169 examined.join(","),
170 advisory.join(","),
171 not_examined.join(",")
172 )
173 }
174}
175
176impl SurfaceCoverage {
177 /// The verdict declaration, when this row carries one; the
178 /// stamping sites use it so a surface can only stamp what its
179 /// registry row declares.
180 pub fn axis_coverage(&self) -> Option<&AxisCoverage> {
181 match &self.disposition {
182 CoverageDisposition::Verdict(c) => Some(c),
183 CoverageDisposition::NoVerdict(_) => None,
184 }
185 }
186}
187
188/// The health surface's coverage claim, shared by every consumer
189/// that renders a health report (the CLI command, the full MCP
190/// server's composer, and the lean server's own assembly): the axes
191/// whose findings the report treats as defects, so an empty defect
192/// statement reads as an all-clear exactly over them. Everything
193/// descriptive or advisory is excluded by name.
194pub const HEALTH_COVERAGE: AxisCoverage = AxisCoverage {
195 examined: &[
196 "dangling_links",
197 "missing_required_outgoing",
198 "constraints",
199 "signals",
200 "integrity",
201 "config",
202 "mounts",
203 ],
204 // Every axis health renders (always, or on `--include`) without
205 // folding it into the defect verdict. `anchors` is promoted into
206 // the examined set for the pass that rendered it.
207 advisory: &[
208 (
209 "orphans",
210 "descriptive list; the defect verdict polices orphan stubs through the integrity findings",
211 ),
212 (
213 "stubs",
214 "descriptive list; the defect verdict polices orphan stubs through the integrity findings",
215 ),
216 (
217 "most_connected",
218 "descriptive ranking with no pass/fail semantics",
219 ),
220 (
221 "missing_fields",
222 "advisory count, never part of the defect verdict",
223 ),
224 (
225 "stale",
226 "advisory freshness, never part of the defect verdict",
227 ),
228 (
229 "tags",
230 "descriptive distribution with no pass/fail semantics",
231 ),
232 (
233 "labelling",
234 "advisory audit, never part of the defect verdict",
235 ),
236 (
237 "conformance",
238 "reported per entity beside the verdict, never folded into it",
239 ),
240 (
241 "anchors",
242 "drifted anchors stay advisory; the verify surfaces carry the drift statement",
243 ),
244 ("friction", "descriptive ledger counts"),
245 ("open_questions", "descriptive listing of open questions"),
246 (
247 "vital_signs",
248 "descriptive model-truth counts; the remodel skill holds the thresholds",
249 ),
250 (
251 "stale_derivations",
252 "advisory freshness of derived artifacts",
253 ),
254 (
255 "checks",
256 "check states are derived views; the verdicts in them belong to their recording callers",
257 ),
258 ("ledger", "descriptive view of the check ledger"),
259 ],
260 not_examined: &[(
261 "projection",
262 "projection fidelity is answered by status and projection verify",
263 )],
264};
265
266/// The overview surface's coverage claim, shared by every consumer
267/// that renders the composed overview (the CLI command and both MCP
268/// servers), and stamped into the composed frontmatter by
269/// `compose_overview` itself so the declaration and the output cannot
270/// diverge.
271pub const OVERVIEW_COVERAGE: AxisCoverage = AxisCoverage {
272 examined: &["mounts", "config"],
273 advisory: &[(
274 "dangling_links",
275 "rendered on request as a listing; the verdict over them is health's",
276 )],
277 not_examined: &[
278 ("orphans", OVERVIEW_SCOPE),
279 ("stubs", OVERVIEW_SCOPE),
280 ("most_connected", OVERVIEW_SCOPE),
281 ("missing_fields", OVERVIEW_SCOPE),
282 ("stale", OVERVIEW_SCOPE),
283 ("tags", OVERVIEW_SCOPE),
284 ("missing_required_outgoing", OVERVIEW_SCOPE),
285 ("constraints", OVERVIEW_SCOPE),
286 ("signals", OVERVIEW_SCOPE),
287 ("labelling", OVERVIEW_SCOPE),
288 ("conformance", OVERVIEW_SCOPE),
289 ("integrity", OVERVIEW_SCOPE),
290 ("anchors", OVERVIEW_SCOPE),
291 ("friction", OVERVIEW_SCOPE),
292 ("open_questions", OVERVIEW_SCOPE),
293 ("vital_signs", OVERVIEW_SCOPE),
294 ("stale_derivations", OVERVIEW_SCOPE),
295 ("checks", OVERVIEW_SCOPE),
296 ("ledger", OVERVIEW_SCOPE),
297 ("projection", OVERVIEW_SCOPE),
298 ],
299};
300
301const OVERVIEW_SCOPE: &str = "overview is a descriptive composition; its only \
302 all-clear claim is that the roster it renders is complete and its mounts serve";
303
304/// Hold a registry against the axis vocabulary and a mechanically
305/// discovered surface roster. Returns one finding per defect; an
306/// empty result is the only clean outcome. Pure and total: callers
307/// in tests pass the live vocabulary and their own live walk,
308/// fixtures pass synthetic ones.
309///
310/// The findings, each mapped to the failure it refuses:
311/// - a discovered surface with no registry row (a surface landed
312/// without declaring),
313/// - a registry row no walk discovers (a stale declaration reading
314/// as coverage),
315/// - a duplicate row (two claims, no single truth),
316/// - an axis named by a declaration that the vocabulary does not
317/// carry (a stale axis reading as coverage),
318/// - an axis in the vocabulary that a verdict declaration neither
319/// examines, reports as advisory, nor excludes (a new axis met by
320/// silence: the clean verdict would cover it by omission),
321/// - an axis in more than one bucket (a contradiction),
322/// - an advisory, exclusion or no-verdict claim with an empty reason
323/// (a declaration that declares nothing).
324pub fn validate_coverage(
325 vocab: &[&str],
326 registry: &[SurfaceCoverage],
327 discovered: &[&str],
328) -> Vec<String> {
329 let mut findings = Vec::new();
330
331 for d in discovered {
332 if !registry.iter().any(|r| r.surface == *d) {
333 findings.push(format!(
334 "surface `{d}` is discoverable and has no coverage declaration: \
335 declare its verdict axes, or declare why it emits no verdict"
336 ));
337 }
338 }
339
340 let mut seen: Vec<&str> = Vec::new();
341 for row in registry {
342 if seen.contains(&row.surface) {
343 findings.push(format!(
344 "surface `{}` is declared more than once",
345 row.surface
346 ));
347 continue;
348 }
349 seen.push(row.surface);
350
351 if !discovered.contains(&row.surface) {
352 findings.push(format!(
353 "declared surface `{}` is not discoverable: a stale declaration \
354 reads as coverage, remove it or fix the walk",
355 row.surface
356 ));
357 }
358
359 match row.disposition {
360 CoverageDisposition::NoVerdict(reason) => {
361 if reason.trim().is_empty() {
362 findings.push(format!(
363 "surface `{}` declares no verdict without a reason",
364 row.surface
365 ));
366 }
367 }
368 CoverageDisposition::Verdict(cov) => {
369 let in_advisory = |axis: &str| cov.advisory.iter().any(|(a, _)| *a == axis);
370 let in_not_examined = |axis: &str| cov.not_examined.iter().any(|(a, _)| *a == axis);
371 for axis in cov.examined {
372 if !vocab.contains(axis) {
373 findings.push(format!(
374 "surface `{}` examines axis `{axis}`, which the \
375 vocabulary does not carry",
376 row.surface
377 ));
378 }
379 if in_advisory(axis) || in_not_examined(axis) {
380 findings.push(format!(
381 "surface `{}` both examines and excludes axis `{axis}`",
382 row.surface
383 ));
384 }
385 }
386 for (bucket, rows) in [
387 ("advisory", cov.advisory),
388 ("not_examined", cov.not_examined),
389 ] {
390 for (axis, reason) in rows {
391 if !vocab.contains(axis) {
392 findings.push(format!(
393 "surface `{}` files axis `{axis}` as {bucket}, which the \
394 vocabulary does not carry",
395 row.surface
396 ));
397 }
398 if reason.trim().is_empty() {
399 findings.push(format!(
400 "surface `{}` files axis `{axis}` as {bucket} without a reason",
401 row.surface
402 ));
403 }
404 }
405 }
406 for (axis, _) in cov.advisory {
407 if in_not_examined(axis) {
408 findings.push(format!(
409 "surface `{}` files axis `{axis}` as both advisory and not_examined",
410 row.surface
411 ));
412 }
413 }
414 for axis in vocab {
415 let examined = cov.examined.contains(axis);
416 if !examined && !in_advisory(axis) && !in_not_examined(axis) {
417 findings.push(format!(
418 "surface `{}` declares nothing for axis `{axis}`: \
419 its clean verdict would cover the axis by omission, \
420 examine it, report it as advisory, or exclude it with a reason",
421 row.surface
422 ));
423 }
424 }
425 }
426 }
427 }
428
429 findings
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 const VOCAB: &[&str] = &["anchors", "mounts"];
437
438 fn full() -> SurfaceCoverage {
439 SurfaceCoverage {
440 surface: "verify",
441 disposition: CoverageDisposition::Verdict(AxisCoverage {
442 examined: &["anchors"],
443 advisory: &[],
444 not_examined: &[("mounts", "the roster surface answers for mounts")],
445 }),
446 }
447 }
448
449 fn ledger() -> SurfaceCoverage {
450 SurfaceCoverage {
451 surface: "check",
452 disposition: CoverageDisposition::NoVerdict(
453 "records the caller's verdict about the caller's own work",
454 ),
455 }
456 }
457
458 /// The complement: a registry that declares everything, over a
459 /// walk that finds exactly the declared surfaces, is clean, and
460 /// the only burden it carried was the declaration itself.
461 #[test]
462 fn complete_registry_is_clean() {
463 let findings = validate_coverage(VOCAB, &[full(), ledger()], &["verify", "check"]);
464 assert!(findings.is_empty(), "{findings:?}");
465 }
466
467 /// The gate red, fixture one: a surface whose clean verdict
468 /// covers an axis by omission. This reproduces the sweep's
469 /// condition shape independently of whether the sweep happened,
470 /// since the fixture is synthetic.
471 #[test]
472 fn clean_over_an_unexamined_axis_fails() {
473 let silent = SurfaceCoverage {
474 surface: "verify",
475 disposition: CoverageDisposition::Verdict(AxisCoverage {
476 examined: &["anchors"],
477 advisory: &[],
478 not_examined: &[],
479 }),
480 };
481 let findings = validate_coverage(VOCAB, &[silent, ledger()], &["verify", "check"]);
482 assert!(
483 findings.iter().any(|f| f.contains("`verify`")
484 && f.contains("`mounts`")
485 && f.contains("by omission")),
486 "{findings:?}"
487 );
488 }
489
490 /// The gate red, fixture two: an axis is introduced and an
491 /// existing declaration is not updated. This is the recurrence
492 /// case; a gate that passes here is a one-time sweep.
493 #[test]
494 fn axis_added_without_declaration_update_fails() {
495 let grown: &[&str] = &["anchors", "mounts", "fences"];
496 let findings = validate_coverage(grown, &[full(), ledger()], &["verify", "check"]);
497 assert!(
498 findings
499 .iter()
500 .any(|f| f.contains("`verify`") && f.contains("`fences`")),
501 "{findings:?}"
502 );
503 }
504
505 /// A surface that landed without any declaration fails.
506 #[test]
507 fn undeclared_surface_fails() {
508 let findings = validate_coverage(VOCAB, &[full(), ledger()], &["verify", "check", "dump"]);
509 assert!(
510 findings
511 .iter()
512 .any(|f| f.contains("`dump`") && f.contains("no coverage declaration")),
513 "{findings:?}"
514 );
515 }
516
517 /// A declaration whose surface departed fails rather than skips.
518 #[test]
519 fn stale_surface_declaration_fails() {
520 let findings = validate_coverage(VOCAB, &[full(), ledger()], &["check"]);
521 assert!(
522 findings
523 .iter()
524 .any(|f| f.contains("`verify`") && f.contains("not discoverable")),
525 "{findings:?}"
526 );
527 }
528
529 /// An axis dropped from the vocabulary turns the declarations
530 /// naming it into findings, so a stale axis cannot read as
531 /// coverage.
532 #[test]
533 fn stale_axis_in_declaration_fails() {
534 let shrunk: &[&str] = &["anchors"];
535 let findings = validate_coverage(shrunk, &[full(), ledger()], &["verify", "check"]);
536 assert!(
537 findings
538 .iter()
539 .any(|f| f.contains("`mounts`") && f.contains("does not carry")),
540 "{findings:?}"
541 );
542 }
543
544 /// Excluding an axis without a reason fails: an unexplained
545 /// exclusion is a silent drop with paperwork.
546 #[test]
547 fn exclusion_without_reason_fails() {
548 let bare = SurfaceCoverage {
549 surface: "verify",
550 disposition: CoverageDisposition::Verdict(AxisCoverage {
551 examined: &["anchors"],
552 advisory: &[],
553 not_examined: &[("mounts", " ")],
554 }),
555 };
556 let findings = validate_coverage(VOCAB, &[bare, ledger()], &["verify", "check"]);
557 assert!(
558 findings.iter().any(|f| f.contains("without a reason")),
559 "{findings:?}"
560 );
561 }
562
563 /// Examining and excluding the same axis is a contradiction, not
564 /// a double assurance.
565 #[test]
566 fn examined_and_excluded_fails() {
567 let both = SurfaceCoverage {
568 surface: "verify",
569 disposition: CoverageDisposition::Verdict(AxisCoverage {
570 examined: &["anchors", "mounts"],
571 advisory: &[],
572 not_examined: &[("mounts", "also excluded")],
573 }),
574 };
575 let findings = validate_coverage(VOCAB, &[both, ledger()], &["verify", "check"]);
576 assert!(
577 findings
578 .iter()
579 .any(|f| f.contains("both examines and excludes")),
580 "{findings:?}"
581 );
582 }
583
584 /// Two rows for one surface fail: two claims, no single truth.
585 #[test]
586 fn duplicate_declaration_fails() {
587 let findings = validate_coverage(VOCAB, &[full(), full(), ledger()], &["verify", "check"]);
588 assert!(
589 findings
590 .iter()
591 .any(|f| f.contains("declared more than once")),
592 "{findings:?}"
593 );
594 }
595
596 /// An axis filed as both advisory and not examined is a
597 /// contradiction, not a double assurance.
598 #[test]
599 fn advisory_and_not_examined_fails() {
600 let both = SurfaceCoverage {
601 surface: "verify",
602 disposition: CoverageDisposition::Verdict(AxisCoverage {
603 examined: &["anchors"],
604 advisory: &[("mounts", "rendered beside the verdict")],
605 not_examined: &[("mounts", "the roster surface answers for mounts")],
606 }),
607 };
608 let findings = validate_coverage(VOCAB, &[both, ledger()], &["verify", "check"]);
609 assert!(
610 findings
611 .iter()
612 .any(|f| f.contains("both advisory and not_examined")),
613 "{findings:?}"
614 );
615 }
616
617 /// The wire line names all three buckets, and a promoted axis
618 /// leaves its bucket for the examined set.
619 #[test]
620 fn wire_line_names_three_buckets_and_promotes() {
621 let cov = AxisCoverage {
622 examined: &["mounts"],
623 advisory: &[("anchors", "rendered on request")],
624 not_examined: &[("projection", "another surface answers")],
625 };
626 assert_eq!(
627 cov.wire_line(),
628 "examined=mounts; advisory=anchors; not_examined=projection"
629 );
630 assert_eq!(
631 cov.wire_line_promoting(&["anchors"]),
632 "examined=mounts,anchors; advisory=; not_examined=projection"
633 );
634 }
635
636 /// The health declaration files every axis it can render as
637 /// examined or advisory and only the axis it never renders as not
638 /// examined: the bucket names say what the report did.
639 #[test]
640 fn health_declaration_buckets_follow_what_the_report_renders() {
641 let cov = HEALTH_COVERAGE;
642 for key in HEALTH_INCLUDE_KEYS {
643 let examined = cov.examined.contains(key);
644 let advisory = cov.advisory.iter().any(|(a, _)| a == key);
645 assert!(
646 examined || advisory,
647 "health include `{key}` renders on request, so it is examined or advisory"
648 );
649 }
650 assert_eq!(
651 cov.not_examined.iter().map(|(a, _)| *a).collect::<Vec<_>>(),
652 vec!["projection"]
653 );
654 }
655
656 /// The live vocabulary is the health roster plus the declared
657 /// extras, nothing more: composition, not a copy that can drift.
658 #[test]
659 fn vocabulary_composes_health_roster() {
660 let axes = verdict_axes();
661 for key in HEALTH_INCLUDE_KEYS {
662 assert!(axes.contains(key), "health include `{key}` missing");
663 }
664 for key in EXTRA_VERDICT_AXES {
665 assert!(axes.contains(key), "extra axis `{key}` missing");
666 }
667 assert_eq!(
668 axes.len(),
669 HEALTH_INCLUDE_KEYS.len() + EXTRA_VERDICT_AXES.len()
670 );
671 }
672}