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