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