Skip to main content

memstead_base/ops/
integrity.rs

1//! Integrity linter — read-time conformance findings.
2//!
3//! The engine's schema validation runs at write time as refusals on
4//! `memstead_create` / `memstead_update` / `memstead_relate`. This module runs the
5//! same checks in a read context over the entities already on disk, so
6//! `memstead_health` can report the *conformance* axis: which entities of a
7//! mem would a write refuse under a given schema, and why.
8//!
9//! One validation truth, two contexts: every finding carries the same
10//! typed code (and the same recovery payload, via
11//! [`EngineError::code`] / [`EngineError::details`]) the corresponding
12//! write would refuse with. An entity that lints clean against schema
13//! S is accepted by a write under S, and vice versa — the linter never
14//! invents a parallel conformance vocabulary.
15//!
16//! Determinism: same store state and schema produce the same findings
17//! in the same order, byte for byte. Entities are visited in lexical
18//! id order; within one entity, checks run in a fixed sequence (type,
19//! section keys, required sections, metadata, required fields,
20//! relationships) and map/list iteration follows the entity's own
21//! deterministic on-disk order (`IndexMap` / `Vec`).
22
23use std::collections::HashMap;
24use std::sync::Arc;
25
26use indexmap::IndexMap;
27use memstead_schema::{Schema, SchemaRef};
28use serde::Serialize;
29
30use crate::engine::EngineError;
31use crate::engine::mutation::unknown_type_error;
32use crate::entity::Entity;
33use crate::runtime_validator::{
34    CrossMemRelCheck, READ_ONLY_METADATA_KEYS, RelationshipCheck, missing_required_fields,
35    missing_required_sections, parse_metadata_value, validate_cross_mem_edge, validate_rel_shape,
36    validate_rel_type, validate_section_keys,
37};
38use crate::store::Store;
39
40/// Which integrity axis a finding belongs to. Consistency findings
41/// (graph coherence: orphans, stubs, dangling links) come from the
42/// pre-existing health categories; conformance findings (entity vs
43/// schema) come from this linter.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "lowercase")]
46pub enum IntegrityAxis {
47    Consistency,
48    Conformance,
49}
50
51/// One per-entity BODY OBSERVATION — what an entity's stored body carries
52/// that its type does not declare (consistency-sweep 04/01).
53///
54/// **Deliberately not an [`IntegrityFinding`].** A finding is a thing to fix,
55/// and most of what this reports is nothing to fix: absorbing an undeclared
56/// heading into the catch-all is the feature working as designed, and making
57/// it a violation would fail every mem that uses the catch-all for the prose
58/// the schema did not anticipate. The distinction the reader needs is between
59/// content that was OBSERVED and content that was LOST, not between clean and
60/// dirty, so observations travel on their own channel and no observation can
61/// mark an entity unconformant.
62///
63/// What the conformance axis could see before this was a tautology: it linted
64/// `entity.sections.keys()`, which came out of the parser and are declared by
65/// construction. Every heading the file actually carried was invisible to it.
66#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
67pub struct BodyObservation {
68    pub id: String,
69    /// `ABSORBED_SECTION` | `UNDECLARED_METADATA_KEY` | `REPEATED_SECTION_HEADING`
70    pub code: String,
71    /// Whether the content survives the next write. This is the whole point of
72    /// the channel: `absorbed` content round-trips, `dropped` content does not,
73    /// and before this the reader could not tell which case they were in.
74    pub fate: ObservationFate,
75    pub detail: serde_json::Value,
76}
77
78/// What happens to the observed content on the next write.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "kebab-case")]
81pub enum ObservationFate {
82    /// Kept, byte-verbatim, in the type's catch-all section. Nothing to fix.
83    Absorbed,
84    /// NOT kept. The next write drops it, and the reader is told before that
85    /// write rather than after it.
86    Dropped,
87}
88
89/// One per-entity integrity finding — the stable wire shape
90/// `{ id, axis, code, detail }`.
91///
92/// `code` is drawn from the write-time typed-code vocabulary
93/// ([`EngineError::code`]) and `detail` mirrors that code's write-time
94/// recovery payload ([`EngineError::details`]).
95#[derive(Debug, Clone, Serialize)]
96pub struct IntegrityFinding {
97    pub id: String,
98    pub axis: IntegrityAxis,
99    pub code: String,
100    pub detail: serde_json::Value,
101}
102
103impl BodyObservation {
104    /// Test convenience: the recorded occurrence count of a repeated heading.
105    #[cfg(test)]
106    fn occurrences_is(&self, n: u64) -> bool {
107        self.detail["occurrences"].as_u64() == Some(n)
108    }
109}
110
111impl IntegrityFinding {
112    fn conformance(id: &crate::entity::EntityId, err: &EngineError) -> Self {
113        Self {
114            id: id.to_string(),
115            axis: IntegrityAxis::Conformance,
116            code: err.code().to_string(),
117            detail: err.details(),
118        }
119    }
120
121    /// A conformance finding whose detail the read path knows and the write
122    /// path cannot: a write sees one section's content, a read sees which
123    /// declared sections that content swallowed.
124    fn conformance_with_detail(
125        id: &crate::entity::EntityId,
126        code: &str,
127        detail: serde_json::Value,
128    ) -> Self {
129        Self {
130            id: id.to_string(),
131            axis: IntegrityAxis::Conformance,
132            code: code.to_string(),
133            detail,
134        }
135    }
136}
137
138/// The declared sections an unterminated fence in `body` has swallowed.
139///
140/// The parser masked their heading lines, so they never became section keys;
141/// their bytes sit verbatim inside `body`. Scanning the UNMASKED body for `## `
142/// lines and intersecting with the type's declared headings recovers exactly
143/// what the entity lost. Headings the type does not declare are left out on
144/// purpose: those are the catch-all's business (04/01), and naming them here
145/// would report the same bytes under two codes.
146pub(crate) fn swallowed_declared_sections(
147    body: &str,
148    type_def: &memstead_schema::TypeDefinition,
149) -> Vec<String> {
150    let declared: std::collections::BTreeSet<&str> = type_def
151        .sections
152        .iter()
153        .map(|s| s.heading.as_str())
154        .collect();
155    let mut out = Vec::new();
156    for line in body.lines() {
157        if let Some(heading) = line.strip_prefix("## ")
158            && declared.contains(heading.trim())
159            && !out.iter().any(|h| h == heading.trim())
160        {
161            out.push(heading.trim().to_string());
162        }
163    }
164    out
165}
166
167/// Run the conformance axis over every non-stub entity of `mem`,
168/// validating against `schema` (the mem's current pin, or an
169/// arbitrary target schema — the caller chooses the effective schema).
170///
171/// `mem_schemas` maps mem name → pinned schema for *every* mounted
172/// mem; it is consulted only to route relationship checks the same
173/// way the write path routes them (same schema *name* → intra-mem
174/// vocabulary of `schema`; different name → `schema`'s
175/// `cross_mem_relationships`). For cross-mem edges this is the
176/// read-time twin of the write-time `validate_cross_mem_edge` —
177/// including the target-entity type fetch — so target-type drift on
178/// existing edges surfaces here.
179pub fn conformance_findings(
180    store: &Store,
181    mem: &str,
182    schema: &Schema,
183    mem_schemas: &HashMap<String, Arc<Schema>>,
184) -> Vec<IntegrityFinding> {
185    let mut entities: Vec<&Entity> = store
186        .all_entities()
187        .filter(|e| e.mem == mem && !e.stub)
188        .collect();
189    entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
190
191    let mut findings = Vec::new();
192    for entity in entities {
193        lint_entity(store, entity, schema, mem_schemas, &mut findings);
194    }
195    findings
196}
197
198/// Every body observation for `mem`, in a stable order.
199///
200/// Reads what the FILE carried, not what the parser kept: `raw_section_headings`
201/// is the literal `## ` list in document order, and `entity.metadata` holds
202/// every frontmatter key that arrived, declared or not. Linting the parsed
203/// section keys instead (which is what the conformance axis does) can only ever
204/// answer a question it already knows: those keys are declared by construction.
205pub fn body_observations(store: &Store, mem: &str, schema: &Schema) -> Vec<BodyObservation> {
206    let mut entities: Vec<&Entity> = store
207        .all_entities()
208        .filter(|e| e.mem == mem && !e.stub)
209        .collect();
210    entities.sort_by(|a, b| a.id.0.cmp(&b.id.0));
211
212    let mut out = Vec::new();
213    for entity in entities {
214        let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
215            // An unknown type is already a conformance FINDING; observing its
216            // body on top would say the same thing twice in a weaker voice.
217            continue;
218        };
219        observe_entity(entity, type_def, &mut out);
220    }
221    out.sort_by(|a, b| {
222        a.id.cmp(&b.id)
223            .then_with(|| a.code.cmp(&b.code))
224            .then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
225    });
226    out
227}
228
229fn observe_entity(
230    entity: &Entity,
231    type_def: &memstead_schema::TypeDefinition,
232    out: &mut Vec<BodyObservation>,
233) {
234    // Compare on KEYS through `derive_section_key`, and chain the
235    // relationships block in, because that is exactly the set the parser's
236    // own `build_catch_all` treats as known. Comparing raw heading strings
237    // against `s.heading` looks equivalent and is not: it misses the
238    // engine's auto-managed `## Relationships` block, which no type
239    // declares and which the generator re-emits from the parsed relations
240    // on every write. Reporting it made every entity in a real mem carry an
241    // observation. The rule must be the parser's own key set, never a
242    // second spelling of it.
243    let known: std::collections::BTreeSet<String> = type_def
244        .sections
245        .iter()
246        .map(|s| s.key.clone())
247        .chain(std::iter::once("relationships".to_string()))
248        .collect();
249    let catch_all = type_def.catch_all_section();
250
251    // 1. Headings the file carried that the type does not declare. Absorbed
252    //    into the catch-all and kept byte-verbatim, UNLESS the body under them
253    //    is empty: the catch-all builder skips empty content, so a bare heading
254    //    line is the one case that really is dropped. That is the case the
255    //    original repro described.
256    let mut seen: std::collections::BTreeMap<&str, usize> = Default::default();
257    for heading in &entity.raw_section_headings {
258        let occurrence = {
259            let n = seen.entry(heading.as_str()).or_default();
260            *n += 1;
261            *n
262        };
263        if known.contains(&memstead_schema::derive_section_key(heading)) {
264            continue;
265        }
266        // Only the FIRST occurrence is absorbed. Splitting is first-wins, so a
267        // later occurrence's body is gone whatever the catch-all does, and
268        // emitting a second `ABSORBED_SECTION` for it claimed a survival it
269        // does not have. The repeat is reported on its own code below, which
270        // is where that loss belongs.
271        if occurrence > 1 {
272            continue;
273        }
274        let absorbed_into = catch_all.map(|c| c.key.as_str());
275        let kept = absorbed_into.is_some() && heading_has_body(entity, heading, catch_all);
276        out.push(BodyObservation {
277            id: entity.id.to_string(),
278            code: "ABSORBED_SECTION".to_string(),
279            fate: if kept {
280                ObservationFate::Absorbed
281            } else {
282                ObservationFate::Dropped
283            },
284            detail: serde_json::json!({
285                "heading": heading,
286                "entity_type": entity.entity_type,
287                "absorbed_into": absorbed_into,
288                "note": if kept {
289                    "the type does not declare this heading; its content is kept \
290                     byte-verbatim in the catch-all section and survives the next write"
291                } else if absorbed_into.is_some() {
292                    "the type does not declare this heading and its body is empty; the \
293                     catch-all skips empty content, so the next write does NOT keep it"
294                } else {
295                    "the type does not declare this heading and has no catch-all section, \
296                     so the next write does NOT keep it"
297                },
298            }),
299        });
300    }
301
302    // 2. A heading that appears twice. Section splitting is first-wins, so
303    //    every later body is silently gone. The existing duplicate-heading
304    //    warning is filtered through the DECLARED keys with the catch-all
305    //    excluded, which is why a repeat of an undeclared heading and a repeat
306    //    of the catch-all's own heading both produce no warning anywhere.
307    for (heading, count) in seen.iter().filter(|(_, n)| **n > 1) {
308        out.push(BodyObservation {
309            id: entity.id.to_string(),
310            code: "REPEATED_SECTION_HEADING".to_string(),
311            fate: ObservationFate::Dropped,
312            detail: serde_json::json!({
313                "heading": heading,
314                "occurrences": count,
315                "note": "section splitting is first-wins: the body under the first \
316                         occurrence is kept and every later body was NOT kept",
317            }),
318        });
319    }
320
321    // 3. Frontmatter keys the file carried that the type does not declare. The
322    //    metadata builder emits only declared fields, so these are dropped on
323    //    EVERY write, unconditionally. Reported here, before that write rather
324    //    than after it. (A key supplied by a CALLER already refuses today with
325    //    `UNKNOWN_METADATA_FIELD`; this is the file-facing half, where the key
326    //    was never presented to a validator.)
327    for key in entity.metadata.keys() {
328        if RESERVED_METADATA.contains(&key.as_str()) || type_def.metadata_field(key).is_some() {
329            continue;
330        }
331        out.push(BodyObservation {
332            id: entity.id.to_string(),
333            code: "UNDECLARED_METADATA_KEY".to_string(),
334            fate: ObservationFate::Dropped,
335            detail: serde_json::json!({
336                "key": key,
337                "entity_type": entity.entity_type,
338                "note": "the type does not declare this frontmatter key; the generator \
339                         emits only declared fields, so the next write drops it",
340            }),
341        });
342    }
343}
344
345/// Engine-stamped frontmatter keys every type carries without declaring.
346const RESERVED_METADATA: &[&str] = &["type", "created_date", "last_modified"];
347
348/// Whether an undeclared heading's content actually survived into the
349/// catch-all. The catch-all re-emits absorbed content under its original
350/// heading line, so the heading appearing there is the evidence that it was
351/// kept; a bare heading with no body never reaches it.
352fn heading_has_body(
353    entity: &Entity,
354    heading: &str,
355    catch_all: Option<&memstead_schema::SectionDef>,
356) -> bool {
357    let Some(c) = catch_all else { return false };
358    let Some(value) = entity.sections.get(c.key.as_str()) else {
359        return false;
360    };
361    // Line-anchored, not `contains`. The catch-all builder SKIPS empty
362    // content, so an undeclared heading survives exactly when its own
363    // heading line was re-emitted into the catch-all value — and a
364    // substring test answers a different question, saying "kept" for a
365    // heading whose text merely appears inside neighbouring prose.
366    value.lines().any(|line| {
367        line.strip_prefix("## ")
368            .is_some_and(|rest| rest.trim() == heading)
369    })
370}
371
372/// Run the consistency axis over `mem`, projecting the pre-existing
373/// graph-coherence checks into the integrity-finding shape: dangling
374/// wiki-links (the `DANGLING_LINK_*` / `DANGLING_RELATION_*` family, on the
375/// linking entity), stubs with
376/// their referrers (`ORPHAN_STUB`, on the stub), and cross-mem edges the
377/// workspace no longer permits (`CROSS_MEM_EDGE_UNGRANTED`, on the referrer).
378/// The category collectors are the same ones the dedicated health includes
379/// use — `integrity` is a projection, not a second implementation.
380///
381/// `grant_allows` is the workspace's cross-mem grant resolution, passed in
382/// rather than recomputed. There is exactly one such resolver
383/// (`Engine::cross_mem_link_allowed`) and it is the same one the write gate
384/// consults; a second implementation here would answer a subtly different
385/// question from the gate it exists to mirror, and would drift the moment the
386/// create-rule default union changed (04/07, criterion 8). It is a closure
387/// because this module has no `Engine`, and the single Engine-side funnel
388/// supplies it for every caller.
389pub fn consistency_findings(
390    store: &Store,
391    mem: &str,
392    grant_allows: &dyn Fn(&str, &str) -> bool,
393) -> Vec<IntegrityFinding> {
394    let mut findings = Vec::new();
395    for link in super::health::collect_dangling_links(store, Some(mem)) {
396        findings.push(IntegrityFinding {
397            id: link.from.to_string(),
398            axis: IntegrityAxis::Consistency,
399            // The code comes from the discriminator the producer set, so the
400            // three conditions arrive under three names and each carries the
401            // repair it implies (04/06).
402            code: link.kind.code().to_string(),
403            detail: serde_json::json!({
404                "from": link.from,
405                "target_id": link.target_id,
406                "target_path": link.target_path,
407                "section": link.section,
408                "repair": link.kind.repair(),
409            }),
410        });
411    }
412    // Cross-mem edges whose grant no longer permits them. The write gate is
413    // default-deny, so such an edge is a state the engine would refuse to
414    // create today; leaving it unreported means the workspace policy file has
415    // stopped describing the graph and nothing forces the two back into
416    // agreement. Reported and strict, never a load refusal: a policy edit must
417    // not take a mem offline, because the recovery needs the very links the
418    // refusal would block (04/07).
419    for entity in store.all_entities() {
420        if entity.mem != mem || entity.stub {
421            continue;
422        }
423        for rel in &entity.relationships {
424            let to_mem = rel.target.mem();
425            // Same-mem edges never traverse the gate. The resolver admits them
426            // unconditionally, so asking is correct as well as cheap, but the
427            // early skip keeps the scan proportional to cross-mem edges.
428            if to_mem == entity.mem {
429                continue;
430            }
431            if grant_allows(&entity.mem, to_mem) {
432                continue;
433            }
434            findings.push(IntegrityFinding {
435                id: entity.id.to_string(),
436                axis: IntegrityAxis::Consistency,
437                code: "CROSS_MEM_EDGE_UNGRANTED".to_string(),
438                detail: serde_json::json!({
439                    "from": entity.id,
440                    "target_id": rel.target,
441                    "rel_type": rel.rel_type,
442                    "from_mem": entity.mem,
443                    "to_mem": to_mem,
444                    // The cause, stated: this is NOT a missing target. The
445                    // target may be perfectly present; what is absent is the
446                    // workspace's permission for the pair (criterion 1).
447                    "cause": "no cross-mem grant permits this pair",
448                    "repair": "grant the pair with `memstead workspace grant-cross-link`, \
449                               or remove the edge with `memstead relate --remove` \
450                               (removal needs no grant)",
451                }),
452            });
453        }
454    }
455    for (stub_id, referrers) in crate::graph::query::find_stubs(store) {
456        if stub_id.mem() != mem {
457            continue;
458        }
459        findings.push(IntegrityFinding {
460            id: stub_id.to_string(),
461            axis: IntegrityAxis::Consistency,
462            code: "ORPHAN_STUB".to_string(),
463            detail: serde_json::json!({ "referrers": referrers }),
464        });
465    }
466    // The collectors iterate the HashMap-backed store, so impose the
467    // full order here: id, code, then the rendered detail as the
468    // tiebreak for several same-code findings on one entity.
469    findings.sort_by(|a, b| {
470        a.id.cmp(&b.id)
471            .then_with(|| a.code.cmp(&b.code))
472            .then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
473    });
474    findings
475}
476
477/// Conformance findings for a single entity — the per-entity slice of
478/// [`conformance_findings`], exposed for callers that gate on one
479/// entity's current conformance (the `memstead_update` repair-power gate).
480/// Empty result == the entity is conformant: a write of this entity
481/// under `schema` would be accepted.
482pub fn entity_conformance_findings(
483    store: &Store,
484    entity: &Entity,
485    schema: &Schema,
486    mem_schemas: &HashMap<String, Arc<Schema>>,
487) -> Vec<IntegrityFinding> {
488    let mut findings = Vec::new();
489    lint_entity(store, entity, schema, mem_schemas, &mut findings);
490    findings
491}
492
493fn lint_entity(
494    store: &Store,
495    entity: &Entity,
496    schema: &Schema,
497    mem_schemas: &HashMap<String, Arc<Schema>>,
498    findings: &mut Vec<IntegrityFinding>,
499) {
500    // Type lookup gates everything else: an unknown type means no
501    // type definition to validate sections/metadata against, exactly
502    // as a write of this entity would refuse before any other check.
503    let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
504        findings.push(IntegrityFinding::conformance(
505            &entity.id,
506            &unknown_type_error(schema, &entity.entity_type),
507        ));
508        return;
509    };
510
511    // An unterminated fence, before anything else: it is the one condition
512    // under which the rest of this walk is reading a body that is not the
513    // entity's. Every declared section after the open fence was absorbed into
514    // it, so those keys are absent from `entity.sections` and the required-
515    // section check below would report them missing without saying why. This
516    // finding names the cause; that one names the symptom.
517    for (key, value) in &entity.sections {
518        let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) else {
519            continue;
520        };
521        let swallowed = swallowed_declared_sections(value, type_def);
522        findings.push(IntegrityFinding::conformance_with_detail(
523            &entity.id,
524            "UNTERMINATED_FENCE",
525            serde_json::json!({
526                "section": key,
527                "fence": fence,
528                "entity_type": entity.entity_type,
529                "swallowed_sections": swallowed,
530                "note": if swallowed.is_empty() {
531                    "this section ends inside an unterminated code fence; no declared section \
532                     follows it in the file yet, but the next write would bury whatever does"
533                } else {
534                    "these declared sections are NOT empty: their content sits verbatim inside \
535                     the section above, hidden by an unterminated code fence. Supply a corrected \
536                     body for that section; the next write would otherwise close the fence \
537                     around them and make the loss permanent"
538                },
539            }),
540        ));
541    }
542
543    // Section keys — one finding per unknown key (the write path stops
544    // at the first; the linter reports all so one repair pass fixes
545    // the entity).
546    for key in entity.sections.keys() {
547        if let Err(v) = validate_section_keys(std::iter::once(key.as_str()), type_def) {
548            findings.push(IntegrityFinding::conformance(
549                &entity.id,
550                &EngineError::Validation(v),
551            ));
552        }
553    }
554
555    // Required sections — one finding per entity, carrying every
556    // missing section, mirroring the create path's bundled refusal.
557    let missing_sections = missing_required_sections(type_def, &entity.sections);
558    if !missing_sections.is_empty() {
559        let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
560        if !type_def.write_rules.is_empty() {
561            type_guidance.insert(entity.entity_type.clone(), type_def.write_rules.clone());
562        }
563        findings.push(IntegrityFinding::conformance(
564            &entity.id,
565            &EngineError::MissingRequiredSection {
566                entity_type: entity.entity_type.clone(),
567                missing_count: missing_sections.len(),
568                sections: missing_sections,
569                type_guidance,
570                // The linter reports every gate as its own finding
571                // (the RequiredFieldUnset finding below), so a
572                // pre-announcement here would duplicate it.
573                pre_announced_missing_fields: Vec::new(),
574            },
575        ));
576    }
577
578    // Metadata — unknown keys, enum violations, malformed typed values.
579    // Engine-managed keys (`mem`, `id`, `type`) are skipped exactly
580    // as the write path treats them (read-only, never caller-supplied).
581    let mut supplied: IndexMap<String, String> = IndexMap::new();
582    for (key, value) in &entity.metadata {
583        let raw = value.to_frontmatter_string();
584        supplied.insert(key.clone(), raw.clone());
585        if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
586            continue;
587        }
588        if let Err(v) = parse_metadata_value(key, &raw, type_def) {
589            findings.push(IntegrityFinding::conformance(
590                &entity.id,
591                &EngineError::Validation(v),
592            ));
593        }
594    }
595
596    // Required metadata fields the schema does not auto-fill — one
597    // finding per entity mirroring the create path's accumulator.
598    let missing_fields = missing_required_fields(type_def, &supplied);
599    if let Some(first) = missing_fields.first() {
600        findings.push(IntegrityFinding::conformance(
601            &entity.id,
602            &EngineError::RequiredFieldUnset {
603                field: first.key.clone(),
604                entity_type: entity.entity_type.clone(),
605                field_description: Some(first.description.clone()),
606                enum_values: first.enum_values.clone(),
607                type_write_rules: type_def.write_rules.clone(),
608                on_create: true,
609                missing: missing_fields.clone(),
610            },
611        ));
612    }
613
614    // Relationships — routed exactly as the write path routes them:
615    // same schema *name* on both ends (any version pair) consults the
616    // intra-mem vocabulary of the effective schema; a different name
617    // consults its `cross_mem_relationships`. An unmounted target
618    // mem falls back to the intra path, mirroring the relate path.
619    let (src_name, src_version) = schema.id();
620    for rel in &entity.relationships {
621        let target_mem = rel.target.mem();
622        let target_schema = if target_mem == entity.mem {
623            None
624        } else {
625            mem_schemas.get(target_mem)
626        };
627        let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
628        let target_type = store
629            .get(&rel.target)
630            .map(|e| e.entity_type.clone())
631            .filter(|t| !t.is_empty());
632
633        if cross_mem_different {
634            let target = target_schema.expect("Some when cross_mem_different");
635            let (t_name, t_version) = target.id();
636            let target_ref = SchemaRef::new(t_name, t_version.clone());
637            match validate_cross_mem_edge(
638                &rel.rel_type,
639                &entity.entity_type,
640                target_type.as_deref(),
641                schema,
642                &target_ref,
643            ) {
644                CrossMemRelCheck::Ok => {}
645                CrossMemRelCheck::EdgeNotDeclared => {
646                    findings.push(IntegrityFinding::conformance(
647                        &entity.id,
648                        &EngineError::CrossMemEdgeNotDeclared {
649                            source_schema: format!("{src_name}@{src_version}"),
650                            target_schema: target_ref.as_display(),
651                            rel_type: rel.rel_type.clone(),
652                            from_id: entity.id.to_string(),
653                            to_id: rel.target.to_string(),
654                        },
655                    ));
656                }
657                CrossMemRelCheck::Invalid(v) => {
658                    findings.push(IntegrityFinding::conformance(
659                        &entity.id,
660                        &EngineError::Validation(v),
661                    ));
662                }
663            }
664        } else {
665            match validate_rel_type(&rel.rel_type, schema) {
666                // Open-mode schemas admit unknown names at write time
667                // (warning, not refusal) — so they lint clean too.
668                Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
669                Err(v) => {
670                    findings.push(IntegrityFinding::conformance(
671                        &entity.id,
672                        &EngineError::Validation(v),
673                    ));
674                    continue;
675                }
676            }
677            if let Err(v) = validate_rel_shape(
678                &rel.rel_type,
679                &entity.entity_type,
680                target_type.as_deref(),
681                schema,
682            ) {
683                findings.push(IntegrityFinding::conformance(
684                    &entity.id,
685                    &EngineError::Validation(v),
686                ));
687            }
688        }
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::entity::{EntityId, MetadataValue, Relationship};
696
697    const TYPE_TAIL: &str = r#"sections:
698  - key: body
699    heading: Body
700    required: true
701    search_weight: 10.0
702    catch_all: false
703    write_rules: []
704  - key: notes
705    heading: Notes
706    required: false
707    search_weight: 1.0
708    catch_all: true
709    write_rules: []
710metadata_fields:
711  - key: status
712    description: Lifecycle state
713    field_type: string
714    enum_values:
715      - open
716      - closed
717title_weight: 100.0
718text_fields:
719  - body
720hierarchy_relationship: _default
721no_self_loop_relationships: []
722updatable_fields:
723  - title
724  - body
725  - notes
726  - status
727health_required_fields:
728  - body
729staleness_threshold_days: 90
730write_rules: []
731"#;
732
733    const PLAIN_TYPE_TAIL: &str = r#"sections:
734  - key: body
735    heading: Body
736    required: false
737    search_weight: 10.0
738    catch_all: true
739    write_rules: []
740metadata_fields: []
741title_weight: 100.0
742text_fields:
743  - body
744hierarchy_relationship: _default
745no_self_loop_relationships: []
746updatable_fields:
747  - title
748  - body
749health_required_fields: []
750staleness_threshold_days: 90
751write_rules: []
752"#;
753
754    /// `lint-src@0.1.0`: strict vocabulary with shape-pinned
755    /// `IMPLEMENTS: doc → doc`, a cross-mem declaration to the
756    /// `other` domain (`ADDRESSES: doc → requirement`), and a `doc`
757    /// type carrying a required `body` section and a required enum
758    /// `status` field with no default.
759    fn lint_schema() -> Arc<Schema> {
760        let manifest = r#"name: lint-src
761version: 0.1.0
762description: linter test schema
763when_to_use: tests
764types:
765  - doc
766  - req
767relationships:
768  mode: strict
769  definitions:
770    - name: IMPLEMENTS
771      description: shape-pinned
772      default_weight: 1.0
773      source_types: [doc]
774      target_types: [doc]
775    - name: _default
776      description: fallback
777      default_weight: 1.0
778cross_mem_relationships:
779  - to_schema: other
780    definitions:
781      - name: ADDRESSES
782        description: outbound
783        default_weight: 1.0
784        source_types: [doc]
785        target_types: [requirement]
786community:
787  resolution: 1.0
788  seed: 42
789"#;
790        Arc::new(
791            memstead_schema::load_schema_from_memory(
792                manifest,
793                &[
794                    (
795                        "doc".to_string(),
796                        format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
797                    ),
798                    (
799                        "req".to_string(),
800                        format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
801                    ),
802                ],
803            )
804            .expect("lint schema loads"),
805        )
806    }
807
808    /// `other@1.0.0`: the cross-mem target domain, declaring a
809    /// `requirement` and a `task` type.
810    fn other_schema() -> Arc<Schema> {
811        let manifest = r#"name: other
812version: 1.0.0
813description: target schema
814when_to_use: tests
815types:
816  - requirement
817  - task
818relationships:
819  mode: strict
820  definitions:
821    - name: _default
822      description: fallback
823      default_weight: 1.0
824community:
825  resolution: 1.0
826  seed: 42
827"#;
828        Arc::new(
829            memstead_schema::load_schema_from_memory(
830                manifest,
831                &[
832                    (
833                        "requirement".to_string(),
834                        format!(
835                            "name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
836                        ),
837                    ),
838                    (
839                        "task".to_string(),
840                        format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
841                    ),
842                ],
843            )
844            .expect("other schema loads"),
845        )
846    }
847
848    fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
849        Entity {
850            id: EntityId::new(mem, slug),
851            title: slug.to_string(),
852            entity_type: entity_type.to_string(),
853            mem: mem.to_string(),
854            file_path: format!("{slug}.md"),
855            metadata: IndexMap::new(),
856            sections: IndexMap::new(),
857            relationships: Vec::new(),
858            content_hash: "h".to_string(),
859            stub: false,
860            stub_kind: None,
861            heading_spans: Default::default(),
862            raw_section_headings: Vec::new(),
863        }
864    }
865
866    fn conformant_entity(mem: &str, slug: &str) -> Entity {
867        let mut e = entity(mem, slug, "doc");
868        e.sections.insert("body".to_string(), "content".to_string());
869        e.metadata.insert(
870            "status".to_string(),
871            MetadataValue::String("open".to_string()),
872        );
873        e
874    }
875
876    fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
877        entries
878            .iter()
879            .map(|(v, s)| (v.to_string(), s.clone()))
880            .collect()
881    }
882
883    fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
884        findings.iter().map(|f| f.code.as_str()).collect()
885    }
886
887    /// Criteria 1 and 2 (consistency-sweep 04/01). A heading the type does not
888    /// declare is REPORTED, naming the entity, the heading and where the
889    /// content went — and it is an observation, never a conformance finding,
890    /// because absorbing it is the catch-all working as designed.
891    #[test]
892    fn an_absorbed_heading_is_observed_and_never_a_violation() {
893        let schema = lint_schema();
894        let mut store = Store::new();
895        let mut e = conformant_entity("lv", "alpha");
896        e.raw_section_headings = vec!["Body".into(), "Field Notes".into()];
897        // The catch-all re-emits absorbed content under its original heading.
898        e.sections.insert(
899            "notes".into(),
900            "## Field Notes\n\nsomething useful\n".into(),
901        );
902        let id = e.id.to_string();
903        store.upsert(e.id.clone(), e);
904
905        let obs = body_observations(&store, "lv", &schema);
906        assert_eq!(obs.len(), 1, "got {obs:?}");
907        assert_eq!(obs[0].code, "ABSORBED_SECTION");
908        assert_eq!(obs[0].id, id);
909        assert_eq!(obs[0].detail["heading"], "Field Notes");
910        assert_eq!(
911            obs[0].fate,
912            ObservationFate::Absorbed,
913            "the content survives the next write, and the report must say so"
914        );
915
916        // The refusal complement: nothing on the conformance axis.
917        let schemas = schemas_for(&[("lv", schema.clone())]);
918        let findings = conformance_findings(&store, "lv", &schema, &schemas);
919        assert!(
920            findings.is_empty(),
921            "healthy catch-all use must not be a violation: {:?}",
922            codes(&findings)
923        );
924    }
925
926    /// Criterion 1's other half: a bare heading with no body is the one case
927    /// that really is lost, because the catch-all builder skips empty content.
928    /// Appending a bare heading line is what the original repro described.
929    #[test]
930    fn a_bare_undeclared_heading_is_observed_as_dropped() {
931        let schema = lint_schema();
932        let mut store = Store::new();
933        let mut e = conformant_entity("lv", "alpha");
934        e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
935        // Nothing reached the catch-all: the heading had no body.
936        store.upsert(e.id.clone(), e);
937
938        let obs = body_observations(&store, "lv", &schema);
939        assert_eq!(obs.len(), 1, "got {obs:?}");
940        assert_eq!(obs[0].code, "ABSORBED_SECTION");
941        assert_eq!(
942            obs[0].fate,
943            ObservationFate::Dropped,
944            "an empty heading is skipped by the catch-all, so it does NOT survive"
945        );
946    }
947
948    /// Criterion 3: a frontmatter key the type does not declare is reported
949    /// BEFORE the write that drops it. The generator emits only declared
950    /// fields, so this one is unconditional loss.
951    #[test]
952    fn an_undeclared_metadata_key_is_observed_as_dropped() {
953        let schema = lint_schema();
954        let mut store = Store::new();
955        let mut e = conformant_entity("lv", "alpha");
956        e.metadata
957            .insert("reviewer".into(), MetadataValue::String("ada".into()));
958        // Engine-stamped keys are not the caller's and are not reported.
959        e.metadata
960            .insert("last_modified".into(), MetadataValue::String("x".into()));
961        store.upsert(e.id.clone(), e);
962
963        let obs = body_observations(&store, "lv", &schema);
964        assert_eq!(obs.len(), 1, "got {obs:?}");
965        assert_eq!(obs[0].code, "UNDECLARED_METADATA_KEY");
966        assert_eq!(obs[0].detail["key"], "reviewer");
967        assert_eq!(obs[0].fate, ObservationFate::Dropped);
968    }
969
970    /// Criterion 4: a repeated heading loses every later body, and the two
971    /// cases that produce no warning anywhere today are a repeat of an
972    /// UNDECLARED heading and a repeat of the CATCH-ALL's own heading.
973    #[test]
974    fn a_repeated_heading_is_observed_in_both_silent_cases() {
975        let schema = lint_schema();
976        for (headings, label) in [
977            (
978                vec!["Body", "Scratch", "Scratch"],
979                "undeclared heading twice",
980            ),
981            (
982                vec!["Body", "Notes", "Notes"],
983                "the catch-all's own heading twice",
984            ),
985        ] {
986            let mut store = Store::new();
987            let mut e = conformant_entity("lv", "alpha");
988            e.raw_section_headings = headings.iter().map(|h| h.to_string()).collect();
989            e.sections
990                .insert("notes".into(), "## Scratch\n\nkept\n".into());
991            store.upsert(e.id.clone(), e);
992
993            let obs = body_observations(&store, "lv", &schema);
994            let repeats: Vec<_> = obs
995                .iter()
996                .filter(|o| o.code == "REPEATED_SECTION_HEADING")
997                .collect();
998            assert_eq!(repeats.len(), 1, "{label}: got {obs:?}");
999            assert!(repeats[0].occurrences_is(2), "{label}");
1000            assert_eq!(repeats[0].fate, ObservationFate::Dropped, "{label}");
1001        }
1002    }
1003
1004    /// Criterion 5, the refusal complement that gives the rest its worth: the
1005    /// ordinary entity produces nothing. A check that fires on healthy content
1006    /// is worse than no check, because it teaches readers to ignore it.
1007    #[test]
1008    fn an_ordinary_entity_produces_no_observations() {
1009        let schema = lint_schema();
1010        let mut store = Store::new();
1011        let mut e = conformant_entity("lv", "alpha");
1012        // `Relationships` belongs here deliberately: it is the heading EVERY
1013        // real entity carries, no type declares it, and a fixture without it
1014        // is the fixture that lets the ordinary case look clean while a live
1015        // mem reports one observation per entity. It was exactly that, until
1016        // a grade read a real mem: 553 entities, 553 observations.
1017        e.raw_section_headings = vec!["Body".into(), "Notes".into(), "Relationships".into()];
1018        e.sections.insert("notes".into(), "plain prose\n".into());
1019        store.upsert(e.id.clone(), e);
1020        assert!(
1021            body_observations(&store, "lv", &schema).is_empty(),
1022            "declared headings, each once, the relationships block, no undeclared keys"
1023        );
1024    }
1025
1026    #[test]
1027    fn a_repeated_undeclared_heading_claims_survival_only_for_the_first() {
1028        // Splitting is first-wins, so the second body is gone whatever the
1029        // catch-all does. Emitting `ABSORBED_SECTION` twice said "survives the
1030        // next write" about a body that did not (grade caveat, 2026-08-27).
1031        let schema = lint_schema();
1032        let mut store = Store::new();
1033        let mut e = conformant_entity("lv", "alpha");
1034        e.raw_section_headings = vec!["Body".into(), "Scratch".into(), "Scratch".into()];
1035        e.sections
1036            .insert("notes".into(), "## Scratch\n\nkept\n".into());
1037        store.upsert(e.id.clone(), e);
1038        let obs = body_observations(&store, "lv", &schema);
1039        let absorbed: Vec<_> = obs
1040            .iter()
1041            .filter(|o| o.code == "ABSORBED_SECTION")
1042            .collect();
1043        assert_eq!(
1044            absorbed.len(),
1045            1,
1046            "one per heading, not per occurrence: {obs:?}"
1047        );
1048        assert_eq!(absorbed[0].fate, ObservationFate::Absorbed);
1049        // The loss the repeat causes is still reported, on its own code.
1050        let repeats: Vec<_> = obs
1051            .iter()
1052            .filter(|o| o.code == "REPEATED_SECTION_HEADING")
1053            .collect();
1054        assert_eq!(repeats.len(), 1, "got: {obs:?}");
1055        assert_eq!(repeats[0].detail["occurrences"], 2);
1056    }
1057
1058    #[test]
1059    fn the_auto_managed_relationships_block_is_never_an_observation() {
1060        // The generator re-emits `## Relationships` from the parsed relations
1061        // on every write, so it is neither absorbed nor dropped. Pinned apart
1062        // from the ordinary-entity test because the two fail for different
1063        // reasons: this one guards the exclusion, that one guards the fixture.
1064        let schema = lint_schema();
1065        let mut store = Store::new();
1066        let mut e = conformant_entity("lv", "alpha");
1067        e.raw_section_headings = vec!["Relationships".into()];
1068        store.upsert(e.id.clone(), e);
1069        assert!(
1070            body_observations(&store, "lv", &schema).is_empty(),
1071            "the relationships block is engine-owned, not undeclared content"
1072        );
1073    }
1074
1075    #[test]
1076    fn a_heading_named_inside_prose_is_not_mistaken_for_a_kept_one() {
1077        // `heading_has_body` asks whether the catch-all RE-EMITTED the
1078        // heading line, and a substring test answers a different question:
1079        // prose that merely mentions the words reported the heading as
1080        // absorbed when the write had in fact dropped it.
1081        let schema = lint_schema();
1082        let mut store = Store::new();
1083        let mut e = conformant_entity("lv", "alpha");
1084        e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
1085        e.sections
1086            .insert("notes".into(), "we discussed Scratch at length\n".into());
1087        store.upsert(e.id.clone(), e);
1088        let obs = body_observations(&store, "lv", &schema);
1089        let absorbed: Vec<_> = obs
1090            .iter()
1091            .filter(|o| o.code == "ABSORBED_SECTION")
1092            .collect();
1093        assert_eq!(absorbed.len(), 1, "got: {obs:?}");
1094        assert_eq!(
1095            absorbed[0].fate,
1096            ObservationFate::Dropped,
1097            "a bare heading whose text appears in prose is still dropped"
1098        );
1099    }
1100
1101    #[test]
1102    fn an_unterminated_fence_names_the_sections_it_swallowed() {
1103        // Criteria 3 and 4. The `Notes` heading was masked by the open fence,
1104        // so it never became a section key: its bytes sit inside `body`. The
1105        // finding has to name it, because the only other signal the entity
1106        // gives is a `notes` key that is simply absent.
1107        let schema = lint_schema();
1108        let mut store = Store::new();
1109        let mut e = conformant_entity("lv", "alpha");
1110        e.sections.insert(
1111            "body".into(),
1112            "intro\n\n```rust\nfn main() {}\n\n## Notes\n\nthe real notes\n".into(),
1113        );
1114        e.sections.shift_remove("notes");
1115        store.upsert(e.id.clone(), e);
1116        let schemas = schemas_for(&[("lv", schema.clone())]);
1117        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1118        let fence: Vec<_> = findings
1119            .iter()
1120            .filter(|f| f.code == "UNTERMINATED_FENCE")
1121            .collect();
1122        assert_eq!(fence.len(), 1, "got: {:?}", codes(&findings));
1123        assert_eq!(fence[0].id, "lv--alpha");
1124        assert_eq!(fence[0].detail["section"], "body");
1125        assert_eq!(fence[0].detail["fence"], "```");
1126        assert_eq!(
1127            fence[0].detail["swallowed_sections"],
1128            serde_json::json!(["Notes"]),
1129        );
1130        // Criterion 4: never clean. A finding on the conformance axis is
1131        // exactly what "not clean" means on this surface.
1132        assert!(!findings.is_empty());
1133    }
1134
1135    #[test]
1136    fn an_entity_with_no_open_fence_gains_no_fence_finding() {
1137        // Criterion 7 at the read tier. Both the no-fence and the closed-fence
1138        // cases, because a guard that fires on any fence character would pass
1139        // the first and fail the second.
1140        let schema = lint_schema();
1141        let schemas = schemas_for(&[("lv", schema.clone())]);
1142        for body in [
1143            "just prose",
1144            "prose\n\n```rust\nfn main() {}\n```\n\nmore",
1145            "```md\n## Notes\n```",
1146        ] {
1147            let mut store = Store::new();
1148            let mut e = conformant_entity("lv", "alpha");
1149            e.sections.insert("body".into(), body.into());
1150            store.upsert(e.id.clone(), e);
1151            let findings = conformance_findings(&store, "lv", &schema, &schemas);
1152            assert!(
1153                !findings.iter().any(|f| f.code == "UNTERMINATED_FENCE"),
1154                "body {body:?} produced: {:?}",
1155                codes(&findings)
1156            );
1157        }
1158    }
1159
1160    #[test]
1161    fn clean_mem_produces_no_findings() {
1162        let schema = lint_schema();
1163        let mut store = Store::new();
1164        let a = conformant_entity("lv", "alpha");
1165        let mut b = conformant_entity("lv", "beta");
1166        b.relationships
1167            .push(Relationship::new("IMPLEMENTS", a.id.clone()));
1168        store.upsert(a.id.clone(), a);
1169        store.upsert(b.id.clone(), b);
1170        let schemas = schemas_for(&[("lv", schema.clone())]);
1171        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1172        assert!(findings.is_empty(), "got: {:?}", codes(&findings));
1173    }
1174
1175    #[test]
1176    fn missing_required_section_and_field_carry_write_time_codes() {
1177        let schema = lint_schema();
1178        let mut store = Store::new();
1179        // No body section, no status field — both required.
1180        let e = entity("lv", "broken", "doc");
1181        let id = e.id.to_string();
1182        store.upsert(e.id.clone(), e);
1183        let schemas = schemas_for(&[("lv", schema.clone())]);
1184        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1185        let cs = codes(&findings);
1186        assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
1187        assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
1188        for f in &findings {
1189            assert_eq!(f.id, id);
1190            assert_eq!(f.axis, IntegrityAxis::Conformance);
1191        }
1192        // Detail mirrors the write-time recovery payload.
1193        let section_finding = findings
1194            .iter()
1195            .find(|f| f.code == "MISSING_REQUIRED_SECTION")
1196            .unwrap();
1197        assert_eq!(
1198            section_finding.detail["sections"][0]["key"].as_str(),
1199            Some("body")
1200        );
1201        let field_finding = findings
1202            .iter()
1203            .find(|f| f.code == "REQUIRED_FIELD_UNSET")
1204            .unwrap();
1205        assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
1206    }
1207
1208    #[test]
1209    fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
1210        let schema = lint_schema();
1211        let mut store = Store::new();
1212        let mut e = conformant_entity("lv", "drifted");
1213        e.metadata.insert(
1214            "status".to_string(),
1215            MetadataValue::String("banana".to_string()),
1216        );
1217        e.metadata
1218            .insert("wat".to_string(), MetadataValue::String("x".to_string()));
1219        e.sections.insert("bogus".to_string(), "text".to_string());
1220        store.upsert(e.id.clone(), e);
1221        let schemas = schemas_for(&[("lv", schema.clone())]);
1222        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1223        let cs = codes(&findings);
1224        assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
1225        assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
1226        assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
1227        let enum_finding = findings
1228            .iter()
1229            .find(|f| f.code == "INVALID_ENUM_VALUE")
1230            .unwrap();
1231        assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
1232        assert_eq!(
1233            enum_finding.detail["allowed"]
1234                .as_array()
1235                .unwrap()
1236                .iter()
1237                .map(|v| v.as_str().unwrap())
1238                .collect::<Vec<_>>(),
1239            vec!["open", "closed"]
1240        );
1241    }
1242
1243    #[test]
1244    fn unknown_type_short_circuits_with_unknown_entity_type() {
1245        let schema = lint_schema();
1246        let mut store = Store::new();
1247        let e = entity("lv", "mystery", "ghost");
1248        store.upsert(e.id.clone(), e);
1249        let schemas = schemas_for(&[("lv", schema.clone())]);
1250        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1251        assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
1252        assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
1253    }
1254
1255    #[test]
1256    fn invalid_rel_type_and_shape_surface() {
1257        let schema = lint_schema();
1258        let mut store = Store::new();
1259        let mut req_target = conformant_entity("lv", "target");
1260        req_target.entity_type = "req".to_string();
1261        // `req` has no required section/field constraints (plain type).
1262        req_target.metadata.clear();
1263        req_target.sections.clear();
1264        let mut e = conformant_entity("lv", "edges");
1265        e.relationships
1266            .push(Relationship::new("UNDECLARED", req_target.id.clone()));
1267        // IMPLEMENTS pins doc → doc; the target is a `req`.
1268        e.relationships
1269            .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
1270        store.upsert(req_target.id.clone(), req_target);
1271        store.upsert(e.id.clone(), e);
1272        let schemas = schemas_for(&[("lv", schema.clone())]);
1273        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1274        let cs = codes(&findings);
1275        assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
1276        assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
1277    }
1278
1279    #[test]
1280    fn cross_mem_edges_lint_like_the_write_path() {
1281        let schema = lint_schema();
1282        let other = other_schema();
1283        let mut store = Store::new();
1284        let mut requirement = entity("tv", "goal", "requirement");
1285        requirement
1286            .sections
1287            .insert("body".to_string(), "x".to_string());
1288        let mut task = entity("tv", "chore", "task");
1289        task.sections.insert("body".to_string(), "x".to_string());
1290
1291        let mut e = conformant_entity("lv", "linker");
1292        // Declared domain + matching target type → clean.
1293        e.relationships
1294            .push(Relationship::new("ADDRESSES", requirement.id.clone()));
1295        // Declared domain, target type drifted off `target_types` →
1296        // the write-time shape code resurfaces at lint time.
1297        e.relationships
1298            .push(Relationship::new("ADDRESSES", task.id.clone()));
1299        // Rel-type absent from the cross-mem entry entirely.
1300        e.relationships
1301            .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
1302        store.upsert(requirement.id.clone(), requirement);
1303        store.upsert(task.id.clone(), task);
1304        store.upsert(e.id.clone(), e);
1305        let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
1306        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1307        let cs = codes(&findings);
1308        assert_eq!(
1309            cs,
1310            vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
1311            "declared+conformant edge must stay silent; got: {cs:?}"
1312        );
1313    }
1314
1315    #[test]
1316    fn stub_entities_are_skipped() {
1317        let schema = lint_schema();
1318        let mut store = Store::new();
1319        let mut stub = entity("lv", "ghost-stub", "");
1320        stub.stub = true;
1321        store.upsert(stub.id.clone(), stub);
1322        let schemas = schemas_for(&[("lv", schema.clone())]);
1323        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1324        assert!(findings.is_empty());
1325    }
1326
1327    #[test]
1328    fn other_mems_are_out_of_scope() {
1329        let schema = lint_schema();
1330        let mut store = Store::new();
1331        let e = entity("elsewhere", "broken", "doc");
1332        store.upsert(e.id.clone(), e);
1333        let schemas = schemas_for(&[("lv", schema.clone())]);
1334        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1335        assert!(findings.is_empty());
1336    }
1337
1338    #[test]
1339    fn findings_are_deterministic_and_id_ordered() {
1340        let schema = lint_schema();
1341        let mut store = Store::new();
1342        // Insert in non-lexical order; several findings per entity.
1343        for slug in ["zeta", "alpha", "mid"] {
1344            let e = entity("lv", slug, "doc");
1345            store.upsert(e.id.clone(), e);
1346        }
1347        let schemas = schemas_for(&[("lv", schema.clone())]);
1348        let first = conformance_findings(&store, "lv", &schema, &schemas);
1349        let second = conformance_findings(&store, "lv", &schema, &schemas);
1350        let a = serde_json::to_string(&first).unwrap();
1351        let b = serde_json::to_string(&second).unwrap();
1352        assert_eq!(a, b, "two runs must be byte-identical");
1353        let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
1354        let mut sorted = ids.clone();
1355        sorted.sort();
1356        assert_eq!(ids, sorted, "findings must be in lexical id order");
1357    }
1358
1359    #[test]
1360    fn lint_against_target_schema_differs_from_pin() {
1361        // The caller picks the effective schema: the same entity lints
1362        // clean against the `other` schema's `task` type but fails
1363        // against `lint-src` (which has no `task` type) — the
1364        // `target_schema` selector semantics.
1365        let pin = lint_schema();
1366        let target = other_schema();
1367        let mut store = Store::new();
1368        let mut e = entity("lv", "shifting", "task");
1369        e.sections.insert("body".to_string(), "x".to_string());
1370        store.upsert(e.id.clone(), e);
1371        let schemas = schemas_for(&[("lv", pin.clone())]);
1372        let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
1373        assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
1374        let against_target = conformance_findings(&store, "lv", &target, &schemas);
1375        assert!(
1376            against_target.is_empty(),
1377            "got: {:?}",
1378            codes(&against_target)
1379        );
1380    }
1381}