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