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