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}