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}