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