memstead_base/engine/error.rs
1//! Engine error envelopes.
2//!
3//! `EngineError` lifts the typed payloads every consumer pattern-matches
4//! on (`BackendError` via `#[from]`, `ValidationError` from the runtime
5//! validator, `SlugError` from the slug helper, `ParseError` from the
6//! markdown parser). `BootError` is the smaller envelope produced by
7//! `Engine::from_workspace_root` and its full counterpart — the failure
8//! modes specific to layout detection, workspace-store load, per-mount
9//! backend instantiation, and engine construction.
10
11use std::fmt;
12use std::path::PathBuf;
13
14use crate::backend::BackendError;
15use crate::entity::EntityId;
16use crate::entity::id::SlugError;
17use crate::entity::parser::ParseError;
18use crate::runtime_validator::{MissingRequiredField, ValidationError};
19
20/// Maximum items rendered inline before truncation kicks in. Picked to
21/// keep the typical fanout (1–25 items) on one terminal line while
22/// still bounding pathological cases (200+ referrers on a hub entity)
23/// to a constant prefix plus a count.
24pub const INLINE_LIST_CAP: usize = 3;
25
26/// One blocked-direction summary entry for
27/// [`EngineError::RenameBlockedByCrossMemPolicy`]. Pairs the
28/// referrer's mem with the renaming entity's mem (the edge's
29/// actual `referrer → renamed` direction post-rewrite) and the count
30/// of distinct referrers in that mem that would emit the blocked
31/// rewrite.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct BlockedReferrer {
34 /// Referrer's mem — `from_mem` in the propagated edge's
35 /// actual direction.
36 pub from_mem: String,
37 /// Renaming entity's mem — `to_mem` in the propagated edge's
38 /// actual direction. Always the same value across every
39 /// `blocked_referrers` entry of a single rename refusal.
40 pub to_mem: String,
41 /// Distinct referrers in `from_mem` that would emit the
42 /// blocked rewrite.
43 pub count: usize,
44}
45
46impl fmt::Display for BlockedReferrer {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 write!(
49 f,
50 "{} → {} ({} referrer{})",
51 self.from_mem,
52 self.to_mem,
53 self.count,
54 if self.count == 1 { "" } else { "s" }
55 )
56 }
57}
58
59fn format_blocked_referrers(items: &[BlockedReferrer]) -> String {
60 format_inline_list_overflow(items, "blocked_referrers")
61}
62
63/// Occupant rendering for [`EngineError::AlreadyExists`]: a real
64/// entity renders its quoted title; a stub renders as "a stub" (with
65/// its title when it has one — a titleless stub must never render as
66/// an empty title).
67fn render_occupant(existing_title: &str, existing_is_stub: bool) -> String {
68 match (existing_is_stub, existing_title.is_empty()) {
69 (true, true) => "a stub".to_string(),
70 (true, false) => format!("a stub titled '{existing_title}'"),
71 (false, _) => format!("'{existing_title}'"),
72 }
73}
74
75/// Render a structured-list payload onto the text-mirror message. The
76/// first [`INLINE_LIST_CAP`] items appear inline, comma-separated; when
77/// the list is longer, the suffix " +N more — see details.<field>"
78/// points the agent at the structured channel's typed list under
79/// `field`. Empty input renders as an empty string. The function is
80/// generic over any [`fmt::Display`] item — wrap structs in a small
81/// `Display` newtype if their default rendering is too verbose for the
82/// text channel.
83pub fn format_inline_list_overflow<T: fmt::Display>(items: &[T], field: &str) -> String {
84 if items.is_empty() {
85 return String::new();
86 }
87 let head: Vec<String> = items
88 .iter()
89 .take(INLINE_LIST_CAP)
90 .map(|i| i.to_string())
91 .collect();
92 let inline = head.join(", ");
93 if items.len() > INLINE_LIST_CAP {
94 let extra = items.len() - INLINE_LIST_CAP;
95 format!("{inline} +{extra} more — see details.{field}")
96 } else {
97 inline
98 }
99}
100
101/// One resolution-source line on [`EngineError::SchemaNotFound`]'s
102/// `details.sources` payload.
103///
104/// The schema registry consults sources in a fixed order — local
105/// storage (the mem's own storage backend), built-in (compiled into
106/// the engine binary), remote (memstead.io, reserved) — and records
107/// what each held for the pinned *name* so an agent or operator can
108/// tell *where* a pin failed: missing from local authoring, absent
109/// from the shipped catalogue, or past the not-yet-wired remote. The
110/// `local_storage`/`builtin` lines report a wrong-version partial
111/// match (right name, wrong version) through `pinned_version_match`.
112#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
113pub struct SchemaSourceDiagnostic {
114 /// Stable source label: `"local_storage"`, `"builtin"`, or
115 /// `"remote"`. Agents may branch on it.
116 pub source: &'static str,
117 /// Versions of the pinned *name* this source held, ascending.
118 /// Empty when the source carried nothing for that name — or was
119 /// not enumerated (today only `remote`, see `status`).
120 pub versions_found: Vec<String>,
121 /// `true` when the pinned exact version is among `versions_found`.
122 /// Always `false` across every source on a genuine not-found (the
123 /// fixed resolution order means a match on any source would have
124 /// resolved); a lone `true` here signals right-name/wrong-version.
125 pub pinned_version_match: bool,
126 /// Non-enumerable status for sources that do not list versions —
127 /// today only `remote`, which reports `"not_configured"`. `None`
128 /// for the enumerable `local_storage`/`builtin` sources.
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub status: Option<&'static str>,
131}
132
133impl SchemaSourceDiagnostic {
134 /// Build the fixed-order source diagnostics for a failed pin.
135 ///
136 /// `consulted` is the resolution set the call site actually
137 /// searched: at boot it is the workspace-authored schemas layered
138 /// over the built-ins; at the create/migration sites it is the
139 /// set that path consulted (built-in alone, or workspace + built-in
140 /// for the migration resolver). The `builtin` line is recomputed
141 /// from the static catalogue so it is honest regardless of what the
142 /// caller passed; anything in `consulted` the built-in set does not
143 /// carry is attributed to `local_storage`. `remote` is always the
144 /// reserved `not_configured` slot.
145 pub fn for_failed_pin(
146 name: &str,
147 requested: &semver::Version,
148 consulted: &[std::sync::Arc<memstead_schema::Schema>],
149 ) -> Vec<Self> {
150 use std::collections::BTreeSet;
151 let builtin: BTreeSet<semver::Version> = memstead_schema::builtins::load_builtin_schemas()
152 .map(|set| {
153 set.iter()
154 .filter(|s| s.manifest.name == name)
155 .map(|s| s.version.clone())
156 .collect()
157 })
158 .unwrap_or_default();
159 let local: BTreeSet<semver::Version> = consulted
160 .iter()
161 .filter(|s| s.manifest.name == name)
162 .map(|s| s.version.clone())
163 .filter(|v| !builtin.contains(v))
164 .collect();
165 let to_strings =
166 |set: &BTreeSet<semver::Version>| set.iter().map(|v| v.to_string()).collect::<Vec<_>>();
167 vec![
168 Self {
169 source: "local_storage",
170 pinned_version_match: local.contains(requested),
171 versions_found: to_strings(&local),
172 status: None,
173 },
174 Self {
175 source: "builtin",
176 pinned_version_match: builtin.contains(requested),
177 versions_found: to_strings(&builtin),
178 status: None,
179 },
180 Self {
181 source: "remote",
182 versions_found: Vec::new(),
183 pinned_version_match: false,
184 status: Some("not_configured"),
185 },
186 ]
187 }
188}
189
190/// Errors surfaced by [`Engine`].
191///
192/// `Backend` lifts [`BackendError`] verbatim through a `#[from]`
193/// conversion so the engine layer's error envelope preserves the
194/// backend's typed `Sealed` / `HashMismatch` payloads. The MCP layer
195/// branches on the discriminant when mapping into the typed `code`
196/// field of its error envelope.
197#[derive(Debug, thiserror::Error)]
198pub enum EngineError {
199 /// `Engine::from_mounts` received two mounts naming the same
200 /// mem. Configuration error: the persistence adapter or
201 /// caller produced a malformed mount list.
202 #[error("duplicate mem in mount list: {0}")]
203 DuplicateMem(String),
204 /// No mount in this engine names the requested mem. Surfaced
205 /// before reaching any backend so callers can distinguish
206 /// "wrong mem name" from "backend failure".
207 #[error("unknown mem: {0}")]
208 UnknownMem(String),
209 /// The mem exists in the workspace but failed its mem-level boot
210 /// step and is quarantined — it serves nothing until repaired
211 /// (degrade, never disappear; quarantine is not tolerance).
212 /// `reason_message` is the underlying typed failure verbatim, its
213 /// final clause the repair command; after the repair,
214 /// `memstead_reload` re-attaches the mem without a restart.
215 #[error(
216 "mem '{mem}' is quarantined — it failed to attach at boot and serves nothing until \
217 repaired: [{reason_code}] {reason_message}. After repairing, run memstead_reload \
218 (or `memstead reload`) to bring it back into service."
219 )]
220 MemQuarantined {
221 mem: String,
222 reason_code: String,
223 reason_message: String,
224 },
225 /// Mutation rejected because the mount declares
226 /// [`MountCapability::ReadOnly`]. Surfaced before reaching the
227 /// backend so the typed `Sealed` payload from the archive
228 /// backend never triggers — capability gating runs first.
229 #[error("mem {0} is mounted read-only; mutations rejected")]
230 ReadOnlyMount(String),
231 /// A check could not be persisted — either the engine has no
232 /// workspace root (in-memory engines have no durable check
233 /// store) or the ledger append failed. A check the caller
234 /// believes recorded but was not is worse than a refusal, so
235 /// recording is never best-effort.
236 #[error("check not recorded: {reason}")]
237 CheckNotRecorded { reason: String },
238 /// Entity type is not declared in the pinned schema for this
239 /// mem. Carries the declared types (sorted) and a fuzzy
240 /// suggestion so the agent can recover without re-reading the
241 /// schema. `schema_ref` is the pinned `<name>@<version>`.
242 #[error(
243 "unknown entity type '{name}' in schema '{schema_ref}'. Declared types: [{}]{}",
244 declared.join(", "),
245 suggestion.as_deref().map(|s| format!(". Did you mean '{s}'?")).unwrap_or_default()
246 )]
247 UnknownType {
248 name: String,
249 schema_ref: String,
250 declared: Vec<String>,
251 suggestion: Option<String>,
252 },
253 /// Title slug is empty / invalid.
254 #[error("title is invalid: {0}")]
255 InvalidTitle(#[from] SlugError),
256 /// Create attempted against an id already present in the store.
257 /// Names the occupant's title — distinct titles can derive the
258 /// same slug, so the id alone does not tell the caller which
259 /// entity holds it. `existing_is_stub` marks a stub occupant
260 /// (reachable via rename; the create path adopts stubs instead
261 /// of refusing).
262 #[error(
263 "entity already exists: {id} — occupied by {}",
264 render_occupant(existing_title, *existing_is_stub)
265 )]
266 AlreadyExists {
267 id: String,
268 existing_title: String,
269 existing_is_stub: bool,
270 },
271 /// Write refused: the entity as written would violate a
272 /// block-tier declared constraint of its type (`severity: block`
273 /// in the schema's `constraints`). Warn-tier violations warn
274 /// instead (`WarningHint::ConstraintUnsatisfied`) — same
275 /// evaluation, tier decided by the declaration. `violations`
276 /// restates each violated declaration so the caller can repair
277 /// without re-fetching the schema.
278 #[error(
279 "write refused: {entity_id} ({entity_type}) violates {n} block-tier declared constraint(s) — first: {first}",
280 n = violations.len(),
281 first = violations.first().map(|v| v.describe()).unwrap_or_default(),
282 )]
283 ConstraintUnsatisfied {
284 entity_type: String,
285 entity_id: String,
286 violations: Vec<crate::ops::health::UnsatisfiedConstraint>,
287 },
288 /// Write refused: a section body violates its schema-declared
289 /// markdown format (`content` / `item_pattern` / `table` on the
290 /// section, `format_severity: block`). The code and recovery
291 /// payload come from the violation itself
292 /// (`SECTION_CONTENT_MISMATCH` / `SECTION_ITEM_PATTERN_MISMATCH`
293 /// / `INVALID_TABLE_COLUMNS`, or `SECTION_CONTENT_INVALID` for a
294 /// reserved setext heading); the payload echoes the declared
295 /// `example` where one exists — for an agent, a conforming
296 /// example outperforms any grammar string.
297 #[error("write refused: {entity_id} ({entity_type}) — {}", violation.describe())]
298 SectionFormatRefused {
299 entity_type: String,
300 entity_id: String,
301 violation: crate::section_format::SectionFormatViolation,
302 },
303 /// Write refused: the entity's final edge set leaves a
304 /// block-tier `required_outgoing` block unsatisfied
305 /// (`severity: block` on the block). The default warn tier keeps
306 /// the long-standing warning behavior; this refusal exists only
307 /// where a schema explicitly promoted the block. Shares the
308 /// `MISSING_REQUIRED_OUTGOING` code and `missing` payload shape
309 /// with the warning — one condition, one vocabulary, tier decided
310 /// by the declaration.
311 #[error(
312 "write refused: {entity_id} ({entity_type}) leaves {n} block-tier `required_outgoing` block(s) unsatisfied",
313 n = missing.len(),
314 )]
315 RequiredOutgoingUnsatisfied {
316 entity_type: String,
317 entity_id: String,
318 missing: Vec<crate::ops::MissingRequiredOutgoingBlock>,
319 },
320 /// Mutation rejected because the named entity is not in the
321 /// store. Distinct from `UnknownMem`: the mem exists, the
322 /// entity does not.
323 #[error("entity not found: {id}")]
324 NotFound { id: String },
325 /// Optimistic-locking failure: the caller's `expected_hash` does
326 /// not match the entity's current `content_hash` in the store.
327 /// `current` is the live hash — pass it as `expected_hash` after
328 /// re-reading to retry. `is_stub` is set when the entity is a
329 /// stub (no body, no content_hash); the corrective action is to
330 /// pass `expected_hash: ""` rather than re-read via `memstead_entity`.
331 /// Surfaces on `details.is_stub` so MCP callers branch on the
332 /// structured payload instead of parsing the message text — pre-fix
333 /// the wire emitted `(current: )` with an empty paren that
334 /// misdirected toward hash-recovery for a stub-shaped entity.
335 #[error("{}", _hash_mismatch_msg(id, current, *is_stub))]
336 HashMismatch {
337 id: String,
338 current: String,
339 is_stub: bool,
340 },
341 /// Refusal to delete or rename an entity because other entities
342 /// in **Write-Mems** still reference it. There is no force flag
343 /// or escape hatch — the agent removes the offending references
344 /// (via `memstead_relate --remove` or `memstead_update`) before retrying.
345 /// `referrers` carries the typed referrer info (source id,
346 /// rel-type, source mem) so the response payload describes the
347 /// full surface in one round-trip. ReadOnly-mount referrers are
348 /// excluded from this list — they are handled by the residual-
349 /// stub demotion path on the destructive mutation.
350 #[error(
351 "entity {id} has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
352 n = referrers.len(),
353 inline = format_inline_list_overflow(referrers, "referrers"),
354 )]
355 HasIncomingRefs {
356 id: String,
357 referrers: Vec<ReferrerInfo>,
358 },
359 /// Refusal to delete a mem because entities in other Write-Mems
360 /// still reference entities inside it. Mirrors entity-level
361 /// [`Self::HasIncomingRefs`] at the mem granularity — the
362 /// edge-graph axis (F15 / CLI F8). Revoking a workspace-level grant only closes
363 /// the policy axis; this check closes the actual-edge axis so a
364 /// mem delete that would orphan cross-mem edges refuses with
365 /// the typed envelope listing every offending `(from_id, rel_type,
366 /// source_mem)` triple. No force flag — the operator must
367 /// `memstead_relate --remove` (or `memstead_update` to drop the section)
368 /// on each referrer first, then retry. ReadOnly-mount referrers
369 /// stay out of this list and route through the residual-stub
370 /// demotion path on the destructive mutation, same posture as the
371 /// entity-level variant.
372 #[error(
373 "mem `{mem}` has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
374 n = referrers.len(),
375 inline = format_inline_list_overflow(referrers, "referrers"),
376 )]
377 MemHasIncomingRefs {
378 mem: String,
379 referrers: Vec<ReferrerInfo>,
380 },
381 /// Relate across mems rejected because the workspace's
382 /// `[cross_mem_links]` policy (or the per-create-rule
383 /// `default_cross_links` synthesis) does not permit `from_mem →
384 /// to_mem`. Agents adjust the policy or pick a same-mem
385 /// target. The hint points at the workspace `[cross_mem_links]`
386 /// section.
387 #[error(
388 "cross-mem link from mem `{from_mem}` to mem `{to_mem}` is not allowed by the workspace `[cross_mem_links]` policy"
389 )]
390 CrossMemLinkNotAllowed { from_mem: String, to_mem: String },
391 /// Any add-shaped cross-mem edge write (`memstead_relate`,
392 /// `memstead_create.relations[]`, `memstead_update.declare_relations`,
393 /// or a body wiki-link) to a target whose mem is mounted
394 /// `MountCapability::ReadOnly` and the target is absent. Auto-stub
395 /// is unavailable across the engine/ReadOnly-mem boundary (the
396 /// engine cannot persist a stub in a mem it has no write access
397 /// to), and a read-only mem never gains the entity later — the
398 /// target must already exist before the link is written.
399 #[error(
400 "cross-mem link target {target_id} is absent in read-only mem `{target_mem}` — auto-stub is unavailable across the read-only boundary; the target must exist before linking"
401 )]
402 CrossMemTargetNotFound {
403 target_id: String,
404 target_mem: String,
405 },
406 /// `memstead_relate` across mems pinning schemas with different
407 /// *names* refused because the source schema's
408 /// `cross_mem_relationships:` section declares no entry for the
409 /// target schema's domain. Each source schema must explicitly
410 /// enumerate outbound cross-mem edges per target domain; the
411 /// absence here means the source schema does not speak the target
412 /// domain's vocabulary. Eligibility is name-based — a declaration
413 /// covers every version of the named target schema. The agent's
414 /// recovery is to declare the rel-type in the source schema's
415 /// `cross_mem_relationships:` section under the target's bare
416 /// schema name (`to_schema: <name>`).
417 ///
418 /// Orthogonal to the `cross_mem_links` permission policy:
419 /// vocabulary and permission fire independently. A policy-admissible
420 /// edge that violates vocabulary surfaces here; a vocabulary-admissible
421 /// edge that violates policy surfaces as
422 /// [`Self::CrossMemLinkNotAllowed`].
423 #[error(
424 "cross-mem edge {rel_type} from `{from_id}` (schema {source_schema}) to `{to_id}` (schema {target_schema}) is not declared in {source_schema}'s `cross_mem_relationships:` section"
425 )]
426 CrossMemEdgeNotDeclared {
427 source_schema: String,
428 target_schema: String,
429 rel_type: String,
430 from_id: String,
431 to_id: String,
432 },
433 /// `memstead_update` received repair-shaped input (`relations_unset`)
434 /// for an entity that currently passes the conformance check.
435 /// Repair-powers gate on evidence — a conformance failure on the
436 /// target entity — and a conformant entity has the focused tools
437 /// instead: `memstead_relate(remove)` detaches an edge, the additive
438 /// `memstead_update` params evolve content. The entity is not
439 /// modified.
440 #[error(
441 "repair input refused for {id}: the entity currently passes the conformance check — {recovery}"
442 )]
443 RepairNotNeeded { id: String, recovery: String },
444 /// Rename where the new title would slugify to the existing id.
445 /// Surfaced as a typed no-op so callers don't loop on a degenerate
446 /// retry.
447 #[error(
448 "rename would not change the id of {id} — new title {new_title:?} produces the same slug"
449 )]
450 RenameNoOp { id: String, new_title: String },
451 /// `memstead_update` / `memstead_batch_update` payload parsed cleanly but
452 /// carries no recognised mutation content — every mutation map is
453 /// empty and no relations are declared. Distinct from
454 /// `UPDATE_NOOP` (a warning that fires when mutation content was
455 /// provided but matched the current state): `EMPTY_UPDATE` is
456 /// keyed on "no mutation content provided at all", and refuses
457 /// before any mutation work runs so a misspelled/omitted mutation
458 /// key doesn't silently land as `succeeded: 1, commit_sha: ""`.
459 #[error(
460 "no mutation content for {id} — payload carries an id but every mutation map is empty (recognised keys: sections, append_sections, patch_sections, metadata, metadata_unset, declare_relations, relations_unset)"
461 )]
462 EmptyUpdate { id: String },
463 /// `memstead_rename` cannot proceed because one or more cross-mem
464 /// referrers would emit a propagated rewrite whose direction the
465 /// workspace's `cross_mem_links` policy does not permit. The
466 /// engine refuses the rename up-front (before any write); the
467 /// agent's recovery is either to grant the missing direction in
468 /// `[cross_mem_links]` or to drop the offending edges first.
469 ///
470 /// Each `blocked_referrers` entry names a single blocked direction
471 /// (`from_mem → to_mem`) — the referrer's mem and the
472 /// renaming entity's mem, respectively — together with the
473 /// count of distinct referrers in that mem that would emit the
474 /// blocked rewrite. The direction is the edge's actual direction
475 /// post-rewrite (`referrer → renamed`), which is what the policy
476 /// gates.
477 #[error(
478 "rename blocked: cross-mem rewrite from referrer mem(s) into `{from_mem}` is not permitted by `[cross_mem_links]` — blocked: {} — grant the missing direction or rewrite the blocked referrers manually",
479 format_blocked_referrers(blocked_referrers)
480 )]
481 RenameBlockedByCrossMemPolicy {
482 from_mem: String,
483 blocked_referrers: Vec<BlockedReferrer>,
484 },
485 /// `memstead_create` / `memstead_update` / `memstead_batch_update` refused
486 /// because the post-mutation entity's section bodies contain
487 /// inline wiki-links to targets that have no corresponding
488 /// explicit relation in `entity.relationships`. Strict
489 /// wiki-link / relation invariant: every body wiki-link must
490 /// have a backing relation. The agent's recovery is
491 /// `memstead_relate <this-entity> REFERENCES <target>` (or a more
492 /// specific rel-type) for each missing entry, then re-issue
493 /// the mutation. `missing` enumerates each violation as a
494 /// `(section_key, target_id)` pair so the agent can fix every
495 /// surviving link in one pass. This validator is gated behind
496 /// the workspace's reference-coherence migration completion
497 /// marker; workspaces that haven't been migrated continue
498 /// running the permissive auto-stub regime.
499 #[error(
500 "post-mutation body of {from_id} has {n} wiki-link(s) without a backing relation ({inline}) — declare the relation(s) via memstead_relate first (REFERENCES or a more specific rel-type), then retry",
501 n = missing.len(),
502 inline = format_inline_list_overflow(missing, "missing"),
503 )]
504 WikiLinkWithoutRelation {
505 from_id: String,
506 missing: Vec<MissingWikiLink>,
507 },
508 /// `memstead_relate --remove` refused because the source entity's
509 /// section bodies still contain `[[<target>]]` (or
510 /// `[[<mem>:<target>]]`) wiki-links pointing at the relation's
511 /// target. Removing the explicit relation while body links
512 /// survive would violate the strict wiki-link/relation invariant
513 /// (inline links require a backing relation). The agent's
514 /// recovery is `memstead_update <source-id>` with section content
515 /// that drops the wiki-link tokens, then re-issue `memstead_relate
516 /// --remove`. `body_links` enumerates the surviving section keys
517 /// so the agent can patch them in one pass.
518 #[error(
519 "cannot remove {rel_type} {from_id} → {to_id}: source body still contains wiki-link(s) to the target in section(s) {inline} — drop them via memstead_update before removing the relation",
520 inline = format_inline_list_overflow(body_links, "body_links"),
521 )]
522 RelationHasBodyLinks {
523 from_id: String,
524 to_id: String,
525 rel_type: String,
526 body_links: Vec<String>,
527 },
528 /// A multi-mem `memstead_rename` partially landed: at least one
529 /// mem committed successfully, then a subsequent per-mem
530 /// commit aborted (typically because a sibling writer advanced
531 /// the failed mem's head between the rename's snapshot and the
532 /// commit attempt — the parent-ref pin tripped via
533 /// `BackendError::ParentMismatch`). The committed mems' state
534 /// has already landed and is durable; the failed mem's writes
535 /// did not land. The agent's recovery options: retry the rename
536 /// (reload the workspace first so the engine re-derives the right
537 /// referrer set), or accept the partial state and reconcile
538 /// manually via subsequent mutations.
539 #[error(
540 "rename partial-failure: mem `{failed_mem}` aborted with cause {failure_cause:?} after {committed_mems:?} already committed — reload and retry, or reconcile manually"
541 )]
542 RenamePartialFailure {
543 committed_mems: Vec<String>,
544 failed_mem: String,
545 failure_cause: String,
546 },
547 /// `memstead_relate` source is a stub — stubs have no `entity_type`
548 /// and cannot author edges. The agent must promote the stub to a
549 /// real entity via `memstead_create` (stub adoption preserves any
550 /// incoming references) before relating. Pre-fix surfaced as the
551 /// cryptic `UnknownType { name: "" }`.
552 #[error("source entity {id} is a stub — promote it to a real entity via memstead_create first")]
553 StubCannotRelate { id: String },
554 /// `memstead_update` target is a stub — stubs have no body, no
555 /// metadata, no schema-resolved type to validate against. The
556 /// agent must promote the stub to a real entity via `memstead_create`
557 /// (stub adoption preserves any incoming references) before
558 /// updating. Pre-Item-02 surfaced as the cryptic
559 /// `UnknownType { name: "" }` cascade — identical symptom to the
560 /// one `StubCannotRelate` was added to replace on `memstead_relate`.
561 #[error("entity {id} is a stub — promote it to a real entity via memstead_create first")]
562 StubNotUpdatable { id: String },
563 /// `memstead_rename` target is a stub — stubs do not have a title to
564 /// rename (their title is derived from the id). Same recovery
565 /// path as [`Self::StubNotUpdatable`].
566 #[error(
567 "entity {id} is a stub — promote it to a real entity via memstead_create before renaming"
568 )]
569 StubNotRenamable { id: String },
570 /// An `EntityId` reaching a write path (notably `memstead_relate to=`)
571 /// does not match the wiki-link grammar
572 /// (`^[a-z0-9-]+(/[a-z0-9-]+)*$` for the slug; `^[a-z0-9-]+$` for
573 /// the mem). The gate prevents an auto-stub being created at a
574 /// malformed id — once present, that stub would fail any
575 /// downstream wiki-link parse that referenced it.
576 #[error("entity id '{id}' is malformed: {reason}")]
577 InvalidEntityId { id: String, reason: String },
578 /// A body wiki-link target in a section body failed the strict
579 /// slug-form grammar gate. The invariant is that every wiki-link target reaching
580 /// `entity.relationships` carries a grammar-valid `EntityId` — the
581 /// alias-synthesis pass would otherwise emit a relation pointing
582 /// at a literal id (e.g. `mem--Knowledge Graph`) that no
583 /// downstream wiki-link parse could ever resolve. `raw` is the
584 /// input between brackets (after alias / `.md` strip); `suggested`
585 /// is the `title_to_slug`-derived slug-form the agent lifts
586 /// directly into the retry (omitted when the input has no
587 /// meaningful canonical form — empty, all-punctuation, all-emoji);
588 /// `section` is the section key whose body carried the link;
589 /// `source` is a stable discriminator (`"body_link"`) future-
590 /// proofed against additional ingress surfaces.
591 #[error("body wiki-link target '{raw}' in section '{section}' is not slug-form: {reason}")]
592 InvalidWikiLinkTarget {
593 raw: String,
594 suggested: Option<String>,
595 section: String,
596 link_source: String,
597 reason: String,
598 },
599 /// A body wiki-link's Tier-2 mem prefix `[[mem:slug]]` failed
600 /// the mem-name grammar (`^[a-z0-9-]+(/[a-z0-9-]+)*$`). Distinct
601 /// from `InvalidWikiLinkTarget` because the recovery is different
602 /// — mem names are fixed identifiers in the workspace, not
603 /// free-form text the agent can mechanically slugify; the agent
604 /// correlates the bad prefix against the workspace's known mems
605 /// rather than reaching for `title_to_slug`.
606 #[error(
607 "body wiki-link mem prefix '{raw}' in section '{section}' is not a valid mem name: {reason}"
608 )]
609 InvalidWikiLinkMem {
610 raw: String,
611 section: String,
612 reason: String,
613 },
614 /// `memstead_update` was asked to apply more than one section-
615 /// mutation mode (`sections`, `append_sections`,
616 /// `patch_sections`) to the same key. The request is ambiguous
617 /// and rejected before any disk write. `modes` lists the
618 /// conflicting modes for the key in canonical order.
619 #[error("conflicting section modes for {section}: {modes:?}")]
620 ConflictingSectionModes { section: String, modes: Vec<String> },
621 /// Adding the proposed edge would close a cycle in an
622 /// acyclic-declared subgraph. Carries the existing back-path
623 /// `[from, …, current, target's intermediates, … from]` so MCP
624 /// envelopes ship the cycle's shape without a follow-up
625 /// `memstead_search`. Truncated at
626 /// [`RELATIONSHIP_CYCLE_PATH_CAP`] entries.
627 #[error(
628 "creating edge {rel_type} from '{from}' to '{to}' would close a cycle in the {rel_type} subgraph"
629 )]
630 RelationshipCycle {
631 rel_type: String,
632 from: EntityId,
633 to: EntityId,
634 existing_path: Vec<EntityId>,
635 path_truncated: bool,
636 },
637 /// `memstead_update` received the same metadata key in both `metadata`
638 /// (set) and `metadata_unset` lists. The request is ambiguous and
639 /// rejected before any disk write — the caller picks which map the
640 /// key belongs in. `keys` lists every overlapping key in alphabetical
641 /// order so a single envelope describes the full conflict.
642 #[error("metadata keys appear in both set and unset: {keys:?}")]
643 SetAndUnsetConflict { keys: Vec<String> },
644 /// `metadata_unset` targeted a required field. Carries the
645 /// recovery payload so the agent reads the field's purpose,
646 /// allowed values, and type-level write rules from the envelope
647 /// rather than re-fetching the schema.
648 ///
649 /// Also fires from `memstead_create` when the
650 /// caller did not supply a required metadata field that the
651 /// schema does not auto-fill (`default_value` / `init_timestamp`
652 /// / `auto_timestamp` all absent). Pre-fix the create path
653 /// surfaced this as a `MISSING_REQUIRED_FIELD` warning and let
654 /// the entity land with a placeholder — silently corrupted the
655 /// export-then-install round-trip when the placeholder was
656 /// invalid for the install-time strict validator. The refusal
657 /// fires once per call on the first missing field (declaration
658 /// order); subsequent fields surface on the next attempt.
659 #[error("{}", _required_field_unset_msg(field, entity_type, *on_create))]
660 RequiredFieldUnset {
661 field: String,
662 entity_type: String,
663 /// Schema-supplied description of the field.
664 field_description: Option<String>,
665 /// Allowed enum values when the unset field is enum-typed;
666 /// empty when the field is free-form.
667 enum_values: Vec<String>,
668 /// Type-level `write_rules` for the entity type.
669 type_write_rules: Vec<String>,
670 /// Path discriminator: `true` when the
671 /// create path constructed the variant (caller didn't supply
672 /// the field), `false` when the update path constructed it
673 /// (caller passed `metadata_unset: ["field"]` against a
674 /// required field). The typed code stays `REQUIRED_FIELD_UNSET`
675 /// on both paths; only the rendered prose differs.
676 ///
677 /// Not exposed on the `details` payload — agents already
678 /// branch on the typed code; the new field is for the prose
679 /// dispatch only.
680 on_create: bool,
681 /// Multi-field
682 /// accumulator on the create path. Every required-no-default
683 /// field that was unset, in schema declaration order. Empty
684 /// on the unset path (where the agent targets one field by
685 /// definition and the singular fields above are authoritative);
686 /// always non-empty (and at least a singleton echo of the
687 /// singular fields) on the create path.
688 ///
689 /// Surfaces on `details.missing[]` so an agent fixes every
690 /// missing field in one round-trip. `details.field` and
691 /// `details.missing[0].field` agree on the first-missing
692 /// entry, keeping the back-compat singular-field shape.
693 missing: Vec<MissingRequiredField>,
694 },
695 /// `memstead_create`: one or more required sections for the entity's
696 /// type were absent or whitespace-only in the request. Pre-fix
697 /// the create path surfaced this as `MISSING_REQUIRED_SECTION`
698 /// warnings and wrote the entity with empty placeholders for
699 /// the missing sections; the resulting on-disk state failed the
700 /// install-time strict validator, breaking the export-then-
701 /// install round-trip. The refusal carries every missing section
702 /// (one entry per affected key) plus the type-level `type_guidance`
703 /// map so the agent has a single round-trip recovery via re-call
704 /// with the missing content filled in.
705 ///
706 /// Loader / health / `memstead_update` paths keep their permissive
707 /// posture — a legacy on-disk entity created when this gate was
708 /// a warning continues to load, surface in health, and accept
709 /// partial updates. The refusal is a write-boundary gate, not a
710 /// global invariant.
711 #[error("missing {missing_count} required section(s) for type '{entity_type}'")]
712 MissingRequiredSection {
713 entity_type: String,
714 /// Echoed for diagnostics; equals `sections.len()`.
715 missing_count: usize,
716 /// One entry per missing required section, in schema
717 /// declaration order. Each entry mirrors the shape of the
718 /// pre-fix `WarningHint::MissingRequiredSection` warning so
719 /// agents reading the recovery payload don't branch on
720 /// surface (refusal vs warning).
721 sections: Vec<crate::runtime_validator::MissingRequiredSection>,
722 /// Type-level `write_rules` keyed by `entity_type`. Map shape
723 /// matches the mutation-response top-level `type_guidance`
724 /// the warning-surface ships so a single decoder reads
725 /// guidance from either path.
726 type_guidance: std::collections::BTreeMap<String, Vec<String>>,
727 },
728 /// `patch_sections` targeted a key whose section body is
729 /// absent from the entity (or has never been authored).
730 #[error("patch target section is empty: {section}")]
731 PatchSectionEmpty { section: String },
732 /// `patch_sections` provided an `old` substring that does not
733 /// appear in the section's current body. Carries a truncated
734 /// snapshot of the current content so the caller can surface
735 /// the actual state to the operator.
736 #[error("patch `old` substring not found in {section}")]
737 PatchOldNotFound {
738 section: String,
739 current_content: String,
740 truncated: bool,
741 },
742 /// Schema-strictness rejection from the runtime validator
743 /// (`UNKNOWN_SECTION`, `UNKNOWN_METADATA`, `INVALID_ENUM_VALUE`).
744 #[error("schema validation: {0}")]
745 Validation(#[from] ValidationError),
746 /// Re-parse of the freshly-generated markdown failed. Should
747 /// never happen — the generator's contract is that its output
748 /// round-trips through `parse_markdown`. Surfaces if a future
749 /// generator change breaks that invariant.
750 #[error("parse-after-write failed: {0}")]
751 ParseAfterWrite(String),
752 /// A wrapped parse error for completeness; today only the
753 /// parse-after-write variant above is constructed in the create
754 /// path.
755 #[error("parse error: {0}")]
756 Parse(#[from] ParseError),
757 /// A backend operation failed. Inner error carries the typed
758 /// payload (e.g. `Sealed`, `HashMismatch`, `Io`).
759 #[error(transparent)]
760 Backend(#[from] BackendError),
761 /// A mem's schema pin did not resolve. `sources` carries the
762 /// fixed-order resolution diagnostics (local storage / built-in /
763 /// remote) so the caller can tell *where* the pin failed and spot a
764 /// right-name/wrong-version partial match; it surfaces under
765 /// `details.sources`. Empty `sources` marks an internal lookup miss
766 /// (an already-resolved schema absent from the engine's per-mem
767 /// map), not a genuine source-resolution failure.
768 ///
769 /// The MESSAGE summarises the trail — which sources were searched
770 /// and whether the name was found at other versions — so the
771 /// distinction between a wrong-version pin and a never-installed
772 /// package reaches consumers that never open `details` (a reported
773 /// autonomous loop burned five rounds on the payload-only shape).
774 /// `install_hint` (set by [`EngineError::with_schema_install_probe`]
775 /// where a workspace root is known) names the authoring package
776 /// that exists in the working tree but was never installed, and
777 /// the message then points at `memstead schema install`.
778 #[error("{}", schema_not_found_message(mem, pin, sources, install_hint))]
779 SchemaNotFound {
780 mem: String,
781 pin: String,
782 sources: Vec<SchemaSourceDiagnostic>,
783 /// Path to an authoring package in the working tree whose
784 /// manifest name matches the pin's name while NO source holds
785 /// any version of that name — i.e. the package was authored
786 /// but never installed. `None` when no such package exists,
787 /// when the name is installed at other versions (a version
788 /// mismatch is a different fix), or when no workspace root
789 /// was available to probe.
790 install_hint: Option<String>,
791 },
792 /// A sealed schema package carried inside a mem archive could not
793 /// be loaded — the archive's own `.memstead/schema/` tree is
794 /// broken. Deliberately NOT `SchemaNotFound`: the package is right
795 /// here, so the recovery is never "obtain the schema and install
796 /// it". The message quotes the loader's own diagnosis and the
797 /// refusal leaves nothing mounted and nothing staged; only the
798 /// publisher can fix it.
799 #[error(
800 "mem {mem}: the schema {pin} embedded in the archive could not be loaded: {reason} — \
801 the package is inside the archive, so this is the publisher's to fix; nothing was \
802 staged or mounted"
803 )]
804 EmbeddedSchemaInvalid {
805 mem: String,
806 pin: String,
807 reason: String,
808 },
809 /// A schema package handed to `install_schema` failed validation —
810 /// the loader's semantic checks or the section-heading round-trip
811 /// gate. The engine refuses to seal an invalid schema onto
812 /// `__MEMSTEAD`: install time is the last moment the author can
813 /// act, because a schema already sealed keeps loading even when a
814 /// later rule would refuse it.
815 #[error("schema package '{name}@{version}' failed validation: {message}")]
816 SchemaPackageInvalid {
817 name: String,
818 version: String,
819 message: String,
820 },
821 /// `memstead_schema::builtins::load_builtin_schemas` itself failed.
822 /// Surfaces during `Engine::from_mounts`; should never trip in
823 /// practice (the built-in catalogue is statically embedded), but
824 /// the failure path is preserved so a future on-disk catalogue
825 /// switch lifts cleanly.
826 #[error("built-in schema catalogue failed to load: {0}")]
827 SchemaResolverInit(String),
828 /// Generic mem-level error message — used by accessors that
829 /// surface "mem exists, but the requested resource is not
830 /// available for this backend" (e.g. `gitdir_for` against a
831 /// folder mount, `worktree_for` against a git-branch mount).
832 #[error("mem error: {0}")]
833 Mem(String),
834 /// `register_writable_mem` rejected because `name` is already
835 /// registered (writable OR read-only). `source_origin` is the
836 /// human-readable description of the colliding registration,
837 /// rendered via [`MemOrigin::render_source`] for writable
838 /// entries or a stand-in for read-only ones.
839 #[error("mem name collision: {name} is already registered ({source_origin})")]
840 MemNameCollision { name: String, source_origin: String },
841 /// Lifecycle orchestrator rejected the input. Carries a single
842 /// free-form message — the orchestrator's typed payload (note
843 /// length, malformed path, etc.) is the message text.
844 #[error("invalid input: {0}")]
845 InvalidInput(String),
846 /// `memstead_fetch` / `memstead_pull` / `memstead_push` named a remote that is
847 /// not configured on the workspace's mem-repo. Typed code
848 /// `UNKNOWN_REMOTE`. Recovery: configure the remote via
849 /// `memstead mem-repo remote-add <name> <url>`.
850 #[error("unknown remote: {0}")]
851 UnknownRemote(String),
852 /// `memstead_pull` refused because the local branch has diverged from
853 /// the remote-tracking ref — fast-forward is impossible without
854 /// losing local commits. Recovery: run `memstead branch-reset` to the
855 /// remote-tracking ref (if the local commits are dispensable) or
856 /// run a replay workflow to rewrite them onto the new remote tip.
857 /// Typed code `LOCAL_DIVERGENCE`.
858 #[error(
859 "mem `{mem}`'s local branch has diverged from `{remote_ref}` — pull cannot fast-forward without losing local commits; rebase / replay first or run memstead branch-reset"
860 )]
861 LocalDivergence { mem: String, remote_ref: String },
862 /// `memstead_push` refused because the push would not be a fast-forward
863 /// against the remote and the caller did not pass `force: true`.
864 /// Typed code `NON_FAST_FORWARD`. Recovery: re-fetch + replay, or
865 /// re-issue with `force: true` (warning: rewrites the remote's
866 /// view of the branch — other peers will see the rewrite).
867 #[error(
868 "push to remote `{remote}` for mem `{mem}` is not a fast-forward; rebase / replay locally or pass `force: true` to overwrite the remote"
869 )]
870 NonFastForward { mem: String, remote: String },
871 /// `memstead_push` refused because the local state failed pre-push
872 /// schema validation. The remote was not contacted. Recovery: fix
873 /// the schema violations (use `memstead_health` to find them) and
874 /// retry. Typed code `LOCAL_INVALID_STATE`.
875 #[error(
876 "mem `{mem}` failed pre-push schema validation; remote `{remote}` was not contacted: {detail}"
877 )]
878 LocalInvalidState {
879 mem: String,
880 remote: String,
881 detail: String,
882 },
883 /// `memstead_pull` (or any future merge path that consumes fetched
884 /// commits) refused because the prospective post-merge tree
885 /// contains entities that fail schema validation. The branch
886 /// pointer was not moved. `violations` carries one entry per
887 /// offending entity — typically `(relative_path, parse_error)`
888 /// pairs rendered as strings — so the caller can surface the
889 /// remediation surface without re-walking the tree. Typed code
890 /// `SCHEMA_VIOLATION_IN_FETCH`.
891 #[error(
892 "mem `{mem}` would fail schema validation at `{ref_name}` — {n} violation(s); fix the remote or replay locally first",
893 n = violations.len(),
894 )]
895 SchemaViolationInFetch {
896 mem: String,
897 ref_name: String,
898 violations: Vec<String>,
899 },
900 /// `memstead_branch_reset` refused because at least one commit that
901 /// would be discarded by the reset is already reachable from a
902 /// `refs/remotes/*` ref (the engine's definition of "pushed").
903 /// `pushed_shas` lists the offending commits. The agent's
904 /// recovery is to pick a target SHA that does not strand a pushed
905 /// commit, or to push the pre-reset state under a different
906 /// branch name first. Typed code: `PUSHED_COMMITS_PROTECTED`.
907 #[error(
908 "branch_reset refused: {} pushed commit(s) would be discarded ({}); pick a target that preserves the pushed segment or push the pre-reset state under a different branch first",
909 pushed_shas.len(),
910 pushed_shas.join(", "),
911 )]
912 PushedCommitsProtected {
913 mem: String,
914 target_sha: String,
915 pushed_shas: Vec<String>,
916 },
917 /// `branch_reset` refused because the live branch head no longer
918 /// matches the head the caller observed (`expected_head`) — a
919 /// sibling writer advanced the mem, and resetting now would discard
920 /// that foreign work. Optimistic concurrency for history rewrites;
921 /// the caller re-reads and re-decides. Typed code:
922 /// `BRANCH_RESET_HEAD_MOVED`.
923 #[error(
924 "branch_reset refused: '{mem}' has advanced past the observed head (expected {expected}, live {current}) — the span now contains foreign commits; reload and review the accumulated delta instead"
925 )]
926 BranchResetHeadMoved {
927 mem: String,
928 expected: String,
929 current: String,
930 },
931 /// `memstead_diff` (or any future ref-comparing op) received a ref
932 /// that does not resolve against the workspace's mem-repo.
933 /// Carries the ref string verbatim so the caller can fix the
934 /// input. Typed code `UNKNOWN_REF`.
935 #[error("unknown ref: {0}")]
936 UnknownRef(String),
937 /// `memstead_changes_since` received a `rename_similarity` value
938 /// outside the allowed range. Maps to wire code `INVALID_INPUT`
939 /// with `details.allowed_range: [min, max]` and
940 /// `details.requested`. Promoted from the prior silent-clamp + LIMIT_CLAMPED warning so
941 /// nonsense inputs surface as recoverable refusal rather than
942 /// silent rounding.
943 #[error("rename_similarity {requested} outside allowed range [{allowed_min}, {allowed_max}]")]
944 RenameSimilarityOutOfRange {
945 requested: f32,
946 allowed_min: f32,
947 allowed_max: f32,
948 },
949 /// `memstead_changes_since` / `memstead changes --since` was given a `since`
950 /// commit cursor the mem's git repository can't resolve — a
951 /// malformed prefix or a well-formed-but-absent 40-hex. Surfaces the
952 /// `INVALID_CURSOR` code (the documented contract for this op, which
953 /// the CLI previously leaked as the `MEM_ERROR` catch-all) so a
954 /// sync loop branches cleanly: `INVALID_CURSOR` → re-seed from the
955 /// empty-tree sentinel; `MEM_ERROR` → genuine backend fault.
956 /// `details.since` carries the offending cursor untruncated.
957 #[error(
958 "commit cursor '{since}' is not a known commit in mem '{mem}' — pass a commit_sha from a prior mutation, or the empty-tree sentinel to re-seed"
959 )]
960 InvalidChangesCursor { mem: String, since: String },
961 /// `review_mark_diff` was called on a mem with no review mark set.
962 /// Marklessness is a first-class, known-from-the-roster state — the
963 /// diff surface refuses typed rather than silently equating "no
964 /// mark" with "no changes".
965 #[error(
966 "mem '{mem}' has no review mark — set one first, or read the full history via changes_since"
967 )]
968 ReviewMarkNotSet { mem: String },
969 /// Mem config is missing a required field that the engine
970 /// itself would normally populate (today: `version` at mem
971 /// init). Surfaced on the export path — pre-fix this collapsed
972 /// to `INTERNAL` with a misleading `.memstead/config.json` reference
973 /// that doesn't match the mem-repo backend's blob layout.
974 /// Recovery: run `memstead mem set-version <mem> <version>` to
975 /// populate the field, then retry the export. F1.
976 #[error(
977 "mem `{mem}` config is missing required field(s) {missing_fields:?} — \
978 set via `memstead mem set-version {mem} <version>` (e.g. 0.1.0)"
979 )]
980 MemConfigIncomplete {
981 mem: String,
982 missing_fields: Vec<String>,
983 },
984 /// `memstead_relate` (or a `declare_relations` entry) targeted a
985 /// rel-type whose schema declares `per_edge_description:
986 /// required` without supplying a description. Recovery: re-issue
987 /// the call with `--description "<text>"` describing why this
988 /// particular edge exists (the rel-type's name documents the
989 /// kind of edge; the description documents the instance).
990 #[error(
991 "rel-type `{rel_type}` declares `per_edge_description: required` — \
992 {from_id} → {to_id} needs a description; re-issue with \
993 `--description \"<text>\"`."
994 )]
995 MissingRequiredDescription {
996 rel_type: String,
997 from_id: String,
998 to_id: String,
999 },
1000 /// `memstead_relate` (or a `declare_relations` entry) supplied a
1001 /// description for a rel-type whose schema declares
1002 /// `per_edge_description: forbidden`. Recovery: drop the
1003 /// `description` parameter — the rel-type's name describes the
1004 /// edge; per-edge text is not permitted on this rel-type.
1005 #[error(
1006 "rel-type `{rel_type}` declares `per_edge_description: forbidden` — \
1007 {from_id} → {to_id} cannot carry a description; drop the \
1008 `--description` argument."
1009 )]
1010 DescriptionNotPermitted {
1011 rel_type: String,
1012 from_id: String,
1013 to_id: String,
1014 },
1015 /// `memstead_relate` (or a `declare_relations` / `memstead_create`'s
1016 /// inline `relations:` entry) targeted a rel-type whose schema
1017 /// declares `manual_authoring: forbidden`. The rel-type is
1018 /// reserved for engine-emitted synthesis (the body-link →
1019 /// relation alias machinery, typically). Recovery: don't author
1020 /// the relation explicitly; instead author a body wiki-link
1021 /// `[[target]]` in the source's section content, which the
1022 /// engine surfaces as the appropriate alias relation
1023 /// automatically.
1024 #[error(
1025 "rel-type `{rel_type}` declares `manual_authoring: forbidden` — \
1026 {from_id} → {to_id} cannot be authored explicitly; this rel-type \
1027 is reserved for engine-emitted synthesis via the body-link → \
1028 relation alias path. {guidance}"
1029 )]
1030 RelationManualAuthoringForbidden {
1031 rel_type: String,
1032 from_id: String,
1033 to_id: String,
1034 guidance: String,
1035 },
1036 /// Full-text search is unavailable in the current engine build —
1037 /// `Engine::search` is callable on every target so JS / FFI
1038 /// consumers don't need to re-shape their call sites, but `wasm32`
1039 /// builds omit the tantivy index entirely (its native-only
1040 /// transitives — `getrandom 0.2` without `js`, `memmap2`, `rayon`,
1041 /// `zstd-sys` — block WASM compilation). Browser consumers route
1042 /// queries to the bridge's `memstead_search` endpoint. The MCP layer
1043 /// maps this to typed code `SEARCH_UNAVAILABLE_IN_WASM`.
1044 #[error(
1045 "full-text search is unavailable in this engine build (wasm32); \
1046 route search queries to the bridge's memstead_search endpoint"
1047 )]
1048 SearchUnavailable,
1049 /// `memstead export --format markdown --mem-name <V>` was called
1050 /// against a mem whose active backend doesn't support markdown
1051 /// regeneration in place (today: every backend other than
1052 /// `folder`). Pre-fix this collapsed to a silent
1053 /// `ExportResult { written: 0, unchanged: 0 }` masquerading as
1054 /// success. Recovery: use `--format mem` to produce a portable
1055 /// `.mem` archive, which every backend supports.
1056 #[error(
1057 "mem `{mem}` is on backend `{active_backend}`; `memstead export --format markdown` \
1058 is supported only on backends [{}] — use `--format mem` to produce a portable \
1059 `.mem` archive instead",
1060 supported_backends.join(", ")
1061 )]
1062 MarkdownExportUnsupportedBackend {
1063 mem: String,
1064 active_backend: String,
1065 supported_backends: Vec<String>,
1066 },
1067 /// A `memstead_create` / `memstead_update` `anchors[]` element was
1068 /// malformed — an unknown provenance class or grain, a missing artifact
1069 /// reference, a content hash on a class without hash semantics, or a
1070 /// grain the resolving medium's namespace cannot express. The whole
1071 /// mutation refuses and the entity is not written; the wrapped
1072 /// [`crate::anchor::AnchorValidationError`] carries the recovery
1073 /// `details` (offending field, bad value, allowed set). Typed code
1074 /// `INVALID_ANCHOR`.
1075 #[error("invalid anchor: {0}")]
1076 InvalidAnchor(#[from] crate::anchor::AnchorValidationError),
1077}
1078
1079/// Typed payload for a single Write-Mem referrer in
1080/// [`EngineError::HasIncomingRefs`]. Captures the (from_id, rel_types,
1081/// mem) triple the surface envelope projects so consumers can reason
1082/// about the offending edges without a follow-up `memstead_entity` call.
1083/// The mem is always a Write-Mem — ReadOnly referrers are
1084/// partitioned out before this struct is constructed and surfaced via
1085/// the residual-stub warning channel instead.
1086///
1087/// Per-source deduplication: when one source entity has multiple
1088/// edges of different rel-types pointing at the deletion target, the
1089/// engine collapses them into a single `ReferrerInfo` whose
1090/// `rel_types` list carries every edge type. A prior shape
1091/// emitted one entry per edge, making a source-with-N-edges look
1092/// like N distinct referrers in the error message and structured
1093/// payload.
1094#[derive(Debug, Clone, serde::Serialize)]
1095pub struct ReferrerInfo {
1096 pub from_id: String,
1097 pub rel_types: Vec<String>,
1098 pub mem: String,
1099}
1100
1101/// Inline rendering on the text mirror. Single rel-type renders as
1102/// just the referring entity id; multiple rel-types append the
1103/// `×N [REL1, REL2]` annotation so the count and the offending
1104/// edge-types stay visible without parsing the structured payload.
1105impl fmt::Display for ReferrerInfo {
1106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1107 if self.rel_types.len() <= 1 {
1108 f.write_str(&self.from_id)
1109 } else {
1110 write!(
1111 f,
1112 "{} ×{} [{}]",
1113 self.from_id,
1114 self.rel_types.len(),
1115 self.rel_types.join(", ")
1116 )
1117 }
1118 }
1119}
1120
1121/// One body wiki-link that violates the strict wiki-link /
1122/// relation invariant. Surfaces inside
1123/// [`EngineError::WikiLinkWithoutRelation::missing`].
1124#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
1125pub struct MissingWikiLink {
1126 /// Section key of the entity body where the unbacked
1127 /// wiki-link appears.
1128 pub section_key: String,
1129 /// EntityId target of the unbacked wiki-link.
1130 pub target_id: String,
1131}
1132
1133/// Inline rendering pairs the section key with the unbacked target id
1134/// so an agent reading only the text mirror can see both where the link
1135/// lives and what it points at without decoding the structured payload.
1136impl fmt::Display for MissingWikiLink {
1137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1138 write!(f, "{}→{}", self.section_key, self.target_id)
1139 }
1140}
1141
1142impl EngineError {
1143 /// Stable, surface-independent error code token.
1144 ///
1145 /// Each surface (MCP envelope, CLI envelope, UniFFI binding) maps
1146 /// the variant to its wire shape; the code returned here is the
1147 /// canonical name agents key on. Add a new code here when a new
1148 /// variant lands; do not invent ad-hoc strings inside the
1149 /// per-surface mapping.
1150 pub fn code(&self) -> &'static str {
1151 match self {
1152 EngineError::DuplicateMem(_) => "DUPLICATE_MEM",
1153 EngineError::UnknownMem(_) => "UNKNOWN_MEM",
1154 EngineError::MemQuarantined { .. } => "MEM_QUARANTINED",
1155 EngineError::UnknownRef(_) => "UNKNOWN_REF",
1156 EngineError::UnknownRemote(_) => "UNKNOWN_REMOTE",
1157 EngineError::LocalDivergence { .. } => "LOCAL_DIVERGENCE",
1158 EngineError::NonFastForward { .. } => "NON_FAST_FORWARD",
1159 EngineError::LocalInvalidState { .. } => "LOCAL_INVALID_STATE",
1160 EngineError::SchemaViolationInFetch { .. } => "SCHEMA_VIOLATION_IN_FETCH",
1161 EngineError::PushedCommitsProtected { .. } => "PUSHED_COMMITS_PROTECTED",
1162 EngineError::BranchResetHeadMoved { .. } => "BRANCH_RESET_HEAD_MOVED",
1163 EngineError::ReadOnlyMount(_) => "READ_ONLY_MOUNT",
1164 EngineError::CheckNotRecorded { .. } => "CHECK_NOT_RECORDED",
1165 EngineError::UnknownType { .. } => "UNKNOWN_ENTITY_TYPE",
1166 EngineError::InvalidTitle(_) => "INVALID_TITLE",
1167 EngineError::AlreadyExists { .. } => "ENTITY_ALREADY_EXISTS",
1168 EngineError::ConstraintUnsatisfied { .. } => "CONSTRAINT_UNSATISFIED",
1169 EngineError::RequiredOutgoingUnsatisfied { .. } => "MISSING_REQUIRED_OUTGOING",
1170 EngineError::SectionFormatRefused { violation, .. } => violation.code(),
1171 EngineError::NotFound { .. } => "ENTITY_NOT_FOUND",
1172 EngineError::HashMismatch { .. } => "HASH_MISMATCH",
1173 EngineError::HasIncomingRefs { .. } => "HAS_INCOMING_REFS",
1174 EngineError::MemHasIncomingRefs { .. } => "MEM_HAS_INCOMING_REFS",
1175 EngineError::CrossMemLinkNotAllowed { .. } => "CROSS_MEM_LINK_NOT_ALLOWED",
1176 EngineError::CrossMemTargetNotFound { .. } => "CROSS_MEM_TARGET_NOT_FOUND",
1177 EngineError::CrossMemEdgeNotDeclared { .. } => "CROSS_MEM_EDGE_NOT_DECLARED",
1178 EngineError::RepairNotNeeded { .. } => "REPAIR_NOT_NEEDED",
1179 EngineError::RenameNoOp { .. } => "RENAME_NO_OP",
1180 EngineError::EmptyUpdate { .. } => "EMPTY_UPDATE",
1181 EngineError::RenameBlockedByCrossMemPolicy { .. } => {
1182 "RENAME_BLOCKED_BY_CROSS_MEM_POLICY"
1183 }
1184 EngineError::RenamePartialFailure { .. } => "RENAME_PARTIAL_FAILURE",
1185 EngineError::RelationHasBodyLinks { .. } => "RELATION_HAS_BODY_LINKS",
1186 EngineError::WikiLinkWithoutRelation { .. } => "WIKILINK_WITHOUT_RELATION",
1187 EngineError::StubCannotRelate { .. } => "STUB_CANNOT_RELATE",
1188 EngineError::StubNotUpdatable { .. } => "STUB_NOT_UPDATABLE",
1189 EngineError::StubNotRenamable { .. } => "STUB_NOT_RENAMABLE",
1190 EngineError::InvalidEntityId { .. } => "INVALID_ENTITY_ID",
1191 EngineError::InvalidWikiLinkTarget { .. } => "INVALID_WIKI_LINK_TARGET",
1192 EngineError::InvalidWikiLinkMem { .. } => "INVALID_MEM_NAME",
1193 EngineError::ConflictingSectionModes { .. } => "CONFLICTING_SECTION_MODES",
1194 EngineError::RelationshipCycle { .. } => "RELATIONSHIP_CYCLE",
1195 EngineError::SetAndUnsetConflict { .. } => "SET_AND_UNSET_CONFLICT",
1196 EngineError::RequiredFieldUnset { .. } => "REQUIRED_FIELD_UNSET",
1197 EngineError::MissingRequiredSection { .. } => "MISSING_REQUIRED_SECTION",
1198 EngineError::PatchSectionEmpty { .. } => "PATCH_SECTION_EMPTY",
1199 EngineError::PatchOldNotFound { .. } => "PATCH_OLD_NOT_FOUND",
1200 EngineError::Validation(v) => v.code(),
1201 EngineError::ParseAfterWrite(_) => "PARSE_ERROR",
1202 EngineError::Parse(_) => "PARSE_ERROR",
1203 EngineError::Backend(_) => "MEM_ERROR",
1204 EngineError::SchemaNotFound { .. } => "SCHEMA_NOT_FOUND",
1205 EngineError::EmbeddedSchemaInvalid { .. } => "EMBEDDED_SCHEMA_INVALID",
1206 EngineError::SchemaPackageInvalid { .. } => "SCHEMA_VALIDATION_FAILED",
1207 EngineError::SchemaResolverInit(_) => "SCHEMA_RESOLVER_INIT_FAILED",
1208 EngineError::Mem(_) => "MEM_ERROR",
1209 EngineError::MemNameCollision { .. } => "MEM_NAME_COLLISION",
1210 EngineError::InvalidInput(_) => "INVALID_INPUT",
1211 EngineError::RenameSimilarityOutOfRange { .. } => "INVALID_INPUT",
1212 EngineError::InvalidChangesCursor { .. } => "INVALID_CURSOR",
1213 EngineError::ReviewMarkNotSet { .. } => "REVIEW_MARK_NOT_SET",
1214 EngineError::MemConfigIncomplete { .. } => "MEM_CONFIG_INCOMPLETE",
1215 EngineError::MissingRequiredDescription { .. } => "MISSING_REQUIRED_DESCRIPTION",
1216 EngineError::DescriptionNotPermitted { .. } => "DESCRIPTION_NOT_PERMITTED",
1217 EngineError::RelationManualAuthoringForbidden { .. } => {
1218 "RELATION_MANUAL_AUTHORING_FORBIDDEN"
1219 }
1220 EngineError::SearchUnavailable => "SEARCH_UNAVAILABLE_IN_WASM",
1221 EngineError::MarkdownExportUnsupportedBackend { .. } => {
1222 "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND"
1223 }
1224 EngineError::InvalidAnchor(_) => crate::anchor::INVALID_ANCHOR_CODE,
1225 }
1226 }
1227
1228 /// Variant-specific recovery payload, rendered as a structured
1229 /// JSON object that surfaces under `error.details` in MCP /
1230 /// CLI envelopes.
1231 ///
1232 /// Pre-fix the
1233 /// batch-update per-item envelope (`batch_error_envelope`)
1234 /// shipped `{}` for every typed code except `Validation`, while
1235 /// the singleton-call surfaces (`CliError::from_engine_op`,
1236 /// `memstead-mcp`'s `engine_err_unified`) populated structured
1237 /// payloads per-variant. Two envelopes, two details paths —
1238 /// agents' "fix from `details`" recovery loop worked
1239 /// differently in batch vs singleton mode. The centralised
1240 /// helper here gives both surfaces one source of truth.
1241 ///
1242 /// Returns an empty object for variants whose recovery payload
1243 /// is the message text alone (no structured fields beyond
1244 /// `code` + `message`).
1245 pub fn details(&self) -> serde_json::Value {
1246 match self {
1247 EngineError::NotFound { id } => serde_json::json!({ "id": id }),
1248 EngineError::AlreadyExists {
1249 id,
1250 existing_title,
1251 existing_is_stub,
1252 } => serde_json::json!({
1253 "id": id,
1254 "existing_title": existing_title,
1255 "existing_is_stub": existing_is_stub,
1256 }),
1257 EngineError::MemQuarantined {
1258 mem,
1259 reason_code,
1260 reason_message,
1261 } => serde_json::json!({
1262 "mem": mem,
1263 "reason_code": reason_code,
1264 "reason_message": reason_message,
1265 }),
1266 EngineError::ConstraintUnsatisfied {
1267 entity_type,
1268 entity_id,
1269 violations,
1270 } => serde_json::json!({
1271 "entity_type": entity_type,
1272 "entity_id": entity_id,
1273 "violations": violations,
1274 }),
1275 EngineError::RequiredOutgoingUnsatisfied {
1276 entity_type,
1277 entity_id,
1278 missing,
1279 } => serde_json::json!({
1280 "entity_type": entity_type,
1281 "entity_id": entity_id,
1282 "missing": missing,
1283 }),
1284 EngineError::SectionFormatRefused {
1285 entity_type,
1286 entity_id,
1287 violation,
1288 } => {
1289 let mut v = serde_json::to_value(violation).unwrap_or_default();
1290 if let Some(obj) = v.as_object_mut() {
1291 obj.insert("entity_type".into(), serde_json::json!(entity_type));
1292 obj.insert("entity_id".into(), serde_json::json!(entity_id));
1293 }
1294 v
1295 }
1296 EngineError::RepairNotNeeded { id, recovery } => {
1297 serde_json::json!({ "id": id, "recovery": recovery })
1298 }
1299 // Same shape the full MCP singleton envelope ships for
1300 // UNKNOWN_ENTITY_TYPE — keeps the centralised helper (and
1301 // every consumer: batch envelopes, the integrity linter)
1302 // aligned with the wire payload agents already decode.
1303 EngineError::UnknownType {
1304 name,
1305 schema_ref,
1306 declared,
1307 suggestion,
1308 } => serde_json::json!({
1309 "name": name,
1310 "schema_ref": schema_ref,
1311 "declared": declared,
1312 "suggestion": suggestion,
1313 }),
1314 EngineError::HashMismatch {
1315 id,
1316 current,
1317 is_stub,
1318 } => serde_json::json!({
1319 "id": id,
1320 "current": current,
1321 "is_stub": is_stub,
1322 }),
1323 EngineError::HasIncomingRefs { id, referrers } => {
1324 let referrers_json: Vec<_> = referrers
1325 .iter()
1326 .map(|r| {
1327 serde_json::json!({
1328 "from_id": r.from_id,
1329 "rel_types": r.rel_types,
1330 "mem": r.mem,
1331 })
1332 })
1333 .collect();
1334 serde_json::json!({ "id": id, "referrers": referrers_json })
1335 }
1336 EngineError::MemHasIncomingRefs { mem, referrers } => {
1337 let referrers_json: Vec<_> = referrers
1338 .iter()
1339 .map(|r| {
1340 serde_json::json!({
1341 "from_id": r.from_id,
1342 "rel_types": r.rel_types,
1343 "mem": r.mem,
1344 })
1345 })
1346 .collect();
1347 serde_json::json!({ "mem": mem, "referrers": referrers_json })
1348 }
1349 EngineError::WikiLinkWithoutRelation { from_id, missing } => serde_json::json!({
1350 "from_id": from_id,
1351 "missing": missing,
1352 }),
1353 EngineError::RelationHasBodyLinks {
1354 from_id,
1355 to_id,
1356 rel_type,
1357 body_links,
1358 } => {
1359 serde_json::json!({
1360 "from_id": from_id,
1361 "to_id": to_id,
1362 "rel_type": rel_type,
1363 "body_links": body_links,
1364 })
1365 }
1366 EngineError::InvalidEntityId { id, reason } => {
1367 serde_json::json!({ "id": id, "reason": reason })
1368 }
1369 EngineError::InvalidWikiLinkTarget {
1370 raw,
1371 suggested,
1372 section,
1373 link_source,
1374 reason,
1375 } => {
1376 // Surface
1377 // the slug-form retry under `proposed_slug`, mirroring the
1378 // title gate's `INVALID_TITLE` recovery key, so an agent
1379 // that wrote `[[Idempotency]]` finds `idempotency` under
1380 // the same field it already knows. `suggested` is the
1381 // general hint and is sometimes a colon-form
1382 // (`mem:slug`) for the ambiguous-grammar case — only
1383 // promote it to `proposed_slug` when it's a bare slug.
1384 let proposed_slug = suggested
1385 .as_ref()
1386 .filter(|s| !s.contains(':') && !s.contains("--"));
1387 serde_json::json!({
1388 "raw": raw,
1389 "suggested": suggested,
1390 "proposed_slug": proposed_slug,
1391 "section": section,
1392 "source": link_source,
1393 "reason": reason,
1394 })
1395 }
1396 EngineError::InvalidWikiLinkMem {
1397 raw,
1398 section,
1399 reason,
1400 } => {
1401 serde_json::json!({ "raw": raw, "section": section, "reason": reason })
1402 }
1403 EngineError::ConflictingSectionModes { section, modes } => {
1404 serde_json::json!({ "section": section, "modes": modes })
1405 }
1406 EngineError::SetAndUnsetConflict { keys } => serde_json::json!({ "keys": keys }),
1407 EngineError::RequiredFieldUnset {
1408 field,
1409 entity_type,
1410 field_description,
1411 enum_values,
1412 type_write_rules,
1413 // `on_create` is a prose-dispatch
1414 // discriminator only; agents branch on the typed
1415 // `REQUIRED_FIELD_UNSET` code, not on this field.
1416 on_create: _,
1417 missing,
1418 } => {
1419 // `details.missing[]` carries every required-no-
1420 // default field unset on the create path so an
1421 // agent fixes the whole set in one retry. Each
1422 // entry echoes the type-level `write_rules` for
1423 // self-containment. Empty on the unset path.
1424 let missing_json: Vec<_> = missing
1425 .iter()
1426 .map(|m| {
1427 serde_json::json!({
1428 "field": m.key,
1429 "description": m.description,
1430 "enum_values": m.enum_values,
1431 "write_rules": type_write_rules,
1432 })
1433 })
1434 .collect();
1435 serde_json::json!({
1436 "field": field,
1437 "entity_type": entity_type,
1438 "field_description": field_description,
1439 "enum_values": enum_values,
1440 "type_write_rules": type_write_rules,
1441 "missing": missing_json,
1442 })
1443 }
1444 EngineError::MissingRequiredSection {
1445 entity_type,
1446 missing_count,
1447 sections,
1448 type_guidance,
1449 } => {
1450 let sections_json: Vec<_> = sections
1451 .iter()
1452 .map(|s| {
1453 serde_json::json!({
1454 "entity_type": s.entity_type,
1455 "key": s.key,
1456 "heading": s.heading,
1457 "write_rules": s.write_rules,
1458 })
1459 })
1460 .collect();
1461 serde_json::json!({
1462 "entity_type": entity_type,
1463 "missing_count": missing_count,
1464 "sections": sections_json,
1465 "type_guidance": type_guidance,
1466 })
1467 }
1468 EngineError::PatchSectionEmpty { section } => serde_json::json!({ "section": section }),
1469 EngineError::PatchOldNotFound {
1470 section,
1471 current_content,
1472 truncated,
1473 } => {
1474 serde_json::json!({
1475 "section": section,
1476 "current_content": current_content,
1477 "truncated": truncated,
1478 })
1479 }
1480 EngineError::RelationshipCycle {
1481 rel_type,
1482 from,
1483 to,
1484 existing_path,
1485 path_truncated,
1486 } => {
1487 let path_json: Vec<_> = existing_path.iter().map(|id| id.to_string()).collect();
1488 serde_json::json!({
1489 "rel_type": rel_type,
1490 "from": from.to_string(),
1491 "to": to.to_string(),
1492 "existing_path": path_json,
1493 "path_truncated": path_truncated,
1494 })
1495 }
1496 EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
1497 serde_json::json!({ "from_mem": from_mem, "to_mem": to_mem })
1498 }
1499 EngineError::EmptyUpdate { id } => {
1500 serde_json::json!({
1501 "id": id,
1502 "recognised_keys": [
1503 "sections", "append_sections", "patch_sections",
1504 "metadata", "metadata_unset", "declare_relations", "relations_unset",
1505 ],
1506 })
1507 }
1508 EngineError::RenameBlockedByCrossMemPolicy {
1509 from_mem,
1510 blocked_referrers,
1511 } => {
1512 let entries: Vec<_> = blocked_referrers
1513 .iter()
1514 .map(|r| {
1515 serde_json::json!({
1516 "from_mem": r.from_mem,
1517 "to_mem": r.to_mem,
1518 "count": r.count,
1519 })
1520 })
1521 .collect();
1522 serde_json::json!({
1523 "from_mem": from_mem,
1524 "blocked_referrers": entries,
1525 })
1526 }
1527 EngineError::CrossMemTargetNotFound {
1528 target_id,
1529 target_mem,
1530 } => {
1531 serde_json::json!({ "target_id": target_id, "target_mem": target_mem })
1532 }
1533 EngineError::Validation(v) => v.details(),
1534 EngineError::MissingRequiredDescription {
1535 rel_type,
1536 from_id,
1537 to_id,
1538 } => {
1539 serde_json::json!({
1540 "rel_type": rel_type,
1541 "from_id": from_id,
1542 "to_id": to_id,
1543 })
1544 }
1545 EngineError::DescriptionNotPermitted {
1546 rel_type,
1547 from_id,
1548 to_id,
1549 } => {
1550 serde_json::json!({
1551 "rel_type": rel_type,
1552 "from_id": from_id,
1553 "to_id": to_id,
1554 })
1555 }
1556 EngineError::RelationManualAuthoringForbidden {
1557 rel_type,
1558 from_id,
1559 to_id,
1560 guidance,
1561 } => serde_json::json!({
1562 "rel_type": rel_type,
1563 "from_id": from_id,
1564 "to_id": to_id,
1565 "guidance": guidance,
1566 }),
1567 EngineError::MarkdownExportUnsupportedBackend {
1568 mem,
1569 active_backend,
1570 supported_backends,
1571 } => serde_json::json!({
1572 "mem": mem,
1573 "active_backend": active_backend,
1574 "supported_backends": supported_backends,
1575 }),
1576 EngineError::ReviewMarkNotSet { mem } => serde_json::json!({ "mem": mem }),
1577 EngineError::InvalidChangesCursor { mem, since } => serde_json::json!({
1578 "mem": mem,
1579 "since": since,
1580 }),
1581 EngineError::SchemaNotFound {
1582 mem,
1583 pin,
1584 sources,
1585 install_hint,
1586 } => {
1587 let mut details = serde_json::json!({
1588 "mem": mem,
1589 "pin": pin,
1590 "sources": sources,
1591 });
1592 if let Some(path) = install_hint {
1593 details["install_hint"] = serde_json::json!({
1594 "authoring_package": path,
1595 "command": format!("memstead schema install {path}"),
1596 });
1597 }
1598 details
1599 }
1600 EngineError::SchemaPackageInvalid {
1601 name,
1602 version,
1603 message,
1604 } => serde_json::json!({
1605 "schema": format!("{name}@{version}"),
1606 "error": message,
1607 }),
1608 EngineError::InvalidAnchor(e) => {
1609 serde_json::Value::Object(e.detail().into_iter().collect::<serde_json::Map<_, _>>())
1610 }
1611 _ => serde_json::Value::Object(serde_json::Map::new()),
1612 }
1613 }
1614
1615 /// Render rich, fully-inlined recovery prose for the agent-visible
1616 /// text channel.
1617 ///
1618 /// Warnings
1619 /// already render their structured payload inline via
1620 /// `WarningHint::Display`; pre-fix errors with rich payloads
1621 /// collapsed to `Display` plus `format_inline_list_overflow`'s
1622 /// "+N more — see details.X" pointer pointing at a structured
1623 /// channel the agent's MCP client doesn't surface to the model.
1624 /// This method gives errors the same prose-rich rendering warnings
1625 /// have, so `result.content[0].text` is self-recoverable.
1626 ///
1627 /// Variants whose `Display` already inlines every recovery field
1628 /// (no truncation, no "see details" pointer) inherit the default
1629 /// trait impl — they just `to_string()`. Override only the
1630 /// variants that need richer rendering than `Display` provides.
1631 ///
1632 /// The structured `details()` channel is unchanged; consumers
1633 /// branching on `code` continue to receive the typed shape. The
1634 /// `Display` impl stays terse for logs, tracing, panic messages,
1635 /// and other non-agent consumers.
1636 pub fn prose_render(&self) -> String {
1637 match self {
1638 // The echoed conforming `example` is the highest-leverage
1639 // part of a format refusal — inline it on the text channel
1640 // too, not only under `details.example`.
1641 EngineError::SectionFormatRefused { violation, .. } => {
1642 let base = self.to_string();
1643 match violation.example() {
1644 Some(example) => {
1645 format!("{base}\nA conforming example:\n{}", example.trim_end())
1646 }
1647 None => base,
1648 }
1649 }
1650 EngineError::HasIncomingRefs { id, referrers } => {
1651 let inline = render_referrers_inline(referrers);
1652 format!(
1653 "entity {id} has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
1654 n = referrers.len(),
1655 )
1656 }
1657 EngineError::MemHasIncomingRefs { mem, referrers } => {
1658 let inline = render_referrers_inline(referrers);
1659 format!(
1660 "mem `{mem}` has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
1661 n = referrers.len(),
1662 )
1663 }
1664 EngineError::WikiLinkWithoutRelation { from_id, missing } => {
1665 let inline = missing
1666 .iter()
1667 .map(|m| m.to_string())
1668 .collect::<Vec<_>>()
1669 .join(", ");
1670 format!(
1671 "post-mutation body of {from_id} has {n} wiki-link(s) without a backing relation ({inline}) — declare the relation(s) via memstead_relate first (REFERENCES or a more specific rel-type), then retry",
1672 n = missing.len(),
1673 )
1674 }
1675 EngineError::RelationHasBodyLinks {
1676 from_id,
1677 to_id,
1678 rel_type,
1679 body_links,
1680 } => {
1681 let inline = body_links.join(", ");
1682 format!(
1683 "cannot remove {rel_type} {from_id} → {to_id}: source body still contains wiki-link(s) to the target in section(s) {inline} — drop them via memstead_update before removing the relation"
1684 )
1685 }
1686 EngineError::RelationshipCycle {
1687 rel_type,
1688 from,
1689 to,
1690 existing_path,
1691 path_truncated,
1692 } => {
1693 let path_inline = if existing_path.is_empty() {
1694 String::from("(unavailable)")
1695 } else {
1696 existing_path
1697 .iter()
1698 .map(|id| id.to_string())
1699 .collect::<Vec<_>>()
1700 .join(" → ")
1701 };
1702 let trunc = if *path_truncated {
1703 " (path truncated)"
1704 } else {
1705 ""
1706 };
1707 format!(
1708 "creating edge {rel_type} from '{from}' to '{to}' would close a cycle in the {rel_type} subgraph — existing path: {path_inline}{trunc}; remove an edge along this path to break the cycle, then retry"
1709 )
1710 }
1711 EngineError::RequiredFieldUnset {
1712 field,
1713 entity_type,
1714 field_description,
1715 enum_values,
1716 type_write_rules,
1717 on_create,
1718 missing,
1719 } => {
1720 let desc_clause = field_description
1721 .as_deref()
1722 .map(|d| format!(" Field purpose: {d}."))
1723 .unwrap_or_default();
1724 let enum_clause = if enum_values.is_empty() {
1725 String::new()
1726 } else {
1727 format!(" Allowed values: {}.", enum_values.join(", "))
1728 };
1729 let rules_clause = if type_write_rules.is_empty() {
1730 String::new()
1731 } else {
1732 format!(" Type-level write_rules: {}.", type_write_rules.join("; "))
1733 };
1734 // Path-aware wording — create
1735 // path says "not provided"; update path says "cannot
1736 // unset". Display impl shares the same dispatch via
1737 // `_required_field_unset_msg`.
1738 let lead = if *on_create {
1739 format!(
1740 "required metadata field '{field}' not provided — type '{entity_type}' declares the field as required and has no default for it"
1741 )
1742 } else {
1743 format!("cannot unset required field '{field}' for type '{entity_type}'")
1744 };
1745 // Multi-field accumulator. On the create path,
1746 // append a tail-list naming every other unset
1747 // required field so the agent's one-shot retry
1748 // covers all of them. The unset path's `missing`
1749 // is empty (or singleton), so the clause is empty
1750 // there.
1751 let tail_clause = if missing.len() > 1 {
1752 let others: Vec<&str> =
1753 missing.iter().skip(1).map(|m| m.key.as_str()).collect();
1754 format!(" Also unset (declaration order): {}.", others.join(", "))
1755 } else {
1756 String::new()
1757 };
1758 format!("{lead}.{desc_clause}{enum_clause}{rules_clause}{tail_clause}")
1759 }
1760 EngineError::MissingRequiredSection {
1761 entity_type,
1762 missing_count,
1763 sections,
1764 type_guidance,
1765 } => {
1766 let mut out = format!(
1767 "missing {missing_count} required section(s) for type '{entity_type}':"
1768 );
1769 for s in sections {
1770 let rules = if s.write_rules.is_empty() {
1771 String::new()
1772 } else {
1773 format!(" — write_rules: {}", s.write_rules.join("; "))
1774 };
1775 out.push_str(&format!("\n - '{}' ({}){rules}", s.key, s.heading));
1776 }
1777 if !type_guidance.is_empty() {
1778 out.push_str("\nType guidance:");
1779 for (etype, rules) in type_guidance {
1780 if rules.is_empty() {
1781 continue;
1782 }
1783 out.push_str(&format!("\n - {etype}: {}", rules.join("; ")));
1784 }
1785 }
1786 out
1787 }
1788 EngineError::Validation(v) => v.prose_render(),
1789 // Variants whose `Display` already inlines every recovery
1790 // field — title invariants, hash mismatch (already explains
1791 // the stub case), unknown mem / type (already prints
1792 // declared list verbatim), cross-mem gates, stubs,
1793 // patch errors, etc. — fall back to `Display`. Logs and
1794 // tracing consumers see the same string.
1795 _ => self.to_string(),
1796 }
1797 }
1798}
1799
1800/// Inline-render every [`ReferrerInfo`] without the truncation suffix
1801/// `format_inline_list_overflow` applies. Used by
1802/// [`EngineError::prose_render`]'s `HasIncomingRefs` /
1803/// `MemHasIncomingRefs` arms — the agent text channel inlines the
1804/// full list so recovery doesn't depend on the structured channel.
1805fn render_referrers_inline(referrers: &[ReferrerInfo]) -> String {
1806 referrers
1807 .iter()
1808 .map(|r| r.to_string())
1809 .collect::<Vec<_>>()
1810 .join(", ")
1811}
1812
1813/// Format the `RequiredFieldUnset` message. The same typed code
1814/// fires from two semantically-distinct call sites:
1815///
1816/// * The create path constructs the variant when the caller didn't
1817/// supply a required metadata field. The pre-fix message ("cannot
1818/// unset required field …") was misleading because the field was
1819/// never set in the first place — `on_create: true` flips the
1820/// wording to "required metadata field … not provided".
1821/// * The update path constructs the variant when the caller passed
1822/// `metadata_unset: ["field"]` against a required field. The
1823/// pre-fix wording is correct for this path — `on_create: false`
1824/// keeps it.
1825///
1826/// Both paths share recovery (provide the field); the typed code
1827/// stays `REQUIRED_FIELD_UNSET` for code-key branching consumers.
1828fn _required_field_unset_msg(field: &str, entity_type: &str, on_create: bool) -> String {
1829 if on_create {
1830 format!(
1831 "required metadata field '{field}' not provided — type '{entity_type}' declares the field as required and has no default for it"
1832 )
1833 } else {
1834 format!("cannot unset required field '{field}' for type '{entity_type}'")
1835 }
1836}
1837
1838/// Format the `HashMismatch` message. Stub-shaped entities have no
1839/// `content_hash` to compare against; rendering the empty `current:`
1840/// paren the way pre-fix code did misdirects an agent toward
1841/// hash-recovery via `memstead_entity` (which returns the same empty
1842/// hash). Surface the actual corrective action — pass
1843/// `expected_hash: ""` — instead.
1844/// Render the `SCHEMA_NOT_FOUND` message with its source-trail
1845/// summary. The trail says exactly which sources were searched and
1846/// what each held — never more (a source absent from `sources` is not
1847/// claimed searched). A right-name/wrong-version failure and a
1848/// never-installed failure are distinguishable from this sentence
1849/// alone; the structured `details.sources` stays the richer channel.
1850/// Empty `sources` (internal lookup miss) keeps the bare legacy
1851/// sentence — there was no source search to summarise.
1852fn schema_not_found_message(
1853 mem: &str,
1854 pin: &str,
1855 sources: &[SchemaSourceDiagnostic],
1856 install_hint: &Option<String>,
1857) -> String {
1858 let mut msg = format!("mem {mem}: schema pin {pin:?} did not resolve in any schema source");
1859 if sources.is_empty() {
1860 return msg;
1861 }
1862 let name = pin.split('@').next().unwrap_or(pin);
1863 let trail: Vec<String> = sources
1864 .iter()
1865 .map(|s| {
1866 if let Some(status) = s.status {
1867 format!("{} ({status})", s.source)
1868 } else if s.versions_found.is_empty() {
1869 format!("{} (nothing for {name:?})", s.source)
1870 } else {
1871 format!("{} (holds {})", s.source, s.versions_found.join(", "))
1872 }
1873 })
1874 .collect();
1875 msg.push_str(&format!(" — searched {}", trail.join(", ")));
1876 if sources.iter().any(|s| !s.versions_found.is_empty()) {
1877 // Right name, wrong version. The honest hint is version
1878 // repair, not silence: the final clause is the concrete repin
1879 // command against the newest version a source actually holds.
1880 let best = sources
1881 .iter()
1882 .flat_map(|s| &s.versions_found)
1883 .filter_map(|v| semver::Version::parse(v).ok())
1884 .max();
1885 msg.push_str(&format!(
1886 "; the name {name:?} exists at the versions listed — the pinned version is wrong, \
1887 or the pinned version was never installed"
1888 ));
1889 if let Some(best) = best {
1890 msg.push_str(&format!(
1891 "; repin to an installed version: run: memstead mem set-schema {mem} {name}@{best}"
1892 ));
1893 }
1894 } else if let Some(path) = install_hint {
1895 msg.push_str(&format!(
1896 "; an authoring package named {name:?} exists at {path:?} but is not installed — \
1897 run: memstead schema install {path}"
1898 ));
1899 } else {
1900 // Name unknown everywhere and no authoring package in sight —
1901 // the repair path is still the install path, stated without a
1902 // concrete package location because none exists to name.
1903 msg.push_str(&format!(
1904 "; no source holds any version of {name:?} — author or obtain the schema package, \
1905 then run: memstead schema install <package-dir>"
1906 ));
1907 }
1908 msg
1909}
1910
1911impl EngineError {
1912 /// Attach the schema-install hint to a `SchemaNotFound` where a
1913 /// workspace root is known: probe the root's immediate
1914 /// subdirectories for an authoring schema package (a directory the
1915 /// schema loader accepts) whose manifest name matches the pin's
1916 /// name, and record its path when NO resolution source holds any
1917 /// version of that name. Any other error variant — and any
1918 /// `SchemaNotFound` where the name IS installed at some version
1919 /// (a version mismatch is a different fix), where `sources` is
1920 /// empty (internal miss, no source search happened), or where no
1921 /// candidate package exists — passes through unchanged. Read-only:
1922 /// the probe never writes, installs, or seals anything.
1923 pub fn with_schema_install_probe(self, workspace_root: Option<&std::path::Path>) -> Self {
1924 let EngineError::SchemaNotFound {
1925 mem,
1926 pin,
1927 sources,
1928 install_hint,
1929 } = self
1930 else {
1931 return self;
1932 };
1933 let hint = if install_hint.is_some() {
1934 install_hint
1935 } else if sources.is_empty() || sources.iter().any(|s| !s.versions_found.is_empty()) {
1936 None
1937 } else {
1938 let name = pin.split('@').next().unwrap_or(&pin).to_string();
1939 workspace_root.and_then(|root| probe_authoring_package(root, &name))
1940 };
1941 EngineError::SchemaNotFound {
1942 mem,
1943 pin,
1944 sources,
1945 install_hint: hint,
1946 }
1947 }
1948}
1949
1950/// Scan `root`'s immediate subdirectories for a loadable schema
1951/// package whose manifest name is `name`. Hidden directories and the
1952/// workspace's own storage (`mem-repo`, `.memstead`) are skipped. The
1953/// full loader runs (error path only, so the cost is acceptable) — a
1954/// directory that merely LOOKS like a package but fails validation
1955/// produces no hint, because `memstead schema install` would refuse it
1956/// anyway.
1957fn probe_authoring_package(root: &std::path::Path, name: &str) -> Option<String> {
1958 let entries = std::fs::read_dir(root).ok()?;
1959 for entry in entries.flatten() {
1960 let path = entry.path();
1961 if !path.is_dir() {
1962 continue;
1963 }
1964 let dir_name = entry.file_name();
1965 let dir_name = dir_name.to_string_lossy();
1966 if dir_name.starts_with('.') || dir_name == "mem-repo" {
1967 continue;
1968 }
1969 if !path.join("schema.yaml").is_file() {
1970 continue;
1971 }
1972 if let Ok(schema) = memstead_schema::load_schema_from_dir(&path) {
1973 let (loaded_name, _) = schema.id();
1974 if loaded_name == name {
1975 return Some(path.display().to_string());
1976 }
1977 }
1978 }
1979 None
1980}
1981
1982fn _hash_mismatch_msg(id: &str, current: &str, is_stub: bool) -> String {
1983 if is_stub {
1984 format!(
1985 "hash mismatch for {id} — entity is a stub (no content_hash); pass expected_hash: \"\" to operate on stubs"
1986 )
1987 } else {
1988 format!("hash mismatch for {id} — entity was modified concurrently (current: {current})")
1989 }
1990}
1991
1992/// Errors surfaced by [`Engine::from_workspace_root`] (lean) and its
1993/// full counterpart (`memstead_git_branch::engine_from_workspace_root`).
1994///
1995/// The boot path layers three error sources: layout detection,
1996/// workspace-store load failures, per-mount backend instantiation
1997/// (folder + archive vs git-branch), and engine construction
1998/// (duplicate-mem checks). `#[from]` lifts the lower-layer types so
1999/// callers branch on a single error envelope.
2000// Variants lift lower-layer error types verbatim via `#[from]`, so the enum is
2001// as wide as its widest member. Boot errors are constructed at most once per
2002// process; boxing to equalise them would buy nothing.
2003#[allow(clippy::large_enum_variant)]
2004#[derive(Debug, thiserror::Error)]
2005pub enum BootError {
2006 /// `detect_layout` returned [`crate::Layout::Empty`] — workspace
2007 /// root has no recognised layout marker. Operator runs
2008 /// `memstead mem-repo init` rather than booting against an empty
2009 /// directory.
2010 #[error("workspace at {0} is not initialised — run `memstead mem-repo init` first")]
2011 NotInitialised(PathBuf),
2012 /// Underlying [`crate::WorkspaceStoreAdapter`] load failed
2013 /// (missing config file, parse error, format-mismatch).
2014 #[error(transparent)]
2015 Store(#[from] crate::workspace_store::StoreError),
2016 /// Per-mount backend instantiation failed. Today: a mount
2017 /// declared `MountStorage::GitBranch` while the lean boot path
2018 /// only knows folder + archive.
2019 #[error(transparent)]
2020 Instantiate(#[from] crate::workspace_store::InstantiateError),
2021 /// Engine construction failed (duplicate mem names, etc.).
2022 #[error(transparent)]
2023 Engine(#[from] EngineError),
2024}
2025
2026impl BootError {
2027 /// Stable, surface-independent error code token (UPPER_SNAKE, per
2028 /// the [`EngineError::code`] convention). Every boot failure class
2029 /// resolves to a typed code — `INTERNAL` is not producible from
2030 /// this seam. The wrapped layers each own their vocabulary:
2031 /// store-load failures delegate to
2032 /// [`crate::workspace_store::StoreError::code`], backend
2033 /// instantiation to
2034 /// [`crate::workspace_store::InstantiateError::code`], engine
2035 /// construction to [`EngineError::code`].
2036 pub fn code(&self) -> &'static str {
2037 match self {
2038 // Same token the CLI's workspace walk uses — "no
2039 // workspace here" is one condition wherever detected.
2040 BootError::NotInitialised(_) => "WORKSPACE_NOT_INITIALISED",
2041 BootError::Store(e) => e.code(),
2042 BootError::Instantiate(e) => e.code(),
2043 BootError::Engine(e) => e.code(),
2044 }
2045 }
2046
2047 /// Structured recovery payload for the boot failure, surfacing
2048 /// under `error.details` on `--json` envelopes. Engine-layer
2049 /// failures reuse [`EngineError::details`] (so e.g. a boot-time
2050 /// `SCHEMA_NOT_FOUND` ships the same `details.sources` trail the
2051 /// per-verb surfaces ship); the other layers name the offending
2052 /// path.
2053 pub fn details(&self) -> serde_json::Value {
2054 use crate::workspace_store::StoreError;
2055 match self {
2056 BootError::NotInitialised(path) => {
2057 serde_json::json!({
2058 "path": path.display().to_string(),
2059 "hint": { "recovery_command": "memstead mem-repo init" },
2060 })
2061 }
2062 BootError::Store(e) => match e {
2063 StoreError::NotInitialised { path }
2064 | StoreError::Io { path, .. }
2065 | StoreError::Parse { path, .. }
2066 | StoreError::FormatMismatch { path, .. }
2067 | StoreError::LegacyLayout { path, .. }
2068 | StoreError::UnknownBindingVersion { path, .. } => {
2069 serde_json::json!({ "path": path.display().to_string() })
2070 }
2071 StoreError::LegacyProjectionStore { path } => serde_json::json!({
2072 "path": path.display().to_string(),
2073 "hint": { "recovery_command": "memstead projection migrate" },
2074 }),
2075 StoreError::Other(_) => serde_json::json!({}),
2076 },
2077 BootError::Instantiate(
2078 crate::workspace_store::InstantiateError::GitBranchRequiresMemRepoFeature { mem },
2079 ) => serde_json::json!({ "mem": mem }),
2080 BootError::Engine(e) => e.details(),
2081 }
2082 }
2083
2084 /// The one boot-failure message every surface prints verbatim
2085 /// (CLI stderr / `--json`, MCP boot diagnostics), so the same
2086 /// broken workspace reads identically wherever it refuses. The
2087 /// leaf error's own message carries the repair command (or states
2088 /// plainly that none exists).
2089 pub fn surface_message(&self, workspace_root: &std::path::Path) -> String {
2090 format!("init engine at {}: {self}", workspace_root.display())
2091 }
2092}
2093
2094#[cfg(test)]
2095mod plan05_subsystem_tests {
2096 use super::*;
2097
2098 /// A title-case body wiki-link refusal carries the
2099 /// slug-form retry under `proposed_slug` (mirroring `INVALID_TITLE`),
2100 /// so an agent that wrote `[[Idempotency]]` finds `idempotency` under
2101 /// the key it already knows.
2102 #[test]
2103 fn invalid_wiki_link_details_carry_proposed_slug_for_title_case() {
2104 let err = EngineError::InvalidWikiLinkTarget {
2105 raw: "Idempotency".to_string(),
2106 suggested: Some("idempotency".to_string()),
2107 section: "purpose".to_string(),
2108 link_source: "body_link".to_string(),
2109 reason: "slugs must be lowercase".to_string(),
2110 };
2111 let d = err.details();
2112 assert_eq!(d["proposed_slug"], "idempotency");
2113 assert_eq!(d["suggested"], "idempotency");
2114 }
2115
2116 /// `SCHEMA_NOT_FOUND` carries the fixed-order resolution
2117 /// diagnostics under `details.sources`: a right-name/wrong-version
2118 /// pin shows the built-in's available versions with
2119 /// `pinned_version_match = false`, and `remote` is the reserved
2120 /// `not_configured` slot. This is the agent-visible payload that
2121 /// tells the caller the name resolves but the version does not.
2122 #[test]
2123 fn schema_not_found_details_carry_fixed_order_source_diagnostics() {
2124 let requested: semver::Version = "99.0.0".parse().unwrap();
2125 let sources = SchemaSourceDiagnostic::for_failed_pin("default", &requested, &[]);
2126 let err = EngineError::SchemaNotFound {
2127 mem: "specs".to_string(),
2128 pin: "default@99.0.0".to_string(),
2129 sources,
2130 install_hint: None,
2131 };
2132 assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
2133 let d = err.details();
2134 assert_eq!(d["mem"], "specs");
2135 assert_eq!(d["pin"], "default@99.0.0");
2136 let src = d["sources"].as_array().expect("sources is an array");
2137 let labels: Vec<&str> = src.iter().map(|s| s["source"].as_str().unwrap()).collect();
2138 assert_eq!(labels, ["local_storage", "builtin", "remote"]);
2139 // The `default` builtin exists at 1.0.0 — right name, wrong
2140 // version: builtin enumerates it but the pin does not match.
2141 let builtin = &src[1];
2142 assert!(
2143 builtin["versions_found"]
2144 .as_array()
2145 .unwrap()
2146 .iter()
2147 .any(|v| v == "1.0.0"),
2148 "builtin must enumerate default@1.0.0, got {builtin:?}",
2149 );
2150 assert_eq!(builtin["pinned_version_match"], false);
2151 // No local storage was consulted (empty `consulted` slice).
2152 assert_eq!(src[0]["versions_found"].as_array().unwrap().len(), 0);
2153 // Remote is the reserved, unenumerated slot.
2154 assert_eq!(src[2]["status"], "not_configured");
2155 assert!(
2156 src[2].get("versions_found").is_some(),
2157 "remote still ships an (empty) versions_found list",
2158 );
2159 }
2160
2161 /// The MESSAGE (not just `details`) summarises the source trail,
2162 /// and a right-name/wrong-version failure is distinguishable from
2163 /// a never-installed one without opening `details`. The message
2164 /// names exactly the sources in `sources` — never one that was
2165 /// not searched — and the empty-sources internal miss keeps the
2166 /// bare legacy sentence.
2167 #[test]
2168 fn schema_not_found_message_summarises_trail_and_distinguishes_wrong_version() {
2169 // Wrong version: the builtin catalogue holds `default@1.0.0`,
2170 // the pin asks for 99.0.0.
2171 let requested: semver::Version = "99.0.0".parse().unwrap();
2172 let wrong_version = EngineError::SchemaNotFound {
2173 mem: "specs".to_string(),
2174 pin: "default@99.0.0".to_string(),
2175 sources: SchemaSourceDiagnostic::for_failed_pin("default", &requested, &[]),
2176 install_hint: None,
2177 };
2178 let msg = wrong_version.to_string();
2179 assert!(msg.contains("searched local_storage"), "got: {msg}");
2180 assert!(msg.contains("builtin (holds"), "got: {msg}");
2181 assert!(msg.contains("remote (not_configured)"), "got: {msg}");
2182 assert!(
2183 msg.contains("the pinned version is wrong"),
2184 "wrong-version case must be named in the message: {msg}"
2185 );
2186 assert!(
2187 msg.contains("memstead mem set-schema specs default@1.3.0"),
2188 "wrong-version case ends in the concrete repin command: {msg}"
2189 );
2190
2191 // Never installed: no source holds any version of the name.
2192 let never: semver::Version = "1.0.0".parse().unwrap();
2193 let never_installed = EngineError::SchemaNotFound {
2194 mem: "specs".to_string(),
2195 pin: "no-such-schema@1.0.0".to_string(),
2196 sources: SchemaSourceDiagnostic::for_failed_pin("no-such-schema", &never, &[]),
2197 install_hint: None,
2198 };
2199 let msg2 = never_installed.to_string();
2200 assert!(
2201 msg2.contains("nothing for \"no-such-schema\""),
2202 "never-installed case names the empty sources: {msg2}"
2203 );
2204 assert!(
2205 !msg2.contains("the pinned version is wrong"),
2206 "never-installed must NOT claim a version mismatch: {msg2}"
2207 );
2208 assert!(
2209 msg2.contains("memstead schema install <package-dir>"),
2210 "never-installed (no probe) still names the install path: {msg2}"
2211 );
2212 assert_ne!(msg, msg2, "the two failures are distinguishable");
2213
2214 // Internal lookup miss (empty sources): bare legacy sentence,
2215 // no trail is claimed.
2216 let internal = EngineError::SchemaNotFound {
2217 mem: "specs".to_string(),
2218 pin: "x@1.0.0".to_string(),
2219 sources: Vec::new(),
2220 install_hint: None,
2221 };
2222 assert_eq!(
2223 internal.to_string(),
2224 "mem specs: schema pin \"x@1.0.0\" did not resolve in any schema source",
2225 );
2226 }
2227
2228 /// The install-hint probe attaches the authoring-package pointer
2229 /// exactly when a loadable package with the pin's name sits in the
2230 /// workspace root while NO source holds any version of the name —
2231 /// and stays silent for a version mismatch (installed at another
2232 /// version), for an absent package, and for a non-`SchemaNotFound`
2233 /// error.
2234 #[test]
2235 fn schema_install_probe_hints_only_for_uninstalled_authoring_package() {
2236 // Workspace root carrying the memstead-schema `examples/minimal`
2237 // package (name `recipe`) as an authoring folder.
2238 let tmp = tempfile::TempDir::new().unwrap();
2239 let src_pkg = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2240 .join("../memstead-schema/examples/minimal");
2241 let dst = tmp.path().join("recipe");
2242 std::fs::create_dir_all(dst.join("types")).unwrap();
2243 std::fs::copy(src_pkg.join("schema.yaml"), dst.join("schema.yaml")).unwrap();
2244 for entry in std::fs::read_dir(src_pkg.join("types")).unwrap().flatten() {
2245 std::fs::copy(entry.path(), dst.join("types").join(entry.file_name())).unwrap();
2246 }
2247
2248 let requested: semver::Version = "0.1.0".parse().unwrap();
2249 let not_found = || EngineError::SchemaNotFound {
2250 mem: "specs".to_string(),
2251 pin: "recipe@0.1.0".to_string(),
2252 sources: SchemaSourceDiagnostic::for_failed_pin("recipe", &requested, &[]),
2253 install_hint: None,
2254 };
2255
2256 // Uninstalled + authored → hint attaches, message + details
2257 // point at `memstead schema install`.
2258 let hinted = not_found().with_schema_install_probe(Some(tmp.path()));
2259 let msg = hinted.to_string();
2260 assert!(
2261 msg.contains("memstead schema install"),
2262 "hint must name the install command: {msg}"
2263 );
2264 assert!(msg.contains("recipe"), "hint names the package: {msg}");
2265 let d = hinted.details();
2266 assert!(
2267 d["install_hint"]["command"]
2268 .as_str()
2269 .unwrap()
2270 .starts_with("memstead schema install"),
2271 "details carry the hint: {d}"
2272 );
2273
2274 // No workspace root → no concrete package hint; the message
2275 // falls back to the generic install path.
2276 let no_root = not_found().with_schema_install_probe(None);
2277 let no_root_msg = no_root.to_string();
2278 assert!(
2279 no_root_msg.contains("memstead schema install <package-dir>"),
2280 "generic install path without a probe hit: {no_root_msg}"
2281 );
2282 assert!(
2283 !no_root_msg.contains(&tmp.path().display().to_string()),
2284 "no concrete package path without a probe hit: {no_root_msg}"
2285 );
2286
2287 // No such authoring package → same generic fallback, no
2288 // concrete path.
2289 let other_tmp = tempfile::TempDir::new().unwrap();
2290 let absent = not_found().with_schema_install_probe(Some(other_tmp.path()));
2291 let absent_msg = absent.to_string();
2292 assert!(
2293 absent_msg.contains("memstead schema install <package-dir>"),
2294 "generic install path when no package exists: {absent_msg}"
2295 );
2296 assert!(
2297 !absent_msg.contains(&other_tmp.path().display().to_string()),
2298 "no concrete package path when no package exists: {absent_msg}"
2299 );
2300
2301 // Version mismatch against an installed package (some source
2302 // holds versions of the name) → no hint even though the
2303 // authoring package exists.
2304 let mismatch_req: semver::Version = "99.0.0".parse().unwrap();
2305 let mismatch = EngineError::SchemaNotFound {
2306 mem: "specs".to_string(),
2307 pin: "default@99.0.0".to_string(),
2308 sources: SchemaSourceDiagnostic::for_failed_pin("default", &mismatch_req, &[]),
2309 install_hint: None,
2310 }
2311 .with_schema_install_probe(Some(tmp.path()));
2312 let mismatch_msg = mismatch.to_string();
2313 assert!(
2314 !mismatch_msg.contains("schema install"),
2315 "version mismatch must not hint install: {mismatch_msg}"
2316 );
2317 assert!(
2318 mismatch_msg.contains("memstead mem set-schema specs default@1.3.0"),
2319 "version mismatch hints version repair instead: {mismatch_msg}"
2320 );
2321
2322 // Non-SchemaNotFound errors pass through unchanged.
2323 let other = EngineError::UnknownMem("specs".to_string())
2324 .with_schema_install_probe(Some(tmp.path()));
2325 assert_eq!(other.code(), "UNKNOWN_MEM");
2326 }
2327
2328 /// The ambiguous-grammar case suggests a
2329 /// colon-form (`mem:slug`), which is NOT a bare slug — it must not
2330 /// be promoted to `proposed_slug`.
2331 #[test]
2332 fn invalid_wiki_link_colon_form_suggestion_is_not_a_proposed_slug() {
2333 let err = EngineError::InvalidWikiLinkTarget {
2334 raw: "team/sub--thing".to_string(),
2335 suggested: Some("team/sub:thing".to_string()),
2336 section: "purpose".to_string(),
2337 link_source: "body_link".to_string(),
2338 reason: "ambiguous".to_string(),
2339 };
2340 let d = err.details();
2341 assert!(
2342 d["proposed_slug"].is_null(),
2343 "colon-form must not be a proposed_slug: {d}"
2344 );
2345 assert_eq!(d["suggested"], "team/sub:thing");
2346 }
2347
2348 /// A bad `--since` cursor is the typed `INVALID_CURSOR`
2349 /// code carrying the untruncated SHA in `details.since`.
2350 #[test]
2351 fn invalid_changes_cursor_code_and_details() {
2352 let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
2353 let err = EngineError::InvalidChangesCursor {
2354 mem: "specs".to_string(),
2355 since: sha.to_string(),
2356 };
2357 assert_eq!(err.code(), "INVALID_CURSOR");
2358 let d = err.details();
2359 assert_eq!(d["mem"], "specs");
2360 assert_eq!(
2361 d["since"], sha,
2362 "the offending SHA must ride untruncated in details"
2363 );
2364 }
2365}
2366
2367#[cfg(test)]
2368mod inline_list_tests {
2369 use super::*;
2370
2371 #[test]
2372 fn empty_list_renders_empty_string() {
2373 let items: Vec<String> = Vec::new();
2374 assert_eq!(format_inline_list_overflow(&items, "x"), "");
2375 }
2376
2377 #[test]
2378 fn list_at_cap_renders_all_no_overflow_suffix() {
2379 let items = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2380 assert_eq!(format_inline_list_overflow(&items, "x"), "a, b, c");
2381 }
2382
2383 #[test]
2384 fn list_under_cap_renders_all_no_overflow_suffix() {
2385 let items = vec!["a".to_string(), "b".to_string()];
2386 assert_eq!(format_inline_list_overflow(&items, "x"), "a, b");
2387 }
2388
2389 #[test]
2390 fn list_over_cap_appends_count_and_field_name() {
2391 let items: Vec<String> = (0..23).map(|i| format!("id{i}")).collect();
2392 let rendered = format_inline_list_overflow(&items, "referrers");
2393 assert_eq!(rendered, "id0, id1, id2 +20 more — see details.referrers");
2394 }
2395
2396 #[test]
2397 fn list_six_items_truncates_to_three_plus_three() {
2398 let items: Vec<String> = (0..6).map(|i| format!("t{i}")).collect();
2399 let rendered = format_inline_list_overflow(&items, "missing");
2400 assert_eq!(rendered, "t0, t1, t2 +3 more — see details.missing");
2401 }
2402
2403 #[test]
2404 fn has_incoming_refs_display_inlines_first_three_referrer_ids() {
2405 let referrers: Vec<ReferrerInfo> = (0..23)
2406 .map(|i| ReferrerInfo {
2407 from_id: format!("specs--ref{i}"),
2408 rel_types: vec!["USES".to_string()],
2409 mem: "specs".to_string(),
2410 })
2411 .collect();
2412 let err = EngineError::HasIncomingRefs {
2413 id: "specs--hub".to_string(),
2414 referrers,
2415 };
2416 let s = err.to_string();
2417 // First three ids appear inline; the rest are summarised plus a
2418 // pointer to `details.referrers` on the structured channel.
2419 assert!(
2420 s.contains("specs--ref0, specs--ref1, specs--ref2"),
2421 "got: {s}"
2422 );
2423 assert!(s.contains("+20 more — see details.referrers"), "got: {s}");
2424 // Pre-fix the message only carried the count; check the count
2425 // still appears so callers parsing it for "N references" keep
2426 // working.
2427 assert!(s.contains("23 incoming reference"), "got: {s}");
2428 }
2429
2430 #[test]
2431 fn wiki_link_without_relation_display_lists_all_when_under_cap() {
2432 let missing = vec![
2433 MissingWikiLink {
2434 section_key: "specifies".to_string(),
2435 target_id: "specs--a".to_string(),
2436 },
2437 MissingWikiLink {
2438 section_key: "specifies".to_string(),
2439 target_id: "specs--b".to_string(),
2440 },
2441 MissingWikiLink {
2442 section_key: "rationale".to_string(),
2443 target_id: "specs--c".to_string(),
2444 },
2445 ];
2446 let err = EngineError::WikiLinkWithoutRelation {
2447 from_id: "specs--src".to_string(),
2448 missing,
2449 };
2450 let s = err.to_string();
2451 assert!(s.contains("specifies→specs--a"), "got: {s}");
2452 assert!(s.contains("specifies→specs--b"), "got: {s}");
2453 assert!(s.contains("rationale→specs--c"), "got: {s}");
2454 assert!(!s.contains("more — see details"), "got: {s}");
2455 }
2456
2457 #[test]
2458 fn wiki_link_without_relation_display_truncates_at_cap_with_pointer() {
2459 let missing: Vec<MissingWikiLink> = (0..6)
2460 .map(|i| MissingWikiLink {
2461 section_key: format!("s{i}"),
2462 target_id: format!("specs--t{i}"),
2463 })
2464 .collect();
2465 let err = EngineError::WikiLinkWithoutRelation {
2466 from_id: "specs--src".to_string(),
2467 missing,
2468 };
2469 let s = err.to_string();
2470 assert!(
2471 s.contains("s0→specs--t0, s1→specs--t1, s2→specs--t2"),
2472 "got: {s}"
2473 );
2474 assert!(s.contains("+3 more — see details.missing"), "got: {s}");
2475 }
2476
2477 #[test]
2478 fn relation_has_body_links_display_inlines_section_keys() {
2479 let err = EngineError::RelationHasBodyLinks {
2480 from_id: "specs--src".to_string(),
2481 to_id: "specs--dst".to_string(),
2482 rel_type: "USES".to_string(),
2483 body_links: vec!["specifies".to_string(), "rationale".to_string()],
2484 };
2485 let s = err.to_string();
2486 assert!(s.contains("specifies, rationale"), "got: {s}");
2487 assert!(!s.contains("more — see details"), "got: {s}");
2488 }
2489
2490 // --- prose_render -----------------------------------------------
2491 // The text
2492 // channel inlines full recovery payloads (no `+N more — see
2493 // details.X` pointer). Display stays terse for logs; prose_render
2494 // is the rich method MCP / CLI surfaces call for `content[0].text`.
2495
2496 #[test]
2497 fn prose_render_has_incoming_refs_inlines_every_referrer() {
2498 let referrers = (0..7)
2499 .map(|i| ReferrerInfo {
2500 from_id: format!("specs--r{i}"),
2501 rel_types: vec!["DEPENDS_ON".to_string()],
2502 mem: "specs".to_string(),
2503 })
2504 .collect();
2505 let err = EngineError::HasIncomingRefs {
2506 id: "specs--target".to_string(),
2507 referrers,
2508 };
2509 let prose = err.prose_render();
2510 for i in 0..7 {
2511 assert!(
2512 prose.contains(&format!("specs--r{i}")),
2513 "every referrer must appear inline; missing r{i} in: {prose}"
2514 );
2515 }
2516 assert!(!prose.contains("see details"), "got: {prose}");
2517 // Display stays terse with the overflow suffix.
2518 let display = err.to_string();
2519 assert!(
2520 display.contains("+4 more — see details.referrers"),
2521 "got: {display}"
2522 );
2523 }
2524
2525 #[test]
2526 fn prose_render_required_field_unset_inlines_field_description_and_rules() {
2527 // Update-path semantic: `on_create: false` → "cannot unset".
2528 let err = EngineError::RequiredFieldUnset {
2529 field: "verified_on".to_string(),
2530 entity_type: "requirement".to_string(),
2531 field_description: Some("ISO-8601 date the requirement was last validated".to_string()),
2532 enum_values: vec![],
2533 type_write_rules: vec!["bump verified_on on every status change".to_string()],
2534 on_create: false,
2535 missing: Vec::new(),
2536 };
2537 let prose = err.prose_render();
2538 assert!(
2539 prose.contains("ISO-8601 date"),
2540 "field_description missing: {prose}"
2541 );
2542 assert!(
2543 prose.contains("bump verified_on"),
2544 "type_write_rules missing: {prose}"
2545 );
2546 assert!(!prose.contains("see details"), "got: {prose}");
2547 assert!(
2548 prose.contains("cannot unset"),
2549 "update-path wording must say 'cannot unset': {prose}"
2550 );
2551 }
2552
2553 /// Create
2554 /// path renders "not provided" instead of "cannot unset" — the
2555 /// pre-fix wording was misleading on a path where nothing was
2556 /// ever set in the first place.
2557 #[test]
2558 fn prose_render_required_field_unset_create_path_uses_not_provided_wording() {
2559 let err = EngineError::RequiredFieldUnset {
2560 field: "verified_on".to_string(),
2561 entity_type: "requirement".to_string(),
2562 field_description: Some("ISO-8601 date the requirement was last validated".to_string()),
2563 enum_values: vec![],
2564 type_write_rules: vec![],
2565 on_create: true,
2566 missing: Vec::new(),
2567 };
2568 let prose = err.prose_render();
2569 assert!(
2570 prose.contains("not provided"),
2571 "create-path wording must say 'not provided': {prose}"
2572 );
2573 assert!(
2574 !prose.contains("cannot unset"),
2575 "create-path wording must NOT say 'cannot unset': {prose}"
2576 );
2577 // Same Display dispatch — `to_string()` mirrors `prose_render`'s
2578 // create-path lead.
2579 let display = err.to_string();
2580 assert!(
2581 display.contains("not provided"),
2582 "Display must match: {display}"
2583 );
2584 assert!(
2585 !display.contains("cannot unset"),
2586 "Display must match: {display}"
2587 );
2588 }
2589
2590 /// The
2591 /// create-path multi-field accumulator surfaces every required-
2592 /// no-default field unset in `details.missing[]`. Each entry
2593 /// carries `{field, description, enum_values, write_rules}` so
2594 /// the agent fixes the whole set in one retry. The singular
2595 /// `details.field` echoes `missing[0].field` for back-compat.
2596 #[test]
2597 fn details_required_field_unset_multi_field_envelope_shape() {
2598 use crate::runtime_validator::MissingRequiredField;
2599 let err = EngineError::RequiredFieldUnset {
2600 field: "decided_on".to_string(),
2601 entity_type: "decision".to_string(),
2602 field_description: Some("Date the decision was accepted. ISO YYYY-MM-DD.".to_string()),
2603 enum_values: vec![],
2604 type_write_rules: vec!["status transitions: proposed → accepted".to_string()],
2605 on_create: true,
2606 missing: vec![
2607 MissingRequiredField {
2608 entity_type: "decision".to_string(),
2609 key: "decided_on".to_string(),
2610 description: "Date the decision was accepted. ISO YYYY-MM-DD.".to_string(),
2611 enum_values: vec![],
2612 },
2613 MissingRequiredField {
2614 entity_type: "decision".to_string(),
2615 key: "deciders".to_string(),
2616 description: "Who made the call. Comma-separated handles.".to_string(),
2617 enum_values: vec![],
2618 },
2619 ],
2620 };
2621 let details = err.details();
2622 // Back-compat: singular `field` echoes the first-missing entry.
2623 assert_eq!(details["field"].as_str(), Some("decided_on"));
2624 // Multi-field accumulator surfaces every entry in
2625 // declaration order.
2626 let missing = details["missing"].as_array().expect("missing[] array");
2627 assert_eq!(missing.len(), 2);
2628 assert_eq!(missing[0]["field"].as_str(), Some("decided_on"));
2629 assert_eq!(missing[1]["field"].as_str(), Some("deciders"));
2630 // First entry's `field` agrees with the singular shape.
2631 assert_eq!(details["field"], missing[0]["field"]);
2632 // Per-entry `write_rules` echoes the type-level rules for
2633 // self-containment.
2634 assert_eq!(missing[0]["write_rules"], details["type_write_rules"]);
2635 // Prose mentions both field names so the agent reading the
2636 // text channel sees the whole set without crossing into the
2637 // structured channel.
2638 let prose = err.prose_render();
2639 assert!(prose.contains("decided_on"), "got: {prose}");
2640 assert!(prose.contains("deciders"), "got: {prose}");
2641 }
2642
2643 /// The unset path's singular shape is
2644 /// preserved — `missing[]` is empty (the user targeted one field
2645 /// by definition); the singular fields above are authoritative.
2646 /// The typed code stays `REQUIRED_FIELD_UNSET`.
2647 #[test]
2648 fn details_required_field_unset_singular_shape_for_unset_path() {
2649 let err = EngineError::RequiredFieldUnset {
2650 field: "decided_on".to_string(),
2651 entity_type: "decision".to_string(),
2652 field_description: Some("…".to_string()),
2653 enum_values: vec![],
2654 type_write_rules: vec![],
2655 on_create: false,
2656 missing: Vec::new(),
2657 };
2658 let details = err.details();
2659 assert_eq!(details["field"].as_str(), Some("decided_on"));
2660 let missing = details["missing"]
2661 .as_array()
2662 .expect("missing[] array present");
2663 assert!(missing.is_empty(), "unset-path missing[] must be empty");
2664 assert_eq!(err.code(), "REQUIRED_FIELD_UNSET");
2665 }
2666
2667 #[test]
2668 fn prose_render_missing_required_section_enumerates_each_section_with_write_rules() {
2669 use crate::runtime_validator::MissingRequiredSection;
2670 let sections = vec![
2671 MissingRequiredSection {
2672 entity_type: "spec".to_string(),
2673 key: "purpose".to_string(),
2674 heading: "Purpose".to_string(),
2675 write_rules: vec!["one-sentence statement of intent".to_string()],
2676 },
2677 MissingRequiredSection {
2678 entity_type: "spec".to_string(),
2679 key: "scope".to_string(),
2680 heading: "Scope".to_string(),
2681 write_rules: vec!["what is in and out of scope".to_string()],
2682 },
2683 ];
2684 let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
2685 type_guidance.insert(
2686 "spec".to_string(),
2687 vec!["specs are immutable once stable".to_string()],
2688 );
2689 let err = EngineError::MissingRequiredSection {
2690 entity_type: "spec".to_string(),
2691 missing_count: 2,
2692 sections,
2693 type_guidance,
2694 };
2695 let prose = err.prose_render();
2696 assert!(prose.contains("purpose"), "got: {prose}");
2697 assert!(prose.contains("scope"), "got: {prose}");
2698 assert!(
2699 prose.contains("one-sentence statement of intent"),
2700 "got: {prose}"
2701 );
2702 assert!(
2703 prose.contains("specs are immutable once stable"),
2704 "got: {prose}"
2705 );
2706 assert!(!prose.contains("see details"), "got: {prose}");
2707 }
2708
2709 #[test]
2710 fn prose_render_relationship_cycle_inlines_existing_path() {
2711 use crate::entity::EntityId;
2712 let path = vec![
2713 EntityId::canonical("specs--a"),
2714 EntityId::canonical("specs--b"),
2715 EntityId::canonical("specs--c"),
2716 EntityId::canonical("specs--a"),
2717 ];
2718 let err = EngineError::RelationshipCycle {
2719 rel_type: "PART_OF".to_string(),
2720 from: EntityId::canonical("specs--a"),
2721 to: EntityId::canonical("specs--c"),
2722 existing_path: path,
2723 path_truncated: false,
2724 };
2725 let prose = err.prose_render();
2726 assert!(
2727 prose.contains("specs--a → specs--b → specs--c → specs--a"),
2728 "got: {prose}"
2729 );
2730 assert!(!prose.contains("see details"), "got: {prose}");
2731 }
2732
2733 #[test]
2734 fn prose_render_falls_back_to_display_for_trivial_variants() {
2735 // ReadOnlyMount has no list payload — Display already inlines
2736 // the recovery context.
2737 let err = EngineError::ReadOnlyMount("archive-2024".to_string());
2738 assert_eq!(err.prose_render(), err.to_string());
2739 }
2740
2741 /// A slug collision names the occupying title on both channels —
2742 /// two distinct titles can derive one id, and the id alone does
2743 /// not tell the caller which one is already there.
2744 #[test]
2745 fn already_exists_names_the_occupying_title_on_both_channels() {
2746 let err = EngineError::AlreadyExists {
2747 id: "muehle--bösenberg-grundstücks-gmbh-co-kg".to_string(),
2748 existing_title: "Bösenberg Grundstücks GmbH Co KG".to_string(),
2749 existing_is_stub: false,
2750 };
2751 assert!(
2752 err.to_string()
2753 .contains("occupied by 'Bösenberg Grundstücks GmbH Co KG'"),
2754 "got: {err}"
2755 );
2756 let details = err.details();
2757 assert_eq!(
2758 details["existing_title"],
2759 "Bösenberg Grundstücks GmbH Co KG"
2760 );
2761 assert_eq!(details["existing_is_stub"], false);
2762 assert_eq!(details["id"], "muehle--bösenberg-grundstücks-gmbh-co-kg");
2763 }
2764
2765 /// A stub occupant states it is a stub; a titleless stub must not
2766 /// render as an empty or missing title.
2767 #[test]
2768 fn already_exists_stub_occupant_never_renders_an_empty_title() {
2769 let titled = EngineError::AlreadyExists {
2770 id: "specs--x".to_string(),
2771 existing_title: "X".to_string(),
2772 existing_is_stub: true,
2773 };
2774 assert!(
2775 titled.to_string().contains("a stub titled 'X'"),
2776 "got: {titled}"
2777 );
2778
2779 let untitled = EngineError::AlreadyExists {
2780 id: "specs--x".to_string(),
2781 existing_title: String::new(),
2782 existing_is_stub: true,
2783 };
2784 let msg = untitled.to_string();
2785 assert!(msg.contains("occupied by a stub"), "got: {msg}");
2786 assert!(!msg.contains("''"), "empty title must not render: {msg}");
2787 }
2788}