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