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