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