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 (`UNRESOLVED_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.
389/// The consistency finding for a stub that is still referenced but never
390/// written (renamed on 2026-09-02, see the changelog: a stub is by
391/// construction referenced, never orphaned, so the former name said the
392/// opposite of the condition). A named constant so the error-code index
393/// scan publishes it beside its dangling-link siblings.
394pub const UNRESOLVED_STUB_CODE: &str = "UNRESOLVED_STUB";
395
396pub fn consistency_findings(
397    store: &Store,
398    mem: &str,
399    grant_allows: &dyn Fn(&str, &str) -> bool,
400) -> Vec<IntegrityFinding> {
401    let mut findings = Vec::new();
402    for link in super::health::collect_dangling_links(store, Some(mem)) {
403        findings.push(IntegrityFinding {
404            id: link.from.to_string(),
405            axis: IntegrityAxis::Consistency,
406            // The code comes from the discriminator the producer set, so the
407            // three conditions arrive under three names and each carries the
408            // repair it implies (04/06).
409            code: link.kind.code().to_string(),
410            detail: serde_json::json!({
411                "from": link.from,
412                "target_id": link.target_id,
413                "target_path": link.target_path,
414                "section": link.section,
415                "repair": link.kind.repair(),
416            }),
417        });
418    }
419    // Cross-mem edges whose grant no longer permits them. The write gate is
420    // default-deny, so such an edge is a state the engine would refuse to
421    // create today; leaving it unreported means the workspace policy file has
422    // stopped describing the graph and nothing forces the two back into
423    // agreement. Reported and strict, never a load refusal: a policy edit must
424    // not take a mem offline, because the recovery needs the very links the
425    // refusal would block (04/07).
426    for entity in store.all_entities() {
427        if entity.mem != mem || entity.stub {
428            continue;
429        }
430        for rel in &entity.relationships {
431            let to_mem = rel.target.mem();
432            // Same-mem edges never traverse the gate. The resolver admits them
433            // unconditionally, so asking is correct as well as cheap, but the
434            // early skip keeps the scan proportional to cross-mem edges.
435            if to_mem == entity.mem {
436                continue;
437            }
438            if grant_allows(&entity.mem, to_mem) {
439                continue;
440            }
441            findings.push(IntegrityFinding {
442                id: entity.id.to_string(),
443                axis: IntegrityAxis::Consistency,
444                code: "CROSS_MEM_EDGE_UNGRANTED".to_string(),
445                detail: serde_json::json!({
446                    "from": entity.id,
447                    "target_id": rel.target,
448                    "rel_type": rel.rel_type,
449                    "from_mem": entity.mem,
450                    "to_mem": to_mem,
451                    // The cause, stated: this is NOT a missing target. The
452                    // target may be perfectly present; what is absent is the
453                    // workspace's permission for the pair (criterion 1).
454                    "cause": "no cross-mem grant permits this pair",
455                    "repair": "grant the pair with `memstead workspace grant-cross-link`, \
456                               or remove the edge with `memstead relate --remove` \
457                               (removal needs no grant)",
458                }),
459            });
460        }
461    }
462    for (stub_id, referrers) in crate::graph::query::find_stubs(store) {
463        if stub_id.mem() != mem {
464            continue;
465        }
466        findings.push(IntegrityFinding {
467            id: stub_id.to_string(),
468            axis: IntegrityAxis::Consistency,
469            code: UNRESOLVED_STUB_CODE.to_string(),
470            detail: serde_json::json!({ "referrers": referrers }),
471        });
472    }
473    // The collectors iterate the HashMap-backed store, so impose the
474    // full order here: id, code, then the rendered detail as the
475    // tiebreak for several same-code findings on one entity.
476    findings.sort_by(|a, b| {
477        a.id.cmp(&b.id)
478            .then_with(|| a.code.cmp(&b.code))
479            .then_with(|| a.detail.to_string().cmp(&b.detail.to_string()))
480    });
481    findings
482}
483
484/// Conformance findings for a single entity — the per-entity slice of
485/// [`conformance_findings`], exposed for callers that gate on one
486/// entity's current conformance (the `memstead_update` repair-power gate).
487/// Empty result == the entity is conformant: a write of this entity
488/// under `schema` would be accepted.
489pub fn entity_conformance_findings(
490    store: &Store,
491    entity: &Entity,
492    schema: &Schema,
493    mem_schemas: &HashMap<String, Arc<Schema>>,
494) -> Vec<IntegrityFinding> {
495    let mut findings = Vec::new();
496    lint_entity(store, entity, schema, mem_schemas, &mut findings);
497    findings
498}
499
500fn lint_entity(
501    store: &Store,
502    entity: &Entity,
503    schema: &Schema,
504    mem_schemas: &HashMap<String, Arc<Schema>>,
505    findings: &mut Vec<IntegrityFinding>,
506) {
507    // Type lookup gates everything else: an unknown type means no
508    // type definition to validate sections/metadata against, exactly
509    // as a write of this entity would refuse before any other check.
510    let Some(type_def) = schema.types.get(entity.entity_type.as_str()) else {
511        findings.push(IntegrityFinding::conformance(
512            &entity.id,
513            &unknown_type_error(schema, &entity.entity_type),
514        ));
515        return;
516    };
517
518    // An unterminated fence, before anything else: it is the one condition
519    // under which the rest of this walk is reading a body that is not the
520    // entity's. Every declared section after the open fence was absorbed into
521    // it, so those keys are absent from `entity.sections` and the required-
522    // section check below would report them missing without saying why. This
523    // finding names the cause; that one names the symptom.
524    for (key, value) in &entity.sections {
525        let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) else {
526            continue;
527        };
528        let swallowed = swallowed_declared_sections(value, type_def);
529        findings.push(IntegrityFinding::conformance_with_detail(
530            &entity.id,
531            "UNTERMINATED_FENCE",
532            serde_json::json!({
533                "section": key,
534                "fence": fence,
535                "entity_type": entity.entity_type,
536                "swallowed_sections": swallowed,
537                "note": if swallowed.is_empty() {
538                    "this section ends inside an unterminated code fence; no declared section \
539                     follows it in the file yet, but the next write would bury whatever does"
540                } else {
541                    "these declared sections are NOT empty: their content sits verbatim inside \
542                     the section above, hidden by an unterminated code fence. Supply a corrected \
543                     body for that section; the next write would otherwise close the fence \
544                     around them and make the loss permanent"
545                },
546            }),
547        ));
548    }
549
550    // Section keys — one finding per unknown key (the write path stops
551    // at the first; the linter reports all so one repair pass fixes
552    // the entity).
553    for key in entity.sections.keys() {
554        if let Err(v) = validate_section_keys(std::iter::once(key.as_str()), type_def) {
555            findings.push(IntegrityFinding::conformance(
556                &entity.id,
557                &EngineError::Validation(v),
558            ));
559        }
560    }
561
562    // Required sections — one finding per entity, carrying every
563    // missing section, mirroring the create path's bundled refusal.
564    let missing_sections = missing_required_sections(type_def, &entity.sections);
565    if !missing_sections.is_empty() {
566        let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
567        if !type_def.write_rules.is_empty() {
568            type_guidance.insert(entity.entity_type.clone(), type_def.write_rules.clone());
569        }
570        findings.push(IntegrityFinding::conformance(
571            &entity.id,
572            &EngineError::MissingRequiredSection {
573                entity_type: entity.entity_type.clone(),
574                missing_count: missing_sections.len(),
575                sections: missing_sections,
576                type_guidance,
577                // The linter reports every gate as its own finding
578                // (the RequiredFieldUnset finding below), so a
579                // pre-announcement here would duplicate it.
580                pre_announced_missing_fields: Vec::new(),
581            },
582        ));
583    }
584
585    // Metadata — unknown keys, enum violations, malformed typed values.
586    // Engine-managed keys (`mem`, `id`, `type`) are skipped exactly
587    // as the write path treats them (read-only, never caller-supplied).
588    let mut supplied: IndexMap<String, String> = IndexMap::new();
589    for (key, value) in &entity.metadata {
590        let raw = value.to_frontmatter_string();
591        supplied.insert(key.clone(), raw.clone());
592        if READ_ONLY_METADATA_KEYS.iter().any(|k| k == key) {
593            continue;
594        }
595        if let Err(v) = parse_metadata_value(key, &raw, type_def) {
596            findings.push(IntegrityFinding::conformance(
597                &entity.id,
598                &EngineError::Validation(v),
599            ));
600        }
601    }
602
603    // Required metadata fields the schema does not auto-fill — one
604    // finding per entity mirroring the create path's accumulator.
605    let missing_fields = missing_required_fields(type_def, &supplied);
606    if let Some(first) = missing_fields.first() {
607        findings.push(IntegrityFinding::conformance(
608            &entity.id,
609            &EngineError::RequiredFieldUnset {
610                field: first.key.clone(),
611                entity_type: entity.entity_type.clone(),
612                field_description: Some(first.description.clone()),
613                enum_values: first.enum_values.clone(),
614                type_write_rules: type_def.write_rules.clone(),
615                on_create: true,
616                missing: missing_fields.clone(),
617            },
618        ));
619    }
620
621    // Relationships — routed exactly as the write path routes them:
622    // same schema *name* on both ends (any version pair) consults the
623    // intra-mem vocabulary of the effective schema; a different name
624    // consults its `cross_mem_relationships`. An unmounted target
625    // mem falls back to the intra path, mirroring the relate path.
626    let (src_name, src_version) = schema.id();
627    for rel in &entity.relationships {
628        let target_mem = rel.target.mem();
629        let target_schema = if target_mem == entity.mem {
630            None
631        } else {
632            mem_schemas.get(target_mem)
633        };
634        let cross_mem_different = target_schema.map(|t| t.id().0 != src_name).unwrap_or(false);
635        let target_type = store
636            .get(&rel.target)
637            .map(|e| e.entity_type.clone())
638            .filter(|t| !t.is_empty());
639
640        if cross_mem_different {
641            let target = target_schema.expect("Some when cross_mem_different");
642            let (t_name, t_version) = target.id();
643            let target_ref = SchemaRef::new(t_name, t_version.clone());
644            match validate_cross_mem_edge(
645                &rel.rel_type,
646                &entity.entity_type,
647                target_type.as_deref(),
648                schema,
649                &target_ref,
650            ) {
651                CrossMemRelCheck::Ok => {}
652                CrossMemRelCheck::EdgeNotDeclared => {
653                    findings.push(IntegrityFinding::conformance(
654                        &entity.id,
655                        &EngineError::CrossMemEdgeNotDeclared {
656                            source_schema: format!("{src_name}@{src_version}"),
657                            target_schema: target_ref.as_display(),
658                            rel_type: rel.rel_type.clone(),
659                            from_id: entity.id.to_string(),
660                            to_id: rel.target.to_string(),
661                        },
662                    ));
663                }
664                CrossMemRelCheck::Invalid(v) => {
665                    findings.push(IntegrityFinding::conformance(
666                        &entity.id,
667                        &EngineError::Validation(v),
668                    ));
669                }
670            }
671        } else {
672            match validate_rel_type(&rel.rel_type, schema) {
673                // Open-mode schemas admit unknown names at write time
674                // (warning, not refusal) — so they lint clean too.
675                Ok(RelationshipCheck::Ok) | Ok(RelationshipCheck::OpenWarning(_)) => {}
676                Err(v) => {
677                    findings.push(IntegrityFinding::conformance(
678                        &entity.id,
679                        &EngineError::Validation(v),
680                    ));
681                    continue;
682                }
683            }
684            if let Err(v) = validate_rel_shape(
685                &rel.rel_type,
686                &entity.entity_type,
687                target_type.as_deref(),
688                schema,
689            ) {
690                findings.push(IntegrityFinding::conformance(
691                    &entity.id,
692                    &EngineError::Validation(v),
693                ));
694            }
695        }
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use crate::entity::{EntityId, MetadataValue, Relationship};
703
704    const TYPE_TAIL: &str = r#"sections:
705  - key: body
706    heading: Body
707    required: true
708    search_weight: 10.0
709    catch_all: false
710    write_rules: []
711  - key: notes
712    heading: Notes
713    required: false
714    search_weight: 1.0
715    catch_all: true
716    write_rules: []
717metadata_fields:
718  - key: status
719    description: Lifecycle state
720    field_type: string
721    enum_values:
722      - open
723      - closed
724title_weight: 100.0
725text_fields:
726  - body
727hierarchy_relationship: _default
728no_self_loop_relationships: []
729updatable_fields:
730  - title
731  - body
732  - notes
733  - status
734health_required_fields:
735  - body
736staleness_threshold_days: 90
737write_rules: []
738"#;
739
740    const PLAIN_TYPE_TAIL: &str = r#"sections:
741  - key: body
742    heading: Body
743    required: false
744    search_weight: 10.0
745    catch_all: true
746    write_rules: []
747metadata_fields: []
748title_weight: 100.0
749text_fields:
750  - body
751hierarchy_relationship: _default
752no_self_loop_relationships: []
753updatable_fields:
754  - title
755  - body
756health_required_fields: []
757staleness_threshold_days: 90
758write_rules: []
759"#;
760
761    /// `lint-src@0.1.0`: strict vocabulary with shape-pinned
762    /// `IMPLEMENTS: doc → doc`, a cross-mem declaration to the
763    /// `other` domain (`ADDRESSES: doc → requirement`), and a `doc`
764    /// type carrying a required `body` section and a required enum
765    /// `status` field with no default.
766    fn lint_schema() -> Arc<Schema> {
767        let manifest = r#"name: lint-src
768version: 0.1.0
769description: linter test schema
770when_to_use: tests
771types:
772  - doc
773  - req
774relationships:
775  mode: strict
776  definitions:
777    - name: IMPLEMENTS
778      description: shape-pinned
779      default_weight: 1.0
780      source_types: [doc]
781      target_types: [doc]
782    - name: _default
783      description: fallback
784      default_weight: 1.0
785cross_mem_relationships:
786  - to_schema: other
787    definitions:
788      - name: ADDRESSES
789        description: outbound
790        default_weight: 1.0
791        source_types: [doc]
792        target_types: [requirement]
793community:
794  resolution: 1.0
795  seed: 42
796"#;
797        Arc::new(
798            memstead_schema::load_schema_from_memory(
799                manifest,
800                &[
801                    (
802                        "doc".to_string(),
803                        format!("name: doc\ndescription: t\nwhen_to_use: tests\n{TYPE_TAIL}"),
804                    ),
805                    (
806                        "req".to_string(),
807                        format!("name: req\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
808                    ),
809                ],
810            )
811            .expect("lint schema loads"),
812        )
813    }
814
815    /// `other@1.0.0`: the cross-mem target domain, declaring a
816    /// `requirement` and a `task` type.
817    fn other_schema() -> Arc<Schema> {
818        let manifest = r#"name: other
819version: 1.0.0
820description: target schema
821when_to_use: tests
822types:
823  - requirement
824  - task
825relationships:
826  mode: strict
827  definitions:
828    - name: _default
829      description: fallback
830      default_weight: 1.0
831community:
832  resolution: 1.0
833  seed: 42
834"#;
835        Arc::new(
836            memstead_schema::load_schema_from_memory(
837                manifest,
838                &[
839                    (
840                        "requirement".to_string(),
841                        format!(
842                            "name: requirement\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"
843                        ),
844                    ),
845                    (
846                        "task".to_string(),
847                        format!("name: task\ndescription: t\nwhen_to_use: tests\n{PLAIN_TYPE_TAIL}"),
848                    ),
849                ],
850            )
851            .expect("other schema loads"),
852        )
853    }
854
855    fn entity(mem: &str, slug: &str, entity_type: &str) -> Entity {
856        Entity {
857            id: EntityId::new(mem, slug),
858            title: slug.to_string(),
859            entity_type: entity_type.to_string(),
860            mem: mem.to_string(),
861            file_path: format!("{slug}.md"),
862            metadata: IndexMap::new(),
863            sections: IndexMap::new(),
864            relationships: Vec::new(),
865            content_hash: "h".to_string(),
866            stub: false,
867            stub_kind: None,
868            heading_spans: Default::default(),
869            raw_section_headings: Vec::new(),
870        }
871    }
872
873    fn conformant_entity(mem: &str, slug: &str) -> Entity {
874        let mut e = entity(mem, slug, "doc");
875        e.sections.insert("body".to_string(), "content".to_string());
876        e.metadata.insert(
877            "status".to_string(),
878            MetadataValue::String("open".to_string()),
879        );
880        e
881    }
882
883    fn schemas_for(entries: &[(&str, Arc<Schema>)]) -> HashMap<String, Arc<Schema>> {
884        entries
885            .iter()
886            .map(|(v, s)| (v.to_string(), s.clone()))
887            .collect()
888    }
889
890    fn codes(findings: &[IntegrityFinding]) -> Vec<&str> {
891        findings.iter().map(|f| f.code.as_str()).collect()
892    }
893
894    /// Criteria 1 and 2 (consistency-sweep 04/01). A heading the type does not
895    /// declare is REPORTED, naming the entity, the heading and where the
896    /// content went — and it is an observation, never a conformance finding,
897    /// because absorbing it is the catch-all working as designed.
898    #[test]
899    fn an_absorbed_heading_is_observed_and_never_a_violation() {
900        let schema = lint_schema();
901        let mut store = Store::new();
902        let mut e = conformant_entity("lv", "alpha");
903        e.raw_section_headings = vec!["Body".into(), "Field Notes".into()];
904        // The catch-all re-emits absorbed content under its original heading.
905        e.sections.insert(
906            "notes".into(),
907            "## Field Notes\n\nsomething useful\n".into(),
908        );
909        let id = e.id.to_string();
910        store.upsert(e.id.clone(), e);
911
912        let obs = body_observations(&store, "lv", &schema);
913        assert_eq!(obs.len(), 1, "got {obs:?}");
914        assert_eq!(obs[0].code, "ABSORBED_SECTION");
915        assert_eq!(obs[0].id, id);
916        assert_eq!(obs[0].detail["heading"], "Field Notes");
917        assert_eq!(
918            obs[0].fate,
919            ObservationFate::Absorbed,
920            "the content survives the next write, and the report must say so"
921        );
922
923        // The refusal complement: nothing on the conformance axis.
924        let schemas = schemas_for(&[("lv", schema.clone())]);
925        let findings = conformance_findings(&store, "lv", &schema, &schemas);
926        assert!(
927            findings.is_empty(),
928            "healthy catch-all use must not be a violation: {:?}",
929            codes(&findings)
930        );
931    }
932
933    /// Criterion 1's other half: a bare heading with no body is the one case
934    /// that really is lost, because the catch-all builder skips empty content.
935    /// Appending a bare heading line is what the original repro described.
936    #[test]
937    fn a_bare_undeclared_heading_is_observed_as_dropped() {
938        let schema = lint_schema();
939        let mut store = Store::new();
940        let mut e = conformant_entity("lv", "alpha");
941        e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
942        // Nothing reached the catch-all: the heading had no body.
943        store.upsert(e.id.clone(), e);
944
945        let obs = body_observations(&store, "lv", &schema);
946        assert_eq!(obs.len(), 1, "got {obs:?}");
947        assert_eq!(obs[0].code, "ABSORBED_SECTION");
948        assert_eq!(
949            obs[0].fate,
950            ObservationFate::Dropped,
951            "an empty heading is skipped by the catch-all, so it does NOT survive"
952        );
953    }
954
955    /// Criterion 3: a frontmatter key the type does not declare is reported
956    /// BEFORE the write that drops it. The generator emits only declared
957    /// fields, so this one is unconditional loss.
958    #[test]
959    fn an_undeclared_metadata_key_is_observed_as_dropped() {
960        let schema = lint_schema();
961        let mut store = Store::new();
962        let mut e = conformant_entity("lv", "alpha");
963        e.metadata
964            .insert("reviewer".into(), MetadataValue::String("ada".into()));
965        // Engine-stamped keys are not the caller's and are not reported.
966        e.metadata
967            .insert("last_modified".into(), MetadataValue::String("x".into()));
968        store.upsert(e.id.clone(), e);
969
970        let obs = body_observations(&store, "lv", &schema);
971        assert_eq!(obs.len(), 1, "got {obs:?}");
972        assert_eq!(obs[0].code, "UNDECLARED_METADATA_KEY");
973        assert_eq!(obs[0].detail["key"], "reviewer");
974        assert_eq!(obs[0].fate, ObservationFate::Dropped);
975    }
976
977    /// Criterion 4: a repeated heading loses every later body, and the two
978    /// cases that produce no warning anywhere today are a repeat of an
979    /// UNDECLARED heading and a repeat of the CATCH-ALL's own heading.
980    #[test]
981    fn a_repeated_heading_is_observed_in_both_silent_cases() {
982        let schema = lint_schema();
983        for (headings, label) in [
984            (
985                vec!["Body", "Scratch", "Scratch"],
986                "undeclared heading twice",
987            ),
988            (
989                vec!["Body", "Notes", "Notes"],
990                "the catch-all's own heading twice",
991            ),
992        ] {
993            let mut store = Store::new();
994            let mut e = conformant_entity("lv", "alpha");
995            e.raw_section_headings = headings.iter().map(|h| h.to_string()).collect();
996            e.sections
997                .insert("notes".into(), "## Scratch\n\nkept\n".into());
998            store.upsert(e.id.clone(), e);
999
1000            let obs = body_observations(&store, "lv", &schema);
1001            let repeats: Vec<_> = obs
1002                .iter()
1003                .filter(|o| o.code == "REPEATED_SECTION_HEADING")
1004                .collect();
1005            assert_eq!(repeats.len(), 1, "{label}: got {obs:?}");
1006            assert!(repeats[0].occurrences_is(2), "{label}");
1007            assert_eq!(repeats[0].fate, ObservationFate::Dropped, "{label}");
1008        }
1009    }
1010
1011    /// Criterion 5, the refusal complement that gives the rest its worth: the
1012    /// ordinary entity produces nothing. A check that fires on healthy content
1013    /// is worse than no check, because it teaches readers to ignore it.
1014    #[test]
1015    fn an_ordinary_entity_produces_no_observations() {
1016        let schema = lint_schema();
1017        let mut store = Store::new();
1018        let mut e = conformant_entity("lv", "alpha");
1019        // `Relationships` belongs here deliberately: it is the heading EVERY
1020        // real entity carries, no type declares it, and a fixture without it
1021        // is the fixture that lets the ordinary case look clean while a live
1022        // mem reports one observation per entity. It was exactly that, until
1023        // a grade read a real mem: 553 entities, 553 observations.
1024        e.raw_section_headings = vec!["Body".into(), "Notes".into(), "Relationships".into()];
1025        e.sections.insert("notes".into(), "plain prose\n".into());
1026        store.upsert(e.id.clone(), e);
1027        assert!(
1028            body_observations(&store, "lv", &schema).is_empty(),
1029            "declared headings, each once, the relationships block, no undeclared keys"
1030        );
1031    }
1032
1033    #[test]
1034    fn a_repeated_undeclared_heading_claims_survival_only_for_the_first() {
1035        // Splitting is first-wins, so the second body is gone whatever the
1036        // catch-all does. Emitting `ABSORBED_SECTION` twice said "survives the
1037        // next write" about a body that did not (grade caveat, 2026-08-27).
1038        let schema = lint_schema();
1039        let mut store = Store::new();
1040        let mut e = conformant_entity("lv", "alpha");
1041        e.raw_section_headings = vec!["Body".into(), "Scratch".into(), "Scratch".into()];
1042        e.sections
1043            .insert("notes".into(), "## Scratch\n\nkept\n".into());
1044        store.upsert(e.id.clone(), e);
1045        let obs = body_observations(&store, "lv", &schema);
1046        let absorbed: Vec<_> = obs
1047            .iter()
1048            .filter(|o| o.code == "ABSORBED_SECTION")
1049            .collect();
1050        assert_eq!(
1051            absorbed.len(),
1052            1,
1053            "one per heading, not per occurrence: {obs:?}"
1054        );
1055        assert_eq!(absorbed[0].fate, ObservationFate::Absorbed);
1056        // The loss the repeat causes is still reported, on its own code.
1057        let repeats: Vec<_> = obs
1058            .iter()
1059            .filter(|o| o.code == "REPEATED_SECTION_HEADING")
1060            .collect();
1061        assert_eq!(repeats.len(), 1, "got: {obs:?}");
1062        assert_eq!(repeats[0].detail["occurrences"], 2);
1063    }
1064
1065    #[test]
1066    fn the_auto_managed_relationships_block_is_never_an_observation() {
1067        // The generator re-emits `## Relationships` from the parsed relations
1068        // on every write, so it is neither absorbed nor dropped. Pinned apart
1069        // from the ordinary-entity test because the two fail for different
1070        // reasons: this one guards the exclusion, that one guards the fixture.
1071        let schema = lint_schema();
1072        let mut store = Store::new();
1073        let mut e = conformant_entity("lv", "alpha");
1074        e.raw_section_headings = vec!["Relationships".into()];
1075        store.upsert(e.id.clone(), e);
1076        assert!(
1077            body_observations(&store, "lv", &schema).is_empty(),
1078            "the relationships block is engine-owned, not undeclared content"
1079        );
1080    }
1081
1082    #[test]
1083    fn a_heading_named_inside_prose_is_not_mistaken_for_a_kept_one() {
1084        // `heading_has_body` asks whether the catch-all RE-EMITTED the
1085        // heading line, and a substring test answers a different question:
1086        // prose that merely mentions the words reported the heading as
1087        // absorbed when the write had in fact dropped it.
1088        let schema = lint_schema();
1089        let mut store = Store::new();
1090        let mut e = conformant_entity("lv", "alpha");
1091        e.raw_section_headings = vec!["Body".into(), "Scratch".into()];
1092        e.sections
1093            .insert("notes".into(), "we discussed Scratch at length\n".into());
1094        store.upsert(e.id.clone(), e);
1095        let obs = body_observations(&store, "lv", &schema);
1096        let absorbed: Vec<_> = obs
1097            .iter()
1098            .filter(|o| o.code == "ABSORBED_SECTION")
1099            .collect();
1100        assert_eq!(absorbed.len(), 1, "got: {obs:?}");
1101        assert_eq!(
1102            absorbed[0].fate,
1103            ObservationFate::Dropped,
1104            "a bare heading whose text appears in prose is still dropped"
1105        );
1106    }
1107
1108    #[test]
1109    fn an_unterminated_fence_names_the_sections_it_swallowed() {
1110        // Criteria 3 and 4. The `Notes` heading was masked by the open fence,
1111        // so it never became a section key: its bytes sit inside `body`. The
1112        // finding has to name it, because the only other signal the entity
1113        // gives is a `notes` key that is simply absent.
1114        let schema = lint_schema();
1115        let mut store = Store::new();
1116        let mut e = conformant_entity("lv", "alpha");
1117        e.sections.insert(
1118            "body".into(),
1119            "intro\n\n```rust\nfn main() {}\n\n## Notes\n\nthe real notes\n".into(),
1120        );
1121        e.sections.shift_remove("notes");
1122        store.upsert(e.id.clone(), e);
1123        let schemas = schemas_for(&[("lv", schema.clone())]);
1124        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1125        let fence: Vec<_> = findings
1126            .iter()
1127            .filter(|f| f.code == "UNTERMINATED_FENCE")
1128            .collect();
1129        assert_eq!(fence.len(), 1, "got: {:?}", codes(&findings));
1130        assert_eq!(fence[0].id, "lv--alpha");
1131        assert_eq!(fence[0].detail["section"], "body");
1132        assert_eq!(fence[0].detail["fence"], "```");
1133        assert_eq!(
1134            fence[0].detail["swallowed_sections"],
1135            serde_json::json!(["Notes"]),
1136        );
1137        // Criterion 4: never clean. A finding on the conformance axis is
1138        // exactly what "not clean" means on this surface.
1139        assert!(!findings.is_empty());
1140    }
1141
1142    #[test]
1143    fn an_entity_with_no_open_fence_gains_no_fence_finding() {
1144        // Criterion 7 at the read tier. Both the no-fence and the closed-fence
1145        // cases, because a guard that fires on any fence character would pass
1146        // the first and fail the second.
1147        let schema = lint_schema();
1148        let schemas = schemas_for(&[("lv", schema.clone())]);
1149        for body in [
1150            "just prose",
1151            "prose\n\n```rust\nfn main() {}\n```\n\nmore",
1152            "```md\n## Notes\n```",
1153        ] {
1154            let mut store = Store::new();
1155            let mut e = conformant_entity("lv", "alpha");
1156            e.sections.insert("body".into(), body.into());
1157            store.upsert(e.id.clone(), e);
1158            let findings = conformance_findings(&store, "lv", &schema, &schemas);
1159            assert!(
1160                !findings.iter().any(|f| f.code == "UNTERMINATED_FENCE"),
1161                "body {body:?} produced: {:?}",
1162                codes(&findings)
1163            );
1164        }
1165    }
1166
1167    #[test]
1168    fn clean_mem_produces_no_findings() {
1169        let schema = lint_schema();
1170        let mut store = Store::new();
1171        let a = conformant_entity("lv", "alpha");
1172        let mut b = conformant_entity("lv", "beta");
1173        b.relationships
1174            .push(Relationship::new("IMPLEMENTS", a.id.clone()));
1175        store.upsert(a.id.clone(), a);
1176        store.upsert(b.id.clone(), b);
1177        let schemas = schemas_for(&[("lv", schema.clone())]);
1178        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1179        assert!(findings.is_empty(), "got: {:?}", codes(&findings));
1180    }
1181
1182    #[test]
1183    fn missing_required_section_and_field_carry_write_time_codes() {
1184        let schema = lint_schema();
1185        let mut store = Store::new();
1186        // No body section, no status field — both required.
1187        let e = entity("lv", "broken", "doc");
1188        let id = e.id.to_string();
1189        store.upsert(e.id.clone(), e);
1190        let schemas = schemas_for(&[("lv", schema.clone())]);
1191        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1192        let cs = codes(&findings);
1193        assert!(cs.contains(&"MISSING_REQUIRED_SECTION"), "got: {cs:?}");
1194        assert!(cs.contains(&"REQUIRED_FIELD_UNSET"), "got: {cs:?}");
1195        for f in &findings {
1196            assert_eq!(f.id, id);
1197            assert_eq!(f.axis, IntegrityAxis::Conformance);
1198        }
1199        // Detail mirrors the write-time recovery payload.
1200        let section_finding = findings
1201            .iter()
1202            .find(|f| f.code == "MISSING_REQUIRED_SECTION")
1203            .unwrap();
1204        assert_eq!(
1205            section_finding.detail["sections"][0]["key"].as_str(),
1206            Some("body")
1207        );
1208        let field_finding = findings
1209            .iter()
1210            .find(|f| f.code == "REQUIRED_FIELD_UNSET")
1211            .unwrap();
1212        assert_eq!(field_finding.detail["field"].as_str(), Some("status"));
1213    }
1214
1215    #[test]
1216    fn invalid_enum_unknown_section_and_unknown_metadata_surface() {
1217        let schema = lint_schema();
1218        let mut store = Store::new();
1219        let mut e = conformant_entity("lv", "drifted");
1220        e.metadata.insert(
1221            "status".to_string(),
1222            MetadataValue::String("banana".to_string()),
1223        );
1224        e.metadata
1225            .insert("wat".to_string(), MetadataValue::String("x".to_string()));
1226        e.sections.insert("bogus".to_string(), "text".to_string());
1227        store.upsert(e.id.clone(), e);
1228        let schemas = schemas_for(&[("lv", schema.clone())]);
1229        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1230        let cs = codes(&findings);
1231        assert!(cs.contains(&"INVALID_ENUM_VALUE"), "got: {cs:?}");
1232        assert!(cs.contains(&"UNKNOWN_SECTION"), "got: {cs:?}");
1233        assert!(cs.contains(&"UNKNOWN_METADATA_FIELD"), "got: {cs:?}");
1234        let enum_finding = findings
1235            .iter()
1236            .find(|f| f.code == "INVALID_ENUM_VALUE")
1237            .unwrap();
1238        assert_eq!(enum_finding.detail["value"].as_str(), Some("banana"));
1239        assert_eq!(
1240            enum_finding.detail["allowed"]
1241                .as_array()
1242                .unwrap()
1243                .iter()
1244                .map(|v| v.as_str().unwrap())
1245                .collect::<Vec<_>>(),
1246            vec!["open", "closed"]
1247        );
1248    }
1249
1250    #[test]
1251    fn unknown_type_short_circuits_with_unknown_entity_type() {
1252        let schema = lint_schema();
1253        let mut store = Store::new();
1254        let e = entity("lv", "mystery", "ghost");
1255        store.upsert(e.id.clone(), e);
1256        let schemas = schemas_for(&[("lv", schema.clone())]);
1257        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1258        assert_eq!(codes(&findings), vec!["UNKNOWN_ENTITY_TYPE"]);
1259        assert_eq!(findings[0].detail["name"].as_str(), Some("ghost"));
1260    }
1261
1262    #[test]
1263    fn invalid_rel_type_and_shape_surface() {
1264        let schema = lint_schema();
1265        let mut store = Store::new();
1266        let mut req_target = conformant_entity("lv", "target");
1267        req_target.entity_type = "req".to_string();
1268        // `req` has no required section/field constraints (plain type).
1269        req_target.metadata.clear();
1270        req_target.sections.clear();
1271        let mut e = conformant_entity("lv", "edges");
1272        e.relationships
1273            .push(Relationship::new("UNDECLARED", req_target.id.clone()));
1274        // IMPLEMENTS pins doc → doc; the target is a `req`.
1275        e.relationships
1276            .push(Relationship::new("IMPLEMENTS", req_target.id.clone()));
1277        store.upsert(req_target.id.clone(), req_target);
1278        store.upsert(e.id.clone(), e);
1279        let schemas = schemas_for(&[("lv", schema.clone())]);
1280        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1281        let cs = codes(&findings);
1282        assert!(cs.contains(&"INVALID_REL_TYPE"), "got: {cs:?}");
1283        assert!(cs.contains(&"INVALID_REL_SHAPE"), "got: {cs:?}");
1284    }
1285
1286    #[test]
1287    fn cross_mem_edges_lint_like_the_write_path() {
1288        let schema = lint_schema();
1289        let other = other_schema();
1290        let mut store = Store::new();
1291        let mut requirement = entity("tv", "goal", "requirement");
1292        requirement
1293            .sections
1294            .insert("body".to_string(), "x".to_string());
1295        let mut task = entity("tv", "chore", "task");
1296        task.sections.insert("body".to_string(), "x".to_string());
1297
1298        let mut e = conformant_entity("lv", "linker");
1299        // Declared domain + matching target type → clean.
1300        e.relationships
1301            .push(Relationship::new("ADDRESSES", requirement.id.clone()));
1302        // Declared domain, target type drifted off `target_types` →
1303        // the write-time shape code resurfaces at lint time.
1304        e.relationships
1305            .push(Relationship::new("ADDRESSES", task.id.clone()));
1306        // Rel-type absent from the cross-mem entry entirely.
1307        e.relationships
1308            .push(Relationship::new("IMPLEMENTS", requirement.id.clone()));
1309        store.upsert(requirement.id.clone(), requirement);
1310        store.upsert(task.id.clone(), task);
1311        store.upsert(e.id.clone(), e);
1312        let schemas = schemas_for(&[("lv", schema.clone()), ("tv", other)]);
1313        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1314        let cs = codes(&findings);
1315        assert_eq!(
1316            cs,
1317            vec!["INVALID_REL_SHAPE", "INVALID_REL_TYPE"],
1318            "declared+conformant edge must stay silent; got: {cs:?}"
1319        );
1320    }
1321
1322    #[test]
1323    fn stub_entities_are_skipped() {
1324        let schema = lint_schema();
1325        let mut store = Store::new();
1326        let mut stub = entity("lv", "ghost-stub", "");
1327        stub.stub = true;
1328        store.upsert(stub.id.clone(), stub);
1329        let schemas = schemas_for(&[("lv", schema.clone())]);
1330        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1331        assert!(findings.is_empty());
1332    }
1333
1334    #[test]
1335    fn other_mems_are_out_of_scope() {
1336        let schema = lint_schema();
1337        let mut store = Store::new();
1338        let e = entity("elsewhere", "broken", "doc");
1339        store.upsert(e.id.clone(), e);
1340        let schemas = schemas_for(&[("lv", schema.clone())]);
1341        let findings = conformance_findings(&store, "lv", &schema, &schemas);
1342        assert!(findings.is_empty());
1343    }
1344
1345    #[test]
1346    fn findings_are_deterministic_and_id_ordered() {
1347        let schema = lint_schema();
1348        let mut store = Store::new();
1349        // Insert in non-lexical order; several findings per entity.
1350        for slug in ["zeta", "alpha", "mid"] {
1351            let e = entity("lv", slug, "doc");
1352            store.upsert(e.id.clone(), e);
1353        }
1354        let schemas = schemas_for(&[("lv", schema.clone())]);
1355        let first = conformance_findings(&store, "lv", &schema, &schemas);
1356        let second = conformance_findings(&store, "lv", &schema, &schemas);
1357        let a = serde_json::to_string(&first).unwrap();
1358        let b = serde_json::to_string(&second).unwrap();
1359        assert_eq!(a, b, "two runs must be byte-identical");
1360        let ids: Vec<&str> = first.iter().map(|f| f.id.as_str()).collect();
1361        let mut sorted = ids.clone();
1362        sorted.sort();
1363        assert_eq!(ids, sorted, "findings must be in lexical id order");
1364    }
1365
1366    #[test]
1367    fn lint_against_target_schema_differs_from_pin() {
1368        // The caller picks the effective schema: the same entity lints
1369        // clean against the `other` schema's `task` type but fails
1370        // against `lint-src` (which has no `task` type) — the
1371        // `target_schema` selector semantics.
1372        let pin = lint_schema();
1373        let target = other_schema();
1374        let mut store = Store::new();
1375        let mut e = entity("lv", "shifting", "task");
1376        e.sections.insert("body".to_string(), "x".to_string());
1377        store.upsert(e.id.clone(), e);
1378        let schemas = schemas_for(&[("lv", pin.clone())]);
1379        let against_pin = conformance_findings(&store, "lv", &pin, &schemas);
1380        assert_eq!(codes(&against_pin), vec!["UNKNOWN_ENTITY_TYPE"]);
1381        let against_target = conformance_findings(&store, "lv", &target, &schemas);
1382        assert!(
1383            against_target.is_empty(),
1384            "got: {:?}",
1385            codes(&against_target)
1386        );
1387    }
1388}