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