Skip to main content

memstead_base/engine/mutation/
update.rs

1//! `Engine::update_entity` and `Engine::batch_update` — rewrite an
2//! entity's sections / metadata in place, optimistically-locked.
3
4use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::EntityId;
8use crate::entity::generator::generate_markdown;
9use crate::entity::parser::parse_markdown;
10use crate::entity::store_builder::push_entities_into_store;
11use crate::ops::{ModifiedMetadata, ModifiedSections, WarningHint};
12use crate::provenance::{Provenance, ProvenanceKind};
13use crate::runtime_validator::{
14    parse_metadata_value, validate_section_content, validate_section_keys,
15    validate_updatable_section, validate_writable_metadata_key,
16};
17use crate::vcs::{Actor, ClientId, CommitContext};
18use crate::workspace::MountCapability;
19
20use super::super::{Engine, EngineError, UpdateEntityArgs, UpdateEntityOutcome};
21use super::{
22    PATCH_OLD_NOT_FOUND_CONTENT_CAP, make_stub, today_iso, unknown_type_error,
23    validate_relation_target_grammar,
24};
25use crate::engine::outcomes::RelationDeclared;
26use crate::entity::{Entity, Relationship};
27
28use std::sync::Arc;
29
30/// Result of [`Engine::prepare_update`] — the validation + markdown
31/// step split out of the commit so the batch path can prepare every
32/// item before committing the whole set atomically.
33enum PrepareOutcome {
34    /// No commit is needed: the no-op short-circuit (content unchanged)
35    /// and the dry-run preview both return a finished outcome here.
36    Done(UpdateEntityOutcome),
37    /// A real change whose post-mutation markdown is ready to stage +
38    /// commit.
39    Prepared(PreparedUpdate),
40}
41
42/// Everything the commit step needs to stage one prepared update's
43/// disk write and build its outcome. Carries no commit SHA — that's
44/// produced when the (single or batched) commit lands.
45struct PreparedUpdate {
46    mount_idx: usize,
47    id: EntityId,
48    mem: String,
49    type_def: Arc<memstead_schema::TypeDefinition>,
50    file_path: String,
51    markdown: String,
52    /// Body wiki-link targets the entity had *before* this mutation —
53    /// the GC sweep scopes orphan-stub detection to these.
54    prev_body_targets: std::collections::HashSet<EntityId>,
55    modified_date: String,
56    modified_sections: ModifiedSections,
57    modified_metadata: ModifiedMetadata,
58    warnings: Vec<WarningHint>,
59    relations_declared: Vec<RelationDeclared>,
60}
61
62/// The store-side results of applying a prepared write — filled in
63/// after the commit lands by [`Engine::apply_prepared_to_store`].
64struct AppliedWrite {
65    content_hash: String,
66    title: String,
67    orphan_stubs_removed: Vec<EntityId>,
68}
69
70impl Engine {
71    /// Update an entity's sections and/or metadata.
72    ///
73    /// Same six-concern shape as [`Engine::create_entity`]. Optimistic
74    /// locking via `args.expected_hash`: when `Some`, must match the
75    /// store's current `content_hash` or returns
76    /// [`EngineError::HashMismatch`]. The new engine's MCP-facing
77    /// callers should always pass the hash; `None` is the
78    /// `--force`-style escape hatch.
79    ///
80    /// Internally a two-step pipeline: [`Self::prepare_update`] runs
81    /// all validation and computes the post-mutation markdown without
82    /// committing, then [`Self::commit_prepared_update`] stages and
83    /// commits the result. The split lets [`Self::batch_update`]
84    /// prepare every item up front and commit the whole batch as one
85    /// atomic unit.
86    pub fn update_entity(
87        &mut self,
88        args: UpdateEntityArgs,
89        actor: Actor,
90        client: Option<&ClientId>,
91        note: Option<&str>,
92    ) -> Result<UpdateEntityOutcome, EngineError> {
93        // Reload-before-operation: probe the mem ref and reload if a
94        // sibling advanced it, so the `expected_hash` compare inside
95        // `prepare_update` runs against current truth. A stale hash for
96        // the targeted entity then trips a real `HASH_MISMATCH`; an
97        // unrelated concurrent write leaves this entity's hash intact
98        // and the update proceeds. The drift notice rides the outcome.
99        let mut drift_warnings = self.reload_if_stale(Some(args.id.mem()));
100        let mut outcome = match self.prepare_update(args)? {
101            PrepareOutcome::Done(outcome) => outcome,
102            PrepareOutcome::Prepared(prepared) => {
103                self.commit_prepared_update(prepared, actor, client, note)?
104            }
105        };
106        drift_warnings.append(&mut outcome.warnings);
107        outcome.warnings = drift_warnings;
108        Ok(outcome)
109    }
110
111    /// Stage the prepared disk write, commit it as one commit, append
112    /// provenance, and apply the change to the in-memory store — the
113    /// single-update tail of [`Self::update_entity`]. The batch path
114    /// drives the same steps but commits once across all items.
115    fn commit_prepared_update(
116        &mut self,
117        prepared: PreparedUpdate,
118        actor: Actor,
119        client: Option<&ClientId>,
120        note: Option<&str>,
121    ) -> Result<UpdateEntityOutcome, EngineError> {
122        let backend = self.mounts[prepared.mount_idx].backend.as_ref();
123        backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
124        let commit_subject = format!("memstead: update {}", prepared.id);
125        let ctx = CommitContext {
126            actor,
127            client: client.cloned(),
128            tool: Some("update_entity"),
129            note: note.map(String::from),
130            logical_operation_id: None,
131            entity_ids: None,
132        };
133        let commit_sha = backend.commit(&commit_subject, &ctx)?;
134        backend.append_provenance(&Provenance::new(
135            std::time::SystemTime::now(),
136            ProvenanceKind::Update,
137            Some(prepared.id.to_string()),
138            actor,
139            client.cloned(),
140            note.map(String::from),
141        ))?;
142        self.record_self_write(prepared.mount_idx, &commit_sha);
143
144        let applied = self.apply_prepared_to_store(&prepared)?;
145
146        self.invalidate_communities();
147        self.invalidate_search_indexes();
148
149        // `require_notes` provenance nudge — single engine-level
150        // enforcement point. Only reached on the real-commit path; the
151        // no-op and dry-run prepare outcomes never demand a note.
152        let mut warnings = prepared.warnings;
153        if let Some(w) = self.note_missing_warning("update_entity", note) {
154            warnings.push(w);
155        }
156
157        Ok(UpdateEntityOutcome {
158            id: prepared.id.clone(),
159            title: applied.title,
160            file_path: prepared.file_path,
161            content_hash: applied.content_hash,
162            commit_sha,
163            modified_date: prepared.modified_date,
164            orphan_stubs_removed: applied.orphan_stubs_removed,
165            modified_sections: prepared.modified_sections,
166            modified_metadata: prepared.modified_metadata,
167            prospective_hash: None,
168            warnings,
169            relations_declared: prepared.relations_declared,
170        })
171    }
172
173    /// Parse the prepared markdown, push it into the in-memory store,
174    /// re-map alias-target edge sources, and GC any stub the mutation
175    /// orphaned. Shared post-commit store-application step for the
176    /// single-update and batch paths — does NOT touch the backend or
177    /// commit (the caller has already staged + committed the disk
178    /// write).
179    fn apply_prepared_to_store(
180        &mut self,
181        prepared: &PreparedUpdate,
182    ) -> Result<AppliedWrite, EngineError> {
183        let parse_result = parse_markdown(
184            &prepared.markdown,
185            &prepared.file_path,
186            prepared.type_def.as_ref(),
187            &prepared.mem,
188        )
189        .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
190        let content_hash = parse_result.entity.content_hash.clone();
191        let title = parse_result.entity.title.clone();
192        let fallback = engine_fallback_type();
193        push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
194        crate::entity::store_builder::remap_alias_target_edge_sources(
195            &mut self.store,
196            &self.schemas,
197        );
198        let orphan_stubs_removed =
199            super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
200        Ok(AppliedWrite {
201            content_hash,
202            title,
203            orphan_stubs_removed,
204        })
205    }
206
207    /// Validate an update and compute its post-mutation markdown
208    /// *without* committing. Returns [`PrepareOutcome::Done`] for the
209    /// no-op / dry-run short-circuits (which never commit) and
210    /// [`PrepareOutcome::Prepared`] for a real change whose write the
211    /// caller stages + commits. May mutate the store in place via the
212    /// alias-synthesis auto-stub upsert; the batch path snapshots the
213    /// store before preparing so a refused batch can roll that back.
214    fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
215        let id = &args.id;
216        let mem = id.mem().to_string();
217
218        let mount_idx = self
219            .mounts
220            .iter()
221            .position(|m| m.mount.mem == mem)
222            .ok_or_else(|| EngineError::UnknownMem(mem.clone()))?;
223        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
224            return Err(EngineError::ReadOnlyMount(mem));
225        }
226
227        let entity = self
228            .store
229            .get(id)
230            .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
231
232        // Snapshot the prev entity's body wiki-link set before any
233        // subsequent `&mut self` reborrow burns the `entity` borrow.
234        // Fed to the alias-synthesis pass so the GC step can compare
235        // prev vs. next body links and drop pointer-rel-type relations
236        // whose target was a body link before but isn't any more.
237        let prev_body_targets = super::collect_body_link_targets(entity);
238
239        // Stub guard — stubs have no body, no metadata, no
240        // schema-resolved type to validate against. The recovery is
241        // `memstead_create` (stub adoption preserves incoming
242        // references). Pre-Item-02 the update path fell through to
243        // the `type_def` lookup below and surfaced the cryptic
244        // `UnknownType { name: "" }` cascade. Mirrors the
245        // `StubCannotRelate` guard on `memstead_relate`.
246        if entity.stub {
247            return Err(EngineError::StubNotUpdatable { id: id.to_string() });
248        }
249
250        // Skip the hash check on dry_run — full's dry_run is the
251        // designated stale-hash recovery path. Agents preview a
252        // change without holding a fresh hash, get back the current
253        // `content_hash` and a `prospective_hash`, then call the
254        // real update with `expected_hash = content_hash`.
255        if !args.dry_run
256            && let Some(expected) = args.expected_hash.as_deref()
257            && entity.content_hash != expected
258        {
259            return Err(EngineError::HashMismatch {
260                id: id.to_string(),
261                current: entity.content_hash.clone(),
262                is_stub: entity.stub,
263            });
264        }
265
266        // Empty-mutation guard. After existence/stub/hash
267        // gates so the more-specific errors fire first. A payload
268        // with no recognised mutation content refuses BEFORE any
269        // mutation work runs so a misspelled or omitted mutation
270        // key (which deserialised to empty defaults under the
271        // lenient pre-fix posture) doesn't silently land as
272        // `succeeded: N, action: "updated", commit_sha: ""`.
273        // Distinct from `UPDATE_NOOP` (a warning that fires when
274        // mutation content was provided but matched the current
275        // entity state) — the two are different states and ship
276        // different envelopes.
277        if args.sections.is_empty()
278            && args.append_sections.is_empty()
279            && args.patch_sections.is_empty()
280            && args.metadata.is_empty()
281            && args.metadata_unset.is_empty()
282            && args.declare_relations.is_empty()
283            && args.relations_unset.is_empty()
284        {
285            return Err(EngineError::EmptyUpdate { id: id.to_string() });
286        }
287
288        let schema = self
289            .schemas
290            .get(&mem)
291            .expect("schema present for every registered mount")
292            .clone();
293        let type_def = schema
294            .get_type(&entity.entity_type)
295            .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
296
297        // Mode-conflict: the same section key may not appear in
298        // more than one of `sections`, `append_sections`,
299        // `patch_sections`. Mirrors full's
300        // `EngineError::ConflictingSectionModes`. Three-way check:
301        // build the conflict list per key and reject when ≥2 modes
302        // claim it.
303        for key in args.sections.keys() {
304            let mut modes = vec!["sections".to_string()];
305            if args.append_sections.contains_key(key) {
306                modes.push("append_sections".to_string());
307            }
308            if args.patch_sections.contains_key(key) {
309                modes.push("patch_sections".to_string());
310            }
311            if modes.len() > 1 {
312                return Err(EngineError::ConflictingSectionModes {
313                    section: key.clone(),
314                    modes,
315                });
316            }
317        }
318        for key in args.append_sections.keys() {
319            if args.patch_sections.contains_key(key) {
320                return Err(EngineError::ConflictingSectionModes {
321                    section: key.clone(),
322                    modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
323                });
324            }
325        }
326
327        validate_section_keys(
328            args.sections
329                .keys()
330                .chain(args.append_sections.keys())
331                .chain(args.patch_sections.keys())
332                .map(String::as_str),
333            type_def.as_ref(),
334        )?;
335        // Refuse embedded `^## ` in section content on every update path
336        // that writes section bodies: `sections` (replace) and
337        // `append_sections` (append). `patch_sections` replaces a
338        // substring — its `new` text feeds into the eventual section
339        // body so it gets the same gate.
340        validate_section_content(
341            args.sections
342                .iter()
343                .map(|(k, v)| (k.as_str(), v.as_str()))
344                .chain(
345                    args.append_sections
346                        .iter()
347                        .map(|(k, v)| (k.as_str(), v.as_str())),
348                )
349                .chain(
350                    args.patch_sections
351                        .iter()
352                        .map(|(k, p)| (k.as_str(), p.new.as_str())),
353                ),
354        )?;
355        for key in args.sections.keys() {
356            validate_updatable_section(key.as_str(), type_def.as_ref())?;
357        }
358        for key in args.append_sections.keys() {
359            validate_updatable_section(key.as_str(), type_def.as_ref())?;
360        }
361        for key in args.patch_sections.keys() {
362            validate_updatable_section(key.as_str(), type_def.as_ref())?;
363        }
364        for key in args.metadata.keys() {
365            validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
366        }
367        for key in &args.metadata_unset {
368            validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
369        }
370
371        // Reject the same key appearing in `metadata` (set) and
372        // `metadata_unset` — the wire contract is that the conflict is
373        // a hard error. Caught before any required-field /
374        // parse-metadata check so the resolution ("pick one map") is
375        // unambiguous regardless of whether the overlapping key is
376        // required.
377        let mut overlap: Vec<String> = args
378            .metadata
379            .keys()
380            .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
381            .cloned()
382            .collect();
383        if !overlap.is_empty() {
384            overlap.sort();
385            overlap.dedup();
386            return Err(EngineError::SetAndUnsetConflict { keys: overlap });
387        }
388
389        // Repair-power gate:
390        // repair-shaped input is accepted only when the entity
391        // currently fails the conformance check against the effective
392        // schema. Conformance is per-entity-local and cheap, so the
393        // gate runs in pre-validation; no agent-settable flag exists —
394        // the entity's own state is the evidence. A pure-consistency
395        // break does not open the gate (those have ungated repair
396        // paths: `memstead_relate(remove)` and the additive params).
397        if !args.relations_unset.is_empty() {
398            let findings = crate::ops::integrity::entity_conformance_findings(
399                &self.store,
400                entity,
401                schema.as_ref(),
402                &self.schemas,
403            );
404            if findings.is_empty() {
405                return Err(EngineError::RepairNotNeeded {
406                    id: id.to_string(),
407                    recovery: "use memstead_relate(remove=true) to detach an edge from a                                conformant entity, or the additive memstead_update params                                to evolve it"
408                        .to_string(),
409                });
410            }
411        }
412
413        let mut next = entity.clone();
414
415        // Repair-shaped removals, applied before declarations so a
416        // repair can drop and re-shape relations in one atomic
417        // update. Absent (rel_type, target) pairs are silent no-ops,
418        // symmetric with `metadata_unset`. The strict post-state
419        // validation below still runs — repair widens accepted
420        // inputs, never admissible outputs.
421        for unset in &args.relations_unset {
422            let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
423                .unwrap_or_else(|_| unset.rel_type.clone());
424            next.relationships
425                .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
426        }
427
428        // Atomic batched relation declarations. Validated and applied
429        // before the section/metadata changes so the strict
430        // wiki-link/relation validator at the end of this fn sees
431        // the freshly-declared relations as part of the post-state.
432        // Same vocabulary + shape + grammar gates `memstead_relate`
433        // runs; auto-stubs absent Write-mem targets identically
434        // to the relate path. Returns the (rel_type, target,
435        // target_was_stubbed) triples in `relations_declared` on
436        // the outcome so the agent sees what landed.
437        let relations_declared = apply_declare_relations(
438            self,
439            &mut next,
440            &args.declare_relations,
441            &mem,
442            mount_idx,
443            type_def.as_ref(),
444            schema.as_ref(),
445        )?;
446
447        let mut modified_sections: Vec<String> = Vec::new();
448        for (key, body) in args.sections {
449            modified_sections.push(key.clone());
450            next.sections.insert(key, body);
451        }
452
453        // Apply append_sections after replace. Empty/absent body
454        // is replaced wholesale with the append value; otherwise
455        // a `\n` separator joins the two. Mirrors full.
456        let mut modified_sections_appended: Vec<String> = Vec::new();
457        for (key, value) in args.append_sections {
458            let existing = next.sections.get(&key).cloned().unwrap_or_default();
459            let new_content = if existing.trim().is_empty() {
460                value
461            } else {
462                format!("{existing}\n{value}")
463            };
464            next.sections.insert(key.clone(), new_content);
465            modified_sections_appended.push(key);
466        }
467
468        // Apply patch_sections after append. Find-and-replace the
469        // `old` substring with `new`; `all` flips between
470        // first-occurrence (replacen 1) and every-occurrence
471        // (replace). Empty/absent section is rejected with
472        // PatchSectionEmpty; missing-`old` rejected with
473        // PatchOldNotFound carrying a UTF-8-safe truncated snapshot
474        // of the current body. Mirrors full.
475        let mut modified_sections_patched: Vec<String> = Vec::new();
476        for (key, patch) in args.patch_sections {
477            let existing = next
478                .sections
479                .get(&key)
480                .ok_or_else(|| EngineError::PatchSectionEmpty {
481                    section: key.clone(),
482                })?
483                .clone();
484            if !existing.contains(&patch.old) {
485                let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
486                let truncated = existing.len() > cap;
487                // Truncate at a UTF-8 char boundary to avoid
488                // splitting a code point.
489                let mut cut = cap.min(existing.len());
490                while cut > 0 && !existing.is_char_boundary(cut) {
491                    cut -= 1;
492                }
493                let current_content = if truncated {
494                    existing[..cut].to_string()
495                } else {
496                    existing.clone()
497                };
498                return Err(EngineError::PatchOldNotFound {
499                    section: key,
500                    current_content,
501                    truncated,
502                });
503            }
504            let patched = if patch.all {
505                existing.replace(&patch.old, &patch.new)
506            } else {
507                existing.replacen(&patch.old, &patch.new, 1)
508            };
509            next.sections.insert(key.clone(), patched);
510            modified_sections_patched.push(key);
511        }
512
513        let mut modified_metadata_set: Vec<String> = Vec::new();
514        for (key, value) in &args.metadata {
515            let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
516            modified_metadata_set.push(key.clone());
517            next.metadata.insert(key.clone(), parsed);
518        }
519
520        let mut modified_metadata_unset: Vec<String> = Vec::new();
521        for key in args.metadata_unset {
522            // Reject unset on required fields — the pre-remove check
523            // carries the recovery payload (field_description,
524            // enum_values, type_write_rules) so MCP envelopes surface
525            // the full REQUIRED_FIELD_UNSET shape.
526            let field_def = type_def.metadata_field(&key);
527            let is_required = field_def.map(|f| !f.optional).unwrap_or(false);
528            if is_required {
529                let (field_description, enum_values) = match field_def {
530                    Some(f) => (
531                        Some(f.description.clone()),
532                        f.enum_values.clone().unwrap_or_default(),
533                    ),
534                    None => (None, Vec::new()),
535                };
536                return Err(EngineError::RequiredFieldUnset {
537                    field: key,
538                    entity_type: type_def.name.clone(),
539                    field_description,
540                    enum_values,
541                    type_write_rules: type_def.write_rules.clone(),
542                    // Update path — caller passed
543                    // `metadata_unset: ["field"]` against a required
544                    // field. The wording ("cannot unset required
545                    // field …") is semantically correct for this
546                    // path.
547                    on_create: false,
548                    // The unset path targets one field per call by
549                    // definition, so the multi-field accumulator
550                    // stays empty here — the singular fields above
551                    // are authoritative.
552                    missing: Vec::new(),
553                });
554            }
555            if next.metadata.shift_remove(&key).is_some() {
556                modified_metadata_unset.push(key);
557            }
558        }
559
560        // Delay the auto-stamp until AFTER the no-op short-circuit. Pre-fix
561        // this branch overwrote `last_modified` with `today_iso()`
562        // before the bytes-compare, so the prospective markdown
563        // always differed from the on-disk bytes (the schema's
564        // `last_modified` was already populated with a full ISO
565        // timestamp on the prior write, but `today_iso()` returns
566        // date-only), and the no-op compare never matched. Compute
567        // `today` for later use but don't stamp `next` yet.
568        let today = today_iso();
569
570        // Alias-synthesis pass: for schemas declaring
571        // `alias_target_rel_type`, append engine-emitted relations of
572        // that rel-type for every body wiki-link not already backed,
573        // and GC pointer-rel-type relations whose target was a body
574        // wiki-link in the prev state but isn't in next. Cross-mem
575        // refusal aborts the update — no partial state.
576        //
577        // The returned `Vec<Relationship>` is the per-call set of
578        // synthesised relations; feed it into the auto-stub warning
579        // emission below.
580        let (synthesised_relations, self_link_ignored) =
581            super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
582
583        // Alias-existence invariant: every body wiki-link must be
584        // backed by an entry in `entity.relationships`. The validator
585        // runs against the *full* post-mutation state (not just the
586        // delta), so a mutation that leaves an existing unbacked link
587        // in place still fails — forcing cleanup of historical drift.
588        let missing = super::scan_wikilinks_without_relation(&next)?;
589        if !missing.is_empty() {
590            return Err(EngineError::WikiLinkWithoutRelation {
591                from_id: id.to_string(),
592                missing: missing
593                    .into_iter()
594                    .map(|(section_key, target)| crate::engine::MissingWikiLink {
595                        section_key,
596                        target_id: target.to_string(),
597                    })
598                    .collect(),
599            });
600        }
601
602        let file_path = next.file_path.clone();
603
604        // The bytes-compare runs against the pre-stamp markdown so the
605        // auto-timestamp doesn't synthesise a false delta. When the
606        // user-visible payload (sections, user-set metadata,
607        // declared relations) didn't change, the pre-stamp markdown
608        // matches the on-disk bytes byte-for-byte; we short-circuit
609        // and return with `last_modified` preserved at its pre-call
610        // value. Real changes fall through; we stamp + regenerate
611        // below.
612        let markdown_pre_stamp = generate_markdown(&next, type_def.as_ref());
613
614        // No-op short-circuit. When the pre-stamp markdown's hash
615        // matches the entity's current `content_hash`, the
616        // post-mutation user-visible state equals the on-disk state.
617        // Skip the disk write, the commit, the provenance append,
618        // and the store re-parse. Mirrors `relate.rs`'s
619        // `NoOpAlreadyPresent` / `NoOpAbsent` and `rename.rs`'s
620        // slug-noop short-circuits: empty `commit_sha`, unchanged
621        // `content_hash`, preserved `last_modified`, typed
622        // `UpdateNoop` warning. Skipped on the dry_run path because
623        // the dry_run preview semantics document a separate
624        // non-committing shape with `prospective_hash: Some(_)` and
625        // the unchanged `content_hash`; conflating the two would
626        // lose the prospective-hash channel callers use to chain a
627        // follow-up real update with `expected_hash`.
628        if !args.dry_run {
629            let prospective_hash = crate::entity::parser::compute_hash(&markdown_pre_stamp);
630            // `next` was cloned from `entity` at the top of this fn
631            // and its `content_hash` field has not been recomputed
632            // since — equals the on-disk value. Reading from `next`
633            // rather than `entity` avoids extending the `self.store`
634            // immutable borrow past the mutable borrow taken by
635            // `apply_declare_relations`.
636            if prospective_hash == next.content_hash {
637                // No-op: report the preserved `last_modified` from
638                // the pre-stamp `next` (which still carries the
639                // entity's on-disk value because we haven't run the
640                // auto-stamp yet on this branch).
641                let modified_date = next
642                    .metadata
643                    .get("last_modified")
644                    .and_then(|v| v.as_str().map(str::to_string))
645                    .unwrap_or_default();
646                return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
647                    id: id.clone(),
648                    title: next.title.clone(),
649                    file_path,
650                    content_hash: next.content_hash.clone(),
651                    commit_sha: String::new(),
652                    modified_date,
653                    // No-op: the prospective hash equals the on-disk hash,
654                    // so nothing landed. `modified_*` report the *applied*
655                    // delta (empty), consistent with the empty `commit_sha`
656                    // and unchanged hash — not the request-derived keys,
657                    // which would claim a change that did not happen
658                    // (F1). The request vecs
659                    // (`modified_metadata_set` etc.) are intentionally
660                    // dropped on this branch.
661                    modified_sections: ModifiedSections::default(),
662                    modified_metadata: ModifiedMetadata::default(),
663                    prospective_hash: None,
664                    // No write happened on the no-op path, so nothing
665                    // could have orphaned a stub.
666                    orphan_stubs_removed: Vec::new(),
667                    warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
668                    relations_declared,
669                }));
670            }
671        }
672
673        // Real change: apply the auto-stamp now and regenerate the
674        // markdown so the subsequent hash + write reflect it.
675        super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
676        let markdown = generate_markdown(&next, type_def.as_ref());
677
678        let mut warnings: Vec<WarningHint> = Vec::new();
679
680        // Mirror the create-path emission shape — drive the warning from
681        // the synthesised relations the alias pass just emitted, not
682        // from a re-parse of the generated markdown. `parse_markdown`
683        // filters its `inline_links` against the entity's
684        // `relationships` vec (which the synthesis pass has already
685        // appended to), so the pre-fix path saw `inline_links: []`
686        // and silently dropped the warning the docstring promises.
687        let auto_stubbed: Vec<EntityId> = synthesised_relations
688            .iter()
689            .filter_map(|rel| {
690                if !self.store.contains(&rel.target) {
691                    Some(rel.target.clone())
692                } else {
693                    None
694                }
695            })
696            .collect();
697        if !auto_stubbed.is_empty() {
698            warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
699                from: id.clone(),
700                stubs: auto_stubbed,
701            });
702        }
703        // F11: surface a dropped self-referential body link (the alias
704        // pass omitted the vacuous self-edge).
705        if self_link_ignored {
706            warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
707        }
708
709        // Dry-run: compute prospective hash from the in-memory
710        // entity and return without touching disk, store, or
711        // commits. Mirrors full's `UpdateArgs.dry_run` semantics —
712        // `content_hash` carries the unchanged on-disk hash so the
713        // caller can use it as `expected_hash` on the follow-up
714        // real call (designated stale-hash recovery path).
715        if args.dry_run {
716            let prospective = crate::entity::parser::compute_hash(&markdown);
717            // `next.content_hash` was cloned from the source
718            // entity and not modified since; equals the on-disk
719            // value.
720            let current_hash = next.content_hash.clone();
721            let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
722                today.clone()
723            } else {
724                String::new()
725            };
726            return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
727                id: id.clone(),
728                title: next.title.clone(),
729                file_path,
730                content_hash: current_hash,
731                commit_sha: String::new(),
732                modified_date,
733                modified_sections: ModifiedSections {
734                    replaced: modified_sections,
735                    appended: modified_sections_appended,
736                    patched: modified_sections_patched,
737                },
738                modified_metadata: ModifiedMetadata {
739                    set: modified_metadata_set,
740                    unset: modified_metadata_unset,
741                },
742                prospective_hash: Some(prospective),
743                // Dry-run touches neither store nor disk, so no stub
744                // could have been GC'd.
745                orphan_stubs_removed: Vec::new(),
746                warnings,
747                relations_declared: relations_declared.clone(),
748            }));
749        }
750
751        // Real change prepared. Compute `modified_date` (mirrors full's
752        // UpdateResult.modified_date — the `today` the auto-stamp loop
753        // used; empty when the schema has no auto_timestamp field), then
754        // hand the staged write to the caller to commit. The single
755        // path commits immediately; the batch path commits the whole
756        // set at once.
757        let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
758            today.clone()
759        } else {
760            String::new()
761        };
762
763        Ok(PrepareOutcome::Prepared(PreparedUpdate {
764            mount_idx,
765            id: id.clone(),
766            mem,
767            type_def,
768            file_path,
769            markdown,
770            prev_body_targets,
771            modified_date,
772            modified_sections: ModifiedSections {
773                replaced: modified_sections,
774                appended: modified_sections_appended,
775                patched: modified_sections_patched,
776            },
777            modified_metadata: ModifiedMetadata {
778                set: modified_metadata_set,
779                unset: modified_metadata_unset,
780            },
781            // F5: `InlineWikiLinkAutoStubbed` rides on the outcome so the
782            // update path matches create's contract.
783            warnings,
784            relations_declared,
785        }))
786    }
787
788    /// Apply a batch of [`UpdateEntityArgs`] **atomically** — all or
789    /// nothing. Surfaces `BatchResult` for `memstead batch-update`
790    /// consumers.
791    ///
792    /// The batch validates and prepares every item first (each with
793    /// its own optimistic-lock check), then commits the whole set as
794    /// **one** commit per mem. If any item fails — validation error,
795    /// `HASH_MISMATCH`, entity-not-found, any per-item refusal —
796    /// **nothing is committed**: the on-disk mem and the in-memory
797    /// store are restored to exactly their pre-call state, and the
798    /// result is marked `applied: false` with the offending item
799    /// carrying a typed `{code, message, details}` error envelope and
800    /// every other item marked `"not_applied"`. The first failing item
801    /// stops preparation (fail-fast); the caller fixes it and
802    /// resubmits.
803    ///
804    /// On success the returned `commit_sha` is the single batch commit
805    /// — an honest `memstead_changes_since` cursor / revert handle. Each
806    /// item's per-entry note rides into its own provenance record.
807    ///
808    /// Empty batches return `applied: true` with zero counts and no
809    /// commit. A batch where every item is a no-op (content unchanged)
810    /// likewise applies with an empty `commit_sha`.
811    ///
812    /// Atomicity is per-mem: for the common single-mem batch a
813    /// commit-time backend failure rolls the whole batch back. A batch
814    /// spanning multiple mems commits each mem in turn; if a later
815    /// mem's commit fails, already-committed mems stay committed
816    /// (true cross-mem two-phase commit is out of scope) — but the
817    /// dominant failure mode, a per-item validation/hash refusal, is
818    /// always fully atomic because no commit happens until every item
819    /// has passed.
820    pub fn batch_update(
821        &mut self,
822        updates: Vec<(UpdateEntityArgs, Option<String>)>,
823        actor: Actor,
824        client: Option<&ClientId>,
825    ) -> Result<crate::ops::BatchResult, EngineError> {
826        if updates.is_empty() {
827            return Ok(crate::ops::BatchResult {
828                applied: true,
829                results: Vec::new(),
830                succeeded: 0,
831                failed: 0,
832                commit_sha: String::new(),
833            });
834        }
835
836        // Reload-before-operation: refresh every mem this batch
837        // touches *before* preparing items, so each item's
838        // `expected_hash` check runs against current truth (the batch
839        // is the one multi-op-per-process path, so a sibling commit
840        // between boot and this call is plausible). Notices stash on
841        // the engine for the caller to drain.
842        let mut touched_mems: Vec<String> = updates
843            .iter()
844            .map(|(a, _)| a.id.mem().to_string())
845            .collect();
846        touched_mems.sort();
847        touched_mems.dedup();
848        for v in &touched_mems {
849            self.reload_if_stale(Some(v));
850        }
851
852        // Snapshot the in-memory store so a refused batch (or a
853        // commit-time backend failure) can roll back any auto-stubs
854        // and store pushes that earlier items already applied during
855        // preparation. The on-disk side rolls back by discarding each
856        // backend's staged-but-uncommitted pending buffer.
857        let store_snapshot = self.store.clone();
858
859        // What each item is, in submission order, so the result
860        // entries echo the input order. `Prepared` is a real write
861        // (its `PreparedUpdate` lives in `prepared`); `Noop` is an
862        // applied no-op (content unchanged, no write).
863        enum Item {
864            Prepared,
865            Noop,
866        }
867        let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
868        let mut prepared: Vec<PreparedUpdate> = Vec::new();
869        let mut notes: Vec<Option<String>> = Vec::new();
870
871        // --- Phase 1: validate + prepare every item (no commits) ---
872        let mut iter = updates.into_iter();
873        while let Some((args, note)) = iter.next() {
874            let id = args.id.clone();
875            match self.prepare_update(args) {
876                Ok(PrepareOutcome::Done(_)) => {
877                    // No-op (batch never sets dry_run): applied, no write.
878                    items.push((id, Item::Noop));
879                }
880                Ok(PrepareOutcome::Prepared(p)) => {
881                    prepared.push(p);
882                    notes.push(note);
883                    items.push((id, Item::Prepared));
884                }
885                Err(e) => {
886                    // Refuse the whole batch. Roll back store + disk.
887                    self.store = store_snapshot;
888                    self.discard_all_pending();
889                    let mut results: Vec<crate::ops::BatchEntry> = items
890                        .into_iter()
891                        .map(|(prev_id, _)| crate::ops::BatchEntry {
892                            id: prev_id,
893                            action: "not_applied".to_string(),
894                            error: None,
895                        })
896                        .collect();
897                    results.push(crate::ops::BatchEntry {
898                        id,
899                        action: "error".to_string(),
900                        error: Some(batch_error_envelope(&e)),
901                    });
902                    // Items after the failure were never prepared.
903                    for (rem_args, _) in iter {
904                        results.push(crate::ops::BatchEntry {
905                            id: rem_args.id,
906                            action: "not_applied".to_string(),
907                            error: None,
908                        });
909                    }
910                    return Ok(crate::ops::BatchResult {
911                        applied: false,
912                        results,
913                        succeeded: 0,
914                        failed: 1,
915                        commit_sha: String::new(),
916                    });
917                }
918            }
919        }
920
921        // --- Phase 2: stage every prepared write, then commit once
922        // per mem. ---
923        for p in &prepared {
924            if let Err(e) = self.mounts[p.mount_idx]
925                .backend
926                .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
927            {
928                self.store = store_snapshot;
929                self.discard_all_pending();
930                return Err(e.into());
931            }
932        }
933
934        // Distinct mount indices in first-seen order — one commit each.
935        let mut distinct_mounts: Vec<usize> = Vec::new();
936        for p in &prepared {
937            if !distinct_mounts.contains(&p.mount_idx) {
938                distinct_mounts.push(p.mount_idx);
939            }
940        }
941        let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
942        for &m in &distinct_mounts {
943            let entity_ids: Vec<String> = prepared
944                .iter()
945                .filter(|p| p.mount_idx == m)
946                .map(|p| p.id.to_string())
947                .collect();
948            let count = entity_ids.len();
949            let subject = format!("memstead: batch-update ({count} entities)");
950            let ctx = CommitContext {
951                actor,
952                client: client.cloned(),
953                tool: Some("batch_update"),
954                note: None,
955                logical_operation_id: None,
956                // F13: name every entity this batch commit touched so an
957                // `--include-notes` reader can recover them from the note
958                // record alone — the subject only says `(N entities)`.
959                entity_ids: Some(entity_ids),
960            };
961            match self.mounts[m].backend.commit(&subject, &ctx) {
962                Ok(sha) => mount_commits.push((m, sha)),
963                Err(e) => {
964                    // A commit failed. Roll back the store and any
965                    // still-pending backends. Mems already committed
966                    // in this loop stay committed (per-mem atomicity).
967                    self.store = store_snapshot;
968                    self.discard_all_pending();
969                    return Err(e.into());
970                }
971            }
972        }
973
974        // Provenance + store application per item, now that the commits
975        // landed. `record_self_write` marks the commit as engine-self
976        // so drift detection ignores it.
977        for (p, note) in prepared.iter().zip(notes.iter()) {
978            let commit_sha = mount_commits
979                .iter()
980                .find(|(m, _)| *m == p.mount_idx)
981                .map(|(_, s)| s.clone())
982                .unwrap_or_default();
983            self.mounts[p.mount_idx]
984                .backend
985                .append_provenance(&Provenance::new(
986                    std::time::SystemTime::now(),
987                    ProvenanceKind::Update,
988                    Some(p.id.to_string()),
989                    actor,
990                    client.cloned(),
991                    note.clone(),
992                ))?;
993            self.record_self_write(p.mount_idx, &commit_sha);
994            self.apply_prepared_to_store(p)?;
995        }
996
997        self.invalidate_communities();
998        self.invalidate_search_indexes();
999
1000        // Single-mem batches name their one commit; multi-mem names
1001        // the last mem committed (see the method docstring).
1002        let commit_sha = mount_commits
1003            .last()
1004            .map(|(_, s)| s.clone())
1005            .unwrap_or_default();
1006        let succeeded = items.len();
1007        let results: Vec<crate::ops::BatchEntry> = items
1008            .into_iter()
1009            .map(|(id, item)| crate::ops::BatchEntry {
1010                id,
1011                action: match item {
1012                    Item::Prepared => "updated".to_string(),
1013                    Item::Noop => "noop".to_string(),
1014                },
1015                error: None,
1016            })
1017            .collect();
1018
1019        Ok(crate::ops::BatchResult {
1020            applied: true,
1021            results,
1022            succeeded,
1023            failed: 0,
1024            commit_sha,
1025        })
1026    }
1027
1028    /// Best-effort discard of every backend's staged-but-uncommitted
1029    /// pending buffer — the disk-side half of an atomic-batch rollback.
1030    /// Discard errors (a poisoned pending mutex) are swallowed: we are
1031    /// already unwinding a refused batch and have nothing better to do.
1032    fn discard_all_pending(&self) {
1033        for mount in &self.mounts {
1034            let _ = mount.backend.discard_pending();
1035        }
1036    }
1037
1038    /// CommitContext-bundling wrapper around [`Self::update_entity`].
1039    /// See [`Self::create_entity_with_ctx`] for the rationale.
1040    pub fn update_entity_with_ctx(
1041        &mut self,
1042        args: UpdateEntityArgs,
1043        ctx: &CommitContext<'_>,
1044    ) -> Result<UpdateEntityOutcome, EngineError> {
1045        self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1046    }
1047}
1048
1049/// Build a per-item structured error envelope for [`Engine::batch_update`].
1050/// Mirrors the `{code, message, details}` shape single-update failures
1051/// carry on the MCP wire so a mixed-success batch is structurally uniform.
1052/// Variants without a typed recovery payload (boundary / internal failures
1053/// like `ParseAfterWrite`, `Backend`) return an empty details object —
1054/// the code and message channels still discriminate.
1055fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1056    // The per-item envelope reads the centralised
1057    // `EngineError::details()` helper so every typed variant ships the
1058    // same recovery payload the singleton MCP/CLI surfaces emit, so
1059    // agents' "fix from `details` rather than re-fetching" loop works
1060    // the same in batch mode.
1061    let code = err.code().to_string();
1062    let message = err.to_string();
1063    let details = err.details();
1064    crate::ops::BatchError {
1065        code,
1066        message,
1067        details,
1068    }
1069}
1070
1071/// Validate, auto-stub, and append a batch of relation declarations
1072/// onto `next.relationships`. Returns the canonical
1073/// `RelationDeclared` summary echoed back on the outcome.
1074///
1075/// Validates each declared relation against the same gates
1076/// `memstead_relate` runs (target-id grammar, rel-type vocabulary,
1077/// schema shape, cross-mem policy, ReadOnly-target rule). On the
1078/// add path with an absent Write-mem target, the target is
1079/// auto-stubbed via `make_stub` — matching the
1080/// `WarningHint::AutoStubCreated` semantics of the relate flow.
1081///
1082/// Defined at module scope (rather than a method on `Engine`) so
1083/// the borrow on `engine.store` for the auto-stub upsert can run
1084/// alongside the `&mut next` borrow.
1085fn apply_declare_relations(
1086    engine: &mut Engine,
1087    next: &mut Entity,
1088    declarations: &[crate::ops::RelateArg],
1089    source_mem: &str,
1090    source_mount_idx: usize,
1091    type_def: &memstead_schema::TypeDefinition,
1092    schema: &memstead_schema::Schema,
1093) -> Result<Vec<RelationDeclared>, EngineError> {
1094    let _ = type_def; // Reserved for future per-type policy hooks.
1095    let _ = source_mount_idx; // Reserved for parity with delete.
1096    let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1097    for rel in declarations {
1098        // Canonicalise rel_type to UPPER_SNAKE_CASE so the validator
1099        // and the stored edge see the same wire-contract form.
1100        let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1101            .unwrap_or_else(|_| rel.rel_type.clone());
1102
1103        validate_relation_target_grammar(&rel.to)?;
1104
1105        let target_mem = rel.to.mem().to_string();
1106        super::validate_cross_mem_add_policy(engine, source_mem, &target_mem)?;
1107        if target_mem != source_mem
1108            && let Some(mount) = engine.mount(&target_mem)
1109            && mount.capability == MountCapability::ReadOnly
1110            && !engine.store.contains(&rel.to)
1111        {
1112            return Err(EngineError::CrossMemTargetNotFound {
1113                target_id: rel.to.to_string(),
1114                target_mem: target_mem.clone(),
1115            });
1116        }
1117
1118        // Rel-type + shape validation, routed through the engine's
1119        // cross-mem-aware edge validator. Cross-different-schema
1120        // edges check vocabulary + shape against the source schema's
1121        // `cross_mem_relationships:` entry; same-schema edges fall
1122        // through to the intra-mem `relationships.definitions`.
1123        // Open-mode admits unknown rel-types silently (no
1124        // per-declaration warning surfaced here — symmetry with the
1125        // pre-cross-mem behaviour).
1126        let target_type = engine
1127            .store
1128            .get(&rel.to)
1129            .map(|e| e.entity_type.clone())
1130            .filter(|t| !t.is_empty());
1131        let _ = schema; // Helper resolves the schema via the engine.
1132        let _ = super::route_edge_validation(
1133            engine,
1134            &canonical,
1135            next.entity_type.as_str(),
1136            target_type.as_deref(),
1137            source_mem,
1138            &target_mem,
1139            &next.id,
1140            &rel.to,
1141            /* check_shape = */ true,
1142        )?;
1143
1144        // Per-edge description posture. Normalise first so
1145        // empty/whitespace-only inputs collapse to `None` and the
1146        // posture check sees a canonical input that matches what
1147        // the renderer will emit.
1148        let normalised_description =
1149            crate::entity::normalise_description(rel.description.as_deref());
1150        super::validate_description_posture(
1151            engine,
1152            &canonical,
1153            normalised_description.as_deref(),
1154            source_mem,
1155            &target_mem,
1156            &next.id,
1157            &rel.to,
1158        )?;
1159        // declare_relations is an explicit-author
1160        // boundary too — gate on manual_authoring posture.
1161        super::validate_manual_authoring_posture(
1162            engine, &canonical, source_mem, &next.id, &rel.to,
1163        )?;
1164
1165        // Append to the entity's relationships list. Duplicate
1166        // declarations are idempotent — same (rel_type, target) pair
1167        // is a silent no-op so the agent can re-issue the same call
1168        // without surprise.
1169        let exists = next
1170            .relationships
1171            .iter()
1172            .any(|r| r.rel_type == canonical && r.target == rel.to);
1173        if !exists {
1174            next.relationships.push(Relationship {
1175                rel_type: canonical.clone(),
1176                target: rel.to.clone(),
1177                description: normalised_description,
1178            });
1179        }
1180
1181        // Auto-stub absent Write-mem targets. Same mechanic as
1182        // `memstead_relate`'s relate path. ReadOnly cross-mem targets
1183        // were caught above; same-mem and cross-mem-to-Write
1184        // both fall through here.
1185        let target_was_stubbed = !engine.store.contains(&rel.to);
1186        if target_was_stubbed && !exists {
1187            engine.store.upsert(
1188                rel.to.clone(),
1189                make_stub(&rel.to, crate::entity::StubKind::ForwardReference),
1190            );
1191        }
1192
1193        declared.push(RelationDeclared {
1194            rel_type: canonical,
1195            target: rel.to.clone(),
1196            target_was_stubbed,
1197        });
1198    }
1199    Ok(declared)
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204
1205    use indexmap::IndexMap;
1206    use tempfile::TempDir;
1207
1208    use crate::backend::MemBackend;
1209    use crate::engine::test_helpers::*;
1210    use crate::engine::{
1211        CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1212    };
1213    use crate::entity::EntityId;
1214
1215    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1216    use crate::vcs::Actor;
1217
1218    #[test]
1219    fn batch_update_empty_batch_returns_zero_counts() {
1220        // No updates → BatchResult with zero counts + empty
1221        // commit_sha. No engine mutation happens.
1222        let tmp = TempDir::new().unwrap();
1223        let mem_dir = tmp.path().to_path_buf();
1224        let writer = FilesystemMemWriter::new(mem_dir.clone());
1225        let mut engine = Engine::from_mounts(vec![(
1226            folder_mount("specs", mem_dir),
1227            Box::new(writer) as Box<dyn MemBackend>,
1228        )])
1229        .unwrap();
1230
1231        let result = engine.batch_update(Vec::new(), Actor::Cli, None).unwrap();
1232        assert!(result.applied, "empty batch is a vacuous success");
1233        assert_eq!(result.results.len(), 0);
1234        assert_eq!(result.succeeded, 0);
1235        assert_eq!(result.failed, 0);
1236        assert_eq!(result.commit_sha, "");
1237    }
1238
1239    #[test]
1240    fn batch_update_refuses_whole_batch_when_one_item_fails() {
1241        // Atomic semantics: a 2-item batch where item 1 is valid and
1242        // item 2 targets a missing id refuses the WHOLE batch. Nothing
1243        // is committed — item 1 is NOT applied (its section change does
1244        // not land), `applied` is false, `commit_sha` is empty, the
1245        // missing item carries the typed ENTITY_NOT_FOUND envelope, and
1246        // the valid item is marked `not_applied`.
1247        let tmp = TempDir::new().unwrap();
1248        let mem_dir = tmp.path().to_path_buf();
1249        let writer = FilesystemMemWriter::new(mem_dir.clone());
1250        let mut engine = Engine::from_mounts(vec![(
1251            folder_mount("specs", mem_dir),
1252            Box::new(writer) as Box<dyn MemBackend>,
1253        )])
1254        .unwrap();
1255
1256        // Seed: create an entity.
1257        let create_args = CreateEntityArgs {
1258            mem: "specs".to_string(),
1259            title: "Seed".to_string(),
1260            entity_type: "spec".to_string(),
1261            sections: IndexMap::from_iter([
1262                ("identity".to_string(), "seed identity".to_string()),
1263                ("purpose".to_string(), "seed purpose".to_string()),
1264            ]),
1265            metadata: IndexMap::new(),
1266            relations: Vec::new(),
1267            dry_run: false,
1268        };
1269        let created = engine
1270            .create_entity(create_args, Actor::Cli, None, None)
1271            .unwrap();
1272
1273        // Batch: update the seed entity AND a missing id.
1274        let valid_update = UpdateEntityArgs {
1275            id: created.id.clone(),
1276            expected_hash: Some(created.content_hash.clone()),
1277            sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
1278            append_sections: IndexMap::new(),
1279            patch_sections: IndexMap::new(),
1280            metadata: IndexMap::new(),
1281            metadata_unset: Vec::new(),
1282            declare_relations: Vec::new(),
1283            dry_run: false,
1284            relations_unset: Vec::new(),
1285        };
1286        let missing_update = UpdateEntityArgs {
1287            id: EntityId("specs--nonexistent".to_string()),
1288            expected_hash: None,
1289            sections: IndexMap::new(),
1290            append_sections: IndexMap::new(),
1291            patch_sections: IndexMap::new(),
1292            metadata: IndexMap::new(),
1293            metadata_unset: Vec::new(),
1294            declare_relations: Vec::new(),
1295            dry_run: false,
1296            relations_unset: Vec::new(),
1297        };
1298
1299        let result = engine
1300            .batch_update(
1301                vec![(valid_update, None), (missing_update, None)],
1302                Actor::Cli,
1303                None,
1304            )
1305            .unwrap();
1306        // Whole batch refused: nothing applied, no commit.
1307        assert!(!result.applied, "a failing item must refuse the batch");
1308        assert_eq!(result.results.len(), 2);
1309        assert_eq!(result.succeeded, 0);
1310        assert_eq!(result.failed, 1);
1311        assert_eq!(result.commit_sha, "", "refused batch must not commit");
1312        // First entry: the valid item, marked not_applied (the batch
1313        // was refused before it could land).
1314        assert_eq!(result.results[0].action, "not_applied");
1315        assert!(result.results[0].error.is_none());
1316        // Second entry: the failing item carries the typed envelope.
1317        assert_eq!(result.results[1].action, "error");
1318        let err = result.results[1]
1319            .error
1320            .as_ref()
1321            .expect("failed entry must carry a structured error envelope");
1322        assert_eq!(err.code, "ENTITY_NOT_FOUND");
1323        assert!(err.message.contains("not found"), "got: {}", err.message);
1324
1325        // The valid item's section change must NOT have landed — the
1326        // store is byte-identical to pre-call.
1327        let seed = engine.get_entity(&created.id).unwrap();
1328        assert_eq!(
1329            seed.sections.get("identity").map(String::as_str),
1330            Some("seed identity"),
1331            "refused batch must leave the in-memory store untouched",
1332        );
1333        assert_eq!(
1334            seed.content_hash, created.content_hash,
1335            "refused batch must not change the entity's content hash",
1336        );
1337    }
1338
1339    #[test]
1340    fn batch_update_applies_all_valid_items_as_one_commit() {
1341        // A 2-item batch where both items are valid
1342        // applies both and produces exactly one commit; the response's
1343        // commit_sha names it and both entries report "updated".
1344        let tmp = TempDir::new().unwrap();
1345        let mem_dir = tmp.path().to_path_buf();
1346        let writer = FilesystemMemWriter::new(mem_dir.clone());
1347        let mut engine = Engine::from_mounts(vec![(
1348            folder_mount("specs", mem_dir),
1349            Box::new(writer) as Box<dyn MemBackend>,
1350        )])
1351        .unwrap();
1352
1353        let mk = |title: &str| CreateEntityArgs {
1354            mem: "specs".to_string(),
1355            title: title.to_string(),
1356            entity_type: "spec".to_string(),
1357            sections: IndexMap::from_iter([
1358                ("identity".to_string(), "id".to_string()),
1359                ("purpose".to_string(), "purp".to_string()),
1360            ]),
1361            metadata: IndexMap::new(),
1362            relations: Vec::new(),
1363            dry_run: false,
1364        };
1365        let a = engine
1366            .create_entity(mk("A"), Actor::Cli, None, None)
1367            .unwrap();
1368        let b = engine
1369            .create_entity(mk("B"), Actor::Cli, None, None)
1370            .unwrap();
1371
1372        let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
1373            id,
1374            expected_hash: Some(hash),
1375            sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
1376            append_sections: IndexMap::new(),
1377            patch_sections: IndexMap::new(),
1378            metadata: IndexMap::new(),
1379            metadata_unset: Vec::new(),
1380            declare_relations: Vec::new(),
1381            dry_run: false,
1382            relations_unset: Vec::new(),
1383        };
1384
1385        let result = engine
1386            .batch_update(
1387                vec![
1388                    (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
1389                    (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
1390                ],
1391                Actor::Cli,
1392                None,
1393            )
1394            .unwrap();
1395        assert!(result.applied);
1396        assert_eq!(result.succeeded, 2);
1397        assert_eq!(result.failed, 0);
1398        assert!(
1399            !result.commit_sha.is_empty(),
1400            "applied batch carries the commit"
1401        );
1402        assert!(result.results.iter().all(|e| e.action == "updated"));
1403        // Both section changes landed.
1404        assert_eq!(
1405            engine
1406                .get_entity(&a.id)
1407                .unwrap()
1408                .sections
1409                .get("identity")
1410                .map(String::as_str),
1411            Some("A body"),
1412        );
1413        assert_eq!(
1414            engine
1415                .get_entity(&b.id)
1416                .unwrap()
1417                .sections
1418                .get("identity")
1419                .map(String::as_str),
1420            Some("B body"),
1421        );
1422    }
1423
1424    #[test]
1425    fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
1426        // The subtle invariant: an earlier item that auto-stubs a
1427        // relation target during preparation must have that stub rolled
1428        // OUT of the in-memory store when a later item refuses the
1429        // batch. Item 1 declares a relation to an absent target (which
1430        // upserts a forward-reference stub during prepare); item 2
1431        // targets a missing entity and fails. The refusal must leave no
1432        // trace of the stub.
1433        let tmp = TempDir::new().unwrap();
1434        let mem_dir = tmp.path().to_path_buf();
1435        let writer = FilesystemMemWriter::new(mem_dir.clone());
1436        let mut engine = Engine::from_mounts(vec![(
1437            folder_mount("specs", mem_dir.clone()),
1438            Box::new(writer) as Box<dyn MemBackend>,
1439        )])
1440        .unwrap();
1441        engine.set_workspace_root(mem_dir);
1442        let (actor, client) = cli_actor();
1443
1444        let a = engine
1445            .create_entity(
1446                empty_create_args("specs", "Anchor"),
1447                actor,
1448                Some(&client),
1449                None,
1450            )
1451            .unwrap();
1452
1453        let stub_target = EntityId::new("specs", "would-be-stub");
1454        let item1 = UpdateEntityArgs {
1455            relations_unset: Vec::new(),
1456            id: a.id.clone(),
1457            expected_hash: Some(a.content_hash.clone()),
1458            sections: IndexMap::new(),
1459            append_sections: IndexMap::new(),
1460            patch_sections: IndexMap::new(),
1461            metadata: IndexMap::new(),
1462            metadata_unset: Vec::new(),
1463            declare_relations: vec![crate::ops::RelateArg {
1464                rel_type: "USES".to_string(),
1465                to: stub_target.clone(),
1466                description: None,
1467            }],
1468            dry_run: false,
1469        };
1470        let item2 = UpdateEntityArgs {
1471            id: EntityId::new("specs", "nonexistent"),
1472            expected_hash: None,
1473            sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
1474            append_sections: IndexMap::new(),
1475            patch_sections: IndexMap::new(),
1476            metadata: IndexMap::new(),
1477            metadata_unset: Vec::new(),
1478            declare_relations: Vec::new(),
1479            dry_run: false,
1480            relations_unset: Vec::new(),
1481        };
1482
1483        // Sanity: the would-be stub does not exist before the batch.
1484        assert!(engine.get_entity(&stub_target).is_none());
1485
1486        let result = engine
1487            .batch_update(vec![(item1, None), (item2, None)], actor, Some(&client))
1488            .unwrap();
1489        assert!(!result.applied, "missing item 2 must refuse the batch");
1490
1491        // The auto-stub item 1 created during preparation was rolled
1492        // back with the store snapshot — no orphaned stub survives.
1493        assert!(
1494            engine.get_entity(&stub_target).is_none(),
1495            "refused batch must roll the in-memory auto-stub back out of the store",
1496        );
1497        // The anchor's relation set is unchanged too.
1498        let anchor = engine.get_entity(&a.id).unwrap();
1499        assert!(
1500            !anchor.relationships.iter().any(|r| r.target == stub_target),
1501            "refused batch must not leave the declared relation on the anchor",
1502        );
1503    }
1504
1505    #[test]
1506    fn update_entity_replaces_a_section_and_logs_provenance() {
1507        let tmp = TempDir::new().unwrap();
1508        let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
1509        let (actor, client) = cli_actor();
1510
1511        let mut sections = IndexMap::new();
1512        sections.insert("identity".to_string(), "Updated body.".to_string());
1513
1514        let outcome = engine
1515            .update_entity(
1516                UpdateEntityArgs {
1517                    id: seeded.id.clone(),
1518                    expected_hash: Some(seeded.content_hash.clone()),
1519                    sections,
1520                    append_sections: IndexMap::new(),
1521                    patch_sections: IndexMap::new(),
1522                    metadata: IndexMap::new(),
1523                    metadata_unset: Vec::new(),
1524                    declare_relations: Vec::new(),
1525                    dry_run: false,
1526                    relations_unset: Vec::new(),
1527                },
1528                actor,
1529                Some(&client),
1530                Some("section update"),
1531            )
1532            .unwrap();
1533
1534        assert_eq!(
1535            outcome.modified_sections.replaced,
1536            vec!["identity".to_string()]
1537        );
1538        assert_ne!(
1539            outcome.content_hash, seeded.content_hash,
1540            "hash must change"
1541        );
1542        // Store carries the new content.
1543        let entity = engine.get_entity(&seeded.id).unwrap();
1544        assert!(
1545            entity
1546                .sections
1547                .get("identity")
1548                .unwrap()
1549                .contains("Updated body.")
1550        );
1551        // Provenance log records the update.
1552        let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
1553        assert!(log.contains("\"kind\":\"update\""));
1554        assert!(log.contains("\"note\":\"section update\""));
1555    }
1556
1557    #[test]
1558    fn update_entity_rejects_hash_mismatch() {
1559        let tmp = TempDir::new().unwrap();
1560        let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
1561        let (actor, client) = cli_actor();
1562        let err = engine
1563            .update_entity(
1564                UpdateEntityArgs {
1565                    id: seeded.id.clone(),
1566                    expected_hash: Some("wrong-hash".to_string()),
1567                    sections: IndexMap::new(),
1568                    append_sections: IndexMap::new(),
1569                    patch_sections: IndexMap::new(),
1570                    metadata: IndexMap::new(),
1571                    metadata_unset: Vec::new(),
1572                    declare_relations: Vec::new(),
1573                    dry_run: false,
1574                    relations_unset: Vec::new(),
1575                },
1576                actor,
1577                Some(&client),
1578                None,
1579            )
1580            .unwrap_err();
1581        match err {
1582            EngineError::HashMismatch {
1583                id,
1584                current,
1585                is_stub,
1586            } => {
1587                assert_eq!(id, seeded.id.to_string());
1588                assert_eq!(current, seeded.content_hash);
1589                assert!(!is_stub, "real entity must not flag as stub");
1590            }
1591            other => panic!("expected HashMismatch, got {other:?}"),
1592        }
1593    }
1594
1595    #[test]
1596    fn update_entity_rejects_unknown_id() {
1597        let tmp = TempDir::new().unwrap();
1598        let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
1599        let (actor, client) = cli_actor();
1600        let err = engine
1601            .update_entity(
1602                UpdateEntityArgs {
1603                    id: crate::EntityId::new("specs", "ghost"),
1604                    expected_hash: None,
1605                    sections: IndexMap::new(),
1606                    append_sections: IndexMap::new(),
1607                    patch_sections: IndexMap::new(),
1608                    metadata: IndexMap::new(),
1609                    metadata_unset: Vec::new(),
1610                    declare_relations: Vec::new(),
1611                    dry_run: false,
1612                    relations_unset: Vec::new(),
1613                },
1614                actor,
1615                Some(&client),
1616                None,
1617            )
1618            .unwrap_err();
1619        assert!(matches!(err, EngineError::NotFound { .. }));
1620    }
1621
1622    #[test]
1623    fn update_entity_rejects_read_only_mount() {
1624        let tmp = TempDir::new().unwrap();
1625        let archive_path = build_archive(
1626            tmp.path(),
1627            "ext",
1628            &[(
1629                "a.md",
1630                b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
1631            )],
1632        );
1633        let mut engine = Engine::from_mounts(vec![(
1634            archive_mount("external", archive_path.clone()),
1635            Box::new(ArchiveBackend::new(archive_path)),
1636        )])
1637        .unwrap();
1638        let (actor, client) = cli_actor();
1639        let id = crate::EntityId::new("external", "a");
1640        let err = engine
1641            .update_entity(
1642                UpdateEntityArgs {
1643                    id,
1644                    expected_hash: None,
1645                    sections: IndexMap::new(),
1646                    append_sections: IndexMap::new(),
1647                    patch_sections: IndexMap::new(),
1648                    metadata: IndexMap::new(),
1649                    metadata_unset: Vec::new(),
1650                    declare_relations: Vec::new(),
1651                    dry_run: false,
1652                    relations_unset: Vec::new(),
1653                },
1654                actor,
1655                Some(&client),
1656                None,
1657            )
1658            .unwrap_err();
1659        assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
1660    }
1661
1662    #[test]
1663    fn update_entity_patches_section_with_find_and_replace() {
1664        let tmp = TempDir::new().unwrap();
1665        let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
1666        let (actor, client) = cli_actor();
1667
1668        // Pre-write a known body via the replace path so the patch
1669        // test has a deterministic substring to target.
1670        let mut replace = IndexMap::new();
1671        replace.insert("identity".to_string(), "hello world hello".to_string());
1672        let replaced = engine
1673            .update_entity(
1674                UpdateEntityArgs {
1675                    id: seeded.id.clone(),
1676                    expected_hash: Some(seeded.content_hash.clone()),
1677                    sections: replace,
1678                    append_sections: IndexMap::new(),
1679                    patch_sections: IndexMap::new(),
1680                    metadata: IndexMap::new(),
1681                    metadata_unset: Vec::new(),
1682                    declare_relations: Vec::new(),
1683                    dry_run: false,
1684                    relations_unset: Vec::new(),
1685                },
1686                actor,
1687                Some(&client),
1688                None,
1689            )
1690            .unwrap();
1691
1692        // First-occurrence patch (all = false).
1693        let mut patches = IndexMap::new();
1694        patches.insert(
1695            "identity".to_string(),
1696            crate::ops::PatchArg {
1697                old: "hello".to_string(),
1698                new: "HI".to_string(),
1699                all: false,
1700            },
1701        );
1702        let outcome = engine
1703            .update_entity(
1704                UpdateEntityArgs {
1705                    id: seeded.id.clone(),
1706                    expected_hash: Some(replaced.content_hash.clone()),
1707                    sections: IndexMap::new(),
1708                    append_sections: IndexMap::new(),
1709                    patch_sections: patches,
1710                    metadata: IndexMap::new(),
1711                    metadata_unset: Vec::new(),
1712                    declare_relations: Vec::new(),
1713                    dry_run: false,
1714                    relations_unset: Vec::new(),
1715                },
1716                actor,
1717                Some(&client),
1718                None,
1719            )
1720            .unwrap();
1721        assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
1722        let body = engine
1723            .get_entity(&seeded.id)
1724            .unwrap()
1725            .sections
1726            .get("identity")
1727            .unwrap()
1728            .clone();
1729        assert!(body.contains("HI world hello"), "first-only: {body:?}");
1730    }
1731
1732    #[test]
1733    fn update_entity_patch_rejects_missing_old_substring() {
1734        let tmp = TempDir::new().unwrap();
1735        let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
1736        let (actor, client) = cli_actor();
1737        let mut patches = IndexMap::new();
1738        patches.insert(
1739            "identity".to_string(),
1740            crate::ops::PatchArg {
1741                old: "this-substring-does-not-exist".to_string(),
1742                new: "nope".to_string(),
1743                all: false,
1744            },
1745        );
1746        let err = engine
1747            .update_entity(
1748                UpdateEntityArgs {
1749                    id: seeded.id.clone(),
1750                    expected_hash: Some(seeded.content_hash.clone()),
1751                    sections: IndexMap::new(),
1752                    append_sections: IndexMap::new(),
1753                    patch_sections: patches,
1754                    metadata: IndexMap::new(),
1755                    metadata_unset: Vec::new(),
1756                    declare_relations: Vec::new(),
1757                    dry_run: false,
1758                    relations_unset: Vec::new(),
1759                },
1760                actor,
1761                Some(&client),
1762                None,
1763            )
1764            .unwrap_err();
1765        match err {
1766            EngineError::PatchOldNotFound { section, .. } => {
1767                assert_eq!(section, "identity");
1768            }
1769            other => panic!("expected PatchOldNotFound, got {other:?}"),
1770        }
1771    }
1772
1773    #[test]
1774    fn update_entity_appends_to_existing_section_with_newline_separator() {
1775        let tmp = TempDir::new().unwrap();
1776        let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
1777        let (actor, client) = cli_actor();
1778
1779        let mut appends = IndexMap::new();
1780        appends.insert("identity".to_string(), "appended tail.".to_string());
1781
1782        let outcome = engine
1783            .update_entity(
1784                UpdateEntityArgs {
1785                    id: seeded.id.clone(),
1786                    expected_hash: Some(seeded.content_hash.clone()),
1787                    sections: IndexMap::new(),
1788                    append_sections: appends,
1789                    patch_sections: IndexMap::new(),
1790                    metadata: IndexMap::new(),
1791                    metadata_unset: Vec::new(),
1792                    declare_relations: Vec::new(),
1793                    dry_run: false,
1794                    relations_unset: Vec::new(),
1795                },
1796                actor,
1797                Some(&client),
1798                None,
1799            )
1800            .unwrap();
1801
1802        // modified_sections.appended carries the append key;
1803        // modified_sections.replaced stays empty.
1804        assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
1805        assert!(outcome.modified_sections.replaced.is_empty());
1806
1807        // The section body now contains the appended tail.
1808        let updated = engine.get_entity(&seeded.id).unwrap();
1809        let body = updated.sections.get("identity").expect("identity section");
1810        assert!(
1811            body.contains("appended tail."),
1812            "appended body missing: {body:?}"
1813        );
1814    }
1815
1816    /// Item 02: `memstead_update` against a stub must surface a typed
1817    /// `StubNotUpdatable` envelope rather than the pre-fix
1818    /// `UnknownType { name: "" }` cascade. Mirrors the
1819    /// `StubCannotRelate` guard that `memstead_relate` already runs;
1820    /// before Item 02 the docstring list advertised the
1821    /// `STUB_NOT_UPDATABLE` code but no engine path emitted it.
1822    #[test]
1823    fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
1824        let tmp = TempDir::new().unwrap();
1825        let (mut engine, source) = engine_with_seed(&tmp, "Source");
1826        let (actor, client) = cli_actor();
1827        // Materialise a stub by relating from a real entity to an
1828        // absent target. The relate path upserts the stub.
1829        let stub_id = crate::EntityId::new("specs", "stub-update-target");
1830        engine
1831            .relate_entity(
1832                RelateEntityArgs {
1833                    source: source.id.clone(),
1834                    expected_hash: Some(source.content_hash.clone()),
1835                    rel_type: "USES".to_string(),
1836                    target: stub_id.clone(),
1837                    remove: false,
1838                    description: None,
1839                },
1840                actor,
1841                Some(&client),
1842                None,
1843            )
1844            .unwrap();
1845
1846        let err = engine
1847            .update_entity(
1848                UpdateEntityArgs {
1849                    id: stub_id.clone(),
1850                    expected_hash: Some(String::new()),
1851                    sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
1852                    append_sections: IndexMap::new(),
1853                    patch_sections: IndexMap::new(),
1854                    metadata: IndexMap::new(),
1855                    metadata_unset: Vec::new(),
1856                    declare_relations: Vec::new(),
1857                    dry_run: false,
1858                    relations_unset: Vec::new(),
1859                },
1860                actor,
1861                Some(&client),
1862                None,
1863            )
1864            .unwrap_err();
1865        match err {
1866            EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
1867            other => panic!("expected StubNotUpdatable, got {other:?}"),
1868        }
1869    }
1870
1871    #[test]
1872    fn update_entity_rejects_conflicting_section_modes() {
1873        let tmp = TempDir::new().unwrap();
1874        let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
1875        let (actor, client) = cli_actor();
1876
1877        let mut sections = IndexMap::new();
1878        sections.insert("identity".to_string(), "replace".to_string());
1879        let mut appends = IndexMap::new();
1880        appends.insert("identity".to_string(), "append".to_string());
1881
1882        let err = engine
1883            .update_entity(
1884                UpdateEntityArgs {
1885                    id: seeded.id.clone(),
1886                    expected_hash: Some(seeded.content_hash.clone()),
1887                    sections,
1888                    append_sections: appends,
1889                    patch_sections: IndexMap::new(),
1890                    metadata: IndexMap::new(),
1891                    metadata_unset: Vec::new(),
1892                    declare_relations: Vec::new(),
1893                    dry_run: false,
1894                    relations_unset: Vec::new(),
1895                },
1896                actor,
1897                Some(&client),
1898                None,
1899            )
1900            .unwrap_err();
1901
1902        match err {
1903            EngineError::ConflictingSectionModes { section, modes } => {
1904                assert_eq!(section, "identity");
1905                assert_eq!(modes, vec!["sections", "append_sections"]);
1906            }
1907            other => panic!("expected ConflictingSectionModes, got {other:?}"),
1908        }
1909    }
1910
1911    #[test]
1912    fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
1913        // Wire contract: setting and unsetting the same key is a hard
1914        // error. The check runs before the required-field check so the
1915        // resolution (pick one map) is unambiguous regardless of
1916        // whether the conflicting key is required.
1917        let tmp = TempDir::new().unwrap();
1918        let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
1919        let (actor, client) = cli_actor();
1920
1921        let mut metadata = IndexMap::new();
1922        // `tags` is a non-required field on the default `spec` schema —
1923        // so this conflict is purely about the overlap, not about
1924        // unsetting-a-required-field.
1925        metadata.insert("tags".to_string(), "foo".to_string());
1926
1927        let err = engine
1928            .update_entity(
1929                UpdateEntityArgs {
1930                    id: seeded.id.clone(),
1931                    expected_hash: Some(seeded.content_hash.clone()),
1932                    sections: IndexMap::new(),
1933                    append_sections: IndexMap::new(),
1934                    patch_sections: IndexMap::new(),
1935                    metadata,
1936                    metadata_unset: vec!["tags".to_string()],
1937                    declare_relations: Vec::new(),
1938                    dry_run: false,
1939                    relations_unset: Vec::new(),
1940                },
1941                actor,
1942                Some(&client),
1943                None,
1944            )
1945            .unwrap_err();
1946        match err {
1947            EngineError::SetAndUnsetConflict { keys } => {
1948                assert_eq!(keys, vec!["tags".to_string()]);
1949            }
1950            other => panic!("expected SetAndUnsetConflict, got {other:?}"),
1951        }
1952    }
1953
1954    #[test]
1955    fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
1956        // Under the default schema's `alias_target_rel_type: REFERENCES`
1957        // pointer, a body wiki-link no longer trips the strict validator
1958        // — the alias-synthesis pass emits the REFERENCES relation
1959        // first, the validator finds the link backed, the body lands.
1960        // (Schemas without the pointer continue to refuse with
1961        // `WIKILINK_WITHOUT_RELATION`; that path is covered by the
1962        // dedicated no-pointer fixture test elsewhere.)
1963        use crate::EntityId;
1964        use crate::engine::UpdateEntityArgs;
1965        use indexmap::IndexMap;
1966        use tempfile::TempDir;
1967
1968        let tmp = TempDir::new().unwrap();
1969        let mem_dir = tmp.path().to_path_buf();
1970        let writer = FilesystemMemWriter::new(mem_dir.clone());
1971        let mut engine = Engine::from_mounts(vec![(
1972            folder_mount("specs", mem_dir.clone()),
1973            Box::new(writer) as Box<dyn MemBackend>,
1974        )])
1975        .unwrap();
1976        engine.set_workspace_root(mem_dir.clone());
1977        let (actor, client) = cli_actor();
1978
1979        let target = engine
1980            .create_entity(
1981                empty_create_args("specs", "Target"),
1982                actor,
1983                Some(&client),
1984                None,
1985            )
1986            .unwrap();
1987        let source = engine
1988            .create_entity(
1989                empty_create_args("specs", "Source"),
1990                actor,
1991                Some(&client),
1992                None,
1993            )
1994            .unwrap();
1995
1996        let mut sections: IndexMap<String, String> = IndexMap::new();
1997        sections.insert(
1998            "purpose".to_string(),
1999            "see [[target]] for context".to_string(),
2000        );
2001        let outcome = engine
2002            .update_entity(
2003                UpdateEntityArgs {
2004                    id: source.id.clone(),
2005                    expected_hash: Some(source.content_hash.clone()),
2006                    sections,
2007                    append_sections: IndexMap::new(),
2008                    patch_sections: IndexMap::new(),
2009                    metadata: IndexMap::new(),
2010                    metadata_unset: Vec::new(),
2011                    declare_relations: Vec::new(),
2012                    dry_run: false,
2013                    relations_unset: Vec::new(),
2014                },
2015                actor,
2016                Some(&client),
2017                None,
2018            )
2019            .expect("auto-synthesis must satisfy the alias-existence invariant");
2020        // Body landed.
2021        assert!(
2022            outcome
2023                .modified_sections
2024                .replaced
2025                .iter()
2026                .any(|s| s == "purpose"),
2027        );
2028        let in_mem = engine.get_entity(&source.id).unwrap();
2029        assert_eq!(
2030            in_mem
2031                .sections
2032                .get("purpose")
2033                .map(String::as_str)
2034                .unwrap_or(""),
2035            "see [[target]] for context",
2036        );
2037        // REFERENCES relation synthesised from the body wiki-link.
2038        assert!(
2039            in_mem
2040                .relationships
2041                .iter()
2042                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2043            "synthesis must emit REFERENCES → target; relationships: {:?}",
2044            in_mem.relationships,
2045        );
2046        // Defeat unused-import warnings for the helper imports.
2047        let _ = EntityId::new("specs", "x");
2048    }
2049
2050    #[test]
2051    fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
2052        // The agent declares the relation + adds the body wiki-link
2053        // in a single `memstead_update` call. Without
2054        // `declare_relations`, the strict validator would refuse
2055        // (no backing relation yet); with the batched declaration,
2056        // the relation lands *before* the strict validator runs so
2057        // the body link passes the gate.
2058        use crate::engine::UpdateEntityArgs;
2059        use crate::ops::RelateArg;
2060        use indexmap::IndexMap;
2061        use tempfile::TempDir;
2062
2063        let tmp = TempDir::new().unwrap();
2064        let mem_dir = tmp.path().to_path_buf();
2065        let writer = FilesystemMemWriter::new(mem_dir.clone());
2066        let mut engine = Engine::from_mounts(vec![(
2067            folder_mount("specs", mem_dir.clone()),
2068            Box::new(writer) as Box<dyn MemBackend>,
2069        )])
2070        .unwrap();
2071        engine.set_workspace_root(mem_dir.clone());
2072        let (actor, client) = cli_actor();
2073
2074        let target = engine
2075            .create_entity(
2076                empty_create_args("specs", "Target"),
2077                actor,
2078                Some(&client),
2079                None,
2080            )
2081            .unwrap();
2082        let source = engine
2083            .create_entity(
2084                empty_create_args("specs", "Source"),
2085                actor,
2086                Some(&client),
2087                None,
2088            )
2089            .unwrap();
2090
2091        // Atomic declare + body update. USES (not REFERENCES) — under
2092        // the default schema's `alias_target_rel_type: REFERENCES`
2093        // pointer, explicit declare_relations type=REFERENCES is
2094        // refused; the body wiki-link is auto-emitted via synthesis.
2095        // The test's intent — that declare_relations atomically lands
2096        // alongside body changes — holds for any rel-type that admits
2097        // explicit authoring.
2098        let mut sections: IndexMap<String, String> = IndexMap::new();
2099        sections.insert(
2100            "purpose".to_string(),
2101            "see [[target]] for context".to_string(),
2102        );
2103        let outcome = engine
2104            .update_entity(
2105                UpdateEntityArgs {
2106                    relations_unset: Vec::new(),
2107                    id: source.id.clone(),
2108                    expected_hash: Some(source.content_hash.clone()),
2109                    sections,
2110                    append_sections: IndexMap::new(),
2111                    patch_sections: IndexMap::new(),
2112                    metadata: IndexMap::new(),
2113                    metadata_unset: Vec::new(),
2114                    dry_run: false,
2115                    declare_relations: vec![RelateArg {
2116                        rel_type: "USES".to_string(),
2117                        to: target.id.clone(),
2118                        description: None,
2119                    }],
2120                },
2121                actor,
2122                Some(&client),
2123                None,
2124            )
2125            .expect("declare_relations + body update must succeed in one call");
2126
2127        assert_eq!(outcome.relations_declared.len(), 1);
2128        assert_eq!(outcome.relations_declared[0].rel_type, "USES");
2129        assert_eq!(outcome.relations_declared[0].target, target.id);
2130        assert!(
2131            !outcome.relations_declared[0].target_was_stubbed,
2132            "target was already present in store; target_was_stubbed must be false"
2133        );
2134
2135        let in_mem = engine.get_entity(&source.id).unwrap();
2136        assert!(
2137            in_mem.relationships.iter().any(|r| r.target == target.id),
2138            "declared relation must land in entity.relationships; got {:?}",
2139            in_mem.relationships
2140        );
2141    }
2142
2143    #[test]
2144    fn update_entity_declare_relations_auto_stubs_absent_target() {
2145        // When the declared target doesn't exist yet, the engine
2146        // auto-stubs it (same mechanic as `memstead_relate`) and flags
2147        // `target_was_stubbed: true` in the outcome.
2148        use crate::EntityId;
2149        use crate::engine::UpdateEntityArgs;
2150        use crate::ops::RelateArg;
2151        use indexmap::IndexMap;
2152
2153        let tmp = TempDir::new().unwrap();
2154        let (mut engine, source) = engine_with_seed(&tmp, "Source");
2155        let (actor, client) = cli_actor();
2156        let absent_target = EntityId::new("specs", "not-yet-existing");
2157        assert!(!engine.store().contains(&absent_target));
2158
2159        let outcome = engine
2160            .update_entity(
2161                UpdateEntityArgs {
2162                    relations_unset: Vec::new(),
2163                    id: source.id.clone(),
2164                    expected_hash: Some(source.content_hash.clone()),
2165                    sections: IndexMap::new(),
2166                    append_sections: IndexMap::new(),
2167                    patch_sections: IndexMap::new(),
2168                    metadata: IndexMap::new(),
2169                    metadata_unset: Vec::new(),
2170                    dry_run: false,
2171                    declare_relations: vec![RelateArg {
2172                        rel_type: "USES".to_string(),
2173                        to: absent_target.clone(),
2174                        description: None,
2175                    }],
2176                },
2177                actor,
2178                Some(&client),
2179                None,
2180            )
2181            .unwrap();
2182
2183        assert_eq!(outcome.relations_declared.len(), 1);
2184        assert!(
2185            outcome.relations_declared[0].target_was_stubbed,
2186            "absent target must be auto-stubbed; got target_was_stubbed=false"
2187        );
2188        // Stub now exists in the store.
2189        assert!(engine.store().contains(&absent_target));
2190        let stub = engine.get_entity(&absent_target).unwrap();
2191        assert!(stub.stub);
2192    }
2193
2194    #[test]
2195    fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
2196        // Under the alias model with a pointer-set schema (default
2197        // schema's `alias_target_rel_type: REFERENCES`), a fresh
2198        // workspace's first body-wiki-link write triggers the
2199        // alias-synthesis pass and the mutation lands with the
2200        // REFERENCES relation auto-emitted.
2201        use crate::engine::UpdateEntityArgs;
2202        use indexmap::IndexMap;
2203        use tempfile::TempDir;
2204
2205        let tmp = TempDir::new().unwrap();
2206        let mem_dir = tmp.path().to_path_buf();
2207        let writer = FilesystemMemWriter::new(mem_dir.clone());
2208        let mut engine = Engine::from_mounts(vec![(
2209            folder_mount("specs", mem_dir.clone()),
2210            Box::new(writer) as Box<dyn MemBackend>,
2211        )])
2212        .unwrap();
2213        engine.set_workspace_root(mem_dir.clone());
2214        let (actor, client) = cli_actor();
2215        let target = engine
2216            .create_entity(
2217                empty_create_args("specs", "Target"),
2218                actor,
2219                Some(&client),
2220                None,
2221            )
2222            .unwrap();
2223        let source = engine
2224            .create_entity(
2225                empty_create_args("specs", "Source"),
2226                actor,
2227                Some(&client),
2228                None,
2229            )
2230            .unwrap();
2231
2232        let mut sections: IndexMap<String, String> = IndexMap::new();
2233        sections.insert(
2234            "purpose".to_string(),
2235            "see [[target]] for context".to_string(),
2236        );
2237        engine
2238            .update_entity(
2239                UpdateEntityArgs {
2240                    id: source.id.clone(),
2241                    expected_hash: Some(source.content_hash.clone()),
2242                    sections,
2243                    append_sections: IndexMap::new(),
2244                    patch_sections: IndexMap::new(),
2245                    metadata: IndexMap::new(),
2246                    metadata_unset: Vec::new(),
2247                    declare_relations: Vec::new(),
2248                    dry_run: false,
2249                    relations_unset: Vec::new(),
2250                },
2251                actor,
2252                Some(&client),
2253                None,
2254            )
2255            .expect("synthesis must back the wiki-link and let the body land");
2256        let in_mem = engine.get_entity(&source.id).unwrap();
2257        assert!(
2258            in_mem
2259                .relationships
2260                .iter()
2261                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2262            "synthesis must emit REFERENCES → target; relationships: {:?}",
2263            in_mem.relationships,
2264        );
2265    }
2266
2267    #[test]
2268    fn update_entity_dry_run_returns_prospective_hash_without_writing() {
2269        let tmp = TempDir::new().unwrap();
2270        let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
2271        let (actor, client) = cli_actor();
2272        let original_hash = seeded.content_hash.clone();
2273
2274        let mut sections = IndexMap::new();
2275        sections.insert("identity".to_string(), "preview body".to_string());
2276
2277        let outcome = engine
2278            .update_entity(
2279                UpdateEntityArgs {
2280                    id: seeded.id.clone(),
2281                    // Stale-hash recovery path — dry_run skips the
2282                    // hash check, so a wrong expected_hash is OK.
2283                    expected_hash: Some("wrong-hash".to_string()),
2284                    sections,
2285                    append_sections: IndexMap::new(),
2286                    patch_sections: IndexMap::new(),
2287                    metadata: IndexMap::new(),
2288                    metadata_unset: Vec::new(),
2289                    declare_relations: Vec::new(),
2290                    dry_run: true,
2291                    relations_unset: Vec::new(),
2292                },
2293                actor,
2294                Some(&client),
2295                None,
2296            )
2297            .unwrap();
2298
2299        // Wire shape: content_hash = current; prospective_hash =
2300        // what the write would produce; commit_sha empty.
2301        assert_eq!(outcome.content_hash, original_hash);
2302        let prospective = outcome
2303            .prospective_hash
2304            .expect("prospective_hash populated on dry_run");
2305        assert_ne!(prospective, original_hash);
2306        assert!(outcome.commit_sha.is_empty());
2307        // Store entity unchanged.
2308        let store_entity = engine.get_entity(&seeded.id).unwrap();
2309        assert_eq!(store_entity.content_hash, original_hash);
2310    }
2311
2312    /// Edge-count round-trip lock. Captures the REFERENCES-drift
2313    /// finding:
2314    /// a mutation cycle (create body wiki-links → relate → update
2315    /// body → rename → delete) must return both `total_edges` and the
2316    /// REFERENCES counter to the pre-cycle values exactly. The bug
2317    /// was in `push_entities_into_store`: `upsert` preserved the
2318    /// entity's pre-existing out-edges, so `add_edge` (idempotent on
2319    /// `(from, to, rel_type)`) couldn't remove edges that the new
2320    /// parse no longer emits. Dropping a wiki-link from a body or
2321    /// absorbing one into an explicit relationship leaked the stale
2322    /// REFERENCES edge.
2323    ///
2324    /// Under the alias model the leak is structurally impossible —
2325    /// body wiki-links no longer emit edges, so the cleanup-on-reparse
2326    /// path the original test exercised has no premise. The test
2327    /// keeps the CRUD cycle but routes edges through atomic
2328    /// `relations:` declarations and explicit `memstead_relate`, which is
2329    /// what the model now treats as the only edge source.
2330    #[test]
2331    fn references_edges_round_trip_across_full_crud_cycle() {
2332        let tmp = TempDir::new().unwrap();
2333        let mem_dir = tmp.path().to_path_buf();
2334        let writer = FilesystemMemWriter::new(mem_dir.clone());
2335        let mut engine = Engine::from_mounts(vec![(
2336            folder_mount("specs", mem_dir),
2337            Box::new(writer) as Box<dyn MemBackend>,
2338        )])
2339        .unwrap();
2340        let (actor, client) = cli_actor();
2341
2342        // Seed two link targets so the wiki-links inside the
2343        // probe entity's body resolve to real entities (not auto-
2344        // stubs we'd then have to GC).
2345        let foo = engine
2346            .create_entity(
2347                empty_create_args("specs", "Foo"),
2348                actor,
2349                Some(&client),
2350                None,
2351            )
2352            .unwrap();
2353        let bar = engine
2354            .create_entity(
2355                empty_create_args("specs", "Bar"),
2356                actor,
2357                Some(&client),
2358                None,
2359            )
2360            .unwrap();
2361
2362        let count_references = |engine: &Engine| -> usize {
2363            engine
2364                .store()
2365                .all_ids()
2366                .flat_map(|id| engine.store().outgoing(id))
2367                .filter(|e| e.rel_type == "REFERENCES")
2368                .count()
2369        };
2370
2371        let baseline_edges = engine.store().edge_count();
2372        let baseline_refs = count_references(&engine);
2373
2374        // Step 1: create entity with body wiki-links — the
2375        // alias-synthesis pass auto-emits one REFERENCES per body
2376        // wiki-link (default schema's `alias_target_rel_type` →
2377        // REFERENCES), so the explicit `relations:` slot stays
2378        // empty. Net: 2 REFERENCES.
2379        let mut sections = IndexMap::new();
2380        sections.insert(
2381            "identity".to_string(),
2382            "See [[foo]] and [[bar]] inline.".to_string(),
2383        );
2384        sections.insert("purpose".to_string(), "probe purpose".to_string());
2385        let probe = engine
2386            .create_entity(
2387                CreateEntityArgs {
2388                    mem: "specs".to_string(),
2389                    title: "Probe".to_string(),
2390                    entity_type: "spec".to_string(),
2391                    sections,
2392                    metadata: IndexMap::new(),
2393                    relations: Vec::new(),
2394                    dry_run: false,
2395                },
2396                actor,
2397                Some(&client),
2398                None,
2399            )
2400            .unwrap();
2401        assert_eq!(count_references(&engine), baseline_refs + 2);
2402
2403        // Step 2: relate INFORMED_BY → foo as a second relation to the
2404        // same target. The body wiki-link `[[foo]]` aliases the set of
2405        // relations to foo, so adding INFORMED_BY does not affect the
2406        // REFERENCES count — both relations coexist.
2407        let relate1 = engine
2408            .relate_entity(
2409                RelateEntityArgs {
2410                    source: probe.id.clone(),
2411                    expected_hash: Some(probe.content_hash.clone()),
2412                    rel_type: "INFORMED_BY".to_string(),
2413                    target: foo.id.clone(),
2414                    remove: false,
2415                    description: None,
2416                },
2417                actor,
2418                Some(&client),
2419                None,
2420            )
2421            .unwrap();
2422        assert_eq!(
2423            count_references(&engine),
2424            baseline_refs + 2,
2425            "set-membership aliasing — adding INFORMED_BY does not \
2426             absorb the REFERENCES relation"
2427        );
2428
2429        // Step 3: drop the [[bar]] body link. The alias-synthesis pass
2430        // GCs the synthesised REFERENCES → bar atomically with the
2431        // body update — no second `memstead_relate --remove` needed.
2432        let mut sections = IndexMap::new();
2433        sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
2434        let updated = engine
2435            .update_entity(
2436                UpdateEntityArgs {
2437                    id: probe.id.clone(),
2438                    expected_hash: Some(relate1.content_hash.clone()),
2439                    sections,
2440                    append_sections: IndexMap::new(),
2441                    patch_sections: IndexMap::new(),
2442                    metadata: IndexMap::new(),
2443                    metadata_unset: Vec::new(),
2444                    declare_relations: Vec::new(),
2445                    dry_run: false,
2446                    relations_unset: Vec::new(),
2447                },
2448                actor,
2449                Some(&client),
2450                None,
2451            )
2452            .unwrap();
2453        assert_eq!(
2454            count_references(&engine),
2455            baseline_refs + 1,
2456            "REFERENCES → bar must be auto-GC'd when its body link drops"
2457        );
2458
2459        // Step 4: rename the entity. Edges follow via remove + push.
2460        let renamed = engine
2461            .rename_entity(
2462                crate::engine::RenameEntityArgs {
2463                    id: probe.id.clone(),
2464                    expected_hash: Some(updated.content_hash.clone()),
2465                    new_title: "Probe Renamed".to_string(),
2466                },
2467                actor,
2468                Some(&client),
2469                None,
2470            )
2471            .unwrap();
2472        assert_eq!(count_references(&engine), baseline_refs + 1);
2473
2474        // Step 5: delete the renamed entity. The INFORMED_BY → foo
2475        // edge cascades; REFERENCES count unchanged.
2476        engine
2477            .delete_entity(
2478                crate::engine::DeleteEntityArgs {
2479                    id: renamed.new_id.clone(),
2480                    expected_hash: Some(renamed.content_hash.clone()),
2481                },
2482                actor,
2483                Some(&client),
2484                None,
2485            )
2486            .unwrap();
2487
2488        // Final assertion: every counter back to baseline.
2489        assert_eq!(
2490            engine.store().edge_count(),
2491            baseline_edges,
2492            "total edges must round-trip to baseline"
2493        );
2494        assert_eq!(
2495            count_references(&engine),
2496            baseline_refs,
2497            "REFERENCES counter must round-trip to baseline"
2498        );
2499
2500        // Cross-check: a full reload of the mem produces the same
2501        // post-cycle counts. If the in-memory store and the on-disk
2502        // bytes drift, reload uncovers it.
2503        engine.reload_one_mem("specs").unwrap();
2504        assert_eq!(
2505            engine.store().edge_count(),
2506            baseline_edges,
2507            "total edges must match disk after reload"
2508        );
2509        assert_eq!(
2510            count_references(&engine),
2511            baseline_refs,
2512            "REFERENCES must match disk after reload"
2513        );
2514        // Sanity: foo + bar still in the store (they were not deleted).
2515        assert!(engine.store().contains(&foo.id));
2516        assert!(engine.store().contains(&bar.id));
2517    }
2518
2519    #[test]
2520    fn update_entity_returns_commit_sha_title_modified_date_warnings_shape() {
2521        let tmp = TempDir::new().unwrap();
2522        let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
2523        let (actor, client) = cli_actor();
2524
2525        let mut sections = IndexMap::new();
2526        sections.insert("identity".to_string(), "edited body".to_string());
2527
2528        let outcome = engine
2529            .update_entity(
2530                UpdateEntityArgs {
2531                    id: seeded.id.clone(),
2532                    expected_hash: Some(seeded.content_hash.clone()),
2533                    sections,
2534                    append_sections: IndexMap::new(),
2535                    patch_sections: IndexMap::new(),
2536                    metadata: IndexMap::new(),
2537                    metadata_unset: Vec::new(),
2538                    declare_relations: Vec::new(),
2539                    dry_run: false,
2540                    relations_unset: Vec::new(),
2541                },
2542                actor,
2543                Some(&client),
2544                None,
2545            )
2546            .unwrap();
2547
2548        // Folder backend produces a synthetic CommitId.
2549        assert!(
2550            !outcome.commit_sha.is_empty(),
2551            "commit_sha must be populated on a real update"
2552        );
2553        // Title echoed from the parsed entity post-write.
2554        assert_eq!(outcome.title, "Subject");
2555        // The default `spec` schema declares `modified_date` with
2556        // `auto_timestamp: true`; the unified update path
2557        // auto-stamps it. Asserting non-empty pins the
2558        // wire-shape parity with full's UpdateResult.modified_date.
2559        assert!(
2560            !outcome.modified_date.is_empty(),
2561            "modified_date must be auto-stamped on update for the default spec schema",
2562        );
2563        // V1: warnings always empty (typed warning surfaces are
2564        // separate session work). The vec is present on the outcome
2565        // so the wire shape parity with full's UpdateResult holds.
2566        assert!(outcome.warnings.is_empty());
2567        // Section was modified (existing behaviour, sanity check).
2568        assert_eq!(
2569            outcome.modified_sections.replaced,
2570            vec!["identity".to_string()]
2571        );
2572    }
2573
2574    // ---- Engine::update_entity no-op detection ---------------------
2575
2576    /// Re-setting a
2577    /// section to its current on-disk value short-circuits to
2578    /// `UPDATE_NOOP` and preserves `last_modified` at its pre-call
2579    /// value. Pre-fix the auto-timestamp stamped `last_modified` to
2580    /// `today_iso()` before the bytes-compare ran; the stamp
2581    /// synthesised a delta and the no-op never matched.
2582    #[test]
2583    fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
2584        let tmp = TempDir::new().unwrap();
2585        let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
2586        let (actor, client) = cli_actor();
2587
2588        // Read the pre-update `last_modified` so we can assert it
2589        // survives the no-op.
2590        let pre_last_modified = engine
2591            .get_entity(&seeded.id)
2592            .and_then(|e| e.metadata.get("last_modified"))
2593            .map(|v| v.to_frontmatter_string())
2594            .expect("seeded entity has last_modified");
2595
2596        // Re-set `identity` to its current on-disk body. The seed
2597        // helper writes "fixture identity body" — passing the same
2598        // string back must be a no-op.
2599        let mut sections = IndexMap::new();
2600        sections.insert("identity".to_string(), "fixture identity body".to_string());
2601        let outcome = engine
2602            .update_entity(
2603                UpdateEntityArgs {
2604                    id: seeded.id.clone(),
2605                    expected_hash: Some(seeded.content_hash.clone()),
2606                    sections,
2607                    append_sections: IndexMap::new(),
2608                    patch_sections: IndexMap::new(),
2609                    metadata: IndexMap::new(),
2610                    metadata_unset: Vec::new(),
2611                    declare_relations: Vec::new(),
2612                    dry_run: false,
2613                    relations_unset: Vec::new(),
2614                },
2615                actor,
2616                Some(&client),
2617                None,
2618            )
2619            .unwrap();
2620
2621        assert_eq!(outcome.commit_sha, "", "no-op must not commit");
2622        assert_eq!(
2623            outcome.content_hash, seeded.content_hash,
2624            "no-op must not advance content_hash",
2625        );
2626        assert!(
2627            outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2628            "UPDATE_NOOP must fire on bytes-identical re-set",
2629        );
2630        assert_eq!(
2631            outcome.modified_date, pre_last_modified,
2632            "no-op must preserve last_modified at the pre-call value",
2633        );
2634        // The applied delta is empty on a
2635        // no-op — `modified_sections` must not claim `identity` was
2636        // replaced when nothing landed (matching the empty commit_sha
2637        // and unchanged hash above).
2638        assert!(
2639            outcome.modified_sections.replaced.is_empty()
2640                && outcome.modified_sections.appended.is_empty()
2641                && outcome.modified_sections.patched.is_empty(),
2642            "no-op must report an empty section delta, got {:?}",
2643            outcome.modified_sections,
2644        );
2645
2646        // The on-disk entity also still carries the pre-update
2647        // last_modified — the no-op didn't bump it through some
2648        // other path.
2649        let post_last_modified = engine
2650            .get_entity(&seeded.id)
2651            .and_then(|e| e.metadata.get("last_modified"))
2652            .map(|v| v.to_frontmatter_string())
2653            .expect("entity still in store");
2654        assert_eq!(post_last_modified, pre_last_modified);
2655    }
2656
2657    /// A payload with no
2658    /// recognised mutation content refuses with `EMPTY_UPDATE` BEFORE
2659    /// any engine work runs. Previously this same input short-
2660    /// circuited as a success-with-`UPDATE_NOOP`-warning, which
2661    /// hid a boundary-discipline failure mode (a
2662    /// misspelled mutation key deserialises to empty defaults and
2663    /// looks like a no-op success on the wire). The new refusal
2664    /// makes "no mutation content provided" structurally distinct
2665    /// from "mutation content provided but matched current state"
2666    /// (which still surfaces `UPDATE_NOOP` — see
2667    /// `update_entity_noop_same_content_surfaces_warning` below).
2668    #[test]
2669    fn update_entity_empty_payload_refuses_with_typed_code() {
2670        let tmp = TempDir::new().unwrap();
2671        let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
2672        let (actor, client) = cli_actor();
2673
2674        let err = engine
2675            .update_entity(
2676                UpdateEntityArgs {
2677                    id: seeded.id.clone(),
2678                    expected_hash: Some(seeded.content_hash.clone()),
2679                    sections: IndexMap::new(),
2680                    append_sections: IndexMap::new(),
2681                    patch_sections: IndexMap::new(),
2682                    metadata: IndexMap::new(),
2683                    metadata_unset: Vec::new(),
2684                    declare_relations: Vec::new(),
2685                    dry_run: false,
2686                    relations_unset: Vec::new(),
2687                },
2688                actor,
2689                Some(&client),
2690                None,
2691            )
2692            .unwrap_err();
2693        match err {
2694            EngineError::EmptyUpdate { id } => {
2695                assert_eq!(id, seeded.id.to_string());
2696            }
2697            other => panic!("expected EMPTY_UPDATE, got {other:?}"),
2698        }
2699        // No provenance row landed — the refusal preempts any write.
2700        let log_path = tmp.path().join(".memstead/changes.jsonl");
2701        if let Ok(log) = std::fs::read_to_string(&log_path) {
2702            let updates = log.matches("\"kind\":\"update\"").count();
2703            assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
2704        }
2705    }
2706
2707    /// Complement: a
2708    /// payload with mutation content that matches the current entity
2709    /// state continues to land as success-with-`UPDATE_NOOP`-warning.
2710    /// The new `EMPTY_UPDATE` refusal applies only when no mutation
2711    /// content was provided; this path is structurally distinct.
2712    #[test]
2713    fn update_entity_noop_same_content_surfaces_warning() {
2714        let tmp = TempDir::new().unwrap();
2715        let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
2716        let (actor, client) = cli_actor();
2717
2718        // `empty_create_args` seeds `identity` with this exact body.
2719        let mut sections = IndexMap::new();
2720        sections.insert("identity".to_string(), "fixture identity body".to_string());
2721
2722        let outcome = engine
2723            .update_entity(
2724                UpdateEntityArgs {
2725                    id: seeded.id.clone(),
2726                    expected_hash: Some(seeded.content_hash.clone()),
2727                    sections,
2728                    append_sections: IndexMap::new(),
2729                    patch_sections: IndexMap::new(),
2730                    metadata: IndexMap::new(),
2731                    metadata_unset: Vec::new(),
2732                    declare_relations: Vec::new(),
2733                    dry_run: false,
2734                    relations_unset: Vec::new(),
2735                },
2736                actor,
2737                Some(&client),
2738                None,
2739            )
2740            .unwrap();
2741
2742        assert_eq!(outcome.commit_sha, "");
2743        assert_eq!(outcome.content_hash, seeded.content_hash);
2744        let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
2745        assert!(
2746            codes.contains(&"UPDATE_NOOP"),
2747            "same-content update must surface UPDATE_NOOP; got {codes:?}",
2748        );
2749    }
2750
2751    #[test]
2752    fn update_entity_noop_metadata_unset_on_absent_key() {
2753        // `metadata_unset=["never-set-key"]`
2754        // where the key was never set is a no-op — no field actually
2755        // changed, no commit advances, follow-up calls can chain
2756        // `expected_hash` without `HASH_MISMATCH`.
2757        let tmp = TempDir::new().unwrap();
2758        let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
2759        let (actor, client) = cli_actor();
2760
2761        let outcome = engine
2762            .update_entity(
2763                UpdateEntityArgs {
2764                    id: seeded.id.clone(),
2765                    expected_hash: Some(seeded.content_hash.clone()),
2766                    sections: IndexMap::new(),
2767                    append_sections: IndexMap::new(),
2768                    patch_sections: IndexMap::new(),
2769                    metadata: IndexMap::new(),
2770                    // `tags` is declared on the `spec` schema but
2771                    // unset on the seeded entity. Unsetting it should
2772                    // be a no-op rather than producing a fresh commit.
2773                    metadata_unset: vec!["tags".to_string()],
2774                    declare_relations: Vec::new(),
2775                    dry_run: false,
2776                    relations_unset: Vec::new(),
2777                },
2778                actor,
2779                Some(&client),
2780                None,
2781            )
2782            .unwrap();
2783
2784        assert_eq!(outcome.commit_sha, "");
2785        assert_eq!(outcome.content_hash, seeded.content_hash);
2786        assert!(
2787            outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2788            "absent-key metadata_unset must surface UPDATE_NOOP",
2789        );
2790        // Empty applied delta on the no-op —
2791        // `unset` must not claim `tags` was removed when nothing landed.
2792        assert!(
2793            outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
2794            "no-op must report an empty metadata delta, got {:?}",
2795            outcome.modified_metadata,
2796        );
2797
2798        // Follow-up real change against the unchanged hash succeeds —
2799        // no HASH_MISMATCH cascade from a phantom advance.
2800        let mut sections = IndexMap::new();
2801        sections.insert("identity".to_string(), "real change".to_string());
2802        let real = engine
2803            .update_entity(
2804                UpdateEntityArgs {
2805                    id: seeded.id.clone(),
2806                    expected_hash: Some(seeded.content_hash.clone()),
2807                    sections,
2808                    append_sections: IndexMap::new(),
2809                    patch_sections: IndexMap::new(),
2810                    metadata: IndexMap::new(),
2811                    metadata_unset: Vec::new(),
2812                    declare_relations: Vec::new(),
2813                    dry_run: false,
2814                    relations_unset: Vec::new(),
2815                },
2816                actor,
2817                Some(&client),
2818                None,
2819            )
2820            .unwrap();
2821        assert!(!real.commit_sha.is_empty());
2822        assert_ne!(real.content_hash, seeded.content_hash);
2823    }
2824
2825    /// The exact MCP repro — re-setting a
2826    /// metadata key to its current value no-ops, and the response's
2827    /// `modified_metadata` reports the applied delta (empty), not the
2828    /// requested key. Pre-fix the no-op short-circuit echoed
2829    /// `set: ["level"]` while `commit_sha` was empty and the hash
2830    /// unchanged — a self-contradictory response.
2831    #[test]
2832    fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
2833        let tmp = TempDir::new().unwrap();
2834        let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
2835        let (actor, client) = cli_actor();
2836
2837        // `level` defaults to "M0" on the spec schema, so the seed
2838        // carries it. Re-setting it to "M0" changes nothing.
2839        let mut metadata = IndexMap::new();
2840        metadata.insert("level".to_string(), "M0".to_string());
2841        let outcome = engine
2842            .update_entity(
2843                UpdateEntityArgs {
2844                    id: seeded.id.clone(),
2845                    expected_hash: Some(seeded.content_hash.clone()),
2846                    sections: IndexMap::new(),
2847                    append_sections: IndexMap::new(),
2848                    patch_sections: IndexMap::new(),
2849                    metadata,
2850                    metadata_unset: Vec::new(),
2851                    declare_relations: Vec::new(),
2852                    dry_run: false,
2853                    relations_unset: Vec::new(),
2854                },
2855                actor,
2856                Some(&client),
2857                None,
2858            )
2859            .unwrap();
2860
2861        assert_eq!(outcome.commit_sha, "", "no-op must not commit");
2862        assert_eq!(
2863            outcome.content_hash, seeded.content_hash,
2864            "no-op must not advance hash"
2865        );
2866        assert!(
2867            outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2868            "re-set to current value must surface UPDATE_NOOP",
2869        );
2870        assert!(
2871            outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
2872            "no-op must not claim `level` was set — applied delta is empty, got {:?}",
2873            outcome.modified_metadata,
2874        );
2875    }
2876
2877    #[test]
2878    fn update_entity_noop_declare_already_related_edge() {
2879        // Re-declare an already-related edge
2880        // via `declare_relations` — no field, section, metadata or
2881        // relations list actually changes, so the bytes are identical
2882        // and the call no-ops.
2883        use crate::ops::RelateArg;
2884        let tmp = TempDir::new().unwrap();
2885        let mem_dir = tmp.path().to_path_buf();
2886        let writer = FilesystemMemWriter::new(mem_dir.clone());
2887        let mut engine = Engine::from_mounts(vec![(
2888            folder_mount("specs", mem_dir),
2889            Box::new(writer) as Box<dyn MemBackend>,
2890        )])
2891        .unwrap();
2892        let (actor, client) = cli_actor();
2893        let target = engine
2894            .create_entity(
2895                empty_create_args("specs", "Target Already Related"),
2896                actor,
2897                Some(&client),
2898                None,
2899            )
2900            .unwrap();
2901        let source = engine
2902            .create_entity(
2903                empty_create_args("specs", "Source Already Related"),
2904                actor,
2905                Some(&client),
2906                None,
2907            )
2908            .unwrap();
2909        let after_relate = engine
2910            .relate_entity(
2911                RelateEntityArgs {
2912                    source: source.id.clone(),
2913                    expected_hash: Some(source.content_hash.clone()),
2914                    rel_type: "USES".to_string(),
2915                    target: target.id.clone(),
2916                    remove: false,
2917                    description: None,
2918                },
2919                actor,
2920                Some(&client),
2921                None,
2922            )
2923            .unwrap();
2924        // Now re-declare the same edge via update.declare_relations.
2925        let outcome = engine
2926            .update_entity(
2927                UpdateEntityArgs {
2928                    relations_unset: Vec::new(),
2929                    id: source.id.clone(),
2930                    expected_hash: Some(after_relate.content_hash.clone()),
2931                    sections: IndexMap::new(),
2932                    append_sections: IndexMap::new(),
2933                    patch_sections: IndexMap::new(),
2934                    metadata: IndexMap::new(),
2935                    metadata_unset: Vec::new(),
2936                    declare_relations: vec![RelateArg {
2937                        rel_type: "USES".to_string(),
2938                        to: target.id.clone(),
2939                        description: None,
2940                    }],
2941                    dry_run: false,
2942                },
2943                actor,
2944                Some(&client),
2945                None,
2946            )
2947            .unwrap();
2948
2949        assert_eq!(outcome.commit_sha, "");
2950        assert_eq!(outcome.content_hash, after_relate.content_hash);
2951        assert!(
2952            outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
2953            "duplicate declare must surface UPDATE_NOOP",
2954        );
2955        // `relations_declared` still records the entry — the per-
2956        // relation outcome is part of the surface, even for no-ops.
2957        assert_eq!(outcome.relations_declared.len(), 1);
2958        assert_eq!(outcome.relations_declared[0].rel_type, "USES");
2959        assert_eq!(outcome.relations_declared[0].target, target.id);
2960        assert!(!outcome.relations_declared[0].target_was_stubbed);
2961    }
2962
2963    #[test]
2964    fn update_entity_real_change_still_commits_and_advances_hash() {
2965        // Regression: the no-op short-circuit must not short-circuit
2966        // real changes. A section replacement still produces a
2967        // non-empty `commit_sha`, advances `content_hash`, and does
2968        // NOT surface UPDATE_NOOP.
2969        let tmp = TempDir::new().unwrap();
2970        let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
2971        let (actor, client) = cli_actor();
2972
2973        let mut sections = IndexMap::new();
2974        sections.insert("identity".to_string(), "definitely new body".to_string());
2975
2976        let outcome = engine
2977            .update_entity(
2978                UpdateEntityArgs {
2979                    id: seeded.id.clone(),
2980                    expected_hash: Some(seeded.content_hash.clone()),
2981                    sections,
2982                    append_sections: IndexMap::new(),
2983                    patch_sections: IndexMap::new(),
2984                    metadata: IndexMap::new(),
2985                    metadata_unset: Vec::new(),
2986                    declare_relations: Vec::new(),
2987                    dry_run: false,
2988                    relations_unset: Vec::new(),
2989                },
2990                actor,
2991                Some(&client),
2992                None,
2993            )
2994            .unwrap();
2995
2996        assert!(!outcome.commit_sha.is_empty(), "real change must commit");
2997        assert_ne!(
2998            outcome.content_hash, seeded.content_hash,
2999            "real change must advance content_hash",
3000        );
3001        assert!(
3002            !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3003            "real change must not surface UPDATE_NOOP",
3004        );
3005    }
3006
3007    #[test]
3008    fn update_entity_noop_preserves_expected_hash_across_chain() {
3009        // A follow-up update with the original
3010        // hash after one or more no-ops succeeds because the hash
3011        // never advanced. Demonstrates the `expected_hash`-caching
3012        // posture the agent surface relies on.
3013        let tmp = TempDir::new().unwrap();
3014        let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
3015        let (actor, client) = cli_actor();
3016
3017        // Two no-op calls in a row — both must return the same hash.
3018        // Pass same-content mutation
3019        // so UPDATE_NOOP fires (rather than EMPTY_UPDATE) and the
3020        // hash-chain invariant is exercised on the warning path.
3021        let mut noop_sections = IndexMap::new();
3022        noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
3023        for _ in 0..2 {
3024            let outcome = engine
3025                .update_entity(
3026                    UpdateEntityArgs {
3027                        id: seeded.id.clone(),
3028                        expected_hash: Some(seeded.content_hash.clone()),
3029                        sections: noop_sections.clone(),
3030                        append_sections: IndexMap::new(),
3031                        patch_sections: IndexMap::new(),
3032                        metadata: IndexMap::new(),
3033                        metadata_unset: Vec::new(),
3034                        declare_relations: Vec::new(),
3035                        dry_run: false,
3036                        relations_unset: Vec::new(),
3037                    },
3038                    actor,
3039                    Some(&client),
3040                    None,
3041                )
3042                .unwrap();
3043            assert_eq!(outcome.commit_sha, "");
3044            assert_eq!(outcome.content_hash, seeded.content_hash);
3045        }
3046
3047        // Real follow-up with the original hash still works — no
3048        // HASH_MISMATCH cascade because the hash never advanced.
3049        let mut sections = IndexMap::new();
3050        sections.insert(
3051            "identity".to_string(),
3052            "third call: real change".to_string(),
3053        );
3054        let real = engine
3055            .update_entity(
3056                UpdateEntityArgs {
3057                    id: seeded.id.clone(),
3058                    expected_hash: Some(seeded.content_hash.clone()),
3059                    sections,
3060                    append_sections: IndexMap::new(),
3061                    patch_sections: IndexMap::new(),
3062                    metadata: IndexMap::new(),
3063                    metadata_unset: Vec::new(),
3064                    declare_relations: Vec::new(),
3065                    dry_run: false,
3066                    relations_unset: Vec::new(),
3067                },
3068                actor,
3069                Some(&client),
3070                None,
3071            )
3072            .unwrap();
3073        assert!(!real.commit_sha.is_empty());
3074        assert_ne!(real.content_hash, seeded.content_hash);
3075    }
3076
3077    // ---- Engine::delete_entity --------------------------------------
3078
3079    // ---------------------------------------------------------------------
3080    // Alias-synthesis pass. Body wiki-links auto-emit
3081    // relations of the source schema's `alias_target_rel_type` pointer
3082    // and are garbage-collected when the body wiki-link disappears.
3083    // ---------------------------------------------------------------------
3084
3085    #[test]
3086    fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
3087        // Create with `[[target]]` in body → synthesis emits
3088        // REFERENCES. Update body to drop the wiki-link → GC drops
3089        // the auto-emitted REFERENCES.
3090        use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3091        use indexmap::IndexMap;
3092        use tempfile::TempDir;
3093
3094        let tmp = TempDir::new().unwrap();
3095        let mem_dir = tmp.path().to_path_buf();
3096        let writer = FilesystemMemWriter::new(mem_dir.clone());
3097        let mut engine = Engine::from_mounts(vec![(
3098            folder_mount("specs", mem_dir.clone()),
3099            Box::new(writer) as Box<dyn MemBackend>,
3100        )])
3101        .unwrap();
3102        engine.set_workspace_root(mem_dir.clone());
3103        let (actor, client) = cli_actor();
3104
3105        let target = engine
3106            .create_entity(
3107                empty_create_args("specs", "Target"),
3108                actor,
3109                Some(&client),
3110                None,
3111            )
3112            .unwrap();
3113        // Create source with the body wiki-link already present —
3114        // synthesis fires inside create.
3115        let mut sections: IndexMap<String, String> = IndexMap::new();
3116        sections.insert("identity".to_string(), "source identity".to_string());
3117        sections.insert(
3118            "purpose".to_string(),
3119            "see [[target]] for context".to_string(),
3120        );
3121        let source = engine
3122            .create_entity(
3123                CreateEntityArgs {
3124                    mem: "specs".to_string(),
3125                    title: "Source".to_string(),
3126                    entity_type: "spec".to_string(),
3127                    sections,
3128                    metadata: IndexMap::new(),
3129                    relations: Vec::new(),
3130                    dry_run: false,
3131                },
3132                actor,
3133                Some(&client),
3134                None,
3135            )
3136            .unwrap();
3137        assert!(
3138            engine
3139                .get_entity(&source.id)
3140                .unwrap()
3141                .relationships
3142                .iter()
3143                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3144            "create-time synthesis must emit REFERENCES → target",
3145        );
3146
3147        // Update: drop the body wiki-link. GC should remove the
3148        // synthesised REFERENCES.
3149        let mut new_sections: IndexMap<String, String> = IndexMap::new();
3150        new_sections.insert("purpose".to_string(), "no link any more".to_string());
3151        engine
3152            .update_entity(
3153                UpdateEntityArgs {
3154                    id: source.id.clone(),
3155                    expected_hash: Some(source.content_hash.clone()),
3156                    sections: new_sections,
3157                    append_sections: IndexMap::new(),
3158                    patch_sections: IndexMap::new(),
3159                    metadata: IndexMap::new(),
3160                    metadata_unset: Vec::new(),
3161                    declare_relations: Vec::new(),
3162                    dry_run: false,
3163                    relations_unset: Vec::new(),
3164                },
3165                actor,
3166                Some(&client),
3167                None,
3168            )
3169            .expect("update must succeed; GC drops the now-orphan REFERENCES");
3170        let in_mem = engine.get_entity(&source.id).unwrap();
3171        assert!(
3172            !in_mem
3173                .relationships
3174                .iter()
3175                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3176            "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
3177            in_mem.relationships,
3178        );
3179    }
3180
3181    #[test]
3182    fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
3183        // Create source with `[[ghost]]` body link → alias synthesis
3184        // auto-stubs `ghost` and emits REFERENCES → ghost. The update
3185        // drops the link, so the REFERENCES edge (the stub's only
3186        // referrer) disappears; the orphan-stub GC sweep removes the
3187        // stub and surfaces it in `orphan_stubs_removed`. A reload from
3188        // disk shows the same (decremented) stub count — proving the GC
3189        // was a real store mutation, not a session-local view fix.
3190        use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3191        use indexmap::IndexMap;
3192        use tempfile::TempDir;
3193
3194        let tmp = TempDir::new().unwrap();
3195        let mem_dir = tmp.path().to_path_buf();
3196        let writer = FilesystemMemWriter::new(mem_dir.clone());
3197        let mut engine = Engine::from_mounts(vec![(
3198            folder_mount("specs", mem_dir.clone()),
3199            Box::new(writer) as Box<dyn MemBackend>,
3200        )])
3201        .unwrap();
3202        engine.set_workspace_root(mem_dir.clone());
3203        let (actor, client) = cli_actor();
3204
3205        let ghost = crate::EntityId::new("specs", "ghost");
3206        let mut sections: IndexMap<String, String> = IndexMap::new();
3207        sections.insert("identity".to_string(), "source identity".to_string());
3208        sections.insert(
3209            "purpose".to_string(),
3210            "see [[ghost]] for context".to_string(),
3211        );
3212        let source = engine
3213            .create_entity(
3214                CreateEntityArgs {
3215                    mem: "specs".to_string(),
3216                    title: "Source".to_string(),
3217                    entity_type: "spec".to_string(),
3218                    sections,
3219                    metadata: IndexMap::new(),
3220                    relations: Vec::new(),
3221                    dry_run: false,
3222                },
3223                actor,
3224                Some(&client),
3225                None,
3226            )
3227            .unwrap();
3228        assert!(
3229            engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
3230            "body wiki-link to an absent target must auto-stub it",
3231        );
3232        assert_eq!(
3233            engine.health().stub_count,
3234            1,
3235            "one stub before the link drop"
3236        );
3237
3238        let mut new_sections: IndexMap<String, String> = IndexMap::new();
3239        new_sections.insert("purpose".to_string(), "no link any more".to_string());
3240        let outcome = engine
3241            .update_entity(
3242                UpdateEntityArgs {
3243                    id: source.id.clone(),
3244                    expected_hash: Some(source.content_hash.clone()),
3245                    sections: new_sections,
3246                    append_sections: IndexMap::new(),
3247                    patch_sections: IndexMap::new(),
3248                    metadata: IndexMap::new(),
3249                    metadata_unset: Vec::new(),
3250                    declare_relations: Vec::new(),
3251                    dry_run: false,
3252                    relations_unset: Vec::new(),
3253                },
3254                actor,
3255                Some(&client),
3256                None,
3257            )
3258            .expect("update must succeed and GC the now-orphan stub");
3259
3260        assert_eq!(
3261            outcome.orphan_stubs_removed,
3262            vec![ghost.clone()],
3263            "the update that dropped the last body link must report the GC'd stub",
3264        );
3265        assert!(
3266            !engine.store().contains(&ghost),
3267            "orphan stub must be gone from the in-memory store",
3268        );
3269        assert_eq!(
3270            engine.health().stub_count,
3271            0,
3272            "stub count decremented in-session"
3273        );
3274
3275        // Reload from disk: the source's on-disk markdown no longer
3276        // carries the link, so the parser re-emits no stub. The
3277        // decremented count holds across the reload — the GC was real.
3278        engine.reload_each_writable_mem().unwrap();
3279        assert!(
3280            !engine.store().contains(&ghost),
3281            "stub stays gone after reload-from-disk",
3282        );
3283        assert_eq!(
3284            engine.health().stub_count,
3285            0,
3286            "reloaded-from-disk store carries the same stub count as the in-session post-update state",
3287        );
3288    }
3289
3290    #[test]
3291    fn update_gc_noop_when_section_edit_changes_no_body_link() {
3292        // An update that edits one section while leaving the `[[ghost]]`
3293        // link standing in another orphans nothing: `orphan_stubs_removed`
3294        // is present and empty (stable shape, no spurious GC), and the
3295        // stub survives because its referrer survives.
3296        use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3297        use indexmap::IndexMap;
3298        use tempfile::TempDir;
3299
3300        let tmp = TempDir::new().unwrap();
3301        let mem_dir = tmp.path().to_path_buf();
3302        let writer = FilesystemMemWriter::new(mem_dir.clone());
3303        let mut engine = Engine::from_mounts(vec![(
3304            folder_mount("specs", mem_dir.clone()),
3305            Box::new(writer) as Box<dyn MemBackend>,
3306        )])
3307        .unwrap();
3308        engine.set_workspace_root(mem_dir.clone());
3309        let (actor, client) = cli_actor();
3310
3311        let ghost = crate::EntityId::new("specs", "ghost");
3312        let mut sections: IndexMap<String, String> = IndexMap::new();
3313        sections.insert("identity".to_string(), "original identity".to_string());
3314        sections.insert(
3315            "purpose".to_string(),
3316            "see [[ghost]] for context".to_string(),
3317        );
3318        let source = engine
3319            .create_entity(
3320                CreateEntityArgs {
3321                    mem: "specs".to_string(),
3322                    title: "Source".to_string(),
3323                    entity_type: "spec".to_string(),
3324                    sections,
3325                    metadata: IndexMap::new(),
3326                    relations: Vec::new(),
3327                    dry_run: false,
3328                },
3329                actor,
3330                Some(&client),
3331                None,
3332            )
3333            .unwrap();
3334        assert!(engine.store().contains(&ghost), "ghost stub materialised");
3335
3336        // Replace `identity` only; the `[[ghost]]` link in `purpose`
3337        // stays, so no edge drops.
3338        let mut edit: IndexMap<String, String> = IndexMap::new();
3339        edit.insert("identity".to_string(), "edited identity".to_string());
3340        let outcome = engine
3341            .update_entity(
3342                UpdateEntityArgs {
3343                    id: source.id.clone(),
3344                    expected_hash: Some(source.content_hash.clone()),
3345                    sections: edit,
3346                    append_sections: IndexMap::new(),
3347                    patch_sections: IndexMap::new(),
3348                    metadata: IndexMap::new(),
3349                    metadata_unset: Vec::new(),
3350                    declare_relations: Vec::new(),
3351                    dry_run: false,
3352                    relations_unset: Vec::new(),
3353                },
3354                actor,
3355                Some(&client),
3356                None,
3357            )
3358            .expect("update must succeed");
3359        assert!(
3360            outcome.orphan_stubs_removed.is_empty(),
3361            "an edit that keeps every body wiki-link orphans nothing; got {:?}",
3362            outcome.orphan_stubs_removed,
3363        );
3364        assert!(
3365            engine.store().contains(&ghost),
3366            "the still-referenced stub survives the unrelated section edit",
3367        );
3368    }
3369
3370    #[test]
3371    fn update_gc_preserves_stub_with_surviving_referrer() {
3372        // Two sources both body-link `[[ghost]]`. Dropping the link from
3373        // one leaves `ghost` referenced by the other — set-membership
3374        // semantics keep the stub alive and `orphan_stubs_removed` empty.
3375        use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3376        use indexmap::IndexMap;
3377        use tempfile::TempDir;
3378
3379        let tmp = TempDir::new().unwrap();
3380        let mem_dir = tmp.path().to_path_buf();
3381        let writer = FilesystemMemWriter::new(mem_dir.clone());
3382        let mut engine = Engine::from_mounts(vec![(
3383            folder_mount("specs", mem_dir.clone()),
3384            Box::new(writer) as Box<dyn MemBackend>,
3385        )])
3386        .unwrap();
3387        engine.set_workspace_root(mem_dir.clone());
3388        let (actor, client) = cli_actor();
3389
3390        let ghost = crate::EntityId::new("specs", "ghost");
3391        let make_with_link = |title: &str| {
3392            let mut sections: IndexMap<String, String> = IndexMap::new();
3393            sections.insert("identity".to_string(), format!("{title} identity"));
3394            sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
3395            CreateEntityArgs {
3396                mem: "specs".to_string(),
3397                title: title.to_string(),
3398                entity_type: "spec".to_string(),
3399                sections,
3400                metadata: IndexMap::new(),
3401                relations: Vec::new(),
3402                dry_run: false,
3403            }
3404        };
3405        let source_a = engine
3406            .create_entity(make_with_link("Source A"), actor, Some(&client), None)
3407            .unwrap();
3408        engine
3409            .create_entity(make_with_link("Source B"), actor, Some(&client), None)
3410            .unwrap();
3411        assert!(engine.store().contains(&ghost), "ghost stub materialised");
3412
3413        // Drop the link from source A only.
3414        let mut drop_link: IndexMap<String, String> = IndexMap::new();
3415        drop_link.insert("purpose".to_string(), "no link here".to_string());
3416        let outcome = engine
3417            .update_entity(
3418                UpdateEntityArgs {
3419                    id: source_a.id.clone(),
3420                    expected_hash: Some(source_a.content_hash.clone()),
3421                    sections: drop_link,
3422                    append_sections: IndexMap::new(),
3423                    patch_sections: IndexMap::new(),
3424                    metadata: IndexMap::new(),
3425                    metadata_unset: Vec::new(),
3426                    declare_relations: Vec::new(),
3427                    dry_run: false,
3428                    relations_unset: Vec::new(),
3429                },
3430                actor,
3431                Some(&client),
3432                None,
3433            )
3434            .expect("update must succeed");
3435        assert!(
3436            outcome.orphan_stubs_removed.is_empty(),
3437            "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
3438            outcome.orphan_stubs_removed,
3439        );
3440        assert!(
3441            engine.store().contains(&ghost),
3442            "stub survives via the surviving referrer",
3443        );
3444    }
3445
3446    #[test]
3447    fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
3448        // Explicit USES to target (USES is not the schema's
3449        // alias_target_rel_type pointer). A subsequent body-changing
3450        // update must NOT drop the USES edge — GC only touches
3451        // relations of the pointer rel-type. Under Option C, REFERENCES
3452        // can't be authored explicitly (`manual_authoring: forbidden`),
3453        // so the analogous "explicit REFERENCES preserved" scenario is
3454        // structurally impossible; USES exercises the same invariant
3455        // from the rel-type-discrimination side.
3456        use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
3457        use indexmap::IndexMap;
3458        use tempfile::TempDir;
3459
3460        let tmp = TempDir::new().unwrap();
3461        let mem_dir = tmp.path().to_path_buf();
3462        let writer = FilesystemMemWriter::new(mem_dir.clone());
3463        let mut engine = Engine::from_mounts(vec![(
3464            folder_mount("specs", mem_dir.clone()),
3465            Box::new(writer) as Box<dyn MemBackend>,
3466        )])
3467        .unwrap();
3468        engine.set_workspace_root(mem_dir.clone());
3469        let (actor, client) = cli_actor();
3470
3471        let target = engine
3472            .create_entity(
3473                empty_create_args("specs", "Target"),
3474                actor,
3475                Some(&client),
3476                None,
3477            )
3478            .unwrap();
3479        let source = engine
3480            .create_entity(
3481                empty_create_args("specs", "Source"),
3482                actor,
3483                Some(&client),
3484                None,
3485            )
3486            .unwrap();
3487
3488        // Explicit relate — no body wiki-link.
3489        let relate = engine
3490            .relate_entity(
3491                RelateEntityArgs {
3492                    source: source.id.clone(),
3493                    expected_hash: Some(source.content_hash.clone()),
3494                    rel_type: "USES".to_string(),
3495                    target: target.id.clone(),
3496                    remove: false,
3497                    description: None,
3498                },
3499                actor,
3500                Some(&client),
3501                None,
3502            )
3503            .unwrap();
3504
3505        // Update an unrelated section. The explicit USES must survive
3506        // — it's not the alias_target_rel_type, GC ignores it.
3507        let mut sections: IndexMap<String, String> = IndexMap::new();
3508        sections.insert("purpose".to_string(), "unrelated edit".to_string());
3509        engine
3510            .update_entity(
3511                UpdateEntityArgs {
3512                    id: source.id.clone(),
3513                    expected_hash: Some(relate.content_hash.clone()),
3514                    sections,
3515                    append_sections: IndexMap::new(),
3516                    patch_sections: IndexMap::new(),
3517                    metadata: IndexMap::new(),
3518                    metadata_unset: Vec::new(),
3519                    declare_relations: Vec::new(),
3520                    dry_run: false,
3521                    relations_unset: Vec::new(),
3522                },
3523                actor,
3524                Some(&client),
3525                None,
3526            )
3527            .expect("update must succeed");
3528        let in_mem = engine.get_entity(&source.id).unwrap();
3529        assert!(
3530            in_mem
3531                .relationships
3532                .iter()
3533                .any(|r| r.rel_type == "USES" && r.target == target.id),
3534            "explicit USES must survive an unrelated body update; got {:?}",
3535            in_mem.relationships,
3536        );
3537    }
3538
3539    #[test]
3540    fn synthesis_dedupes_repeated_body_links_to_same_target() {
3541        // Two `[[target]]` wiki-links in one body — synthesis must
3542        // not double-add. Result: exactly one REFERENCES.
3543        use crate::engine::UpdateEntityArgs;
3544        use indexmap::IndexMap;
3545        use tempfile::TempDir;
3546
3547        let tmp = TempDir::new().unwrap();
3548        let mem_dir = tmp.path().to_path_buf();
3549        let writer = FilesystemMemWriter::new(mem_dir.clone());
3550        let mut engine = Engine::from_mounts(vec![(
3551            folder_mount("specs", mem_dir.clone()),
3552            Box::new(writer) as Box<dyn MemBackend>,
3553        )])
3554        .unwrap();
3555        engine.set_workspace_root(mem_dir.clone());
3556        let (actor, client) = cli_actor();
3557
3558        let target = engine
3559            .create_entity(
3560                empty_create_args("specs", "Target"),
3561                actor,
3562                Some(&client),
3563                None,
3564            )
3565            .unwrap();
3566        let source = engine
3567            .create_entity(
3568                empty_create_args("specs", "Source"),
3569                actor,
3570                Some(&client),
3571                None,
3572            )
3573            .unwrap();
3574
3575        let mut sections: IndexMap<String, String> = IndexMap::new();
3576        sections.insert(
3577            "purpose".to_string(),
3578            "see [[target]] and again [[target]]".to_string(),
3579        );
3580        engine
3581            .update_entity(
3582                UpdateEntityArgs {
3583                    id: source.id.clone(),
3584                    expected_hash: Some(source.content_hash.clone()),
3585                    sections,
3586                    append_sections: IndexMap::new(),
3587                    patch_sections: IndexMap::new(),
3588                    metadata: IndexMap::new(),
3589                    metadata_unset: Vec::new(),
3590                    declare_relations: Vec::new(),
3591                    dry_run: false,
3592                    relations_unset: Vec::new(),
3593                },
3594                actor,
3595                Some(&client),
3596                None,
3597            )
3598            .unwrap();
3599        let in_mem = engine.get_entity(&source.id).unwrap();
3600        let count = in_mem
3601            .relationships
3602            .iter()
3603            .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
3604            .count();
3605        assert_eq!(
3606            count, 1,
3607            "dedupe must leave exactly one REFERENCES → target; got {:?}",
3608            in_mem.relationships,
3609        );
3610    }
3611
3612    #[test]
3613    fn synthesis_coexists_with_explicit_uses_to_same_target() {
3614        // Explicit `USES` to target AND body wiki-link to target →
3615        // entity carries both USES and REFERENCES edges; synthesis
3616        // dedupes on `(rel_type, target)` so USES never suppresses
3617        // REFERENCES.
3618        use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
3619        use indexmap::IndexMap;
3620        use tempfile::TempDir;
3621
3622        let tmp = TempDir::new().unwrap();
3623        let mem_dir = tmp.path().to_path_buf();
3624        let writer = FilesystemMemWriter::new(mem_dir.clone());
3625        let mut engine = Engine::from_mounts(vec![(
3626            folder_mount("specs", mem_dir.clone()),
3627            Box::new(writer) as Box<dyn MemBackend>,
3628        )])
3629        .unwrap();
3630        engine.set_workspace_root(mem_dir.clone());
3631        let (actor, client) = cli_actor();
3632
3633        let target = engine
3634            .create_entity(
3635                empty_create_args("specs", "Target"),
3636                actor,
3637                Some(&client),
3638                None,
3639            )
3640            .unwrap();
3641        let source = engine
3642            .create_entity(
3643                empty_create_args("specs", "Source"),
3644                actor,
3645                Some(&client),
3646                None,
3647            )
3648            .unwrap();
3649        // Explicit USES.
3650        let relate = engine
3651            .relate_entity(
3652                RelateEntityArgs {
3653                    source: source.id.clone(),
3654                    expected_hash: Some(source.content_hash.clone()),
3655                    rel_type: "USES".to_string(),
3656                    target: target.id.clone(),
3657                    remove: false,
3658                    description: None,
3659                },
3660                actor,
3661                Some(&client),
3662                None,
3663            )
3664            .unwrap();
3665        // Body wiki-link to the same target — synthesis emits REFERENCES.
3666        let mut sections: IndexMap<String, String> = IndexMap::new();
3667        sections.insert(
3668            "purpose".to_string(),
3669            "we also reference [[target]]".to_string(),
3670        );
3671        engine
3672            .update_entity(
3673                UpdateEntityArgs {
3674                    id: source.id.clone(),
3675                    expected_hash: Some(relate.content_hash.clone()),
3676                    sections,
3677                    append_sections: IndexMap::new(),
3678                    patch_sections: IndexMap::new(),
3679                    metadata: IndexMap::new(),
3680                    metadata_unset: Vec::new(),
3681                    declare_relations: Vec::new(),
3682                    dry_run: false,
3683                    relations_unset: Vec::new(),
3684                },
3685                actor,
3686                Some(&client),
3687                None,
3688            )
3689            .unwrap();
3690        let in_mem = engine.get_entity(&source.id).unwrap();
3691        assert!(
3692            in_mem
3693                .relationships
3694                .iter()
3695                .any(|r| r.rel_type == "USES" && r.target == target.id),
3696            "USES must survive — synthesis dedupes on (rel_type, target)",
3697        );
3698        assert!(
3699            in_mem
3700                .relationships
3701                .iter()
3702                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3703            "REFERENCES must be synthesised even though USES already targets the same entity",
3704        );
3705    }
3706
3707    // ---------------------------------------------------------------------
3708    // Alias-synthesis integration tests against custom-schema engines.
3709    // The default schema pins `alias_target_rel_type: REFERENCES`; these
3710    // tests verify the engine doesn't hardcode that name by mounting
3711    // schemas with a non-REFERENCES pointer (proves name-agnosticism)
3712    // and schemas with no pointer at all (proves the strict
3713    // `WIKILINK_WITHOUT_RELATION` refusal still fires for opt-out
3714    // schemas). Both build the engine via `from_mounts_with_schemas_dir`
3715    // — the production path for workspace-authored schemas.
3716    // ---------------------------------------------------------------------
3717
3718    mod alias_synthesis_custom_schema {
3719        use std::path::Path;
3720
3721        use indexmap::IndexMap;
3722        use memstead_schema::SchemaRef;
3723        use tempfile::TempDir;
3724
3725        use crate::backend::MemBackend;
3726        use crate::engine::test_helpers::*;
3727        use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
3728        use crate::storage::FilesystemMemWriter;
3729        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
3730
3731        const TYPE_BODY: &str = r#"description: t
3732when_to_use: tests
3733sections:
3734  - key: body
3735    heading: Body
3736    required: true
3737    search_weight: 10.0
3738    catch_all: true
3739    write_rules: []
3740metadata_fields: []
3741title_weight: 100.0
3742text_fields:
3743  - body
3744hierarchy_relationship: _default
3745propagating_relationships: []
3746updatable_fields:
3747  - title
3748  - body
3749health_required_fields:
3750  - body
3751staleness_threshold_days: 90
3752write_rules: []
3753"#;
3754
3755        fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
3756            let dir = root.join(name);
3757            std::fs::create_dir_all(dir.join("types")).unwrap();
3758            std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
3759            for (type_name, body) in types {
3760                std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
3761            }
3762        }
3763
3764        fn make_type_yaml(name: &str) -> String {
3765            format!("name: {name}\n{TYPE_BODY}")
3766        }
3767
3768        fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
3769            Mount {
3770                mem: mem.to_string(),
3771                schema: Some(pin),
3772                storage: MountStorage::Folder { path },
3773                capability: MountCapability::Write,
3774                lifecycle: MountLifecycle::Eager,
3775                cross_linkable: true,
3776                migration_target: None,
3777            }
3778        }
3779
3780        fn engine_with_schema(
3781            manifest: &str,
3782            type_yaml_name: &str,
3783            schema_name: &str,
3784            schema_version: semver::Version,
3785        ) -> (Engine, TempDir) {
3786            let tmp = TempDir::new().unwrap();
3787            let schemas_dir = tmp.path().join("schemas");
3788            std::fs::create_dir_all(&schemas_dir).unwrap();
3789            write_schema_files(
3790                &schemas_dir,
3791                schema_name,
3792                manifest,
3793                &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
3794            );
3795            let mem_dir = tmp.path().join("mem");
3796            std::fs::create_dir_all(&mem_dir).unwrap();
3797            let writer = FilesystemMemWriter::new(mem_dir.clone());
3798            let pin = SchemaRef::new(schema_name, schema_version);
3799            let mount = folder_mount_with_pin("v", mem_dir, pin);
3800            let mut engine = Engine::from_mounts_with_schemas_dir(
3801                vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3802                Some(&schemas_dir),
3803            )
3804            .expect("engine with custom schema constructs");
3805            engine.set_workspace_root(tmp.path().to_path_buf());
3806            (engine, tmp)
3807        }
3808
3809        #[test]
3810        fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
3811            // Schema names CITES as the alias pointer — the engine
3812            // must emit CITES (not REFERENCES) from a body wiki-link.
3813            // Proves no hard-coded "REFERENCES" string anywhere in
3814            // the synthesis path.
3815            let manifest = r#"name: aliased
3816version: 0.1.0
3817description: alias-synthesis fixture using a non-REFERENCES pointer
3818when_to_use: tests prove the engine does not hard-code REFERENCES
3819types:
3820  - doc
3821relationships:
3822  mode: strict
3823  definitions:
3824    - name: CITES
3825      description: Citation — auto-emitted from body wiki-links
3826      default_weight: 0.5
3827    - name: PART_OF
3828      description: Hierarchy
3829      default_weight: 3.0
3830      acyclic: true
3831    - name: _default
3832      description: Fallback
3833      default_weight: 1.0
3834alias_target_rel_type: CITES
3835community:
3836  resolution: 1.0
3837  seed: 42
3838"#;
3839            let (mut engine, _tmp) =
3840                engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
3841            let (actor, client) = cli_actor();
3842
3843            let target = engine
3844                .create_entity(
3845                    CreateEntityArgs {
3846                        mem: "v".to_string(),
3847                        title: "Target".to_string(),
3848                        entity_type: "doc".to_string(),
3849                        sections: IndexMap::from_iter([(
3850                            "body".to_string(),
3851                            "target body".to_string(),
3852                        )]),
3853                        metadata: IndexMap::new(),
3854                        relations: Vec::new(),
3855                        dry_run: false,
3856                    },
3857                    actor,
3858                    Some(&client),
3859                    None,
3860                )
3861                .unwrap();
3862
3863            let mut sections: IndexMap<String, String> = IndexMap::new();
3864            sections.insert("body".to_string(), "see [[target]]".to_string());
3865            let source = engine
3866                .create_entity(
3867                    CreateEntityArgs {
3868                        mem: "v".to_string(),
3869                        title: "Source".to_string(),
3870                        entity_type: "doc".to_string(),
3871                        sections,
3872                        metadata: IndexMap::new(),
3873                        relations: Vec::new(),
3874                        dry_run: false,
3875                    },
3876                    actor,
3877                    Some(&client),
3878                    None,
3879                )
3880                .expect("create must succeed; CITES is auto-emitted by synthesis");
3881
3882            let in_mem = engine.get_entity(&source.id).unwrap();
3883            assert!(
3884                in_mem
3885                    .relationships
3886                    .iter()
3887                    .any(|r| r.rel_type == "CITES" && r.target == target.id),
3888                "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
3889                in_mem.relationships,
3890            );
3891            assert!(
3892                !in_mem
3893                    .relationships
3894                    .iter()
3895                    .any(|r| r.rel_type == "REFERENCES"),
3896                "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
3897                in_mem.relationships,
3898            );
3899        }
3900
3901        #[test]
3902        fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
3903            // Schema declares no `alias_target_rel_type`. Body wiki-link
3904            // without a backing relation must refuse with
3905            // `WIKILINK_WITHOUT_RELATION` — the strict validator's
3906            // pre-Option-C semantics, preserved for opt-out schemas.
3907            let manifest = r#"name: no-alias
3908version: 0.1.0
3909description: schema without alias_target_rel_type pointer
3910when_to_use: tests prove strict validator still fires for opt-out schemas
3911types:
3912  - doc
3913relationships:
3914  mode: strict
3915  definitions:
3916    - name: USES
3917      description: Use
3918      default_weight: 1.0
3919    - name: PART_OF
3920      description: Hierarchy
3921      default_weight: 3.0
3922      acyclic: true
3923    - name: _default
3924      description: Fallback
3925      default_weight: 1.0
3926community:
3927  resolution: 1.0
3928  seed: 42
3929"#;
3930            let (mut engine, _tmp) =
3931                engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
3932            let (actor, client) = cli_actor();
3933
3934            let target = engine
3935                .create_entity(
3936                    CreateEntityArgs {
3937                        mem: "v".to_string(),
3938                        title: "Target".to_string(),
3939                        entity_type: "doc".to_string(),
3940                        sections: IndexMap::from_iter([(
3941                            "body".to_string(),
3942                            "target body".to_string(),
3943                        )]),
3944                        metadata: IndexMap::new(),
3945                        relations: Vec::new(),
3946                        dry_run: false,
3947                    },
3948                    actor,
3949                    Some(&client),
3950                    None,
3951                )
3952                .unwrap();
3953            let source = engine
3954                .create_entity(
3955                    CreateEntityArgs {
3956                        mem: "v".to_string(),
3957                        title: "Source".to_string(),
3958                        entity_type: "doc".to_string(),
3959                        sections: IndexMap::from_iter([(
3960                            "body".to_string(),
3961                            "source body".to_string(),
3962                        )]),
3963                        metadata: IndexMap::new(),
3964                        relations: Vec::new(),
3965                        dry_run: false,
3966                    },
3967                    actor,
3968                    Some(&client),
3969                    None,
3970                )
3971                .unwrap();
3972
3973            // Add a body wiki-link with no backing relation. The
3974            // synthesis pass is a no-op (no pointer), so the
3975            // validator surfaces `WIKILINK_WITHOUT_RELATION`.
3976            let mut sections: IndexMap<String, String> = IndexMap::new();
3977            sections.insert("body".to_string(), "see [[target]]".to_string());
3978            let err = engine
3979                .update_entity(
3980                    UpdateEntityArgs {
3981                        id: source.id.clone(),
3982                        expected_hash: Some(source.content_hash.clone()),
3983                        sections,
3984                        append_sections: IndexMap::new(),
3985                        patch_sections: IndexMap::new(),
3986                        metadata: IndexMap::new(),
3987                        metadata_unset: Vec::new(),
3988                        declare_relations: Vec::new(),
3989                        dry_run: false,
3990                        relations_unset: Vec::new(),
3991                    },
3992                    actor,
3993                    Some(&client),
3994                    None,
3995                )
3996                .unwrap_err();
3997            match err {
3998                EngineError::WikiLinkWithoutRelation { from_id, missing } => {
3999                    assert_eq!(from_id, source.id.to_string());
4000                    assert_eq!(missing.len(), 1);
4001                    assert_eq!(missing[0].section_key, "body");
4002                    assert_eq!(missing[0].target_id, target.id.to_string());
4003                }
4004                other => panic!(
4005                    "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
4006                ),
4007            }
4008        }
4009
4010        /// Restoring the
4011        /// pre-alias-synthesis invariant that every body wiki-link
4012        /// target carries a grammar-valid `EntityId`. Natural-form
4013        /// `[[Knowledge Graph]]` no longer slips through into a
4014        /// malformed auto-stub; the engine refuses with the typed
4015        /// `InvalidWikiLinkTarget` envelope and the
4016        /// `title_to_slug`-derived suggestion the agent lifts
4017        /// directly into a retry. Covers F1 of the 2026-05-18 CLI probe.
4018        #[test]
4019        fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
4020            let manifest = r#"name: aliased
4021version: 0.1.0
4022description: alias-synthesis fixture
4023when_to_use: tests prove strict wiki-link grammar at mutation entry
4024types:
4025  - doc
4026relationships:
4027  mode: strict
4028  definitions:
4029    - name: REFERENCES
4030      description: Reference — auto-emitted from body wiki-links
4031      default_weight: 0.5
4032    - name: PART_OF
4033      description: Hierarchy
4034      default_weight: 3.0
4035      acyclic: true
4036    - name: _default
4037      description: Fallback
4038      default_weight: 1.0
4039alias_target_rel_type: REFERENCES
4040community:
4041  resolution: 1.0
4042  seed: 42
4043"#;
4044            let (mut engine, _tmp) =
4045                engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4046            let (actor, client) = cli_actor();
4047
4048            let mut sections: IndexMap<String, String> = IndexMap::new();
4049            sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
4050            let err = engine
4051                .create_entity(
4052                    CreateEntityArgs {
4053                        mem: "v".to_string(),
4054                        title: "Source".to_string(),
4055                        entity_type: "doc".to_string(),
4056                        sections,
4057                        metadata: IndexMap::new(),
4058                        relations: Vec::new(),
4059                        dry_run: false,
4060                    },
4061                    actor,
4062                    Some(&client),
4063                    None,
4064                )
4065                .unwrap_err();
4066            match err {
4067                EngineError::InvalidWikiLinkTarget {
4068                    raw,
4069                    suggested,
4070                    section,
4071                    link_source,
4072                    ..
4073                } => {
4074                    assert_eq!(raw, "Knowledge Graph");
4075                    assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
4076                    assert_eq!(section, "body");
4077                    assert_eq!(link_source, "body_link");
4078                }
4079                other => panic!(
4080                    "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
4081                ),
4082            }
4083        }
4084
4085        /// Tier-2 body wiki-link with a non-conformant
4086        /// mem prefix refuses with the distinct `InvalidMemName`
4087        /// (wire code `INVALID_MEM_NAME`) — mems are fixed
4088        /// identifiers, not free-form text the agent can slugify, so
4089        /// the recovery path is different from `InvalidWikiLinkTarget`.
4090        #[test]
4091        fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
4092            let manifest = r#"name: aliased
4093version: 0.1.0
4094description: alias-synthesis fixture
4095when_to_use: tests prove strict mem-prefix grammar at mutation entry
4096types:
4097  - doc
4098relationships:
4099  mode: strict
4100  definitions:
4101    - name: REFERENCES
4102      description: Reference
4103      default_weight: 0.5
4104    - name: PART_OF
4105      description: Hierarchy
4106      default_weight: 3.0
4107      acyclic: true
4108    - name: _default
4109      description: Fallback
4110      default_weight: 1.0
4111alias_target_rel_type: REFERENCES
4112community:
4113  resolution: 1.0
4114  seed: 42
4115"#;
4116            let (mut engine, _tmp) =
4117                engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4118            let (actor, client) = cli_actor();
4119
4120            let mut sections: IndexMap<String, String> = IndexMap::new();
4121            sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
4122            let err = engine
4123                .create_entity(
4124                    CreateEntityArgs {
4125                        mem: "v".to_string(),
4126                        title: "Source".to_string(),
4127                        entity_type: "doc".to_string(),
4128                        sections,
4129                        metadata: IndexMap::new(),
4130                        relations: Vec::new(),
4131                        dry_run: false,
4132                    },
4133                    actor,
4134                    Some(&client),
4135                    None,
4136                )
4137                .unwrap_err();
4138            match err {
4139                EngineError::InvalidWikiLinkMem { raw, section, .. } => {
4140                    assert_eq!(raw, "Other Mem");
4141                    assert_eq!(section, "body");
4142                }
4143                other => panic!(
4144                    "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
4145                ),
4146            }
4147        }
4148
4149        /// Body wiki-link
4150        /// containing the ambiguous `[[<segments>/<segments>--<slug>]]`
4151        /// form refuses with `InvalidWikiLinkTarget` carrying the
4152        /// colon-form (`<prefix>:<slug>`) as `suggested`. Pre-fix the
4153        /// dash form silently produced a same-mem phantom stub at
4154        /// slug `team/sub-mem--target`, losing the agent's intent.
4155        #[test]
4156        fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
4157            let manifest = r#"name: aliased
4158version: 0.1.0
4159description: alias-synthesis fixture
4160when_to_use: tests prove hierarchical dash-form refusal at mutation entry
4161types:
4162  - doc
4163relationships:
4164  mode: strict
4165  definitions:
4166    - name: REFERENCES
4167      description: Reference — auto-emitted from body wiki-links
4168      default_weight: 0.5
4169    - name: PART_OF
4170      description: Hierarchy
4171      default_weight: 3.0
4172      acyclic: true
4173    - name: _default
4174      description: Fallback
4175      default_weight: 1.0
4176alias_target_rel_type: REFERENCES
4177community:
4178  resolution: 1.0
4179  seed: 42
4180"#;
4181            let (mut engine, _tmp) =
4182                engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4183            let (actor, client) = cli_actor();
4184
4185            let mut sections: IndexMap<String, String> = IndexMap::new();
4186            sections.insert(
4187                "body".to_string(),
4188                "see [[team/sub-mem--target]]".to_string(),
4189            );
4190            let err = engine
4191                .create_entity(
4192                    CreateEntityArgs {
4193                        mem: "v".to_string(),
4194                        title: "Source".to_string(),
4195                        entity_type: "doc".to_string(),
4196                        sections,
4197                        metadata: IndexMap::new(),
4198                        relations: Vec::new(),
4199                        dry_run: false,
4200                    },
4201                    actor,
4202                    Some(&client),
4203                    None,
4204                )
4205                .unwrap_err();
4206            match err {
4207                EngineError::InvalidWikiLinkTarget {
4208                    raw,
4209                    suggested,
4210                    section,
4211                    link_source,
4212                    ..
4213                } => {
4214                    assert_eq!(raw, "team/sub-mem--target");
4215                    assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
4216                    assert_eq!(section, "body");
4217                    assert_eq!(link_source, "body_link");
4218                }
4219                other => panic!(
4220                    "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
4221                ),
4222            }
4223
4224            // The entity did not land — no phantom stub for the source,
4225            // no phantom stub for the would-be target.
4226            let listed = engine.store().all_entities().collect::<Vec<_>>();
4227            assert!(
4228                listed.is_empty(),
4229                "refused create must not leave any entity behind, got: {listed:?}"
4230            );
4231        }
4232    }
4233
4234    // ---- relations_unset repair-power gating -------------------------
4235
4236    /// Markdown for a deliberately non-conformant `spec`: carries an
4237    /// undeclared metadata field (`zzz_bogus_field`) plus one USES
4238    /// relation. Written straight to disk before engine construction —
4239    /// the write path refuses non-conformant entities, so out-of-band
4240    /// state is the only way to seed one (which is exactly the
4241    /// repair-power scenario: drift entered outside the engine).
4242    const DRIFTED_MD: &str = "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\nzzz_bogus_field: x\n---\n# Drifted\n\n## Identity\n\nNon-conformant fixture.\n\n## Purpose\n\nRepair-gate tests.\n\n## Relationships\n\n- **USES**: [[anchor]]\n";
4243
4244    fn repair_engine() -> (TempDir, Engine) {
4245        let tmp = TempDir::new().unwrap();
4246        let mem_dir = tmp.path().to_path_buf();
4247        std::fs::write(
4248            mem_dir.join("anchor.md"),
4249            "---\ntype: spec\ncreated_date: 2026-01-15\nlast_modified: 2026-04-12\n---\n# Anchor\n\n## Identity\n\nTarget.\n\n## Purpose\n\nRelation target.\n",
4250        )
4251        .unwrap();
4252        std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
4253        let writer = FilesystemMemWriter::new(mem_dir.clone());
4254        let engine = Engine::from_mounts(vec![(
4255            folder_mount("specs", mem_dir),
4256            Box::new(writer) as Box<dyn MemBackend>,
4257        )])
4258        .unwrap();
4259        (tmp, engine)
4260    }
4261
4262    fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
4263        UpdateEntityArgs {
4264            id,
4265            expected_hash: hash,
4266            sections: IndexMap::new(),
4267            append_sections: IndexMap::new(),
4268            patch_sections: IndexMap::new(),
4269            metadata: IndexMap::new(),
4270            metadata_unset: Vec::new(),
4271            declare_relations: Vec::new(),
4272            dry_run: false,
4273            relations_unset: vec![crate::ops::RelationUnsetArg {
4274                rel_type: "USES".to_string(),
4275                target: EntityId::new("specs", "anchor"),
4276            }],
4277        }
4278    }
4279
4280    /// A conformant entity refuses repair-shaped input with
4281    /// `REPAIR_NOT_NEEDED` and is not modified — even though it has a
4282    /// relation that `relations_unset` names. The recovery text points
4283    /// at the focused detach path.
4284    #[test]
4285    fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
4286        let (_tmp, mut engine) = repair_engine();
4287        // `anchor` is conformant. Give it a relation first via the
4288        // ordinary relate path so there is something to (not) remove.
4289        let anchor = EntityId::new("specs", "anchor");
4290        let drifted = EntityId::new("specs", "drifted");
4291        engine
4292            .relate_entity(
4293                RelateEntityArgs {
4294                    source: anchor.clone(),
4295                    expected_hash: None,
4296                    rel_type: "USES".to_string(),
4297                    target: drifted.clone(),
4298                    remove: false,
4299                    description: None,
4300                },
4301                Actor::Cli,
4302                None,
4303                None,
4304            )
4305            .expect("relate on conformant entity works");
4306        let mut args = repair_args(anchor.clone(), None);
4307        args.relations_unset[0].target = drifted.clone();
4308        let err = engine
4309            .update_entity(args, Actor::Cli, None, None)
4310            .unwrap_err();
4311        match err {
4312            EngineError::RepairNotNeeded { id, recovery } => {
4313                assert_eq!(id, anchor.to_string());
4314                assert!(
4315                    recovery.contains("memstead_relate"),
4316                    "recovery must point at the focused tool; got {recovery}"
4317                );
4318            }
4319            other => panic!("expected RepairNotNeeded, got {other:?}"),
4320        }
4321        // Entity unmodified — the relation is still there.
4322        let entity = engine.store().get(&anchor).unwrap();
4323        assert!(
4324            entity.relationships.iter().any(|r| r.target == drifted),
4325            "gate must not modify the entity"
4326        );
4327    }
4328
4329    /// A non-conformant entity accepts `relations_unset`: the named
4330    /// relation is removed atomically within the same update that also
4331    /// repairs the conformance break (`metadata_unset` on the
4332    /// undeclared field). The post-write entity is integral.
4333    #[test]
4334    fn relations_unset_repairs_non_conformant_entity_atomically() {
4335        let (_tmp, mut engine) = repair_engine();
4336        let drifted = EntityId::new("specs", "drifted");
4337        // Pre-state really is non-conformant.
4338        let pre = engine.conformance_findings("specs", None).unwrap();
4339        assert!(
4340            pre.iter().any(|f| f.id == drifted.to_string()),
4341            "fixture must lint non-conformant; got {pre:?}"
4342        );
4343        let mut args = repair_args(drifted.clone(), None);
4344        args.metadata_unset = vec!["zzz_bogus_field".to_string()];
4345        engine
4346            .update_entity(args, Actor::Cli, None, None)
4347            .expect("repair update lands");
4348        let entity = engine.store().get(&drifted).unwrap();
4349        assert!(
4350            entity.relationships.is_empty(),
4351            "relation must be removed; got {:?}",
4352            entity.relationships
4353        );
4354        assert!(
4355            !entity.metadata.contains_key("zzz_bogus_field"),
4356            "conformance break must be repaired in the same update"
4357        );
4358        let post = engine.conformance_findings("specs", None).unwrap();
4359        assert!(
4360            post.iter().all(|f| f.id != drifted.to_string()),
4361            "post-repair entity must be conformant; got {post:?}"
4362        );
4363    }
4364
4365    /// Repair widens accepted inputs, never admissible outputs: a
4366    /// repair write whose post-state would violate the schema refuses
4367    /// with the relevant write-time code and nothing lands.
4368    #[test]
4369    fn relations_unset_post_state_must_still_validate() {
4370        let (_tmp, mut engine) = repair_engine();
4371        let drifted = EntityId::new("specs", "drifted");
4372        let mut args = repair_args(drifted.clone(), None);
4373        // Post-state violation: an unknown section alongside the
4374        // repair input.
4375        args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
4376        let err = engine
4377            .update_entity(args, Actor::Cli, None, None)
4378            .unwrap_err();
4379        assert_eq!(
4380            err.code(),
4381            "UNKNOWN_SECTION",
4382            "strict-write post-condition must hold during repair; got {err:?}"
4383        );
4384        // Nothing landed: the relation survives.
4385        let entity = engine.store().get(&drifted).unwrap();
4386        assert!(
4387            !entity.relationships.is_empty(),
4388            "refused repair must not partially apply"
4389        );
4390    }
4391
4392    /// Absent `(rel_type, target)` pairs are silent no-ops — symmetric
4393    /// with `metadata_unset` — so a repair retry is idempotent.
4394    #[test]
4395    fn relations_unset_absent_pair_is_silent_noop() {
4396        let (_tmp, mut engine) = repair_engine();
4397        let drifted = EntityId::new("specs", "drifted");
4398        let mut args = repair_args(drifted.clone(), None);
4399        args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
4400        // Also repair the field so the post-state is integral.
4401        args.metadata_unset = vec!["zzz_bogus_field".to_string()];
4402        engine
4403            .update_entity(args, Actor::Cli, None, None)
4404            .expect("absent pair no-ops, update lands");
4405        let entity = engine.store().get(&drifted).unwrap();
4406        assert_eq!(
4407            entity.relationships.len(),
4408            1,
4409            "the USES relation must survive an unmatched unset"
4410        );
4411    }
4412}