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