Skip to main content

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}