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