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 let not_examined: Vec<&str> = self.excluded.iter().map(|(a, _)| *a).collect();
119 format!(
120 "examined={}; not_examined={}",
121 self.examined.join(","),
122 not_examined.join(",")
123 )
124 }
125}
126
127impl SurfaceCoverage {
128 /// The verdict declaration, when this row carries one; the
129 /// stamping sites use it so a surface can only stamp what its
130 /// registry row declares.
131 pub fn axis_coverage(&self) -> Option<&AxisCoverage> {
132 match &self.disposition {
133 CoverageDisposition::Verdict(c) => Some(c),
134 CoverageDisposition::NoVerdict(_) => None,
135 }
136 }
137}
138
139/// The health surface's coverage claim, shared by every consumer
140/// that renders a health report (the CLI command, the full MCP
141/// server's composer, and the lean server's own assembly): the axes
142/// whose findings the report treats as defects, so an empty defect
143/// statement reads as an all-clear exactly over them. Everything
144/// descriptive or advisory is excluded by name.
145pub const HEALTH_COVERAGE: AxisCoverage = AxisCoverage {
146 examined: &[
147 "dangling_links",
148 "missing_required_outgoing",
149 "constraints",
150 "signals",
151 "integrity",
152 "config",
153 "mounts",
154 ],
155 excluded: &[
156 (
157 "orphans",
158 "descriptive list; the defect verdict polices orphan stubs through the integrity findings",
159 ),
160 (
161 "stubs",
162 "descriptive list; the defect verdict polices orphan stubs through the integrity findings",
163 ),
164 (
165 "most_connected",
166 "descriptive ranking with no pass/fail semantics",
167 ),
168 (
169 "missing_fields",
170 "advisory count, never part of the defect verdict",
171 ),
172 (
173 "stale",
174 "advisory freshness, never part of the defect verdict",
175 ),
176 (
177 "tags",
178 "descriptive distribution with no pass/fail semantics",
179 ),
180 (
181 "labelling",
182 "advisory audit, never part of the defect verdict",
183 ),
184 (
185 "conformance",
186 "reported per entity beside the verdict, never folded into it",
187 ),
188 (
189 "anchors",
190 "drifted anchors stay advisory; the verify surfaces carry the drift statement",
191 ),
192 ("friction", "descriptive ledger counts"),
193 ("open_questions", "descriptive listing of open questions"),
194 (
195 "stale_derivations",
196 "advisory freshness of derived artifacts",
197 ),
198 (
199 "checks",
200 "check states are derived views; the verdicts in them belong to their recording callers",
201 ),
202 ("ledger", "descriptive view of the check ledger"),
203 (
204 "projection",
205 "projection fidelity is answered by status and projection verify",
206 ),
207 ],
208};
209
210/// The overview surface's coverage claim, shared by every consumer
211/// that renders the composed overview (the CLI command and both MCP
212/// servers), and stamped into the composed frontmatter by
213/// `compose_overview` itself so the declaration and the output cannot
214/// diverge.
215pub const OVERVIEW_COVERAGE: AxisCoverage = AxisCoverage {
216 examined: &["mounts", "config"],
217 excluded: &[
218 ("orphans", OVERVIEW_SCOPE),
219 ("stubs", OVERVIEW_SCOPE),
220 ("most_connected", OVERVIEW_SCOPE),
221 ("missing_fields", OVERVIEW_SCOPE),
222 ("stale", OVERVIEW_SCOPE),
223 (
224 "dangling_links",
225 "rendered on request as a listing; the verdict over them is health's",
226 ),
227 ("tags", OVERVIEW_SCOPE),
228 ("missing_required_outgoing", OVERVIEW_SCOPE),
229 ("constraints", OVERVIEW_SCOPE),
230 ("signals", OVERVIEW_SCOPE),
231 ("labelling", OVERVIEW_SCOPE),
232 ("conformance", OVERVIEW_SCOPE),
233 ("integrity", OVERVIEW_SCOPE),
234 ("anchors", OVERVIEW_SCOPE),
235 ("friction", OVERVIEW_SCOPE),
236 ("open_questions", OVERVIEW_SCOPE),
237 ("stale_derivations", OVERVIEW_SCOPE),
238 ("checks", OVERVIEW_SCOPE),
239 ("ledger", OVERVIEW_SCOPE),
240 ("projection", OVERVIEW_SCOPE),
241 ],
242};
243
244const OVERVIEW_SCOPE: &str = "overview is a descriptive composition; its only \
245 all-clear claim is that the roster it renders is complete and its mounts serve";
246
247/// Hold a registry against the axis vocabulary and a mechanically
248/// discovered surface roster. Returns one finding per defect; an
249/// empty result is the only clean outcome. Pure and total: callers
250/// in tests pass the live vocabulary and their own live walk,
251/// fixtures pass synthetic ones.
252///
253/// The findings, each mapped to the failure it refuses:
254/// - a discovered surface with no registry row (a surface landed
255/// without declaring),
256/// - a registry row no walk discovers (a stale declaration reading
257/// as coverage),
258/// - a duplicate row (two claims, no single truth),
259/// - an axis named by a declaration that the vocabulary does not
260/// carry (a stale axis reading as coverage),
261/// - an axis in the vocabulary that a verdict declaration neither
262/// examines nor excludes (a new axis met by silence: the clean
263/// verdict would cover it by omission),
264/// - an axis both examined and excluded (a contradiction),
265/// - an exclusion or no-verdict claim with an empty reason (a
266/// declaration that declares nothing).
267pub fn validate_coverage(
268 vocab: &[&str],
269 registry: &[SurfaceCoverage],
270 discovered: &[&str],
271) -> Vec<String> {
272 let mut findings = Vec::new();
273
274 for d in discovered {
275 if !registry.iter().any(|r| r.surface == *d) {
276 findings.push(format!(
277 "surface `{d}` is discoverable and has no coverage declaration: \
278 declare its verdict axes, or declare why it emits no verdict"
279 ));
280 }
281 }
282
283 let mut seen: Vec<&str> = Vec::new();
284 for row in registry {
285 if seen.contains(&row.surface) {
286 findings.push(format!(
287 "surface `{}` is declared more than once",
288 row.surface
289 ));
290 continue;
291 }
292 seen.push(row.surface);
293
294 if !discovered.contains(&row.surface) {
295 findings.push(format!(
296 "declared surface `{}` is not discoverable: a stale declaration \
297 reads as coverage, remove it or fix the walk",
298 row.surface
299 ));
300 }
301
302 match row.disposition {
303 CoverageDisposition::NoVerdict(reason) => {
304 if reason.trim().is_empty() {
305 findings.push(format!(
306 "surface `{}` declares no verdict without a reason",
307 row.surface
308 ));
309 }
310 }
311 CoverageDisposition::Verdict(cov) => {
312 for axis in cov.examined {
313 if !vocab.contains(axis) {
314 findings.push(format!(
315 "surface `{}` examines axis `{axis}`, which the \
316 vocabulary does not carry",
317 row.surface
318 ));
319 }
320 if cov.excluded.iter().any(|(a, _)| a == axis) {
321 findings.push(format!(
322 "surface `{}` both examines and excludes axis `{axis}`",
323 row.surface
324 ));
325 }
326 }
327 for (axis, reason) in cov.excluded {
328 if !vocab.contains(axis) {
329 findings.push(format!(
330 "surface `{}` excludes axis `{axis}`, which the \
331 vocabulary does not carry",
332 row.surface
333 ));
334 }
335 if reason.trim().is_empty() {
336 findings.push(format!(
337 "surface `{}` excludes axis `{axis}` without a reason",
338 row.surface
339 ));
340 }
341 }
342 for axis in vocab {
343 let examined = cov.examined.contains(axis);
344 let excluded = cov.excluded.iter().any(|(a, _)| a == axis);
345 if !examined && !excluded {
346 findings.push(format!(
347 "surface `{}` declares nothing for axis `{axis}`: \
348 its clean verdict would cover the axis by omission, \
349 examine it or exclude it with a reason",
350 row.surface
351 ));
352 }
353 }
354 }
355 }
356 }
357
358 findings
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 const VOCAB: &[&str] = &["anchors", "mounts"];
366
367 fn full() -> SurfaceCoverage {
368 SurfaceCoverage {
369 surface: "verify",
370 disposition: CoverageDisposition::Verdict(AxisCoverage {
371 examined: &["anchors"],
372 excluded: &[("mounts", "the roster surface answers for mounts")],
373 }),
374 }
375 }
376
377 fn ledger() -> SurfaceCoverage {
378 SurfaceCoverage {
379 surface: "check",
380 disposition: CoverageDisposition::NoVerdict(
381 "records the caller's verdict about the caller's own work",
382 ),
383 }
384 }
385
386 /// The complement: a registry that declares everything, over a
387 /// walk that finds exactly the declared surfaces, is clean, and
388 /// the only burden it carried was the declaration itself.
389 #[test]
390 fn complete_registry_is_clean() {
391 let findings = validate_coverage(VOCAB, &[full(), ledger()], &["verify", "check"]);
392 assert!(findings.is_empty(), "{findings:?}");
393 }
394
395 /// The gate red, fixture one: a surface whose clean verdict
396 /// covers an axis by omission. This reproduces the sweep's
397 /// condition shape independently of whether the sweep happened,
398 /// since the fixture is synthetic.
399 #[test]
400 fn clean_over_an_unexamined_axis_fails() {
401 let silent = SurfaceCoverage {
402 surface: "verify",
403 disposition: CoverageDisposition::Verdict(AxisCoverage {
404 examined: &["anchors"],
405 excluded: &[],
406 }),
407 };
408 let findings = validate_coverage(VOCAB, &[silent, ledger()], &["verify", "check"]);
409 assert!(
410 findings.iter().any(|f| f.contains("`verify`")
411 && f.contains("`mounts`")
412 && f.contains("by omission")),
413 "{findings:?}"
414 );
415 }
416
417 /// The gate red, fixture two: an axis is introduced and an
418 /// existing declaration is not updated. This is the recurrence
419 /// case; a gate that passes here is a one-time sweep.
420 #[test]
421 fn axis_added_without_declaration_update_fails() {
422 let grown: &[&str] = &["anchors", "mounts", "fences"];
423 let findings = validate_coverage(grown, &[full(), ledger()], &["verify", "check"]);
424 assert!(
425 findings
426 .iter()
427 .any(|f| f.contains("`verify`") && f.contains("`fences`")),
428 "{findings:?}"
429 );
430 }
431
432 /// A surface that landed without any declaration fails.
433 #[test]
434 fn undeclared_surface_fails() {
435 let findings = validate_coverage(VOCAB, &[full(), ledger()], &["verify", "check", "dump"]);
436 assert!(
437 findings
438 .iter()
439 .any(|f| f.contains("`dump`") && f.contains("no coverage declaration")),
440 "{findings:?}"
441 );
442 }
443
444 /// A declaration whose surface departed fails rather than skips.
445 #[test]
446 fn stale_surface_declaration_fails() {
447 let findings = validate_coverage(VOCAB, &[full(), ledger()], &["check"]);
448 assert!(
449 findings
450 .iter()
451 .any(|f| f.contains("`verify`") && f.contains("not discoverable")),
452 "{findings:?}"
453 );
454 }
455
456 /// An axis dropped from the vocabulary turns the declarations
457 /// naming it into findings, so a stale axis cannot read as
458 /// coverage.
459 #[test]
460 fn stale_axis_in_declaration_fails() {
461 let shrunk: &[&str] = &["anchors"];
462 let findings = validate_coverage(shrunk, &[full(), ledger()], &["verify", "check"]);
463 assert!(
464 findings
465 .iter()
466 .any(|f| f.contains("`mounts`") && f.contains("does not carry")),
467 "{findings:?}"
468 );
469 }
470
471 /// Excluding an axis without a reason fails: an unexplained
472 /// exclusion is a silent drop with paperwork.
473 #[test]
474 fn exclusion_without_reason_fails() {
475 let bare = SurfaceCoverage {
476 surface: "verify",
477 disposition: CoverageDisposition::Verdict(AxisCoverage {
478 examined: &["anchors"],
479 excluded: &[("mounts", " ")],
480 }),
481 };
482 let findings = validate_coverage(VOCAB, &[bare, ledger()], &["verify", "check"]);
483 assert!(
484 findings.iter().any(|f| f.contains("without a reason")),
485 "{findings:?}"
486 );
487 }
488
489 /// Examining and excluding the same axis is a contradiction, not
490 /// a double assurance.
491 #[test]
492 fn examined_and_excluded_fails() {
493 let both = SurfaceCoverage {
494 surface: "verify",
495 disposition: CoverageDisposition::Verdict(AxisCoverage {
496 examined: &["anchors", "mounts"],
497 excluded: &[("mounts", "also excluded")],
498 }),
499 };
500 let findings = validate_coverage(VOCAB, &[both, ledger()], &["verify", "check"]);
501 assert!(
502 findings
503 .iter()
504 .any(|f| f.contains("both examines and excludes")),
505 "{findings:?}"
506 );
507 }
508
509 /// Two rows for one surface fail: two claims, no single truth.
510 #[test]
511 fn duplicate_declaration_fails() {
512 let findings = validate_coverage(VOCAB, &[full(), full(), ledger()], &["verify", "check"]);
513 assert!(
514 findings
515 .iter()
516 .any(|f| f.contains("declared more than once")),
517 "{findings:?}"
518 );
519 }
520
521 /// The live vocabulary is the health roster plus the declared
522 /// extras, nothing more: composition, not a copy that can drift.
523 #[test]
524 fn vocabulary_composes_health_roster() {
525 let axes = verdict_axes();
526 for key in HEALTH_INCLUDE_KEYS {
527 assert!(axes.contains(key), "health include `{key}` missing");
528 }
529 for key in EXTRA_VERDICT_AXES {
530 assert!(axes.contains(key), "extra axis `{key}` missing");
531 }
532 assert_eq!(
533 axes.len(),
534 HEALTH_INCLUDE_KEYS.len() + EXTRA_VERDICT_AXES.len()
535 );
536 }
537}