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/// Render a structured-list payload onto the text-mirror message. The
64/// first [`INLINE_LIST_CAP`] items appear inline, comma-separated; when
65/// the list is longer, the suffix " +N more — see details.<field>"
66/// points the agent at the structured channel's typed list under
67/// `field`. Empty input renders as an empty string. The function is
68/// generic over any [`fmt::Display`] item — wrap structs in a small
69/// `Display` newtype if their default rendering is too verbose for the
70/// text channel.
71pub fn format_inline_list_overflow<T: fmt::Display>(items: &[T], field: &str) -> String {
72    if items.is_empty() {
73        return String::new();
74    }
75    let head: Vec<String> = items
76        .iter()
77        .take(INLINE_LIST_CAP)
78        .map(|i| i.to_string())
79        .collect();
80    let inline = head.join(", ");
81    if items.len() > INLINE_LIST_CAP {
82        let extra = items.len() - INLINE_LIST_CAP;
83        format!("{inline} +{extra} more — see details.{field}")
84    } else {
85        inline
86    }
87}
88
89/// One resolution-source line on [`EngineError::SchemaNotFound`]'s
90/// `details.sources` payload.
91///
92/// The schema registry consults sources in a fixed order — local
93/// storage (the mem's own storage backend), built-in (compiled into
94/// the engine binary), remote (memstead.io, reserved) — and records
95/// what each held for the pinned *name* so an agent or operator can
96/// tell *where* a pin failed: missing from local authoring, absent
97/// from the shipped catalogue, or past the not-yet-wired remote. The
98/// `local_storage`/`builtin` lines report a wrong-version partial
99/// match (right name, wrong version) through `pinned_version_match`.
100#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
101pub struct SchemaSourceDiagnostic {
102    /// Stable source label: `"local_storage"`, `"builtin"`, or
103    /// `"remote"`. Agents may branch on it.
104    pub source: &'static str,
105    /// Versions of the pinned *name* this source held, ascending.
106    /// Empty when the source carried nothing for that name — or was
107    /// not enumerated (today only `remote`, see `status`).
108    pub versions_found: Vec<String>,
109    /// `true` when the pinned exact version is among `versions_found`.
110    /// Always `false` across every source on a genuine not-found (the
111    /// fixed resolution order means a match on any source would have
112    /// resolved); a lone `true` here signals right-name/wrong-version.
113    pub pinned_version_match: bool,
114    /// Non-enumerable status for sources that do not list versions —
115    /// today only `remote`, which reports `"not_configured"`. `None`
116    /// for the enumerable `local_storage`/`builtin` sources.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub status: Option<&'static str>,
119}
120
121impl SchemaSourceDiagnostic {
122    /// Build the fixed-order source diagnostics for a failed pin.
123    ///
124    /// `consulted` is the resolution set the call site actually
125    /// searched: at boot it is the workspace-authored schemas layered
126    /// over the built-ins; at the create/migration sites it is the
127    /// set that path consulted (built-in alone, or workspace + built-in
128    /// for the migration resolver). The `builtin` line is recomputed
129    /// from the static catalogue so it is honest regardless of what the
130    /// caller passed; anything in `consulted` the built-in set does not
131    /// carry is attributed to `local_storage`. `remote` is always the
132    /// reserved `not_configured` slot.
133    pub fn for_failed_pin(
134        name: &str,
135        requested: &semver::Version,
136        consulted: &[std::sync::Arc<memstead_schema::Schema>],
137    ) -> Vec<Self> {
138        use std::collections::BTreeSet;
139        let builtin: BTreeSet<semver::Version> = memstead_schema::builtins::load_builtin_schemas()
140            .map(|set| {
141                set.iter()
142                    .filter(|s| s.manifest.name == name)
143                    .map(|s| s.version.clone())
144                    .collect()
145            })
146            .unwrap_or_default();
147        let local: BTreeSet<semver::Version> = consulted
148            .iter()
149            .filter(|s| s.manifest.name == name)
150            .map(|s| s.version.clone())
151            .filter(|v| !builtin.contains(v))
152            .collect();
153        let to_strings =
154            |set: &BTreeSet<semver::Version>| set.iter().map(|v| v.to_string()).collect::<Vec<_>>();
155        vec![
156            Self {
157                source: "local_storage",
158                pinned_version_match: local.contains(requested),
159                versions_found: to_strings(&local),
160                status: None,
161            },
162            Self {
163                source: "builtin",
164                pinned_version_match: builtin.contains(requested),
165                versions_found: to_strings(&builtin),
166                status: None,
167            },
168            Self {
169                source: "remote",
170                versions_found: Vec::new(),
171                pinned_version_match: false,
172                status: Some("not_configured"),
173            },
174        ]
175    }
176}
177
178/// Errors surfaced by [`Engine`].
179///
180/// `Backend` lifts [`BackendError`] verbatim through a `#[from]`
181/// conversion so the engine layer's error envelope preserves the
182/// backend's typed `Sealed` / `HashMismatch` payloads. The MCP layer
183/// branches on the discriminant when mapping into the typed `code`
184/// field of its error envelope.
185#[derive(Debug, thiserror::Error)]
186pub enum EngineError {
187    /// `Engine::from_mounts` received two mounts naming the same
188    /// mem. Configuration error: the persistence adapter or
189    /// caller produced a malformed mount list.
190    #[error("duplicate mem in mount list: {0}")]
191    DuplicateMem(String),
192    /// No mount in this engine names the requested mem. Surfaced
193    /// before reaching any backend so callers can distinguish
194    /// "wrong mem name" from "backend failure".
195    #[error("unknown mem: {0}")]
196    UnknownMem(String),
197    /// Mutation rejected because the mount declares
198    /// [`MountCapability::ReadOnly`]. Surfaced before reaching the
199    /// backend so the typed `Sealed` payload from the archive
200    /// backend never triggers — capability gating runs first.
201    #[error("mem {0} is mounted read-only; mutations rejected")]
202    ReadOnlyMount(String),
203    /// Entity type is not declared in the pinned schema for this
204    /// mem. Carries the declared types (sorted) and a fuzzy
205    /// suggestion so the agent can recover without re-reading the
206    /// schema. `schema_ref` is the pinned `<name>@<version>`.
207    #[error(
208        "unknown entity type '{name}' in schema '{schema_ref}'. Declared types: [{}]{}",
209        declared.join(", "),
210        suggestion.as_deref().map(|s| format!(". Did you mean '{s}'?")).unwrap_or_default()
211    )]
212    UnknownType {
213        name: String,
214        schema_ref: String,
215        declared: Vec<String>,
216        suggestion: Option<String>,
217    },
218    /// Title slug is empty / invalid.
219    #[error("title is invalid: {0}")]
220    InvalidTitle(#[from] SlugError),
221    /// Create attempted against an id already present in the store.
222    #[error("entity already exists: {id}")]
223    AlreadyExists { id: String },
224    /// Mutation rejected because the named entity is not in the
225    /// store. Distinct from `UnknownMem`: the mem exists, the
226    /// entity does not.
227    #[error("entity not found: {id}")]
228    NotFound { id: String },
229    /// Optimistic-locking failure: the caller's `expected_hash` does
230    /// not match the entity's current `content_hash` in the store.
231    /// `current` is the live hash — pass it as `expected_hash` after
232    /// re-reading to retry. `is_stub` is set when the entity is a
233    /// stub (no body, no content_hash); the corrective action is to
234    /// pass `expected_hash: ""` rather than re-read via `memstead_entity`.
235    /// Surfaces on `details.is_stub` so MCP callers branch on the
236    /// structured payload instead of parsing the message text — pre-fix
237    /// the wire emitted `(current: )` with an empty paren that
238    /// misdirected toward hash-recovery for a stub-shaped entity.
239    #[error("{}", _hash_mismatch_msg(id, current, *is_stub))]
240    HashMismatch {
241        id: String,
242        current: String,
243        is_stub: bool,
244    },
245    /// Refusal to delete or rename an entity because other entities
246    /// in **Write-Mems** still reference it. There is no force flag
247    /// or escape hatch — the agent removes the offending references
248    /// (via `memstead_relate --remove` or `memstead_update`) before retrying.
249    /// `referrers` carries the typed referrer info (source id,
250    /// rel-type, source mem) so the response payload describes the
251    /// full surface in one round-trip. ReadOnly-mount referrers are
252    /// excluded from this list — they are handled by the residual-
253    /// stub demotion path on the destructive mutation.
254    #[error(
255        "entity {id} has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
256        n = referrers.len(),
257        inline = format_inline_list_overflow(referrers, "referrers"),
258    )]
259    HasIncomingRefs {
260        id: String,
261        referrers: Vec<ReferrerInfo>,
262    },
263    /// Refusal to delete a mem because entities in other Write-Mems
264    /// still reference entities inside it. Mirrors entity-level
265    /// [`Self::HasIncomingRefs`] at the mem granularity — the
266    /// edge-graph axis (F15 / CLI F8). Revoking a workspace-level grant only closes
267    /// the policy axis; this check closes the actual-edge axis so a
268    /// mem delete that would orphan cross-mem edges refuses with
269    /// the typed envelope listing every offending `(from_id, rel_type,
270    /// source_mem)` triple. No force flag — the operator must
271    /// `memstead_relate --remove` (or `memstead_update` to drop the section)
272    /// on each referrer first, then retry. ReadOnly-mount referrers
273    /// stay out of this list and route through the residual-stub
274    /// demotion path on the destructive mutation, same posture as the
275    /// entity-level variant.
276    #[error(
277        "mem `{mem}` has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
278        n = referrers.len(),
279        inline = format_inline_list_overflow(referrers, "referrers"),
280    )]
281    MemHasIncomingRefs {
282        mem: String,
283        referrers: Vec<ReferrerInfo>,
284    },
285    /// Relate across mems rejected because the workspace's
286    /// `[cross_mem_links]` policy (or the per-create-rule
287    /// `default_cross_links` synthesis) does not permit `from_mem →
288    /// to_mem`. Agents adjust the policy or pick a same-mem
289    /// target. The hint points at the workspace `[cross_mem_links]`
290    /// section.
291    #[error(
292        "cross-mem link from mem `{from_mem}` to mem `{to_mem}` is not allowed by the workspace `[cross_mem_links]` policy"
293    )]
294    CrossMemLinkNotAllowed { from_mem: String, to_mem: String },
295    /// `memstead_relate` cross-mem to a target whose mem is mounted
296    /// `MountCapability::ReadOnly` and the target is absent. Auto-stub
297    /// is unavailable across the engine/ReadOnly-mem boundary (the
298    /// engine cannot persist a stub in a mem it has no write access
299    /// to), so the target must already exist before the relate call.
300    #[error(
301        "cross-mem relate 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 relating"
302    )]
303    CrossMemTargetNotFound {
304        target_id: String,
305        target_mem: String,
306    },
307    /// `memstead_relate` across mems pinning schemas with different
308    /// *names* refused because the source schema's
309    /// `cross_mem_relationships:` section declares no entry for the
310    /// target schema's domain. Each source schema must explicitly
311    /// enumerate outbound cross-mem edges per target domain; the
312    /// absence here means the source schema does not speak the target
313    /// domain's vocabulary. Eligibility is name-based — a declaration
314    /// covers every version of the named target schema. The agent's
315    /// recovery is to declare the rel-type in the source schema's
316    /// `cross_mem_relationships:` section under the target's bare
317    /// schema name (`to_schema: <name>`).
318    ///
319    /// Orthogonal to the `cross_mem_links` permission policy:
320    /// vocabulary and permission fire independently. A policy-admissible
321    /// edge that violates vocabulary surfaces here; a vocabulary-admissible
322    /// edge that violates policy surfaces as
323    /// [`Self::CrossMemLinkNotAllowed`].
324    #[error(
325        "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"
326    )]
327    CrossMemEdgeNotDeclared {
328        source_schema: String,
329        target_schema: String,
330        rel_type: String,
331        from_id: String,
332        to_id: String,
333    },
334    /// `memstead_update` received repair-shaped input (`relations_unset`)
335    /// for an entity that currently passes the conformance check.
336    /// Repair-powers gate on evidence — a conformance failure on the
337    /// target entity — and a conformant entity has the focused tools
338    /// instead: `memstead_relate(remove)` detaches an edge, the additive
339    /// `memstead_update` params evolve content. The entity is not
340    /// modified.
341    #[error(
342        "repair input refused for {id}: the entity currently passes the conformance check — {recovery}"
343    )]
344    RepairNotNeeded { id: String, recovery: String },
345    /// Rename where the new title would slugify to the existing id.
346    /// Surfaced as a typed no-op so callers don't loop on a degenerate
347    /// retry.
348    #[error(
349        "rename would not change the id of {id} — new title {new_title:?} produces the same slug"
350    )]
351    RenameNoOp { id: String, new_title: String },
352    /// `memstead_update` / `memstead_batch_update` payload parsed cleanly but
353    /// carries no recognised mutation content — every mutation map is
354    /// empty and no relations are declared. Distinct from
355    /// `UPDATE_NOOP` (a warning that fires when mutation content was
356    /// provided but matched the current state): `EMPTY_UPDATE` is
357    /// keyed on "no mutation content provided at all", and refuses
358    /// before any mutation work runs so a misspelled/omitted mutation
359    /// key doesn't silently land as `succeeded: 1, commit_sha: ""`.
360    #[error(
361        "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)"
362    )]
363    EmptyUpdate { id: String },
364    /// `memstead_rename` cannot proceed because one or more cross-mem
365    /// referrers would emit a propagated rewrite whose direction the
366    /// workspace's `cross_mem_links` policy does not permit. The
367    /// engine refuses the rename up-front (before any write); the
368    /// agent's recovery is either to grant the missing direction in
369    /// `[cross_mem_links]` or to drop the offending edges first.
370    ///
371    /// Each `blocked_referrers` entry names a single blocked direction
372    /// (`from_mem → to_mem`) — the referrer's mem and the
373    /// renaming entity's mem, respectively — together with the
374    /// count of distinct referrers in that mem that would emit the
375    /// blocked rewrite. The direction is the edge's actual direction
376    /// post-rewrite (`referrer → renamed`), which is what the policy
377    /// gates.
378    #[error(
379        "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",
380        format_blocked_referrers(blocked_referrers)
381    )]
382    RenameBlockedByCrossMemPolicy {
383        from_mem: String,
384        blocked_referrers: Vec<BlockedReferrer>,
385    },
386    /// `memstead_create` / `memstead_update` / `memstead_batch_update` refused
387    /// because the post-mutation entity's section bodies contain
388    /// inline wiki-links to targets that have no corresponding
389    /// explicit relation in `entity.relationships`. Strict
390    /// wiki-link / relation invariant: every body wiki-link must
391    /// have a backing relation. The agent's recovery is
392    /// `memstead_relate <this-entity> REFERENCES <target>` (or a more
393    /// specific rel-type) for each missing entry, then re-issue
394    /// the mutation. `missing` enumerates each violation as a
395    /// `(section_key, target_id)` pair so the agent can fix every
396    /// surviving link in one pass. This validator is gated behind
397    /// the workspace's reference-coherence migration completion
398    /// marker; workspaces that haven't been migrated continue
399    /// running the permissive auto-stub regime.
400    #[error(
401        "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",
402        n = missing.len(),
403        inline = format_inline_list_overflow(missing, "missing"),
404    )]
405    WikiLinkWithoutRelation {
406        from_id: String,
407        missing: Vec<MissingWikiLink>,
408    },
409    /// `memstead_relate --remove` refused because the source entity's
410    /// section bodies still contain `[[<target>]]` (or
411    /// `[[<mem>:<target>]]`) wiki-links pointing at the relation's
412    /// target. Removing the explicit relation while body links
413    /// survive would violate the strict wiki-link/relation invariant
414    /// (inline links require a backing relation). The agent's
415    /// recovery is `memstead_update <source-id>` with section content
416    /// that drops the wiki-link tokens, then re-issue `memstead_relate
417    /// --remove`. `body_links` enumerates the surviving section keys
418    /// so the agent can patch them in one pass.
419    #[error(
420        "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",
421        inline = format_inline_list_overflow(body_links, "body_links"),
422    )]
423    RelationHasBodyLinks {
424        from_id: String,
425        to_id: String,
426        rel_type: String,
427        body_links: Vec<String>,
428    },
429    /// A multi-mem `memstead_rename` partially landed: at least one
430    /// mem committed successfully, then a subsequent per-mem
431    /// commit aborted (typically because a sibling writer advanced
432    /// the failed mem's head between the rename's snapshot and the
433    /// commit attempt — the parent-ref pin tripped via
434    /// `BackendError::ParentMismatch`). The committed mems' state
435    /// has already landed and is durable; the failed mem's writes
436    /// did not land. The agent's recovery options: retry the rename
437    /// (reload the workspace first so the engine re-derives the right
438    /// referrer set), or accept the partial state and reconcile
439    /// manually via subsequent mutations.
440    #[error(
441        "rename partial-failure: mem `{failed_mem}` aborted with cause {failure_cause:?} after {committed_mems:?} already committed — reload and retry, or reconcile manually"
442    )]
443    RenamePartialFailure {
444        committed_mems: Vec<String>,
445        failed_mem: String,
446        failure_cause: String,
447    },
448    /// `memstead_relate` source is a stub — stubs have no `entity_type`
449    /// and cannot author edges. The agent must promote the stub to a
450    /// real entity via `memstead_create` (stub adoption preserves any
451    /// incoming references) before relating. Pre-fix surfaced as the
452    /// cryptic `UnknownType { name: "" }`.
453    #[error("source entity {id} is a stub — promote it to a real entity via memstead_create first")]
454    StubCannotRelate { id: String },
455    /// `memstead_update` target is a stub — stubs have no body, no
456    /// metadata, no schema-resolved type to validate against. The
457    /// agent must promote the stub to a real entity via `memstead_create`
458    /// (stub adoption preserves any incoming references) before
459    /// updating. Pre-Item-02 surfaced as the cryptic
460    /// `UnknownType { name: "" }` cascade — identical symptom to the
461    /// one `StubCannotRelate` was added to replace on `memstead_relate`.
462    #[error("entity {id} is a stub — promote it to a real entity via memstead_create first")]
463    StubNotUpdatable { id: String },
464    /// `memstead_rename` target is a stub — stubs do not have a title to
465    /// rename (their title is derived from the id). Same recovery
466    /// path as [`Self::StubNotUpdatable`].
467    #[error(
468        "entity {id} is a stub — promote it to a real entity via memstead_create before renaming"
469    )]
470    StubNotRenamable { id: String },
471    /// An `EntityId` reaching a write path (notably `memstead_relate to=`)
472    /// does not match the wiki-link grammar
473    /// (`^[a-z0-9-]+(/[a-z0-9-]+)*$` for the slug; `^[a-z0-9-]+$` for
474    /// the mem). The gate prevents an auto-stub being created at a
475    /// malformed id — once present, that stub would fail any
476    /// downstream wiki-link parse that referenced it.
477    #[error("entity id '{id}' is malformed: {reason}")]
478    InvalidEntityId { id: String, reason: String },
479    /// A body wiki-link target in a section body failed the strict
480    /// slug-form grammar gate. The invariant is that every wiki-link target reaching
481    /// `entity.relationships` carries a grammar-valid `EntityId` — the
482    /// alias-synthesis pass would otherwise emit a relation pointing
483    /// at a literal id (e.g. `mem--Knowledge Graph`) that no
484    /// downstream wiki-link parse could ever resolve. `raw` is the
485    /// input between brackets (after alias / `.md` strip); `suggested`
486    /// is the `title_to_slug`-derived slug-form the agent lifts
487    /// directly into the retry (omitted when the input has no
488    /// meaningful canonical form — empty, all-punctuation, all-emoji);
489    /// `section` is the section key whose body carried the link;
490    /// `source` is a stable discriminator (`"body_link"`) future-
491    /// proofed against additional ingress surfaces.
492    #[error("body wiki-link target '{raw}' in section '{section}' is not slug-form: {reason}")]
493    InvalidWikiLinkTarget {
494        raw: String,
495        suggested: Option<String>,
496        section: String,
497        link_source: String,
498        reason: String,
499    },
500    /// A body wiki-link's Tier-2 mem prefix `[[mem:slug]]` failed
501    /// the mem-name grammar (`^[a-z0-9-]+(/[a-z0-9-]+)*$`). Distinct
502    /// from `InvalidWikiLinkTarget` because the recovery is different
503    /// — mem names are fixed identifiers in the workspace, not
504    /// free-form text the agent can mechanically slugify; the agent
505    /// correlates the bad prefix against the workspace's known mems
506    /// rather than reaching for `title_to_slug`.
507    #[error(
508        "body wiki-link mem prefix '{raw}' in section '{section}' is not a valid mem name: {reason}"
509    )]
510    InvalidWikiLinkMem {
511        raw: String,
512        section: String,
513        reason: String,
514    },
515    /// `memstead_update` was asked to apply more than one section-
516    /// mutation mode (`sections`, `append_sections`,
517    /// `patch_sections`) to the same key. The request is ambiguous
518    /// and rejected before any disk write. `modes` lists the
519    /// conflicting modes for the key in canonical order.
520    #[error("conflicting section modes for {section}: {modes:?}")]
521    ConflictingSectionModes { section: String, modes: Vec<String> },
522    /// Adding the proposed edge would close a cycle in an
523    /// acyclic-declared subgraph. Carries the existing back-path
524    /// `[from, …, current, target's intermediates, … from]` so MCP
525    /// envelopes ship the cycle's shape without a follow-up
526    /// `memstead_search`. Truncated at
527    /// [`RELATIONSHIP_CYCLE_PATH_CAP`] entries.
528    #[error(
529        "creating edge {rel_type} from '{from}' to '{to}' would close a cycle in the {rel_type} subgraph"
530    )]
531    RelationshipCycle {
532        rel_type: String,
533        from: EntityId,
534        to: EntityId,
535        existing_path: Vec<EntityId>,
536        path_truncated: bool,
537    },
538    /// `memstead_update` received the same metadata key in both `metadata`
539    /// (set) and `metadata_unset` lists. The request is ambiguous and
540    /// rejected before any disk write — the caller picks which map the
541    /// key belongs in. `keys` lists every overlapping key in alphabetical
542    /// order so a single envelope describes the full conflict.
543    #[error("metadata keys appear in both set and unset: {keys:?}")]
544    SetAndUnsetConflict { keys: Vec<String> },
545    /// `metadata_unset` targeted a required field. Carries the
546    /// recovery payload so the agent reads the field's purpose,
547    /// allowed values, and type-level write rules from the envelope
548    /// rather than re-fetching the schema.
549    ///
550    /// Also fires from `memstead_create` when the
551    /// caller did not supply a required metadata field that the
552    /// schema does not auto-fill (`default_value` / `init_timestamp`
553    /// / `auto_timestamp` all absent). Pre-fix the create path
554    /// surfaced this as a `MISSING_REQUIRED_FIELD` warning and let
555    /// the entity land with a placeholder — silently corrupted the
556    /// export-then-install round-trip when the placeholder was
557    /// invalid for the install-time strict validator. The refusal
558    /// fires once per call on the first missing field (declaration
559    /// order); subsequent fields surface on the next attempt.
560    #[error("{}", _required_field_unset_msg(field, entity_type, *on_create))]
561    RequiredFieldUnset {
562        field: String,
563        entity_type: String,
564        /// Schema-supplied description of the field.
565        field_description: Option<String>,
566        /// Allowed enum values when the unset field is enum-typed;
567        /// empty when the field is free-form.
568        enum_values: Vec<String>,
569        /// Type-level `write_rules` for the entity type.
570        type_write_rules: Vec<String>,
571        /// Path discriminator: `true` when the
572        /// create path constructed the variant (caller didn't supply
573        /// the field), `false` when the update path constructed it
574        /// (caller passed `metadata_unset: ["field"]` against a
575        /// required field). The typed code stays `REQUIRED_FIELD_UNSET`
576        /// on both paths; only the rendered prose differs.
577        ///
578        /// Not exposed on the `details` payload — agents already
579        /// branch on the typed code; the new field is for the prose
580        /// dispatch only.
581        on_create: bool,
582        /// Multi-field
583        /// accumulator on the create path. Every required-no-default
584        /// field that was unset, in schema declaration order. Empty
585        /// on the unset path (where the agent targets one field by
586        /// definition and the singular fields above are authoritative);
587        /// always non-empty (and at least a singleton echo of the
588        /// singular fields) on the create path.
589        ///
590        /// Surfaces on `details.missing[]` so an agent fixes every
591        /// missing field in one round-trip. `details.field` and
592        /// `details.missing[0].field` agree on the first-missing
593        /// entry, keeping the back-compat singular-field shape.
594        missing: Vec<MissingRequiredField>,
595    },
596    /// `memstead_create`: one or more required sections for the entity's
597    /// type were absent or whitespace-only in the request. Pre-fix
598    /// the create path surfaced this as `MISSING_REQUIRED_SECTION`
599    /// warnings and wrote the entity with empty placeholders for
600    /// the missing sections; the resulting on-disk state failed the
601    /// install-time strict validator, breaking the export-then-
602    /// install round-trip. The refusal carries every missing section
603    /// (one entry per affected key) plus the type-level `type_guidance`
604    /// map so the agent has a single round-trip recovery via re-call
605    /// with the missing content filled in.
606    ///
607    /// Loader / health / `memstead_update` paths keep their permissive
608    /// posture — a legacy on-disk entity created when this gate was
609    /// a warning continues to load, surface in health, and accept
610    /// partial updates. The refusal is a write-boundary gate, not a
611    /// global invariant.
612    #[error("missing {missing_count} required section(s) for type '{entity_type}'")]
613    MissingRequiredSection {
614        entity_type: String,
615        /// Echoed for diagnostics; equals `sections.len()`.
616        missing_count: usize,
617        /// One entry per missing required section, in schema
618        /// declaration order. Each entry mirrors the shape of the
619        /// pre-fix `WarningHint::MissingRequiredSection` warning so
620        /// agents reading the recovery payload don't branch on
621        /// surface (refusal vs warning).
622        sections: Vec<crate::runtime_validator::MissingRequiredSection>,
623        /// Type-level `write_rules` keyed by `entity_type`. Map shape
624        /// matches the mutation-response top-level `type_guidance`
625        /// the warning-surface ships so a single decoder reads
626        /// guidance from either path.
627        type_guidance: std::collections::BTreeMap<String, Vec<String>>,
628    },
629    /// `patch_sections` targeted a key whose section body is
630    /// absent from the entity (or has never been authored).
631    #[error("patch target section is empty: {section}")]
632    PatchSectionEmpty { section: String },
633    /// `patch_sections` provided an `old` substring that does not
634    /// appear in the section's current body. Carries a truncated
635    /// snapshot of the current content so the caller can surface
636    /// the actual state to the operator.
637    #[error("patch `old` substring not found in {section}")]
638    PatchOldNotFound {
639        section: String,
640        current_content: String,
641        truncated: bool,
642    },
643    /// Schema-strictness rejection from the runtime validator
644    /// (`UNKNOWN_SECTION`, `UNKNOWN_METADATA`, `INVALID_ENUM_VALUE`).
645    #[error("schema validation: {0}")]
646    Validation(#[from] ValidationError),
647    /// Re-parse of the freshly-generated markdown failed. Should
648    /// never happen — the generator's contract is that its output
649    /// round-trips through `parse_markdown`. Surfaces if a future
650    /// generator change breaks that invariant.
651    #[error("parse-after-write failed: {0}")]
652    ParseAfterWrite(String),
653    /// A wrapped parse error for completeness; today only the
654    /// parse-after-write variant above is constructed in the create
655    /// path.
656    #[error("parse error: {0}")]
657    Parse(#[from] ParseError),
658    /// A backend operation failed. Inner error carries the typed
659    /// payload (e.g. `Sealed`, `HashMismatch`, `Io`).
660    #[error(transparent)]
661    Backend(#[from] BackendError),
662    /// A mem's schema pin did not resolve. `sources` carries the
663    /// fixed-order resolution diagnostics (local storage / built-in /
664    /// remote) so the caller can tell *where* the pin failed and spot a
665    /// right-name/wrong-version partial match; it surfaces under
666    /// `details.sources`. Empty `sources` marks an internal lookup miss
667    /// (an already-resolved schema absent from the engine's per-mem
668    /// map), not a genuine source-resolution failure.
669    #[error("mem {mem}: schema pin {pin:?} did not resolve in any schema source")]
670    SchemaNotFound {
671        mem: String,
672        pin: String,
673        sources: Vec<SchemaSourceDiagnostic>,
674    },
675    /// `memstead_schema::builtins::load_builtin_schemas` itself failed.
676    /// Surfaces during `Engine::from_mounts`; should never trip in
677    /// practice (the built-in catalogue is statically embedded), but
678    /// the failure path is preserved so a future on-disk catalogue
679    /// switch lifts cleanly.
680    #[error("built-in schema catalogue failed to load: {0}")]
681    SchemaResolverInit(String),
682    /// Generic mem-level error message — used by accessors that
683    /// surface "mem exists, but the requested resource is not
684    /// available for this backend" (e.g. `gitdir_for` against a
685    /// folder mount, `worktree_for` against a git-branch mount).
686    #[error("mem error: {0}")]
687    Mem(String),
688    /// `register_writable_mem` rejected because `name` is already
689    /// registered (writable OR read-only). `source_origin` is the
690    /// human-readable description of the colliding registration,
691    /// rendered via [`MemOrigin::render_source`] for writable
692    /// entries or a stand-in for read-only ones.
693    #[error("mem name collision: {name} is already registered ({source_origin})")]
694    MemNameCollision { name: String, source_origin: String },
695    /// Lifecycle orchestrator rejected the input. Carries a single
696    /// free-form message — the orchestrator's typed payload (note
697    /// length, malformed path, etc.) is the message text.
698    #[error("invalid input: {0}")]
699    InvalidInput(String),
700    /// `memstead_fetch` / `memstead_pull` / `memstead_push` named a remote that is
701    /// not configured on the workspace's mem-repo. Typed code
702    /// `UNKNOWN_REMOTE`. Recovery: configure the remote via
703    /// `memstead mem-repo remote-add <name> <url>`.
704    #[error("unknown remote: {0}")]
705    UnknownRemote(String),
706    /// `memstead_pull` refused because the local branch has diverged from
707    /// the remote-tracking ref — fast-forward is impossible without
708    /// losing local commits. Recovery: run `memstead branch-reset` to the
709    /// remote-tracking ref (if the local commits are dispensable) or
710    /// run a replay workflow to rewrite them onto the new remote tip.
711    /// Typed code `LOCAL_DIVERGENCE`.
712    #[error(
713        "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"
714    )]
715    LocalDivergence { mem: String, remote_ref: String },
716    /// `memstead_push` refused because the push would not be a fast-forward
717    /// against the remote and the caller did not pass `force: true`.
718    /// Typed code `NON_FAST_FORWARD`. Recovery: re-fetch + replay, or
719    /// re-issue with `force: true` (warning: rewrites the remote's
720    /// view of the branch — other peers will see the rewrite).
721    #[error(
722        "push to remote `{remote}` for mem `{mem}` is not a fast-forward; rebase / replay locally or pass `force: true` to overwrite the remote"
723    )]
724    NonFastForward { mem: String, remote: String },
725    /// `memstead_push` refused because the local state failed pre-push
726    /// schema validation. The remote was not contacted. Recovery: fix
727    /// the schema violations (use `memstead_health` to find them) and
728    /// retry. Typed code `LOCAL_INVALID_STATE`.
729    #[error(
730        "mem `{mem}` failed pre-push schema validation; remote `{remote}` was not contacted: {detail}"
731    )]
732    LocalInvalidState {
733        mem: String,
734        remote: String,
735        detail: String,
736    },
737    /// `memstead_pull` (or any future merge path that consumes fetched
738    /// commits) refused because the prospective post-merge tree
739    /// contains entities that fail schema validation. The branch
740    /// pointer was not moved. `violations` carries one entry per
741    /// offending entity — typically `(relative_path, parse_error)`
742    /// pairs rendered as strings — so the caller can surface the
743    /// remediation surface without re-walking the tree. Typed code
744    /// `SCHEMA_VIOLATION_IN_FETCH`.
745    #[error(
746        "mem `{mem}` would fail schema validation at `{ref_name}` — {n} violation(s); fix the remote or replay locally first",
747        n = violations.len(),
748    )]
749    SchemaViolationInFetch {
750        mem: String,
751        ref_name: String,
752        violations: Vec<String>,
753    },
754    /// `memstead_branch_reset` refused because at least one commit that
755    /// would be discarded by the reset is already reachable from a
756    /// `refs/remotes/*` ref (the engine's definition of "pushed").
757    /// `pushed_shas` lists the offending commits. The agent's
758    /// recovery is to pick a target SHA that does not strand a pushed
759    /// commit, or to push the pre-reset state under a different
760    /// branch name first. Typed code: `PUSHED_COMMITS_PROTECTED`.
761    #[error(
762        "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",
763        pushed_shas.len(),
764        pushed_shas.join(", "),
765    )]
766    PushedCommitsProtected {
767        mem: String,
768        target_sha: String,
769        pushed_shas: Vec<String>,
770    },
771    /// `memstead_diff` (or any future ref-comparing op) received a ref
772    /// that does not resolve against the workspace's mem-repo.
773    /// Carries the ref string verbatim so the caller can fix the
774    /// input. Typed code `UNKNOWN_REF`.
775    #[error("unknown ref: {0}")]
776    UnknownRef(String),
777    /// `memstead_changes_since` received a `rename_similarity` value
778    /// outside the allowed range. Maps to wire code `INVALID_INPUT`
779    /// with `details.allowed_range: [min, max]` and
780    /// `details.requested`. Promoted from the prior silent-clamp + LIMIT_CLAMPED warning so
781    /// nonsense inputs surface as recoverable refusal rather than
782    /// silent rounding.
783    #[error("rename_similarity {requested} outside allowed range [{allowed_min}, {allowed_max}]")]
784    RenameSimilarityOutOfRange {
785        requested: f32,
786        allowed_min: f32,
787        allowed_max: f32,
788    },
789    /// `memstead_changes_since` / `memstead changes --since` was given a `since`
790    /// commit cursor the mem's git repository can't resolve — a
791    /// malformed prefix or a well-formed-but-absent 40-hex. Surfaces the
792    /// `INVALID_CURSOR` code (the documented contract for this op, which
793    /// the CLI previously leaked as the `MEM_ERROR` catch-all) so a
794    /// sync loop branches cleanly: `INVALID_CURSOR` → re-seed from the
795    /// empty-tree sentinel; `MEM_ERROR` → genuine backend fault.
796    /// `details.since` carries the offending cursor untruncated.
797    #[error(
798        "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"
799    )]
800    InvalidChangesCursor { mem: String, since: String },
801    /// Mem config is missing a required field that the engine
802    /// itself would normally populate (today: `version` at mem
803    /// init). Surfaced on the export path — pre-fix this collapsed
804    /// to `INTERNAL` with a misleading `.memstead/config.json` reference
805    /// that doesn't match the mem-repo backend's blob layout.
806    /// Recovery: run `memstead mem set-version <mem> <version>` to
807    /// populate the field, then retry the export. F1.
808    #[error(
809        "mem `{mem}` config is missing required field(s) {missing_fields:?} — \
810         set via `memstead mem set-version {mem} <version>` (e.g. 0.1.0)"
811    )]
812    MemConfigIncomplete {
813        mem: String,
814        missing_fields: Vec<String>,
815    },
816    /// `memstead_relate` (or a `declare_relations` entry) targeted a
817    /// rel-type whose schema declares `per_edge_description:
818    /// required` without supplying a description. Recovery: re-issue
819    /// the call with `--description "<text>"` describing why this
820    /// particular edge exists (the rel-type's name documents the
821    /// kind of edge; the description documents the instance).
822    #[error(
823        "rel-type `{rel_type}` declares `per_edge_description: required` — \
824         {from_id} → {to_id} needs a description; re-issue with \
825         `--description \"<text>\"`."
826    )]
827    MissingRequiredDescription {
828        rel_type: String,
829        from_id: String,
830        to_id: String,
831    },
832    /// `memstead_relate` (or a `declare_relations` entry) supplied a
833    /// description for a rel-type whose schema declares
834    /// `per_edge_description: forbidden`. Recovery: drop the
835    /// `description` parameter — the rel-type's name describes the
836    /// edge; per-edge text is not permitted on this rel-type.
837    #[error(
838        "rel-type `{rel_type}` declares `per_edge_description: forbidden` — \
839         {from_id} → {to_id} cannot carry a description; drop the \
840         `--description` argument."
841    )]
842    DescriptionNotPermitted {
843        rel_type: String,
844        from_id: String,
845        to_id: String,
846    },
847    /// `memstead_relate` (or a `declare_relations` / `memstead_create`'s
848    /// inline `relations:` entry) targeted a rel-type whose schema
849    /// declares `manual_authoring: forbidden`. The rel-type is
850    /// reserved for engine-emitted synthesis (the body-link →
851    /// relation alias machinery, typically). Recovery: don't author
852    /// the relation explicitly; instead author a body wiki-link
853    /// `[[target]]` in the source's section content, which the
854    /// engine surfaces as the appropriate alias relation
855    /// automatically.
856    #[error(
857        "rel-type `{rel_type}` declares `manual_authoring: forbidden` — \
858         {from_id} → {to_id} cannot be authored explicitly; this rel-type \
859         is reserved for engine-emitted synthesis via the body-link → \
860         relation alias path. {guidance}"
861    )]
862    RelationManualAuthoringForbidden {
863        rel_type: String,
864        from_id: String,
865        to_id: String,
866        guidance: String,
867    },
868    /// Full-text search is unavailable in the current engine build —
869    /// `Engine::search` is callable on every target so JS / FFI
870    /// consumers don't need to re-shape their call sites, but `wasm32`
871    /// builds omit the tantivy index entirely (its native-only
872    /// transitives — `getrandom 0.2` without `js`, `memmap2`, `rayon`,
873    /// `zstd-sys` — block WASM compilation). Browser consumers route
874    /// queries to the bridge's `memstead_search` endpoint. The MCP layer
875    /// maps this to typed code `SEARCH_UNAVAILABLE_IN_WASM`.
876    #[error(
877        "full-text search is unavailable in this engine build (wasm32); \
878         route search queries to the bridge's memstead_search endpoint"
879    )]
880    SearchUnavailable,
881    /// `memstead export --format markdown --mem-name <V>` was called
882    /// against a mem whose active backend doesn't support markdown
883    /// regeneration in place (today: every backend other than
884    /// `folder`). Pre-fix this collapsed to a silent
885    /// `ExportResult { written: 0, unchanged: 0 }` masquerading as
886    /// success. Recovery: use `--format mem` to produce a portable
887    /// `.mem` archive, which every backend supports.
888    #[error(
889        "mem `{mem}` is on backend `{active_backend}`; `memstead export --format markdown` \
890         is supported only on backends [{}] — use `--format mem` to produce a portable \
891         `.mem` archive instead",
892        supported_backends.join(", ")
893    )]
894    MarkdownExportUnsupportedBackend {
895        mem: String,
896        active_backend: String,
897        supported_backends: Vec<String>,
898    },
899}
900
901/// Typed payload for a single Write-Mem referrer in
902/// [`EngineError::HasIncomingRefs`]. Captures the (from_id, rel_types,
903/// mem) triple the surface envelope projects so consumers can reason
904/// about the offending edges without a follow-up `memstead_entity` call.
905/// The mem is always a Write-Mem — ReadOnly referrers are
906/// partitioned out before this struct is constructed and surfaced via
907/// the residual-stub warning channel instead.
908///
909/// Per-source deduplication: when one source entity has multiple
910/// edges of different rel-types pointing at the deletion target, the
911/// engine collapses them into a single `ReferrerInfo` whose
912/// `rel_types` list carries every edge type. A prior shape
913/// emitted one entry per edge, making a source-with-N-edges look
914/// like N distinct referrers in the error message and structured
915/// payload.
916#[derive(Debug, Clone, serde::Serialize)]
917pub struct ReferrerInfo {
918    pub from_id: String,
919    pub rel_types: Vec<String>,
920    pub mem: String,
921}
922
923/// Inline rendering on the text mirror. Single rel-type renders as
924/// just the referring entity id; multiple rel-types append the
925/// `×N [REL1, REL2]` annotation so the count and the offending
926/// edge-types stay visible without parsing the structured payload.
927impl fmt::Display for ReferrerInfo {
928    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
929        if self.rel_types.len() <= 1 {
930            f.write_str(&self.from_id)
931        } else {
932            write!(
933                f,
934                "{} ×{} [{}]",
935                self.from_id,
936                self.rel_types.len(),
937                self.rel_types.join(", ")
938            )
939        }
940    }
941}
942
943/// One body wiki-link that violates the strict wiki-link /
944/// relation invariant. Surfaces inside
945/// [`EngineError::WikiLinkWithoutRelation::missing`].
946#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
947pub struct MissingWikiLink {
948    /// Section key of the entity body where the unbacked
949    /// wiki-link appears.
950    pub section_key: String,
951    /// EntityId target of the unbacked wiki-link.
952    pub target_id: String,
953}
954
955/// Inline rendering pairs the section key with the unbacked target id
956/// so an agent reading only the text mirror can see both where the link
957/// lives and what it points at without decoding the structured payload.
958impl fmt::Display for MissingWikiLink {
959    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
960        write!(f, "{}→{}", self.section_key, self.target_id)
961    }
962}
963
964impl EngineError {
965    /// Stable, surface-independent error code token.
966    ///
967    /// Each surface (MCP envelope, CLI envelope, UniFFI binding) maps
968    /// the variant to its wire shape; the code returned here is the
969    /// canonical name agents key on. Add a new code here when a new
970    /// variant lands; do not invent ad-hoc strings inside the
971    /// per-surface mapping.
972    pub fn code(&self) -> &'static str {
973        match self {
974            EngineError::DuplicateMem(_) => "DUPLICATE_MEM",
975            EngineError::UnknownMem(_) => "UNKNOWN_MEM",
976            EngineError::UnknownRef(_) => "UNKNOWN_REF",
977            EngineError::UnknownRemote(_) => "UNKNOWN_REMOTE",
978            EngineError::LocalDivergence { .. } => "LOCAL_DIVERGENCE",
979            EngineError::NonFastForward { .. } => "NON_FAST_FORWARD",
980            EngineError::LocalInvalidState { .. } => "LOCAL_INVALID_STATE",
981            EngineError::SchemaViolationInFetch { .. } => "SCHEMA_VIOLATION_IN_FETCH",
982            EngineError::PushedCommitsProtected { .. } => "PUSHED_COMMITS_PROTECTED",
983            EngineError::ReadOnlyMount(_) => "READ_ONLY_MOUNT",
984            EngineError::UnknownType { .. } => "UNKNOWN_ENTITY_TYPE",
985            EngineError::InvalidTitle(_) => "INVALID_TITLE",
986            EngineError::AlreadyExists { .. } => "ENTITY_ALREADY_EXISTS",
987            EngineError::NotFound { .. } => "ENTITY_NOT_FOUND",
988            EngineError::HashMismatch { .. } => "HASH_MISMATCH",
989            EngineError::HasIncomingRefs { .. } => "HAS_INCOMING_REFS",
990            EngineError::MemHasIncomingRefs { .. } => "MEM_HAS_INCOMING_REFS",
991            EngineError::CrossMemLinkNotAllowed { .. } => "CROSS_MEM_LINK_NOT_ALLOWED",
992            EngineError::CrossMemTargetNotFound { .. } => "CROSS_MEM_TARGET_NOT_FOUND",
993            EngineError::CrossMemEdgeNotDeclared { .. } => "CROSS_MEM_EDGE_NOT_DECLARED",
994            EngineError::RepairNotNeeded { .. } => "REPAIR_NOT_NEEDED",
995            EngineError::RenameNoOp { .. } => "RENAME_NO_OP",
996            EngineError::EmptyUpdate { .. } => "EMPTY_UPDATE",
997            EngineError::RenameBlockedByCrossMemPolicy { .. } => {
998                "RENAME_BLOCKED_BY_CROSS_MEM_POLICY"
999            }
1000            EngineError::RenamePartialFailure { .. } => "RENAME_PARTIAL_FAILURE",
1001            EngineError::RelationHasBodyLinks { .. } => "RELATION_HAS_BODY_LINKS",
1002            EngineError::WikiLinkWithoutRelation { .. } => "WIKILINK_WITHOUT_RELATION",
1003            EngineError::StubCannotRelate { .. } => "STUB_CANNOT_RELATE",
1004            EngineError::StubNotUpdatable { .. } => "STUB_NOT_UPDATABLE",
1005            EngineError::StubNotRenamable { .. } => "STUB_NOT_RENAMABLE",
1006            EngineError::InvalidEntityId { .. } => "INVALID_ENTITY_ID",
1007            EngineError::InvalidWikiLinkTarget { .. } => "INVALID_WIKI_LINK_TARGET",
1008            EngineError::InvalidWikiLinkMem { .. } => "INVALID_MEM_NAME",
1009            EngineError::ConflictingSectionModes { .. } => "CONFLICTING_SECTION_MODES",
1010            EngineError::RelationshipCycle { .. } => "RELATIONSHIP_CYCLE",
1011            EngineError::SetAndUnsetConflict { .. } => "SET_AND_UNSET_CONFLICT",
1012            EngineError::RequiredFieldUnset { .. } => "REQUIRED_FIELD_UNSET",
1013            EngineError::MissingRequiredSection { .. } => "MISSING_REQUIRED_SECTION",
1014            EngineError::PatchSectionEmpty { .. } => "PATCH_SECTION_EMPTY",
1015            EngineError::PatchOldNotFound { .. } => "PATCH_OLD_NOT_FOUND",
1016            EngineError::Validation(v) => v.code(),
1017            EngineError::ParseAfterWrite(_) => "PARSE_ERROR",
1018            EngineError::Parse(_) => "PARSE_ERROR",
1019            EngineError::Backend(_) => "MEM_ERROR",
1020            EngineError::SchemaNotFound { .. } => "SCHEMA_NOT_FOUND",
1021            EngineError::SchemaResolverInit(_) => "SCHEMA_RESOLVER_INIT_FAILED",
1022            EngineError::Mem(_) => "MEM_ERROR",
1023            EngineError::MemNameCollision { .. } => "MEM_NAME_COLLISION",
1024            EngineError::InvalidInput(_) => "INVALID_INPUT",
1025            EngineError::RenameSimilarityOutOfRange { .. } => "INVALID_INPUT",
1026            EngineError::InvalidChangesCursor { .. } => "INVALID_CURSOR",
1027            EngineError::MemConfigIncomplete { .. } => "MEM_CONFIG_INCOMPLETE",
1028            EngineError::MissingRequiredDescription { .. } => "MISSING_REQUIRED_DESCRIPTION",
1029            EngineError::DescriptionNotPermitted { .. } => "DESCRIPTION_NOT_PERMITTED",
1030            EngineError::RelationManualAuthoringForbidden { .. } => {
1031                "RELATION_MANUAL_AUTHORING_FORBIDDEN"
1032            }
1033            EngineError::SearchUnavailable => "SEARCH_UNAVAILABLE_IN_WASM",
1034            EngineError::MarkdownExportUnsupportedBackend { .. } => {
1035                "MARKDOWN_EXPORT_UNSUPPORTED_BACKEND"
1036            }
1037        }
1038    }
1039
1040    /// Variant-specific recovery payload, rendered as a structured
1041    /// JSON object that surfaces under `error.details` in MCP /
1042    /// CLI envelopes.
1043    ///
1044    /// Pre-fix the
1045    /// batch-update per-item envelope (`batch_error_envelope`)
1046    /// shipped `{}` for every typed code except `Validation`, while
1047    /// the singleton-call surfaces (`CliError::from_engine_op`,
1048    /// `memstead-mcp`'s `engine_err_unified`) populated structured
1049    /// payloads per-variant. Two envelopes, two details paths —
1050    /// agents' "fix from `details`" recovery loop worked
1051    /// differently in batch vs singleton mode. The centralised
1052    /// helper here gives both surfaces one source of truth.
1053    ///
1054    /// Returns an empty object for variants whose recovery payload
1055    /// is the message text alone (no structured fields beyond
1056    /// `code` + `message`).
1057    pub fn details(&self) -> serde_json::Value {
1058        match self {
1059            EngineError::NotFound { id } => serde_json::json!({ "id": id }),
1060            EngineError::RepairNotNeeded { id, recovery } => {
1061                serde_json::json!({ "id": id, "recovery": recovery })
1062            }
1063            // Same shape the full MCP singleton envelope ships for
1064            // UNKNOWN_ENTITY_TYPE — keeps the centralised helper (and
1065            // every consumer: batch envelopes, the integrity linter)
1066            // aligned with the wire payload agents already decode.
1067            EngineError::UnknownType {
1068                name,
1069                schema_ref,
1070                declared,
1071                suggestion,
1072            } => serde_json::json!({
1073                "name": name,
1074                "schema_ref": schema_ref,
1075                "declared": declared,
1076                "suggestion": suggestion,
1077            }),
1078            EngineError::HashMismatch {
1079                id,
1080                current,
1081                is_stub,
1082            } => serde_json::json!({
1083                "id": id,
1084                "current": current,
1085                "is_stub": is_stub,
1086            }),
1087            EngineError::HasIncomingRefs { id, referrers } => {
1088                let referrers_json: Vec<_> = referrers
1089                    .iter()
1090                    .map(|r| {
1091                        serde_json::json!({
1092                            "from_id": r.from_id,
1093                            "rel_types": r.rel_types,
1094                            "mem": r.mem,
1095                        })
1096                    })
1097                    .collect();
1098                serde_json::json!({ "id": id, "referrers": referrers_json })
1099            }
1100            EngineError::MemHasIncomingRefs { mem, referrers } => {
1101                let referrers_json: Vec<_> = referrers
1102                    .iter()
1103                    .map(|r| {
1104                        serde_json::json!({
1105                            "from_id": r.from_id,
1106                            "rel_types": r.rel_types,
1107                            "mem": r.mem,
1108                        })
1109                    })
1110                    .collect();
1111                serde_json::json!({ "mem": mem, "referrers": referrers_json })
1112            }
1113            EngineError::WikiLinkWithoutRelation { from_id, missing } => serde_json::json!({
1114                "from_id": from_id,
1115                "missing": missing,
1116            }),
1117            EngineError::RelationHasBodyLinks {
1118                from_id,
1119                to_id,
1120                rel_type,
1121                body_links,
1122            } => {
1123                serde_json::json!({
1124                    "from_id": from_id,
1125                    "to_id": to_id,
1126                    "rel_type": rel_type,
1127                    "body_links": body_links,
1128                })
1129            }
1130            EngineError::InvalidEntityId { id, reason } => {
1131                serde_json::json!({ "id": id, "reason": reason })
1132            }
1133            EngineError::InvalidWikiLinkTarget {
1134                raw,
1135                suggested,
1136                section,
1137                link_source,
1138                reason,
1139            } => {
1140                // Surface
1141                // the slug-form retry under `proposed_slug`, mirroring the
1142                // title gate's `INVALID_TITLE` recovery key, so an agent
1143                // that wrote `[[Idempotency]]` finds `idempotency` under
1144                // the same field it already knows. `suggested` is the
1145                // general hint and is sometimes a colon-form
1146                // (`mem:slug`) for the ambiguous-grammar case — only
1147                // promote it to `proposed_slug` when it's a bare slug.
1148                let proposed_slug = suggested
1149                    .as_ref()
1150                    .filter(|s| !s.contains(':') && !s.contains("--"));
1151                serde_json::json!({
1152                    "raw": raw,
1153                    "suggested": suggested,
1154                    "proposed_slug": proposed_slug,
1155                    "section": section,
1156                    "source": link_source,
1157                    "reason": reason,
1158                })
1159            }
1160            EngineError::InvalidWikiLinkMem {
1161                raw,
1162                section,
1163                reason,
1164            } => {
1165                serde_json::json!({ "raw": raw, "section": section, "reason": reason })
1166            }
1167            EngineError::ConflictingSectionModes { section, modes } => {
1168                serde_json::json!({ "section": section, "modes": modes })
1169            }
1170            EngineError::SetAndUnsetConflict { keys } => serde_json::json!({ "keys": keys }),
1171            EngineError::RequiredFieldUnset {
1172                field,
1173                entity_type,
1174                field_description,
1175                enum_values,
1176                type_write_rules,
1177                // `on_create` is a prose-dispatch
1178                // discriminator only; agents branch on the typed
1179                // `REQUIRED_FIELD_UNSET` code, not on this field.
1180                on_create: _,
1181                missing,
1182            } => {
1183                // `details.missing[]` carries every required-no-
1184                // default field unset on the create path so an
1185                // agent fixes the whole set in one retry. Each
1186                // entry echoes the type-level `write_rules` for
1187                // self-containment. Empty on the unset path.
1188                let missing_json: Vec<_> = missing
1189                    .iter()
1190                    .map(|m| {
1191                        serde_json::json!({
1192                            "field": m.key,
1193                            "description": m.description,
1194                            "enum_values": m.enum_values,
1195                            "write_rules": type_write_rules,
1196                        })
1197                    })
1198                    .collect();
1199                serde_json::json!({
1200                    "field": field,
1201                    "entity_type": entity_type,
1202                    "field_description": field_description,
1203                    "enum_values": enum_values,
1204                    "type_write_rules": type_write_rules,
1205                    "missing": missing_json,
1206                })
1207            }
1208            EngineError::MissingRequiredSection {
1209                entity_type,
1210                missing_count,
1211                sections,
1212                type_guidance,
1213            } => {
1214                let sections_json: Vec<_> = sections
1215                    .iter()
1216                    .map(|s| {
1217                        serde_json::json!({
1218                            "entity_type": s.entity_type,
1219                            "key": s.key,
1220                            "heading": s.heading,
1221                            "write_rules": s.write_rules,
1222                        })
1223                    })
1224                    .collect();
1225                serde_json::json!({
1226                    "entity_type": entity_type,
1227                    "missing_count": missing_count,
1228                    "sections": sections_json,
1229                    "type_guidance": type_guidance,
1230                })
1231            }
1232            EngineError::PatchSectionEmpty { section } => serde_json::json!({ "section": section }),
1233            EngineError::PatchOldNotFound {
1234                section,
1235                current_content,
1236                truncated,
1237            } => {
1238                serde_json::json!({
1239                    "section": section,
1240                    "current_content": current_content,
1241                    "truncated": truncated,
1242                })
1243            }
1244            EngineError::RelationshipCycle {
1245                rel_type,
1246                from,
1247                to,
1248                existing_path,
1249                path_truncated,
1250            } => {
1251                let path_json: Vec<_> = existing_path.iter().map(|id| id.to_string()).collect();
1252                serde_json::json!({
1253                    "rel_type": rel_type,
1254                    "from": from.to_string(),
1255                    "to": to.to_string(),
1256                    "existing_path": path_json,
1257                    "path_truncated": path_truncated,
1258                })
1259            }
1260            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
1261                serde_json::json!({ "from_mem": from_mem, "to_mem": to_mem })
1262            }
1263            EngineError::EmptyUpdate { id } => {
1264                serde_json::json!({
1265                    "id": id,
1266                    "recognised_keys": [
1267                        "sections", "append_sections", "patch_sections",
1268                        "metadata", "metadata_unset", "declare_relations", "relations_unset",
1269                    ],
1270                })
1271            }
1272            EngineError::RenameBlockedByCrossMemPolicy {
1273                from_mem,
1274                blocked_referrers,
1275            } => {
1276                let entries: Vec<_> = blocked_referrers
1277                    .iter()
1278                    .map(|r| {
1279                        serde_json::json!({
1280                            "from_mem": r.from_mem,
1281                            "to_mem": r.to_mem,
1282                            "count": r.count,
1283                        })
1284                    })
1285                    .collect();
1286                serde_json::json!({
1287                    "from_mem": from_mem,
1288                    "blocked_referrers": entries,
1289                })
1290            }
1291            EngineError::CrossMemTargetNotFound {
1292                target_id,
1293                target_mem,
1294            } => {
1295                serde_json::json!({ "target_id": target_id, "target_mem": target_mem })
1296            }
1297            EngineError::Validation(v) => v.details(),
1298            EngineError::MissingRequiredDescription {
1299                rel_type,
1300                from_id,
1301                to_id,
1302            } => {
1303                serde_json::json!({
1304                    "rel_type": rel_type,
1305                    "from_id": from_id,
1306                    "to_id": to_id,
1307                })
1308            }
1309            EngineError::DescriptionNotPermitted {
1310                rel_type,
1311                from_id,
1312                to_id,
1313            } => {
1314                serde_json::json!({
1315                    "rel_type": rel_type,
1316                    "from_id": from_id,
1317                    "to_id": to_id,
1318                })
1319            }
1320            EngineError::RelationManualAuthoringForbidden {
1321                rel_type,
1322                from_id,
1323                to_id,
1324                guidance,
1325            } => serde_json::json!({
1326                "rel_type": rel_type,
1327                "from_id": from_id,
1328                "to_id": to_id,
1329                "guidance": guidance,
1330            }),
1331            EngineError::MarkdownExportUnsupportedBackend {
1332                mem,
1333                active_backend,
1334                supported_backends,
1335            } => serde_json::json!({
1336                "mem": mem,
1337                "active_backend": active_backend,
1338                "supported_backends": supported_backends,
1339            }),
1340            EngineError::InvalidChangesCursor { mem, since } => serde_json::json!({
1341                "mem": mem,
1342                "since": since,
1343            }),
1344            EngineError::SchemaNotFound { mem, pin, sources } => serde_json::json!({
1345                "mem": mem,
1346                "pin": pin,
1347                "sources": sources,
1348            }),
1349            _ => serde_json::Value::Object(serde_json::Map::new()),
1350        }
1351    }
1352
1353    /// Render rich, fully-inlined recovery prose for the agent-visible
1354    /// text channel.
1355    ///
1356    /// Warnings
1357    /// already render their structured payload inline via
1358    /// `WarningHint::Display`; pre-fix errors with rich payloads
1359    /// collapsed to `Display` plus `format_inline_list_overflow`'s
1360    /// "+N more — see details.X" pointer pointing at a structured
1361    /// channel the agent's MCP client doesn't surface to the model.
1362    /// This method gives errors the same prose-rich rendering warnings
1363    /// have, so `result.content[0].text` is self-recoverable.
1364    ///
1365    /// Variants whose `Display` already inlines every recovery field
1366    /// (no truncation, no "see details" pointer) inherit the default
1367    /// trait impl — they just `to_string()`. Override only the
1368    /// variants that need richer rendering than `Display` provides.
1369    ///
1370    /// The structured `details()` channel is unchanged; consumers
1371    /// branching on `code` continue to receive the typed shape. The
1372    /// `Display` impl stays terse for logs, tracing, panic messages,
1373    /// and other non-agent consumers.
1374    pub fn prose_render(&self) -> String {
1375        match self {
1376            EngineError::HasIncomingRefs { id, referrers } => {
1377                let inline = render_referrers_inline(referrers);
1378                format!(
1379                    "entity {id} has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
1380                    n = referrers.len(),
1381                )
1382            }
1383            EngineError::MemHasIncomingRefs { mem, referrers } => {
1384                let inline = render_referrers_inline(referrers);
1385                format!(
1386                    "mem `{mem}` has {n} incoming reference(s) in write mems ({inline}); remove them first via memstead_relate --remove or memstead_update",
1387                    n = referrers.len(),
1388                )
1389            }
1390            EngineError::WikiLinkWithoutRelation { from_id, missing } => {
1391                let inline = missing
1392                    .iter()
1393                    .map(|m| m.to_string())
1394                    .collect::<Vec<_>>()
1395                    .join(", ");
1396                format!(
1397                    "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",
1398                    n = missing.len(),
1399                )
1400            }
1401            EngineError::RelationHasBodyLinks {
1402                from_id,
1403                to_id,
1404                rel_type,
1405                body_links,
1406            } => {
1407                let inline = body_links.join(", ");
1408                format!(
1409                    "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"
1410                )
1411            }
1412            EngineError::RelationshipCycle {
1413                rel_type,
1414                from,
1415                to,
1416                existing_path,
1417                path_truncated,
1418            } => {
1419                let path_inline = if existing_path.is_empty() {
1420                    String::from("(unavailable)")
1421                } else {
1422                    existing_path
1423                        .iter()
1424                        .map(|id| id.to_string())
1425                        .collect::<Vec<_>>()
1426                        .join(" → ")
1427                };
1428                let trunc = if *path_truncated {
1429                    " (path truncated)"
1430                } else {
1431                    ""
1432                };
1433                format!(
1434                    "creating edge {rel_type} from '{from}' to '{to}' would close a cycle in the {rel_type} subgraph — existing path: {path_inline}{trunc}; remove an edge along this path to break the cycle, then retry"
1435                )
1436            }
1437            EngineError::RequiredFieldUnset {
1438                field,
1439                entity_type,
1440                field_description,
1441                enum_values,
1442                type_write_rules,
1443                on_create,
1444                missing,
1445            } => {
1446                let desc_clause = field_description
1447                    .as_deref()
1448                    .map(|d| format!(" Field purpose: {d}."))
1449                    .unwrap_or_default();
1450                let enum_clause = if enum_values.is_empty() {
1451                    String::new()
1452                } else {
1453                    format!(" Allowed values: {}.", enum_values.join(", "))
1454                };
1455                let rules_clause = if type_write_rules.is_empty() {
1456                    String::new()
1457                } else {
1458                    format!(" Type-level write_rules: {}.", type_write_rules.join("; "))
1459                };
1460                // Path-aware wording — create
1461                // path says "not provided"; update path says "cannot
1462                // unset". Display impl shares the same dispatch via
1463                // `_required_field_unset_msg`.
1464                let lead = if *on_create {
1465                    format!(
1466                        "required metadata field '{field}' not provided — type '{entity_type}' declares the field as required and has no default for it"
1467                    )
1468                } else {
1469                    format!("cannot unset required field '{field}' for type '{entity_type}'")
1470                };
1471                // Multi-field accumulator. On the create path,
1472                // append a tail-list naming every other unset
1473                // required field so the agent's one-shot retry
1474                // covers all of them. The unset path's `missing`
1475                // is empty (or singleton), so the clause is empty
1476                // there.
1477                let tail_clause = if missing.len() > 1 {
1478                    let others: Vec<&str> =
1479                        missing.iter().skip(1).map(|m| m.key.as_str()).collect();
1480                    format!(" Also unset (declaration order): {}.", others.join(", "))
1481                } else {
1482                    String::new()
1483                };
1484                format!("{lead}.{desc_clause}{enum_clause}{rules_clause}{tail_clause}")
1485            }
1486            EngineError::MissingRequiredSection {
1487                entity_type,
1488                missing_count,
1489                sections,
1490                type_guidance,
1491            } => {
1492                let mut out = format!(
1493                    "missing {missing_count} required section(s) for type '{entity_type}':"
1494                );
1495                for s in sections {
1496                    let rules = if s.write_rules.is_empty() {
1497                        String::new()
1498                    } else {
1499                        format!(" — write_rules: {}", s.write_rules.join("; "))
1500                    };
1501                    out.push_str(&format!("\n  - '{}' ({}){rules}", s.key, s.heading));
1502                }
1503                if !type_guidance.is_empty() {
1504                    out.push_str("\nType guidance:");
1505                    for (etype, rules) in type_guidance {
1506                        if rules.is_empty() {
1507                            continue;
1508                        }
1509                        out.push_str(&format!("\n  - {etype}: {}", rules.join("; ")));
1510                    }
1511                }
1512                out
1513            }
1514            EngineError::Validation(v) => v.prose_render(),
1515            // Variants whose `Display` already inlines every recovery
1516            // field — title invariants, hash mismatch (already explains
1517            // the stub case), unknown mem / type (already prints
1518            // declared list verbatim), cross-mem gates, stubs,
1519            // patch errors, etc. — fall back to `Display`. Logs and
1520            // tracing consumers see the same string.
1521            _ => self.to_string(),
1522        }
1523    }
1524}
1525
1526/// Inline-render every [`ReferrerInfo`] without the truncation suffix
1527/// `format_inline_list_overflow` applies. Used by
1528/// [`EngineError::prose_render`]'s `HasIncomingRefs` /
1529/// `MemHasIncomingRefs` arms — the agent text channel inlines the
1530/// full list so recovery doesn't depend on the structured channel.
1531fn render_referrers_inline(referrers: &[ReferrerInfo]) -> String {
1532    referrers
1533        .iter()
1534        .map(|r| r.to_string())
1535        .collect::<Vec<_>>()
1536        .join(", ")
1537}
1538
1539/// Format the `RequiredFieldUnset` message. The same typed code
1540/// fires from two semantically-distinct call sites:
1541///
1542/// * The create path constructs the variant when the caller didn't
1543///   supply a required metadata field. The pre-fix message ("cannot
1544///   unset required field …") was misleading because the field was
1545///   never set in the first place — `on_create: true` flips the
1546///   wording to "required metadata field … not provided".
1547/// * The update path constructs the variant when the caller passed
1548///   `metadata_unset: ["field"]` against a required field. The
1549///   pre-fix wording is correct for this path — `on_create: false`
1550///   keeps it.
1551///
1552/// Both paths share recovery (provide the field); the typed code
1553/// stays `REQUIRED_FIELD_UNSET` for code-key branching consumers.
1554fn _required_field_unset_msg(field: &str, entity_type: &str, on_create: bool) -> String {
1555    if on_create {
1556        format!(
1557            "required metadata field '{field}' not provided — type '{entity_type}' declares the field as required and has no default for it"
1558        )
1559    } else {
1560        format!("cannot unset required field '{field}' for type '{entity_type}'")
1561    }
1562}
1563
1564/// Format the `HashMismatch` message. Stub-shaped entities have no
1565/// `content_hash` to compare against; rendering the empty `current:`
1566/// paren the way pre-fix code did misdirects an agent toward
1567/// hash-recovery via `memstead_entity` (which returns the same empty
1568/// hash). Surface the actual corrective action — pass
1569/// `expected_hash: ""` — instead.
1570fn _hash_mismatch_msg(id: &str, current: &str, is_stub: bool) -> String {
1571    if is_stub {
1572        format!(
1573            "hash mismatch for {id} — entity is a stub (no content_hash); pass expected_hash: \"\" to operate on stubs"
1574        )
1575    } else {
1576        format!("hash mismatch for {id} — entity was modified concurrently (current: {current})")
1577    }
1578}
1579
1580/// Errors surfaced by [`Engine::from_workspace_root`] (lean) and its
1581/// full counterpart (`memstead_git_branch::engine_from_workspace_root`).
1582///
1583/// The boot path layers three error sources: layout detection,
1584/// workspace-store load failures, per-mount backend instantiation
1585/// (folder + archive vs git-branch), and engine construction
1586/// (duplicate-mem checks). `#[from]` lifts the lower-layer types so
1587/// callers branch on a single error envelope.
1588#[derive(Debug, thiserror::Error)]
1589pub enum BootError {
1590    /// `detect_layout` returned [`crate::Layout::Empty`] — workspace
1591    /// root has no recognised layout marker. Operator runs
1592    /// `memstead mem-repo init` rather than booting against an empty
1593    /// directory.
1594    #[error("workspace at {0} is not initialised — run `memstead mem-repo init` first")]
1595    NotInitialised(PathBuf),
1596    /// Underlying [`crate::WorkspaceStoreAdapter`] load failed
1597    /// (missing config file, parse error, format-mismatch).
1598    #[error(transparent)]
1599    Store(#[from] crate::workspace_store::StoreError),
1600    /// Per-mount backend instantiation failed. Today: a mount
1601    /// declared `MountStorage::GitBranch` while the lean boot path
1602    /// only knows folder + archive.
1603    #[error(transparent)]
1604    Instantiate(#[from] crate::workspace_store::InstantiateError),
1605    /// Engine construction failed (duplicate mem names, etc.).
1606    #[error(transparent)]
1607    Engine(#[from] EngineError),
1608}
1609
1610#[cfg(test)]
1611mod plan05_subsystem_tests {
1612    use super::*;
1613
1614    /// A title-case body wiki-link refusal carries the
1615    /// slug-form retry under `proposed_slug` (mirroring `INVALID_TITLE`),
1616    /// so an agent that wrote `[[Idempotency]]` finds `idempotency` under
1617    /// the key it already knows.
1618    #[test]
1619    fn invalid_wiki_link_details_carry_proposed_slug_for_title_case() {
1620        let err = EngineError::InvalidWikiLinkTarget {
1621            raw: "Idempotency".to_string(),
1622            suggested: Some("idempotency".to_string()),
1623            section: "purpose".to_string(),
1624            link_source: "body_link".to_string(),
1625            reason: "slugs must be lowercase".to_string(),
1626        };
1627        let d = err.details();
1628        assert_eq!(d["proposed_slug"], "idempotency");
1629        assert_eq!(d["suggested"], "idempotency");
1630    }
1631
1632    /// `SCHEMA_NOT_FOUND` carries the fixed-order resolution
1633    /// diagnostics under `details.sources`: a right-name/wrong-version
1634    /// pin shows the built-in's available versions with
1635    /// `pinned_version_match = false`, and `remote` is the reserved
1636    /// `not_configured` slot. This is the agent-visible payload that
1637    /// tells the caller the name resolves but the version does not.
1638    #[test]
1639    fn schema_not_found_details_carry_fixed_order_source_diagnostics() {
1640        let requested: semver::Version = "99.0.0".parse().unwrap();
1641        let sources = SchemaSourceDiagnostic::for_failed_pin("default", &requested, &[]);
1642        let err = EngineError::SchemaNotFound {
1643            mem: "specs".to_string(),
1644            pin: "default@99.0.0".to_string(),
1645            sources,
1646        };
1647        assert_eq!(err.code(), "SCHEMA_NOT_FOUND");
1648        let d = err.details();
1649        assert_eq!(d["mem"], "specs");
1650        assert_eq!(d["pin"], "default@99.0.0");
1651        let src = d["sources"].as_array().expect("sources is an array");
1652        let labels: Vec<&str> = src.iter().map(|s| s["source"].as_str().unwrap()).collect();
1653        assert_eq!(labels, ["local_storage", "builtin", "remote"]);
1654        // The `default` builtin exists at 1.0.0 — right name, wrong
1655        // version: builtin enumerates it but the pin does not match.
1656        let builtin = &src[1];
1657        assert!(
1658            builtin["versions_found"]
1659                .as_array()
1660                .unwrap()
1661                .iter()
1662                .any(|v| v == "1.0.0"),
1663            "builtin must enumerate default@1.0.0, got {builtin:?}",
1664        );
1665        assert_eq!(builtin["pinned_version_match"], false);
1666        // No local storage was consulted (empty `consulted` slice).
1667        assert_eq!(src[0]["versions_found"].as_array().unwrap().len(), 0);
1668        // Remote is the reserved, unenumerated slot.
1669        assert_eq!(src[2]["status"], "not_configured");
1670        assert!(
1671            src[2].get("versions_found").is_some(),
1672            "remote still ships an (empty) versions_found list",
1673        );
1674    }
1675
1676    /// The ambiguous-grammar case suggests a
1677    /// colon-form (`mem:slug`), which is NOT a bare slug — it must not
1678    /// be promoted to `proposed_slug`.
1679    #[test]
1680    fn invalid_wiki_link_colon_form_suggestion_is_not_a_proposed_slug() {
1681        let err = EngineError::InvalidWikiLinkTarget {
1682            raw: "team/sub--thing".to_string(),
1683            suggested: Some("team/sub:thing".to_string()),
1684            section: "purpose".to_string(),
1685            link_source: "body_link".to_string(),
1686            reason: "ambiguous".to_string(),
1687        };
1688        let d = err.details();
1689        assert!(
1690            d["proposed_slug"].is_null(),
1691            "colon-form must not be a proposed_slug: {d}"
1692        );
1693        assert_eq!(d["suggested"], "team/sub:thing");
1694    }
1695
1696    /// A bad `--since` cursor is the typed `INVALID_CURSOR`
1697    /// code carrying the untruncated SHA in `details.since`.
1698    #[test]
1699    fn invalid_changes_cursor_code_and_details() {
1700        let sha = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1701        let err = EngineError::InvalidChangesCursor {
1702            mem: "specs".to_string(),
1703            since: sha.to_string(),
1704        };
1705        assert_eq!(err.code(), "INVALID_CURSOR");
1706        let d = err.details();
1707        assert_eq!(d["mem"], "specs");
1708        assert_eq!(
1709            d["since"], sha,
1710            "the offending SHA must ride untruncated in details"
1711        );
1712    }
1713}
1714
1715#[cfg(test)]
1716mod inline_list_tests {
1717    use super::*;
1718
1719    #[test]
1720    fn empty_list_renders_empty_string() {
1721        let items: Vec<String> = Vec::new();
1722        assert_eq!(format_inline_list_overflow(&items, "x"), "");
1723    }
1724
1725    #[test]
1726    fn list_at_cap_renders_all_no_overflow_suffix() {
1727        let items = vec!["a".to_string(), "b".to_string(), "c".to_string()];
1728        assert_eq!(format_inline_list_overflow(&items, "x"), "a, b, c");
1729    }
1730
1731    #[test]
1732    fn list_under_cap_renders_all_no_overflow_suffix() {
1733        let items = vec!["a".to_string(), "b".to_string()];
1734        assert_eq!(format_inline_list_overflow(&items, "x"), "a, b");
1735    }
1736
1737    #[test]
1738    fn list_over_cap_appends_count_and_field_name() {
1739        let items: Vec<String> = (0..23).map(|i| format!("id{i}")).collect();
1740        let rendered = format_inline_list_overflow(&items, "referrers");
1741        assert_eq!(rendered, "id0, id1, id2 +20 more — see details.referrers");
1742    }
1743
1744    #[test]
1745    fn list_six_items_truncates_to_three_plus_three() {
1746        let items: Vec<String> = (0..6).map(|i| format!("t{i}")).collect();
1747        let rendered = format_inline_list_overflow(&items, "missing");
1748        assert_eq!(rendered, "t0, t1, t2 +3 more — see details.missing");
1749    }
1750
1751    #[test]
1752    fn has_incoming_refs_display_inlines_first_three_referrer_ids() {
1753        let referrers: Vec<ReferrerInfo> = (0..23)
1754            .map(|i| ReferrerInfo {
1755                from_id: format!("specs--ref{i}"),
1756                rel_types: vec!["USES".to_string()],
1757                mem: "specs".to_string(),
1758            })
1759            .collect();
1760        let err = EngineError::HasIncomingRefs {
1761            id: "specs--hub".to_string(),
1762            referrers,
1763        };
1764        let s = err.to_string();
1765        // First three ids appear inline; the rest are summarised plus a
1766        // pointer to `details.referrers` on the structured channel.
1767        assert!(
1768            s.contains("specs--ref0, specs--ref1, specs--ref2"),
1769            "got: {s}"
1770        );
1771        assert!(s.contains("+20 more — see details.referrers"), "got: {s}");
1772        // Pre-fix the message only carried the count; check the count
1773        // still appears so callers parsing it for "N references" keep
1774        // working.
1775        assert!(s.contains("23 incoming reference"), "got: {s}");
1776    }
1777
1778    #[test]
1779    fn wiki_link_without_relation_display_lists_all_when_under_cap() {
1780        let missing = vec![
1781            MissingWikiLink {
1782                section_key: "specifies".to_string(),
1783                target_id: "specs--a".to_string(),
1784            },
1785            MissingWikiLink {
1786                section_key: "specifies".to_string(),
1787                target_id: "specs--b".to_string(),
1788            },
1789            MissingWikiLink {
1790                section_key: "rationale".to_string(),
1791                target_id: "specs--c".to_string(),
1792            },
1793        ];
1794        let err = EngineError::WikiLinkWithoutRelation {
1795            from_id: "specs--src".to_string(),
1796            missing,
1797        };
1798        let s = err.to_string();
1799        assert!(s.contains("specifies→specs--a"), "got: {s}");
1800        assert!(s.contains("specifies→specs--b"), "got: {s}");
1801        assert!(s.contains("rationale→specs--c"), "got: {s}");
1802        assert!(!s.contains("more — see details"), "got: {s}");
1803    }
1804
1805    #[test]
1806    fn wiki_link_without_relation_display_truncates_at_cap_with_pointer() {
1807        let missing: Vec<MissingWikiLink> = (0..6)
1808            .map(|i| MissingWikiLink {
1809                section_key: format!("s{i}"),
1810                target_id: format!("specs--t{i}"),
1811            })
1812            .collect();
1813        let err = EngineError::WikiLinkWithoutRelation {
1814            from_id: "specs--src".to_string(),
1815            missing,
1816        };
1817        let s = err.to_string();
1818        assert!(
1819            s.contains("s0→specs--t0, s1→specs--t1, s2→specs--t2"),
1820            "got: {s}"
1821        );
1822        assert!(s.contains("+3 more — see details.missing"), "got: {s}");
1823    }
1824
1825    #[test]
1826    fn relation_has_body_links_display_inlines_section_keys() {
1827        let err = EngineError::RelationHasBodyLinks {
1828            from_id: "specs--src".to_string(),
1829            to_id: "specs--dst".to_string(),
1830            rel_type: "USES".to_string(),
1831            body_links: vec!["specifies".to_string(), "rationale".to_string()],
1832        };
1833        let s = err.to_string();
1834        assert!(s.contains("specifies, rationale"), "got: {s}");
1835        assert!(!s.contains("more — see details"), "got: {s}");
1836    }
1837
1838    // --- prose_render -----------------------------------------------
1839    // The text
1840    // channel inlines full recovery payloads (no `+N more — see
1841    // details.X` pointer). Display stays terse for logs; prose_render
1842    // is the rich method MCP / CLI surfaces call for `content[0].text`.
1843
1844    #[test]
1845    fn prose_render_has_incoming_refs_inlines_every_referrer() {
1846        let referrers = (0..7)
1847            .map(|i| ReferrerInfo {
1848                from_id: format!("specs--r{i}"),
1849                rel_types: vec!["DEPENDS_ON".to_string()],
1850                mem: "specs".to_string(),
1851            })
1852            .collect();
1853        let err = EngineError::HasIncomingRefs {
1854            id: "specs--target".to_string(),
1855            referrers,
1856        };
1857        let prose = err.prose_render();
1858        for i in 0..7 {
1859            assert!(
1860                prose.contains(&format!("specs--r{i}")),
1861                "every referrer must appear inline; missing r{i} in: {prose}"
1862            );
1863        }
1864        assert!(!prose.contains("see details"), "got: {prose}");
1865        // Display stays terse with the overflow suffix.
1866        let display = err.to_string();
1867        assert!(
1868            display.contains("+4 more — see details.referrers"),
1869            "got: {display}"
1870        );
1871    }
1872
1873    #[test]
1874    fn prose_render_required_field_unset_inlines_field_description_and_rules() {
1875        // Update-path semantic: `on_create: false` → "cannot unset".
1876        let err = EngineError::RequiredFieldUnset {
1877            field: "verified_on".to_string(),
1878            entity_type: "requirement".to_string(),
1879            field_description: Some("ISO-8601 date the requirement was last validated".to_string()),
1880            enum_values: vec![],
1881            type_write_rules: vec!["bump verified_on on every status change".to_string()],
1882            on_create: false,
1883            missing: Vec::new(),
1884        };
1885        let prose = err.prose_render();
1886        assert!(
1887            prose.contains("ISO-8601 date"),
1888            "field_description missing: {prose}"
1889        );
1890        assert!(
1891            prose.contains("bump verified_on"),
1892            "type_write_rules missing: {prose}"
1893        );
1894        assert!(!prose.contains("see details"), "got: {prose}");
1895        assert!(
1896            prose.contains("cannot unset"),
1897            "update-path wording must say 'cannot unset': {prose}"
1898        );
1899    }
1900
1901    /// Create
1902    /// path renders "not provided" instead of "cannot unset" — the
1903    /// pre-fix wording was misleading on a path where nothing was
1904    /// ever set in the first place.
1905    #[test]
1906    fn prose_render_required_field_unset_create_path_uses_not_provided_wording() {
1907        let err = EngineError::RequiredFieldUnset {
1908            field: "verified_on".to_string(),
1909            entity_type: "requirement".to_string(),
1910            field_description: Some("ISO-8601 date the requirement was last validated".to_string()),
1911            enum_values: vec![],
1912            type_write_rules: vec![],
1913            on_create: true,
1914            missing: Vec::new(),
1915        };
1916        let prose = err.prose_render();
1917        assert!(
1918            prose.contains("not provided"),
1919            "create-path wording must say 'not provided': {prose}"
1920        );
1921        assert!(
1922            !prose.contains("cannot unset"),
1923            "create-path wording must NOT say 'cannot unset': {prose}"
1924        );
1925        // Same Display dispatch — `to_string()` mirrors `prose_render`'s
1926        // create-path lead.
1927        let display = err.to_string();
1928        assert!(
1929            display.contains("not provided"),
1930            "Display must match: {display}"
1931        );
1932        assert!(
1933            !display.contains("cannot unset"),
1934            "Display must match: {display}"
1935        );
1936    }
1937
1938    /// The
1939    /// create-path multi-field accumulator surfaces every required-
1940    /// no-default field unset in `details.missing[]`. Each entry
1941    /// carries `{field, description, enum_values, write_rules}` so
1942    /// the agent fixes the whole set in one retry. The singular
1943    /// `details.field` echoes `missing[0].field` for back-compat.
1944    #[test]
1945    fn details_required_field_unset_multi_field_envelope_shape() {
1946        use crate::runtime_validator::MissingRequiredField;
1947        let err = EngineError::RequiredFieldUnset {
1948            field: "decided_on".to_string(),
1949            entity_type: "decision".to_string(),
1950            field_description: Some("Date the decision was accepted. ISO YYYY-MM-DD.".to_string()),
1951            enum_values: vec![],
1952            type_write_rules: vec!["status transitions: proposed → accepted".to_string()],
1953            on_create: true,
1954            missing: vec![
1955                MissingRequiredField {
1956                    entity_type: "decision".to_string(),
1957                    key: "decided_on".to_string(),
1958                    description: "Date the decision was accepted. ISO YYYY-MM-DD.".to_string(),
1959                    enum_values: vec![],
1960                },
1961                MissingRequiredField {
1962                    entity_type: "decision".to_string(),
1963                    key: "deciders".to_string(),
1964                    description: "Who made the call. Comma-separated handles.".to_string(),
1965                    enum_values: vec![],
1966                },
1967            ],
1968        };
1969        let details = err.details();
1970        // Back-compat: singular `field` echoes the first-missing entry.
1971        assert_eq!(details["field"].as_str(), Some("decided_on"));
1972        // Multi-field accumulator surfaces every entry in
1973        // declaration order.
1974        let missing = details["missing"].as_array().expect("missing[] array");
1975        assert_eq!(missing.len(), 2);
1976        assert_eq!(missing[0]["field"].as_str(), Some("decided_on"));
1977        assert_eq!(missing[1]["field"].as_str(), Some("deciders"));
1978        // First entry's `field` agrees with the singular shape.
1979        assert_eq!(details["field"], missing[0]["field"]);
1980        // Per-entry `write_rules` echoes the type-level rules for
1981        // self-containment.
1982        assert_eq!(missing[0]["write_rules"], details["type_write_rules"]);
1983        // Prose mentions both field names so the agent reading the
1984        // text channel sees the whole set without crossing into the
1985        // structured channel.
1986        let prose = err.prose_render();
1987        assert!(prose.contains("decided_on"), "got: {prose}");
1988        assert!(prose.contains("deciders"), "got: {prose}");
1989    }
1990
1991    /// The unset path's singular shape is
1992    /// preserved — `missing[]` is empty (the user targeted one field
1993    /// by definition); the singular fields above are authoritative.
1994    /// The typed code stays `REQUIRED_FIELD_UNSET`.
1995    #[test]
1996    fn details_required_field_unset_singular_shape_for_unset_path() {
1997        let err = EngineError::RequiredFieldUnset {
1998            field: "decided_on".to_string(),
1999            entity_type: "decision".to_string(),
2000            field_description: Some("…".to_string()),
2001            enum_values: vec![],
2002            type_write_rules: vec![],
2003            on_create: false,
2004            missing: Vec::new(),
2005        };
2006        let details = err.details();
2007        assert_eq!(details["field"].as_str(), Some("decided_on"));
2008        let missing = details["missing"]
2009            .as_array()
2010            .expect("missing[] array present");
2011        assert!(missing.is_empty(), "unset-path missing[] must be empty");
2012        assert_eq!(err.code(), "REQUIRED_FIELD_UNSET");
2013    }
2014
2015    #[test]
2016    fn prose_render_missing_required_section_enumerates_each_section_with_write_rules() {
2017        use crate::runtime_validator::MissingRequiredSection;
2018        let sections = vec![
2019            MissingRequiredSection {
2020                entity_type: "spec".to_string(),
2021                key: "purpose".to_string(),
2022                heading: "Purpose".to_string(),
2023                write_rules: vec!["one-sentence statement of intent".to_string()],
2024            },
2025            MissingRequiredSection {
2026                entity_type: "spec".to_string(),
2027                key: "scope".to_string(),
2028                heading: "Scope".to_string(),
2029                write_rules: vec!["what is in and out of scope".to_string()],
2030            },
2031        ];
2032        let mut type_guidance: std::collections::BTreeMap<String, Vec<String>> = Default::default();
2033        type_guidance.insert(
2034            "spec".to_string(),
2035            vec!["specs are immutable once stable".to_string()],
2036        );
2037        let err = EngineError::MissingRequiredSection {
2038            entity_type: "spec".to_string(),
2039            missing_count: 2,
2040            sections,
2041            type_guidance,
2042        };
2043        let prose = err.prose_render();
2044        assert!(prose.contains("purpose"), "got: {prose}");
2045        assert!(prose.contains("scope"), "got: {prose}");
2046        assert!(
2047            prose.contains("one-sentence statement of intent"),
2048            "got: {prose}"
2049        );
2050        assert!(
2051            prose.contains("specs are immutable once stable"),
2052            "got: {prose}"
2053        );
2054        assert!(!prose.contains("see details"), "got: {prose}");
2055    }
2056
2057    #[test]
2058    fn prose_render_relationship_cycle_inlines_existing_path() {
2059        use crate::entity::EntityId;
2060        let path = vec![
2061            EntityId::canonical("specs--a"),
2062            EntityId::canonical("specs--b"),
2063            EntityId::canonical("specs--c"),
2064            EntityId::canonical("specs--a"),
2065        ];
2066        let err = EngineError::RelationshipCycle {
2067            rel_type: "PART_OF".to_string(),
2068            from: EntityId::canonical("specs--a"),
2069            to: EntityId::canonical("specs--c"),
2070            existing_path: path,
2071            path_truncated: false,
2072        };
2073        let prose = err.prose_render();
2074        assert!(
2075            prose.contains("specs--a → specs--b → specs--c → specs--a"),
2076            "got: {prose}"
2077        );
2078        assert!(!prose.contains("see details"), "got: {prose}");
2079    }
2080
2081    #[test]
2082    fn prose_render_falls_back_to_display_for_trivial_variants() {
2083        // ReadOnlyMount has no list payload — Display already inlines
2084        // the recovery context.
2085        let err = EngineError::ReadOnlyMount("archive-2024".to_string());
2086        assert_eq!(err.prose_render(), err.to_string());
2087    }
2088}