Skip to main content

memstead_base/engine/
error.rs

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