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