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