Skip to main content

memstead_base/ops/
mod.rs

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