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