Skip to main content

memstead_base/ops/
mod.rs

1//! Operation request/response types and the gix-free read paths
2//! (`health`, `search`).
3//!
4//! Per-entity delta envelopes for `memstead_changes_since` live in
5//! [`changes`] — backend-neutral so both the git-branch tree-diff
6//! and any future folder-backend JSONL-walk produce the same shape.
7//! Wire types for the agent-notes payload live in [`agent_notes`] —
8//! pure data shapes, no gix. The producer functions
9//! (`agent_notes_since`, `read_memstead_ref`) stay in
10//! `memstead-git-branch::ops::agent_notes` because they read from a
11//! gitdir.
12//! The git-touching operation submodules (`crud`, `export`) still
13//! live in `memstead-git-branch` and are re-exported into
14//! `memstead_git_branch::ops` for downstream callers.
15
16pub mod agent_notes;
17pub mod branch_reset;
18pub mod changes;
19pub mod commit_envelope;
20pub mod coverage;
21pub mod diff;
22pub mod export;
23pub mod health;
24pub mod integrity;
25pub mod labelling;
26#[cfg(not(target_arch = "wasm32"))]
27pub mod search;
28pub mod signals;
29pub mod transport;
30
31pub use agent_notes::{AgentNotesReport, CommitNote};
32pub use branch_reset::{BranchResetOutcome, StrandedCrossMemRef};
33pub use changes::{
34    BackendChanges, ChangeEnvelope, ChangesReport, EMPTY_TREE_SHA, MemChangedNotice,
35    NoticeByChange, NoticeChanges, RENAME_SIMILARITY_DEFAULT, RENAME_SIMILARITY_MAX,
36    RENAME_SIMILARITY_MIN, folder_changes_since,
37};
38pub use commit_envelope::{CommitEnvelope, EntityChange};
39pub use diff::{Diff, DiffConfig, EntityDiff, IncomingRipple};
40pub use export::{MemExportBytes, MemExportError};
41pub use transport::{FetchOutcome, PullOutcome, PushOutcome, RemoteAddOutcome, UpdatedRef};
42
43use crate::entity::EntityId;
44use indexmap::IndexMap;
45use schemars::JsonSchema;
46use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct};
47use std::collections::HashMap;
48use std::fmt;
49
50/// Allowed `include` keys for `memstead_overview` — single source of
51/// truth shared across the lean MCP server, full MCP server, and the
52/// lean CLI's `overview` command. Mirrors `HEALTH_INCLUDE_KEYS` for
53/// the `health` surface. The CLI `--include` flag validates against
54/// this list and surfaces `UNKNOWN_INCLUDE_KEY` warnings, matching the
55/// MCP tool's behaviour.
56pub const OVERVIEW_INCLUDE_KEYS: &[&str] = &[
57    "community_members",
58    "community_bridges",
59    "mem_distribution",
60    "dangling_links",
61];
62
63// The unknown-filter warning prose lives in the `Display` impl of the
64// typed `WarningHint::UnknownFilterKey` / `WarningHint::UnknownRangeFilterField`
65// variants below. These helpers are shared with that Display impl. They
66// are pure string formatting with no search/tantivy dependency, so they
67// live here (not in the wasm-gated `search` module) and stay available
68// on `wasm32`.
69
70/// Render the type-list clause as quoted items only — `"'X'"` for one
71/// declarer, `"'X', 'Y'"` for many — without a leading "type" /
72/// "types" word. Caller composes the leading word via
73/// [`type_word_for`] so prose contexts like `"of types ..."` don't
74/// produce the duplicate-word output `"of types types '...'"`.
75pub(crate) fn format_types_clause(types: &[String]) -> String {
76    types
77        .iter()
78        .map(|t| format!("'{t}'"))
79        .collect::<Vec<_>>()
80        .join(", ")
81}
82
83/// Leading word to pair with [`format_types_clause`]: `"type"` for a
84/// single declarer, `"types"` for many. Empty slice maps to `"types"`
85/// (callers should not invoke this for an empty list; the typed-
86/// warning sites guard the `is_empty` case already).
87pub(crate) fn type_word_for(types: &[String]) -> &'static str {
88    if types.len() == 1 { "type" } else { "types" }
89}
90
91// ---------------------------------------------------------------------------
92// CRUD types
93// ---------------------------------------------------------------------------
94
95/// Arguments for creating an entity.
96#[derive(Debug, Clone)]
97pub struct CreateArgs {
98    pub title: String,
99    pub mem: String,
100    pub entity_type: String,
101    /// Section contents keyed by section key: `{ "<section-key>": "..." }`.
102    /// Valid keys depend on the schema (see `TypeDefinition::sections`).
103    pub sections: IndexMap<String, String>,
104    /// Metadata overrides: `{ "<field-key>": "value" }`.
105    pub metadata: IndexMap<String, String>,
106    /// Relationships to create: `[{ target: EntityId, rel_type: "USES" }]`.
107    pub relations: Vec<RelateArg>,
108    /// When true, validate and compute the result but do not write to
109    /// disk, mutate the store, create edges, or commit. Response carries
110    /// the prospective `id`, `file_path`, `content_hash`, and any
111    /// `warnings` — `write_id` is empty.
112    pub dry_run: bool,
113}
114
115/// Arguments for updating an entity.
116#[derive(Debug, Clone)]
117pub struct UpdateArgs {
118    pub id: EntityId,
119    /// Expected content hash (optimistic locking). Required.
120    pub expected_hash: String,
121    /// Section fields to set: `{ "<section-key>": "new content" }`.
122    pub sections: IndexMap<String, String>,
123    /// Section fields to append to: `{ "<section-key>": "extra content" }`.
124    pub append_sections: IndexMap<String, String>,
125    /// Section fields to patch: `{ "<section-key>": PatchArg { old, new } }`.
126    pub patch_sections: IndexMap<String, PatchArg>,
127    /// Metadata fields to set: `{ "<field-key>": "value" }`.
128    pub metadata: IndexMap<String, String>,
129    /// Metadata keys to remove from the entity. Silent no-op on absent
130    /// keys. Errors on read-only fields (mem, id, type) and on
131    /// schema-required fields for the entity's type.
132    pub metadata_unset: Vec<String>,
133    /// Dry-run mode — return proposed changes without persisting.
134    pub dry_run: bool,
135}
136
137/// Arguments for a patch (substring replacement).
138#[derive(Debug, Clone)]
139pub struct PatchArg {
140    pub old: String,
141    pub new: String,
142    /// When `true`, replace every occurrence of `old` in the target
143    /// section. Default `false` replaces only the first occurrence.
144    pub all: bool,
145}
146
147/// Section-level mutations applied by a single `memstead_update` call.
148/// Each vec lists the section keys that landed in that mutation mode.
149/// Empty inner vecs are serde-omitted so the wire stays quiet; the
150/// struct itself always serialises so the outer `modified_sections` key
151/// is a stable shape regardless of what the call actually touched.
152#[derive(Debug, Clone, Default, Serialize)]
153pub struct ModifiedSections {
154    /// Section keys whose body was replaced wholesale (`sections` input).
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub replaced: Vec<String>,
157    /// Section keys whose body received an append (`append_sections`).
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub appended: Vec<String>,
160    /// Section keys whose body was patched via find-and-replace
161    /// (`patch_sections`).
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub patched: Vec<String>,
164    /// Section keys removed outright — heading and body (`sections_unset`).
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub unset: Vec<String>,
167}
168
169/// Metadata-level mutations applied by a single `memstead_update` call.
170/// Same empty-vec-omit convention as `ModifiedSections`; auto-timestamp
171/// metadata fields written by the engine are NOT surfaced here (they are
172/// engine-driven, not user-driven — the caller has nothing to react to).
173#[derive(Debug, Clone, Default, Serialize)]
174pub struct ModifiedMetadata {
175    /// Metadata keys whose value was set or replaced.
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub set: Vec<String>,
178    /// Metadata keys that were removed from the frontmatter.
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub unset: Vec<String>,
181}
182
183/// Result of an update operation.
184#[derive(Debug, Clone, Serialize)]
185pub struct UpdateResult {
186    pub id: EntityId,
187    pub title: String,
188    /// Section-level mutations grouped by mode. Replaces the former flat
189    /// `modified_fields: Vec<String>` (which leaked mode as a string
190    /// prefix and collided on bare keys with `modified_metadata`).
191    pub modified_sections: ModifiedSections,
192    /// Metadata-level mutations grouped by direction (set vs unset).
193    pub modified_metadata: ModifiedMetadata,
194    pub modified_date: String,
195    /// On a real (non-dry-run) update: the new on-disk content hash after
196    /// the write. On a dry-run: the **current** on-disk hash (unchanged) —
197    /// the value an agent passes back as `expected_hash` on the follow-up
198    /// real call. Pair with `prospective_hash` to predict the post-write
199    /// hash without a second read. Wire key `_hash`.
200    #[serde(rename = "_hash")]
201    pub content_hash: String,
202    /// Dry-run only: the hash the entity *would* have after the proposed
203    /// write. `None` on real (non-dry-run) updates. Lets agents preview a
204    /// change and then call the real update with `expected_hash =
205    /// content_hash` (pinning the disk state) while still knowing what the
206    /// post-write hash will look like. Additive optional field — stable
207    /// shape for callers that ignore it.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub prospective_hash: Option<String>,
210    /// The identity the mem's backend minted for this write: a commit
211    /// SHA on a git-branch mem, an opaque synthetic token on a folder
212    /// or in-memory mem. It is an identity and NOT a change cursor —
213    /// `memstead_changes_since` takes a commit SHA on a git-branch mem
214    /// and an RFC3339 ledger timestamp on a folder mem, and feeding it
215    /// this token refuses with `INVALID_CURSOR` (before that guard it
216    /// silently replayed a folder mem's whole history).
217    /// Empty for dry runs (no write happens).
218    #[serde(default)]
219    pub write_id: String,
220    /// Typed non-fatal issues — same shape as `CreateResult::warnings`.
221    /// Pre-Bug-4 this was `Vec<String>` and unused; now carries
222    /// `WarningHint` so e.g. `INLINE_WIKI_LINK_AUTO_STUBBED` from update
223    /// flows out via the same `{code, message, details}` envelope agents
224    /// already branch on for create-time warnings.
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub warnings: Vec<WarningHint>,
227}
228
229/// Typed non-fatal issue surfaced from engine operations. Serialises as the
230/// uniform `{ code, message, details }` envelope so a generic warning handler
231/// (log sink, UI, alerting) can read `code` + `message` without branching on
232/// variant. `Display` renders the agent-facing text, reachable via
233/// [`WarningHint::message`]; per-variant structured fields land under
234/// `details`, their shape keyed by `code`.
235///
236/// Shared across `CreateResult`, `RelateResult`, and `HealthSummary`. New
237/// variants are additive; they widen the enum rather than fork a per-site
238/// type so wire-level warning consumers keep a single discriminated union
239/// to branch on. The wire shape matches what [`envelope`] produces for the
240/// MCP error channel, so one decoder handles both surfaces.
241#[derive(Debug, Clone)]
242pub enum WarningHint {
243    /// A required section was empty or missing at create time. Carries
244    /// the type and section keys plus the section's own `write_rules`
245    /// so the agent can self-correct with a follow-up `memstead_update`.
246    /// Type-level `write_rules` no longer ride per warning — they
247    /// ship once at the mutation-response top level on
248    /// `type_guidance` keyed by `entity_type` (F9). Decoders look up
249    /// the guidance via `entity_type` against the top-level map.
250    MissingRequiredSection {
251        entity_type: String,
252        key: String,
253        heading: String,
254        write_rules: Vec<String>,
255    },
256    /// A required metadata field was not supplied at create time and the
257    /// schema does not auto-fill the value (no `default_value`, no
258    /// `init_timestamp`, no `auto_timestamp`). The entity still lands —
259    /// the generator may write an empty / today's-date placeholder into
260    /// the frontmatter — but the warning surfaces the gap so the agent
261    /// follows up via `memstead_update` rather than leaving the entity in a
262    /// stuck state. Payload mirrors [`Self::MissingRequiredSection`] in
263    /// shape so a single decoder handles both. Wire-equivalent shape
264    /// with `EngineError::RequiredFieldUnset`'s `details` payload, since
265    /// the recovery path is the same (read the description / allowed
266    /// enum values from the envelope rather than re-fetching the
267    /// schema).
268    MissingRequiredField {
269        entity_type: String,
270        key: String,
271        description: String,
272        enum_values: Vec<String>,
273    },
274    /// An undeclared relationship was admitted because the mem's schema
275    /// is in open mode. The caller can still suggest the name be added to
276    /// the schema vocabulary.
277    UndeclaredRelationshipOpen { rel_type: String, message: String },
278    /// `memstead_relate` was asked to add an edge that already exists. The
279    /// op is a successful no-op — the warning surfaces what would otherwise
280    /// be silent so an agent relying on `renames` / side-effects can notice
281    /// the call didn't change the graph.
282    DuplicateRelationship {
283        rel_type: String,
284        from: EntityId,
285        to: EntityId,
286    },
287    /// `memstead_relate` with `remove: true` was asked to drop an edge that
288    /// wasn't present. Successful no-op, surfaced so an agent operating on
289    /// a stale mental model sees the mismatch.
290    NoSuchRelationship {
291        rel_type: String,
292        from: EntityId,
293        to: EntityId,
294    },
295    /// An `include` key passed to `memstead_health` was outside the accepted
296    /// set. The key is ignored; the allowed list is echoed back verbatim so
297    /// an agent with a typo can correct on the next call without opening a
298    /// schema doc.
299    UnknownIncludeKey { key: String, allowed: Vec<String> },
300    /// A paged/bounded parameter exceeded its cap. The cap is authoritative
301    /// so the op still ran, but the warning surfaces what the caller
302    /// requested vs. what was served.
303    LimitClamped { requested: usize, actual: usize },
304    /// `memstead_rename` was asked to change the title but normalisation
305    /// (lowercase, diacritic-folding, punctuation-strip, hyphen-collapse)
306    /// mapped the requested title to the existing slug — so the id is
307    /// unchanged and nothing is written to disk. Surfaced so autonomous
308    /// skills don't mistake the silent short-circuit for a successful
309    /// cosmetic rewrite.
310    TitleNormalizedToSlugNoop {
311        requested_title: String,
312        current_slug: String,
313    },
314    /// The title grammar admits any single-line text, but the slug
315    /// alphabet stays narrow — this create/rename derived an id that
316    /// dropped one or more title characters (`&`, `.`, `§`, …). The
317    /// entity lands with the verbatim title; the warning keeps the
318    /// title↔id divergence visible without being fatal, naming each
319    /// distinct dropped character and the derived slug.
320    TitleCharsDroppedFromSlug {
321        title: String,
322        dropped_chars: Vec<char>,
323        slug: String,
324    },
325    /// `memstead_update` produced a post-mutation entity whose regenerated
326    /// markdown is bytes-identical to the on-disk content — no field,
327    /// section, metadata value, relation, or auto-timestamp actually
328    /// changed. The op is a successful no-op: no disk write, no
329    /// commit, `content_hash` unchanged. Surfaced so autonomous skills
330    /// branching on `write_id != ""` see an explicit signal, and
331    /// `expected_hash`-based polling stays stable across the no-op.
332    /// Mirrors `TitleNormalizedToSlugNoop` for the rename surface.
333    UpdateNoop { id: EntityId },
334    /// `memstead_search` was called with both `stub=true` and `entity_type`
335    /// set. Stubs carry no `entity_type` (they are ID-only placeholders),
336    /// so the combined filter excludes every stub — the call is an empty
337    /// set by construction. Surfaced so an agent doesn't interpret the
338    /// empty result as "no stubs of this type exist" when in fact no
339    /// stub can ever satisfy the filter. Drop `entity_type` to list stubs.
340    StubFilterExcludesAll { entity_type: String },
341    /// `memstead_search(filters: {<key>: ...})` named a filter key that the
342    /// queried type does not declare. The wire `code()` discriminates
343    /// the two outcomes, so a consumer branches on `code` alone:
344    /// - `declared_on_other_types` **empty** → no reachable schema
345    ///   declares the key → `UNKNOWN_FILTER_KEY`; the filter is truly
346    ///   ignored and the result set equals the same search without it.
347    /// - `declared_on_other_types` **non-empty** → the key is declared
348    ///   on other type(s) and the filter was applied with strict
349    ///   type-narrowing (result restricted to the declaring type(s), or
350    ///   emptied when the call scoped to a non-declaring type) →
351    ///   `FILTER_TYPE_SCOPED`.
352    ///
353    /// `declared_on_other_types` stays on the wire as enrichment, not as
354    /// the disambiguator.
355    UnknownFilterKey {
356        key: String,
357        /// `entity_type` the search call scoped to (`None` for an
358        /// unscoped call).
359        scoped_type: Option<String>,
360        /// Types where the filter IS declared, sorted alphabetically.
361        /// Empty when no reachable schema declares the key at all.
362        declared_on_other_types: Vec<String>,
363    },
364    /// `memstead_search(filters: {<field>: ...})` named a field that the
365    /// schema declares but with `filterable: none` — the filter is
366    /// ignored, the hit set is unconstrained by it.
367    FieldNotFilterable { field: String },
368    /// `memstead_search(filters: {<csv-field>: "a,b"})` passed a comma-bearing
369    /// value to a csv-array field. csv fields match a *single* member, so
370    /// the whole rendered value (e.g. the `tags: dedup,retry` an entity
371    /// displays) can never equal any one member — the filter matches
372    /// nothing. Surfaced so an agent that copied the rendered value gets a
373    /// recoverable signal (split into repeated single-member filters)
374    /// rather than an empty result indistinguishable from a true
375    /// no-match. The filter still applies as written (matches nothing);
376    /// this only adds the advisory.
377    FilterValueMultiMember { key: String, value: String },
378    /// `memstead_search(filters: {<field>: <value>})` passed a value the
379    /// schema field constrains with an `enum_values` allow-list, but the
380    /// value (or, for a csv-array field, one of its comma members) is not a
381    /// member. The filter still applies as written and matches nothing for
382    /// that value, so an empty result is otherwise indistinguishable from a
383    /// true no-match — this surfaces the typo plus the allowed values so an
384    /// agent corrects without opening the schema. Reuses the
385    /// `INVALID_ENUM_VALUE` code from the mutation surface.
386    FilterValueNotInEnum {
387        key: String,
388        value: String,
389        allowed: Vec<String>,
390    },
391    /// `memstead_search(related_to: <id>)` reached a neighbourhood larger
392    /// than the cap. The results were ranked by proximity (nearer first)
393    /// and bounded to the nearest `kept` of `total` reachable entities so a
394    /// hub can't flood the caller. Surfaced so the agent knows the
395    /// neighbourhood was truncated — narrow with `depth`/filters for more.
396    NeighbourhoodCapped { kept: usize, total: usize },
397    /// `memstead_search` trimmed the returned page to fit the token budget.
398    /// The highest-ranked `kept` hits that fit under `budget` are returned;
399    /// the rest of the page is dropped so the response stays under the MCP
400    /// transport cap. `_total` still reflects the full match count — page the
401    /// remainder with `offset`, narrow the query, or raise `token_budget`.
402    SearchResultsTruncated { kept: usize, budget: usize },
403    /// `memstead_search(range_filters: {<key>: ...})` named a key that
404    /// doesn't follow the `min_<field>` / `max_<field>` / `<field>_before`
405    /// / `<field>_after` grammar. The key is ignored.
406    RangeFilterKeyMalformed { key: String },
407    /// `memstead_search(range_filters: {<key>: ...})` named a range-filter
408    /// key whose underlying field the queried type does not declare.
409    /// Same shape and same one-code-per-outcome split as
410    /// [`Self::UnknownFilterKey`]: `code()` is `UNKNOWN_RANGE_FILTER_FIELD`
411    /// when `declared_on_other_types` is empty (truly ignored, result =
412    /// unfiltered) and `RANGE_FILTER_TYPE_SCOPED` when non-empty (applied
413    /// with strict type-narrowing). Includes the literal `key` (the
414    /// prefixed/suffixed form the caller sent) alongside the bare `field`.
415    UnknownRangeFilterField {
416        field: String,
417        /// The literal filter key the caller sent, e.g. `min_count`.
418        key: String,
419        scoped_type: Option<String>,
420        declared_on_other_types: Vec<String>,
421    },
422    /// `memstead_search(range_filters: {<field>: ...})` named a field that
423    /// the schema declares but with a filterability other than `range`.
424    /// The range filter is ignored.
425    FieldNotRangeFilterable { field: String },
426    /// `memstead_search` could not query a target mem's search index —
427    /// either the mem has no index yet (`reason: "missing_index"`)
428    /// or a tantivy execution failure surfaced (`reason:
429    /// "query_failed"` plus the error string).
430    SearchMemIndexUnavailable {
431        mem: String,
432        /// Discriminator: `"missing_index"` or `"query_failed"`.
433        reason: &'static str,
434        /// The underlying error string when `reason == "query_failed"`;
435        /// `None` for `"missing_index"`.
436        error: Option<String>,
437    },
438    // There is deliberately no `RenameSimilarityClamped` variant:
439    // out-of-range `rename_similarity` hard-refuses
440    // (`EngineError::RenameSimilarityOutOfRange` → typed
441    // `INVALID_INPUT`) rather than clamping, so the warning channel has
442    // no story to tell and the typed-warning vocabulary tracks the live
443    // wire shape.
444    /// `memstead_create` (or `memstead_rename`) received a `title` with leading
445    /// or trailing whitespace. The engine silently strips the surround
446    /// before slug derivation and storage; the warning records what the
447    /// caller sent vs. what landed so the audit trail can spot the
448    /// drift. Internal whitespace (between words) is preserved
449    /// untouched. Fully-whitespace titles are still refused at the
450    /// validator boundary (those collapse to empty).
451    TitleTrimmed { original: String, trimmed: String },
452    /// An inline wiki-link resolved to an ID of the form
453    /// `<current-mem>--<other-known-mem-suffix>--<slug>`. This is
454    /// almost always drift from a mem-rename — the author wrote
455    /// `[[plugin--slug]]` expecting `plugin` to be the mem prefix, but
456    /// the current mem is `test-mem-plugin`, so the literal
457    /// resolution nests the prefix. Detection only — the load path still
458    /// creates the stub (no silent rewrite). Fix via `memstead_update
459    /// patch_sections` to either the bare slug or the fully-qualified ID.
460    /// Emitted at load / reload / attach time and carried through
461    /// `HealthSummary.warnings`; mutation paths never emit this warning
462    /// to avoid noise on every edit.
463    SuspiciousNestedPrefix {
464        from: EntityId,
465        resolved_id: EntityId,
466        /// Stripped-and-resolved candidate via the two-pass resolver
467        /// (cross-mem lookup first, bare-slug fallback second). `None`
468        /// when no real entity was found — the author must disambiguate.
469        candidate_target: Option<EntityId>,
470        section: String,
471        /// Whether the link's prefix (`resolved_id.mem()`) is itself a
472        /// mounted mem. `true` means the link is a well-formed cross-mem
473        /// reference whose target is missing in that mem (no rename
474        /// happened); `false` means the prefix only resembles a mem
475        /// (it matches a roster member's last name segment), the
476        /// classic mem-rename drift. The message says which, instead
477        /// of calling every case rename drift: on the dogfood graph all
478        /// eight recorded hits were missing targets in mounted mems.
479        prefix_mounted: bool,
480    },
481    /// Inline `[[wiki-link]]` syntax in entity section bodies parsed to
482    /// targets that did not yet resolve, so the engine auto-created stub
483    /// entities for them. A common authoring hazard: an agent illustrating
484    /// link syntax in prose (`[[example:slug]]`) inadvertently creates
485    /// ghost stubs and a REFERENCES edge from the prose entity to each.
486    /// Surfaced so the agent reviews the list and either replaces the
487    /// inline literal with a fenced/quoted form or removes the entity if
488    /// the stub was not intended. Carries the source entity id (`from`)
489    /// and every newly-stubbed `target` id created by THIS call.
490    InlineWikiLinkAutoStubbed {
491        from: EntityId,
492        stubs: Vec<EntityId>,
493    },
494    /// A body wiki-link resolved to the entity's own id, so the
495    /// alias-synthesis pass dropped the would-be self-referential edge
496    /// (F11) — a self-edge carries no navigational value and would render
497    /// as both an Outgoing and an Incoming neighbour of itself. The
498    /// create/update still succeeds (the author may have written their
499    /// own slug); this warns so the dropped link is observable, matching
500    /// the alias pass's other side-effect warnings (`AUTO_STUB_CREATED` /
501    /// `INLINE_WIKI_LINK_AUTO_STUBBED`).
502    SelfLinkIgnored { id: EntityId },
503    /// A body wiki-link crossed into a destination whose SCHEMA the
504    /// source schema declares no cross-mem entry for (and no wildcard),
505    /// so the alias-synthesis pass emitted no edge — the schema
506    /// legitimately declines it, and the write still succeeds. Before
507    /// this warning the link became inert prose SILENTLY (found by the
508    /// graph-plans 02 grading: a default-schema scratch mem citing a
509    /// planning mem, 2026-08-28); the write knows it dropped the edge,
510    /// so it says so, naming the target and the declaration gap. The
511    /// remedy is schema-side: declare the destination schema (or a
512    /// wildcard) under `cross_mem_relationships`.
513    CrossSchemaLinkUndeclared {
514        /// The entity carrying the link.
515        from: EntityId,
516        /// The link's resolved target.
517        target: EntityId,
518        /// The source mem's schema (`name@version` display form).
519        source_schema: String,
520        /// The target mem's schema name — the missing `to_schema` entry.
521        target_schema: String,
522    },
523    /// `memstead_relate` to a cross-mem target whose mem is not (yet)
524    /// mounted in the workspace. The cross-mem link policy permits
525    /// the edge, so the engine auto-stubs the target as a forward
526    /// reference — but with the target mem entirely absent from
527    /// `writable_mems()`, the stub has no `_mem_schema` resolution
528    /// and any later read sees an indeterminate-schema entity. The
529    /// warning makes the missing-mem state visible so an operator
530    /// can distinguish a typo (intended `B` but typed `b`) from a
531    /// deliberate forward reference that expects the mem to be
532    /// created later. (F4)
533    CrossMemTargetMemUncreated {
534        from_mem: String,
535        to_mem: String,
536        target_id: EntityId,
537    },
538    /// A mutation landed without a `note` field while the workspace
539    /// config's `[mutations].require_notes = true` — provenance is
540    /// best-effort, so the engine completes the commit but flags the
541    /// absence so autonomous skills can audit their coverage. The
542    /// mutation still writes to disk and produces a commit; this warning
543    /// exists purely to surface the missed opportunity for a human- /
544    /// agent-readable body line. `tool` carries the MCP tool name
545    /// (`memstead_create`, `memstead_update`, …) so consumers can attribute the
546    /// gap without re-deriving it from the response context.
547    NoteMissing { tool: String },
548    /// A create supplied a value for an auto-managed metadata field
549    /// (`init_timestamp` like `created_date`, or `auto_timestamp` like
550    /// `last_modified`); the engine owns those values, so the supplied
551    /// one was discarded and the engine value stamped instead. The
552    /// entity still lands — this warning closes the silent-drop gap so
553    /// the agent learns its input had no effect without a follow-up
554    /// read. `field` names the discarded key; `supplied` echoes the
555    /// rejected value. (The `memstead_update` path refuses the same keys
556    /// outright with `READ_ONLY_FIELD`; create's posture is
557    /// stamp-and-proceed, so it warns rather than refusing.)
558    IgnoredReadonlyField { field: String, supplied: String },
559    /// The workspace is embedded inside another git repository
560    /// (`outer_repo_root`) whose `.gitignore` does not list
561    /// `mem-repo/`. Without that ignore line, the outer repo would
562    /// either swallow `mem-repo-git` as a nested untracked tree or
563    /// (worse) record it as a submodule via gitlink — both shapes
564    /// silently corrupt the mem-repo identity.
565    ///
566    /// Surfaced from `memstead_health` so the agent / operator can fix
567    /// the outer repo's `.gitignore` (or pass `--no-gitignore` at
568    /// `memstead mem-repo init`/`migrate-from-disk` time and accept the
569    /// risk explicitly).
570    OuterRepoNotIgnoringMemRepo {
571        outer_repo_root: String,
572        workspace_root: String,
573    },
574    /// One or more `required_outgoing` blocks on the entity's type are
575    /// not yet satisfied by its post-application outgoing edges. Tier-2
576    /// — the create/update lands; the warning surfaces every unsatisfied
577    /// block in a single payload so the agent can emit one batched
578    /// `memstead_relate` follow-up.
579    MissingRequiredOutgoing {
580        entity_type: String,
581        entity_id: EntityId,
582        /// Each entry mirrors one unsatisfied `RequiredOutgoing` block:
583        /// the alternative relationship names plus the rendered
584        /// cardinality literal (`"at_least_one"`).
585        missing: Vec<MissingRequiredOutgoingBlock>,
586    },
587    /// A successful write moved a declared aggregate signal across a
588    /// threshold, in either direction. Out-of-band diagnostics beside
589    /// the success payload — never error-shaped, never changing the
590    /// mutation's success semantics (a signal crossing on a
591    /// successful write must not read as a failed write). Levels are
592    /// the wire literals `none` / `notice` / `warn`.
593    SignalThresholdCrossed {
594        entity_id: EntityId,
595        signal: String,
596        value: u64,
597        old_level: String,
598        new_level: String,
599    },
600    /// The written entity violates warn-tier declared `constraints`
601    /// of its type (e.g. `requires_when`: a field required under the
602    /// current value of another field is unset). Block-tier violations
603    /// refuse instead ([`EngineError::ConstraintUnsatisfied`]) — the
604    /// warning only ever carries `severity: warn` entries.
605    ConstraintUnsatisfied {
606        entity_type: String,
607        entity_id: EntityId,
608        violations: Vec<crate::ops::health::UnsatisfiedConstraint>,
609    },
610    /// A markdown file declared the same `## <Heading>` twice or more for a
611    /// schema-declared section key. The parser keeps the first occurrence's
612    /// body and drops the rest — the duplicate headers and their bodies are
613    /// removed from the storage value, so the next read-modify-write cycle
614    /// emits a single heading. Surfaced so the operator (or the next ingest
615    /// cycle) sees that content was discarded; common cause is an agent
616    /// appending a section instead of replacing it.
617    ///
618    /// Emitted at load / reload / attach time only; mutation paths do not
619    /// re-parse the just-written file.
620    DuplicateSectionHeading {
621        entity_id: EntityId,
622        section_key: String,
623        heading: String,
624        occurrences: usize,
625    },
626    /// The engine detected that a sibling writer (another `Engine`
627    /// instance, an out-of-band `git pull`, etc.) advanced the on-disk
628    /// HEAD of `mem` past the engine's cached `last_known_head`, so
629    /// the engine reloaded that mem's slice of the in-memory store
630    /// before serving the current call. The response carries fresh
631    /// content; the warning explains why state shifted under the
632    /// caller. Agents that need the per-entity diff call
633    /// `memstead_changes_since` with the supplied `old_head`.
634    MemReloaded {
635        mem: String,
636        old_head: String,
637        new_head: String,
638        entities_loaded: usize,
639    },
640    /// `OUT_OF_BAND_EDITS_UNDETECTED`: this folder mem's drift cursor is its
641    /// own change ledger, which only the engine writes, so an edit made to the
642    /// files by anything else advances nothing (04/04, criterion 3).
643    ///
644    /// The engine keeps serving pre-edit content and `changes_since` reports
645    /// the edit as never having happened. It is not fixable cheaply: the
646    /// staleness probe runs before every operation, and turning it into a
647    /// directory walk would change the cost profile of the whole folder
648    /// backend. So the engine says it cannot detect them rather than staying
649    /// quiet, and `memstead health --include ledger` reconciles on demand.
650    ///
651    /// Never fires for a git-branch mem: its change set is a real two-tree
652    /// diff, so the condition cannot arise.
653    OutOfBandEditsUndetected { mem: String },
654    /// A config write found the stored config had moved on from what this
655    /// engine last observed: another writer changed it in between
656    /// (consistency-sweep 04/03, criterion 3).
657    ///
658    /// The write still lands. It is applied to the CONFIG THAT IS THERE, not
659    /// to the engine's cached copy, so the intervening writer's fields
660    /// survive; `fields` names what they had changed. The warning exists
661    /// because a caller who set one field and finds three different is owed
662    /// the explanation on the response that did it, not in a log.
663    ///
664    /// Never fires in a single-writer workspace: the cached copy equals the
665    /// file there, so there is nothing to report.
666    ConfigWriteIntervened { mem: String, fields: Vec<String> },
667    /// `memstead_relate` add path landed on a not-yet-real target id and
668    /// the engine materialised a stub at that id (in-memory upsert; the
669    /// file lands when a follow-up `memstead_create` promotes the stub).
670    /// Pre-fix surfaced through a top-level `stub_warning: Option<String>`
671    /// field on the relate response — agents iterating `warnings[]` to
672    /// surface non-fatal findings silently skipped the auto-stub case.
673    /// Carries the materialised stub id so the agent can pin a
674    /// follow-up `memstead_create` (or `memstead_relate remove=true` to drop
675    /// the edge before authoring). `pending` marks the dry-run path:
676    /// the rehearsal validated the add and REPORTS the would-be stub
677    /// without writing it — the code stays `AUTO_STUB_CREATED`
678    /// (response-shape stability), only the message branches, so a
679    /// rehearsed response never claims a performed effect.
680    AutoStubCreated { stub_id: EntityId, pending: bool },
681    /// A duplicate-add `memstead_relate` on a derivation-declared
682    /// rel-type refreshed the edge's baseline (agent-trust plan 12) —
683    /// the agent's explicit "I have reviewed the target's change; the
684    /// derivation still holds". Sidecar-only: `_hash` unchanged, the
685    /// edge unchanged; the response carries this warning so the
686    /// refresh is stated rather than a bare no-op.
687    DerivationBaselineRefreshed {
688        from: EntityId,
689        rel_type: String,
690        to: EntityId,
691    },
692    /// A relation parsed from an entity's `## Relationships` section
693    /// at load time failed validation against the source mem's
694    /// schema (or wiki-link grammar). The entity itself loads
695    /// normally; the offending relation is dropped from the
696    /// in-memory store. `reason` discriminates:
697    /// - `unknown_rel_type` — the rel-type is not declared in the
698    ///   source mem's schema and the schema is in `strict` mode.
699    /// - `shape` — the `(source_type, target_type)` pair is not
700    ///   allowed by the rel-type's `source_types` / `target_types`.
701    /// - `cycle` — adding this relation would close a cycle in an
702    ///   acyclic-declared subgraph (emitted by the post-load
703    ///   second-pass cycle check; not yet implemented).
704    ///
705    /// Hand-edits, external tooling, and embedder editor
706    /// surfaces can inject relations that bypass `memstead_relate`; the
707    /// parse-path validation catches those. Mutation-path writes
708    /// pre-validated by the engine never trip this warning.
709    ///
710    /// `origin` discriminates the source mount's capability:
711    /// `"writable"` (the operator can fix the source markdown via
712    /// `memstead_update` / `memstead_relate` and re-run) or `"readonly"`
713    /// (the source mem is mounted read-only — purely diagnostic,
714    /// the operator either uninstalls the archive or accepts the
715    /// dropped relation).
716    ///
717    /// `recovery` carries an abstract-action payload sufficient to
718    /// reverse the drop without consulting another response. `Some`
719    /// when `origin == "writable"` — the engine can rewrite the
720    /// source markdown via the mutation surface, so a consumer (an
721    /// agent walking `memstead_health`, a bulk-fix orchestrator, a
722    /// UI drift panel) maps `kind` to the concrete call on
723    /// whichever MCP / CLI surface it uses. `None` when
724    /// `origin == "readonly"` — the source markdown is not reachable
725    /// via the engine, so no abstract action exists; the warning's
726    /// message names the operator-level path (uninstall the archive
727    /// or accept the drop).
728    ParsedRelationInvalid {
729        entity_id: EntityId,
730        rel_type: String,
731        target: EntityId,
732        reason: String,
733        origin: String,
734        recovery: Option<ParsedRelationRecovery>,
735    },
736    /// `memstead_delete` (or `memstead_rename`, when implemented) on a
737    /// Write-Mem entity that had **no** Write-Mem referrers but
738    /// **does** have ReadOnly-mount referrers. The on-disk file is
739    /// removed and committed; the in-memory entity is demoted to a
740    /// stub at the same id so the surviving incoming edges from the
741    /// ReadOnly mount(s) keep a valid target. The agent sees
742    /// `memstead_entity <id>` returning a stub immediately and not
743    /// stale data after a server reload — fresh boot from disk
744    /// reconstructs the same stub via the parser's auto-stub-on-
745    /// unresolved-link path. `referrers` carries the surviving
746    /// ReadOnly source ids so the agent can either accept the stub
747    /// or uninstall the archive.
748    ResidualStubForReadOnlyReferrers {
749        id: EntityId,
750        referrers: Vec<EntityId>,
751    },
752    /// `memstead_mem_delete` was called with `delete_files: true` but
753    /// at least one part of the symmetric cleanup did not complete.
754    /// The mem is already unregistered from the router; this
755    /// warning surfaces what survived so an agent reading
756    /// `files_deleted: false` doesn't trigger redundant cleanup or
757    /// blame the wrong layer. `reason` discriminates:
758    /// - `rmdir_failed` — folder-backed mem directory survived
759    ///   `remove_dir_all` (filesystem permission, busy handle, …).
760    ///   `path` names the directory; `error` carries the OS-level
761    ///   diagnostic.
762    /// - `backend_prune_failed` — git-branch backend rejected the
763    ///   ref-edit transaction that prunes
764    ///   `refs/heads/<branch_leaf>` + `__MEMSTEAD:mems/.../config.json`
765    ///   (gitdir IO, concurrent writer racing the ref). `path` is
766    ///   `None`; `error` carries the wrapped backend message.
767    ///
768    /// One emission per failed step — both can land in the same
769    /// response when a folder mount somehow has both an rmdir
770    /// failure and a backend cleanup failure (rare; the folder
771    /// backend's `delete_artifacts` is a no-op default).
772    MemFilesNotDeleted {
773        mem: String,
774        reason: String,
775        path: Option<String>,
776        error: Option<String>,
777    },
778    /// `memstead mem init` detected a pre-existing branch + config
779    /// blob carrying the `unregistered_at` tombstone marker that
780    /// `memstead mem unregister` writes — the operator's deliberate
781    /// "preserve for re-attach" signal. The create path adopted the
782    /// residual entities, cleared the tombstone, and registered the
783    /// branch as a writable mount. Audit visibility for the
784    /// reattach so an agent reading the warnings sees what shape
785    /// the new mount took. `unregistered_at` carries the ISO-8601
786    /// timestamp the tombstone recorded so the operator can correlate
787    /// the reattach with a prior unregister event.
788    MemReattachedAfterUnregister {
789        mem: String,
790        unregistered_at: String,
791    },
792    /// One-time boot migration: legacy `readMems` entries found in a
793    /// writable mem's config were converted into workspace-level
794    /// read-only mounts and the legacy key was removed from the
795    /// config. `mems` lists the migrated read-mem names,
796    /// `from_host_mems` the writable mems whose configs carried them.
797    /// A second boot is silent — the source key is gone.
798    ReadMemsMigratedToMounts {
799        mems: Vec<String>,
800        from_host_mems: Vec<String>,
801    },
802    /// Boot-honesty skew: the mem's engine-owned mutation stamp
803    /// (`MemConfig.mutation_stamp`, written after mutations) records a
804    /// different engine version than the running binary. Informative,
805    /// never fatal — the next mutation under this binary re-stamps.
806    /// Absence of a stamp (a pre-stamp mem) never fires this; only a
807    /// present, disagreeing stamp does. Surfaces on boot output and
808    /// `memstead health` without an include gate.
809    EngineVersionSkew {
810        mem: String,
811        /// Engine version the last mutation was performed under.
812        stamped_engine: String,
813        /// Engine version of the running binary.
814        running_engine: String,
815        /// Resolved schema the last mutation validated against.
816        stamped_schema: String,
817        /// Which way the versions differ. Present because "they differ" left
818        /// the reader to work out whether their binary was ahead of the mem
819        /// or behind it, which is the only part that changes what they should
820        /// do (04/04, criterion 8).
821        direction: crate::build_info::SkewDirection,
822    },
823    /// Generation-behind hint: the mem's pinned schema resolved from
824    /// the BUILT-IN catalogue and the catalogue registers at least
825    /// one strictly-higher version of the same name (real semver
826    /// ordering). Warn-tier, ungated, never blocking — retention
827    /// seals every shipped version, so the pin keeps working; the
828    /// hint names the newest available generation and the migration
829    /// verb. Locally-installed (workspace-storage) pins are silent:
830    /// the engine only knows generations for built-ins. Surfaces on
831    /// boot output and `memstead health` without an include gate,
832    /// like the skew hint above.
833    SchemaGenerationsBehind {
834        mem: String,
835        /// The pinned ref (`name@version`).
836        pinned: String,
837        /// The newest built-in version registered under the same name.
838        newest: String,
839    },
840    /// The mem was created on storage with no version control (a
841    /// folder mount). Provenance means something WEAKER there than the
842    /// headline "every mutation a reasoned commit": mutations ARE
843    /// recorded — each lands in the folder backend's changelog ledger
844    /// (`.memstead/changes.jsonl`) with its provenance note — but
845    /// there are no commits, the `write_id` every mutation returns
846    /// is a synthetic token rather than a commit and is not a change
847    /// cursor (poll with the last ledger entry's `ts`), and the
848    /// content is not durable until the surrounding repository
849    /// commits it. Emitted once, at
850    /// creation, to whoever is actually acting; never a refusal —
851    /// folder mems are a supported storage class.
852    FolderMemProvenance { mem: String },
853    /// Authoring-drift health axis: a pinned schema's sealed copy
854    /// carries an install-provenance stamp, and the authoring path it
855    /// names is GONE from the working tree. Distinct from
856    /// [`WarningHint::SchemaAuthoringSourceDiverged`] — a missing
857    /// package and a diverged one need different actions. Only
858    /// stamped schemas are checked: on git-branch workspaces the
859    /// authoring folder is typically absent for unstamped seals, so a
860    /// naive existence check would warn on healthy workspaces.
861    SchemaAuthoringSourceMissing {
862        schema_ref: String,
863        stamped_path: String,
864        mems: Vec<String>,
865    },
866    /// Authoring-drift health axis: the stamped authoring path exists
867    /// but its package no longer parses EQUIVALENT to the sealed copy
868    /// the engine runs on (parsed-schema comparison, never raw bytes —
869    /// editor-header comment lines and serialisation reordering do not
870    /// trip it). `detail` says how: a load failure's message, or the
871    /// parsed-difference marker.
872    SchemaAuthoringSourceDiverged {
873        schema_ref: String,
874        stamped_path: String,
875        mems: Vec<String>,
876        detail: String,
877    },
878    /// Low-tier rot axis for UNSTAMPED pins — distinct from the two
879    /// stamped variants above, whose no-false-positive contract stays
880    /// untouched. The pinned schema's sealed package still loads
881    /// tolerantly (the mem runs fine), but its content no longer passes
882    /// current-language AUTHORING validation — so the package is, as of
883    /// the seal, no longer installable, and the (unstamped, therefore
884    /// unlocatable) authoring source it was sealed from has rotted the
885    /// same way unless someone has since fixed it. `detail` carries the
886    /// authoring-tier load error. Remedy: re-author the package under
887    /// the current language and `memstead schema install` it — which
888    /// re-seals AND stamps, handing the check over to the divergence
889    /// axis. An unstamped package that still parses under the authoring
890    /// tier produces no hint.
891    SchemaUnstampedSourceRot {
892        schema_ref: String,
893        mems: Vec<String>,
894        detail: String,
895    },
896    /// A `## Relationships` row was followed by trailing content that
897    /// did not match the canonical em-dash delimiter (` — `, U+2014
898    /// framed by spaces) — ASCII `--`, ASCII `-`, en-dash U+2013, or
899    /// minus U+2212. The relation parses with `description: None`;
900    /// the trailing content is NOT preserved on the in-memory
901    /// `Relationship`, so the next render of this entity normalises
902    /// the row to the simple form `- **TYPE**: [[X]]`. The warning is
903    /// the operator's signal that content was dropped — restore the
904    /// description with an explicit em-dash if it should round-trip.
905    /// Emitted at parse time (load / reload / attach); mutation paths
906    /// never trip it because they go through the typed `description`
907    /// parameter rather than markdown text.
908    AmbiguousDescriptionDelimiter {
909        from: EntityId,
910        rel_type: String,
911        target: EntityId,
912        /// Literal trailing content captured between `]]` and end of
913        /// line — surfaced verbatim so the operator can paste the
914        /// intended text back in with a canonical delimiter.
915        trailing: String,
916    },
917    /// Parse-time variant of [`crate::EngineError::MissingRequiredDescription`].
918    /// A hand-edited `## Relationships` row used a rel-type whose
919    /// schema declares `per_edge_description: required` without a
920    /// trailing description. The relation still loads (the engine
921    /// does not block the file from booting), but the warning
922    /// surfaces the gap so the operator follows up with `memstead_update`
923    /// / `memstead_relate` to author the missing description.
924    ParseMissingRequiredDescription {
925        from: EntityId,
926        rel_type: String,
927        target: EntityId,
928    },
929    /// Parse-time variant of [`crate::EngineError::DescriptionNotPermitted`].
930    /// A hand-edited `## Relationships` row used a rel-type whose
931    /// schema declares `per_edge_description: forbidden` together
932    /// with a trailing em-dash description. The relation still loads
933    /// (the engine does not block the file from booting); the
934    /// description is dropped from the in-memory `Relationship` and
935    /// the next render normalises the row to the simple form. The
936    /// warning surfaces the violation so the operator either removes
937    /// the text from disk or asks the schema author to widen the
938    /// rel-type's posture.
939    ParseDescriptionNotPermitted {
940        from: EntityId,
941        rel_type: String,
942        target: EntityId,
943    },
944    /// A mem's `Mount.schema` expectation (the pin recorded in the
945    /// workspace `mounts.json`) disagreed with the authoritative pin in
946    /// the mem's own per-mem config. Boot resolves the effective
947    /// schema from the mem config (authoritative — a copied/cloned
948    /// mem is self-resolvable); this warning surfaces the discrepancy
949    /// so neither value is silently dropped. Recovery: align the
950    /// `mounts.json` entry to the mem's config, or correct the config.
951    SchemaPinMismatch {
952        /// Mem whose mount expectation and config pin disagree.
953        mem: String,
954        /// Authoritative pin from the mem's per-mem config.
955        config_pin: String,
956        /// Expectation pin recorded on the workspace mount.
957        mount_pin: String,
958    },
959    /// A mount resolved to nothing: the storage it names does not
960    /// exist (`missing_ref` for a git-branch mount whose branch was
961    /// never created or was deleted, `missing_path` for a folder or
962    /// archive mount whose path is gone) or exists and holds no
963    /// entity (`empty`). Before this warning a mount pointing at a
964    /// nonexistent branch sat in the writable roster with zero
965    /// entities and nothing said so (the dogfood workspace carried two
966    /// such mounts for weeks). Emitted at boot and on reload; a mount
967    /// that resolves to at least one entity is silent. Lazy mounts are
968    /// probed for storage presence only (the entity walk is deferred),
969    /// so `empty` is reported for eager mounts.
970    MountUnbacked {
971        /// The mount's mem name.
972        mem: String,
973        /// Why it is unbacked.
974        reason: MountUnbackedReason,
975        /// What the mount names: the branch ref, the folder path or
976        /// the archive path, for the operator's repair.
977        location: String,
978    },
979    /// A mutation wrote a section whose emitted heading differs from a
980    /// heading already present in the file that derives to the same
981    /// section key. The write still commits — refusing would strand
982    /// entities written before the round-trip gate existed — but the
983    /// divergence is surfaced so the caller sees the file's heading
984    /// text shifting under it (the regenerated file carries the
985    /// schema's declared heading; the previous text is replaced).
986    SectionHeadingDivergence {
987        entity_id: EntityId,
988        section_key: String,
989        /// Heading the mutation is writing (the schema's declared one).
990        writing_heading: String,
991        /// Different heading the file carried for the same key.
992        existing_heading: String,
993    },
994    /// A mem's resolved (already-installed) schema declares one or
995    /// more sections whose heading does not derive back to its key —
996    /// the condition new installs are refused for
997    /// (`check_section_heading_roundtrip`). Sealed schemas keep
998    /// loading by contract (refusing at boot would brick the
999    /// workspace), so the violation surfaces here instead: every write
1000    /// against such a section forks its content into a second heading
1001    /// or the catch-all. Recovery: fix the schema's heading/key pairs
1002    /// and reinstall.
1003    SchemaHeadingRoundtripViolation {
1004        /// Mem whose pinned schema violates the rule.
1005        mem: String,
1006        /// The pinned `<name>@<version>`.
1007        schema_ref: String,
1008        /// Every offending `(type, key, heading, derived_key)` tuple.
1009        violations: Vec<SchemaHeadingViolation>,
1010    },
1011}
1012
1013/// Wire-shape entry inside `SchemaHeadingRoundtripViolation.violations`
1014/// — one section whose declared heading does not derive back to its
1015/// declared key. Mirrors `memstead_schema::HeadingKeyViolation`, kept
1016/// as a local struct so the warning's JSON shape is owned here.
1017#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1018pub struct SchemaHeadingViolation {
1019    pub type_name: String,
1020    pub key: String,
1021    pub heading: String,
1022    pub derived_key: String,
1023}
1024
1025impl From<&memstead_schema::HeadingKeyViolation> for SchemaHeadingViolation {
1026    fn from(v: &memstead_schema::HeadingKeyViolation) -> Self {
1027        Self {
1028            type_name: v.type_name.clone(),
1029            key: v.key.clone(),
1030            heading: v.heading.clone(),
1031            derived_key: v.derived_key.clone(),
1032        }
1033    }
1034}
1035
1036/// Wire-shape entry inside `MissingRequiredOutgoing.missing`. Lists the
1037/// relationship-name alternatives and the rendered cardinality literal
1038/// for one unsatisfied `RequiredOutgoing` block. Custom struct so the
1039/// JSON output is `{ "relationships": [...], "cardinality": "at_least_one" }`
1040/// — identical to the schema YAML shape, so an agent can copy the
1041/// envelope's `details.missing` entry directly into a `memstead_relate`
1042/// plan without renaming fields.
1043#[derive(Debug, Clone, Serialize)]
1044pub struct MissingRequiredOutgoingBlock {
1045    pub relationships: Vec<String>,
1046    pub cardinality: String,
1047    /// The block's declared severity. Serialized only for `block` —
1048    /// warn is the default the vocabulary has always had, and existing
1049    /// consumers keep their byte-identical `{ relationships,
1050    /// cardinality }` shape.
1051    #[serde(skip_serializing_if = "severity_is_warn")]
1052    pub severity: memstead_schema::ConstraintSeverity,
1053    /// The condition that armed a conditional block (`when_field` /
1054    /// `when_value` on the declaration). Serialized only when present,
1055    /// so unconditional blocks keep their byte-identical shape — and
1056    /// the reader of a refusal, warning, or health finding sees which
1057    /// trigger armed the obligation.
1058    #[serde(skip_serializing_if = "Option::is_none")]
1059    pub when_field: Option<String>,
1060    #[serde(skip_serializing_if = "Option::is_none")]
1061    pub when_value: Option<String>,
1062}
1063
1064fn severity_is_warn(s: &memstead_schema::ConstraintSeverity) -> bool {
1065    *s == memstead_schema::ConstraintSeverity::Warn
1066}
1067
1068/// Abstract recovery action attached to a `PARSED_RELATION_INVALID`
1069/// warning when the source mem is writable. The shape is tool-
1070/// agnostic: it names *what* to do, not *which tool* to call. A
1071/// consumer (agent, bulk-fix orchestrator, app surface) maps `kind`
1072/// to the concrete call on whichever MCP / CLI path it
1073/// uses; the warning's payload itself does not drift when the
1074/// mutation surface evolves.
1075///
1076/// `kind` is the discriminator. Additive — new variants may land as
1077/// the recovery taxonomy grows. Current values:
1078///
1079/// - `"remove_explicit_relation"` — drop the relation from the
1080///   source entity's `## Relationships` section. Agents map this to
1081///   `memstead_relate { from: source_id, to: target_id, type: rel_type,
1082///   remove: true }`. The CLI maps it to the equivalent
1083///   `memstead relate --remove` invocation. The bulk-fix consumer reads
1084///   `source_id`, `target_id`, `rel_type` straight from the payload.
1085///
1086/// The mirrored `source_id` / `target_id` / `rel_type` fields are
1087/// redundant with the warning's `entity_id` / `target` / `rel_type`
1088/// — duplication is intentional. A consumer that branches on
1089/// `recovery` and forwards the payload downstream does not need to
1090/// stitch the warning's top-level fields back in.
1091#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1092pub struct ParsedRelationRecovery {
1093    pub kind: String,
1094    pub source_id: EntityId,
1095    pub target_id: EntityId,
1096    pub rel_type: String,
1097}
1098
1099impl ParsedRelationRecovery {
1100    /// Stable discriminator for the "drop the relation from the
1101    /// source markdown" recovery — the only abstract action this
1102    /// warning emits today.
1103    pub const KIND_REMOVE_EXPLICIT_RELATION: &'static str = "remove_explicit_relation";
1104
1105    /// Constructor for the standard `remove_explicit_relation`
1106    /// recovery — the only shape produced by the parser today.
1107    /// Emission sites use this so the discriminator string lives in
1108    /// one place.
1109    pub fn remove_explicit_relation(
1110        source_id: EntityId,
1111        target_id: EntityId,
1112        rel_type: String,
1113    ) -> Self {
1114        Self {
1115            kind: Self::KIND_REMOVE_EXPLICIT_RELATION.to_string(),
1116            source_id,
1117            target_id,
1118            rel_type,
1119        }
1120    }
1121}
1122
1123/// Per-entry result of an `apply_parse_recovery` call. One entry per
1124/// `PARSED_RELATION_INVALID` warning the engine observed at the call
1125/// site: the bulk-fix dispatches the writable-origin recoveries and
1126/// reports the read-only-origin warnings as skipped. Wire-equivalent
1127/// across the MCP and CLI surfaces; the renderer chooses the
1128/// shape it prefers.
1129///
1130/// `outcome` is the stable discriminator. Current values:
1131/// - `"removed"` — the source entity was re-rendered; the parse-time-
1132///   dropped row no longer appears in the on-disk markdown. `reason`
1133///   is `None`.
1134/// - `"skipped"` — the engine intentionally did not attempt the
1135///   recovery. `reason` carries a stable code: `"readonly_mount"`
1136///   (source mem is read-only and not engine-writable).
1137/// - `"failed"` — the engine attempted the recovery and the underlying
1138///   mutation surfaced a typed error. `reason` carries the engine's
1139///   `UPPER_SNAKE_CASE` error code (`HASH_MISMATCH`,
1140///   `WIKILINK_WITHOUT_RELATION`, etc.). The original entity-side
1141///   drift survives and will surface again on the next reload.
1142#[derive(Debug, Clone, Serialize)]
1143pub struct ParseRecoveryEntry {
1144    pub entity_id: EntityId,
1145    pub rel_type: String,
1146    pub target: EntityId,
1147    pub outcome: String,
1148    #[serde(default, skip_serializing_if = "Option::is_none")]
1149    pub reason: Option<String>,
1150}
1151
1152impl ParseRecoveryEntry {
1153    pub const OUTCOME_REMOVED: &'static str = "removed";
1154    pub const OUTCOME_SKIPPED: &'static str = "skipped";
1155    pub const OUTCOME_FAILED: &'static str = "failed";
1156
1157    /// Stable reason value for read-only-origin warnings the bulk-fix
1158    /// cannot act on — the source markdown is not engine-writable.
1159    pub const REASON_READONLY_MOUNT: &'static str = "readonly_mount";
1160}
1161
1162/// Outcome of `Engine::apply_parse_recovery`. Carries one
1163/// `ParseRecoveryEntry` per parse-time-dropped relation observed at
1164/// the call site plus the last successful commit sha for callers that
1165/// want to poll `memstead_changes_since` for the per-entity diff. An empty
1166/// `entries` list means the workspace was already clean.
1167///
1168/// Idempotency: re-running on a workspace where the writable drops
1169/// were already cleaned produces an empty `entries` list (no work,
1170/// no commits, no errors).
1171#[derive(Debug, Clone, Default, Serialize)]
1172pub struct ParseRecoveryReport {
1173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1174    pub entries: Vec<ParseRecoveryEntry>,
1175    /// The backend's identity for the last successful per-source
1176    /// re-render the bulk-fix performed — a commit SHA on a git-branch
1177    /// mem, a synthetic token on a folder mem, and never a change
1178    /// cursor. Empty when no recovery wrote to disk
1179    /// (workspace already clean, only read-only warnings, or every
1180    /// writable attempt failed).
1181    #[serde(default, skip_serializing_if = "String::is_empty")]
1182    pub write_id: String,
1183}
1184
1185impl fmt::Display for WarningHint {
1186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1187        match self {
1188            WarningHint::SchemaPinMismatch {
1189                mem,
1190                config_pin,
1191                mount_pin,
1192            } => write!(
1193                f,
1194                "mem '{mem}': the workspace mount expects schema '{mount_pin}' but the \
1195                 mem's own config pins '{config_pin}' — the config pin is authoritative and \
1196                 was used; align the mounts.json entry or the mem config to clear this"
1197            ),
1198            WarningHint::MountUnbacked {
1199                mem,
1200                reason,
1201                location,
1202            } => match reason {
1203                MountUnbackedReason::MissingRef => write!(
1204                    f,
1205                    "mount '{mem}' is unbacked: its branch {location} does not exist \
1206                     (the mem was never created there, or the branch was deleted); create \
1207                     it, point the mount at the right branch, or remove the mount"
1208                ),
1209                MountUnbackedReason::MissingPath => write!(
1210                    f,
1211                    "mount '{mem}' is unbacked: its path {location} does not exist; \
1212                     restore the folder or remove the mount"
1213                ),
1214                MountUnbackedReason::Empty => write!(
1215                    f,
1216                    "mount '{mem}' is unbacked: {location} exists but holds no entity \
1217                     (an empty mem serves nothing); author into it or remove the mount"
1218                ),
1219            },
1220            WarningHint::SectionHeadingDivergence {
1221                entity_id,
1222                section_key,
1223                writing_heading,
1224                existing_heading,
1225            } => write!(
1226                f,
1227                "entity '{entity_id}': section '{section_key}' is being written under \
1228                 heading '{writing_heading}' but the file carried '{existing_heading}' for \
1229                 the same section — the write commits and the regenerated file uses \
1230                 '{writing_heading}'; the previous heading text is replaced"
1231            ),
1232            WarningHint::SchemaHeadingRoundtripViolation {
1233                mem,
1234                schema_ref,
1235                violations,
1236            } => {
1237                let list = violations
1238                    .iter()
1239                    .map(|v| {
1240                        format!(
1241                            "type '{}' section '{}' heading '{}' (derives to '{}')",
1242                            v.type_name, v.key, v.heading, v.derived_key
1243                        )
1244                    })
1245                    .collect::<Vec<_>>()
1246                    .join("; ");
1247                write!(
1248                    f,
1249                    "mem '{mem}': pinned schema '{schema_ref}' declares section heading(s) \
1250                     that cannot round-trip to their key(s): {list}. The mem keeps loading, \
1251                     but writes to these sections fork content into a second heading or the \
1252                     catch-all. Fix the schema's heading/key pairs and reinstall — new \
1253                     installs of such a schema are refused"
1254                )
1255            }
1256            WarningHint::MissingRequiredSection {
1257                key,
1258                heading,
1259                write_rules,
1260                ..
1261            } => {
1262                write!(
1263                    f,
1264                    "required section '{key}' (heading \"{heading}\") is empty — \
1265                     entity will show as unhealthy"
1266                )?;
1267                if !write_rules.is_empty() {
1268                    write!(f, ". Writing guidance:")?;
1269                    for rule in write_rules {
1270                        write!(f, "\n  - {rule}")?;
1271                    }
1272                }
1273                Ok(())
1274            }
1275            WarningHint::MissingRequiredField {
1276                key,
1277                entity_type,
1278                description,
1279                enum_values,
1280            } => {
1281                write!(
1282                    f,
1283                    "required metadata field '{key}' on type '{entity_type}' was not \
1284                     supplied — entity landed with a placeholder. {description}"
1285                )?;
1286                if !enum_values.is_empty() {
1287                    write!(f, " Allowed values: [{}].", enum_values.join(", "))?;
1288                }
1289                Ok(())
1290            }
1291            WarningHint::UndeclaredRelationshipOpen { message, .. } => f.write_str(message),
1292            WarningHint::DuplicateRelationship { rel_type, from, to } => write!(
1293                f,
1294                "relationship {rel_type} from {from} to {to} already exists — no-op"
1295            ),
1296            WarningHint::NoSuchRelationship { rel_type, from, to } => write!(
1297                f,
1298                "relationship {rel_type} from {from} to {to} does not exist — no-op"
1299            ),
1300            WarningHint::UnknownIncludeKey { key, allowed } => write!(
1301                f,
1302                "unknown include key '{key}' ignored. Allowed: [{}]",
1303                allowed.join(", ")
1304            ),
1305            WarningHint::LimitClamped { requested, actual } => write!(
1306                f,
1307                "limit clamped from {requested} to {actual} (max for memstead_health)"
1308            ),
1309            WarningHint::TitleNormalizedToSlugNoop {
1310                requested_title,
1311                current_slug,
1312            } => write!(
1313                f,
1314                "requested title '{requested_title}' normalises to the existing slug \
1315                 '{current_slug}' — no change written to disk"
1316            ),
1317            WarningHint::TitleCharsDroppedFromSlug {
1318                title,
1319                dropped_chars,
1320                slug,
1321            } => write!(
1322                f,
1323                "title '{title}' keeps its characters as display text, but the derived \
1324                 slug '{slug}' drops {dropped_chars:?} — link this entity by its slug"
1325            ),
1326            WarningHint::UpdateNoop { id } => write!(
1327                f,
1328                "update on {id} produced bytes-identical content — no \
1329                 disk write, no commit, content_hash unchanged"
1330            ),
1331            WarningHint::StubFilterExcludesAll { entity_type } => write!(
1332                f,
1333                "stub=true combined with entity_type='{entity_type}' excludes every \
1334                 stub — stubs carry no entity_type. Drop entity_type to list stubs."
1335            ),
1336            WarningHint::UnknownFilterKey {
1337                key,
1338                scoped_type,
1339                declared_on_other_types,
1340            } => {
1341                let on_other = !declared_on_other_types.is_empty();
1342                let scoped_matches_other = matches!(
1343                    scoped_type.as_deref(),
1344                    Some(t) if declared_on_other_types.iter().any(|o| o == t)
1345                );
1346                if let Some(t) = scoped_type.as_deref() {
1347                    if on_other && !scoped_matches_other {
1348                        let word = type_word_for(declared_on_other_types);
1349                        let items = format_types_clause(declared_on_other_types);
1350                        return write!(
1351                            f,
1352                            "filter '{key}' applied with strict type-exclusion semantics — exists on {word} {items} but the query scoped to type '{t}', where it is not declared. All entities of type '{t}' will be excluded; scope to a declaring type to apply the filter."
1353                        );
1354                    }
1355                    return write!(
1356                        f,
1357                        "unknown filter key '{key}' for type '{t}' — filter ignored"
1358                    );
1359                }
1360                if on_other {
1361                    let word = type_word_for(declared_on_other_types);
1362                    let items = format_types_clause(declared_on_other_types);
1363                    return write!(
1364                        f,
1365                        "filter '{key}' applied with strict type-exclusion semantics — only entities of {word} {items} will match. Scope explicitly via entity_type=… to suppress this warning."
1366                    );
1367                }
1368                write!(
1369                    f,
1370                    "unknown filter key '{key}' — no reachable schema declares it — filter ignored"
1371                )
1372            }
1373            WarningHint::FieldNotFilterable { field } => {
1374                write!(f, "field '{field}' is not filterable — filter ignored")
1375            }
1376            WarningHint::FilterValueMultiMember { key, value } => write!(
1377                f,
1378                "filter '{key}={value}' targets a csv-array field but the value contains a comma — \
1379                 csv fields match a single member, so the full value matches nothing. Filter on one \
1380                 member at a time (e.g. `{key}={first}`)",
1381                first = value.split(',').next().map(str::trim).unwrap_or("").trim(),
1382            ),
1383            WarningHint::FilterValueNotInEnum {
1384                key,
1385                value,
1386                allowed,
1387            } => write!(
1388                f,
1389                "filter '{key}={value}' is not an allowed value for '{key}' — allowed: [{}]. \
1390                 The filter applies as written and matches nothing.",
1391                allowed.join(", ")
1392            ),
1393            WarningHint::NeighbourhoodCapped { kept, total } => write!(
1394                f,
1395                "related_to neighbourhood has {total} entities; ranked by proximity and bounded to \
1396                 the nearest {kept}. Narrow with `depth` or filters to see fewer, more specific hits."
1397            ),
1398            WarningHint::SearchResultsTruncated { kept, budget } => write!(
1399                f,
1400                "results trimmed to the highest-ranked {kept} hits to fit the {budget}-token budget. \
1401                 `_total` is the full match count — page the rest with `offset`, narrow the query, \
1402                 or raise `token_budget`."
1403            ),
1404            WarningHint::RangeFilterKeyMalformed { key } => write!(
1405                f,
1406                "range filter key '{key}' must start with 'min_'/'max_' or end with '_before'/'_after' — filter ignored"
1407            ),
1408            WarningHint::UnknownRangeFilterField {
1409                field,
1410                key,
1411                scoped_type,
1412                declared_on_other_types,
1413            } => {
1414                let on_other = !declared_on_other_types.is_empty();
1415                let scoped_matches_other = matches!(
1416                    scoped_type.as_deref(),
1417                    Some(t) if declared_on_other_types.iter().any(|o| o == t)
1418                );
1419                if let Some(t) = scoped_type.as_deref() {
1420                    if on_other && !scoped_matches_other {
1421                        let word = type_word_for(declared_on_other_types);
1422                        let items = format_types_clause(declared_on_other_types);
1423                        return write!(
1424                            f,
1425                            "range filter field '{field}' (from key '{key}') applied with strict type-exclusion semantics — exists on {word} {items} but the query scoped to type '{t}', where it is not declared. All entities of type '{t}' will be excluded; scope to a declaring type to apply the filter."
1426                        );
1427                    }
1428                    return write!(
1429                        f,
1430                        "unknown range filter field '{field}' (from key '{key}') for type '{t}' — filter ignored"
1431                    );
1432                }
1433                if on_other {
1434                    let word = type_word_for(declared_on_other_types);
1435                    let items = format_types_clause(declared_on_other_types);
1436                    return write!(
1437                        f,
1438                        "range filter field '{field}' (from key '{key}') applied with strict type-exclusion semantics — only entities of {word} {items} will match. Scope explicitly via entity_type=… to suppress this warning."
1439                    );
1440                }
1441                write!(
1442                    f,
1443                    "unknown range filter field '{field}' (from key '{key}') — no reachable schema declares it — filter ignored"
1444                )
1445            }
1446            WarningHint::FieldNotRangeFilterable { field } => write!(
1447                f,
1448                "field '{field}' is not range-filterable — filter ignored"
1449            ),
1450            WarningHint::SearchMemIndexUnavailable { mem, reason, error } => {
1451                match (*reason, error.as_deref()) {
1452                    ("missing_index", _) => {
1453                        write!(f, "mem '{mem}' has no search index — query returns no hits")
1454                    }
1455                    ("query_failed", Some(e)) => {
1456                        write!(f, "search index for mem '{mem}' errored: {e}")
1457                    }
1458                    _ => write!(f, "search index for mem '{mem}' is unavailable ({reason})"),
1459                }
1460            }
1461            WarningHint::TitleTrimmed { original, trimmed } => write!(
1462                f,
1463                "title trimmed of surrounding whitespace: {original:?} → {trimmed:?}"
1464            ),
1465            WarningHint::SuspiciousNestedPrefix {
1466                from,
1467                resolved_id,
1468                candidate_target,
1469                section,
1470                prefix_mounted,
1471            } => {
1472                if *prefix_mounted {
1473                    write!(
1474                        f,
1475                        "wiki-link in {from}#{section} resolves to {resolved_id}: \
1476                         target missing in mem {}",
1477                        resolved_id.mem()
1478                    )?;
1479                } else {
1480                    write!(
1481                        f,
1482                        "wiki-link in {from}#{section} resolves to {resolved_id}: \
1483                         prefix '{}' is not a mounted mem (it only matches a mem \
1484                         name's last segment, the mem-rename drift pattern)",
1485                        resolved_id.mem()
1486                    )?;
1487                }
1488                if let Some(cand) = candidate_target {
1489                    write!(f, "; did you mean {cand}?")?;
1490                }
1491                Ok(())
1492            }
1493            WarningHint::InlineWikiLinkAutoStubbed { from, stubs } => {
1494                write!(
1495                    f,
1496                    "{from} contained {n} inline wiki-link(s) that auto-created stub \
1497                     entities — review whether the stubs were intended; if not, \
1498                     remove the inline syntax or wrap the example in a fenced/quoted \
1499                     form. Auto-stubbed targets:",
1500                    n = stubs.len(),
1501                )?;
1502                for s in stubs {
1503                    write!(f, "\n  - {s}")?;
1504                }
1505                Ok(())
1506            }
1507            WarningHint::SelfLinkIgnored { id } => write!(
1508                f,
1509                "{id} contains a body wiki-link to its own id — the self-referential edge \
1510                 was dropped (a self-link carries no navigational value). The entity was \
1511                 created/updated normally; remove the `[[{slug}]]` link if it was a mistake",
1512                slug = id.name(),
1513            ),
1514            WarningHint::CrossSchemaLinkUndeclared {
1515                from,
1516                target,
1517                source_schema,
1518                target_schema,
1519            } => write!(
1520                f,
1521                "{from} body-links {target}, but schema {source_schema} declares no \
1522                 cross_mem_relationships entry for schema '{target_schema}' (and no \
1523                 wildcard), so NO edge was emitted — the link is prose only. The write \
1524                 succeeded. To make such citations real edges, declare '{target_schema}' \
1525                 (or a `to_schema: \"*\"` wildcard) under the source schema's \
1526                 cross_mem_relationships",
1527            ),
1528            WarningHint::CrossMemTargetMemUncreated {
1529                from_mem,
1530                to_mem,
1531                target_id,
1532            } => write!(
1533                f,
1534                "cross-mem relate from '{from_mem}' to '{target_id}': \
1535                 target mem '{to_mem}' is not mounted in the workspace — \
1536                 the auto-stub has no schema resolution until the mem is created. \
1537                 If '{to_mem}' is a typo, fix the relate; if forward-reference \
1538                 is intended, create the mem to promote the stub."
1539            ),
1540            WarningHint::NoteMissing { tool } => write!(
1541                f,
1542                "{tool} called without a `note` while \
1543                 `[mutations].require_notes = true` — commit landed, \
1544                 body carries no provenance line"
1545            ),
1546            WarningHint::IgnoredReadonlyField { field, supplied } => write!(
1547                f,
1548                "'{field}' is auto-managed by the engine — the supplied \
1549                 value '{supplied}' was discarded and the engine value \
1550                 stamped instead"
1551            ),
1552            WarningHint::OuterRepoNotIgnoringMemRepo {
1553                outer_repo_root,
1554                workspace_root,
1555            } => write!(
1556                f,
1557                "workspace at '{workspace_root}' is embedded inside the git \
1558                 repository at '{outer_repo_root}' but the outer .gitignore \
1559                 does not list 'mem-repo/'. Add 'mem-repo/' (or the \
1560                 workspace-relative equivalent) to the outer repo's \
1561                 .gitignore to keep mem-repo-git out of the outer index."
1562            ),
1563            WarningHint::SignalThresholdCrossed {
1564                entity_id,
1565                signal,
1566                value,
1567                old_level,
1568                new_level,
1569            } => write!(
1570                f,
1571                "signal '{signal}' on {entity_id} crossed a declared threshold: \
1572                 {old_level} → {new_level} (value {value})"
1573            ),
1574            WarningHint::MissingRequiredOutgoing {
1575                entity_type,
1576                entity_id,
1577                missing,
1578            } => {
1579                write!(
1580                    f,
1581                    "{entity_id} ({entity_type}) is missing required outgoing edges — \
1582                     schema declares {n} `required_outgoing` block(s) still unsatisfied:",
1583                    n = missing.len(),
1584                )?;
1585                for block in missing {
1586                    write!(
1587                        f,
1588                        "\n  - [{}] cardinality={}",
1589                        block.relationships.join(", "),
1590                        block.cardinality,
1591                    )?;
1592                }
1593                Ok(())
1594            }
1595            WarningHint::ConstraintUnsatisfied {
1596                entity_type,
1597                entity_id,
1598                violations,
1599            } => {
1600                write!(
1601                    f,
1602                    "{entity_id} ({entity_type}) violates {n} declared constraint(s):",
1603                    n = violations.len(),
1604                )?;
1605                for v in violations {
1606                    write!(f, "\n  - {}", v.describe())?;
1607                }
1608                Ok(())
1609            }
1610            WarningHint::DuplicateSectionHeading {
1611                entity_id,
1612                section_key,
1613                heading,
1614                occurrences,
1615            } => write!(
1616                f,
1617                "{entity_id} declared `## {heading}` {occurrences} times — \
1618                 section '{section_key}' kept the first occurrence's body \
1619                 and dropped the rest. The next read-modify-write will \
1620                 collapse the markdown to one heading."
1621            ),
1622            WarningHint::OutOfBandEditsUndetected { mem } => write!(
1623                f,
1624                "mem '{mem}' is folder-backed, so its drift cursor is its own change ledger and \
1625                 only the engine writes it: an edit made to its files by anything else is not \
1626                 detected, and reads keep serving the pre-edit content. Reconcile on demand with \
1627                 `memstead health --include ledger`.",
1628            ),
1629            WarningHint::ConfigWriteIntervened { mem, fields } => write!(
1630                f,
1631                "mem '{mem}' config had changed since this engine last read it: another writer \
1632                 set {}. This write was applied on top of theirs, so nothing of theirs was \
1633                 lost.",
1634                fields.join(", "),
1635            ),
1636            WarningHint::MemReloaded {
1637                mem,
1638                old_head,
1639                new_head,
1640                entities_loaded,
1641            } => write!(
1642                f,
1643                "mem '{mem}' was reloaded — on-disk HEAD advanced from \
1644                 {old_head} to {new_head} (a sibling writer or out-of-band \
1645                 commit landed since the engine last read the mem). \
1646                 {entities_loaded} entities reloaded; response carries \
1647                 fresh content. Re-derive any conclusions that depended on \
1648                 the prior content of this mem before continuing. Call \
1649                 `memstead_changes_since since={old_head}` for the per-entity \
1650                 diff."
1651            ),
1652            WarningHint::AutoStubCreated { stub_id, pending } => {
1653                if *pending {
1654                    write!(
1655                        f,
1656                        "target '{stub_id}' does not exist — a stub would be \
1657                         auto-created by the real call. Promote it via \
1658                         memstead_create first, or let the real call create \
1659                         the stub (adoption preserves the incoming edge)."
1660                    )
1661                } else {
1662                    write!(
1663                        f,
1664                        "target '{stub_id}' did not exist — stub auto-created. \
1665                         Promote it via memstead_create when authoring the real \
1666                         entity (stub adoption preserves the incoming edge)."
1667                    )
1668                }
1669            }
1670            WarningHint::DerivationBaselineRefreshed { from, rel_type, to } => write!(
1671                f,
1672                "derivation baseline refreshed: '{from}' -[{rel_type}]-> '{to}' — the edge \
1673                 already existed; its baseline now records the target's current content \
1674                 hash (reviewed, still holds). Nothing else changed."
1675            ),
1676            WarningHint::ParsedRelationInvalid {
1677                entity_id,
1678                rel_type,
1679                target,
1680                reason,
1681                origin,
1682                recovery: _,
1683            } => {
1684                let recovery_msg = if origin == "readonly" {
1685                    "Source mem is mounted read-only; the engine cannot \
1686                     rewrite the markdown. Either remove the mount \
1687                     (`memstead uninstall <mem>`) or accept the dropped \
1688                     relation."
1689                } else {
1690                    "Fix the source markdown (via memstead_update / \
1691                     memstead_relate — `details.recovery` carries the abstract \
1692                     action) or adjust the schema."
1693                };
1694                write!(
1695                    f,
1696                    "parsed relation {rel_type} from {entity_id} to \
1697                     {target} was dropped — reason: {reason}, origin: \
1698                     {origin}. The entity loaded but the relation does \
1699                     not appear in the in-memory graph. {recovery_msg}"
1700                )
1701            }
1702            WarningHint::ResidualStubForReadOnlyReferrers { id, referrers } => write!(
1703                f,
1704                "{id} was deleted from disk but {n} read-only-mount \
1705                 referrer(s) still target it; the in-memory entity is \
1706                 demoted to a stub at the same id so the surviving \
1707                 incoming edges keep a valid target. Surviving referrers: \
1708                 [{}]. Either accept the stub or remove the source mount \
1709                 (`memstead uninstall <mem>`) — read-only content cannot \
1710                 be rewritten by the engine.",
1711                referrers
1712                    .iter()
1713                    .map(|r| r.to_string())
1714                    .collect::<Vec<_>>()
1715                    .join(", "),
1716                n = referrers.len(),
1717            ),
1718            WarningHint::AmbiguousDescriptionDelimiter {
1719                from,
1720                rel_type,
1721                target,
1722                trailing,
1723            } => write!(
1724                f,
1725                "{from} → {target} ({rel_type}): trailing content {trailing:?} \
1726                 after `]]` did not match the canonical em-dash delimiter ` — ` \
1727                 (U+2014); content dropped, the relation parses with no \
1728                 description. Restore with `memstead_relate {from} {rel_type} \
1729                 {target} --description \"<text>\"` (or hand-edit using \
1730                 ` — `) if the text was intentional."
1731            ),
1732            WarningHint::ParseMissingRequiredDescription {
1733                from,
1734                rel_type,
1735                target,
1736            } => write!(
1737                f,
1738                "{from} → {target} ({rel_type}): rel-type declares \
1739                 `per_edge_description: required` but the row has no \
1740                 trailing em-dash description. Add one via `memstead_relate \
1741                 {from} {rel_type} {target} --description \"<text>\"` (or \
1742                 hand-edit the markdown using ` — `)."
1743            ),
1744            WarningHint::ParseDescriptionNotPermitted {
1745                from,
1746                rel_type,
1747                target,
1748            } => write!(
1749                f,
1750                "{from} → {target} ({rel_type}): rel-type declares \
1751                 `per_edge_description: forbidden` but the markdown row \
1752                 carries a trailing description. The description is \
1753                 dropped from the in-memory graph and the next render \
1754                 normalises the row to the simple form. Drop the trailing \
1755                 text from the source markdown if it should not round-trip."
1756            ),
1757            WarningHint::MemReattachedAfterUnregister {
1758                mem,
1759                unregistered_at,
1760            } => write!(
1761                f,
1762                "mem '{mem}' was reattached to pre-existing storage \
1763                 that carried an `unregistered_at: {unregistered_at}` \
1764                 tombstone marker. The entities from the prior session \
1765                 were adopted; the tombstone has been cleared. If this \
1766                 reattach was unexpected, run `memstead mem delete \
1767                 {mem}` to destroy the storage and start fresh."
1768            ),
1769            WarningHint::ReadMemsMigratedToMounts {
1770                mems,
1771                from_host_mems,
1772            } => write!(
1773                f,
1774                "legacy `readMems` registrations were migrated to \
1775                 workspace-level read-only mounts: [{}] (previously \
1776                 attached to writable mem(s) [{}]). The legacy key was \
1777                 removed from the config; this migration runs once. \
1778                 Remove a migrated read-mem with `memstead uninstall \
1779                 <name>`.",
1780                mems.join(", "),
1781                from_host_mems.join(", "),
1782            ),
1783            WarningHint::EngineVersionSkew {
1784                mem,
1785                stamped_engine,
1786                running_engine,
1787                stamped_schema,
1788                direction,
1789            } => write!(
1790                f,
1791                "mem '{mem}': the last mutation was performed by engine \
1792                 v{stamped_engine} (against schema {stamped_schema}); \
1793                 this binary is engine v{running_engine} ({}). Informative \
1794                 only — the next mutation re-stamps. If behaviour \
1795                 differs from the last session, the binary changed \
1796                 between them.",
1797                match direction {
1798                    crate::build_info::SkewDirection::StampedNewer =>
1799                        "the mem was last written by a NEWER binary than this one",
1800                    crate::build_info::SkewDirection::StampedOlder =>
1801                        "the mem was last written by an OLDER binary than this one",
1802                },
1803            ),
1804            WarningHint::SchemaGenerationsBehind {
1805                mem,
1806                pinned,
1807                newest,
1808            } => write!(
1809                f,
1810                "mem '{mem}' pins built-in schema {pinned}, but the \
1811                 catalogue registers newer generations up to {newest}. \
1812                 The pin keeps working (retained versions stay sealed); \
1813                 migrate via `memstead mem set-schema` when ready.",
1814            ),
1815            WarningHint::FolderMemProvenance { mem } => write!(
1816                f,
1817                "mem '{mem}' was created on folder storage with no \
1818                 version control. Provenance here is the changelog \
1819                 ledger (`.memstead/changes.jsonl`), which records \
1820                 every mutation with its note — but there are no \
1821                 commits: the `write_id` mutations return is a \
1822                 synthetic token rather than a commit, and it is not a \
1823                 change cursor — poll this mem with the `ts` of the last \
1824                 ledger entry you read. The content is not durable until \
1825                 the surrounding repository commits it."
1826            ),
1827            WarningHint::SchemaAuthoringSourceMissing {
1828                schema_ref,
1829                stamped_path,
1830                mems,
1831            } => write!(
1832                f,
1833                "schema '{schema_ref}' (pinned by {}) was installed from \
1834                 '{stamped_path}', and that authoring package is no longer \
1835                 there. The engine keeps running on its sealed copy — \
1836                 nothing is broken — but the source the seal came from is \
1837                 gone: restore or move back the package, or re-install \
1838                 from its new location to re-stamp.",
1839                mems.join(", ")
1840            ),
1841            WarningHint::SchemaAuthoringSourceDiverged {
1842                schema_ref,
1843                stamped_path,
1844                mems,
1845                detail,
1846            } => write!(
1847                f,
1848                "schema '{schema_ref}' (pinned by {}) no longer matches \
1849                 its authoring package at '{stamped_path}': {detail}. The \
1850                 engine keeps running on its sealed copy; if the authoring \
1851                 change is intended, bump the version and `memstead schema \
1852                 install` it.",
1853                mems.join(", ")
1854            ),
1855            WarningHint::SchemaUnstampedSourceRot {
1856                schema_ref,
1857                mems,
1858                detail,
1859            } => write!(
1860                f,
1861                "schema '{schema_ref}' (pinned by {}) has no install-provenance \
1862                 stamp, and its sealed package no longer passes current-language \
1863                 authoring validation: {detail}. The mem keeps running on the \
1864                 tolerantly-loaded seal — nothing is broken — but the package is \
1865                 no longer installable as authored. Re-author it under the \
1866                 current language and `memstead schema install` it (which also \
1867                 stamps it, so future drift is checked).",
1868                mems.join(", ")
1869            ),
1870            WarningHint::MemFilesNotDeleted {
1871                mem,
1872                reason,
1873                path,
1874                error,
1875            } => match (reason.as_str(), path.as_deref(), error.as_deref()) {
1876                ("rmdir_failed", Some(p), Some(e)) => write!(
1877                    f,
1878                    "mem '{mem}' was unregistered but rmdir of \
1879                         {p:?} failed: {e}. Files remain on disk; agent \
1880                         may follow up with manual cleanup."
1881                ),
1882                ("rmdir_failed", Some(p), None) => write!(
1883                    f,
1884                    "mem '{mem}' was unregistered but rmdir of \
1885                         {p:?} failed. Files remain on disk."
1886                ),
1887                ("backend_prune_failed", _, Some(e)) => write!(
1888                    f,
1889                    "mem '{mem}' was unregistered but backend \
1890                         artifact cleanup failed: {e}. The mem-repo \
1891                         branch and/or `__MEMSTEAD:mems/.../config.json` \
1892                         entry may survive; rerun delete with the same \
1893                         arguments or have an operator inspect."
1894                ),
1895                ("backend_prune_failed", _, None) => write!(
1896                    f,
1897                    "mem '{mem}' was unregistered but backend \
1898                         artifact cleanup failed. The mem-repo branch \
1899                         and/or `__MEMSTEAD` config entry may survive."
1900                ),
1901                _ => write!(
1902                    f,
1903                    "mem '{mem}' was unregistered but \
1904                         `delete_files: true` did not run to completion \
1905                         (reason: {reason})."
1906                ),
1907            },
1908        }
1909    }
1910}
1911
1912/// Closed vocabulary of [`WarningHint::MountUnbacked`] reasons, serialised
1913/// as the lowercase `details.reason` value.
1914#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1915pub enum MountUnbackedReason {
1916    /// Git-branch mount: the branch ref does not exist.
1917    MissingRef,
1918    /// Folder or archive mount: the path does not exist.
1919    MissingPath,
1920    /// The storage exists and holds no entity.
1921    Empty,
1922}
1923
1924impl MountUnbackedReason {
1925    /// The wire value (`missing_ref` / `missing_path` / `empty`).
1926    pub fn as_str(self) -> &'static str {
1927        match self {
1928            Self::MissingRef => "missing_ref",
1929            Self::MissingPath => "missing_path",
1930            Self::Empty => "empty",
1931        }
1932    }
1933}
1934
1935impl WarningHint {
1936    /// Stable UPPER_SNAKE_CASE identifier. Wire-level contract — never rename
1937    /// an existing value; new variants add new codes. Agents branch on this,
1938    /// not on [`WarningHint::message`].
1939    pub fn code(&self) -> &'static str {
1940        match self {
1941            Self::InlineWikiLinkAutoStubbed { .. } => "INLINE_WIKI_LINK_AUTO_STUBBED",
1942            Self::CrossMemTargetMemUncreated { .. } => "CROSS_MEM_TARGET_MEM_UNCREATED",
1943            Self::MissingRequiredSection { .. } => "MISSING_REQUIRED_SECTION",
1944            Self::MissingRequiredField { .. } => "MISSING_REQUIRED_FIELD",
1945            Self::UndeclaredRelationshipOpen { .. } => "UNDECLARED_RELATIONSHIP_OPEN",
1946            Self::DuplicateRelationship { .. } => "DUPLICATE_RELATIONSHIP",
1947            Self::NoSuchRelationship { .. } => "NO_SUCH_RELATIONSHIP",
1948            Self::UnknownIncludeKey { .. } => "UNKNOWN_INCLUDE_KEY",
1949            Self::LimitClamped { .. } => "LIMIT_CLAMPED",
1950            Self::TitleNormalizedToSlugNoop { .. } => "TITLE_NORMALIZED_TO_SLUG_NOOP",
1951            Self::TitleCharsDroppedFromSlug { .. } => "TITLE_CHARS_DROPPED_FROM_SLUG",
1952            Self::UpdateNoop { .. } => "UPDATE_NOOP",
1953            Self::StubFilterExcludesAll { .. } => "STUB_FILTER_EXCLUDES_ALL",
1954            // One code per outcome:
1955            // a key declared on some OTHER reachable type was applied
1956            // with strict type-narrowing (the filter took effect — it
1957            // restricts the result to the declaring type(s)), so it
1958            // carries a distinct code from a key no schema declares
1959            // (which is truly ignored). A consumer branches on `code`
1960            // alone to learn whether its filter took effect, without
1961            // inspecting `declared_on_other_types`.
1962            Self::UnknownFilterKey {
1963                declared_on_other_types,
1964                ..
1965            } => {
1966                if declared_on_other_types.is_empty() {
1967                    "UNKNOWN_FILTER_KEY"
1968                } else {
1969                    "FILTER_TYPE_SCOPED"
1970                }
1971            }
1972            Self::FieldNotFilterable { .. } => "FIELD_NOT_FILTERABLE",
1973            Self::FilterValueMultiMember { .. } => "FILTER_VALUE_MULTI_MEMBER",
1974            Self::FilterValueNotInEnum { .. } => "INVALID_ENUM_VALUE",
1975            Self::NeighbourhoodCapped { .. } => "NEIGHBOURHOOD_CAPPED",
1976            Self::SearchResultsTruncated { .. } => "SEARCH_RESULTS_TRUNCATED",
1977            Self::RangeFilterKeyMalformed { .. } => "RANGE_FILTER_KEY_MALFORMED",
1978            Self::UnknownRangeFilterField {
1979                declared_on_other_types,
1980                ..
1981            } => {
1982                if declared_on_other_types.is_empty() {
1983                    "UNKNOWN_RANGE_FILTER_FIELD"
1984                } else {
1985                    "RANGE_FILTER_TYPE_SCOPED"
1986                }
1987            }
1988            Self::FieldNotRangeFilterable { .. } => "FIELD_NOT_RANGE_FILTERABLE",
1989            Self::SearchMemIndexUnavailable { .. } => "SEARCH_MEM_INDEX_UNAVAILABLE",
1990            Self::TitleTrimmed { .. } => "TITLE_TRIMMED",
1991            Self::SuspiciousNestedPrefix { .. } => "SUSPICIOUS_NESTED_PREFIX",
1992            Self::NoteMissing { .. } => "NOTE_MISSING",
1993            Self::IgnoredReadonlyField { .. } => "IGNORED_READONLY_FIELD",
1994            Self::OuterRepoNotIgnoringMemRepo { .. } => "OUTER_REPO_NOT_IGNORING_MEM_REPO",
1995            Self::MissingRequiredOutgoing { .. } => "MISSING_REQUIRED_OUTGOING",
1996            Self::SignalThresholdCrossed { .. } => "SIGNAL_THRESHOLD_CROSSED",
1997            Self::ConstraintUnsatisfied { .. } => "CONSTRAINT_UNSATISFIED",
1998            Self::DuplicateSectionHeading { .. } => "DUPLICATE_SECTION_HEADING",
1999            Self::MemReloaded { .. } => "MEM_RELOADED",
2000            Self::ConfigWriteIntervened { .. } => "CONFIG_WRITE_INTERVENED",
2001            Self::OutOfBandEditsUndetected { .. } => "OUT_OF_BAND_EDITS_UNDETECTED",
2002            Self::SchemaPinMismatch { .. } => "SCHEMA_PIN_MISMATCH",
2003            Self::MountUnbacked { .. } => "MOUNT_UNBACKED",
2004            Self::EngineVersionSkew { .. } => "ENGINE_VERSION_SKEW",
2005            Self::SchemaGenerationsBehind { .. } => "SCHEMA_GENERATIONS_BEHIND",
2006            Self::SchemaHeadingRoundtripViolation { .. } => "SCHEMA_HEADING_ROUNDTRIP_VIOLATION",
2007            Self::SectionHeadingDivergence { .. } => "SECTION_HEADING_DIVERGENCE",
2008            Self::AutoStubCreated { .. } => "AUTO_STUB_CREATED",
2009            Self::DerivationBaselineRefreshed { .. } => "DERIVATION_BASELINE_REFRESHED",
2010            Self::SelfLinkIgnored { .. } => "SELF_LINK_IGNORED",
2011            Self::CrossSchemaLinkUndeclared { .. } => "CROSS_SCHEMA_LINK_UNDECLARED",
2012            Self::ParsedRelationInvalid { .. } => "PARSED_RELATION_INVALID",
2013            Self::ResidualStubForReadOnlyReferrers { .. } => "RESIDUAL_STUB_FOR_READONLY_REFERRERS",
2014            Self::MemFilesNotDeleted { .. } => "MEM_FILES_NOT_DELETED",
2015            Self::MemReattachedAfterUnregister { .. } => "MEM_REATTACHED_AFTER_UNREGISTER",
2016            Self::ReadMemsMigratedToMounts { .. } => "READ_MEMS_MIGRATED_TO_MOUNTS",
2017            Self::FolderMemProvenance { .. } => "FOLDER_MEM_PROVENANCE",
2018            Self::SchemaAuthoringSourceMissing { .. } => "SCHEMA_AUTHORING_SOURCE_MISSING",
2019            Self::SchemaAuthoringSourceDiverged { .. } => "SCHEMA_AUTHORING_SOURCE_DIVERGED",
2020            Self::SchemaUnstampedSourceRot { .. } => "SCHEMA_UNSTAMPED_SOURCE_ROT",
2021            Self::AmbiguousDescriptionDelimiter { .. } => "AMBIGUOUS_DESCRIPTION_DELIMITER",
2022            Self::ParseMissingRequiredDescription { .. } => "MISSING_REQUIRED_DESCRIPTION",
2023            Self::ParseDescriptionNotPermitted { .. } => "DESCRIPTION_NOT_PERMITTED",
2024        }
2025    }
2026
2027    /// Human-readable message — delegates to `Display`. May change across
2028    /// releases; use [`WarningHint::code`] for branching.
2029    pub fn message(&self) -> String {
2030        self.to_string()
2031    }
2032
2033    /// Mem that "owns" the warning when one can be attributed.
2034    /// Workspace-/request-scoped variants return `None` — `memstead_health`'s
2035    /// mem filter keeps those visible regardless of scope, while
2036    /// mem-attributable variants drop out when the filter doesn't
2037    /// match. The contract mirrors the data fields the same filter
2038    /// gates (counts, distributions, detail lists are source-mem
2039    /// scoped; rosters stay global).
2040    pub fn source_mem(&self) -> Option<&str> {
2041        match self {
2042            Self::SuspiciousNestedPrefix { from, .. } => Some(from.mem()),
2043            Self::DuplicateSectionHeading { entity_id, .. } => Some(entity_id.mem()),
2044            Self::SchemaPinMismatch { mem, .. } => Some(mem.as_str()),
2045            Self::MountUnbacked { mem, .. } => Some(mem.as_str()),
2046            Self::SchemaHeadingRoundtripViolation { mem, .. } => Some(mem.as_str()),
2047            Self::SectionHeadingDivergence { entity_id, .. } => Some(entity_id.mem()),
2048            Self::MemReloaded { mem, .. } => Some(mem.as_str()),
2049            Self::MemFilesNotDeleted { mem, .. } => Some(mem.as_str()),
2050            Self::MemReattachedAfterUnregister { mem, .. } => Some(mem.as_str()),
2051            Self::ReadMemsMigratedToMounts { .. } => None,
2052            Self::EngineVersionSkew { mem, .. } => Some(mem.as_str()),
2053            Self::SchemaGenerationsBehind { mem, .. } => Some(mem.as_str()),
2054            Self::FolderMemProvenance { mem } => Some(mem.as_str()),
2055            Self::MissingRequiredOutgoing { entity_id, .. } => Some(entity_id.mem()),
2056            Self::SignalThresholdCrossed { entity_id, .. } => Some(entity_id.mem()),
2057            Self::ConstraintUnsatisfied { entity_id, .. } => Some(entity_id.mem()),
2058            Self::DuplicateRelationship { from, .. } => Some(from.mem()),
2059            Self::NoSuchRelationship { from, .. } => Some(from.mem()),
2060            Self::InlineWikiLinkAutoStubbed { from, .. } => Some(from.mem()),
2061            Self::SelfLinkIgnored { id } => Some(id.mem()),
2062            Self::CrossMemTargetMemUncreated { from_mem, .. } => Some(from_mem.as_str()),
2063            Self::AutoStubCreated { stub_id, .. } => Some(stub_id.mem()),
2064            Self::DerivationBaselineRefreshed { from, .. } => Some(from.mem()),
2065            Self::UpdateNoop { id } => Some(id.mem()),
2066            Self::ParsedRelationInvalid { entity_id, .. } => Some(entity_id.mem()),
2067            Self::ResidualStubForReadOnlyReferrers { id, .. } => Some(id.mem()),
2068            Self::AmbiguousDescriptionDelimiter { from, .. } => Some(from.mem()),
2069            Self::ParseMissingRequiredDescription { from, .. } => Some(from.mem()),
2070            Self::ParseDescriptionNotPermitted { from, .. } => Some(from.mem()),
2071            // Search-mem-index unavailability is attributable to the
2072            // failing mem; the filter-key warnings are request-
2073            // derived (the agent's filter payload) and fall through
2074            // to `None` below to stay visible to the caller.
2075            Self::SearchMemIndexUnavailable { mem, .. } => Some(mem.as_str()),
2076            // Workspace- or request-scoped — no mem to attribute.
2077            // OuterRepoNotIgnoringMemRepo concerns the embedding repo,
2078            // not a specific mem; an agent should see it under any
2079            // filter. UnknownIncludeKey / LimitClamped / NoteMissing /
2080            // TitleNormalizedToSlugNoop / StubFilterExcludesAll /
2081            // UndeclaredRelationshipOpen / MissingRequiredSection /
2082            // MissingRequiredField are request-derived (mutation
2083            // payload or schema-level), so the mem is the
2084            // request's mem — `None` here keeps them visible to
2085            // the caller that triggered them.
2086            _ => None,
2087        }
2088    }
2089
2090    /// One representative of every `WarningHint` variant — the single
2091    /// source of truth consumed by stability tests (`envelope_*`,
2092    /// `code_values_are_upper_snake_case`) and by the MCP description
2093    /// drift-guard (`every_warning_code_appears_in_a_description`).
2094    /// Adding a new variant without extending this list fails those tests;
2095    /// that's the forcing function.
2096    pub fn all_samples() -> Vec<WarningHint> {
2097        vec![
2098            WarningHint::EngineVersionSkew {
2099                mem: "m".into(),
2100                stamped_engine: "0.3.0".into(),
2101                running_engine: "0.4.0".into(),
2102                stamped_schema: "default@1.0.0".into(),
2103                direction: crate::build_info::SkewDirection::StampedOlder,
2104            },
2105            WarningHint::SchemaGenerationsBehind {
2106                mem: "m".into(),
2107                pinned: "default@1.0.0".into(),
2108                newest: "1.2.0".into(),
2109            },
2110            WarningHint::MissingRequiredSection {
2111                entity_type: "t".into(),
2112                key: "k".into(),
2113                heading: "H".into(),
2114                write_rules: vec![],
2115            },
2116            WarningHint::MissingRequiredField {
2117                entity_type: "decision".into(),
2118                key: "decided_on".into(),
2119                description: "Date the decision was accepted.".into(),
2120                enum_values: vec![],
2121            },
2122            WarningHint::UndeclaredRelationshipOpen {
2123                rel_type: "X".into(),
2124                message: "m".into(),
2125            },
2126            WarningHint::DuplicateRelationship {
2127                rel_type: "X".into(),
2128                from: EntityId("a".into()),
2129                to: EntityId("b".into()),
2130            },
2131            WarningHint::NoSuchRelationship {
2132                rel_type: "X".into(),
2133                from: EntityId("a".into()),
2134                to: EntityId("b".into()),
2135            },
2136            WarningHint::UnknownIncludeKey {
2137                key: "x".into(),
2138                allowed: vec![],
2139            },
2140            WarningHint::LimitClamped {
2141                requested: 1,
2142                actual: 1,
2143            },
2144            WarningHint::SearchResultsTruncated {
2145                kept: 12,
2146                budget: 12_000,
2147            },
2148            WarningHint::TitleNormalizedToSlugNoop {
2149                requested_title: "Hello World!".into(),
2150                current_slug: "hello-world".into(),
2151            },
2152            WarningHint::TitleCharsDroppedFromSlug {
2153                title: "Acme Inc. & Co".into(),
2154                dropped_chars: vec!['.', '&'],
2155                slug: "acme-inc-co".into(),
2156            },
2157            WarningHint::UpdateNoop {
2158                id: EntityId("specs--example".into()),
2159            },
2160            WarningHint::StubFilterExcludesAll {
2161                entity_type: "spec".into(),
2162            },
2163            // Non-empty `declared_on_other_types` → code FILTER_TYPE_SCOPED.
2164            WarningHint::UnknownFilterKey {
2165                key: "nonexistent_field".into(),
2166                scoped_type: Some("spec".into()),
2167                declared_on_other_types: vec!["decision".into()],
2168            },
2169            // Empty `declared_on_other_types` → code UNKNOWN_FILTER_KEY.
2170            WarningHint::UnknownFilterKey {
2171                key: "stauts".into(),
2172                scoped_type: None,
2173                declared_on_other_types: vec![],
2174            },
2175            WarningHint::FieldNotFilterable {
2176                field: "title".into(),
2177            },
2178            WarningHint::RangeFilterKeyMalformed {
2179                key: "weird_key".into(),
2180            },
2181            // Empty `declared_on_other_types` → code UNKNOWN_RANGE_FILTER_FIELD.
2182            WarningHint::UnknownRangeFilterField {
2183                field: "count".into(),
2184                key: "min_count".into(),
2185                scoped_type: None,
2186                declared_on_other_types: vec![],
2187            },
2188            // Non-empty → code RANGE_FILTER_TYPE_SCOPED.
2189            WarningHint::UnknownRangeFilterField {
2190                field: "priority".into(),
2191                key: "min_priority".into(),
2192                scoped_type: Some("spec".into()),
2193                declared_on_other_types: vec!["decision".into()],
2194            },
2195            WarningHint::FieldNotRangeFilterable {
2196                field: "tags".into(),
2197            },
2198            WarningHint::SearchMemIndexUnavailable {
2199                mem: "specs".into(),
2200                reason: "missing_index",
2201                error: None,
2202            },
2203            WarningHint::SuspiciousNestedPrefix {
2204                from: EntityId("test-mem-plugin--audit-skill".into()),
2205                resolved_id: EntityId("test-mem-plugin--plugin--memstead-mcp-tool-surface".into()),
2206                candidate_target: Some(EntityId(
2207                    "test-mem-plugin--memstead-mcp-tool-surface".into(),
2208                )),
2209                section: "constraints".into(),
2210                prefix_mounted: false,
2211            },
2212            WarningHint::MountUnbacked {
2213                mem: "institute".into(),
2214                reason: MountUnbackedReason::MissingRef,
2215                location: "refs/heads/institute".into(),
2216            },
2217            WarningHint::InlineWikiLinkAutoStubbed {
2218                from: EntityId("specs--demo".into()),
2219                stubs: vec![EntityId("specs--example-target".into())],
2220            },
2221            WarningHint::CrossMemTargetMemUncreated {
2222                from_mem: "specs".into(),
2223                to_mem: "memos".into(),
2224                target_id: EntityId("memos--example".into()),
2225            },
2226            WarningHint::NoteMissing {
2227                tool: "memstead_update".into(),
2228            },
2229            WarningHint::OuterRepoNotIgnoringMemRepo {
2230                outer_repo_root: "/repos/demo".into(),
2231                workspace_root: "/repos/demo/memstead".into(),
2232            },
2233            WarningHint::MissingRequiredOutgoing {
2234                entity_type: "decision".into(),
2235                entity_id: EntityId("planning--decision-x".into()),
2236                missing: vec![
2237                    MissingRequiredOutgoingBlock {
2238                        relationships: vec!["CHOSEN".into()],
2239                        cardinality: "at_least_one".into(),
2240                        severity: memstead_schema::ConstraintSeverity::Warn,
2241                        when_field: None,
2242                        when_value: None,
2243                    },
2244                    MissingRequiredOutgoingBlock {
2245                        relationships: vec!["REJECTED".into()],
2246                        cardinality: "at_least_one".into(),
2247                        severity: memstead_schema::ConstraintSeverity::Warn,
2248                        when_field: None,
2249                        when_value: None,
2250                    },
2251                ],
2252            },
2253            WarningHint::DuplicateSectionHeading {
2254                entity_id: EntityId("plugin--hooks-subsystem".into()),
2255                section_key: "realization".into(),
2256                heading: "Realization".into(),
2257                occurrences: 3,
2258            },
2259            WarningHint::ConfigWriteIntervened {
2260                mem: "test-mem-plugin".into(),
2261                fields: vec!["description".into()],
2262            },
2263            WarningHint::OutOfBandEditsUndetected {
2264                mem: "test-mem-plugin".into(),
2265            },
2266            WarningHint::MemReloaded {
2267                mem: "test-mem-plugin".into(),
2268                old_head: "abc123".into(),
2269                new_head: "def456".into(),
2270                entities_loaded: 42,
2271            },
2272            WarningHint::AutoStubCreated {
2273                stub_id: EntityId("specs--future-target".into()),
2274                pending: false,
2275            },
2276            WarningHint::ParsedRelationInvalid {
2277                entity_id: EntityId("specs--example-source".into()),
2278                rel_type: "EXECUTES".into(),
2279                target: EntityId("specs--example-target".into()),
2280                reason: "shape".into(),
2281                origin: "writable".into(),
2282                recovery: Some(ParsedRelationRecovery::remove_explicit_relation(
2283                    EntityId("specs--example-source".into()),
2284                    EntityId("specs--example-target".into()),
2285                    "EXECUTES".into(),
2286                )),
2287            },
2288            WarningHint::ResidualStubForReadOnlyReferrers {
2289                id: EntityId("specs--archived-target".into()),
2290                referrers: vec![EntityId("archive--archived-source".into())],
2291            },
2292            WarningHint::MemFilesNotDeleted {
2293                mem: "plan-example".into(),
2294                reason: "backend_prune_failed".into(),
2295                path: None,
2296                error: Some("ref-edit transaction rejected".into()),
2297            },
2298            WarningHint::MemReattachedAfterUnregister {
2299                mem: "plan-example".into(),
2300                unregistered_at: "2026-05-17T08:43:29Z".into(),
2301            },
2302            WarningHint::FolderMemProvenance {
2303                mem: "plan-example".into(),
2304            },
2305            WarningHint::SchemaAuthoringSourceMissing {
2306                schema_ref: "authored@0.1.0".into(),
2307                stamped_path: "/workspace/authored".into(),
2308                mems: vec!["specs".into()],
2309            },
2310            WarningHint::SchemaAuthoringSourceDiverged {
2311                schema_ref: "authored@0.1.0".into(),
2312                stamped_path: "/workspace/authored".into(),
2313                mems: vec!["specs".into()],
2314                detail: "the parsed authoring package differs from the sealed copy".into(),
2315            },
2316            WarningHint::SchemaUnstampedSourceRot {
2317                schema_ref: "authored@0.1.0".into(),
2318                mems: vec!["specs".into()],
2319                detail: "type 'decision': `propagating_relationships` was renamed".into(),
2320            },
2321            WarningHint::AmbiguousDescriptionDelimiter {
2322                from: EntityId("specs--example-source".into()),
2323                rel_type: "OTHER".into(),
2324                target: EntityId("specs--example-target".into()),
2325                trailing: " -- legacy delimiter".into(),
2326            },
2327            WarningHint::ParseMissingRequiredDescription {
2328                from: EntityId("specs--example-source".into()),
2329                rel_type: "OTHER".into(),
2330                target: EntityId("specs--example-target".into()),
2331            },
2332            WarningHint::ParseDescriptionNotPermitted {
2333                from: EntityId("specs--example-source".into()),
2334                rel_type: "IMPLEMENTS".into(),
2335                target: EntityId("specs--example-target".into()),
2336            },
2337        ]
2338    }
2339
2340    fn details_payload(&self) -> serde_json::Value {
2341        match self {
2342            Self::OutOfBandEditsUndetected { mem } => serde_json::json!({ "mem": mem }),
2343            Self::ConfigWriteIntervened { mem, fields } => serde_json::json!({
2344                "mem": mem,
2345                "fields": fields,
2346            }),
2347            Self::MissingRequiredSection {
2348                entity_type,
2349                key,
2350                heading,
2351                write_rules,
2352            } => serde_json::json!({
2353                "entity_type": entity_type,
2354                "key": key,
2355                "heading": heading,
2356                "write_rules": write_rules,
2357            }),
2358            Self::MissingRequiredField {
2359                entity_type,
2360                key,
2361                description,
2362                enum_values,
2363            } => serde_json::json!({
2364                "entity_type": entity_type,
2365                "key": key,
2366                "field_description": description,
2367                "enum_values": enum_values,
2368            }),
2369            Self::UndeclaredRelationshipOpen { rel_type, .. } => {
2370                serde_json::json!({ "rel_type": rel_type })
2371            }
2372            Self::DuplicateRelationship { rel_type, from, to } => {
2373                serde_json::json!({ "rel_type": rel_type, "from": from, "to": to })
2374            }
2375            Self::NoSuchRelationship { rel_type, from, to } => {
2376                serde_json::json!({ "rel_type": rel_type, "from": from, "to": to })
2377            }
2378            Self::UnknownIncludeKey { key, allowed } => {
2379                serde_json::json!({ "key": key, "allowed": allowed })
2380            }
2381            Self::LimitClamped { requested, actual } => {
2382                serde_json::json!({ "requested": requested, "actual": actual })
2383            }
2384            Self::TitleNormalizedToSlugNoop {
2385                requested_title,
2386                current_slug,
2387            } => serde_json::json!({
2388                "requested_title": requested_title,
2389                "current_slug": current_slug,
2390            }),
2391            Self::TitleCharsDroppedFromSlug {
2392                title,
2393                dropped_chars,
2394                slug,
2395            } => serde_json::json!({
2396                "title": title,
2397                "dropped_chars": dropped_chars,
2398                "slug": slug,
2399            }),
2400            Self::UpdateNoop { id } => serde_json::json!({ "id": id }),
2401            Self::StubFilterExcludesAll { entity_type } => {
2402                serde_json::json!({ "entity_type": entity_type })
2403            }
2404            Self::UnknownFilterKey {
2405                key,
2406                scoped_type,
2407                declared_on_other_types,
2408            } => serde_json::json!({
2409                "key": key,
2410                "scoped_type": scoped_type,
2411                "declared_on_other_types": declared_on_other_types,
2412            }),
2413            Self::FieldNotFilterable { field } => serde_json::json!({ "field": field }),
2414            Self::FilterValueMultiMember { key, value } => {
2415                serde_json::json!({ "key": key, "value": value })
2416            }
2417            Self::FilterValueNotInEnum {
2418                key,
2419                value,
2420                allowed,
2421            } => {
2422                serde_json::json!({ "key": key, "value": value, "allowed": allowed })
2423            }
2424            Self::NeighbourhoodCapped { kept, total } => {
2425                serde_json::json!({ "kept": kept, "total": total })
2426            }
2427            Self::SearchResultsTruncated { kept, budget } => {
2428                serde_json::json!({ "kept": kept, "budget": budget })
2429            }
2430            Self::RangeFilterKeyMalformed { key } => serde_json::json!({ "key": key }),
2431            Self::UnknownRangeFilterField {
2432                field,
2433                key,
2434                scoped_type,
2435                declared_on_other_types,
2436            } => serde_json::json!({
2437                "field": field,
2438                "key": key,
2439                "scoped_type": scoped_type,
2440                "declared_on_other_types": declared_on_other_types,
2441            }),
2442            Self::FieldNotRangeFilterable { field } => serde_json::json!({ "field": field }),
2443            Self::SearchMemIndexUnavailable { mem, reason, error } => serde_json::json!({
2444                "mem": mem,
2445                "reason": reason,
2446                "error": error,
2447            }),
2448            Self::TitleTrimmed { original, trimmed } => serde_json::json!({
2449                "original": original,
2450                "trimmed": trimmed,
2451            }),
2452            Self::SuspiciousNestedPrefix {
2453                from,
2454                resolved_id,
2455                candidate_target,
2456                section,
2457                prefix_mounted,
2458            } => serde_json::json!({
2459                "from": from,
2460                "resolved_id": resolved_id,
2461                "candidate_target": candidate_target,
2462                "section": section,
2463                "target_mem": resolved_id.mem(),
2464                "prefix_mounted": prefix_mounted,
2465            }),
2466            Self::InlineWikiLinkAutoStubbed { from, stubs } => serde_json::json!({
2467                "from": from,
2468                "stubs": stubs,
2469            }),
2470            Self::SelfLinkIgnored { id } => serde_json::json!({ "id": id }),
2471            Self::CrossSchemaLinkUndeclared {
2472                from,
2473                target,
2474                source_schema,
2475                target_schema,
2476            } => serde_json::json!({
2477                "from": from,
2478                "target": target,
2479                "source_schema": source_schema,
2480                "target_schema": target_schema,
2481            }),
2482            Self::CrossMemTargetMemUncreated {
2483                from_mem,
2484                to_mem,
2485                target_id,
2486            } => serde_json::json!({
2487                "from_mem": from_mem,
2488                "to_mem": to_mem,
2489                "target_id": target_id,
2490            }),
2491            Self::NoteMissing { tool } => serde_json::json!({ "tool": tool }),
2492            Self::IgnoredReadonlyField { field, supplied } => {
2493                serde_json::json!({ "field": field, "supplied": supplied })
2494            }
2495            Self::OuterRepoNotIgnoringMemRepo {
2496                outer_repo_root,
2497                workspace_root,
2498            } => serde_json::json!({
2499                "outer_repo_root": outer_repo_root,
2500                "workspace_root": workspace_root,
2501            }),
2502            Self::MissingRequiredOutgoing {
2503                entity_type,
2504                entity_id,
2505                missing,
2506            } => serde_json::json!({
2507                "entity_type": entity_type,
2508                "entity_id": entity_id,
2509                "missing": missing,
2510            }),
2511            Self::SignalThresholdCrossed {
2512                entity_id,
2513                signal,
2514                value,
2515                old_level,
2516                new_level,
2517            } => serde_json::json!({
2518                "entity": entity_id,
2519                "signal": signal,
2520                "value": value,
2521                "old_level": old_level,
2522                "new_level": new_level,
2523            }),
2524            Self::ConstraintUnsatisfied {
2525                entity_type,
2526                entity_id,
2527                violations,
2528            } => serde_json::json!({
2529                "entity_type": entity_type,
2530                "entity_id": entity_id,
2531                "violations": violations,
2532            }),
2533            Self::DuplicateSectionHeading {
2534                entity_id,
2535                section_key,
2536                heading,
2537                occurrences,
2538            } => serde_json::json!({
2539                "entity_id": entity_id,
2540                "section_key": section_key,
2541                "heading": heading,
2542                "occurrences": occurrences,
2543            }),
2544            Self::MemReloaded {
2545                mem,
2546                old_head,
2547                new_head,
2548                entities_loaded,
2549            } => serde_json::json!({
2550                "mem": mem,
2551                "old_head": old_head,
2552                "new_head": new_head,
2553                "entities_loaded": entities_loaded,
2554            }),
2555            Self::AutoStubCreated { stub_id, .. } => serde_json::json!({ "stub_id": stub_id }),
2556            Self::DerivationBaselineRefreshed { from, rel_type, to } => serde_json::json!({
2557                "from": from,
2558                "rel_type": rel_type,
2559                "to": to,
2560            }),
2561            Self::ParsedRelationInvalid {
2562                entity_id,
2563                rel_type,
2564                target,
2565                reason,
2566                origin,
2567                recovery,
2568            } => {
2569                serde_json::json!({
2570                    "entity_id": entity_id,
2571                    "rel_type": rel_type,
2572                    "target": target,
2573                    "reason": reason,
2574                    "origin": origin,
2575                    "recovery": recovery,
2576                })
2577            }
2578            Self::ResidualStubForReadOnlyReferrers { id, referrers } => serde_json::json!({
2579                "id": id,
2580                "referrers": referrers,
2581            }),
2582            Self::MemFilesNotDeleted {
2583                mem,
2584                reason,
2585                path,
2586                error,
2587            } => serde_json::json!({
2588                "mem": mem,
2589                "reason": reason,
2590                "path": path,
2591                "error": error,
2592            }),
2593            Self::MemReattachedAfterUnregister {
2594                mem,
2595                unregistered_at,
2596            } => serde_json::json!({
2597                "mem": mem,
2598                "unregistered_at": unregistered_at,
2599            }),
2600            Self::EngineVersionSkew {
2601                mem,
2602                stamped_engine,
2603                running_engine,
2604                stamped_schema,
2605                direction,
2606            } => {
2607                serde_json::json!({
2608                    "mem": mem,
2609                    "stamped_engine": stamped_engine,
2610                    "running_engine": running_engine,
2611                    "stamped_schema": stamped_schema,
2612                    "direction": direction,
2613                })
2614            }
2615            Self::SchemaGenerationsBehind {
2616                mem,
2617                pinned,
2618                newest,
2619            } => serde_json::json!({
2620                "mem": mem,
2621                "pinned": pinned,
2622                "newest": newest,
2623            }),
2624            Self::ReadMemsMigratedToMounts {
2625                mems,
2626                from_host_mems,
2627            } => serde_json::json!({
2628                "mems": mems,
2629                "from_host_mems": from_host_mems,
2630            }),
2631            Self::FolderMemProvenance { mem } => serde_json::json!({
2632                "mem": mem,
2633                "ledger": ".memstead/changes.jsonl",
2634                "write_id": "synthetic token, not a commit and not a change cursor (no version control)",
2635                "change_cursor": "an RFC3339 timestamp — the `ts` of the last ledger entry",
2636                "durability": "content persists only when the surrounding repository commits it",
2637            }),
2638            Self::SchemaAuthoringSourceMissing {
2639                schema_ref,
2640                stamped_path,
2641                mems,
2642            } => serde_json::json!({
2643                "schema_ref": schema_ref,
2644                "stamped_path": stamped_path,
2645                "mems": mems,
2646            }),
2647            Self::SchemaAuthoringSourceDiverged {
2648                schema_ref,
2649                stamped_path,
2650                mems,
2651                detail,
2652            } => serde_json::json!({
2653                "schema_ref": schema_ref,
2654                "stamped_path": stamped_path,
2655                "mems": mems,
2656                "detail": detail,
2657            }),
2658            Self::SchemaUnstampedSourceRot {
2659                schema_ref,
2660                mems,
2661                detail,
2662            } => serde_json::json!({
2663                "schema_ref": schema_ref,
2664                "mems": mems,
2665                "detail": detail,
2666            }),
2667            Self::AmbiguousDescriptionDelimiter {
2668                from,
2669                rel_type,
2670                target,
2671                trailing,
2672            } => serde_json::json!({
2673                "from": from,
2674                "rel_type": rel_type,
2675                "target": target,
2676                "trailing": trailing,
2677            }),
2678            Self::ParseMissingRequiredDescription {
2679                from,
2680                rel_type,
2681                target,
2682            } => {
2683                serde_json::json!({ "from": from, "rel_type": rel_type, "target": target })
2684            }
2685            Self::ParseDescriptionNotPermitted {
2686                from,
2687                rel_type,
2688                target,
2689            } => {
2690                serde_json::json!({ "from": from, "rel_type": rel_type, "target": target })
2691            }
2692            Self::SchemaPinMismatch {
2693                mem,
2694                config_pin,
2695                mount_pin,
2696            } => {
2697                serde_json::json!({
2698                    "mem": mem,
2699                    "config_pin": config_pin,
2700                    "mount_pin": mount_pin,
2701                })
2702            }
2703            Self::MountUnbacked {
2704                mem,
2705                reason,
2706                location,
2707            } => serde_json::json!({
2708                "mem": mem,
2709                "reason": reason.as_str(),
2710                "location": location,
2711            }),
2712            Self::SchemaHeadingRoundtripViolation {
2713                mem,
2714                schema_ref,
2715                violations,
2716            } => {
2717                serde_json::json!({
2718                    "mem": mem,
2719                    "schema_ref": schema_ref,
2720                    "violations": violations,
2721                })
2722            }
2723            Self::SectionHeadingDivergence {
2724                entity_id,
2725                section_key,
2726                writing_heading,
2727                existing_heading,
2728            } => {
2729                serde_json::json!({
2730                    "entity_id": entity_id,
2731                    "section_key": section_key,
2732                    "writing_heading": writing_heading,
2733                    "existing_heading": existing_heading,
2734                })
2735            }
2736        }
2737    }
2738}
2739
2740impl Serialize for WarningHint {
2741    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2742        // Direct struct emission — avoids the intermediate `Value` allocation
2743        // `envelope(...).serialize(serializer)` would incur. Wire shape is
2744        // bit-identical to `envelope(...)`'s output; DRY lives at the
2745        // constructor level via the shared `envelope` helper used by the MCP
2746        // error path (`engine_err_with_suggestions`).
2747        let details = self.details_payload();
2748        let mut state = serializer.serialize_struct("WarningHint", 3)?;
2749        state.serialize_field("code", self.code())?;
2750        state.serialize_field("message", &self.message())?;
2751        state.serialize_field("details", &details)?;
2752        state.end()
2753    }
2754}
2755
2756/// Build the uniform `{ code, message, details }` envelope used on both the
2757/// warning wire (`WarningHint`'s custom `Serialize`) and the MCP error wire
2758/// (`tool_error_with_payload` payloads in `engine_err_with_suggestions`).
2759/// Agents and other decoders branch on `code` (UPPER_SNAKE_CASE, stable)
2760/// and parse `details` by `code` when they need structured fields.
2761pub fn envelope(
2762    code: &str,
2763    message: impl Into<String>,
2764    details: serde_json::Value,
2765) -> serde_json::Value {
2766    serde_json::json!({
2767        "code": code,
2768        "message": message.into(),
2769        "details": details,
2770    })
2771}
2772
2773/// Result of a create operation.
2774#[derive(Debug, Clone, Serialize)]
2775pub struct CreateResult {
2776    pub id: EntityId,
2777    pub title: String,
2778    pub mem: String,
2779    pub file_path: String,
2780    pub created_date: String,
2781    /// Post-write content hash under the real path; the **prospective**
2782    /// hash under `dry_run` — bit-identical to what a real call with the
2783    /// same inputs would produce. Wire key `_hash`.
2784    #[serde(rename = "_hash")]
2785    pub content_hash: String,
2786    /// The backend's identity for this write, never a cursor — see
2787    /// `UpdateResult::write_id`. Empty under
2788    /// `dry_run`.
2789    #[serde(default)]
2790    pub write_id: String,
2791    /// Typed non-fatal issues — missing required sections (with writing
2792    /// guidance) and open-mode relationship admissions.
2793    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2794    pub warnings: Vec<WarningHint>,
2795    /// Type-level `write_rules` keyed by `entity_type` — the
2796    /// MISSING_REQUIRED_SECTION / MISSING_REQUIRED_FIELD warnings on
2797    /// `warnings[]` reference this top-level map via their
2798    /// `entity_type` field rather than each carrying the (identical,
2799    /// type-axis) array (F9). Stable empty shape (`{}`) ships when no
2800    /// such warnings fire — consumers don't branch on field presence.
2801    #[serde(default)]
2802    pub type_guidance: std::collections::BTreeMap<String, Vec<String>>,
2803    /// Number of incoming edges adopted from a pre-existing stub at this
2804    /// id (real path) or that would be adopted (dry_run). `None` means
2805    /// no pre-existing stub / no incoming refs — field is serde-omitted.
2806    #[serde(skip_serializing_if = "Option::is_none")]
2807    pub incoming_count: Option<usize>,
2808    /// Incoming edges present at this id at create time. Real path:
2809    /// edges preserved during stub adoption. Dry_run: edges that would
2810    /// be adopted if committed. Sorted by (rel_type, from) for
2811    /// determinism. Empty vec is serde-omitted.
2812    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2813    pub incoming: Vec<IncomingRef>,
2814}
2815
2816/// Serialisable projection of `store::InEdge` for `CreateResult.incoming`.
2817/// `source` is the lowercase `EdgeSource` variant:
2818/// `"explicit" | "hierarchy" | "body_link"`.
2819#[derive(Debug, Clone, Serialize)]
2820pub struct IncomingRef {
2821    pub from: EntityId,
2822    pub rel_type: String,
2823    pub source: String,
2824}
2825
2826/// Project `&[store::InEdge]` into a sorted `Vec<IncomingRef>`. Ordering
2827/// by (rel_type, from) ascending — deterministic output despite the
2828/// underlying HashMap iteration order.
2829pub fn project_incoming(edges: &[crate::store::InEdge]) -> Vec<IncomingRef> {
2830    let mut out: Vec<IncomingRef> = edges
2831        .iter()
2832        .map(|e| IncomingRef {
2833            from: e.from.clone(),
2834            rel_type: e.rel_type.clone(),
2835            source: match e.source {
2836                crate::store::EdgeSource::Explicit => "explicit",
2837                crate::store::EdgeSource::Hierarchy => "hierarchy",
2838                crate::store::EdgeSource::BodyLink => "body_link",
2839            }
2840            .to_string(),
2841        })
2842        .collect();
2843    out.sort_by(|a, b| a.rel_type.cmp(&b.rel_type).then(a.from.0.cmp(&b.from.0)));
2844    out
2845}
2846
2847/// Result of a delete operation.
2848#[derive(Debug, Clone, Serialize)]
2849pub struct DeleteResult {
2850    pub id: EntityId,
2851    pub relations_removed: usize,
2852    /// The backend's identity for this write, never a cursor — see
2853    /// `UpdateResult::write_id`.
2854    #[serde(default)]
2855    pub write_id: String,
2856    /// Stub entities that became orphaned by this delete (their last
2857    /// incoming edge disappeared with this entity) and were garbage-
2858    /// collected. Empty vec is serde-omitted.
2859    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2860    pub orphan_stubs_removed: Vec<EntityId>,
2861}
2862
2863/// Result of a rename operation.
2864#[derive(Debug, Clone, Serialize)]
2865pub struct RenameResult {
2866    pub old_id: EntityId,
2867    pub new_id: EntityId,
2868    pub old_path: String,
2869    pub new_path: String,
2870    /// Content hash of the renamed entity after the write. Sources by branch:
2871    ///   - Real rename (slug change): post-write hash from the re-parsed
2872    ///     entity, including the `modified_date` bump applied by
2873    ///     `rename_entity` and any wiki-link rewrites in referrers.
2874    ///   - Slug-noop short-circuit: the unchanged on-disk hash (no write
2875    ///     happened).
2876    ///
2877    /// Pass this as `expected_hash` on the next hash-protected op
2878    /// (`memstead_update`, `memstead_rename`, `memstead_delete`) on the entity — no
2879    /// `memstead_entity` re-read required. Mirrors `RelateResult._hash`.
2880    /// Wire key `_hash`.
2881    #[serde(default, rename = "_hash", skip_serializing_if = "String::is_empty")]
2882    pub content_hash: String,
2883    /// The backend's identity for this write, never a cursor — see
2884    /// `UpdateResult::write_id`. Empty on the
2885    /// no-op same-title rename (no file change, no commit).
2886    #[serde(default)]
2887    pub write_id: String,
2888    /// Typed non-fatal issues. The slug-noop short-circuit
2889    /// (`TitleNormalizedToSlugNoop`) surfaces here when a requested title
2890    /// normalises to the existing slug — the op stays a silent no-op on
2891    /// disk, but the warning tells autonomous skills not to trust
2892    /// `old_id == new_id` as "cosmetic rewrite landed".
2893    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2894    pub warnings: Vec<WarningHint>,
2895}
2896
2897/// Arguments for a relate/unrelate operation.
2898#[derive(Debug, Clone)]
2899pub struct RelateArg {
2900    /// The far end of the edge. Named `target` rather than `to`
2901    /// because the near end is implied by the call (the entity being
2902    /// created or updated) — the rule the response shapes already
2903    /// follow: a pair is `from`/`to`, an implied near end leaves
2904    /// `target`.
2905    pub target: EntityId,
2906    pub rel_type: String,
2907    /// Optional per-edge description text. Validated against the
2908    /// rel-type's `per_edge_description` posture at call time —
2909    /// `forbidden` rejects `Some`; `required` rejects `None`.
2910    /// Empty / whitespace-only strings normalise to `None` before
2911    /// validation.
2912    pub description: Option<String>,
2913}
2914
2915/// One repair-shaped relation removal on `memstead_update` —
2916/// `relations_unset: [{ rel_type, target }]`. Symmetric with
2917/// `metadata_unset`: an absent `(rel_type, target)` pair is a silent
2918/// no-op. Only accepted when the target entity currently fails the
2919/// conformance check (`REPAIR_NOT_NEEDED` otherwise) — the everyday
2920/// detach path stays `memstead_relate(remove)`.
2921#[derive(Debug, Clone, serde::Deserialize)]
2922pub struct RelationUnsetArg {
2923    pub rel_type: String,
2924    pub target: EntityId,
2925}
2926
2927/// Result of a relate operation.
2928#[derive(Debug, Clone, Serialize)]
2929pub struct RelateResult {
2930    pub from: EntityId,
2931    pub to: EntityId,
2932    pub rel_type: String,
2933    pub source: String,
2934    /// Content hash of the source entity after the relate. On successful
2935    /// add/remove, reflects the re-rendered file (Relationships section
2936    /// updated); on duplicate-add and remove-nonexistent no-ops, reflects
2937    /// the unchanged file. Pass this as `expected_hash` on the next
2938    /// hash-protected op (`memstead_update`, `memstead_rename`, `memstead_delete`) on
2939    /// the source — no `memstead_entity` re-read required. Wire key `_hash`.
2940    #[serde(default, rename = "_hash", skip_serializing_if = "String::is_empty")]
2941    pub content_hash: String,
2942    /// The backend's identity for this write, never a cursor — see
2943    /// `UpdateResult::write_id`.
2944    #[serde(default)]
2945    pub write_id: String,
2946    /// Typed non-fatal issues — open-mode schema admissions, duplicate-add
2947    /// no-ops (`DuplicateRelationship`), remove-nonexistent no-ops
2948    /// (`NoSuchRelationship`). Previously silent edge cases now surface here.
2949    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2950    pub warnings: Vec<WarningHint>,
2951    /// True if the op wrote to disk (real add or real remove). False on
2952    /// duplicate-add and remove-nonexistent-edge. Internal signal — the
2953    /// wrapper gates reindex + vcs_commit on this; the MCP wire relies on
2954    /// `write_id.is_empty()` as the external no-op indicator.
2955    #[serde(skip)]
2956    pub disk_changed: bool,
2957    /// Stub entities that became orphaned by an edge removal (their last
2958    /// incoming edge was this one) and were garbage-collected. Only
2959    /// populated on `remove: true` calls where the edge actually existed;
2960    /// empty on add paths and no-op removes. Empty vec is serde-omitted.
2961    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2962    pub orphan_stubs_removed: Vec<EntityId>,
2963}
2964
2965fn is_zero(n: &usize) -> bool {
2966    *n == 0
2967}
2968
2969/// Result of an **atomic** batch update — all-or-nothing.
2970///
2971/// A batch either applies in full as a single commit (`applied: true`)
2972/// or, if any item fails (validation, hash mismatch, missing entity),
2973/// applies *nothing* and refuses (`applied: false`) with the offending
2974/// item named. There is no partial-application middle state: a refused
2975/// batch leaves the on-disk mem and the in-memory store byte-identical
2976/// to the pre-call state.
2977#[derive(Debug, Clone, Serialize)]
2978pub struct BatchResult {
2979    /// Batch-level warnings. Today this carries `CONFIG_WRITE_INTERVENED`
2980    /// when the mutation version stamp merged over another writer's config
2981    /// change (04/03, criterion 3): the batch is the operation, so the batch
2982    /// result is where its report belongs. Empty on the ordinary path.
2983    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2984    pub warnings: Vec<WarningHint>,
2985    /// `true` when every item applied (one commit); `false` when the
2986    /// batch was refused (a single item failed → nothing committed).
2987    pub applied: bool,
2988    /// One entry per submitted item, in submission order. On an applied
2989    /// batch every entry's `action` is `"updated"` (a real write) or
2990    /// `"noop"` (content unchanged). On a refused batch the failing
2991    /// item's `action` is `"error"` with a populated `error` envelope,
2992    /// and every other item's `action` is `"not_applied"`.
2993    pub results: Vec<BatchEntry>,
2994    /// Count of applied items when `applied`; `0` when refused.
2995    pub succeeded: usize,
2996    /// Number of FAILING entries whose error envelopes were suppressed
2997    /// beyond the reporting cap (bounded reporting for very large
2998    /// failing batches — the entries still carry `action: "error"`,
2999    /// only the detailed envelope is omitted). `0` when every failure
3000    /// is fully reported.
3001    #[serde(default, skip_serializing_if = "is_zero")]
3002    pub errors_suppressed: usize,
3003    /// Count of failed items when refused (≥1); `0` when applied.
3004    pub failed: usize,
3005    /// Ids of stub entities GC'd because a removed edge in this batch
3006    /// was their last incoming reference — the batch sibling of the
3007    /// single relate response's `orphan_stubs_removed`. Empty (and
3008    /// serde-omitted) for batch-create / batch-update and for batches
3009    /// that removed nothing.
3010    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3011    pub orphan_stubs_removed: Vec<EntityId>,
3012    /// The backend's identity for the batch's write — a commit SHA on
3013    /// a git-branch mem, a synthetic token on a folder mem, and never a
3014    /// change cursor. Present when the batch applied and produced at
3015    /// least one write. Empty when the batch was
3016    /// refused, when it was empty, or when every item was a no-op (no
3017    /// commit happens). For a batch spanning multiple mems this names
3018    /// the last mem committed; single-mem batches (the common case)
3019    /// name their one commit.
3020    #[serde(default)]
3021    pub write_id: String,
3022}
3023
3024#[derive(Debug, Clone, Serialize)]
3025pub struct BatchEntry {
3026    pub id: EntityId,
3027    pub action: String,
3028    /// Structured error envelope when this entry failed. Mirrors the
3029    /// `{code, message, details}` shape single-update errors carry on
3030    /// the wire so a mixed-success batch is structurally uniform —
3031    /// consumers branch on `code` rather than prose-parsing a string.
3032    /// Empty (`None`) for successful entries.
3033    pub error: Option<BatchError>,
3034}
3035
3036/// Per-item error envelope on a batch result. The shape matches the
3037/// MCP wire envelope for single-entry failures: `code` is the stable
3038/// `UPPER_SNAKE_CASE` token from [`crate::EngineError::code()`];
3039/// `details` carries the variant-specific recovery payload (e.g.
3040/// declared list, allowed enum values, hash-mismatch current) when
3041/// available, or an empty object for variants without a structured
3042/// payload.
3043#[derive(Debug, Clone, Serialize)]
3044pub struct BatchError {
3045    pub code: String,
3046    pub message: String,
3047    pub details: serde_json::Value,
3048}
3049
3050// ---------------------------------------------------------------------------
3051// Search types
3052// ---------------------------------------------------------------------------
3053
3054/// Flat query shape for full-text search. Four optional fields, all
3055/// combined with implicit AND across fields.
3056///
3057/// Within `any`: at least one term must match (OR semantics). Entities
3058/// matching more terms rank higher automatically — no explicit `and`.
3059/// Within `not`: none of the listed terms may appear. `phrase` requires
3060/// exact adjacency (case- and diacritic-folded). `field` narrows the match
3061/// region for all three to a single indexed field; `None` = match anywhere
3062/// indexed.
3063///
3064/// Empty/unset everywhere ⇒ no text predicate; `search` behaves as a
3065/// metadata-only filter (subsumes the former `list` semantics).
3066///
3067/// No stemming, wildcards, or regex — the caller expands morphology and
3068/// synonyms by enumerating variants in `any`.
3069#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
3070pub struct Query {
3071    /// Terms where at least one must match (OR semantics).
3072    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3073    pub any: Vec<String>,
3074    /// Terms that must not match (exclusion).
3075    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3076    pub not: Vec<String>,
3077    /// Exact phrase that must appear (case- and diacritic-folded).
3078    #[serde(default, skip_serializing_if = "Option::is_none")]
3079    pub phrase: Option<String>,
3080    /// Restrict `any` / `not` / `phrase` to a single field (title or section
3081    /// key). `None` = match anywhere indexed.
3082    #[serde(default, skip_serializing_if = "Option::is_none")]
3083    pub field: Option<String>,
3084}
3085
3086impl Query {
3087    /// True if no text predicate is set — caller falls back to the
3088    /// metadata-only filter path.
3089    pub fn is_empty(&self) -> bool {
3090        self.any.is_empty() && self.not.is_empty() && self.phrase.is_none()
3091    }
3092}
3093
3094/// Scope filters for search and list operations.
3095#[derive(Debug, Clone, Default)]
3096pub struct SearchScope {
3097    /// Structured flat query. All text matching flows through this field;
3098    /// see [`Query`] for semantics. `None` (or an empty query) makes
3099    /// `search` behave as a metadata-only filter.
3100    pub query: Option<Query>,
3101    pub mem: Option<String>,
3102    pub entity_type: Option<String>,
3103    pub limit: Option<usize>,
3104    pub offset: Option<usize>,
3105    /// Equality filters on metadata fields: `{ "level": "M0" }`.
3106    pub filters: HashMap<String, String>,
3107    /// Range filters: `{ "min_coverage": "0.5", "max_coverage": "1.0" }`.
3108    pub range_filters: HashMap<String, String>,
3109    /// Only entities with this edge type (incoming or outgoing).
3110    pub edge_type: Option<String>,
3111    /// Only entities reachable from this entity within `depth` hops.
3112    pub related_to: Option<EntityId>,
3113    pub depth: Option<usize>,
3114    /// Relationship types to follow from primary hits to pull in graph-proximal
3115    /// neighbours.
3116    pub expand_via: Option<Vec<String>>,
3117    /// Maximum hops to traverse via `expand_via` (default: 1 when `expand_via`
3118    /// is set).
3119    pub expand_depth: Option<usize>,
3120    /// Traversal direction for `related_to` AND `expand_via`, applied at
3121    /// EVERY hop (depth > 1 is a pure transitive closure in the chosen
3122    /// direction, never a mixed walk). Defaults to `both` — the
3123    /// historical undirected behaviour, so a query omitting the
3124    /// selector returns exactly what it always returned.
3125    pub direction: crate::graph::query::TraversalDirection,
3126    /// Filter by stub status. `None` = no filter (returns both stubs and real
3127    /// entities); `Some(true)` = only stubs; `Some(false)` = only real entities.
3128    pub stub: Option<bool>,
3129    /// Token budget bounding the returned hit payload (search path only).
3130    /// `None` uses the engine default. A page whose hits exceed the budget is
3131    /// greedily trimmed (at least one hit always returns) with a
3132    /// `SEARCH_RESULTS_TRUNCATED` warning; `total` still reflects the full
3133    /// match count so the agent can page with `offset`.
3134    pub token_budget: Option<usize>,
3135}
3136
3137/// Per-hit score components surfaced so agents can understand ranking.
3138///
3139/// Note: this is illustrative feedback, not a numerically authoritative
3140/// decomposition — tantivy's `Explanation` for `BoostQuery` over
3141/// `BooleanQuery` does not always sum cleanly. Agents should treat these
3142/// as proportions, not exact sums.
3143#[derive(Debug, Clone, Serialize, JsonSchema)]
3144pub struct ScoreBreakdown {
3145    pub bm25: f32,
3146    pub title_boost: f32,
3147    pub field_weights: HashMap<String, f32>,
3148    /// `Some(f32)` on expanded hits only, carrying the depth-based decay
3149    /// factor (`0.5.powi(depth)`). `None` on primary hits.
3150    #[serde(default, skip_serializing_if = "Option::is_none")]
3151    pub expansion_decay: Option<f32>,
3152}
3153
3154/// One snippet-level match recorded per (term, field). `heading_path` is
3155/// `Some` when the match falls under an H3–H6 sub-heading; elements are
3156/// ordered outermost → innermost.
3157#[derive(Debug, Clone, Serialize, JsonSchema)]
3158pub struct TermMatch {
3159    pub field: String,
3160    pub snippet: String,
3161    #[serde(default, skip_serializing_if = "Option::is_none")]
3162    pub heading_path: Option<Vec<String>>,
3163}
3164
3165/// Metadata attached to hits reached via graph expansion. The
3166/// primary hit that seeded the expansion is identified by `of`; `via_edge`
3167/// is the exact `rel_type` string; `depth` counts hops from the seed.
3168#[derive(Debug, Clone, Serialize, JsonSchema)]
3169pub struct ExpansionInfo {
3170    pub of: EntityId,
3171    pub via_edge: String,
3172    pub depth: usize,
3173    /// The direction the first-reaching edge was traversed in (`out` =
3174    /// away from the seed, `in` = at the seed) — keeps a `both` result
3175    /// interpretable. Additive: clients that ignore it decode unchanged.
3176    pub via_direction: crate::graph::query::TraversalDirection,
3177}
3178
3179/// One sub-section-level facet entry. `path` is ordered outermost →
3180/// innermost, prefixed with the H2 section key (e.g. `["specifies",
3181/// "Response Shapes", "Markdown Output"]`). Structured vector (not a
3182/// delimiter-joined string) so headings containing punctuation don't break
3183/// the key.
3184#[derive(Debug, Clone, Serialize, JsonSchema)]
3185pub struct SubsectionFacet {
3186    pub path: Vec<String>,
3187    pub count: usize,
3188}
3189
3190/// Fixed set of facet dimensions computed over the unpaginated hit set.
3191/// Tier 1 freezes the dimensions; extend later only if empirical use
3192/// demands it. Zero-count entries are excluded to keep the payload small.
3193#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
3194pub struct Facets {
3195    pub by_type: HashMap<String, usize>,
3196    pub by_mem: HashMap<String, usize>,
3197    pub by_level: HashMap<String, usize>,
3198    pub by_status: HashMap<String, usize>,
3199    pub by_confidence: HashMap<String, usize>,
3200    pub by_subsection: Vec<SubsectionFacet>,
3201    /// `"primary"` / `"expanded"` — counts of primary vs. graph-expanded
3202    /// hits. Always present; `expanded` is `0` when no expansion ran.
3203    pub by_expansion: HashMap<String, usize>,
3204}
3205
3206/// A search result hit.
3207#[derive(Debug, Clone, Serialize)]
3208pub struct SearchHit {
3209    pub id: EntityId,
3210    pub title: String,
3211    pub mem: String,
3212    pub entity_type: String,
3213    pub stub: bool,
3214    pub score: f32,
3215    pub tokens: usize,
3216    /// The entity's `last_modified` stamp (RFC-3339 date) — list/roster
3217    /// consumers (the app's Liste, agents asking "what moved lately")
3218    /// sort on it without per-entity reads. `None` for stubs and hits
3219    /// built outside the engine ops.
3220    #[serde(default, skip_serializing_if = "Option::is_none")]
3221    pub last_modified: Option<String>,
3222    pub snippet: Option<String>,
3223    /// Lead/key section bodies for the hit. The `search` op leaves this
3224    /// **empty** — search finds entities, `memstead_entity` reads their
3225    /// bodies; carrying every required section per hit overflowed the MCP
3226    /// transport cap. The `list` op still populates it (its human-facing
3227    /// roster consumers read the lead section as a one-line summary).
3228    /// Empty maps are omitted from the serialized envelope.
3229    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
3230    pub sections: HashMap<String, String>,
3231    /// Score component breakdown — populated when the call supplied a
3232    /// text predicate; `None` on the metadata-only path.
3233    #[serde(default, skip_serializing_if = "Option::is_none")]
3234    pub score_breakdown: Option<ScoreBreakdown>,
3235    /// Per-term match details keyed by query term — populated when the
3236    /// call supplied a text predicate; `None` on the metadata-only path.
3237    #[serde(default, skip_serializing_if = "Option::is_none")]
3238    pub matched_terms: Option<HashMap<String, Vec<TermMatch>>>,
3239    /// Expansion metadata — populated on hits reached via graph
3240    /// expansion; `None` on primary hits.
3241    #[serde(default, skip_serializing_if = "Option::is_none")]
3242    pub expansion: Option<ExpansionInfo>,
3243    /// Lead-section summary resolved against the hit's *own* mem schema
3244    /// at search time (see [`SummaryPair`]). The renderer cannot resolve
3245    /// it correctly on its own — the global `type_by_name` only sees the
3246    /// `default` schema, so a `software`-schema hit (`requirement` →
3247    /// `Statement`, `actor` → `Role`) would miss its anchor section and
3248    /// render `—`. `#[serde(skip)]` keeps `SearchHit`'s wire shape
3249    /// unchanged; the value surfaces on the envelope's `summary_heading` /
3250    /// `summary_value`. `None` only on hits built outside the engine
3251    /// search op (FFI/bridge and test fixtures), where the renderer falls
3252    /// back to the default-schema lookup.
3253    #[serde(skip)]
3254    pub summary: Option<SummaryPair>,
3255}
3256
3257/// Lead-section `(heading, value)` for a search/list hit, resolved
3258/// against the hit's own mem schema at search time. Carried in-memory
3259/// from the search op to the renderers; see [`SearchHit::summary`].
3260#[derive(Debug, Clone)]
3261pub struct SummaryPair {
3262    pub heading: String,
3263    pub value: String,
3264}
3265
3266/// Search result with metadata.
3267#[derive(Debug, Clone, Serialize)]
3268pub struct SearchResult {
3269    pub total: usize,
3270    pub returned: usize,
3271    pub offset: usize,
3272    /// Sum of estimated tokens across all matching entities (pre-pagination).
3273    /// Lets agents judge read cost before paging.
3274    pub total_tokens: usize,
3275    pub hits: Vec<SearchHit>,
3276    /// Faceted counts over the unpaginated hit set. Stable closed
3277    /// struct; zero-count entries are excluded.
3278    #[serde(default, skip_serializing_if = "Option::is_none")]
3279    pub facets: Option<Facets>,
3280    /// Non-fatal issues surfaced to the caller. Structured
3281    /// `WarningHint` shape (`{code, details, message}`) — same wire
3282    /// envelope every other tool's warnings already use. Agents
3283    /// branch on `code`; the message field carries the existing
3284    /// remediation prose.
3285    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3286    pub warnings: Vec<WarningHint>,
3287}
3288
3289/// List result with token totals.
3290#[derive(Debug, Clone, Serialize)]
3291pub struct ListResult {
3292    pub total: usize,
3293    pub returned: usize,
3294    pub offset: usize,
3295    pub total_tokens: usize,
3296    pub hits: Vec<SearchHit>,
3297    /// Non-fatal issues surfaced to the caller — same structured
3298    /// shape as `SearchResult.warnings`.
3299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3300    pub warnings: Vec<WarningHint>,
3301}
3302
3303// ---------------------------------------------------------------------------
3304// Health types
3305// ---------------------------------------------------------------------------
3306
3307/// Health check result for one entity.
3308#[derive(Debug, Clone, Serialize)]
3309pub struct HealthReport {
3310    pub id: EntityId,
3311    pub title: String,
3312    pub score: f32,
3313    pub issues: Vec<HealthIssue>,
3314}
3315
3316/// Machine-readable condition discriminator for a [`HealthIssue`] —
3317/// the enumeration lives here, with the issue type, and is never
3318/// re-derived per projection. A projection that lists issues carries
3319/// the code; the code is NEVER only a message-string prefix (a
3320/// projection that drops messages would silently collapse distinct
3321/// conditions — the exact misdirection `SECTION_HEADING_MISMATCH`
3322/// exists to prevent).
3323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
3324#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
3325pub enum HealthIssueCode {
3326    /// The required section/field is genuinely absent or empty.
3327    Missing,
3328    /// The section's content is present in the file but sits under a
3329    /// heading that does not derive back to the section key — NOT
3330    /// missing; fix the schema's heading/key pair.
3331    SectionHeadingMismatch,
3332    /// The entity carries a relationship whose rel-type the mem's
3333    /// schema does not declare.
3334    UndeclaredRelationship,
3335    /// An existing edge violates the rel-type's declared
3336    /// `source_types` / `target_types` shape.
3337    InvalidRelShape,
3338}
3339
3340impl HealthIssueCode {
3341    /// Stable wire string — matches the serde `SCREAMING_SNAKE_CASE`
3342    /// serialization, exposed for text renderers.
3343    pub fn as_wire(&self) -> &'static str {
3344        match self {
3345            HealthIssueCode::Missing => "MISSING",
3346            HealthIssueCode::SectionHeadingMismatch => "SECTION_HEADING_MISMATCH",
3347            HealthIssueCode::UndeclaredRelationship => "UNDECLARED_RELATIONSHIP",
3348            HealthIssueCode::InvalidRelShape => "INVALID_REL_SHAPE",
3349        }
3350    }
3351}
3352
3353#[derive(Debug, Clone, Serialize)]
3354pub struct HealthIssue {
3355    pub field: String,
3356    /// Which condition this issue reports — see [`HealthIssueCode`].
3357    pub code: HealthIssueCode,
3358    pub message: String,
3359}
3360
3361/// One quarantine-roster entry on [`HealthSummary`]: the mem, the
3362/// typed reason code, and the full reason message (repair command
3363/// included — plan-01 material).
3364#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3365pub struct QuarantinedMemReport {
3366    pub mem: String,
3367    pub reason_code: String,
3368    pub reason_message: String,
3369}
3370
3371/// One per-file load failure surfaced on the health report. `file` is
3372/// the path the loader reported (absolute for folder mounts after the
3373/// reload-normalization pass); `error` is the loader's message, which
3374/// names the remedy where one exists (the merge-conflict refusal names
3375/// `memstead conflicts resolve`).
3376#[derive(Debug, Clone, serde::Serialize)]
3377pub struct LoadErrorReport {
3378    pub file: String,
3379    pub error: String,
3380}
3381
3382/// Aggregated health report for the whole graph.
3383#[derive(Debug, Clone, Serialize)]
3384pub struct HealthSummary {
3385    pub stale_entities: Vec<StaleEntity>,
3386    pub missing_fields: Vec<HealthReport>,
3387    pub orphan_count: usize,
3388    pub stub_count: usize,
3389    /// Typed non-fatal issues visible to every caller of `Engine::health()`.
3390    /// Populated in two layers: `Engine.load_warnings` contributes drift
3391    /// warnings surfaced during mem load / reload / attach
3392    /// (`SuspiciousNestedPrefix`, future load-time checks); the MCP
3393    /// handler additionally appends request-scoped warnings (unknown
3394    /// `include` keys, clamped `limit`) on top of whatever the engine
3395    /// merged. Empty on the happy path.
3396    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3397    pub warnings: Vec<WarningHint>,
3398    /// Quarantine roster: mems that failed their mem-level boot step
3399    /// and serve nothing until repaired + reloaded. Always present in
3400    /// `Engine::health()` output when non-empty — a boot-honesty fact,
3401    /// never behind an include gate. Empty (and omitted from the wire)
3402    /// on a healthy workspace, keeping default output byte-unchanged.
3403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3404    pub quarantined: Vec<QuarantinedMemReport>,
3405    /// Per-file load failures: entity files the loader refused (git
3406    /// merge-conflict markers, unreadable bytes, parser failures). The
3407    /// same boot-honesty class as `quarantined` — always present when
3408    /// non-empty, never behind an include gate — because each entry's
3409    /// message names the remedy (e.g. the conflict refusal names
3410    /// `memstead conflicts resolve`), and a remedy no surface renders
3411    /// is a capability nobody finds at the moment it is needed. Empty
3412    /// (and omitted from the wire) on a clean workspace.
3413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3414    pub load_errors: Vec<LoadErrorReport>,
3415    /// Workspace-level boot diagnosis from a diagnostic-shell engine
3416    /// (`{code, message}`): why the real workspace could not boot at
3417    /// all. Absent on every ordinarily booted engine.
3418    #[serde(default, skip_serializing_if = "Option::is_none")]
3419    pub boot_diagnosis: Option<serde_json::Value>,
3420    /// Real-entity count per leaf-declared type (`<schema_ref>:<type>`
3421    /// keys) — the population the orphan axis exempts because those
3422    /// types are terminal by construction (agent-trust plan 06).
3423    /// Visible, never vanished. Empty (and omitted from the wire) for
3424    /// schemas that declare nothing, keeping default output
3425    /// byte-unchanged.
3426    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
3427    pub leaf_entities_by_type: std::collections::BTreeMap<String, usize>,
3428    /// Dangling references: the three conditions [`DanglingLink`] carries,
3429    /// which are NOT all "a body wiki-link to a missing file" — that is one
3430    /// of them. See [`DanglingLinkKind`] for the other two (a body link to
3431    /// a written entity the referrer does not relate to, and a
3432    /// relationships row naming an absent entity) and their repairs.
3433    /// Populated only when the caller
3434    /// opts in via `include=["dangling_links"]`; `None` otherwise, so
3435    /// absence-of-key means "not requested" and presence-of-empty-array
3436    /// means "requested, zero findings". Scan is handler-driven (same
3437    /// pattern as `warnings` above), so non-MCP callers of
3438    /// `Engine::health()` always see `None` unless they invoke
3439    /// [`health::collect_dangling_links`] directly.
3440    #[serde(default, skip_serializing_if = "Option::is_none")]
3441    pub dangling_links: Option<Vec<DanglingLink>>,
3442    /// Integrity findings (`{ id, axis, code, detail }`) over the
3443    /// conformance axis — and, under `include=["integrity"]`, the
3444    /// consistency axis too. Populated only when the caller opts in
3445    /// via `include=["conformance"]` / `include=["integrity"]`;
3446    /// `None` otherwise (same handler-driven pattern as
3447    /// `dangling_links`: absence means "not requested", an empty
3448    /// array means "requested, fully integral").
3449    #[serde(default, skip_serializing_if = "Option::is_none")]
3450    pub findings: Option<Vec<integrity::IntegrityFinding>>,
3451    /// Tag distribution (count per distinct tag, case-sensitive) over non-stub
3452    /// entities. Populated only when the caller opts in via `include=["tags"]`.
3453    /// Case-variant drift is surfaced via the sibling field [`tag_distribution_folded`].
3454    #[serde(default, skip_serializing_if = "Option::is_none")]
3455    pub tag_distribution: Option<Vec<TagDistribution>>,
3456    /// Case-drift audit sidecar: entries where two or more casings of the same
3457    /// canonical tag (lowercase) both appear in authored tags. Only entries with
3458    /// `variants.len() > 1` are returned — the default read of `tag_distribution`
3459    /// stays untouched. Populated alongside `tag_distribution`.
3460    #[serde(default, skip_serializing_if = "Option::is_none")]
3461    pub tag_distribution_folded: Option<Vec<FoldedTag>>,
3462    /// Count of non-stub entities whose `tags` metadata is missing, empty,
3463    /// or resolves to zero effective tags after splitting on `,` and trimming.
3464    /// Populated alongside `tag_distribution` when `include=["tags"]`.
3465    #[serde(default, skip_serializing_if = "Option::is_none")]
3466    pub untagged_entities: Option<UntaggedStats>,
3467}
3468
3469#[derive(Debug, Clone, Serialize)]
3470pub struct StaleEntity {
3471    pub id: EntityId,
3472    pub title: String,
3473    pub days_since_modified: u64,
3474}
3475
3476/// One entry in the tag distribution surface: an authored tag string, the
3477/// number of non-stub entities carrying it, and the per-entity-type breakdown
3478/// of those hits. Comparison is case-sensitive — `decision` and `Decision`
3479/// count as distinct entries here (see `tag_distribution_folded` for the
3480/// drift-aware sidecar).
3481#[derive(Debug, Clone, Serialize)]
3482pub struct TagDistribution {
3483    pub tag: String,
3484    pub count: usize,
3485    pub by_entity_type: HashMap<String, usize>,
3486}
3487
3488/// Case-drift audit entry. Surfaces when two or more casings of the same
3489/// canonical (lowercased) tag appear in the authored graph — the agent-hostile
3490/// bug where `decision` and `Decision` look like two healthy low-count tags
3491/// in the case-sensitive primary surface.
3492#[derive(Debug, Clone, Serialize)]
3493pub struct FoldedTag {
3494    /// Lowercase form — the canonical key.
3495    pub canonical: String,
3496    /// Sum of counts across every casing variant.
3497    pub total: usize,
3498    /// Authored casings (as-written), each with its individual count.
3499    /// Sorted by `count` descending; ties broken by `tag` ascending.
3500    pub variants: Vec<TagVariant>,
3501}
3502
3503#[derive(Debug, Clone, Serialize)]
3504pub struct TagVariant {
3505    pub tag: String,
3506    pub count: usize,
3507}
3508
3509/// Aggregate count of non-stub entities with zero effective tags, broken
3510/// down by `entity_type`. "Untagged" collapses three states: missing `tags`
3511/// metadata, empty string value, and comma-only value (e.g. `","`).
3512#[derive(Debug, Clone, Serialize)]
3513pub struct UntaggedStats {
3514    pub total: usize,
3515    pub by_entity_type: HashMap<String, usize>,
3516}
3517
3518/// One dangling-reference finding surfaced by
3519/// `memstead_health include=["dangling_links"]` and projected onto the
3520/// consistency axis by `include=["integrity"]`.
3521///
3522/// Three conditions reach this type, and [`kind`](Self::kind) says which:
3523/// a body wiki-link whose target has no markdown file (the post-delete /
3524/// renamed-without-rewrite / typo signal), a body wiki-link to a fully
3525/// written entity that the referrer does not relate to, and a relationships
3526/// row naming an entity absent from the store. Their repairs differ, so the
3527/// codes differ; see [`DanglingLinkKind`].
3528///
3529/// The prose this replaces described only the first condition, which is how
3530/// the fusion survived: the type read as if it had one subject while
3531/// producing three (04/06, criterion 6).
3532#[derive(Debug, Clone, Serialize)]
3533pub struct DanglingLink {
3534    /// Which of the three conditions this is, and therefore which repair
3535    /// applies. Carried from the one producer, never re-derived: the split
3536    /// happens where the conditions are distinguished (04/06).
3537    pub kind: DanglingLinkKind,
3538    pub from: EntityId,
3539    /// Canonical ID the wiki-link resolves to.
3540    ///
3541    /// NOT necessarily a stub: it is a stub or absent for
3542    /// [`DanglingLinkKind::LinkTargetMissing`] and
3543    /// [`DanglingLinkKind::RelationTargetMissing`], and a real, non-stub
3544    /// entity for [`DanglingLinkKind::LinkNotRelated`], where the entity is
3545    /// fine and the relationship row is what is missing. The old wording said
3546    /// "stub-typed in the store", which was true of one of the three
3547    /// conditions this type carried.
3548    pub target_id: EntityId,
3549    /// Resolved mem-relative path segment of the target ID (e.g. `gone`
3550    /// for `specs--gone`). This is the normalised form the engine records —
3551    /// not the literal `[[…]]` characters as authored. Widening `WikiLink`
3552    /// to preserve the authored form is a future-work item if agents need
3553    /// grep-to-source precision.
3554    pub target_path: String,
3555    /// Section key the body wiki-link appears in (e.g. `"purpose"`).
3556    ///
3557    /// `None` for [`DanglingLinkKind::RelationTargetMissing`], whose source is
3558    /// the auto-managed relationships block rather than a body section. That
3559    /// absence used to be the ONLY way to tell that condition apart, which is
3560    /// why `kind` exists: a reader should not have to inspect a payload for
3561    /// nulls to learn which repair applies (04/06, criterion 4).
3562    #[serde(skip_serializing_if = "Option::is_none")]
3563    pub section: Option<String>,
3564}
3565
3566/// The three conditions the one dangling-link collector distinguishes.
3567///
3568/// They were emitted under a single `DANGLING_LINK` code through an identical
3569/// payload, so a reader could not tell which of three repairs applied, and
3570/// neither could the surfaces rendering it. Two of the three were not
3571/// discriminable at all. The project's error discipline is that a typed code
3572/// names one condition, so each gets its own (04/06).
3573///
3574/// The serialised value IS the code, so a payload's `kind`, a finding's
3575/// `code` and a rendered line all read the same string. A kebab-case serde
3576/// name would be a second spelling of one condition, which is the shape of
3577/// the defect this plan removes.
3578#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3579pub enum DanglingLinkKind {
3580    /// A body wiki-link whose target is absent from the store or present only
3581    /// as a stub. Repair: create the target entity.
3582    #[serde(rename = "DANGLING_LINK_TARGET_MISSING")]
3583    LinkTargetMissing,
3584    /// A body wiki-link to an existing, non-stub entity that the referrer's
3585    /// relationships list does not name. The entity is fine; the relationship
3586    /// row is missing. Repair: `memstead_relate` the two.
3587    #[serde(rename = "DANGLING_LINK_NOT_RELATED")]
3588    LinkNotRelated,
3589    /// A relationships row whose target is entirely absent, neither stub nor
3590    /// real. Repair: remove the row, or create the target.
3591    ///
3592    /// A stub target here is a legitimate forward reference (the alias
3593    /// machinery auto-stubs absent targets by design) and is deliberately not
3594    /// flagged.
3595    #[serde(rename = "DANGLING_RELATION_TARGET_MISSING")]
3596    RelationTargetMissing,
3597}
3598
3599impl DanglingLinkKind {
3600    /// The stable wire code. One code, one condition, one repair.
3601    pub fn code(&self) -> &'static str {
3602        match self {
3603            DanglingLinkKind::LinkTargetMissing => "DANGLING_LINK_TARGET_MISSING",
3604            DanglingLinkKind::LinkNotRelated => "DANGLING_LINK_NOT_RELATED",
3605            DanglingLinkKind::RelationTargetMissing => "DANGLING_RELATION_TARGET_MISSING",
3606        }
3607    }
3608
3609    /// Every code this family can emit. The strict counter and any other
3610    /// consumer filtering on the literal string reads THIS rather than
3611    /// keeping its own copy (04/06, criterion 3).
3612    ///
3613    /// Kept honest by `all_codes_covers_every_variant`, whose exhaustive
3614    /// match stops compiling when a variant is added — without it this is
3615    /// just another hand-written list, and a fourth condition would fall
3616    /// out of the strict gate silently, which is the failure the roster
3617    /// exists to prevent.
3618    pub const ALL_CODES: &'static [&'static str] = &[
3619        "DANGLING_LINK_TARGET_MISSING",
3620        "DANGLING_LINK_NOT_RELATED",
3621        "DANGLING_RELATION_TARGET_MISSING",
3622    ];
3623
3624    /// What to do about it, in one clause.
3625    pub fn repair(&self) -> &'static str {
3626        match self {
3627            DanglingLinkKind::LinkTargetMissing => {
3628                "create the target entity, or remove the wiki-link"
3629            }
3630            DanglingLinkKind::LinkNotRelated => {
3631                "relate the two entities, so the body link is backed by a relationship row"
3632            }
3633            DanglingLinkKind::RelationTargetMissing => {
3634                "remove the relationship row, or create the target entity"
3635            }
3636        }
3637    }
3638}
3639
3640// ---------------------------------------------------------------------------
3641// Export types
3642// ---------------------------------------------------------------------------
3643
3644/// Export result.
3645///
3646/// Workspace-wide `export_markdown` returns this struct with
3647/// `skipped_mounts` populated for every mount whose active backend
3648/// doesn't support
3649/// markdown regeneration in place (git-branch, archive). Per-mem
3650/// export against an incompatible backend short-circuits with
3651/// `EngineError::MarkdownExportUnsupportedBackend` instead.
3652#[derive(Debug, Clone, Serialize)]
3653pub struct ExportResult {
3654    pub written: usize,
3655    pub unchanged: usize,
3656    /// Mounts that the workspace-wide export declined to write
3657    /// because their backend doesn't support markdown regeneration.
3658    /// Empty on the happy path (every mount is folder-backed).
3659    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3660    pub skipped_mounts: Vec<SkippedMount>,
3661    /// Entities the export declined to regenerate because their stored body
3662    /// ends inside an unterminated code fence: writing them would seal the
3663    /// sections that fence absorbed (04/02, criterion 5). Skipping one entity
3664    /// is the non-stranding half of that refusal — the rest of the export
3665    /// still lands, and the entity is named rather than silently passed over.
3666    /// Empty on the happy path.
3667    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3668    pub refused_entities: Vec<RefusedEntity>,
3669}
3670
3671/// One entity `export_markdown` declined, with the condition that stopped it.
3672#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
3673pub struct RefusedEntity {
3674    pub id: String,
3675    pub reason: String,
3676    pub detail: String,
3677}
3678
3679/// One mount declined by `export_markdown` because the active
3680/// backend doesn't support in-place markdown regeneration.
3681///
3682/// `reason` is a stable token (today: `"backend_does_not_support_markdown_export"`);
3683/// `active_backend` matches [`crate::workspace::MountStorage::backend_id`].
3684#[derive(Debug, Clone, Serialize)]
3685pub struct SkippedMount {
3686    pub mem: String,
3687    pub active_backend: String,
3688    pub reason: String,
3689}
3690
3691/// Result of a `.mem` mem-archive export.
3692#[derive(Debug, Clone, Serialize)]
3693pub struct MemExportResult {
3694    pub archive_path: String,
3695    pub name: String,
3696    pub version: String,
3697    pub entity_count: usize,
3698    pub size_bytes: u64,
3699    /// Cross-mem edges in the exported slice whose target won't travel
3700    /// inside this single-mem archive — `install` will reject the
3701    /// archive for each one. Surfaced at export time
3702    /// (`DANGLING_CROSS_MEM_EDGE_IN_EXPORT`) so the operator sees the
3703    /// install-time failure before sharing. Empty for a self-contained
3704    /// export.
3705    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3706    pub dangling_cross_mem_edges: Vec<crate::validator::DanglingCrossMemEdge>,
3707    /// Ids in the exported slice whose stored body ends inside an
3708    /// unterminated code fence. `install` refuses the archive for each one
3709    /// (the repack would bury the sections that fence absorbed), so the
3710    /// condition is surfaced here for the same reason the dangling edges
3711    /// above are: the operator should see the install-time failure before
3712    /// sharing, not after. One predicate, two postures.
3713    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3714    pub unterminated_fence_entities: Vec<String>,
3715}
3716
3717/// Result of `Engine::set_mem_internal`. Carries `warnings` for the same
3718/// reason every other config setter does: without a channel the
3719/// `CONFIG_WRITE_INTERVENED` report has nowhere to go.
3720#[derive(Debug, Clone, Serialize)]
3721pub struct SetMemInternalOutcome {
3722    pub mem: String,
3723    pub internal: bool,
3724    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3725    pub warnings: Vec<WarningHint>,
3726}
3727
3728/// Result of `Engine::set_mem_version`. Carries the (mem,
3729/// old_version, new_version) triple so callers (CLI, MCP) can surface
3730/// the change without an extra read.
3731#[derive(Debug, Clone, Serialize)]
3732pub struct SetMemVersionOutcome {
3733    pub mem: String,
3734    /// Previous version. `None` when the mem config carried no
3735    /// version field before this call (pre-gate / externally-imported
3736    /// config, or the residual `MEM_CONFIG_INCOMPLETE` path).
3737    #[serde(default, skip_serializing_if = "Option::is_none")]
3738    pub old_version: Option<semver::Version>,
3739    pub new_version: semver::Version,
3740    /// Concurrent-drift warnings detected at the pre-write probe —
3741    /// e.g. `MemReloaded` when a sibling engine committed between
3742    /// this engine's last snapshot and the set-version write. Empty
3743    /// on the happy path. F1.
3744    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3745    pub warnings: Vec<WarningHint>,
3746}
3747
3748/// Result of `Engine::set_mem_title`. Same shape discipline as
3749/// [`SetMemDescriptionOutcome`].
3750#[derive(Debug, Clone, Serialize)]
3751pub struct SetMemTitleOutcome {
3752    pub mem: String,
3753    #[serde(default, skip_serializing_if = "Option::is_none")]
3754    pub old_title: Option<String>,
3755    #[serde(default, skip_serializing_if = "Option::is_none")]
3756    pub new_title: Option<String>,
3757    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3758    pub warnings: Vec<WarningHint>,
3759}
3760
3761/// Result of `Engine::set_mem_subject`. The block sets/clears as a
3762/// unit; old/new carry the whole block.
3763#[derive(Debug, Clone, Serialize)]
3764pub struct SetMemSubjectOutcome {
3765    pub mem: String,
3766    #[serde(default, skip_serializing_if = "Option::is_none")]
3767    pub old_subject: Option<memstead_schema::MemSubject>,
3768    #[serde(default, skip_serializing_if = "Option::is_none")]
3769    pub new_subject: Option<memstead_schema::MemSubject>,
3770    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3771    pub warnings: Vec<WarningHint>,
3772}
3773
3774/// Result of `Engine::set_mem_description`. Carries the (mem,
3775/// old_description, new_description) triple so callers can surface
3776/// the change without an extra read.
3777#[derive(Debug, Clone, Serialize)]
3778pub struct SetMemDescriptionOutcome {
3779    pub mem: String,
3780    /// Previous description. `None` when the mem config carried no
3781    /// description before this call (the common case — mem creation
3782    /// seeds none).
3783    #[serde(default, skip_serializing_if = "Option::is_none")]
3784    pub old_description: Option<String>,
3785    /// The description now persisted; `None` when the call cleared it.
3786    #[serde(default, skip_serializing_if = "Option::is_none")]
3787    pub new_description: Option<String>,
3788    /// Concurrent-drift warnings detected at the pre-write probe.
3789    /// Empty on the happy path.
3790    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3791    pub warnings: Vec<WarningHint>,
3792}
3793
3794/// Result of `Engine::set_mem_sync_state`. Carries the (mem, key,
3795/// previous-token) triple so callers (CLI, MCP) can surface the change
3796/// without an extra read. The token values are opaque to the engine —
3797/// see `MemConfig::sync_state`.
3798#[derive(Debug, Clone, Serialize)]
3799pub struct SetMemSyncStateOutcome {
3800    pub mem: String,
3801    /// The sync-state key that was set or cleared (opaque; the ingest
3802    /// layer keys per `(ingest, facet)`).
3803    pub key: String,
3804    /// Previous token under `key`, `None` when the key was unset before
3805    /// this call. Lets callers report set-vs-overwrite without a read.
3806    #[serde(default, skip_serializing_if = "Option::is_none")]
3807    pub previous: Option<String>,
3808    /// True when an empty token cleared an existing key. `false` for a
3809    /// set/overwrite and for a clear of an already-absent key (a no-op).
3810    pub removed: bool,
3811    /// Concurrent-drift warnings detected at the pre-write probe — e.g.
3812    /// `MemReloaded` when a sibling engine committed between this
3813    /// engine's last snapshot and the write. Empty on the happy path.
3814    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3815    pub warnings: Vec<WarningHint>,
3816}
3817
3818// ---------------------------------------------------------------------------
3819// Context types
3820// ---------------------------------------------------------------------------
3821
3822/// Context around an entity — neighbors, community, related entities.
3823#[derive(Debug, Clone, Serialize)]
3824pub struct ContextResult {
3825    pub entity_id: EntityId,
3826    pub community: Option<String>,
3827    pub neighbors: Vec<NeighborInfo>,
3828}
3829
3830#[derive(Debug, Clone, Serialize)]
3831pub struct NeighborInfo {
3832    pub id: EntityId,
3833    pub title: String,
3834    pub relationship: String,
3835    pub direction: Direction,
3836}
3837
3838#[derive(Debug, Clone, Serialize)]
3839pub enum Direction {
3840    Outgoing,
3841    Incoming,
3842}
3843
3844// ---------------------------------------------------------------------------
3845// Status
3846// ---------------------------------------------------------------------------
3847
3848/// Graph status — node / edge counts and schema distribution. Renamed from
3849/// the former `Stats` when the `stats` command became `status` (bundle plan
3850/// `03-projection-promotion`, D11); the fields are unchanged so every caller's
3851/// payload stays byte-compatible.
3852#[derive(Debug, Clone, Serialize)]
3853pub struct Status {
3854    pub entity_count: usize,
3855    pub edge_count: usize,
3856    pub edge_types: HashMap<String, usize>,
3857    pub community_count: usize,
3858    pub mem_count: usize,
3859    pub types_in_use: Vec<String>,
3860}
3861
3862// ---------------------------------------------------------------------------
3863// Reload result
3864// ---------------------------------------------------------------------------
3865
3866#[derive(Debug, Clone, Serialize)]
3867pub struct ReloadResult {
3868    pub added: Vec<EntityId>,
3869    pub changed: Vec<EntityId>,
3870    pub removed: Vec<EntityId>,
3871}
3872
3873/// Per-mem reload outcome — produced by [`Engine::reload_one_mem`]
3874/// and surfaced verbatim in the `memstead_reload` MCP tool's response when
3875/// an explicit operator-triggered reload runs against a single mem.
3876/// Auto-reloads on the read path consume this internally and emit a
3877/// [`WarningHint::MemReloaded`] (which carries `mem`, `old_head`,
3878/// `new_head`, `entities_loaded` — the diff list is intentionally
3879/// omitted from the lean warning payload; agents that need it call
3880/// `memstead_changes_since` themselves with the supplied `old_head`).
3881///
3882/// `head_before` / `head_after` are hex-rendered SHAs (or
3883/// `EMPTY_TREE_SHA` for the no-baseline case) so the wire shape
3884/// matches what `memstead_changes_since` already accepts as `since`.
3885/// `changed_entity_ids` is the list of non-stub IDs whose
3886/// `content_hash` differs between the pre- and post-reload store
3887/// snapshots, plus every newly-added or newly-removed id — same
3888/// semantic as `ReloadResult { added, changed, removed }` flattened
3889/// into a single set so callers don't have to merge three lists.
3890#[derive(Debug, Clone, Serialize)]
3891pub struct ReloadReport {
3892    pub mem: String,
3893    pub head_before: String,
3894    pub head_after: String,
3895    pub entities_loaded: usize,
3896    pub changed_entity_ids: Vec<EntityId>,
3897}
3898
3899/// What `Engine::full_refresh` changed — and, just as deliberately,
3900/// what it SKIPPED. The refresh is additive-only: removals never take
3901/// effect warm, and this report is how the caller learns whether its
3902/// next call will succeed instead of guessing.
3903#[derive(Debug, Clone, Default, Serialize)]
3904pub struct FullRefreshReport {
3905    /// Schema versions (`name@version`) newly resolvable.
3906    pub schemas_added: Vec<String>,
3907    /// In-memory schema versions absent from the re-scanned sources —
3908    /// the removal was skipped; they stay resolvable until restart.
3909    pub schema_removals_skipped: Vec<String>,
3910    /// Mems newly mounted (cold-loaded like any boot-time mount).
3911    pub mems_mounted: Vec<String>,
3912    /// Mounted writable mems absent from the re-scanned manifest —
3913    /// the removal was skipped; they stay live until restart.
3914    pub mem_removals_skipped: Vec<String>,
3915    /// Per-item failures: a source or mount that failed to refresh.
3916    /// Failed items never surface as newly available; the others
3917    /// proceed.
3918    pub failures: Vec<RefreshFailure>,
3919    /// Wall-clock cost of the refresh (the bounded-cost report).
3920    pub elapsed_ms: u64,
3921}
3922
3923/// One failed refresh item — `item` is `schema-source:<which>`,
3924/// `mount:<mem>`, `mount-manifest`, or `workspace`.
3925#[derive(Debug, Clone, Serialize)]
3926pub struct RefreshFailure {
3927    pub item: String,
3928    pub error: String,
3929}
3930
3931#[cfg(test)]
3932mod tests {
3933    use super::*;
3934
3935    /// `ALL_CODES` is what the strict gate and the graph referee filter
3936    /// on, so a condition missing from it is a condition that stops
3937    /// failing a gate — silently, since nothing else would break. The
3938    /// exhaustive match below is the enforcement: add a variant and this
3939    /// stops COMPILING, which is the only moment anyone would otherwise
3940    /// have to remember (04/06, criterion 3).
3941    #[test]
3942    fn all_codes_covers_every_variant() {
3943        let every = [
3944            DanglingLinkKind::LinkTargetMissing,
3945            DanglingLinkKind::LinkNotRelated,
3946            DanglingLinkKind::RelationTargetMissing,
3947        ];
3948        for kind in every {
3949            // Exhaustive by construction: a new variant fails to compile
3950            // here before it can quietly miss the roster.
3951            match kind {
3952                DanglingLinkKind::LinkTargetMissing
3953                | DanglingLinkKind::LinkNotRelated
3954                | DanglingLinkKind::RelationTargetMissing => {}
3955            }
3956            assert!(
3957                DanglingLinkKind::ALL_CODES.contains(&kind.code()),
3958                "{} is emitted but absent from ALL_CODES, so every filter \
3959                 reading the roster would skip it",
3960                kind.code()
3961            );
3962            assert!(
3963                !kind.repair().is_empty(),
3964                "{} has no repair clause",
3965                kind.code()
3966            );
3967        }
3968        assert_eq!(
3969            DanglingLinkKind::ALL_CODES.len(),
3970            every.len(),
3971            "ALL_CODES carries a code no variant emits"
3972        );
3973        // The serialised `kind` IS the code — one spelling per condition.
3974        for kind in every {
3975            assert_eq!(
3976                serde_json::to_value(kind).unwrap(),
3977                serde_json::Value::String(kind.code().to_string())
3978            );
3979        }
3980    }
3981
3982    // Locks the wire shape of `Query` across every combination of
3983    // set/unset fields. Agents compose queries on the fly; a drift here
3984    // silently changes the MCP tool's JSON contract.
3985    #[test]
3986    fn query_json_roundtrip_every_combination() {
3987        let cases: Vec<Query> = vec![
3988            Query::default(),
3989            Query {
3990                any: vec!["auth".into()],
3991                ..Default::default()
3992            },
3993            Query {
3994                not: vec!["mock".into()],
3995                ..Default::default()
3996            },
3997            Query {
3998                phrase: Some("client side agent".into()),
3999                ..Default::default()
4000            },
4001            Query {
4002                field: Some("identity".into()),
4003                ..Default::default()
4004            },
4005            Query {
4006                any: vec!["a".into(), "b".into()],
4007                not: vec!["x".into()],
4008                phrase: Some("ex act".into()),
4009                field: Some("purpose".into()),
4010            },
4011        ];
4012        for q in &cases {
4013            let json = serde_json::to_string(q).expect("serialize");
4014            let back: Query = serde_json::from_str(&json).expect("deserialize");
4015            assert_eq!(q.any, back.any, "any field round-trip: {json}");
4016            assert_eq!(q.not, back.not, "not field round-trip: {json}");
4017            assert_eq!(q.phrase, back.phrase, "phrase field round-trip: {json}");
4018            assert_eq!(q.field, back.field, "field field round-trip: {json}");
4019            assert_eq!(q.is_empty(), back.is_empty());
4020        }
4021    }
4022
4023    // Empty fields stay out of the wire shape — agents see a lean object.
4024    #[test]
4025    fn query_default_serializes_as_empty_object() {
4026        let q = Query::default();
4027        let json = serde_json::to_string(&q).unwrap();
4028        assert_eq!(json, "{}", "default query must serialize as `{{}}`");
4029    }
4030
4031    // Null / missing keys all round-trip to the same default via serde.
4032    #[test]
4033    fn query_accepts_missing_and_null_fields() {
4034        let with_missing: Query = serde_json::from_str("{}").unwrap();
4035        let with_nulls: Query =
4036            serde_json::from_str(r#"{"any":[],"not":[],"phrase":null,"field":null}"#).unwrap();
4037        assert!(with_missing.is_empty());
4038        assert!(with_nulls.is_empty());
4039    }
4040
4041    // Schema is generated via schemars so MCP agents see the full
4042    // structured contract. Cheap smoke test — locks that the four known
4043    // fields appear and nothing regresses to an action-discriminator.
4044    #[test]
4045    fn query_json_schema_exposes_four_fields() {
4046        let schema = schemars::schema_for!(Query);
4047        let rendered = serde_json::to_string(&schema).unwrap();
4048        for field in ["any", "not", "phrase", "field"] {
4049            assert!(
4050                rendered.contains(&format!("\"{field}\"")),
4051                "schema must mention `{field}`: {rendered}"
4052            );
4053        }
4054    }
4055
4056    // ------------------------------------------------------------------
4057    // WarningHint wire-envelope snapshots. Each variant locks `code`
4058    // (stable UPPER_SNAKE_CASE), a message substring (phrasing may
4059    // drift — we assert a durable anchor), and the `details` key-set.
4060    // Arrays are asserted shape-only because their content depends on
4061    // the active schema / allowed-include list.
4062    // ------------------------------------------------------------------
4063
4064    fn to_envelope(w: &WarningHint) -> serde_json::Value {
4065        serde_json::to_value(w).expect("WarningHint serializes")
4066    }
4067
4068    #[test]
4069    fn warning_hint_missing_required_section_envelope() {
4070        // F9: type-level write_rules moved out of per-warning details
4071        // to the mutation response's top-level `type_guidance` map.
4072        // The warning now carries only section-axis fields.
4073        let w = WarningHint::MissingRequiredSection {
4074            entity_type: "spec".into(),
4075            key: "purpose".into(),
4076            heading: "Purpose".into(),
4077            write_rules: vec!["one sentence".into(), "state the why".into()],
4078        };
4079        let json = to_envelope(&w);
4080        assert_eq!(json["code"], "MISSING_REQUIRED_SECTION");
4081        assert!(
4082            json["message"]
4083                .as_str()
4084                .unwrap()
4085                .contains("required section")
4086        );
4087        assert_eq!(json["details"]["entity_type"], "spec");
4088        assert_eq!(json["details"]["key"], "purpose");
4089        assert_eq!(json["details"]["heading"], "Purpose");
4090        assert!(json["details"]["write_rules"].is_array());
4091        // type_write_rules no longer rides on the per-warning envelope.
4092        assert!(json["details"].get("type_write_rules").is_none());
4093    }
4094
4095    #[test]
4096    fn warning_hint_undeclared_relationship_open_envelope() {
4097        let w = WarningHint::UndeclaredRelationshipOpen {
4098            rel_type: "USES".into(),
4099            message: "USES admitted in open mode".into(),
4100        };
4101        let json = to_envelope(&w);
4102        assert_eq!(json["code"], "UNDECLARED_RELATIONSHIP_OPEN");
4103        // Display delegates to the stored message — substring anchor is safe.
4104        assert!(json["message"].as_str().unwrap().contains("open mode"));
4105        assert_eq!(json["details"]["rel_type"], "USES");
4106        // Consistency rule: details must not duplicate the envelope message.
4107        assert!(json["details"].get("message").is_none());
4108        // Only rel_type belongs under details for this variant.
4109        assert_eq!(json["details"].as_object().unwrap().len(), 1);
4110    }
4111
4112    #[test]
4113    fn warning_hint_duplicate_relationship_envelope() {
4114        let w = WarningHint::DuplicateRelationship {
4115            rel_type: "USES".into(),
4116            from: EntityId("specs--a".into()),
4117            to: EntityId("specs--b".into()),
4118        };
4119        let json = to_envelope(&w);
4120        assert_eq!(json["code"], "DUPLICATE_RELATIONSHIP");
4121        assert!(json["message"].as_str().unwrap().contains("already exists"));
4122        assert_eq!(json["details"]["rel_type"], "USES");
4123        assert_eq!(json["details"]["from"], "specs--a");
4124        assert_eq!(json["details"]["to"], "specs--b");
4125    }
4126
4127    #[test]
4128    fn warning_hint_no_such_relationship_envelope() {
4129        let w = WarningHint::NoSuchRelationship {
4130            rel_type: "USES".into(),
4131            from: EntityId("specs--a".into()),
4132            to: EntityId("specs--b".into()),
4133        };
4134        let json = to_envelope(&w);
4135        assert_eq!(json["code"], "NO_SUCH_RELATIONSHIP");
4136        assert!(json["message"].as_str().unwrap().contains("does not exist"));
4137        assert_eq!(json["details"]["rel_type"], "USES");
4138        assert_eq!(json["details"]["from"], "specs--a");
4139        assert_eq!(json["details"]["to"], "specs--b");
4140    }
4141
4142    #[test]
4143    fn warning_hint_unknown_include_key_envelope() {
4144        let w = WarningHint::UnknownIncludeKey {
4145            key: "bogus".into(),
4146            allowed: vec!["orphans".into(), "stubs".into()],
4147        };
4148        let json = to_envelope(&w);
4149        assert_eq!(json["code"], "UNKNOWN_INCLUDE_KEY");
4150        assert!(json["message"].as_str().unwrap().contains("bogus"));
4151        assert_eq!(json["details"]["key"], "bogus");
4152        assert!(json["details"]["allowed"].is_array());
4153    }
4154
4155    #[test]
4156    fn warning_hint_limit_clamped_envelope() {
4157        let w = WarningHint::LimitClamped {
4158            requested: 1000,
4159            actual: 100,
4160        };
4161        let json = to_envelope(&w);
4162        assert_eq!(json["code"], "LIMIT_CLAMPED");
4163        assert!(json["message"].as_str().unwrap().contains("clamped"));
4164        assert_eq!(json["details"]["requested"].as_u64(), Some(1000));
4165        assert_eq!(json["details"]["actual"].as_u64(), Some(100));
4166    }
4167
4168    #[test]
4169    fn warning_hint_title_normalized_to_slug_noop_envelope() {
4170        let w = WarningHint::TitleNormalizedToSlugNoop {
4171            requested_title: "Hello World!".into(),
4172            current_slug: "hello-world".into(),
4173        };
4174        let json = to_envelope(&w);
4175        assert_eq!(json["code"], "TITLE_NORMALIZED_TO_SLUG_NOOP");
4176        assert!(
4177            json["message"]
4178                .as_str()
4179                .unwrap()
4180                .contains("no change written to disk")
4181        );
4182        assert_eq!(json["details"]["requested_title"], "Hello World!");
4183        assert_eq!(json["details"]["current_slug"], "hello-world");
4184    }
4185
4186    // Top-level envelope shape lock — every WarningHint emits exactly
4187    // three keys and nothing else. Protects against accidental field
4188    // additions at the envelope level.
4189    #[test]
4190    fn warning_hint_envelope_has_exactly_three_top_level_keys() {
4191        for w in &WarningHint::all_samples() {
4192            let json = to_envelope(w);
4193            let obj = json.as_object().expect("envelope is an object");
4194            assert_eq!(
4195                obj.len(),
4196                3,
4197                "{} must emit exactly 3 top-level keys; got {:?}",
4198                w.code(),
4199                obj.keys().collect::<Vec<_>>()
4200            );
4201            assert!(obj.contains_key("code"));
4202            assert!(obj.contains_key("message"));
4203            assert!(obj.contains_key("details"));
4204        }
4205    }
4206
4207    // Stability lock — `code()` values are a public wire contract. Every
4208    // variant must expose an UPPER_SNAKE_CASE identifier. Catches
4209    // accidental rename / case drift in a single test.
4210    #[test]
4211    fn warning_hint_code_values_are_upper_snake_case() {
4212        let re = regex::Regex::new(r"^[A-Z][A-Z0-9_]*$").unwrap();
4213        for w in &WarningHint::all_samples() {
4214            let code = w.code();
4215            assert!(
4216                re.is_match(code),
4217                "code() violates UPPER_SNAKE_CASE: {code}"
4218            );
4219        }
4220    }
4221
4222    // Envelope helper emits the same shape as WarningHint::serialize — one
4223    // constructor, two callers (warnings + MCP error path).
4224    #[test]
4225    fn envelope_shape_is_code_message_details() {
4226        let v = envelope("FOO_BAR", "hello", serde_json::json!({ "x": 1 }));
4227        assert_eq!(v["code"], "FOO_BAR");
4228        assert_eq!(v["message"], "hello");
4229        assert_eq!(v["details"]["x"], 1);
4230        assert_eq!(
4231            v.as_object().unwrap().len(),
4232            3,
4233            "envelope has exactly 3 top-level keys"
4234        );
4235    }
4236}
4237
4238#[cfg(test)]
4239mod write_id_doc_gloss_tests {
4240    /// The rustdoc guard, for the whole crate rather than one file.
4241    ///
4242    /// Two earlier versions of this check were too narrow and each let a
4243    /// real defect through. The first read only `ops/mod.rs`, so five
4244    /// copies of the gloss in `engine/outcomes.rs` — the lean flavour's
4245    /// public outcome types, on a crates.io-published crate, hence
4246    /// docs.rs — were invisible. The second was a phrase-exact banned
4247    /// list built for "Per-mem commit SHA", which "Per-mem commit
4248    /// identifier" walked straight past. A list of forbidden sentences
4249    /// is only ever as good as the sentences someone already wrote.
4250    ///
4251    /// So the rule is structural and positive instead. Every documented
4252    /// `write_id` field must say WHICH backend produces a commit and
4253    /// must say the value is not a cursor. Prose that calls the token a
4254    /// commit without qualification fails whatever words it uses,
4255    /// because it cannot satisfy the qualifier requirement.
4256    #[test]
4257    fn every_write_id_doc_qualifies_the_backend_and_denies_the_cursor() {
4258        fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
4259            let Ok(entries) = std::fs::read_dir(dir) else {
4260                return;
4261            };
4262            for e in entries.flatten() {
4263                let p = e.path();
4264                if p.is_dir() {
4265                    walk(&p, out);
4266                } else if p.extension().is_some_and(|x| x == "rs") {
4267                    out.push(p);
4268                }
4269            }
4270        }
4271        let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
4272        let mut files = Vec::new();
4273        walk(&src, &mut files);
4274        assert!(
4275            !files.is_empty(),
4276            "found no sources — check has gone vacuous"
4277        );
4278
4279        let mut documented = 0usize;
4280        let mut violations = Vec::new();
4281        for path in &files {
4282            let Ok(text) = std::fs::read_to_string(path) else {
4283                continue;
4284            };
4285            let lines: Vec<&str> = text.lines().collect();
4286            for (i, line) in lines.iter().enumerate() {
4287                let t = line.trim_start();
4288                if !(t.starts_with("pub write_id:") || t.starts_with("pub seed_write_id:")) {
4289                    continue;
4290                }
4291                // Collect the contiguous doc block above the field,
4292                // skipping attributes like #[serde(default)].
4293                let mut block = Vec::new();
4294                let mut j = i;
4295                while j > 0 {
4296                    j -= 1;
4297                    let prev = lines[j].trim_start();
4298                    if prev.starts_with("#[") {
4299                        continue;
4300                    }
4301                    if prev.starts_with("///") {
4302                        block.push(prev.trim_start_matches("///").trim());
4303                        continue;
4304                    }
4305                    break;
4306                }
4307                if block.is_empty() {
4308                    continue; // undocumented: nothing to gloss
4309                }
4310                documented += 1;
4311                block.reverse();
4312                let doc = block.join(" ");
4313                let lower = doc.to_lowercase();
4314                // Judge the DEFINING sentence, not every later mention.
4315                // "Empty on the no-op rename (no file change, no commit)"
4316                // is a true statement about a path, not a claim that the
4317                // token is a commit; only the summary sentence defines
4318                // the field, and it is what docs.rs renders as such.
4319                let definition = lower.split_once(". ").map(|(a, _)| a).unwrap_or(&lower);
4320                let claims_commit = definition.contains("commit") || definition.contains("sha");
4321                let names_backend = lower.contains("git-branch");
4322                let denies_cursor = lower.contains("not a change cursor")
4323                    || lower.contains("never a change cursor")
4324                    || lower.contains("not a cursor")
4325                    || lower.contains("never a cursor");
4326                let inherits = lower.contains("see `updateresult::write_id`")
4327                    || lower.contains("wire-equivalent to full's");
4328                if inherits && !claims_commit {
4329                    continue; // documented by pointer at a doc this check governs
4330                }
4331                if claims_commit && !names_backend {
4332                    violations.push(format!(
4333                        "{}:{}: calls the token a commit without naming which backend produces one — {}",
4334                        path.file_name().unwrap_or_default().to_string_lossy(),
4335                        i + 1,
4336                        doc
4337                    ));
4338                } else if !denies_cursor && !inherits {
4339                    violations.push(format!(
4340                        "{}:{}: documents the token without stating it is not a change cursor — {}",
4341                        path.file_name().unwrap_or_default().to_string_lossy(),
4342                        i + 1,
4343                        doc
4344                    ));
4345                }
4346            }
4347        }
4348        assert!(
4349            documented >= 5,
4350            "expected the crate to document several write_id fields, saw {documented} — \
4351             this check has gone vacuous"
4352        );
4353        assert!(
4354            violations.is_empty(),
4355            "write_id docs that gloss the token as a git commit or omit the non-cursor statement:\n  {}",
4356            violations.join("\n  ")
4357        );
4358    }
4359
4360    /// The edge spelling in EMITTED JSON, not just in prose.
4361    ///
4362    /// Every guard before this one read documentation. None read the
4363    /// `json!` macros that build responses, which is how
4364    /// `render_relations_json` kept emitting the relation type under
4365    /// the bare key `"type"` through eight grades while
4366    /// `memstead entity --json` next to it emitted `rel_type` — two CLI
4367    /// commands, one concept, two spellings, on the same edge. Neither
4368    /// enumerator could see it either: one pattern wanted `"to"` beside
4369    /// `"type"`, and this shape pairs `"type"` with `"target"`.
4370    ///
4371    /// The rule is narrow on purpose: a line that writes a JSON key
4372    /// `"type"` and mentions `rel_type` is emitting a relation type
4373    /// under the retired name. An entity type or a content-block kind
4374    /// legitimately owns the word `type` and never mentions `rel_type`,
4375    /// so it does not match.
4376    #[test]
4377    fn no_emitted_json_spells_a_relation_type_as_bare_type() {
4378        fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
4379            let Ok(entries) = std::fs::read_dir(dir) else {
4380                return;
4381            };
4382            for e in entries.flatten() {
4383                let p = e.path();
4384                if p.is_dir() {
4385                    walk(&p, out);
4386                } else if p.extension().is_some_and(|x| x == "rs") {
4387                    out.push(p);
4388                }
4389            }
4390        }
4391        let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4392        let mut roots = vec![base.join("src")];
4393        if let Some(ws) = base.parent().and_then(|p| p.parent()) {
4394            for sibling in ["crates/memstead-cli/src", "crates/memstead-mcp/src"] {
4395                let p = ws.join(sibling);
4396                if p.is_dir() {
4397                    roots.push(p);
4398                }
4399            }
4400            if let Some(outer) = ws.parent() {
4401                let p = outer.join("ui-api/src");
4402                if p.is_dir() {
4403                    roots.push(p);
4404                }
4405            }
4406        }
4407        let mut files = Vec::new();
4408        for r in &roots {
4409            walk(r, &mut files);
4410        }
4411        assert!(
4412            !files.is_empty(),
4413            "found no sources — check has gone vacuous"
4414        );
4415
4416        let mut violations = Vec::new();
4417        let mut saw_a_relation_emit = false;
4418        for path in &files {
4419            let Ok(text) = std::fs::read_to_string(path) else {
4420                continue;
4421            };
4422            let lines: Vec<&str> = text.lines().collect();
4423            for (i, line) in lines.iter().enumerate() {
4424                let t = line.trim_start();
4425                if t.starts_with("//") {
4426                    continue; // prose is the other guards' business
4427                }
4428                if line.contains("rel_type") && line.contains('"') {
4429                    saw_a_relation_emit = true;
4430                }
4431                // The serde form splits the two tokens across lines:
4432                //     #[serde(rename = "type")]
4433                //     rel_type: &'a str,
4434                // A same-line rule passed that in silence — a grader
4435                // proved it by reintroducing exactly this on
4436                // `EdgeTypeCount` and watching the check go green. So
4437                // look at a small window, not one line.
4438                let lo = i.saturating_sub(1);
4439                let hi = (i + 2).min(lines.len());
4440                let window = lines[lo..hi].join(" ");
4441                if window.contains("\"type\"") && window.contains("rel_type") {
4442                    violations.push(format!(
4443                        "{}:{}: {}",
4444                        path.file_name().unwrap_or_default().to_string_lossy(),
4445                        i + 1,
4446                        t
4447                    ));
4448                }
4449            }
4450        }
4451        assert!(
4452            saw_a_relation_emit,
4453            "no source mentions `rel_type` in a string context — check has gone vacuous"
4454        );
4455        assert!(
4456            violations.is_empty(),
4457            "emitted JSON spells a relation type as the retired bare `type`:\n  {}",
4458            violations.join("\n  ")
4459        );
4460    }
4461
4462    /// The edge spelling, across the same crate. The canonical
4463    /// `CreateArgs::relations` doc named both retired keys at once, on
4464    /// a field whose own type is `{target, rel_type}`. Neither
4465    /// enumerator matches that prose form, which is why this exists.
4466    #[test]
4467    fn no_doc_comment_spells_a_relation_entry_the_retired_way() {
4468        const RETIRED_EDGE_SHAPES: &[&str] = &[
4469            "to: EntityId, type:",
4470            "{ to, type }",
4471            "{to, type}",
4472            "{from, to, type}",
4473            "`from` / `type` / `to`",
4474            "(`from`/`to`/`type`)",
4475        ];
4476        fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
4477            let Ok(entries) = std::fs::read_dir(dir) else {
4478                return;
4479            };
4480            for e in entries.flatten() {
4481                let p = e.path();
4482                if p.is_dir() {
4483                    walk(&p, out);
4484                } else if p.extension().is_some_and(|x| x == "rs") {
4485                    out.push(p);
4486                }
4487            }
4488        }
4489        // Reach past this crate. Round five's finding was a ui-api
4490        // struct doc, and the guard installed in answer to it could not
4491        // see the file that produced it. The sibling crates and the two
4492        // private consumers all describe the same edge.
4493        let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4494        let mut roots = vec![base.join("src")];
4495        let mut private_ui_api_present = false;
4496        let mut private_serve_present = false;
4497        if let Some(ws) = base.parent().and_then(|p| p.parent()) {
4498            for sibling in [
4499                "crates/memstead-mcp/src",
4500                "crates/memstead-cli/src",
4501                "crates/memstead-engine/src",
4502                "crates/memstead-schema/src",
4503            ] {
4504                let p = ws.join(sibling);
4505                if p.is_dir() {
4506                    roots.push(p);
4507                }
4508            }
4509            // ui-api and serve live beside the `public/` submodule.
4510            if let Some(outer) = ws.parent() {
4511                for private in ["ui-api/src", "serve/src"] {
4512                    let p = outer.join(private);
4513                    if p.is_dir() {
4514                        roots.push(p);
4515                        if private == "ui-api/src" {
4516                            private_ui_api_present = true;
4517                        } else {
4518                            private_serve_present = true;
4519                        }
4520                    }
4521                }
4522            }
4523        }
4524        // Pin the widening itself. `>= 5` was too loose: `ui-api/src`
4525        // and `serve/src` could both silently drop out and this still
4526        // passed, so the round that widened the reach did not actually
4527        // fix it in place. Require every root that exists on disk.
4528        let expected = 5 + usize::from(private_ui_api_present) + usize::from(private_serve_present);
4529        assert_eq!(
4530            roots.len(),
4531            expected,
4532            "expected {expected} roots (four sibling crates plus the private consumers \
4533             present on disk), saw {} — the check has narrowed",
4534            roots.len()
4535        );
4536        let mut files = Vec::new();
4537        for r in &roots {
4538            walk(r, &mut files);
4539        }
4540        assert!(
4541            !files.is_empty(),
4542            "found no sources — check has gone vacuous"
4543        );
4544
4545        let mut violations = Vec::new();
4546        for path in &files {
4547            let Ok(text) = std::fs::read_to_string(path) else {
4548                continue;
4549            };
4550            for (i, line) in text.lines().enumerate() {
4551                let t = line.trim_start();
4552                if !t.starts_with("///") && !t.starts_with("//!") {
4553                    continue;
4554                }
4555                for shape in RETIRED_EDGE_SHAPES {
4556                    if line.contains(shape) {
4557                        violations.push(format!(
4558                            "{}:{}: {}",
4559                            path.file_name().unwrap_or_default().to_string_lossy(),
4560                            i + 1,
4561                            t
4562                        ));
4563                    }
4564                }
4565            }
4566        }
4567        assert!(
4568            violations.is_empty(),
4569            "doc comments still spell a relation entry the retired way:\n  {}",
4570            violations.join("\n  ")
4571        );
4572    }
4573}