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_unsettable_metadata_key, 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, 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 merge into this entity's sidecar row — staged
61    /// into the same commit as the disk write on the commit step. Empty
62    /// when the update carried no `anchors[]`.
63    anchors: Vec<crate::anchor::Anchor>,
64    /// Validated explicit anchor removals, applied before the `anchors`
65    /// merge in the same staged write. Empty when the update carried no
66    /// `anchors_unset[]`.
67    anchor_unsets: Vec<crate::anchor::AnchorUnset>,
68    /// True when this update's *sole* delta is the anchors sidecar —
69    /// sections, metadata, and relationships are byte-identical to the
70    /// on-disk entity. Such a commit earns the distinct
71    /// `memstead: anchor <id>` subject (parsed `tool_verb == "anchor"`)
72    /// so an anchor-only refresh — which the `_hash` excludes and which
73    /// therefore produces zero entity deltas — is still observable to an
74    /// `--include-notes` reader. A content-changing update (even one that
75    /// also carries anchors) keeps the `memstead: update <id>` subject:
76    /// its content change already bumps `_hash` and surfaces as a delta,
77    /// so the anchor activity riding it is already visible.
78    anchor_only: bool,
79}
80
81/// The store-side results of applying a prepared write — filled in
82/// after the commit lands by [`Engine::apply_prepared_to_store`].
83struct AppliedWrite {
84    content_hash: String,
85    title: String,
86    orphan_stubs_removed: Vec<EntityId>,
87}
88
89impl Engine {
90    /// Update an entity's sections and/or metadata.
91    ///
92    /// Same six-concern shape as [`Engine::create_entity`]. Optimistic
93    /// locking via `args.expected_hash`: when `Some`, must match the
94    /// store's current `content_hash` or returns
95    /// [`EngineError::HashMismatch`]. The new engine's MCP-facing
96    /// callers should always pass the hash; `None` is the
97    /// `--force`-style escape hatch.
98    ///
99    /// Internally a two-step pipeline: [`Self::prepare_update`] runs
100    /// all validation and computes the post-mutation markdown without
101    /// committing, then [`Self::commit_prepared_update`] stages and
102    /// commits the result. The split lets [`Self::batch_update`]
103    /// prepare every item up front and commit the whole batch as one
104    /// atomic unit.
105    pub fn update_entity(
106        &mut self,
107        args: UpdateEntityArgs,
108        actor: Actor,
109        client: Option<&ClientId>,
110        note: Option<&str>,
111    ) -> Result<UpdateEntityOutcome, EngineError> {
112        // Reload-before-operation: probe the mem ref and reload if a
113        // sibling advanced it, so the `expected_hash` compare inside
114        // `prepare_update` runs against current truth. A stale hash for
115        // the targeted entity then trips a real `HASH_MISMATCH`; an
116        // unrelated concurrent write leaves this entity's hash intact
117        // and the update proceeds. The drift notice rides the outcome.
118        let mut drift_warnings = self.reload_if_stale(Some(args.id.mem()));
119        let mut outcome = match self.prepare_update(args)? {
120            PrepareOutcome::Done(outcome) => outcome,
121            PrepareOutcome::Prepared(prepared) => {
122                self.commit_prepared_update(prepared, actor, client, note)?
123            }
124        };
125        drift_warnings.append(&mut outcome.warnings);
126        outcome.warnings = drift_warnings;
127        Ok(outcome)
128    }
129
130    /// Stage the prepared disk write, commit it as one commit, append
131    /// provenance, and apply the change to the in-memory store — the
132    /// single-update tail of [`Self::update_entity`]. The batch path
133    /// drives the same steps but commits once across all items.
134    fn commit_prepared_update(
135        &mut self,
136        prepared: PreparedUpdate,
137        actor: Actor,
138        client: Option<&ClientId>,
139        note: Option<&str>,
140    ) -> Result<UpdateEntityOutcome, EngineError> {
141        let backend = self.mounts[prepared.mount_idx].backend.as_ref();
142        backend.write_entity(Path::new(&prepared.file_path), prepared.markdown.as_bytes())?;
143        // Stage the anchors sidecar into the same commit as the entity
144        // write. Only when the update carried anchors or anchor unsets.
145        if !prepared.anchors.is_empty() || !prepared.anchor_unsets.is_empty() {
146            super::stage_anchors_sidecar(
147                backend,
148                &prepared.id,
149                &prepared.anchor_unsets,
150                prepared.anchors.clone(),
151            )?;
152        }
153        // Derivation baselines (agent-trust plan 12): declared
154        // relations on a derivation rel-type record the target's
155        // current hash, riding the same commit as the entity write.
156        if let Some(schema) = self.schemas.get(prepared.id.mem()) {
157            for r in prepared
158                .relations_declared
159                .iter()
160                .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
161            {
162                let hash = self
163                    .store
164                    .get(&r.target)
165                    .map(|e| e.content_hash.clone())
166                    .unwrap_or_default();
167                let (from, rel, to) = (
168                    prepared.id.to_string(),
169                    r.rel_type.clone(),
170                    r.target.to_string(),
171                );
172                super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
173            }
174        }
175        // Anchor-only commits carry the distinct `anchor` verb so their
176        // otherwise-invisible sidecar change is legible in the note log;
177        // every other update keeps `update`. The verb is a subject-only
178        // signal — delta computation reads the tree diff, not the
179        // subject, so the zero-entity-delta guarantee is untouched.
180        let commit_subject = if prepared.anchor_only {
181            format!("memstead: anchor {}", prepared.id)
182        } else {
183            format!("memstead: update {}", prepared.id)
184        };
185        let ctx = CommitContext {
186            actor,
187            client: client.cloned(),
188            tool: Some("update_entity"),
189            note: note.map(String::from),
190            role: self.current_role,
191            logical_operation_id: None,
192            entity_ids: None,
193        };
194        let commit_sha = backend.commit(&commit_subject, &ctx)?;
195        backend.append_provenance(
196            &Provenance::new(
197                std::time::SystemTime::now(),
198                ProvenanceKind::Update,
199                Some(prepared.id.to_string()),
200                actor,
201                client.cloned(),
202                note.map(String::from),
203            )
204            .with_role(self.current_role),
205        )?;
206        self.record_self_write(prepared.mount_idx, &commit_sha);
207        self.stamp_mutation_versions(prepared.mount_idx);
208
209        let applied = self.apply_prepared_to_store(&prepared)?;
210
211        self.invalidate_communities();
212        self.invalidate_search_indexes();
213
214        // `require_notes` provenance nudge — single engine-level
215        // enforcement point. Only reached on the real-commit path; the
216        // no-op and dry-run prepare outcomes never demand a note.
217        let mut warnings = prepared.warnings;
218        if let Some(w) = self.note_missing_warning("update_entity", note) {
219            warnings.push(w);
220        }
221
222        Ok(UpdateEntityOutcome {
223            id: prepared.id.clone(),
224            title: applied.title,
225            file_path: prepared.file_path,
226            content_hash: applied.content_hash,
227            commit_sha,
228            modified_date: prepared.modified_date,
229            orphan_stubs_removed: applied.orphan_stubs_removed,
230            modified_sections: prepared.modified_sections,
231            modified_metadata: prepared.modified_metadata,
232            prospective_hash: None,
233            warnings,
234            relations_declared: prepared.relations_declared,
235        })
236    }
237
238    /// Parse the prepared markdown, push it into the in-memory store,
239    /// re-map alias-target edge sources, and GC any stub the mutation
240    /// orphaned. Shared post-commit store-application step for the
241    /// single-update and batch paths — does NOT touch the backend or
242    /// commit (the caller has already staged + committed the disk
243    /// write).
244    fn apply_prepared_to_store(
245        &mut self,
246        prepared: &PreparedUpdate,
247    ) -> Result<AppliedWrite, EngineError> {
248        let parse_result = parse_markdown(
249            &prepared.markdown,
250            &prepared.file_path,
251            prepared.type_def.as_ref(),
252            &prepared.mem,
253        )
254        .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
255        let content_hash = parse_result.entity.content_hash.clone();
256        let title = parse_result.entity.title.clone();
257        let fallback = engine_fallback_type();
258        push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
259        crate::entity::store_builder::remap_alias_target_edge_sources(
260            &mut self.store,
261            &self.schemas,
262        );
263        let orphan_stubs_removed =
264            super::gc_orphan_stubs_among(&mut self.store, &prepared.prev_body_targets);
265        Ok(AppliedWrite {
266            content_hash,
267            title,
268            orphan_stubs_removed,
269        })
270    }
271
272    /// Validate an update and compute its post-mutation markdown
273    /// *without* committing. Returns [`PrepareOutcome::Done`] for the
274    /// no-op / dry-run short-circuits (which never commit) and
275    /// [`PrepareOutcome::Prepared`] for a real change whose write the
276    /// caller stages + commits. May mutate the store in place via the
277    /// alias-synthesis auto-stub upsert; the batch path snapshots the
278    /// store before preparing so a refused batch can roll that back.
279    fn prepare_update(&mut self, args: UpdateEntityArgs) -> Result<PrepareOutcome, EngineError> {
280        let id = &args.id;
281        let mem = id.mem().to_string();
282
283        let mount_idx = self
284            .mounts
285            .iter()
286            .position(|m| m.mount.mem == mem)
287            .ok_or_else(|| self.unknown_mem_error(&mem))?;
288        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
289            return Err(EngineError::ReadOnlyMount(mem));
290        }
291
292        let entity = self
293            .store
294            .get(id)
295            .ok_or_else(|| EngineError::NotFound { id: id.to_string() })?;
296
297        // Snapshot the prev entity's body wiki-link set before any
298        // subsequent `&mut self` reborrow burns the `entity` borrow.
299        // Fed to the alias-synthesis pass so the GC step can compare
300        // prev vs. next body links and drop pointer-rel-type relations
301        // whose target was a body link before but isn't any more.
302        let prev_body_targets = super::collect_body_link_targets(entity);
303
304        // Stub guard — stubs have no body, no metadata, no
305        // schema-resolved type to validate against. The recovery is
306        // `memstead_create` (stub adoption preserves incoming
307        // references). Pre-Item-02 the update path fell through to
308        // the `type_def` lookup below and surfaced the cryptic
309        // `UnknownType { name: "" }` cascade. Mirrors the
310        // `StubCannotRelate` guard on `memstead_relate`.
311        if entity.stub {
312            return Err(EngineError::StubNotUpdatable { id: id.to_string() });
313        }
314
315        // Skip the hash check on dry_run — full's dry_run is the
316        // designated stale-hash recovery path. Agents preview a
317        // change without holding a fresh hash, get back the current
318        // `content_hash` and a `prospective_hash`, then call the
319        // real update with `expected_hash = content_hash`.
320        if !args.dry_run
321            && let Some(expected) = args.expected_hash.as_deref()
322            && entity.content_hash != expected
323        {
324            return Err(EngineError::HashMismatch {
325                id: id.to_string(),
326                current: entity.content_hash.clone(),
327                is_stub: entity.stub,
328            });
329        }
330
331        // Empty-mutation guard. After existence/stub/hash
332        // gates so the more-specific errors fire first. A payload
333        // with no recognised mutation content refuses BEFORE any
334        // mutation work runs so a misspelled or omitted mutation
335        // key (which deserialised to empty defaults under the
336        // lenient pre-fix posture) doesn't silently land as
337        // `succeeded: N, action: "updated", commit_sha: ""`.
338        // Distinct from `UPDATE_NOOP` (a warning that fires when
339        // mutation content was provided but matched the current
340        // entity state) — the two are different states and ship
341        // different envelopes.
342        if args.sections.is_empty()
343            && args.append_sections.is_empty()
344            && args.patch_sections.is_empty()
345            && args.metadata.is_empty()
346            && args.metadata_unset.is_empty()
347            && args.declare_relations.is_empty()
348            && args.relations_unset.is_empty()
349            && args.anchors.is_empty()
350            && args.anchors_unset.is_empty()
351        {
352            return Err(EngineError::EmptyUpdate { id: id.to_string() });
353        }
354
355        // Validate any `anchors[]` / `anchors_unset[]` payload up front —
356        // a malformed element refuses the whole update with a typed
357        // `INVALID_ANCHOR` envelope before any disk write, on every path
358        // (real / dry-run / no-op). Empty payloads → empty vecs (no
359        // sidecar write; byte-identical to a pre-anchor update).
360        let validated_anchors = self.validate_anchor_inputs(&mem, &args.anchors)?;
361        let validated_anchor_unsets = Self::validate_anchor_unsets(&args.anchors_unset)?;
362
363        let schema = self
364            .schemas
365            .get(&mem)
366            .expect("schema present for every registered mount")
367            .clone();
368        let type_def = schema
369            .get_type(&entity.entity_type)
370            .ok_or_else(|| unknown_type_error(schema.as_ref(), &entity.entity_type))?;
371
372        // Mode-conflict: the same section key may not appear in
373        // more than one of `sections`, `append_sections`,
374        // `patch_sections`. Mirrors full's
375        // `EngineError::ConflictingSectionModes`. Three-way check:
376        // build the conflict list per key and reject when ≥2 modes
377        // claim it.
378        for key in args.sections.keys() {
379            let mut modes = vec!["sections".to_string()];
380            if args.append_sections.contains_key(key) {
381                modes.push("append_sections".to_string());
382            }
383            if args.patch_sections.contains_key(key) {
384                modes.push("patch_sections".to_string());
385            }
386            if modes.len() > 1 {
387                return Err(EngineError::ConflictingSectionModes {
388                    section: key.clone(),
389                    modes,
390                });
391            }
392        }
393        for key in args.append_sections.keys() {
394            if args.patch_sections.contains_key(key) {
395                return Err(EngineError::ConflictingSectionModes {
396                    section: key.clone(),
397                    modes: vec!["append_sections".to_string(), "patch_sections".to_string()],
398                });
399            }
400        }
401
402        validate_section_keys(
403            args.sections
404                .keys()
405                .chain(args.append_sections.keys())
406                .chain(args.patch_sections.keys())
407                .map(String::as_str),
408            type_def.as_ref(),
409        )?;
410        // Refuse embedded `^## ` in section content on every update path
411        // that writes section bodies: `sections` (replace) and
412        // `append_sections` (append). `patch_sections` replaces a
413        // substring — its `new` text feeds into the eventual section
414        // body so it gets the same gate.
415        validate_section_content(
416            args.sections
417                .iter()
418                .map(|(k, v)| (k.as_str(), v.as_str()))
419                .chain(
420                    args.append_sections
421                        .iter()
422                        .map(|(k, v)| (k.as_str(), v.as_str())),
423                )
424                .chain(
425                    args.patch_sections
426                        .iter()
427                        .map(|(k, p)| (k.as_str(), p.new.as_str())),
428                ),
429        )?;
430        for key in args.sections.keys() {
431            validate_updatable_section(key.as_str(), type_def.as_ref())?;
432        }
433        for key in args.append_sections.keys() {
434            validate_updatable_section(key.as_str(), type_def.as_ref())?;
435        }
436        for key in args.patch_sections.keys() {
437            validate_updatable_section(key.as_str(), type_def.as_ref())?;
438        }
439        for key in args.metadata.keys() {
440            validate_writable_metadata_key(key.as_str(), type_def.as_ref())?;
441        }
442        // Unset has its own gate: the reserved `mem`/`id`/`type` triple
443        // is unset-ALLOWED (the sanctioned repair for entities that
444        // acquired a smuggled reserved key before the write gates
445        // closed) while engine-stamped timestamp fields stay refused —
446        // see `validate_unsettable_metadata_key`.
447        for key in &args.metadata_unset {
448            validate_unsettable_metadata_key(key.as_str(), type_def.as_ref())?;
449        }
450
451        // Reject the same key appearing in `metadata` (set) and
452        // `metadata_unset` — the wire contract is that the conflict is
453        // a hard error. Caught before any required-field /
454        // parse-metadata check so the resolution ("pick one map") is
455        // unambiguous regardless of whether the overlapping key is
456        // required.
457        let mut overlap: Vec<String> = args
458            .metadata
459            .keys()
460            .filter(|k| args.metadata_unset.iter().any(|u| u == k.as_str()))
461            .cloned()
462            .collect();
463        if !overlap.is_empty() {
464            overlap.sort();
465            overlap.dedup();
466            return Err(EngineError::SetAndUnsetConflict { keys: overlap });
467        }
468
469        // Repair-power gate:
470        // repair-shaped input is accepted only when the entity
471        // currently fails the conformance check against the effective
472        // schema. Conformance is per-entity-local and cheap, so the
473        // gate runs in pre-validation; no agent-settable flag exists —
474        // the entity's own state is the evidence. A pure-consistency
475        // break does not open the gate (those have ungated repair
476        // paths: `memstead_relate(remove)` and the additive params).
477        if !args.relations_unset.is_empty() {
478            let findings = crate::ops::integrity::entity_conformance_findings(
479                &self.store,
480                entity,
481                schema.as_ref(),
482                &self.schemas,
483            );
484            if findings.is_empty() {
485                return Err(EngineError::RepairNotNeeded {
486                    id: id.to_string(),
487                    recovery: "use memstead_relate(remove=true) to detach an edge from a                                conformant entity, or the additive memstead_update params                                to evolve it"
488                        .to_string(),
489                });
490            }
491        }
492
493        let mut next = entity.clone();
494
495        // Repair-shaped removals, applied before declarations so a
496        // repair can drop and re-shape relations in one atomic
497        // update. Absent (rel_type, target) pairs are silent no-ops,
498        // symmetric with `metadata_unset`. The strict post-state
499        // validation below still runs — repair widens accepted
500        // inputs, never admissible outputs.
501        for unset in &args.relations_unset {
502            let canonical = crate::entity::id::validate_rel_type(&unset.rel_type)
503                .unwrap_or_else(|_| unset.rel_type.clone());
504            next.relationships
505                .retain(|r| !(r.rel_type == canonical && r.target == unset.target));
506        }
507
508        // Atomic batched relation declarations. Validated and applied
509        // before the section/metadata changes so the strict
510        // wiki-link/relation validator at the end of this fn sees
511        // the freshly-declared relations as part of the post-state.
512        // Same vocabulary + shape + grammar gates `memstead_relate`
513        // runs; auto-stubs absent Write-mem targets identically
514        // to the relate path. Returns the (rel_type, target,
515        // target_was_stubbed) triples in `relations_declared` on
516        // the outcome so the agent sees what landed.
517        let relations_declared = apply_declare_relations(
518            self,
519            &mut next,
520            &args.declare_relations,
521            &mem,
522            mount_idx,
523            type_def.as_ref(),
524            schema.as_ref(),
525        )?;
526
527        // Keys this update touches, captured before the args maps are
528        // consumed — the section-format evaluation below judges
529        // exactly these on their composed bodies.
530        let format_touched: std::collections::HashSet<String> = args
531            .sections
532            .keys()
533            .chain(args.append_sections.keys())
534            .chain(args.patch_sections.keys())
535            .cloned()
536            .collect();
537
538        let mut modified_sections: Vec<String> = Vec::new();
539        for (key, body) in args.sections {
540            modified_sections.push(key.clone());
541            next.sections.insert(key, body);
542        }
543
544        // Apply append_sections after replace. Empty/absent body
545        // is replaced wholesale with the append value; otherwise
546        // a `\n` separator joins the two. Mirrors full.
547        let mut modified_sections_appended: Vec<String> = Vec::new();
548        for (key, value) in args.append_sections {
549            let existing = next.sections.get(&key).cloned().unwrap_or_default();
550            let new_content = if existing.trim().is_empty() {
551                value
552            } else {
553                format!("{existing}\n{value}")
554            };
555            next.sections.insert(key.clone(), new_content);
556            modified_sections_appended.push(key);
557        }
558
559        // Apply patch_sections after append. Find-and-replace the
560        // `old` substring with `new`; `all` flips between
561        // first-occurrence (replacen 1) and every-occurrence
562        // (replace). Empty/absent section is rejected with
563        // PatchSectionEmpty; missing-`old` rejected with
564        // PatchOldNotFound carrying a UTF-8-safe truncated snapshot
565        // of the current body. Mirrors full.
566        let mut modified_sections_patched: Vec<String> = Vec::new();
567        for (key, patch) in args.patch_sections {
568            let existing = next
569                .sections
570                .get(&key)
571                .ok_or_else(|| EngineError::PatchSectionEmpty {
572                    section: key.clone(),
573                })?
574                .clone();
575            if !existing.contains(&patch.old) {
576                let cap = PATCH_OLD_NOT_FOUND_CONTENT_CAP;
577                let truncated = existing.len() > cap;
578                // Truncate at a UTF-8 char boundary to avoid
579                // splitting a code point.
580                let mut cut = cap.min(existing.len());
581                while cut > 0 && !existing.is_char_boundary(cut) {
582                    cut -= 1;
583                }
584                let current_content = if truncated {
585                    existing[..cut].to_string()
586                } else {
587                    existing.clone()
588                };
589                return Err(EngineError::PatchOldNotFound {
590                    section: key,
591                    current_content,
592                    truncated,
593                });
594            }
595            let patched = if patch.all {
596                existing.replace(&patch.old, &patch.new)
597            } else {
598                existing.replacen(&patch.old, &patch.new, 1)
599            };
600            next.sections.insert(key.clone(), patched);
601            modified_sections_patched.push(key);
602        }
603
604        let mut modified_metadata_set: Vec<String> = Vec::new();
605        for (key, value) in &args.metadata {
606            let parsed = parse_metadata_value(key.as_str(), value.as_str(), type_def.as_ref())?;
607            modified_metadata_set.push(key.clone());
608            next.metadata.insert(key.clone(), parsed);
609        }
610
611        let mut modified_metadata_unset: Vec<String> = Vec::new();
612        for key in args.metadata_unset {
613            // Reserved identity/discriminator keys bypass the
614            // required-field gate below: unsetting one is the
615            // sanctioned repair for a historically smuggled key, and
616            // it can only move the entity toward the invariant. For
617            // `type` the engine immediately re-seeds the authoritative
618            // discriminator from the entity's own type — the
619            // frontmatter can never go typeless (a missing `type:`
620            // would silently re-type the entity to the mem's default
621            // on the next parse), so on a healthy entity the unset is
622            // a no-op. `mem`/`id` are never engine-seeded in the map;
623            // removing a smuggled one is a real (recorded) removal.
624            if crate::runtime_validator::READ_ONLY_METADATA_KEYS.contains(&key.as_str()) {
625                if key == "type" {
626                    let authoritative =
627                        crate::entity::MetadataValue::String(next.entity_type.clone());
628                    if next
629                        .metadata
630                        .shift_remove("type")
631                        .is_some_and(|removed| removed != authoritative)
632                    {
633                        modified_metadata_unset.push(key);
634                    }
635                    next.metadata.insert("type".to_string(), authoritative);
636                } else if next.metadata.shift_remove(&key).is_some() {
637                    modified_metadata_unset.push(key);
638                }
639                continue;
640            }
641            // Reject unset on required fields — the pre-remove check
642            // carries the recovery payload (field_description,
643            // enum_values, type_write_rules) so MCP envelopes surface
644            // the full REQUIRED_FIELD_UNSET shape.
645            let field_def = type_def.metadata_field(&key);
646            let is_required = field_def.map(|f| f.is_required()).unwrap_or(false);
647            if is_required {
648                let (field_description, enum_values) = match field_def {
649                    Some(f) => (
650                        Some(f.description.clone()),
651                        f.enum_values.clone().unwrap_or_default(),
652                    ),
653                    None => (None, Vec::new()),
654                };
655                return Err(EngineError::RequiredFieldUnset {
656                    field: key,
657                    entity_type: type_def.name.clone(),
658                    field_description,
659                    enum_values,
660                    type_write_rules: type_def.write_rules.clone(),
661                    // Update path — caller passed
662                    // `metadata_unset: ["field"]` against a required
663                    // field. The wording ("cannot unset required
664                    // field …") is semantically correct for this
665                    // path.
666                    on_create: false,
667                    // The unset path targets one field per call by
668                    // definition, so the multi-field accumulator
669                    // stays empty here — the singular fields above
670                    // are authoritative.
671                    missing: Vec::new(),
672                });
673            }
674            if next.metadata.shift_remove(&key).is_some() {
675                modified_metadata_unset.push(key);
676            }
677        }
678
679        // Delay the auto-stamp until AFTER the no-op short-circuit. Pre-fix
680        // this branch overwrote `last_modified` with `today_iso()`
681        // before the bytes-compare, so the prospective markdown
682        // always differed from the on-disk bytes (the schema's
683        // `last_modified` was already populated with a full ISO
684        // timestamp on the prior write, but `today_iso()` returns
685        // date-only), and the no-op compare never matched. Compute
686        // `today` for later use but don't stamp `next` yet.
687        let today = self.now_iso();
688
689        // Alias-synthesis pass: for schemas declaring
690        // `alias_target_rel_type`, append engine-emitted relations of
691        // that rel-type for every body wiki-link not already backed,
692        // and GC pointer-rel-type relations whose target was a body
693        // wiki-link in the prev state but isn't in next. Cross-mem
694        // refusal aborts the update — no partial state.
695        //
696        // The returned `Vec<Relationship>` is the per-call set of
697        // synthesised relations; feed it into the auto-stub warning
698        // emission below.
699        let (synthesised_relations, self_link_ignored) =
700            super::synthesise_alias_relations(self, &prev_body_targets, &mut next)?;
701
702        // Alias-existence invariant: every body wiki-link must be
703        // backed by an entry in `entity.relationships`. The validator
704        // runs against the *full* post-mutation state (not just the
705        // delta), so a mutation that leaves an existing unbacked link
706        // in place still fails — forcing cleanup of historical drift.
707        let missing = super::scan_wikilinks_without_relation(&next)?;
708        if !missing.is_empty() {
709            return Err(EngineError::WikiLinkWithoutRelation {
710                from_id: id.to_string(),
711                missing: missing
712                    .into_iter()
713                    .map(|(section_key, target)| crate::engine::MissingWikiLink {
714                        section_key,
715                        target_id: target.to_string(),
716                    })
717                    .collect(),
718            });
719        }
720
721        let file_path = next.file_path.clone();
722
723        // The bytes-compare runs against the pre-stamp markdown so the
724        // auto-timestamp doesn't synthesise a false delta. When the
725        // user-visible payload (sections, user-set metadata,
726        // declared relations) didn't change, the pre-stamp markdown
727        // matches the on-disk bytes byte-for-byte; we short-circuit
728        // and return with `last_modified` preserved at its pre-call
729        // value. Real changes fall through; we stamp + regenerate
730        // below.
731        let markdown_pre_stamp = generate_markdown(&next, type_def.as_ref());
732
733        // Whether the user-visible content (sections, user-set metadata,
734        // declared relations) is byte-identical to the on-disk entity —
735        // the pre-stamp markdown's hash matches the current
736        // `content_hash`. When this holds past the no-op guard below, the
737        // *only* remaining delta is the anchors sidecar (an anchor-only
738        // update). `_hash` excludes anchors, so an anchor-only commit
739        // produces zero entity deltas; the distinct `anchor` verb is the
740        // compensating signal that makes it observable. `next.content_hash`
741        // is the on-disk value (cloned from the source entity and never
742        // recomputed since — same rationale as the no-op branch reads it).
743        let content_unchanged =
744            crate::entity::parser::compute_hash(&markdown_pre_stamp) == next.content_hash;
745
746        // No-op short-circuit. When the pre-stamp markdown's hash
747        // matches the entity's current `content_hash`, the
748        // post-mutation user-visible state equals the on-disk state.
749        // Skip the disk write, the commit, the provenance append,
750        // and the store re-parse. Mirrors `relate.rs`'s
751        // `NoOpAlreadyPresent` / `NoOpAbsent` and `rename.rs`'s
752        // slug-noop short-circuits: empty `commit_sha`, unchanged
753        // `content_hash`, preserved `last_modified`, typed
754        // `UpdateNoop` warning. Skipped on the dry_run path because
755        // the dry_run preview semantics document a separate
756        // non-committing shape with `prospective_hash: Some(_)` and
757        // the unchanged `content_hash`; conflating the two would
758        // lose the prospective-hash channel callers use to chain a
759        // follow-up real update with `expected_hash`.
760        if !args.dry_run {
761            // An update carrying `anchors[]` or `anchors_unset[]` is never
762            // a no-op even when the entity content is unchanged — the
763            // anchors sidecar must be written, so fall through to the
764            // real-commit path.
765            if content_unchanged
766                && validated_anchors.is_empty()
767                && validated_anchor_unsets.is_empty()
768            {
769                // No-op: report the preserved `last_modified` from
770                // the pre-stamp `next` (which still carries the
771                // entity's on-disk value because we haven't run the
772                // auto-stamp yet on this branch).
773                let modified_date = next
774                    .metadata
775                    .get("last_modified")
776                    .and_then(|v| v.as_str().map(str::to_string))
777                    .unwrap_or_default();
778                return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
779                    id: id.clone(),
780                    title: next.title.clone(),
781                    file_path,
782                    content_hash: next.content_hash.clone(),
783                    commit_sha: String::new(),
784                    modified_date,
785                    // No-op: the prospective hash equals the on-disk hash,
786                    // so nothing landed. `modified_*` report the *applied*
787                    // delta (empty), consistent with the empty `commit_sha`
788                    // and unchanged hash — not the request-derived keys,
789                    // which would claim a change that did not happen
790                    // (F1). The request vecs
791                    // (`modified_metadata_set` etc.) are intentionally
792                    // dropped on this branch.
793                    modified_sections: ModifiedSections::default(),
794                    modified_metadata: ModifiedMetadata::default(),
795                    prospective_hash: None,
796                    // No write happened on the no-op path, so nothing
797                    // could have orphaned a stub.
798                    orphan_stubs_removed: Vec::new(),
799                    warnings: vec![WarningHint::UpdateNoop { id: id.clone() }],
800                    relations_declared,
801                }));
802            }
803        }
804
805        // Real change: apply the auto-stamp now and regenerate the
806        // markdown so the subsequent hash + write reflect it.
807        // Exception — the anchor-only leg (`content_unchanged` true,
808        // reachable only because anchors/unsets are present): the
809        // sidecar is the sole delta and `_hash` excludes it, so the
810        // entity bytes must stay untouched. Stamping here would move
811        // `last_modified` (and with it `_hash`) whenever the update
812        // lands in a different second than the previous write —
813        // breaking the "anchors never move `_hash`" contract.
814        if !content_unchanged {
815            super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
816        }
817        let markdown = generate_markdown(&next, type_def.as_ref());
818
819        let mut warnings: Vec<WarningHint> = Vec::new();
820
821        // Heading-divergence check: when a written section's declared
822        // heading differs from a heading the file already carried for
823        // the same key (derives to it), warn — the write commits and
824        // the regenerated file replaces the old heading text, which
825        // the caller should see rather than discover on the next read.
826        // `next.raw_section_headings` is the pre-mutation parse
827        // artefact (cloned from the store entity; nothing in this
828        // pipeline rewrites it).
829        for key in modified_sections
830            .iter()
831            .chain(modified_sections_appended.iter())
832            .chain(modified_sections_patched.iter())
833        {
834            let Some(def) = type_def.section(key) else {
835                continue;
836            };
837            if let Some(existing) = next.raw_section_headings.iter().find(|h| {
838                h.as_str() != def.heading && memstead_schema::derive_section_key(h) == *key
839            }) {
840                warnings.push(WarningHint::SectionHeadingDivergence {
841                    entity_id: id.clone(),
842                    section_key: key.clone(),
843                    writing_heading: def.heading.clone(),
844                    existing_heading: existing.clone(),
845                });
846            }
847        }
848
849        // Required-outgoing evaluation — the warning the tool
850        // descriptions have promised all along. `next` carries this
851        // update's final edge set (declared relations applied,
852        // alias-synthesis run), evaluated through the same function
853        // the health sweep uses (one implementation; the two surfaces
854        // cannot disagree). A warning, never a refusal.
855        // Section-format evaluation (plan 08), composed-body rule: a
856        // section touched by this update (replace, append, or patch)
857        // is judged on its COMPOSED final body — the delta-only
858        // byte-class guard keeps its scope, shape needs the
859        // composition point. Untouched sections stay lenient (their
860        // pre-existing violations are health findings; the next write
861        // is the sanctioned repair point).
862        for def in &type_def.sections {
863            if def.format_severity != memstead_schema::ConstraintSeverity::Block {
864                continue;
865            }
866            if !format_touched.contains(def.key.as_str()) {
867                continue;
868            }
869            let Some(body) = next.sections.get(def.key.as_str()) else {
870                continue;
871            };
872            if let Some(first) = crate::section_format::check_section_format(def, body)
873                .into_iter()
874                .next()
875            {
876                return Err(EngineError::SectionFormatRefused {
877                    entity_type: next.entity_type.clone(),
878                    entity_id: id.to_string(),
879                    violation: first,
880                });
881            }
882        }
883
884        let unsatisfied =
885            crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref());
886        if !unsatisfied.is_empty() {
887            // `severity: block` promotes the warning to a refusal —
888            // evaluated in the prepare step, before any disk write or
889            // commit, so a refused update leaves nothing behind.
890            let blocked: Vec<_> = unsatisfied
891                .iter()
892                .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
893                .cloned()
894                .collect();
895            if !blocked.is_empty() {
896                return Err(EngineError::RequiredOutgoingUnsatisfied {
897                    entity_type: next.entity_type.clone(),
898                    entity_id: id.to_string(),
899                    missing: blocked,
900                });
901            }
902            warnings.push(WarningHint::MissingRequiredOutgoing {
903                entity_type: next.entity_type.clone(),
904                entity_id: id.clone(),
905                missing: unsatisfied,
906            });
907        }
908
909        // Declared-constraints evaluation — same single evaluation the
910        // health `constraints` include runs, against this update's
911        // final state. Block-tier violations refuse; warn-tier warn.
912        let violated = crate::ops::health::unsatisfied_constraints(
913            &self.store,
914            &next,
915            type_def.as_ref(),
916            Some(id),
917        );
918        if !violated.is_empty() {
919            let blocked: Vec<_> = violated
920                .iter()
921                .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
922                .cloned()
923                .collect();
924            if !blocked.is_empty() {
925                return Err(EngineError::ConstraintUnsatisfied {
926                    entity_type: next.entity_type.clone(),
927                    entity_id: id.to_string(),
928                    violations: blocked,
929                });
930            }
931            warnings.push(WarningHint::ConstraintUnsatisfied {
932                entity_type: next.entity_type.clone(),
933                entity_id: id.clone(),
934                violations: violated,
935            });
936        }
937
938        // Mirror the create-path emission shape — drive the warning from
939        // the synthesised relations the alias pass just emitted, not
940        // from a re-parse of the generated markdown. `parse_markdown`
941        // filters its `inline_links` against the entity's
942        // `relationships` vec (which the synthesis pass has already
943        // appended to), so the pre-fix path saw `inline_links: []`
944        // and silently dropped the warning the docstring promises.
945        let auto_stubbed: Vec<EntityId> = synthesised_relations
946            .iter()
947            .filter_map(|rel| {
948                if !self.store.contains(&rel.target) {
949                    Some(rel.target.clone())
950                } else {
951                    None
952                }
953            })
954            .collect();
955        if !auto_stubbed.is_empty() {
956            warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
957                from: id.clone(),
958                stubs: auto_stubbed,
959            });
960        }
961        // F11: surface a dropped self-referential body link (the alias
962        // pass omitted the vacuous self-edge).
963        if self_link_ignored {
964            warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
965        }
966
967        // Dry-run: compute prospective hash from the in-memory
968        // entity and return without touching disk, store, or
969        // commits. Mirrors full's `UpdateArgs.dry_run` semantics —
970        // `content_hash` carries the unchanged on-disk hash so the
971        // caller can use it as `expected_hash` on the follow-up
972        // real call (designated stale-hash recovery path).
973        if args.dry_run {
974            let prospective = crate::entity::parser::compute_hash(&markdown);
975            // `next.content_hash` was cloned from the source
976            // entity and not modified since; equals the on-disk
977            // value.
978            let current_hash = next.content_hash.clone();
979            let modified_date = if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
980                today.clone()
981            } else {
982                String::new()
983            };
984            return Ok(PrepareOutcome::Done(UpdateEntityOutcome {
985                id: id.clone(),
986                title: next.title.clone(),
987                file_path,
988                content_hash: current_hash,
989                commit_sha: String::new(),
990                modified_date,
991                modified_sections: ModifiedSections {
992                    replaced: modified_sections,
993                    appended: modified_sections_appended,
994                    patched: modified_sections_patched,
995                },
996                modified_metadata: ModifiedMetadata {
997                    set: modified_metadata_set,
998                    unset: modified_metadata_unset,
999                },
1000                prospective_hash: Some(prospective),
1001                // Dry-run touches neither store nor disk, so no stub
1002                // could have been GC'd.
1003                orphan_stubs_removed: Vec::new(),
1004                warnings,
1005                relations_declared: relations_declared.clone(),
1006            }));
1007        }
1008
1009        // Real change prepared. Compute `modified_date` (mirrors full's
1010        // UpdateResult.modified_date — the `today` the auto-stamp loop
1011        // used; empty when the schema has no auto_timestamp field), then
1012        // hand the staged write to the caller to commit. The single
1013        // path commits immediately; the batch path commits the whole
1014        // set at once.
1015        let modified_date = if content_unchanged {
1016            // Anchor-only: nothing was stamped; report the preserved
1017            // on-entity value, mirroring the no-op branch.
1018            next.metadata
1019                .get("last_modified")
1020                .and_then(|v| v.as_str().map(str::to_string))
1021                .unwrap_or_default()
1022        } else if type_def.metadata_fields.iter().any(|f| f.auto_timestamp) {
1023            today.clone()
1024        } else {
1025            String::new()
1026        };
1027
1028        Ok(PrepareOutcome::Prepared(PreparedUpdate {
1029            mount_idx,
1030            id: id.clone(),
1031            mem,
1032            type_def,
1033            file_path,
1034            markdown,
1035            prev_body_targets,
1036            modified_date,
1037            modified_sections: ModifiedSections {
1038                replaced: modified_sections,
1039                appended: modified_sections_appended,
1040                patched: modified_sections_patched,
1041            },
1042            modified_metadata: ModifiedMetadata {
1043                set: modified_metadata_set,
1044                unset: modified_metadata_unset,
1045            },
1046            // F5: `InlineWikiLinkAutoStubbed` rides on the outcome so the
1047            // update path matches create's contract.
1048            warnings,
1049            relations_declared,
1050            // Anchor-only: content byte-identical to disk, so the sidecar
1051            // is the sole delta. Reachable here only past the no-op guard,
1052            // which already returned when content is unchanged AND no
1053            // anchors or unsets — so `content_unchanged` here implies
1054            // anchor work is present. The explicit `!is_empty()` keeps the
1055            // predicate self-evidently correct without leaning on that
1056            // invariant.
1057            anchor_only: content_unchanged
1058                && (!validated_anchors.is_empty() || !validated_anchor_unsets.is_empty()),
1059            anchors: validated_anchors,
1060            anchor_unsets: validated_anchor_unsets,
1061        }))
1062    }
1063
1064    /// Apply a batch of [`UpdateEntityArgs`] **atomically** — all or
1065    /// nothing. Surfaces `BatchResult` for `memstead batch-update`
1066    /// consumers.
1067    ///
1068    /// The batch validates and prepares every item first (each with
1069    /// its own optimistic-lock check), then commits the whole set as
1070    /// **one** commit per mem. If any item fails — validation error,
1071    /// `HASH_MISMATCH`, entity-not-found, any per-item refusal —
1072    /// **nothing is committed**: the on-disk mem and the in-memory
1073    /// store are restored to exactly their pre-call state, and the
1074    /// result is marked `applied: false` with EVERY failing item
1075    /// carrying a typed `{code, message, details}` error envelope
1076    /// (the family's report-all contract — bounded at
1077    /// [`Self::BATCH_ERROR_REPORT_CAP`] detailed envelopes, with
1078    /// `errors_suppressed` counting the rest) and every valid item
1079    /// marked `"not_applied"`, so one repair cycle fixes the file.
1080    ///
1081    /// On success the returned `commit_sha` is the single batch commit
1082    /// — an honest `memstead_changes_since` cursor / revert handle. Each
1083    /// item's per-entry note rides into its own provenance record.
1084    ///
1085    /// Empty batches return `applied: true` with zero counts and no
1086    /// commit. A batch where every item is a no-op (content unchanged)
1087    /// likewise applies with an empty `commit_sha`.
1088    ///
1089    /// **Rehearsal** (`dry_run: true`): the FULL per-item validation
1090    /// pass runs — identical refusals, identical report-all envelope —
1091    /// then the batch stops before any write or commit. A legal batch
1092    /// returns the would-be receipt (`applied: true`, per-entry
1093    /// actions) with the marker form's empty `commit_sha`; an illegal
1094    /// one returns the same refusal a real call would. Nothing is
1095    /// staged, committed, or stamped.
1096    ///
1097    /// Atomicity is per-mem: for the common single-mem batch a
1098    /// commit-time backend failure rolls the whole batch back. A batch
1099    /// spanning multiple mems commits each mem in turn; if a later
1100    /// mem's commit fails, already-committed mems stay committed
1101    /// (true cross-mem two-phase commit is out of scope) — but the
1102    /// dominant failure mode, a per-item validation/hash refusal, is
1103    /// always fully atomic because no commit happens until every item
1104    /// has passed.
1105    pub fn batch_update(
1106        &mut self,
1107        updates: Vec<(UpdateEntityArgs, Option<String>)>,
1108        actor: Actor,
1109        client: Option<&ClientId>,
1110        dry_run: bool,
1111    ) -> Result<crate::ops::BatchResult, EngineError> {
1112        if updates.is_empty() {
1113            return Ok(crate::ops::BatchResult {
1114                orphan_stubs_removed: Vec::new(),
1115                errors_suppressed: 0,
1116                applied: true,
1117                results: Vec::new(),
1118                succeeded: 0,
1119                failed: 0,
1120                commit_sha: String::new(),
1121            });
1122        }
1123
1124        // Reload-before-operation: refresh every mem this batch
1125        // touches *before* preparing items, so each item's
1126        // `expected_hash` check runs against current truth (the batch
1127        // is the one multi-op-per-process path, so a sibling commit
1128        // between boot and this call is plausible). Notices stash on
1129        // the engine for the caller to drain.
1130        let mut touched_mems: Vec<String> = updates
1131            .iter()
1132            .map(|(a, _)| a.id.mem().to_string())
1133            .collect();
1134        touched_mems.sort();
1135        touched_mems.dedup();
1136        for v in &touched_mems {
1137            self.reload_if_stale(Some(v));
1138        }
1139
1140        // Snapshot the in-memory store so a refused batch (or a
1141        // commit-time backend failure) can roll back any auto-stubs
1142        // and store pushes that earlier items already applied during
1143        // preparation. The on-disk side rolls back by discarding each
1144        // backend's staged-but-uncommitted pending buffer.
1145        let store_snapshot = self.store.clone();
1146
1147        // What each item is, in submission order, so the result
1148        // entries echo the input order. `Prepared` is a real write
1149        // (its `PreparedUpdate` lives in `prepared`); `Noop` is an
1150        // applied no-op (content unchanged, no write); `Error` is a
1151        // refusal (its envelope lives in `errors` by index).
1152        enum Item {
1153            Prepared,
1154            Noop,
1155            Error,
1156        }
1157        let mut items: Vec<(EntityId, Item)> = Vec::with_capacity(updates.len());
1158        let mut prepared: Vec<PreparedUpdate> = Vec::new();
1159        let mut notes: Vec<Option<String>> = Vec::new();
1160        let mut errors: Vec<(usize, EngineError)> = Vec::new();
1161
1162        // --- Phase 1: validate + prepare every item (no commits).
1163        // Report-all: a failing item never stops preparation — every
1164        // remaining item still validates so the refusal can name every
1165        // failing entry at once (the family's upgraded contract).
1166        for (i, (args, note)) in updates.into_iter().enumerate() {
1167            let id = args.id.clone();
1168            // Rehearsal is batch-level (the `dry_run` parameter) —
1169            // force the per-entry flag off so `Done` below always
1170            // means a genuine content no-op, never a per-entry
1171            // dry-run short-circuit misread as one.
1172            let mut args = args;
1173            args.dry_run = false;
1174            match self.prepare_update(args) {
1175                Ok(PrepareOutcome::Done(_)) => {
1176                    // No-op: applied, no write.
1177                    items.push((id, Item::Noop));
1178                }
1179                Ok(PrepareOutcome::Prepared(p)) => {
1180                    prepared.push(p);
1181                    notes.push(note);
1182                    items.push((id, Item::Prepared));
1183                }
1184                Err(e) => {
1185                    items.push((id, Item::Error));
1186                    errors.push((i, e));
1187                }
1188            }
1189        }
1190
1191        if !errors.is_empty() {
1192            // Refuse the whole batch. Roll back store + disk, then
1193            // report every failing entry — bounded at
1194            // `BATCH_ERROR_REPORT_CAP` detailed envelopes with
1195            // `errors_suppressed` counting the rest.
1196            self.store = store_snapshot;
1197            self.discard_all_pending();
1198            let failed = errors.len();
1199            let mut error_map: std::collections::HashMap<usize, EngineError> =
1200                errors.into_iter().collect();
1201            let mut reported = 0usize;
1202            let mut suppressed = 0usize;
1203            let results: Vec<crate::ops::BatchEntry> = items
1204                .into_iter()
1205                .enumerate()
1206                .map(|(i, (id, _))| match error_map.remove(&i) {
1207                    Some(e) => {
1208                        if reported < Self::BATCH_ERROR_REPORT_CAP {
1209                            reported += 1;
1210                            crate::ops::BatchEntry {
1211                                id,
1212                                action: "error".to_string(),
1213                                error: Some(batch_error_envelope(&e)),
1214                            }
1215                        } else {
1216                            suppressed += 1;
1217                            crate::ops::BatchEntry {
1218                                id,
1219                                action: "error".to_string(),
1220                                error: None,
1221                            }
1222                        }
1223                    }
1224                    None => crate::ops::BatchEntry {
1225                        id,
1226                        action: "not_applied".to_string(),
1227                        error: None,
1228                    },
1229                })
1230                .collect();
1231            return Ok(crate::ops::BatchResult {
1232                orphan_stubs_removed: Vec::new(),
1233                errors_suppressed: suppressed,
1234                applied: false,
1235                results,
1236                succeeded: 0,
1237                failed,
1238                commit_sha: String::new(),
1239            });
1240        }
1241
1242        // Rehearsal: every item validated (the pass above is the same
1243        // one a real batch runs), nothing failed — stop before any
1244        // write. Roll back the prepare pass's store effects, discard
1245        // any pending buffers, and return the would-be receipt with
1246        // the marker form's empty `commit_sha`.
1247        if dry_run {
1248            self.store = store_snapshot;
1249            self.discard_all_pending();
1250            let succeeded = items.len();
1251            let results: Vec<crate::ops::BatchEntry> = items
1252                .into_iter()
1253                .map(|(id, item)| crate::ops::BatchEntry {
1254                    id,
1255                    action: match item {
1256                        Item::Prepared => "updated".to_string(),
1257                        Item::Noop => "noop".to_string(),
1258                        Item::Error => unreachable!("refusal path returned above"),
1259                    },
1260                    error: None,
1261                })
1262                .collect();
1263            return Ok(crate::ops::BatchResult {
1264                orphan_stubs_removed: Vec::new(),
1265                errors_suppressed: 0,
1266                applied: true,
1267                results,
1268                succeeded,
1269                failed: 0,
1270                commit_sha: String::new(),
1271            });
1272        }
1273
1274        // --- Phase 2: stage every prepared write, then commit once
1275        // per mem. ---
1276        for p in &prepared {
1277            if let Err(e) = self.mounts[p.mount_idx]
1278                .backend
1279                .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1280            {
1281                self.store = store_snapshot;
1282                self.discard_all_pending();
1283                return Err(e.into());
1284            }
1285            // Stage each item's anchors into the same per-mem pending
1286            // buffer so they ride the batch commit atomically.
1287            if (!p.anchors.is_empty() || !p.anchor_unsets.is_empty())
1288                && let Err(e) = super::stage_anchors_sidecar(
1289                    self.mounts[p.mount_idx].backend.as_ref(),
1290                    &p.id,
1291                    &p.anchor_unsets,
1292                    p.anchors.clone(),
1293                )
1294            {
1295                self.store = store_snapshot;
1296                self.discard_all_pending();
1297                return Err(e);
1298            }
1299            // Derivation baselines (plan 12) — same predicate and
1300            // staging as the single update; rides the batch commit.
1301            if let Some(schema) = self.schemas.get(p.id.mem()) {
1302                for r in p
1303                    .relations_declared
1304                    .iter()
1305                    .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1306                {
1307                    let hash = self
1308                        .store
1309                        .get(&r.target)
1310                        .map(|e| e.content_hash.clone())
1311                        .unwrap_or_default();
1312                    let (from, rel, to) =
1313                        (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1314                    if let Err(e) = super::stage_derivation_sidecar(
1315                        self.mounts[p.mount_idx].backend.as_ref(),
1316                        |s| s.set(&from, &rel, &to, &hash),
1317                    ) {
1318                        self.store = store_snapshot;
1319                        self.discard_all_pending();
1320                        return Err(e);
1321                    }
1322                }
1323            }
1324        }
1325
1326        // Distinct mount indices in first-seen order — one commit each.
1327        let mut distinct_mounts: Vec<usize> = Vec::new();
1328        for p in &prepared {
1329            if !distinct_mounts.contains(&p.mount_idx) {
1330                distinct_mounts.push(p.mount_idx);
1331            }
1332        }
1333        let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1334        for &m in &distinct_mounts {
1335            let entity_ids: Vec<String> = prepared
1336                .iter()
1337                .filter(|p| p.mount_idx == m)
1338                .map(|p| p.id.to_string())
1339                .collect();
1340            let count = entity_ids.len();
1341            let subject = format!("memstead: batch-update ({count} entities)");
1342            let ctx = CommitContext {
1343                actor,
1344                client: client.cloned(),
1345                tool: Some("batch_update"),
1346                note: None,
1347                role: self.current_role,
1348                logical_operation_id: None,
1349                // F13: name every entity this batch commit touched so an
1350                // `--include-notes` reader can recover them from the note
1351                // record alone — the subject only says `(N entities)`.
1352                entity_ids: Some(entity_ids),
1353            };
1354            match self.mounts[m].backend.commit(&subject, &ctx) {
1355                Ok(sha) => mount_commits.push((m, sha)),
1356                Err(e) => {
1357                    // A commit failed. Roll back the store and any
1358                    // still-pending backends. Mems already committed
1359                    // in this loop stay committed (per-mem atomicity).
1360                    self.store = store_snapshot;
1361                    self.discard_all_pending();
1362                    return Err(e.into());
1363                }
1364            }
1365        }
1366
1367        // Provenance + store application per item, now that the commits
1368        // landed. `record_self_write` marks the commit as engine-self
1369        // so drift detection ignores it.
1370        for (p, note) in prepared.iter().zip(notes.iter()) {
1371            let commit_sha = mount_commits
1372                .iter()
1373                .find(|(m, _)| *m == p.mount_idx)
1374                .map(|(_, s)| s.clone())
1375                .unwrap_or_default();
1376            self.mounts[p.mount_idx].backend.append_provenance(
1377                &Provenance::new(
1378                    std::time::SystemTime::now(),
1379                    ProvenanceKind::Update,
1380                    Some(p.id.to_string()),
1381                    actor,
1382                    client.cloned(),
1383                    note.clone(),
1384                )
1385                .with_role(self.current_role),
1386            )?;
1387            self.record_self_write(p.mount_idx, &commit_sha);
1388            self.stamp_mutation_versions(p.mount_idx);
1389            self.apply_prepared_to_store(p)?;
1390        }
1391
1392        self.invalidate_communities();
1393        self.invalidate_search_indexes();
1394
1395        // Single-mem batches name their one commit; multi-mem names
1396        // the last mem committed (see the method docstring).
1397        let commit_sha = mount_commits
1398            .last()
1399            .map(|(_, s)| s.clone())
1400            .unwrap_or_default();
1401        let succeeded = items.len();
1402        let results: Vec<crate::ops::BatchEntry> = items
1403            .into_iter()
1404            .map(|(id, item)| crate::ops::BatchEntry {
1405                id,
1406                action: match item {
1407                    Item::Prepared => "updated".to_string(),
1408                    Item::Noop => "noop".to_string(),
1409                    Item::Error => unreachable!("refusal path returned above"),
1410                },
1411                error: None,
1412            })
1413            .collect();
1414
1415        Ok(crate::ops::BatchResult {
1416            orphan_stubs_removed: Vec::new(),
1417            errors_suppressed: 0,
1418            applied: true,
1419            results,
1420            succeeded,
1421            failed: 0,
1422            commit_sha,
1423        })
1424    }
1425
1426    /// Best-effort discard of every backend's staged-but-uncommitted
1427    /// pending buffer — the disk-side half of an atomic-batch rollback.
1428    /// Discard errors (a poisoned pending mutex) are swallowed: we are
1429    /// already unwinding a refused batch and have nothing better to do.
1430    pub(super) fn discard_all_pending(&self) {
1431        for mount in &self.mounts {
1432            let _ = mount.backend.discard_pending();
1433        }
1434    }
1435
1436    /// CommitContext-bundling wrapper around [`Self::update_entity`].
1437    /// See [`Self::create_entity_with_ctx`] for the rationale.
1438    pub fn update_entity_with_ctx(
1439        &mut self,
1440        args: UpdateEntityArgs,
1441        ctx: &CommitContext<'_>,
1442    ) -> Result<UpdateEntityOutcome, EngineError> {
1443        self.update_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1444    }
1445}
1446
1447/// Build a per-item structured error envelope for [`Engine::batch_update`].
1448/// Mirrors the `{code, message, details}` shape single-update failures
1449/// carry on the MCP wire so a mixed-success batch is structurally uniform.
1450/// Variants without a typed recovery payload (boundary / internal failures
1451/// like `ParseAfterWrite`, `Backend`) return an empty details object —
1452/// the code and message channels still discriminate.
1453pub(super) fn batch_error_envelope(err: &EngineError) -> crate::ops::BatchError {
1454    // The per-item envelope reads the centralised
1455    // `EngineError::details()` helper so every typed variant ships the
1456    // same recovery payload the singleton MCP/CLI surfaces emit, so
1457    // agents' "fix from `details` rather than re-fetching" loop works
1458    // the same in batch mode.
1459    let code = err.code().to_string();
1460    let message = err.to_string();
1461    let details = err.details();
1462    crate::ops::BatchError {
1463        code,
1464        message,
1465        details,
1466    }
1467}
1468
1469/// Validate, auto-stub, and append a batch of relation declarations
1470/// onto `next.relationships`. Returns the canonical
1471/// `RelationDeclared` summary echoed back on the outcome.
1472///
1473/// Validates each declared relation against the same gates
1474/// `memstead_relate` runs (target-id grammar, rel-type vocabulary,
1475/// schema shape, cross-mem policy, ReadOnly-target rule). On the
1476/// add path with an absent Write-mem target, the target is
1477/// auto-stubbed via `make_stub` — matching the
1478/// `WarningHint::AutoStubCreated` semantics of the relate flow.
1479///
1480/// Defined at module scope (rather than a method on `Engine`) so
1481/// the borrow on `engine.store` for the auto-stub upsert can run
1482/// alongside the `&mut next` borrow.
1483fn apply_declare_relations(
1484    engine: &mut Engine,
1485    next: &mut Entity,
1486    declarations: &[crate::ops::RelateArg],
1487    source_mem: &str,
1488    source_mount_idx: usize,
1489    type_def: &memstead_schema::TypeDefinition,
1490    schema: &memstead_schema::Schema,
1491) -> Result<Vec<RelationDeclared>, EngineError> {
1492    let _ = type_def; // Reserved for future per-type policy hooks.
1493    let _ = source_mount_idx; // Reserved for parity with delete.
1494    let mut declared: Vec<RelationDeclared> = Vec::with_capacity(declarations.len());
1495    for rel in declarations {
1496        // Canonicalise rel_type to UPPER_SNAKE_CASE so the validator
1497        // and the stored edge see the same wire-contract form.
1498        let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
1499            .unwrap_or_else(|_| rel.rel_type.clone());
1500
1501        validate_relation_target_grammar(&rel.to)?;
1502
1503        let target_mem = rel.to.mem().to_string();
1504        // Grant + ReadOnly-missing-target checks both live in the
1505        // shared add-path funnel.
1506        super::validate_cross_mem_add_policy(engine, source_mem, &rel.to)?;
1507
1508        // Rel-type + shape validation, routed through the engine's
1509        // cross-mem-aware edge validator. Cross-different-schema
1510        // edges check vocabulary + shape against the source schema's
1511        // `cross_mem_relationships:` entry; same-schema edges fall
1512        // through to the intra-mem `relationships.definitions`.
1513        // Open-mode admits unknown rel-types silently (no
1514        // per-declaration warning surfaced here — symmetry with the
1515        // pre-cross-mem behaviour).
1516        let target_type = engine
1517            .store
1518            .get(&rel.to)
1519            .map(|e| e.entity_type.clone())
1520            .filter(|t| !t.is_empty());
1521        let _ = super::route_edge_validation(
1522            engine,
1523            &canonical,
1524            next.entity_type.as_str(),
1525            target_type.as_deref(),
1526            source_mem,
1527            &target_mem,
1528            &next.id,
1529            &rel.to,
1530            /* check_shape = */ true,
1531        )?;
1532
1533        // Per-edge description posture. Normalise first so
1534        // empty/whitespace-only inputs collapse to `None` and the
1535        // posture check sees a canonical input that matches what
1536        // the renderer will emit.
1537        let normalised_description =
1538            crate::entity::normalise_description(rel.description.as_deref());
1539        super::validate_description_posture(
1540            engine,
1541            &canonical,
1542            normalised_description.as_deref(),
1543            source_mem,
1544            &target_mem,
1545            &next.id,
1546            &rel.to,
1547        )?;
1548        // declare_relations is an explicit-author
1549        // boundary too — gate on manual_authoring posture.
1550        super::validate_manual_authoring_posture(
1551            engine, &canonical, source_mem, &next.id, &rel.to,
1552        )?;
1553
1554        // Cycle family — the same shared gate `memstead_relate` runs
1555        // (self-loop on listed no-self-loop rel-types, long cycle on acyclic
1556        // types), against the current store state.
1557        super::validate_edge_acyclicity(
1558            &engine.store,
1559            schema,
1560            &next.id,
1561            next.entity_type.as_str(),
1562            &rel.to,
1563            &canonical,
1564        )?;
1565
1566        // Append to the entity's relationships list. Duplicate
1567        // declarations are idempotent — same (rel_type, target) pair
1568        // is a silent no-op so the agent can re-issue the same call
1569        // without surprise.
1570        let exists = next
1571            .relationships
1572            .iter()
1573            .any(|r| r.rel_type == canonical && r.target == rel.to);
1574        if !exists {
1575            next.relationships.push(Relationship {
1576                rel_type: canonical.clone(),
1577                target: rel.to.clone(),
1578                description: normalised_description,
1579            });
1580        }
1581
1582        // Auto-stub absent Write-mem targets. Same mechanic as
1583        // `memstead_relate`'s relate path. ReadOnly cross-mem targets
1584        // were caught above; same-mem and cross-mem-to-Write
1585        // both fall through here.
1586        let target_was_stubbed = !engine.store.contains(&rel.to);
1587        if target_was_stubbed && !exists {
1588            engine.store.upsert(
1589                rel.to.clone(),
1590                make_stub(&rel.to, crate::entity::StubKind::ForwardReference),
1591            );
1592        }
1593
1594        declared.push(RelationDeclared {
1595            rel_type: canonical,
1596            target: rel.to.clone(),
1597            target_was_stubbed,
1598        });
1599    }
1600    Ok(declared)
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605
1606    use indexmap::IndexMap;
1607    use tempfile::TempDir;
1608
1609    use crate::backend::MemBackend;
1610    use crate::engine::test_helpers::*;
1611    use crate::engine::{
1612        CreateEntityArgs, Engine, EngineError, RelateEntityArgs, UpdateEntityArgs,
1613    };
1614    use crate::entity::EntityId;
1615
1616    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1617    use crate::vcs::Actor;
1618
1619    /// A mutation writing a section whose declared heading differs
1620    /// from a heading the file already carried for the same key warns
1621    /// (`SECTION_HEADING_DIVERGENCE`, naming both headings) and still
1622    /// commits. Refusal complement: once the file carries the matching
1623    /// heading, the same update emits no such warning.
1624    #[test]
1625    fn update_warns_on_section_heading_divergence_and_still_commits() {
1626        let tmp = TempDir::new().unwrap();
1627        let mem_dir = tmp.path().to_path_buf();
1628        // Pre-existing file whose heading derives to `identity` but is
1629        // not the schema's declared `Identity`.
1630        std::fs::write(
1631            mem_dir.join("diverged.md"),
1632            "---\ntype: spec\n---\n# Diverged\n\n## IDENTITY\n\nold text.\n",
1633        )
1634        .unwrap();
1635        let writer = FilesystemMemWriter::new(mem_dir.clone());
1636        let mut engine = Engine::from_mounts(vec![(
1637            folder_mount("specs", mem_dir),
1638            Box::new(writer) as Box<dyn MemBackend>,
1639        )])
1640        .unwrap();
1641        let (actor, client) = cli_actor();
1642        let id = EntityId::new("specs", "diverged");
1643
1644        let update_identity = |engine: &mut Engine, body: &str| {
1645            let current = engine.get_entity(&id).unwrap().content_hash.clone();
1646            let mut sections = IndexMap::new();
1647            sections.insert("identity".to_string(), body.to_string());
1648            engine
1649                .update_entity(
1650                    UpdateEntityArgs {
1651                        anchors: Vec::new(),
1652                        id: id.clone(),
1653                        expected_hash: Some(current),
1654                        sections,
1655                        append_sections: IndexMap::new(),
1656                        patch_sections: IndexMap::new(),
1657                        metadata: IndexMap::new(),
1658                        metadata_unset: Vec::new(),
1659                        declare_relations: Vec::new(),
1660                        dry_run: false,
1661                        relations_unset: Vec::new(),
1662                        anchors_unset: Vec::new(),
1663                    },
1664                    actor,
1665                    Some(&client),
1666                    None,
1667                )
1668                .unwrap()
1669        };
1670
1671        let outcome = update_identity(&mut engine, "new text.");
1672        assert!(!outcome.commit_sha.is_empty(), "the mutation still commits");
1673        let divergences: Vec<_> = outcome
1674            .warnings
1675            .iter()
1676            .filter_map(|w| match w {
1677                crate::ops::WarningHint::SectionHeadingDivergence {
1678                    section_key,
1679                    writing_heading,
1680                    existing_heading,
1681                    ..
1682                } => Some((
1683                    section_key.clone(),
1684                    writing_heading.clone(),
1685                    existing_heading.clone(),
1686                )),
1687                _ => None,
1688            })
1689            .collect();
1690        assert_eq!(
1691            divergences,
1692            vec![(
1693                "identity".to_string(),
1694                "Identity".to_string(),
1695                "IDENTITY".to_string()
1696            )],
1697            "warning names both headings; all warnings = {:?}",
1698            outcome.warnings
1699        );
1700
1701        // The regenerated file now carries the declared heading — a
1702        // second update to the same section must not warn.
1703        let outcome2 = update_identity(&mut engine, "third text.");
1704        assert!(
1705            !outcome2
1706                .warnings
1707                .iter()
1708                .any(|w| matches!(w, crate::ops::WarningHint::SectionHeadingDivergence { .. })),
1709            "matching heading emits no divergence warning: {:?}",
1710            outcome2.warnings
1711        );
1712    }
1713
1714    #[test]
1715    fn batch_update_empty_batch_returns_zero_counts() {
1716        // No updates → BatchResult with zero counts + empty
1717        // commit_sha. No engine mutation happens.
1718        let tmp = TempDir::new().unwrap();
1719        let mem_dir = tmp.path().to_path_buf();
1720        let writer = FilesystemMemWriter::new(mem_dir.clone());
1721        let mut engine = Engine::from_mounts(vec![(
1722            folder_mount("specs", mem_dir),
1723            Box::new(writer) as Box<dyn MemBackend>,
1724        )])
1725        .unwrap();
1726
1727        let result = engine
1728            .batch_update(Vec::new(), Actor::Cli, None, false)
1729            .unwrap();
1730        assert!(result.applied, "empty batch is a vacuous success");
1731        assert_eq!(result.results.len(), 0);
1732        assert_eq!(result.succeeded, 0);
1733        assert_eq!(result.failed, 0);
1734        assert_eq!(result.commit_sha, "");
1735    }
1736
1737    #[test]
1738    fn batch_update_refuses_whole_batch_when_one_item_fails() {
1739        // Atomic semantics: a 2-item batch where item 1 is valid and
1740        // item 2 targets a missing id refuses the WHOLE batch. Nothing
1741        // is committed — item 1 is NOT applied (its section change does
1742        // not land), `applied` is false, `commit_sha` is empty, the
1743        // missing item carries the typed ENTITY_NOT_FOUND envelope, and
1744        // the valid item is marked `not_applied`.
1745        let tmp = TempDir::new().unwrap();
1746        let mem_dir = tmp.path().to_path_buf();
1747        let writer = FilesystemMemWriter::new(mem_dir.clone());
1748        let mut engine = Engine::from_mounts(vec![(
1749            folder_mount("specs", mem_dir),
1750            Box::new(writer) as Box<dyn MemBackend>,
1751        )])
1752        .unwrap();
1753
1754        // Seed: create an entity.
1755        let create_args = CreateEntityArgs {
1756            anchors: Vec::new(),
1757            mem: "specs".to_string(),
1758            title: "Seed".to_string(),
1759            entity_type: "spec".to_string(),
1760            sections: IndexMap::from_iter([
1761                ("identity".to_string(), "seed identity".to_string()),
1762                ("purpose".to_string(), "seed purpose".to_string()),
1763            ]),
1764            metadata: IndexMap::new(),
1765            relations: Vec::new(),
1766            dry_run: false,
1767        };
1768        let created = engine
1769            .create_entity(create_args, Actor::Cli, None, None)
1770            .unwrap();
1771
1772        // Batch: update the seed entity AND a missing id.
1773        let valid_update = UpdateEntityArgs {
1774            anchors: Vec::new(),
1775            id: created.id.clone(),
1776            expected_hash: Some(created.content_hash.clone()),
1777            sections: IndexMap::from_iter([("identity".to_string(), "updated body".to_string())]),
1778            append_sections: IndexMap::new(),
1779            patch_sections: IndexMap::new(),
1780            metadata: IndexMap::new(),
1781            metadata_unset: Vec::new(),
1782            declare_relations: Vec::new(),
1783            dry_run: false,
1784            relations_unset: Vec::new(),
1785            anchors_unset: Vec::new(),
1786        };
1787        let missing_update = UpdateEntityArgs {
1788            anchors: Vec::new(),
1789            id: EntityId("specs--nonexistent".to_string()),
1790            expected_hash: None,
1791            sections: IndexMap::new(),
1792            append_sections: IndexMap::new(),
1793            patch_sections: IndexMap::new(),
1794            metadata: IndexMap::new(),
1795            metadata_unset: Vec::new(),
1796            declare_relations: Vec::new(),
1797            dry_run: false,
1798            relations_unset: Vec::new(),
1799            anchors_unset: Vec::new(),
1800        };
1801
1802        let result = engine
1803            .batch_update(
1804                vec![(valid_update, None), (missing_update, None)],
1805                Actor::Cli,
1806                None,
1807                false,
1808            )
1809            .unwrap();
1810        // Whole batch refused: nothing applied, no commit.
1811        assert!(!result.applied, "a failing item must refuse the batch");
1812        assert_eq!(result.results.len(), 2);
1813        assert_eq!(result.succeeded, 0);
1814        assert_eq!(result.failed, 1);
1815        assert_eq!(result.commit_sha, "", "refused batch must not commit");
1816        // First entry: the valid item, marked not_applied (the batch
1817        // was refused before it could land).
1818        assert_eq!(result.results[0].action, "not_applied");
1819        assert!(result.results[0].error.is_none());
1820        // Second entry: the failing item carries the typed envelope.
1821        assert_eq!(result.results[1].action, "error");
1822        let err = result.results[1]
1823            .error
1824            .as_ref()
1825            .expect("failed entry must carry a structured error envelope");
1826        assert_eq!(err.code, "ENTITY_NOT_FOUND");
1827        assert!(err.message.contains("not found"), "got: {}", err.message);
1828
1829        // The valid item's section change must NOT have landed — the
1830        // store is byte-identical to pre-call.
1831        let seed = engine.get_entity(&created.id).unwrap();
1832        assert_eq!(
1833            seed.sections.get("identity").map(String::as_str),
1834            Some("seed identity"),
1835            "refused batch must leave the in-memory store untouched",
1836        );
1837        assert_eq!(
1838            seed.content_hash, created.content_hash,
1839            "refused batch must not change the entity's content hash",
1840        );
1841    }
1842
1843    #[test]
1844    fn batch_update_applies_all_valid_items_as_one_commit() {
1845        // A 2-item batch where both items are valid
1846        // applies both and produces exactly one commit; the response's
1847        // commit_sha names it and both entries report "updated".
1848        let tmp = TempDir::new().unwrap();
1849        let mem_dir = tmp.path().to_path_buf();
1850        let writer = FilesystemMemWriter::new(mem_dir.clone());
1851        let mut engine = Engine::from_mounts(vec![(
1852            folder_mount("specs", mem_dir),
1853            Box::new(writer) as Box<dyn MemBackend>,
1854        )])
1855        .unwrap();
1856
1857        let mk = |title: &str| CreateEntityArgs {
1858            anchors: Vec::new(),
1859            mem: "specs".to_string(),
1860            title: title.to_string(),
1861            entity_type: "spec".to_string(),
1862            sections: IndexMap::from_iter([
1863                ("identity".to_string(), "id".to_string()),
1864                ("purpose".to_string(), "purp".to_string()),
1865            ]),
1866            metadata: IndexMap::new(),
1867            relations: Vec::new(),
1868            dry_run: false,
1869        };
1870        let a = engine
1871            .create_entity(mk("A"), Actor::Cli, None, None)
1872            .unwrap();
1873        let b = engine
1874            .create_entity(mk("B"), Actor::Cli, None, None)
1875            .unwrap();
1876
1877        let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
1878            anchors: Vec::new(),
1879            id,
1880            expected_hash: Some(hash),
1881            sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
1882            append_sections: IndexMap::new(),
1883            patch_sections: IndexMap::new(),
1884            metadata: IndexMap::new(),
1885            metadata_unset: Vec::new(),
1886            declare_relations: Vec::new(),
1887            dry_run: false,
1888            relations_unset: Vec::new(),
1889            anchors_unset: Vec::new(),
1890        };
1891
1892        let result = engine
1893            .batch_update(
1894                vec![
1895                    (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
1896                    (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
1897                ],
1898                Actor::Cli,
1899                None,
1900                false,
1901            )
1902            .unwrap();
1903        assert!(result.applied);
1904        assert_eq!(result.succeeded, 2);
1905        assert_eq!(result.failed, 0);
1906        assert!(
1907            !result.commit_sha.is_empty(),
1908            "applied batch carries the commit"
1909        );
1910        assert!(result.results.iter().all(|e| e.action == "updated"));
1911        // Both section changes landed.
1912        assert_eq!(
1913            engine
1914                .get_entity(&a.id)
1915                .unwrap()
1916                .sections
1917                .get("identity")
1918                .map(String::as_str),
1919            Some("A body"),
1920        );
1921        assert_eq!(
1922            engine
1923                .get_entity(&b.id)
1924                .unwrap()
1925                .sections
1926                .get("identity")
1927                .map(String::as_str),
1928            Some("B body"),
1929        );
1930    }
1931
1932    /// Rehearsal contract (agent-trust plan 07): `batch_update` with
1933    /// `dry_run: true` runs the full per-item validation, reports the
1934    /// would-be receipt with the marker form's empty `commit_sha`, and
1935    /// persists NOTHING — on-disk bodies and hashes stay untouched.
1936    /// The follow-up real call with the SAME expected hashes succeeds,
1937    /// proving both the identical-validation contract and the
1938    /// side-effect-freeness (a persisted rehearsal would have moved
1939    /// the hashes and refused the real call).
1940    #[test]
1941    fn batch_update_dry_run_reports_receipt_and_writes_nothing() {
1942        let tmp = TempDir::new().unwrap();
1943        let mem_dir = tmp.path().to_path_buf();
1944        let writer = FilesystemMemWriter::new(mem_dir.clone());
1945        let mut engine = Engine::from_mounts(vec![(
1946            folder_mount("specs", mem_dir),
1947            Box::new(writer) as Box<dyn MemBackend>,
1948        )])
1949        .unwrap();
1950
1951        let mk = |title: &str| CreateEntityArgs {
1952            anchors: Vec::new(),
1953            mem: "specs".to_string(),
1954            title: title.to_string(),
1955            entity_type: "spec".to_string(),
1956            sections: IndexMap::from_iter([
1957                ("identity".to_string(), "id".to_string()),
1958                ("purpose".to_string(), "purp".to_string()),
1959            ]),
1960            metadata: IndexMap::new(),
1961            relations: Vec::new(),
1962            dry_run: false,
1963        };
1964        let a = engine
1965            .create_entity(mk("A"), Actor::Cli, None, None)
1966            .unwrap();
1967        let b = engine
1968            .create_entity(mk("B"), Actor::Cli, None, None)
1969            .unwrap();
1970
1971        let upd = |id: EntityId, hash: String, body: &str| UpdateEntityArgs {
1972            anchors: Vec::new(),
1973            id,
1974            expected_hash: Some(hash),
1975            sections: IndexMap::from_iter([("identity".to_string(), body.to_string())]),
1976            append_sections: IndexMap::new(),
1977            patch_sections: IndexMap::new(),
1978            metadata: IndexMap::new(),
1979            metadata_unset: Vec::new(),
1980            declare_relations: Vec::new(),
1981            dry_run: false,
1982            relations_unset: Vec::new(),
1983            anchors_unset: Vec::new(),
1984        };
1985        let batch = || {
1986            vec![
1987                (upd(a.id.clone(), a.content_hash.clone(), "A body"), None),
1988                (upd(b.id.clone(), b.content_hash.clone(), "B body"), None),
1989            ]
1990        };
1991
1992        let rehearsed = engine
1993            .batch_update(batch(), Actor::Cli, None, true)
1994            .unwrap();
1995        assert!(rehearsed.applied, "{rehearsed:?}");
1996        assert_eq!(rehearsed.succeeded, 2);
1997        assert!(
1998            rehearsed.commit_sha.is_empty(),
1999            "marker form: empty commit_sha"
2000        );
2001        assert!(rehearsed.results.iter().all(|e| e.action == "updated"));
2002        // Nothing persisted: body and hash unchanged.
2003        let a_now = engine.get_entity(&a.id).unwrap();
2004        assert_eq!(
2005            a_now.sections.get("identity").map(String::as_str),
2006            Some("id")
2007        );
2008        assert_eq!(a_now.content_hash, a.content_hash);
2009
2010        // The real call with the same (pre-rehearsal) hashes lands.
2011        let real = engine
2012            .batch_update(batch(), Actor::Cli, None, false)
2013            .unwrap();
2014        assert!(real.applied, "{real:?}");
2015        assert!(!real.commit_sha.is_empty());
2016        assert_eq!(
2017            engine
2018                .get_entity(&a.id)
2019                .unwrap()
2020                .sections
2021                .get("identity")
2022                .map(String::as_str),
2023            Some("A body"),
2024        );
2025    }
2026
2027    /// Rehearsal refusal parity: a failing batch refuses under
2028    /// `dry_run: true` with the SAME per-entry envelope (code,
2029    /// message, details) the real refusal carries.
2030    #[test]
2031    fn batch_update_dry_run_refuses_identically_to_real() {
2032        let tmp = TempDir::new().unwrap();
2033        let mem_dir = tmp.path().to_path_buf();
2034        let writer = FilesystemMemWriter::new(mem_dir.clone());
2035        let mut engine = Engine::from_mounts(vec![(
2036            folder_mount("specs", mem_dir),
2037            Box::new(writer) as Box<dyn MemBackend>,
2038        )])
2039        .unwrap();
2040        let created = engine
2041            .create_entity(
2042                CreateEntityArgs {
2043                    anchors: Vec::new(),
2044                    mem: "specs".to_string(),
2045                    title: "Valid".to_string(),
2046                    entity_type: "spec".to_string(),
2047                    sections: IndexMap::from_iter([
2048                        ("identity".to_string(), "x".to_string()),
2049                        ("purpose".to_string(), "p".to_string()),
2050                    ]),
2051                    metadata: IndexMap::new(),
2052                    relations: Vec::new(),
2053                    dry_run: false,
2054                },
2055                Actor::Cli,
2056                None,
2057                None,
2058            )
2059            .unwrap();
2060        let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2061            anchors: Vec::new(),
2062            id,
2063            expected_hash: hash,
2064            sections: IndexMap::from_iter([("identity".to_string(), "new".to_string())]),
2065            append_sections: IndexMap::new(),
2066            patch_sections: IndexMap::new(),
2067            metadata: IndexMap::new(),
2068            metadata_unset: Vec::new(),
2069            declare_relations: Vec::new(),
2070            dry_run: false,
2071            relations_unset: Vec::new(),
2072            anchors_unset: Vec::new(),
2073        };
2074        let batch = || {
2075            vec![
2076                (
2077                    upd(created.id.clone(), Some("wrong-hash".to_string())),
2078                    None,
2079                ),
2080                (upd(EntityId("specs--missing".to_string()), None), None),
2081            ]
2082        };
2083
2084        let rehearsed = engine
2085            .batch_update(batch(), Actor::Cli, None, true)
2086            .unwrap();
2087        let real = engine
2088            .batch_update(batch(), Actor::Cli, None, false)
2089            .unwrap();
2090        assert!(!rehearsed.applied && !real.applied);
2091        let envelope = |r: &crate::ops::BatchResult| {
2092            r.results
2093                .iter()
2094                .map(|e| {
2095                    (
2096                        e.id.to_string(),
2097                        e.action.clone(),
2098                        e.error.as_ref().map(|err| {
2099                            (err.code.clone(), err.message.clone(), err.details.clone())
2100                        }),
2101                    )
2102                })
2103                .collect::<Vec<_>>()
2104        };
2105        assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
2106        // Neither run persisted anything.
2107        assert_eq!(
2108            engine
2109                .get_entity(&created.id)
2110                .unwrap()
2111                .sections
2112                .get("identity")
2113                .map(String::as_str),
2114            Some("x"),
2115        );
2116    }
2117
2118    /// Report-all (the family's upgraded contract): a batch with TWO
2119    /// failing items names both with their typed codes — a failing
2120    /// item no longer stops preparation at the first error.
2121    #[test]
2122    fn batch_update_reports_every_failing_item() {
2123        let tmp = TempDir::new().unwrap();
2124        let mem_dir = tmp.path().to_path_buf();
2125        let writer = FilesystemMemWriter::new(mem_dir.clone());
2126        let mut engine = Engine::from_mounts(vec![(
2127            folder_mount("specs", mem_dir),
2128            Box::new(writer) as Box<dyn MemBackend>,
2129        )])
2130        .unwrap();
2131        let created = engine
2132            .create_entity(
2133                CreateEntityArgs {
2134                    anchors: Vec::new(),
2135                    mem: "specs".to_string(),
2136                    title: "Seed".to_string(),
2137                    entity_type: "spec".to_string(),
2138                    sections: IndexMap::from_iter([
2139                        ("identity".to_string(), "seed identity".to_string()),
2140                        ("purpose".to_string(), "seed purpose".to_string()),
2141                    ]),
2142                    metadata: IndexMap::new(),
2143                    relations: Vec::new(),
2144                    dry_run: false,
2145                },
2146                Actor::Cli,
2147                None,
2148                None,
2149            )
2150            .unwrap();
2151
2152        let upd = |id: EntityId, hash: Option<String>| UpdateEntityArgs {
2153            anchors: Vec::new(),
2154            id,
2155            expected_hash: hash,
2156            sections: IndexMap::from_iter([("identity".to_string(), "new body".to_string())]),
2157            append_sections: IndexMap::new(),
2158            patch_sections: IndexMap::new(),
2159            metadata: IndexMap::new(),
2160            metadata_unset: Vec::new(),
2161            declare_relations: Vec::new(),
2162            dry_run: false,
2163            relations_unset: Vec::new(),
2164            anchors_unset: Vec::new(),
2165        };
2166        let result = engine
2167            .batch_update(
2168                vec![
2169                    (upd(created.id.clone(), None), None),
2170                    (upd(EntityId("specs--missing-one".to_string()), None), None),
2171                    (upd(EntityId("specs--missing-two".to_string()), None), None),
2172                ],
2173                Actor::Cli,
2174                None,
2175                false,
2176            )
2177            .unwrap();
2178        assert!(!result.applied);
2179        assert_eq!(result.failed, 2, "{result:?}");
2180        assert_eq!(result.commit_sha, "");
2181        let codes: Vec<(usize, &str)> = result
2182            .results
2183            .iter()
2184            .enumerate()
2185            .filter(|(_, r)| r.action == "error")
2186            .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
2187            .collect();
2188        assert_eq!(
2189            codes,
2190            vec![(1, "ENTITY_NOT_FOUND"), (2, "ENTITY_NOT_FOUND")],
2191            "BOTH failing items named, not just the first: {result:?}"
2192        );
2193        assert_eq!(result.results[0].action, "not_applied");
2194        // The valid item's change did not land.
2195        assert_eq!(
2196            engine
2197                .get_entity(&created.id)
2198                .unwrap()
2199                .sections
2200                .get("identity")
2201                .map(String::as_str),
2202            Some("seed identity"),
2203        );
2204    }
2205
2206    #[test]
2207    fn batch_update_rolls_back_in_memory_store_auto_stub_on_refusal() {
2208        // The subtle invariant: an earlier item that auto-stubs a
2209        // relation target during preparation must have that stub rolled
2210        // OUT of the in-memory store when a later item refuses the
2211        // batch. Item 1 declares a relation to an absent target (which
2212        // upserts a forward-reference stub during prepare); item 2
2213        // targets a missing entity and fails. The refusal must leave no
2214        // trace of the stub.
2215        let tmp = TempDir::new().unwrap();
2216        let mem_dir = tmp.path().to_path_buf();
2217        let writer = FilesystemMemWriter::new(mem_dir.clone());
2218        let mut engine = Engine::from_mounts(vec![(
2219            folder_mount("specs", mem_dir.clone()),
2220            Box::new(writer) as Box<dyn MemBackend>,
2221        )])
2222        .unwrap();
2223        engine.set_workspace_root(mem_dir);
2224        let (actor, client) = cli_actor();
2225
2226        let a = engine
2227            .create_entity(
2228                empty_create_args("specs", "Anchor"),
2229                actor,
2230                Some(&client),
2231                None,
2232            )
2233            .unwrap();
2234
2235        let stub_target = EntityId::new("specs", "would-be-stub");
2236        let item1 = UpdateEntityArgs {
2237            anchors: Vec::new(),
2238            relations_unset: Vec::new(),
2239            anchors_unset: Vec::new(),
2240            id: a.id.clone(),
2241            expected_hash: Some(a.content_hash.clone()),
2242            sections: IndexMap::new(),
2243            append_sections: IndexMap::new(),
2244            patch_sections: IndexMap::new(),
2245            metadata: IndexMap::new(),
2246            metadata_unset: Vec::new(),
2247            declare_relations: vec![crate::ops::RelateArg {
2248                rel_type: "USES".to_string(),
2249                to: stub_target.clone(),
2250                description: None,
2251            }],
2252            dry_run: false,
2253        };
2254        let item2 = UpdateEntityArgs {
2255            anchors: Vec::new(),
2256            id: EntityId::new("specs", "nonexistent"),
2257            expected_hash: None,
2258            sections: IndexMap::from_iter([("identity".to_string(), "x".to_string())]),
2259            append_sections: IndexMap::new(),
2260            patch_sections: IndexMap::new(),
2261            metadata: IndexMap::new(),
2262            metadata_unset: Vec::new(),
2263            declare_relations: Vec::new(),
2264            dry_run: false,
2265            relations_unset: Vec::new(),
2266            anchors_unset: Vec::new(),
2267        };
2268
2269        // Sanity: the would-be stub does not exist before the batch.
2270        assert!(engine.get_entity(&stub_target).is_none());
2271
2272        let result = engine
2273            .batch_update(
2274                vec![(item1, None), (item2, None)],
2275                actor,
2276                Some(&client),
2277                false,
2278            )
2279            .unwrap();
2280        assert!(!result.applied, "missing item 2 must refuse the batch");
2281
2282        // The auto-stub item 1 created during preparation was rolled
2283        // back with the store snapshot — no orphaned stub survives.
2284        assert!(
2285            engine.get_entity(&stub_target).is_none(),
2286            "refused batch must roll the in-memory auto-stub back out of the store",
2287        );
2288        // The anchor's relation set is unchanged too.
2289        let anchor = engine.get_entity(&a.id).unwrap();
2290        assert!(
2291            !anchor.relationships.iter().any(|r| r.target == stub_target),
2292            "refused batch must not leave the declared relation on the anchor",
2293        );
2294    }
2295
2296    #[test]
2297    fn update_entity_replaces_a_section_and_logs_provenance() {
2298        let tmp = TempDir::new().unwrap();
2299        let (mut engine, seeded) = engine_with_seed(&tmp, "Updatable");
2300        let (actor, client) = cli_actor();
2301
2302        let mut sections = IndexMap::new();
2303        sections.insert("identity".to_string(), "Updated body.".to_string());
2304
2305        let outcome = engine
2306            .update_entity(
2307                UpdateEntityArgs {
2308                    anchors: Vec::new(),
2309                    id: seeded.id.clone(),
2310                    expected_hash: Some(seeded.content_hash.clone()),
2311                    sections,
2312                    append_sections: IndexMap::new(),
2313                    patch_sections: IndexMap::new(),
2314                    metadata: IndexMap::new(),
2315                    metadata_unset: Vec::new(),
2316                    declare_relations: Vec::new(),
2317                    dry_run: false,
2318                    relations_unset: Vec::new(),
2319                    anchors_unset: Vec::new(),
2320                },
2321                actor,
2322                Some(&client),
2323                Some("section update"),
2324            )
2325            .unwrap();
2326
2327        assert_eq!(
2328            outcome.modified_sections.replaced,
2329            vec!["identity".to_string()]
2330        );
2331        assert_ne!(
2332            outcome.content_hash, seeded.content_hash,
2333            "hash must change"
2334        );
2335        // Store carries the new content.
2336        let entity = engine.get_entity(&seeded.id).unwrap();
2337        assert!(
2338            entity
2339                .sections
2340                .get("identity")
2341                .unwrap()
2342                .contains("Updated body.")
2343        );
2344        // Provenance log records the update.
2345        let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
2346        assert!(log.contains("\"kind\":\"update\""));
2347        assert!(log.contains("\"note\":\"section update\""));
2348    }
2349
2350    #[test]
2351    fn update_entity_rejects_hash_mismatch() {
2352        let tmp = TempDir::new().unwrap();
2353        let (mut engine, seeded) = engine_with_seed(&tmp, "Hash Guarded");
2354        let (actor, client) = cli_actor();
2355        let err = engine
2356            .update_entity(
2357                UpdateEntityArgs {
2358                    anchors: Vec::new(),
2359                    id: seeded.id.clone(),
2360                    expected_hash: Some("wrong-hash".to_string()),
2361                    sections: IndexMap::new(),
2362                    append_sections: IndexMap::new(),
2363                    patch_sections: IndexMap::new(),
2364                    metadata: IndexMap::new(),
2365                    metadata_unset: Vec::new(),
2366                    declare_relations: Vec::new(),
2367                    dry_run: false,
2368                    relations_unset: Vec::new(),
2369                    anchors_unset: Vec::new(),
2370                },
2371                actor,
2372                Some(&client),
2373                None,
2374            )
2375            .unwrap_err();
2376        match err {
2377            EngineError::HashMismatch {
2378                id,
2379                current,
2380                is_stub,
2381            } => {
2382                assert_eq!(id, seeded.id.to_string());
2383                assert_eq!(current, seeded.content_hash);
2384                assert!(!is_stub, "real entity must not flag as stub");
2385            }
2386            other => panic!("expected HashMismatch, got {other:?}"),
2387        }
2388    }
2389
2390    #[test]
2391    fn update_entity_rejects_unknown_id() {
2392        let tmp = TempDir::new().unwrap();
2393        let (mut engine, _) = engine_with_seed(&tmp, "Anchor");
2394        let (actor, client) = cli_actor();
2395        let err = engine
2396            .update_entity(
2397                UpdateEntityArgs {
2398                    anchors: Vec::new(),
2399                    id: crate::EntityId::new("specs", "ghost"),
2400                    expected_hash: None,
2401                    sections: IndexMap::new(),
2402                    append_sections: IndexMap::new(),
2403                    patch_sections: IndexMap::new(),
2404                    metadata: IndexMap::new(),
2405                    metadata_unset: Vec::new(),
2406                    declare_relations: Vec::new(),
2407                    dry_run: false,
2408                    relations_unset: Vec::new(),
2409                    anchors_unset: Vec::new(),
2410                },
2411                actor,
2412                Some(&client),
2413                None,
2414            )
2415            .unwrap_err();
2416        assert!(matches!(err, EngineError::NotFound { .. }));
2417    }
2418
2419    #[test]
2420    fn update_entity_rejects_read_only_mount() {
2421        let tmp = TempDir::new().unwrap();
2422        let archive_path = build_archive(
2423            tmp.path(),
2424            "ext",
2425            &[(
2426                "a.md",
2427                b"---\ntype: spec\n---\n# A\n\n## Identity\n\nbody.\n",
2428            )],
2429        );
2430        let mut engine = Engine::from_mounts(vec![(
2431            archive_mount("external", archive_path.clone()),
2432            Box::new(ArchiveBackend::new(archive_path)),
2433        )])
2434        .unwrap();
2435        let (actor, client) = cli_actor();
2436        let id = crate::EntityId::new("external", "a");
2437        let err = engine
2438            .update_entity(
2439                UpdateEntityArgs {
2440                    anchors: Vec::new(),
2441                    id,
2442                    expected_hash: None,
2443                    sections: IndexMap::new(),
2444                    append_sections: IndexMap::new(),
2445                    patch_sections: IndexMap::new(),
2446                    metadata: IndexMap::new(),
2447                    metadata_unset: Vec::new(),
2448                    declare_relations: Vec::new(),
2449                    dry_run: false,
2450                    relations_unset: Vec::new(),
2451                    anchors_unset: Vec::new(),
2452                },
2453                actor,
2454                Some(&client),
2455                None,
2456            )
2457            .unwrap_err();
2458        assert!(matches!(err, EngineError::ReadOnlyMount(v) if v == "external"));
2459    }
2460
2461    #[test]
2462    fn update_entity_patches_section_with_find_and_replace() {
2463        let tmp = TempDir::new().unwrap();
2464        let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Subject");
2465        let (actor, client) = cli_actor();
2466
2467        // Pre-write a known body via the replace path so the patch
2468        // test has a deterministic substring to target.
2469        let mut replace = IndexMap::new();
2470        replace.insert("identity".to_string(), "hello world hello".to_string());
2471        let replaced = engine
2472            .update_entity(
2473                UpdateEntityArgs {
2474                    anchors: Vec::new(),
2475                    id: seeded.id.clone(),
2476                    expected_hash: Some(seeded.content_hash.clone()),
2477                    sections: replace,
2478                    append_sections: IndexMap::new(),
2479                    patch_sections: IndexMap::new(),
2480                    metadata: IndexMap::new(),
2481                    metadata_unset: Vec::new(),
2482                    declare_relations: Vec::new(),
2483                    dry_run: false,
2484                    relations_unset: Vec::new(),
2485                    anchors_unset: Vec::new(),
2486                },
2487                actor,
2488                Some(&client),
2489                None,
2490            )
2491            .unwrap();
2492
2493        // First-occurrence patch (all = false).
2494        let mut patches = IndexMap::new();
2495        patches.insert(
2496            "identity".to_string(),
2497            crate::ops::PatchArg {
2498                old: "hello".to_string(),
2499                new: "HI".to_string(),
2500                all: false,
2501            },
2502        );
2503        let outcome = engine
2504            .update_entity(
2505                UpdateEntityArgs {
2506                    anchors: Vec::new(),
2507                    id: seeded.id.clone(),
2508                    expected_hash: Some(replaced.content_hash.clone()),
2509                    sections: IndexMap::new(),
2510                    append_sections: IndexMap::new(),
2511                    patch_sections: patches,
2512                    metadata: IndexMap::new(),
2513                    metadata_unset: Vec::new(),
2514                    declare_relations: Vec::new(),
2515                    dry_run: false,
2516                    relations_unset: Vec::new(),
2517                    anchors_unset: Vec::new(),
2518                },
2519                actor,
2520                Some(&client),
2521                None,
2522            )
2523            .unwrap();
2524        assert_eq!(outcome.modified_sections.patched, vec!["identity"]);
2525        let body = engine
2526            .get_entity(&seeded.id)
2527            .unwrap()
2528            .sections
2529            .get("identity")
2530            .unwrap()
2531            .clone();
2532        assert!(body.contains("HI world hello"), "first-only: {body:?}");
2533    }
2534
2535    #[test]
2536    fn update_entity_patch_rejects_missing_old_substring() {
2537        let tmp = TempDir::new().unwrap();
2538        let (mut engine, seeded) = engine_with_seed(&tmp, "Patch Miss");
2539        let (actor, client) = cli_actor();
2540        let mut patches = IndexMap::new();
2541        patches.insert(
2542            "identity".to_string(),
2543            crate::ops::PatchArg {
2544                old: "this-substring-does-not-exist".to_string(),
2545                new: "nope".to_string(),
2546                all: false,
2547            },
2548        );
2549        let err = engine
2550            .update_entity(
2551                UpdateEntityArgs {
2552                    anchors: Vec::new(),
2553                    id: seeded.id.clone(),
2554                    expected_hash: Some(seeded.content_hash.clone()),
2555                    sections: IndexMap::new(),
2556                    append_sections: IndexMap::new(),
2557                    patch_sections: patches,
2558                    metadata: IndexMap::new(),
2559                    metadata_unset: Vec::new(),
2560                    declare_relations: Vec::new(),
2561                    dry_run: false,
2562                    relations_unset: Vec::new(),
2563                    anchors_unset: Vec::new(),
2564                },
2565                actor,
2566                Some(&client),
2567                None,
2568            )
2569            .unwrap_err();
2570        match err {
2571            EngineError::PatchOldNotFound { section, .. } => {
2572                assert_eq!(section, "identity");
2573            }
2574            other => panic!("expected PatchOldNotFound, got {other:?}"),
2575        }
2576    }
2577
2578    #[test]
2579    fn update_entity_appends_to_existing_section_with_newline_separator() {
2580        let tmp = TempDir::new().unwrap();
2581        let (mut engine, seeded) = engine_with_seed(&tmp, "Append Subject");
2582        let (actor, client) = cli_actor();
2583
2584        let mut appends = IndexMap::new();
2585        appends.insert("identity".to_string(), "appended tail.".to_string());
2586
2587        let outcome = engine
2588            .update_entity(
2589                UpdateEntityArgs {
2590                    anchors: Vec::new(),
2591                    id: seeded.id.clone(),
2592                    expected_hash: Some(seeded.content_hash.clone()),
2593                    sections: IndexMap::new(),
2594                    append_sections: appends,
2595                    patch_sections: IndexMap::new(),
2596                    metadata: IndexMap::new(),
2597                    metadata_unset: Vec::new(),
2598                    declare_relations: Vec::new(),
2599                    dry_run: false,
2600                    relations_unset: Vec::new(),
2601                    anchors_unset: Vec::new(),
2602                },
2603                actor,
2604                Some(&client),
2605                None,
2606            )
2607            .unwrap();
2608
2609        // modified_sections.appended carries the append key;
2610        // modified_sections.replaced stays empty.
2611        assert_eq!(outcome.modified_sections.appended, vec!["identity"]);
2612        assert!(outcome.modified_sections.replaced.is_empty());
2613
2614        // The section body now contains the appended tail.
2615        let updated = engine.get_entity(&seeded.id).unwrap();
2616        let body = updated.sections.get("identity").expect("identity section");
2617        assert!(
2618            body.contains("appended tail."),
2619            "appended body missing: {body:?}"
2620        );
2621    }
2622
2623    /// Item 02: `memstead_update` against a stub must surface a typed
2624    /// `StubNotUpdatable` envelope rather than the pre-fix
2625    /// `UnknownType { name: "" }` cascade. Mirrors the
2626    /// `StubCannotRelate` guard that `memstead_relate` already runs;
2627    /// before Item 02 the docstring list advertised the
2628    /// `STUB_NOT_UPDATABLE` code but no engine path emitted it.
2629    #[test]
2630    fn update_entity_against_stub_surfaces_typed_stub_not_updatable() {
2631        let tmp = TempDir::new().unwrap();
2632        let (mut engine, source) = engine_with_seed(&tmp, "Source");
2633        let (actor, client) = cli_actor();
2634        // Materialise a stub by relating from a real entity to an
2635        // absent target. The relate path upserts the stub.
2636        let stub_id = crate::EntityId::new("specs", "stub-update-target");
2637        engine
2638            .relate_entity(
2639                RelateEntityArgs {
2640                    source: source.id.clone(),
2641                    expected_hash: Some(source.content_hash.clone()),
2642                    rel_type: "USES".to_string(),
2643                    target: stub_id.clone(),
2644                    remove: false,
2645                    description: None,
2646                    dry_run: false,
2647                },
2648                actor,
2649                Some(&client),
2650                None,
2651            )
2652            .unwrap();
2653
2654        let err = engine
2655            .update_entity(
2656                UpdateEntityArgs {
2657                    anchors: Vec::new(),
2658                    id: stub_id.clone(),
2659                    expected_hash: Some(String::new()),
2660                    sections: IndexMap::from_iter([("identity".to_string(), "body".to_string())]),
2661                    append_sections: IndexMap::new(),
2662                    patch_sections: IndexMap::new(),
2663                    metadata: IndexMap::new(),
2664                    metadata_unset: Vec::new(),
2665                    declare_relations: Vec::new(),
2666                    dry_run: false,
2667                    relations_unset: Vec::new(),
2668                    anchors_unset: Vec::new(),
2669                },
2670                actor,
2671                Some(&client),
2672                None,
2673            )
2674            .unwrap_err();
2675        match err {
2676            EngineError::StubNotUpdatable { id } => assert_eq!(id, stub_id.to_string()),
2677            other => panic!("expected StubNotUpdatable, got {other:?}"),
2678        }
2679    }
2680
2681    #[test]
2682    fn update_entity_rejects_conflicting_section_modes() {
2683        let tmp = TempDir::new().unwrap();
2684        let (mut engine, seeded) = engine_with_seed(&tmp, "Conflict");
2685        let (actor, client) = cli_actor();
2686
2687        let mut sections = IndexMap::new();
2688        sections.insert("identity".to_string(), "replace".to_string());
2689        let mut appends = IndexMap::new();
2690        appends.insert("identity".to_string(), "append".to_string());
2691
2692        let err = engine
2693            .update_entity(
2694                UpdateEntityArgs {
2695                    anchors: Vec::new(),
2696                    id: seeded.id.clone(),
2697                    expected_hash: Some(seeded.content_hash.clone()),
2698                    sections,
2699                    append_sections: appends,
2700                    patch_sections: IndexMap::new(),
2701                    metadata: IndexMap::new(),
2702                    metadata_unset: Vec::new(),
2703                    declare_relations: Vec::new(),
2704                    dry_run: false,
2705                    relations_unset: Vec::new(),
2706                    anchors_unset: Vec::new(),
2707                },
2708                actor,
2709                Some(&client),
2710                None,
2711            )
2712            .unwrap_err();
2713
2714        match err {
2715            EngineError::ConflictingSectionModes { section, modes } => {
2716                assert_eq!(section, "identity");
2717                assert_eq!(modes, vec!["sections", "append_sections"]);
2718            }
2719            other => panic!("expected ConflictingSectionModes, got {other:?}"),
2720        }
2721    }
2722
2723    #[test]
2724    fn update_entity_rejects_overlapping_metadata_and_metadata_unset_keys() {
2725        // Wire contract: setting and unsetting the same key is a hard
2726        // error. The check runs before the required-field check so the
2727        // resolution (pick one map) is unambiguous regardless of
2728        // whether the conflicting key is required.
2729        let tmp = TempDir::new().unwrap();
2730        let (mut engine, seeded) = engine_with_seed(&tmp, "Overlap Subject");
2731        let (actor, client) = cli_actor();
2732
2733        let mut metadata = IndexMap::new();
2734        // `tags` is a non-required field on the default `spec` schema —
2735        // so this conflict is purely about the overlap, not about
2736        // unsetting-a-required-field.
2737        metadata.insert("tags".to_string(), "foo".to_string());
2738
2739        let err = engine
2740            .update_entity(
2741                UpdateEntityArgs {
2742                    anchors: Vec::new(),
2743                    id: seeded.id.clone(),
2744                    expected_hash: Some(seeded.content_hash.clone()),
2745                    sections: IndexMap::new(),
2746                    append_sections: IndexMap::new(),
2747                    patch_sections: IndexMap::new(),
2748                    metadata,
2749                    metadata_unset: vec!["tags".to_string()],
2750                    declare_relations: Vec::new(),
2751                    dry_run: false,
2752                    relations_unset: Vec::new(),
2753                    anchors_unset: Vec::new(),
2754                },
2755                actor,
2756                Some(&client),
2757                None,
2758            )
2759            .unwrap_err();
2760        match err {
2761            EngineError::SetAndUnsetConflict { keys } => {
2762                assert_eq!(keys, vec!["tags".to_string()]);
2763            }
2764            other => panic!("expected SetAndUnsetConflict, got {other:?}"),
2765        }
2766    }
2767
2768    #[test]
2769    fn update_entity_pointer_schema_auto_synthesises_references_from_body_link() {
2770        // Under the default schema's `alias_target_rel_type: REFERENCES`
2771        // pointer, a body wiki-link no longer trips the strict validator
2772        // — the alias-synthesis pass emits the REFERENCES relation
2773        // first, the validator finds the link backed, the body lands.
2774        // (Schemas without the pointer continue to refuse with
2775        // `WIKILINK_WITHOUT_RELATION`; that path is covered by the
2776        // dedicated no-pointer fixture test elsewhere.)
2777        use crate::EntityId;
2778        use crate::engine::UpdateEntityArgs;
2779        use indexmap::IndexMap;
2780        use tempfile::TempDir;
2781
2782        let tmp = TempDir::new().unwrap();
2783        let mem_dir = tmp.path().to_path_buf();
2784        let writer = FilesystemMemWriter::new(mem_dir.clone());
2785        let mut engine = Engine::from_mounts(vec![(
2786            folder_mount("specs", mem_dir.clone()),
2787            Box::new(writer) as Box<dyn MemBackend>,
2788        )])
2789        .unwrap();
2790        engine.set_workspace_root(mem_dir.clone());
2791        let (actor, client) = cli_actor();
2792
2793        let target = engine
2794            .create_entity(
2795                empty_create_args("specs", "Target"),
2796                actor,
2797                Some(&client),
2798                None,
2799            )
2800            .unwrap();
2801        let source = engine
2802            .create_entity(
2803                empty_create_args("specs", "Source"),
2804                actor,
2805                Some(&client),
2806                None,
2807            )
2808            .unwrap();
2809
2810        let mut sections: IndexMap<String, String> = IndexMap::new();
2811        sections.insert(
2812            "purpose".to_string(),
2813            "see [[target]] for context".to_string(),
2814        );
2815        let outcome = engine
2816            .update_entity(
2817                UpdateEntityArgs {
2818                    anchors: Vec::new(),
2819                    id: source.id.clone(),
2820                    expected_hash: Some(source.content_hash.clone()),
2821                    sections,
2822                    append_sections: IndexMap::new(),
2823                    patch_sections: IndexMap::new(),
2824                    metadata: IndexMap::new(),
2825                    metadata_unset: Vec::new(),
2826                    declare_relations: Vec::new(),
2827                    dry_run: false,
2828                    relations_unset: Vec::new(),
2829                    anchors_unset: Vec::new(),
2830                },
2831                actor,
2832                Some(&client),
2833                None,
2834            )
2835            .expect("auto-synthesis must satisfy the alias-existence invariant");
2836        // Body landed.
2837        assert!(
2838            outcome
2839                .modified_sections
2840                .replaced
2841                .iter()
2842                .any(|s| s == "purpose"),
2843        );
2844        let in_mem = engine.get_entity(&source.id).unwrap();
2845        assert_eq!(
2846            in_mem
2847                .sections
2848                .get("purpose")
2849                .map(String::as_str)
2850                .unwrap_or(""),
2851            "see [[target]] for context",
2852        );
2853        // REFERENCES relation synthesised from the body wiki-link.
2854        assert!(
2855            in_mem
2856                .relationships
2857                .iter()
2858                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2859            "synthesis must emit REFERENCES → target; relationships: {:?}",
2860            in_mem.relationships,
2861        );
2862        // Defeat unused-import warnings for the helper imports.
2863        let _ = EntityId::new("specs", "x");
2864    }
2865
2866    #[test]
2867    fn update_entity_declare_relations_passes_strict_validator_in_one_call() {
2868        // The agent declares the relation + adds the body wiki-link
2869        // in a single `memstead_update` call. Without
2870        // `declare_relations`, the strict validator would refuse
2871        // (no backing relation yet); with the batched declaration,
2872        // the relation lands *before* the strict validator runs so
2873        // the body link passes the gate.
2874        use crate::engine::UpdateEntityArgs;
2875        use crate::ops::RelateArg;
2876        use indexmap::IndexMap;
2877        use tempfile::TempDir;
2878
2879        let tmp = TempDir::new().unwrap();
2880        let mem_dir = tmp.path().to_path_buf();
2881        let writer = FilesystemMemWriter::new(mem_dir.clone());
2882        let mut engine = Engine::from_mounts(vec![(
2883            folder_mount("specs", mem_dir.clone()),
2884            Box::new(writer) as Box<dyn MemBackend>,
2885        )])
2886        .unwrap();
2887        engine.set_workspace_root(mem_dir.clone());
2888        let (actor, client) = cli_actor();
2889
2890        let target = engine
2891            .create_entity(
2892                empty_create_args("specs", "Target"),
2893                actor,
2894                Some(&client),
2895                None,
2896            )
2897            .unwrap();
2898        let source = engine
2899            .create_entity(
2900                empty_create_args("specs", "Source"),
2901                actor,
2902                Some(&client),
2903                None,
2904            )
2905            .unwrap();
2906
2907        // Atomic declare + body update. USES (not REFERENCES) — under
2908        // the default schema's `alias_target_rel_type: REFERENCES`
2909        // pointer, explicit declare_relations type=REFERENCES is
2910        // refused; the body wiki-link is auto-emitted via synthesis.
2911        // The test's intent — that declare_relations atomically lands
2912        // alongside body changes — holds for any rel-type that admits
2913        // explicit authoring.
2914        let mut sections: IndexMap<String, String> = IndexMap::new();
2915        sections.insert(
2916            "purpose".to_string(),
2917            "see [[target]] for context".to_string(),
2918        );
2919        let outcome = engine
2920            .update_entity(
2921                UpdateEntityArgs {
2922                    anchors: Vec::new(),
2923                    relations_unset: Vec::new(),
2924                    anchors_unset: Vec::new(),
2925                    id: source.id.clone(),
2926                    expected_hash: Some(source.content_hash.clone()),
2927                    sections,
2928                    append_sections: IndexMap::new(),
2929                    patch_sections: IndexMap::new(),
2930                    metadata: IndexMap::new(),
2931                    metadata_unset: Vec::new(),
2932                    dry_run: false,
2933                    declare_relations: vec![RelateArg {
2934                        rel_type: "USES".to_string(),
2935                        to: target.id.clone(),
2936                        description: None,
2937                    }],
2938                },
2939                actor,
2940                Some(&client),
2941                None,
2942            )
2943            .expect("declare_relations + body update must succeed in one call");
2944
2945        assert_eq!(outcome.relations_declared.len(), 1);
2946        assert_eq!(outcome.relations_declared[0].rel_type, "USES");
2947        assert_eq!(outcome.relations_declared[0].target, target.id);
2948        assert!(
2949            !outcome.relations_declared[0].target_was_stubbed,
2950            "target was already present in store; target_was_stubbed must be false"
2951        );
2952
2953        let in_mem = engine.get_entity(&source.id).unwrap();
2954        assert!(
2955            in_mem.relationships.iter().any(|r| r.target == target.id),
2956            "declared relation must land in entity.relationships; got {:?}",
2957            in_mem.relationships
2958        );
2959    }
2960
2961    #[test]
2962    fn update_entity_declare_relations_auto_stubs_absent_target() {
2963        // When the declared target doesn't exist yet, the engine
2964        // auto-stubs it (same mechanic as `memstead_relate`) and flags
2965        // `target_was_stubbed: true` in the outcome.
2966        use crate::EntityId;
2967        use crate::engine::UpdateEntityArgs;
2968        use crate::ops::RelateArg;
2969        use indexmap::IndexMap;
2970
2971        let tmp = TempDir::new().unwrap();
2972        let (mut engine, source) = engine_with_seed(&tmp, "Source");
2973        let (actor, client) = cli_actor();
2974        let absent_target = EntityId::new("specs", "not-yet-existing");
2975        assert!(!engine.store().contains(&absent_target));
2976
2977        let outcome = engine
2978            .update_entity(
2979                UpdateEntityArgs {
2980                    anchors: Vec::new(),
2981                    relations_unset: Vec::new(),
2982                    anchors_unset: Vec::new(),
2983                    id: source.id.clone(),
2984                    expected_hash: Some(source.content_hash.clone()),
2985                    sections: IndexMap::new(),
2986                    append_sections: IndexMap::new(),
2987                    patch_sections: IndexMap::new(),
2988                    metadata: IndexMap::new(),
2989                    metadata_unset: Vec::new(),
2990                    dry_run: false,
2991                    declare_relations: vec![RelateArg {
2992                        rel_type: "USES".to_string(),
2993                        to: absent_target.clone(),
2994                        description: None,
2995                    }],
2996                },
2997                actor,
2998                Some(&client),
2999                None,
3000            )
3001            .unwrap();
3002
3003        assert_eq!(outcome.relations_declared.len(), 1);
3004        assert!(
3005            outcome.relations_declared[0].target_was_stubbed,
3006            "absent target must be auto-stubbed; got target_was_stubbed=false"
3007        );
3008        // Stub now exists in the store.
3009        assert!(engine.store().contains(&absent_target));
3010        let stub = engine.get_entity(&absent_target).unwrap();
3011        assert!(stub.stub);
3012    }
3013
3014    #[test]
3015    fn update_entity_alias_synthesis_runs_unconditionally_for_pointer_schemas() {
3016        // Under the alias model with a pointer-set schema (default
3017        // schema's `alias_target_rel_type: REFERENCES`), a fresh
3018        // workspace's first body-wiki-link write triggers the
3019        // alias-synthesis pass and the mutation lands with the
3020        // REFERENCES relation auto-emitted.
3021        use crate::engine::UpdateEntityArgs;
3022        use indexmap::IndexMap;
3023        use tempfile::TempDir;
3024
3025        let tmp = TempDir::new().unwrap();
3026        let mem_dir = tmp.path().to_path_buf();
3027        let writer = FilesystemMemWriter::new(mem_dir.clone());
3028        let mut engine = Engine::from_mounts(vec![(
3029            folder_mount("specs", mem_dir.clone()),
3030            Box::new(writer) as Box<dyn MemBackend>,
3031        )])
3032        .unwrap();
3033        engine.set_workspace_root(mem_dir.clone());
3034        let (actor, client) = cli_actor();
3035        let target = engine
3036            .create_entity(
3037                empty_create_args("specs", "Target"),
3038                actor,
3039                Some(&client),
3040                None,
3041            )
3042            .unwrap();
3043        let source = engine
3044            .create_entity(
3045                empty_create_args("specs", "Source"),
3046                actor,
3047                Some(&client),
3048                None,
3049            )
3050            .unwrap();
3051
3052        let mut sections: IndexMap<String, String> = IndexMap::new();
3053        sections.insert(
3054            "purpose".to_string(),
3055            "see [[target]] for context".to_string(),
3056        );
3057        engine
3058            .update_entity(
3059                UpdateEntityArgs {
3060                    anchors: Vec::new(),
3061                    id: source.id.clone(),
3062                    expected_hash: Some(source.content_hash.clone()),
3063                    sections,
3064                    append_sections: IndexMap::new(),
3065                    patch_sections: IndexMap::new(),
3066                    metadata: IndexMap::new(),
3067                    metadata_unset: Vec::new(),
3068                    declare_relations: Vec::new(),
3069                    dry_run: false,
3070                    relations_unset: Vec::new(),
3071                    anchors_unset: Vec::new(),
3072                },
3073                actor,
3074                Some(&client),
3075                None,
3076            )
3077            .expect("synthesis must back the wiki-link and let the body land");
3078        let in_mem = engine.get_entity(&source.id).unwrap();
3079        assert!(
3080            in_mem
3081                .relationships
3082                .iter()
3083                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3084            "synthesis must emit REFERENCES → target; relationships: {:?}",
3085            in_mem.relationships,
3086        );
3087    }
3088
3089    #[test]
3090    fn update_entity_dry_run_returns_prospective_hash_without_writing() {
3091        let tmp = TempDir::new().unwrap();
3092        let (mut engine, seeded) = engine_with_seed(&tmp, "Preview Subject");
3093        let (actor, client) = cli_actor();
3094        let original_hash = seeded.content_hash.clone();
3095
3096        let mut sections = IndexMap::new();
3097        sections.insert("identity".to_string(), "preview body".to_string());
3098
3099        let outcome = engine
3100            .update_entity(
3101                UpdateEntityArgs {
3102                    anchors: Vec::new(),
3103                    id: seeded.id.clone(),
3104                    // Stale-hash recovery path — dry_run skips the
3105                    // hash check, so a wrong expected_hash is OK.
3106                    expected_hash: Some("wrong-hash".to_string()),
3107                    sections,
3108                    append_sections: IndexMap::new(),
3109                    patch_sections: IndexMap::new(),
3110                    metadata: IndexMap::new(),
3111                    metadata_unset: Vec::new(),
3112                    declare_relations: Vec::new(),
3113                    dry_run: true,
3114                    relations_unset: Vec::new(),
3115                    anchors_unset: Vec::new(),
3116                },
3117                actor,
3118                Some(&client),
3119                None,
3120            )
3121            .unwrap();
3122
3123        // Wire shape: content_hash = current; prospective_hash =
3124        // what the write would produce; commit_sha empty.
3125        assert_eq!(outcome.content_hash, original_hash);
3126        let prospective = outcome
3127            .prospective_hash
3128            .expect("prospective_hash populated on dry_run");
3129        assert_ne!(prospective, original_hash);
3130        assert!(outcome.commit_sha.is_empty());
3131        // Store entity unchanged.
3132        let store_entity = engine.get_entity(&seeded.id).unwrap();
3133        assert_eq!(store_entity.content_hash, original_hash);
3134    }
3135
3136    /// Edge-count round-trip lock. Captures the REFERENCES-drift
3137    /// finding:
3138    /// a mutation cycle (create body wiki-links → relate → update
3139    /// body → rename → delete) must return both `total_edges` and the
3140    /// REFERENCES counter to the pre-cycle values exactly. The bug
3141    /// was in `push_entities_into_store`: `upsert` preserved the
3142    /// entity's pre-existing out-edges, so `add_edge` (idempotent on
3143    /// `(from, to, rel_type)`) couldn't remove edges that the new
3144    /// parse no longer emits. Dropping a wiki-link from a body or
3145    /// absorbing one into an explicit relationship leaked the stale
3146    /// REFERENCES edge.
3147    ///
3148    /// Under the alias model the leak is structurally impossible —
3149    /// body wiki-links no longer emit edges, so the cleanup-on-reparse
3150    /// path the original test exercised has no premise. The test
3151    /// keeps the CRUD cycle but routes edges through atomic
3152    /// `relations:` declarations and explicit `memstead_relate`, which is
3153    /// what the model now treats as the only edge source.
3154    #[test]
3155    fn references_edges_round_trip_across_full_crud_cycle() {
3156        let tmp = TempDir::new().unwrap();
3157        let mem_dir = tmp.path().to_path_buf();
3158        let writer = FilesystemMemWriter::new(mem_dir.clone());
3159        let mut engine = Engine::from_mounts(vec![(
3160            folder_mount("specs", mem_dir),
3161            Box::new(writer) as Box<dyn MemBackend>,
3162        )])
3163        .unwrap();
3164        let (actor, client) = cli_actor();
3165
3166        // Seed two link targets so the wiki-links inside the
3167        // probe entity's body resolve to real entities (not auto-
3168        // stubs we'd then have to GC).
3169        let foo = engine
3170            .create_entity(
3171                empty_create_args("specs", "Foo"),
3172                actor,
3173                Some(&client),
3174                None,
3175            )
3176            .unwrap();
3177        let bar = engine
3178            .create_entity(
3179                empty_create_args("specs", "Bar"),
3180                actor,
3181                Some(&client),
3182                None,
3183            )
3184            .unwrap();
3185
3186        let count_references = |engine: &Engine| -> usize {
3187            engine
3188                .store()
3189                .all_ids()
3190                .flat_map(|id| engine.store().outgoing(id))
3191                .filter(|e| e.rel_type == "REFERENCES")
3192                .count()
3193        };
3194
3195        let baseline_edges = engine.store().edge_count();
3196        let baseline_refs = count_references(&engine);
3197
3198        // Step 1: create entity with body wiki-links — the
3199        // alias-synthesis pass auto-emits one REFERENCES per body
3200        // wiki-link (default schema's `alias_target_rel_type` →
3201        // REFERENCES), so the explicit `relations:` slot stays
3202        // empty. Net: 2 REFERENCES.
3203        let mut sections = IndexMap::new();
3204        sections.insert(
3205            "identity".to_string(),
3206            "See [[foo]] and [[bar]] inline.".to_string(),
3207        );
3208        sections.insert("purpose".to_string(), "probe purpose".to_string());
3209        let probe = engine
3210            .create_entity(
3211                CreateEntityArgs {
3212                    anchors: Vec::new(),
3213                    mem: "specs".to_string(),
3214                    title: "Probe".to_string(),
3215                    entity_type: "spec".to_string(),
3216                    sections,
3217                    metadata: IndexMap::new(),
3218                    relations: Vec::new(),
3219                    dry_run: false,
3220                },
3221                actor,
3222                Some(&client),
3223                None,
3224            )
3225            .unwrap();
3226        assert_eq!(count_references(&engine), baseline_refs + 2);
3227
3228        // Step 2: relate INFORMED_BY → foo as a second relation to the
3229        // same target. The body wiki-link `[[foo]]` aliases the set of
3230        // relations to foo, so adding INFORMED_BY does not affect the
3231        // REFERENCES count — both relations coexist.
3232        let relate1 = engine
3233            .relate_entity(
3234                RelateEntityArgs {
3235                    source: probe.id.clone(),
3236                    expected_hash: Some(probe.content_hash.clone()),
3237                    rel_type: "INFORMED_BY".to_string(),
3238                    target: foo.id.clone(),
3239                    remove: false,
3240                    description: None,
3241                    dry_run: false,
3242                },
3243                actor,
3244                Some(&client),
3245                None,
3246            )
3247            .unwrap();
3248        assert_eq!(
3249            count_references(&engine),
3250            baseline_refs + 2,
3251            "set-membership aliasing — adding INFORMED_BY does not \
3252             absorb the REFERENCES relation"
3253        );
3254
3255        // Step 3: drop the [[bar]] body link. The alias-synthesis pass
3256        // GCs the synthesised REFERENCES → bar atomically with the
3257        // body update — no second `memstead_relate --remove` needed.
3258        let mut sections = IndexMap::new();
3259        sections.insert("identity".to_string(), "See [[foo]] inline.".to_string());
3260        let updated = engine
3261            .update_entity(
3262                UpdateEntityArgs {
3263                    anchors: Vec::new(),
3264                    id: probe.id.clone(),
3265                    expected_hash: Some(relate1.content_hash.clone()),
3266                    sections,
3267                    append_sections: IndexMap::new(),
3268                    patch_sections: IndexMap::new(),
3269                    metadata: IndexMap::new(),
3270                    metadata_unset: Vec::new(),
3271                    declare_relations: Vec::new(),
3272                    dry_run: false,
3273                    relations_unset: Vec::new(),
3274                    anchors_unset: Vec::new(),
3275                },
3276                actor,
3277                Some(&client),
3278                None,
3279            )
3280            .unwrap();
3281        assert_eq!(
3282            count_references(&engine),
3283            baseline_refs + 1,
3284            "REFERENCES → bar must be auto-GC'd when its body link drops"
3285        );
3286
3287        // Step 4: rename the entity. Edges follow via remove + push.
3288        let renamed = engine
3289            .rename_entity(
3290                crate::engine::RenameEntityArgs {
3291                    id: probe.id.clone(),
3292                    expected_hash: Some(updated.content_hash.clone()),
3293                    new_title: "Probe Renamed".to_string(),
3294                },
3295                actor,
3296                Some(&client),
3297                None,
3298            )
3299            .unwrap();
3300        assert_eq!(count_references(&engine), baseline_refs + 1);
3301
3302        // Step 5: delete the renamed entity. The INFORMED_BY → foo
3303        // edge cascades; REFERENCES count unchanged.
3304        engine
3305            .delete_entity(
3306                crate::engine::DeleteEntityArgs {
3307                    id: renamed.new_id.clone(),
3308                    expected_hash: Some(renamed.content_hash.clone()),
3309                },
3310                actor,
3311                Some(&client),
3312                None,
3313            )
3314            .unwrap();
3315
3316        // Final assertion: every counter back to baseline.
3317        assert_eq!(
3318            engine.store().edge_count(),
3319            baseline_edges,
3320            "total edges must round-trip to baseline"
3321        );
3322        assert_eq!(
3323            count_references(&engine),
3324            baseline_refs,
3325            "REFERENCES counter must round-trip to baseline"
3326        );
3327
3328        // Cross-check: a full reload of the mem produces the same
3329        // post-cycle counts. If the in-memory store and the on-disk
3330        // bytes drift, reload uncovers it.
3331        engine.reload_one_mem("specs").unwrap();
3332        assert_eq!(
3333            engine.store().edge_count(),
3334            baseline_edges,
3335            "total edges must match disk after reload"
3336        );
3337        assert_eq!(
3338            count_references(&engine),
3339            baseline_refs,
3340            "REFERENCES must match disk after reload"
3341        );
3342        // Sanity: foo + bar still in the store (they were not deleted).
3343        assert!(engine.store().contains(&foo.id));
3344        assert!(engine.store().contains(&bar.id));
3345    }
3346
3347    #[test]
3348    fn update_entity_returns_commit_sha_title_modified_date_warnings_shape() {
3349        let tmp = TempDir::new().unwrap();
3350        let (mut engine, seeded) = engine_with_seed(&tmp, "Subject");
3351        let (actor, client) = cli_actor();
3352
3353        let mut sections = IndexMap::new();
3354        sections.insert("identity".to_string(), "edited body".to_string());
3355
3356        let outcome = engine
3357            .update_entity(
3358                UpdateEntityArgs {
3359                    anchors: Vec::new(),
3360                    id: seeded.id.clone(),
3361                    expected_hash: Some(seeded.content_hash.clone()),
3362                    sections,
3363                    append_sections: IndexMap::new(),
3364                    patch_sections: IndexMap::new(),
3365                    metadata: IndexMap::new(),
3366                    metadata_unset: Vec::new(),
3367                    declare_relations: Vec::new(),
3368                    dry_run: false,
3369                    relations_unset: Vec::new(),
3370                    anchors_unset: Vec::new(),
3371                },
3372                actor,
3373                Some(&client),
3374                None,
3375            )
3376            .unwrap();
3377
3378        // Folder backend produces a synthetic CommitId.
3379        assert!(
3380            !outcome.commit_sha.is_empty(),
3381            "commit_sha must be populated on a real update"
3382        );
3383        // Title echoed from the parsed entity post-write.
3384        assert_eq!(outcome.title, "Subject");
3385        // The default `spec` schema declares `modified_date` with
3386        // `auto_timestamp: true`; the unified update path
3387        // auto-stamps it. Asserting non-empty pins the
3388        // wire-shape parity with full's UpdateResult.modified_date.
3389        assert!(
3390            !outcome.modified_date.is_empty(),
3391            "modified_date must be auto-stamped on update for the default spec schema",
3392        );
3393        // V1: warnings always empty (typed warning surfaces are
3394        // separate session work). The vec is present on the outcome
3395        // so the wire shape parity with full's UpdateResult holds.
3396        assert!(outcome.warnings.is_empty());
3397        // Section was modified (existing behaviour, sanity check).
3398        assert_eq!(
3399            outcome.modified_sections.replaced,
3400            vec!["identity".to_string()]
3401        );
3402    }
3403
3404    // ---- Engine::update_entity no-op detection ---------------------
3405
3406    /// Re-setting a
3407    /// section to its current on-disk value short-circuits to
3408    /// `UPDATE_NOOP` and preserves `last_modified` at its pre-call
3409    /// value. Pre-fix the auto-timestamp stamped `last_modified` to
3410    /// `today_iso()` before the bytes-compare ran; the stamp
3411    /// synthesised a delta and the no-op never matched.
3412    #[test]
3413    fn update_entity_noop_resetting_section_to_current_value_preserves_last_modified() {
3414        let tmp = TempDir::new().unwrap();
3415        let (mut engine, seeded) = engine_with_seed(&tmp, "Section Resetter");
3416        let (actor, client) = cli_actor();
3417
3418        // Read the pre-update `last_modified` so we can assert it
3419        // survives the no-op.
3420        let pre_last_modified = engine
3421            .get_entity(&seeded.id)
3422            .and_then(|e| e.metadata.get("last_modified"))
3423            .map(|v| v.to_frontmatter_string())
3424            .expect("seeded entity has last_modified");
3425
3426        // Re-set `identity` to its current on-disk body. The seed
3427        // helper writes "fixture identity body" — passing the same
3428        // string back must be a no-op.
3429        let mut sections = IndexMap::new();
3430        sections.insert("identity".to_string(), "fixture identity body".to_string());
3431        let outcome = engine
3432            .update_entity(
3433                UpdateEntityArgs {
3434                    anchors: Vec::new(),
3435                    id: seeded.id.clone(),
3436                    expected_hash: Some(seeded.content_hash.clone()),
3437                    sections,
3438                    append_sections: IndexMap::new(),
3439                    patch_sections: IndexMap::new(),
3440                    metadata: IndexMap::new(),
3441                    metadata_unset: Vec::new(),
3442                    declare_relations: Vec::new(),
3443                    dry_run: false,
3444                    relations_unset: Vec::new(),
3445                    anchors_unset: Vec::new(),
3446                },
3447                actor,
3448                Some(&client),
3449                None,
3450            )
3451            .unwrap();
3452
3453        assert_eq!(outcome.commit_sha, "", "no-op must not commit");
3454        assert_eq!(
3455            outcome.content_hash, seeded.content_hash,
3456            "no-op must not advance content_hash",
3457        );
3458        assert!(
3459            outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3460            "UPDATE_NOOP must fire on bytes-identical re-set",
3461        );
3462        assert_eq!(
3463            outcome.modified_date, pre_last_modified,
3464            "no-op must preserve last_modified at the pre-call value",
3465        );
3466        // The applied delta is empty on a
3467        // no-op — `modified_sections` must not claim `identity` was
3468        // replaced when nothing landed (matching the empty commit_sha
3469        // and unchanged hash above).
3470        assert!(
3471            outcome.modified_sections.replaced.is_empty()
3472                && outcome.modified_sections.appended.is_empty()
3473                && outcome.modified_sections.patched.is_empty(),
3474            "no-op must report an empty section delta, got {:?}",
3475            outcome.modified_sections,
3476        );
3477
3478        // The on-disk entity also still carries the pre-update
3479        // last_modified — the no-op didn't bump it through some
3480        // other path.
3481        let post_last_modified = engine
3482            .get_entity(&seeded.id)
3483            .and_then(|e| e.metadata.get("last_modified"))
3484            .map(|v| v.to_frontmatter_string())
3485            .expect("entity still in store");
3486        assert_eq!(post_last_modified, pre_last_modified);
3487    }
3488
3489    /// A payload with no
3490    /// recognised mutation content refuses with `EMPTY_UPDATE` BEFORE
3491    /// any engine work runs. Previously this same input short-
3492    /// circuited as a success-with-`UPDATE_NOOP`-warning, which
3493    /// hid a boundary-discipline failure mode (a
3494    /// misspelled mutation key deserialises to empty defaults and
3495    /// looks like a no-op success on the wire). The new refusal
3496    /// makes "no mutation content provided" structurally distinct
3497    /// from "mutation content provided but matched current state"
3498    /// (which still surfaces `UPDATE_NOOP` — see
3499    /// `update_entity_noop_same_content_surfaces_warning` below).
3500    #[test]
3501    fn update_entity_empty_payload_refuses_with_typed_code() {
3502        let tmp = TempDir::new().unwrap();
3503        let (mut engine, seeded) = engine_with_seed(&tmp, "Empty Payload");
3504        let (actor, client) = cli_actor();
3505
3506        let err = engine
3507            .update_entity(
3508                UpdateEntityArgs {
3509                    anchors: Vec::new(),
3510                    id: seeded.id.clone(),
3511                    expected_hash: Some(seeded.content_hash.clone()),
3512                    sections: IndexMap::new(),
3513                    append_sections: IndexMap::new(),
3514                    patch_sections: IndexMap::new(),
3515                    metadata: IndexMap::new(),
3516                    metadata_unset: Vec::new(),
3517                    declare_relations: Vec::new(),
3518                    dry_run: false,
3519                    relations_unset: Vec::new(),
3520                    anchors_unset: Vec::new(),
3521                },
3522                actor,
3523                Some(&client),
3524                None,
3525            )
3526            .unwrap_err();
3527        match err {
3528            EngineError::EmptyUpdate { id } => {
3529                assert_eq!(id, seeded.id.to_string());
3530            }
3531            other => panic!("expected EMPTY_UPDATE, got {other:?}"),
3532        }
3533        // No provenance row landed — the refusal preempts any write.
3534        let log_path = tmp.path().join(".memstead/changes.jsonl");
3535        if let Ok(log) = std::fs::read_to_string(&log_path) {
3536            let updates = log.matches("\"kind\":\"update\"").count();
3537            assert_eq!(updates, 0, "EMPTY_UPDATE refusal must not log an update");
3538        }
3539    }
3540
3541    /// Complement: a
3542    /// payload with mutation content that matches the current entity
3543    /// state continues to land as success-with-`UPDATE_NOOP`-warning.
3544    /// The new `EMPTY_UPDATE` refusal applies only when no mutation
3545    /// content was provided; this path is structurally distinct.
3546    #[test]
3547    fn update_entity_noop_same_content_surfaces_warning() {
3548        let tmp = TempDir::new().unwrap();
3549        let (mut engine, seeded) = engine_with_seed(&tmp, "Same Content Noop");
3550        let (actor, client) = cli_actor();
3551
3552        // `empty_create_args` seeds `identity` with this exact body.
3553        let mut sections = IndexMap::new();
3554        sections.insert("identity".to_string(), "fixture identity body".to_string());
3555
3556        let outcome = engine
3557            .update_entity(
3558                UpdateEntityArgs {
3559                    anchors: Vec::new(),
3560                    id: seeded.id.clone(),
3561                    expected_hash: Some(seeded.content_hash.clone()),
3562                    sections,
3563                    append_sections: IndexMap::new(),
3564                    patch_sections: IndexMap::new(),
3565                    metadata: IndexMap::new(),
3566                    metadata_unset: Vec::new(),
3567                    declare_relations: Vec::new(),
3568                    dry_run: false,
3569                    relations_unset: Vec::new(),
3570                    anchors_unset: Vec::new(),
3571                },
3572                actor,
3573                Some(&client),
3574                None,
3575            )
3576            .unwrap();
3577
3578        assert_eq!(outcome.commit_sha, "");
3579        assert_eq!(outcome.content_hash, seeded.content_hash);
3580        let codes: Vec<&str> = outcome.warnings.iter().map(|w| w.code()).collect();
3581        assert!(
3582            codes.contains(&"UPDATE_NOOP"),
3583            "same-content update must surface UPDATE_NOOP; got {codes:?}",
3584        );
3585    }
3586
3587    #[test]
3588    fn update_entity_noop_metadata_unset_on_absent_key() {
3589        // `metadata_unset=["never-set-key"]`
3590        // where the key was never set is a no-op — no field actually
3591        // changed, no commit advances, follow-up calls can chain
3592        // `expected_hash` without `HASH_MISMATCH`.
3593        let tmp = TempDir::new().unwrap();
3594        let (mut engine, seeded) = engine_with_seed(&tmp, "Absent Key Noop");
3595        let (actor, client) = cli_actor();
3596
3597        let outcome = engine
3598            .update_entity(
3599                UpdateEntityArgs {
3600                    anchors: Vec::new(),
3601                    id: seeded.id.clone(),
3602                    expected_hash: Some(seeded.content_hash.clone()),
3603                    sections: IndexMap::new(),
3604                    append_sections: IndexMap::new(),
3605                    patch_sections: IndexMap::new(),
3606                    metadata: IndexMap::new(),
3607                    // `tags` is declared on the `spec` schema but
3608                    // unset on the seeded entity. Unsetting it should
3609                    // be a no-op rather than producing a fresh commit.
3610                    metadata_unset: vec!["tags".to_string()],
3611                    declare_relations: Vec::new(),
3612                    dry_run: false,
3613                    relations_unset: Vec::new(),
3614                    anchors_unset: Vec::new(),
3615                },
3616                actor,
3617                Some(&client),
3618                None,
3619            )
3620            .unwrap();
3621
3622        assert_eq!(outcome.commit_sha, "");
3623        assert_eq!(outcome.content_hash, seeded.content_hash);
3624        assert!(
3625            outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3626            "absent-key metadata_unset must surface UPDATE_NOOP",
3627        );
3628        // Empty applied delta on the no-op —
3629        // `unset` must not claim `tags` was removed when nothing landed.
3630        assert!(
3631            outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
3632            "no-op must report an empty metadata delta, got {:?}",
3633            outcome.modified_metadata,
3634        );
3635
3636        // Follow-up real change against the unchanged hash succeeds —
3637        // no HASH_MISMATCH cascade from a phantom advance.
3638        let mut sections = IndexMap::new();
3639        sections.insert("identity".to_string(), "real change".to_string());
3640        let real = engine
3641            .update_entity(
3642                UpdateEntityArgs {
3643                    anchors: Vec::new(),
3644                    id: seeded.id.clone(),
3645                    expected_hash: Some(seeded.content_hash.clone()),
3646                    sections,
3647                    append_sections: IndexMap::new(),
3648                    patch_sections: IndexMap::new(),
3649                    metadata: IndexMap::new(),
3650                    metadata_unset: Vec::new(),
3651                    declare_relations: Vec::new(),
3652                    dry_run: false,
3653                    relations_unset: Vec::new(),
3654                    anchors_unset: Vec::new(),
3655                },
3656                actor,
3657                Some(&client),
3658                None,
3659            )
3660            .unwrap();
3661        assert!(!real.commit_sha.is_empty());
3662        assert_ne!(real.content_hash, seeded.content_hash);
3663    }
3664
3665    /// The exact MCP repro — re-setting a
3666    /// metadata key to its current value no-ops, and the response's
3667    /// `modified_metadata` reports the applied delta (empty), not the
3668    /// requested key. Pre-fix the no-op short-circuit echoed
3669    /// `set: ["level"]` while `commit_sha` was empty and the hash
3670    /// unchanged — a self-contradictory response.
3671    #[test]
3672    fn update_entity_noop_setting_metadata_to_current_value_reports_empty_delta() {
3673        let tmp = TempDir::new().unwrap();
3674        let (mut engine, seeded) = engine_with_seed(&tmp, "Stability Resetter");
3675        let (actor, client) = cli_actor();
3676
3677        // `level` defaults to "M0" on the spec schema, so the seed
3678        // carries it. Re-setting it to "M0" changes nothing.
3679        let mut metadata = IndexMap::new();
3680        metadata.insert("level".to_string(), "M0".to_string());
3681        let outcome = engine
3682            .update_entity(
3683                UpdateEntityArgs {
3684                    anchors: Vec::new(),
3685                    id: seeded.id.clone(),
3686                    expected_hash: Some(seeded.content_hash.clone()),
3687                    sections: IndexMap::new(),
3688                    append_sections: IndexMap::new(),
3689                    patch_sections: IndexMap::new(),
3690                    metadata,
3691                    metadata_unset: Vec::new(),
3692                    declare_relations: Vec::new(),
3693                    dry_run: false,
3694                    relations_unset: Vec::new(),
3695                    anchors_unset: Vec::new(),
3696                },
3697                actor,
3698                Some(&client),
3699                None,
3700            )
3701            .unwrap();
3702
3703        assert_eq!(outcome.commit_sha, "", "no-op must not commit");
3704        assert_eq!(
3705            outcome.content_hash, seeded.content_hash,
3706            "no-op must not advance hash"
3707        );
3708        assert!(
3709            outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3710            "re-set to current value must surface UPDATE_NOOP",
3711        );
3712        assert!(
3713            outcome.modified_metadata.set.is_empty() && outcome.modified_metadata.unset.is_empty(),
3714            "no-op must not claim `level` was set — applied delta is empty, got {:?}",
3715            outcome.modified_metadata,
3716        );
3717    }
3718
3719    #[test]
3720    fn update_entity_noop_declare_already_related_edge() {
3721        // Re-declare an already-related edge
3722        // via `declare_relations` — no field, section, metadata or
3723        // relations list actually changes, so the bytes are identical
3724        // and the call no-ops.
3725        use crate::ops::RelateArg;
3726        let tmp = TempDir::new().unwrap();
3727        let mem_dir = tmp.path().to_path_buf();
3728        let writer = FilesystemMemWriter::new(mem_dir.clone());
3729        let mut engine = Engine::from_mounts(vec![(
3730            folder_mount("specs", mem_dir),
3731            Box::new(writer) as Box<dyn MemBackend>,
3732        )])
3733        .unwrap();
3734        let (actor, client) = cli_actor();
3735        let target = engine
3736            .create_entity(
3737                empty_create_args("specs", "Target Already Related"),
3738                actor,
3739                Some(&client),
3740                None,
3741            )
3742            .unwrap();
3743        let source = engine
3744            .create_entity(
3745                empty_create_args("specs", "Source Already Related"),
3746                actor,
3747                Some(&client),
3748                None,
3749            )
3750            .unwrap();
3751        let after_relate = engine
3752            .relate_entity(
3753                RelateEntityArgs {
3754                    source: source.id.clone(),
3755                    expected_hash: Some(source.content_hash.clone()),
3756                    rel_type: "USES".to_string(),
3757                    target: target.id.clone(),
3758                    remove: false,
3759                    description: None,
3760                    dry_run: false,
3761                },
3762                actor,
3763                Some(&client),
3764                None,
3765            )
3766            .unwrap();
3767        // Now re-declare the same edge via update.declare_relations.
3768        let outcome = engine
3769            .update_entity(
3770                UpdateEntityArgs {
3771                    anchors: Vec::new(),
3772                    relations_unset: Vec::new(),
3773                    anchors_unset: Vec::new(),
3774                    id: source.id.clone(),
3775                    expected_hash: Some(after_relate.content_hash.clone()),
3776                    sections: IndexMap::new(),
3777                    append_sections: IndexMap::new(),
3778                    patch_sections: IndexMap::new(),
3779                    metadata: IndexMap::new(),
3780                    metadata_unset: Vec::new(),
3781                    declare_relations: vec![RelateArg {
3782                        rel_type: "USES".to_string(),
3783                        to: target.id.clone(),
3784                        description: None,
3785                    }],
3786                    dry_run: false,
3787                },
3788                actor,
3789                Some(&client),
3790                None,
3791            )
3792            .unwrap();
3793
3794        assert_eq!(outcome.commit_sha, "");
3795        assert_eq!(outcome.content_hash, after_relate.content_hash);
3796        assert!(
3797            outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3798            "duplicate declare must surface UPDATE_NOOP",
3799        );
3800        // `relations_declared` still records the entry — the per-
3801        // relation outcome is part of the surface, even for no-ops.
3802        assert_eq!(outcome.relations_declared.len(), 1);
3803        assert_eq!(outcome.relations_declared[0].rel_type, "USES");
3804        assert_eq!(outcome.relations_declared[0].target, target.id);
3805        assert!(!outcome.relations_declared[0].target_was_stubbed);
3806    }
3807
3808    #[test]
3809    fn update_entity_real_change_still_commits_and_advances_hash() {
3810        // Regression: the no-op short-circuit must not short-circuit
3811        // real changes. A section replacement still produces a
3812        // non-empty `commit_sha`, advances `content_hash`, and does
3813        // NOT surface UPDATE_NOOP.
3814        let tmp = TempDir::new().unwrap();
3815        let (mut engine, seeded) = engine_with_seed(&tmp, "Real Change Subject");
3816        let (actor, client) = cli_actor();
3817
3818        let mut sections = IndexMap::new();
3819        sections.insert("identity".to_string(), "definitely new body".to_string());
3820
3821        let outcome = engine
3822            .update_entity(
3823                UpdateEntityArgs {
3824                    anchors: Vec::new(),
3825                    id: seeded.id.clone(),
3826                    expected_hash: Some(seeded.content_hash.clone()),
3827                    sections,
3828                    append_sections: IndexMap::new(),
3829                    patch_sections: IndexMap::new(),
3830                    metadata: IndexMap::new(),
3831                    metadata_unset: Vec::new(),
3832                    declare_relations: Vec::new(),
3833                    dry_run: false,
3834                    relations_unset: Vec::new(),
3835                    anchors_unset: Vec::new(),
3836                },
3837                actor,
3838                Some(&client),
3839                None,
3840            )
3841            .unwrap();
3842
3843        assert!(!outcome.commit_sha.is_empty(), "real change must commit");
3844        assert_ne!(
3845            outcome.content_hash, seeded.content_hash,
3846            "real change must advance content_hash",
3847        );
3848        assert!(
3849            !outcome.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
3850            "real change must not surface UPDATE_NOOP",
3851        );
3852    }
3853
3854    #[test]
3855    fn update_entity_noop_preserves_expected_hash_across_chain() {
3856        // A follow-up update with the original
3857        // hash after one or more no-ops succeeds because the hash
3858        // never advanced. Demonstrates the `expected_hash`-caching
3859        // posture the agent surface relies on.
3860        let tmp = TempDir::new().unwrap();
3861        let (mut engine, seeded) = engine_with_seed(&tmp, "Chained Noops Subject");
3862        let (actor, client) = cli_actor();
3863
3864        // Two no-op calls in a row — both must return the same hash.
3865        // Pass same-content mutation
3866        // so UPDATE_NOOP fires (rather than EMPTY_UPDATE) and the
3867        // hash-chain invariant is exercised on the warning path.
3868        let mut noop_sections = IndexMap::new();
3869        noop_sections.insert("identity".to_string(), "fixture identity body".to_string());
3870        for _ in 0..2 {
3871            let outcome = engine
3872                .update_entity(
3873                    UpdateEntityArgs {
3874                        anchors: Vec::new(),
3875                        id: seeded.id.clone(),
3876                        expected_hash: Some(seeded.content_hash.clone()),
3877                        sections: noop_sections.clone(),
3878                        append_sections: IndexMap::new(),
3879                        patch_sections: IndexMap::new(),
3880                        metadata: IndexMap::new(),
3881                        metadata_unset: Vec::new(),
3882                        declare_relations: Vec::new(),
3883                        dry_run: false,
3884                        relations_unset: Vec::new(),
3885                        anchors_unset: Vec::new(),
3886                    },
3887                    actor,
3888                    Some(&client),
3889                    None,
3890                )
3891                .unwrap();
3892            assert_eq!(outcome.commit_sha, "");
3893            assert_eq!(outcome.content_hash, seeded.content_hash);
3894        }
3895
3896        // Real follow-up with the original hash still works — no
3897        // HASH_MISMATCH cascade because the hash never advanced.
3898        let mut sections = IndexMap::new();
3899        sections.insert(
3900            "identity".to_string(),
3901            "third call: real change".to_string(),
3902        );
3903        let real = engine
3904            .update_entity(
3905                UpdateEntityArgs {
3906                    anchors: Vec::new(),
3907                    id: seeded.id.clone(),
3908                    expected_hash: Some(seeded.content_hash.clone()),
3909                    sections,
3910                    append_sections: IndexMap::new(),
3911                    patch_sections: IndexMap::new(),
3912                    metadata: IndexMap::new(),
3913                    metadata_unset: Vec::new(),
3914                    declare_relations: Vec::new(),
3915                    dry_run: false,
3916                    relations_unset: Vec::new(),
3917                    anchors_unset: Vec::new(),
3918                },
3919                actor,
3920                Some(&client),
3921                None,
3922            )
3923            .unwrap();
3924        assert!(!real.commit_sha.is_empty());
3925        assert_ne!(real.content_hash, seeded.content_hash);
3926    }
3927
3928    // ---- Engine::delete_entity --------------------------------------
3929
3930    // ---------------------------------------------------------------------
3931    // Alias-synthesis pass. Body wiki-links auto-emit
3932    // relations of the source schema's `alias_target_rel_type` pointer
3933    // and are garbage-collected when the body wiki-link disappears.
3934    // ---------------------------------------------------------------------
3935
3936    #[test]
3937    fn synthesis_gc_drops_auto_emitted_reference_when_body_link_removed() {
3938        // Create with `[[target]]` in body → synthesis emits
3939        // REFERENCES. Update body to drop the wiki-link → GC drops
3940        // the auto-emitted REFERENCES.
3941        use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
3942        use indexmap::IndexMap;
3943        use tempfile::TempDir;
3944
3945        let tmp = TempDir::new().unwrap();
3946        let mem_dir = tmp.path().to_path_buf();
3947        let writer = FilesystemMemWriter::new(mem_dir.clone());
3948        let mut engine = Engine::from_mounts(vec![(
3949            folder_mount("specs", mem_dir.clone()),
3950            Box::new(writer) as Box<dyn MemBackend>,
3951        )])
3952        .unwrap();
3953        engine.set_workspace_root(mem_dir.clone());
3954        let (actor, client) = cli_actor();
3955
3956        let target = engine
3957            .create_entity(
3958                empty_create_args("specs", "Target"),
3959                actor,
3960                Some(&client),
3961                None,
3962            )
3963            .unwrap();
3964        // Create source with the body wiki-link already present —
3965        // synthesis fires inside create.
3966        let mut sections: IndexMap<String, String> = IndexMap::new();
3967        sections.insert("identity".to_string(), "source identity".to_string());
3968        sections.insert(
3969            "purpose".to_string(),
3970            "see [[target]] for context".to_string(),
3971        );
3972        let source = engine
3973            .create_entity(
3974                CreateEntityArgs {
3975                    anchors: Vec::new(),
3976                    mem: "specs".to_string(),
3977                    title: "Source".to_string(),
3978                    entity_type: "spec".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            .unwrap();
3989        assert!(
3990            engine
3991                .get_entity(&source.id)
3992                .unwrap()
3993                .relationships
3994                .iter()
3995                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
3996            "create-time synthesis must emit REFERENCES → target",
3997        );
3998
3999        // Update: drop the body wiki-link. GC should remove the
4000        // synthesised REFERENCES.
4001        let mut new_sections: IndexMap<String, String> = IndexMap::new();
4002        new_sections.insert("purpose".to_string(), "no link any more".to_string());
4003        engine
4004            .update_entity(
4005                UpdateEntityArgs {
4006                    anchors: Vec::new(),
4007                    id: source.id.clone(),
4008                    expected_hash: Some(source.content_hash.clone()),
4009                    sections: new_sections,
4010                    append_sections: IndexMap::new(),
4011                    patch_sections: IndexMap::new(),
4012                    metadata: IndexMap::new(),
4013                    metadata_unset: Vec::new(),
4014                    declare_relations: Vec::new(),
4015                    dry_run: false,
4016                    relations_unset: Vec::new(),
4017                    anchors_unset: Vec::new(),
4018                },
4019                actor,
4020                Some(&client),
4021                None,
4022            )
4023            .expect("update must succeed; GC drops the now-orphan REFERENCES");
4024        let in_mem = engine.get_entity(&source.id).unwrap();
4025        assert!(
4026            !in_mem
4027                .relationships
4028                .iter()
4029                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4030            "GC must drop the auto-emitted REFERENCES after body link removal; got {:?}",
4031            in_mem.relationships,
4032        );
4033    }
4034
4035    #[test]
4036    fn update_gc_removes_orphan_stub_when_last_body_link_dropped() {
4037        // Create source with `[[ghost]]` body link → alias synthesis
4038        // auto-stubs `ghost` and emits REFERENCES → ghost. The update
4039        // drops the link, so the REFERENCES edge (the stub's only
4040        // referrer) disappears; the orphan-stub GC sweep removes the
4041        // stub and surfaces it in `orphan_stubs_removed`. A reload from
4042        // disk shows the same (decremented) stub count — proving the GC
4043        // was a real store mutation, not a session-local view fix.
4044        use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4045        use indexmap::IndexMap;
4046        use tempfile::TempDir;
4047
4048        let tmp = TempDir::new().unwrap();
4049        let mem_dir = tmp.path().to_path_buf();
4050        let writer = FilesystemMemWriter::new(mem_dir.clone());
4051        let mut engine = Engine::from_mounts(vec![(
4052            folder_mount("specs", mem_dir.clone()),
4053            Box::new(writer) as Box<dyn MemBackend>,
4054        )])
4055        .unwrap();
4056        engine.set_workspace_root(mem_dir.clone());
4057        let (actor, client) = cli_actor();
4058
4059        let ghost = crate::EntityId::new("specs", "ghost");
4060        let mut sections: IndexMap<String, String> = IndexMap::new();
4061        sections.insert("identity".to_string(), "source identity".to_string());
4062        sections.insert(
4063            "purpose".to_string(),
4064            "see [[ghost]] for context".to_string(),
4065        );
4066        let source = engine
4067            .create_entity(
4068                CreateEntityArgs {
4069                    anchors: Vec::new(),
4070                    mem: "specs".to_string(),
4071                    title: "Source".to_string(),
4072                    entity_type: "spec".to_string(),
4073                    sections,
4074                    metadata: IndexMap::new(),
4075                    relations: Vec::new(),
4076                    dry_run: false,
4077                },
4078                actor,
4079                Some(&client),
4080                None,
4081            )
4082            .unwrap();
4083        assert!(
4084            engine.store().contains(&ghost) && engine.get_entity(&ghost).unwrap().stub,
4085            "body wiki-link to an absent target must auto-stub it",
4086        );
4087        assert_eq!(
4088            engine.health().stub_count,
4089            1,
4090            "one stub before the link drop"
4091        );
4092
4093        let mut new_sections: IndexMap<String, String> = IndexMap::new();
4094        new_sections.insert("purpose".to_string(), "no link any more".to_string());
4095        let outcome = engine
4096            .update_entity(
4097                UpdateEntityArgs {
4098                    anchors: Vec::new(),
4099                    id: source.id.clone(),
4100                    expected_hash: Some(source.content_hash.clone()),
4101                    sections: new_sections,
4102                    append_sections: IndexMap::new(),
4103                    patch_sections: IndexMap::new(),
4104                    metadata: IndexMap::new(),
4105                    metadata_unset: Vec::new(),
4106                    declare_relations: Vec::new(),
4107                    dry_run: false,
4108                    relations_unset: Vec::new(),
4109                    anchors_unset: Vec::new(),
4110                },
4111                actor,
4112                Some(&client),
4113                None,
4114            )
4115            .expect("update must succeed and GC the now-orphan stub");
4116
4117        assert_eq!(
4118            outcome.orphan_stubs_removed,
4119            vec![ghost.clone()],
4120            "the update that dropped the last body link must report the GC'd stub",
4121        );
4122        assert!(
4123            !engine.store().contains(&ghost),
4124            "orphan stub must be gone from the in-memory store",
4125        );
4126        assert_eq!(
4127            engine.health().stub_count,
4128            0,
4129            "stub count decremented in-session"
4130        );
4131
4132        // Reload from disk: the source's on-disk markdown no longer
4133        // carries the link, so the parser re-emits no stub. The
4134        // decremented count holds across the reload — the GC was real.
4135        engine.reload_each_writable_mem().unwrap();
4136        assert!(
4137            !engine.store().contains(&ghost),
4138            "stub stays gone after reload-from-disk",
4139        );
4140        assert_eq!(
4141            engine.health().stub_count,
4142            0,
4143            "reloaded-from-disk store carries the same stub count as the in-session post-update state",
4144        );
4145    }
4146
4147    #[test]
4148    fn update_gc_noop_when_section_edit_changes_no_body_link() {
4149        // An update that edits one section while leaving the `[[ghost]]`
4150        // link standing in another orphans nothing: `orphan_stubs_removed`
4151        // is present and empty (stable shape, no spurious GC), and the
4152        // stub survives because its referrer survives.
4153        use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4154        use indexmap::IndexMap;
4155        use tempfile::TempDir;
4156
4157        let tmp = TempDir::new().unwrap();
4158        let mem_dir = tmp.path().to_path_buf();
4159        let writer = FilesystemMemWriter::new(mem_dir.clone());
4160        let mut engine = Engine::from_mounts(vec![(
4161            folder_mount("specs", mem_dir.clone()),
4162            Box::new(writer) as Box<dyn MemBackend>,
4163        )])
4164        .unwrap();
4165        engine.set_workspace_root(mem_dir.clone());
4166        let (actor, client) = cli_actor();
4167
4168        let ghost = crate::EntityId::new("specs", "ghost");
4169        let mut sections: IndexMap<String, String> = IndexMap::new();
4170        sections.insert("identity".to_string(), "original identity".to_string());
4171        sections.insert(
4172            "purpose".to_string(),
4173            "see [[ghost]] for context".to_string(),
4174        );
4175        let source = engine
4176            .create_entity(
4177                CreateEntityArgs {
4178                    anchors: Vec::new(),
4179                    mem: "specs".to_string(),
4180                    title: "Source".to_string(),
4181                    entity_type: "spec".to_string(),
4182                    sections,
4183                    metadata: IndexMap::new(),
4184                    relations: Vec::new(),
4185                    dry_run: false,
4186                },
4187                actor,
4188                Some(&client),
4189                None,
4190            )
4191            .unwrap();
4192        assert!(engine.store().contains(&ghost), "ghost stub materialised");
4193
4194        // Replace `identity` only; the `[[ghost]]` link in `purpose`
4195        // stays, so no edge drops.
4196        let mut edit: IndexMap<String, String> = IndexMap::new();
4197        edit.insert("identity".to_string(), "edited identity".to_string());
4198        let outcome = engine
4199            .update_entity(
4200                UpdateEntityArgs {
4201                    anchors: Vec::new(),
4202                    id: source.id.clone(),
4203                    expected_hash: Some(source.content_hash.clone()),
4204                    sections: edit,
4205                    append_sections: IndexMap::new(),
4206                    patch_sections: IndexMap::new(),
4207                    metadata: IndexMap::new(),
4208                    metadata_unset: Vec::new(),
4209                    declare_relations: Vec::new(),
4210                    dry_run: false,
4211                    relations_unset: Vec::new(),
4212                    anchors_unset: Vec::new(),
4213                },
4214                actor,
4215                Some(&client),
4216                None,
4217            )
4218            .expect("update must succeed");
4219        assert!(
4220            outcome.orphan_stubs_removed.is_empty(),
4221            "an edit that keeps every body wiki-link orphans nothing; got {:?}",
4222            outcome.orphan_stubs_removed,
4223        );
4224        assert!(
4225            engine.store().contains(&ghost),
4226            "the still-referenced stub survives the unrelated section edit",
4227        );
4228    }
4229
4230    #[test]
4231    fn update_gc_preserves_stub_with_surviving_referrer() {
4232        // Two sources both body-link `[[ghost]]`. Dropping the link from
4233        // one leaves `ghost` referenced by the other — set-membership
4234        // semantics keep the stub alive and `orphan_stubs_removed` empty.
4235        use crate::engine::{CreateEntityArgs, UpdateEntityArgs};
4236        use indexmap::IndexMap;
4237        use tempfile::TempDir;
4238
4239        let tmp = TempDir::new().unwrap();
4240        let mem_dir = tmp.path().to_path_buf();
4241        let writer = FilesystemMemWriter::new(mem_dir.clone());
4242        let mut engine = Engine::from_mounts(vec![(
4243            folder_mount("specs", mem_dir.clone()),
4244            Box::new(writer) as Box<dyn MemBackend>,
4245        )])
4246        .unwrap();
4247        engine.set_workspace_root(mem_dir.clone());
4248        let (actor, client) = cli_actor();
4249
4250        let ghost = crate::EntityId::new("specs", "ghost");
4251        let make_with_link = |title: &str| {
4252            let mut sections: IndexMap<String, String> = IndexMap::new();
4253            sections.insert("identity".to_string(), format!("{title} identity"));
4254            sections.insert("purpose".to_string(), "see [[ghost]]".to_string());
4255            CreateEntityArgs {
4256                anchors: Vec::new(),
4257                mem: "specs".to_string(),
4258                title: title.to_string(),
4259                entity_type: "spec".to_string(),
4260                sections,
4261                metadata: IndexMap::new(),
4262                relations: Vec::new(),
4263                dry_run: false,
4264            }
4265        };
4266        let source_a = engine
4267            .create_entity(make_with_link("Source A"), actor, Some(&client), None)
4268            .unwrap();
4269        engine
4270            .create_entity(make_with_link("Source B"), actor, Some(&client), None)
4271            .unwrap();
4272        assert!(engine.store().contains(&ghost), "ghost stub materialised");
4273
4274        // Drop the link from source A only.
4275        let mut drop_link: IndexMap<String, String> = IndexMap::new();
4276        drop_link.insert("purpose".to_string(), "no link here".to_string());
4277        let outcome = engine
4278            .update_entity(
4279                UpdateEntityArgs {
4280                    anchors: Vec::new(),
4281                    id: source_a.id.clone(),
4282                    expected_hash: Some(source_a.content_hash.clone()),
4283                    sections: drop_link,
4284                    append_sections: IndexMap::new(),
4285                    patch_sections: IndexMap::new(),
4286                    metadata: IndexMap::new(),
4287                    metadata_unset: Vec::new(),
4288                    declare_relations: Vec::new(),
4289                    dry_run: false,
4290                    relations_unset: Vec::new(),
4291                    anchors_unset: Vec::new(),
4292                },
4293                actor,
4294                Some(&client),
4295                None,
4296            )
4297            .expect("update must succeed");
4298        assert!(
4299            outcome.orphan_stubs_removed.is_empty(),
4300            "the stub keeps a referrer (source B), so nothing is GC'd; got {:?}",
4301            outcome.orphan_stubs_removed,
4302        );
4303        assert!(
4304            engine.store().contains(&ghost),
4305            "stub survives via the surviving referrer",
4306        );
4307    }
4308
4309    #[test]
4310    fn synthesis_gc_preserves_non_pointer_explicit_relation_across_body_update() {
4311        // Explicit USES to target (USES is not the schema's
4312        // alias_target_rel_type pointer). A subsequent body-changing
4313        // update must NOT drop the USES edge — GC only touches
4314        // relations of the pointer rel-type. Under Option C, REFERENCES
4315        // can't be authored explicitly (`manual_authoring: forbidden`),
4316        // so the analogous "explicit REFERENCES preserved" scenario is
4317        // structurally impossible; USES exercises the same invariant
4318        // from the rel-type-discrimination side.
4319        use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
4320        use indexmap::IndexMap;
4321        use tempfile::TempDir;
4322
4323        let tmp = TempDir::new().unwrap();
4324        let mem_dir = tmp.path().to_path_buf();
4325        let writer = FilesystemMemWriter::new(mem_dir.clone());
4326        let mut engine = Engine::from_mounts(vec![(
4327            folder_mount("specs", mem_dir.clone()),
4328            Box::new(writer) as Box<dyn MemBackend>,
4329        )])
4330        .unwrap();
4331        engine.set_workspace_root(mem_dir.clone());
4332        let (actor, client) = cli_actor();
4333
4334        let target = engine
4335            .create_entity(
4336                empty_create_args("specs", "Target"),
4337                actor,
4338                Some(&client),
4339                None,
4340            )
4341            .unwrap();
4342        let source = engine
4343            .create_entity(
4344                empty_create_args("specs", "Source"),
4345                actor,
4346                Some(&client),
4347                None,
4348            )
4349            .unwrap();
4350
4351        // Explicit relate — no body wiki-link.
4352        let relate = engine
4353            .relate_entity(
4354                RelateEntityArgs {
4355                    source: source.id.clone(),
4356                    expected_hash: Some(source.content_hash.clone()),
4357                    rel_type: "USES".to_string(),
4358                    target: target.id.clone(),
4359                    remove: false,
4360                    description: None,
4361                    dry_run: false,
4362                },
4363                actor,
4364                Some(&client),
4365                None,
4366            )
4367            .unwrap();
4368
4369        // Update an unrelated section. The explicit USES must survive
4370        // — it's not the alias_target_rel_type, GC ignores it.
4371        let mut sections: IndexMap<String, String> = IndexMap::new();
4372        sections.insert("purpose".to_string(), "unrelated edit".to_string());
4373        engine
4374            .update_entity(
4375                UpdateEntityArgs {
4376                    anchors: Vec::new(),
4377                    id: source.id.clone(),
4378                    expected_hash: Some(relate.content_hash.clone()),
4379                    sections,
4380                    append_sections: IndexMap::new(),
4381                    patch_sections: IndexMap::new(),
4382                    metadata: IndexMap::new(),
4383                    metadata_unset: Vec::new(),
4384                    declare_relations: Vec::new(),
4385                    dry_run: false,
4386                    relations_unset: Vec::new(),
4387                    anchors_unset: Vec::new(),
4388                },
4389                actor,
4390                Some(&client),
4391                None,
4392            )
4393            .expect("update must succeed");
4394        let in_mem = engine.get_entity(&source.id).unwrap();
4395        assert!(
4396            in_mem
4397                .relationships
4398                .iter()
4399                .any(|r| r.rel_type == "USES" && r.target == target.id),
4400            "explicit USES must survive an unrelated body update; got {:?}",
4401            in_mem.relationships,
4402        );
4403    }
4404
4405    #[test]
4406    fn synthesis_dedupes_repeated_body_links_to_same_target() {
4407        // Two `[[target]]` wiki-links in one body — synthesis must
4408        // not double-add. Result: exactly one REFERENCES.
4409        use crate::engine::UpdateEntityArgs;
4410        use indexmap::IndexMap;
4411        use tempfile::TempDir;
4412
4413        let tmp = TempDir::new().unwrap();
4414        let mem_dir = tmp.path().to_path_buf();
4415        let writer = FilesystemMemWriter::new(mem_dir.clone());
4416        let mut engine = Engine::from_mounts(vec![(
4417            folder_mount("specs", mem_dir.clone()),
4418            Box::new(writer) as Box<dyn MemBackend>,
4419        )])
4420        .unwrap();
4421        engine.set_workspace_root(mem_dir.clone());
4422        let (actor, client) = cli_actor();
4423
4424        let target = engine
4425            .create_entity(
4426                empty_create_args("specs", "Target"),
4427                actor,
4428                Some(&client),
4429                None,
4430            )
4431            .unwrap();
4432        let source = engine
4433            .create_entity(
4434                empty_create_args("specs", "Source"),
4435                actor,
4436                Some(&client),
4437                None,
4438            )
4439            .unwrap();
4440
4441        let mut sections: IndexMap<String, String> = IndexMap::new();
4442        sections.insert(
4443            "purpose".to_string(),
4444            "see [[target]] and again [[target]]".to_string(),
4445        );
4446        engine
4447            .update_entity(
4448                UpdateEntityArgs {
4449                    anchors: Vec::new(),
4450                    id: source.id.clone(),
4451                    expected_hash: Some(source.content_hash.clone()),
4452                    sections,
4453                    append_sections: IndexMap::new(),
4454                    patch_sections: IndexMap::new(),
4455                    metadata: IndexMap::new(),
4456                    metadata_unset: Vec::new(),
4457                    declare_relations: Vec::new(),
4458                    dry_run: false,
4459                    relations_unset: Vec::new(),
4460                    anchors_unset: Vec::new(),
4461                },
4462                actor,
4463                Some(&client),
4464                None,
4465            )
4466            .unwrap();
4467        let in_mem = engine.get_entity(&source.id).unwrap();
4468        let count = in_mem
4469            .relationships
4470            .iter()
4471            .filter(|r| r.rel_type == "REFERENCES" && r.target == target.id)
4472            .count();
4473        assert_eq!(
4474            count, 1,
4475            "dedupe must leave exactly one REFERENCES → target; got {:?}",
4476            in_mem.relationships,
4477        );
4478    }
4479
4480    #[test]
4481    fn synthesis_coexists_with_explicit_uses_to_same_target() {
4482        // Explicit `USES` to target AND body wiki-link to target →
4483        // entity carries both USES and REFERENCES edges; synthesis
4484        // dedupes on `(rel_type, target)` so USES never suppresses
4485        // REFERENCES.
4486        use crate::engine::{RelateEntityArgs, UpdateEntityArgs};
4487        use indexmap::IndexMap;
4488        use tempfile::TempDir;
4489
4490        let tmp = TempDir::new().unwrap();
4491        let mem_dir = tmp.path().to_path_buf();
4492        let writer = FilesystemMemWriter::new(mem_dir.clone());
4493        let mut engine = Engine::from_mounts(vec![(
4494            folder_mount("specs", mem_dir.clone()),
4495            Box::new(writer) as Box<dyn MemBackend>,
4496        )])
4497        .unwrap();
4498        engine.set_workspace_root(mem_dir.clone());
4499        let (actor, client) = cli_actor();
4500
4501        let target = engine
4502            .create_entity(
4503                empty_create_args("specs", "Target"),
4504                actor,
4505                Some(&client),
4506                None,
4507            )
4508            .unwrap();
4509        let source = engine
4510            .create_entity(
4511                empty_create_args("specs", "Source"),
4512                actor,
4513                Some(&client),
4514                None,
4515            )
4516            .unwrap();
4517        // Explicit USES.
4518        let relate = engine
4519            .relate_entity(
4520                RelateEntityArgs {
4521                    source: source.id.clone(),
4522                    expected_hash: Some(source.content_hash.clone()),
4523                    rel_type: "USES".to_string(),
4524                    target: target.id.clone(),
4525                    remove: false,
4526                    description: None,
4527                    dry_run: false,
4528                },
4529                actor,
4530                Some(&client),
4531                None,
4532            )
4533            .unwrap();
4534        // Body wiki-link to the same target — synthesis emits REFERENCES.
4535        let mut sections: IndexMap<String, String> = IndexMap::new();
4536        sections.insert(
4537            "purpose".to_string(),
4538            "we also reference [[target]]".to_string(),
4539        );
4540        engine
4541            .update_entity(
4542                UpdateEntityArgs {
4543                    anchors: Vec::new(),
4544                    id: source.id.clone(),
4545                    expected_hash: Some(relate.content_hash.clone()),
4546                    sections,
4547                    append_sections: IndexMap::new(),
4548                    patch_sections: IndexMap::new(),
4549                    metadata: IndexMap::new(),
4550                    metadata_unset: Vec::new(),
4551                    declare_relations: Vec::new(),
4552                    dry_run: false,
4553                    relations_unset: Vec::new(),
4554                    anchors_unset: Vec::new(),
4555                },
4556                actor,
4557                Some(&client),
4558                None,
4559            )
4560            .unwrap();
4561        let in_mem = engine.get_entity(&source.id).unwrap();
4562        assert!(
4563            in_mem
4564                .relationships
4565                .iter()
4566                .any(|r| r.rel_type == "USES" && r.target == target.id),
4567            "USES must survive — synthesis dedupes on (rel_type, target)",
4568        );
4569        assert!(
4570            in_mem
4571                .relationships
4572                .iter()
4573                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4574            "REFERENCES must be synthesised even though USES already targets the same entity",
4575        );
4576    }
4577
4578    // ---------------------------------------------------------------------
4579    // Alias-synthesis integration tests against custom-schema engines.
4580    // The default schema pins `alias_target_rel_type: REFERENCES`; these
4581    // tests verify the engine doesn't hardcode that name by mounting
4582    // schemas with a non-REFERENCES pointer (proves name-agnosticism)
4583    // and schemas with no pointer at all (proves the strict
4584    // `WIKILINK_WITHOUT_RELATION` refusal still fires for opt-out
4585    // schemas). Both build the engine via `from_mounts_with_schemas_dir`
4586    // — the production path for workspace-authored schemas.
4587    // ---------------------------------------------------------------------
4588
4589    mod alias_synthesis_custom_schema {
4590        use std::path::Path;
4591
4592        use indexmap::IndexMap;
4593        use memstead_schema::SchemaRef;
4594        use tempfile::TempDir;
4595
4596        use crate::backend::MemBackend;
4597        use crate::engine::test_helpers::*;
4598        use crate::engine::{CreateEntityArgs, Engine, EngineError, UpdateEntityArgs};
4599        use crate::storage::FilesystemMemWriter;
4600        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4601
4602        const TYPE_BODY: &str = r#"description: t
4603when_to_use: tests
4604sections:
4605  - key: body
4606    heading: Body
4607    required: true
4608    search_weight: 10.0
4609    catch_all: true
4610    write_rules: []
4611metadata_fields: []
4612title_weight: 100.0
4613text_fields:
4614  - body
4615hierarchy_relationship: _default
4616no_self_loop_relationships: []
4617updatable_fields:
4618  - title
4619  - body
4620health_required_fields:
4621  - body
4622staleness_threshold_days: 90
4623write_rules: []
4624"#;
4625
4626        fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
4627            let dir = root.join(name);
4628            std::fs::create_dir_all(dir.join("types")).unwrap();
4629            std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
4630            for (type_name, body) in types {
4631                std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
4632            }
4633        }
4634
4635        fn make_type_yaml(name: &str) -> String {
4636            format!("name: {name}\n{TYPE_BODY}")
4637        }
4638
4639        fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
4640            Mount {
4641                mem: mem.to_string(),
4642                schema: Some(pin),
4643                storage: MountStorage::Folder { path },
4644                capability: MountCapability::Write,
4645                lifecycle: MountLifecycle::Eager,
4646                cross_linkable: true,
4647                migration_target: None,
4648            }
4649        }
4650
4651        fn engine_with_schema(
4652            manifest: &str,
4653            type_yaml_name: &str,
4654            schema_name: &str,
4655            schema_version: semver::Version,
4656        ) -> (Engine, TempDir) {
4657            let tmp = TempDir::new().unwrap();
4658            let schemas_dir = tmp.path().join("schemas");
4659            std::fs::create_dir_all(&schemas_dir).unwrap();
4660            write_schema_files(
4661                &schemas_dir,
4662                schema_name,
4663                manifest,
4664                &[(type_yaml_name, &make_type_yaml(type_yaml_name))],
4665            );
4666            let mem_dir = tmp.path().join("mem");
4667            std::fs::create_dir_all(&mem_dir).unwrap();
4668            let writer = FilesystemMemWriter::new(mem_dir.clone());
4669            let pin = SchemaRef::new(schema_name, schema_version);
4670            let mount = folder_mount_with_pin("v", mem_dir, pin);
4671            let mut engine = Engine::from_mounts_with_schemas_dir(
4672                vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4673                Some(&schemas_dir),
4674            )
4675            .expect("engine with custom schema constructs");
4676            engine.set_workspace_root(tmp.path().to_path_buf());
4677            (engine, tmp)
4678        }
4679
4680        #[test]
4681        fn non_references_alias_pointer_emits_named_rel_type_from_body_link() {
4682            // Schema names CITES as the alias pointer — the engine
4683            // must emit CITES (not REFERENCES) from a body wiki-link.
4684            // Proves no hard-coded "REFERENCES" string anywhere in
4685            // the synthesis path.
4686            let manifest = r#"name: aliased
4687version: 0.1.0
4688description: alias-synthesis fixture using a non-REFERENCES pointer
4689when_to_use: tests prove the engine does not hard-code REFERENCES
4690types:
4691  - doc
4692relationships:
4693  mode: strict
4694  definitions:
4695    - name: CITES
4696      description: Citation — auto-emitted from body wiki-links
4697      default_weight: 0.5
4698    - name: PART_OF
4699      description: Hierarchy
4700      default_weight: 3.0
4701      acyclic: true
4702    - name: _default
4703      description: Fallback
4704      default_weight: 1.0
4705alias_target_rel_type: CITES
4706community:
4707  resolution: 1.0
4708  seed: 42
4709"#;
4710            let (mut engine, _tmp) =
4711                engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4712            let (actor, client) = cli_actor();
4713
4714            let target = engine
4715                .create_entity(
4716                    CreateEntityArgs {
4717                        anchors: Vec::new(),
4718                        mem: "v".to_string(),
4719                        title: "Target".to_string(),
4720                        entity_type: "doc".to_string(),
4721                        sections: IndexMap::from_iter([(
4722                            "body".to_string(),
4723                            "target body".to_string(),
4724                        )]),
4725                        metadata: IndexMap::new(),
4726                        relations: Vec::new(),
4727                        dry_run: false,
4728                    },
4729                    actor,
4730                    Some(&client),
4731                    None,
4732                )
4733                .unwrap();
4734
4735            let mut sections: IndexMap<String, String> = IndexMap::new();
4736            sections.insert("body".to_string(), "see [[target]]".to_string());
4737            let source = engine
4738                .create_entity(
4739                    CreateEntityArgs {
4740                        anchors: Vec::new(),
4741                        mem: "v".to_string(),
4742                        title: "Source".to_string(),
4743                        entity_type: "doc".to_string(),
4744                        sections,
4745                        metadata: IndexMap::new(),
4746                        relations: Vec::new(),
4747                        dry_run: false,
4748                    },
4749                    actor,
4750                    Some(&client),
4751                    None,
4752                )
4753                .expect("create must succeed; CITES is auto-emitted by synthesis");
4754
4755            let in_mem = engine.get_entity(&source.id).unwrap();
4756            assert!(
4757                in_mem
4758                    .relationships
4759                    .iter()
4760                    .any(|r| r.rel_type == "CITES" && r.target == target.id),
4761                "synthesis must emit CITES (the pointer rel-type), not REFERENCES; got {:?}",
4762                in_mem.relationships,
4763            );
4764            assert!(
4765                !in_mem
4766                    .relationships
4767                    .iter()
4768                    .any(|r| r.rel_type == "REFERENCES"),
4769                "engine must not hard-code REFERENCES — pointer rel-type is CITES; got {:?}",
4770                in_mem.relationships,
4771            );
4772        }
4773
4774        #[test]
4775        fn no_pointer_schema_refuses_unbacked_body_wiki_link() {
4776            // Schema declares no `alias_target_rel_type`. Body wiki-link
4777            // without a backing relation must refuse with
4778            // `WIKILINK_WITHOUT_RELATION` — the strict validator's
4779            // pre-Option-C semantics, preserved for opt-out schemas.
4780            let manifest = r#"name: no-alias
4781version: 0.1.0
4782description: schema without alias_target_rel_type pointer
4783when_to_use: tests prove strict validator still fires for opt-out schemas
4784types:
4785  - doc
4786relationships:
4787  mode: strict
4788  definitions:
4789    - name: USES
4790      description: Use
4791      default_weight: 1.0
4792    - name: PART_OF
4793      description: Hierarchy
4794      default_weight: 3.0
4795      acyclic: true
4796    - name: _default
4797      description: Fallback
4798      default_weight: 1.0
4799community:
4800  resolution: 1.0
4801  seed: 42
4802"#;
4803            let (mut engine, _tmp) =
4804                engine_with_schema(manifest, "doc", "no-alias", semver::Version::new(0, 1, 0));
4805            let (actor, client) = cli_actor();
4806
4807            let target = engine
4808                .create_entity(
4809                    CreateEntityArgs {
4810                        anchors: Vec::new(),
4811                        mem: "v".to_string(),
4812                        title: "Target".to_string(),
4813                        entity_type: "doc".to_string(),
4814                        sections: IndexMap::from_iter([(
4815                            "body".to_string(),
4816                            "target body".to_string(),
4817                        )]),
4818                        metadata: IndexMap::new(),
4819                        relations: Vec::new(),
4820                        dry_run: false,
4821                    },
4822                    actor,
4823                    Some(&client),
4824                    None,
4825                )
4826                .unwrap();
4827            let source = engine
4828                .create_entity(
4829                    CreateEntityArgs {
4830                        anchors: Vec::new(),
4831                        mem: "v".to_string(),
4832                        title: "Source".to_string(),
4833                        entity_type: "doc".to_string(),
4834                        sections: IndexMap::from_iter([(
4835                            "body".to_string(),
4836                            "source body".to_string(),
4837                        )]),
4838                        metadata: IndexMap::new(),
4839                        relations: Vec::new(),
4840                        dry_run: false,
4841                    },
4842                    actor,
4843                    Some(&client),
4844                    None,
4845                )
4846                .unwrap();
4847
4848            // Add a body wiki-link with no backing relation. The
4849            // synthesis pass is a no-op (no pointer), so the
4850            // validator surfaces `WIKILINK_WITHOUT_RELATION`.
4851            let mut sections: IndexMap<String, String> = IndexMap::new();
4852            sections.insert("body".to_string(), "see [[target]]".to_string());
4853            let err = engine
4854                .update_entity(
4855                    UpdateEntityArgs {
4856                        anchors: Vec::new(),
4857                        id: source.id.clone(),
4858                        expected_hash: Some(source.content_hash.clone()),
4859                        sections,
4860                        append_sections: IndexMap::new(),
4861                        patch_sections: IndexMap::new(),
4862                        metadata: IndexMap::new(),
4863                        metadata_unset: Vec::new(),
4864                        declare_relations: Vec::new(),
4865                        dry_run: false,
4866                        relations_unset: Vec::new(),
4867                        anchors_unset: Vec::new(),
4868                    },
4869                    actor,
4870                    Some(&client),
4871                    None,
4872                )
4873                .unwrap_err();
4874            match err {
4875                EngineError::WikiLinkWithoutRelation { from_id, missing } => {
4876                    assert_eq!(from_id, source.id.to_string());
4877                    assert_eq!(missing.len(), 1);
4878                    assert_eq!(missing[0].section_key, "body");
4879                    assert_eq!(missing[0].target_id, target.id.to_string());
4880                }
4881                other => panic!(
4882                    "no-pointer schema must refuse with WikiLinkWithoutRelation; got {other:?}"
4883                ),
4884            }
4885        }
4886
4887        /// Restoring the
4888        /// pre-alias-synthesis invariant that every body wiki-link
4889        /// target carries a grammar-valid `EntityId`. Natural-form
4890        /// `[[Knowledge Graph]]` no longer slips through into a
4891        /// malformed auto-stub; the engine refuses with the typed
4892        /// `InvalidWikiLinkTarget` envelope and the
4893        /// `title_to_slug`-derived suggestion the agent lifts
4894        /// directly into a retry. Covers F1 of the 2026-05-18 CLI probe.
4895        #[test]
4896        fn natural_form_body_wiki_link_refuses_with_typed_envelope() {
4897            let manifest = r#"name: aliased
4898version: 0.1.0
4899description: alias-synthesis fixture
4900when_to_use: tests prove strict wiki-link grammar at mutation entry
4901types:
4902  - doc
4903relationships:
4904  mode: strict
4905  definitions:
4906    - name: REFERENCES
4907      description: Reference — auto-emitted from body wiki-links
4908      default_weight: 0.5
4909    - name: PART_OF
4910      description: Hierarchy
4911      default_weight: 3.0
4912      acyclic: true
4913    - name: _default
4914      description: Fallback
4915      default_weight: 1.0
4916alias_target_rel_type: REFERENCES
4917community:
4918  resolution: 1.0
4919  seed: 42
4920"#;
4921            let (mut engine, _tmp) =
4922                engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4923            let (actor, client) = cli_actor();
4924
4925            let mut sections: IndexMap<String, String> = IndexMap::new();
4926            sections.insert("body".to_string(), "see [[Knowledge Graph]]".to_string());
4927            let err = engine
4928                .create_entity(
4929                    CreateEntityArgs {
4930                        anchors: Vec::new(),
4931                        mem: "v".to_string(),
4932                        title: "Source".to_string(),
4933                        entity_type: "doc".to_string(),
4934                        sections,
4935                        metadata: IndexMap::new(),
4936                        relations: Vec::new(),
4937                        dry_run: false,
4938                    },
4939                    actor,
4940                    Some(&client),
4941                    None,
4942                )
4943                .unwrap_err();
4944            match err {
4945                EngineError::InvalidWikiLinkTarget {
4946                    raw,
4947                    suggested,
4948                    section,
4949                    link_source,
4950                    ..
4951                } => {
4952                    assert_eq!(raw, "Knowledge Graph");
4953                    assert_eq!(suggested.as_deref(), Some("knowledge-graph"));
4954                    assert_eq!(section, "body");
4955                    assert_eq!(link_source, "body_link");
4956                }
4957                other => panic!(
4958                    "natural-form body wiki-link must refuse with InvalidWikiLinkTarget; got {other:?}"
4959                ),
4960            }
4961        }
4962
4963        /// Tier-2 body wiki-link with a non-conformant
4964        /// mem prefix refuses with the distinct `InvalidMemName`
4965        /// (wire code `INVALID_MEM_NAME`) — mems are fixed
4966        /// identifiers, not free-form text the agent can slugify, so
4967        /// the recovery path is different from `InvalidWikiLinkTarget`.
4968        #[test]
4969        fn tier_two_bad_mem_prefix_refuses_with_distinct_envelope() {
4970            let manifest = r#"name: aliased
4971version: 0.1.0
4972description: alias-synthesis fixture
4973when_to_use: tests prove strict mem-prefix grammar at mutation entry
4974types:
4975  - doc
4976relationships:
4977  mode: strict
4978  definitions:
4979    - name: REFERENCES
4980      description: Reference
4981      default_weight: 0.5
4982    - name: PART_OF
4983      description: Hierarchy
4984      default_weight: 3.0
4985      acyclic: true
4986    - name: _default
4987      description: Fallback
4988      default_weight: 1.0
4989alias_target_rel_type: REFERENCES
4990community:
4991  resolution: 1.0
4992  seed: 42
4993"#;
4994            let (mut engine, _tmp) =
4995                engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
4996            let (actor, client) = cli_actor();
4997
4998            let mut sections: IndexMap<String, String> = IndexMap::new();
4999            sections.insert("body".to_string(), "see [[Other Mem:foo]]".to_string());
5000            let err = engine
5001                .create_entity(
5002                    CreateEntityArgs {
5003                        anchors: Vec::new(),
5004                        mem: "v".to_string(),
5005                        title: "Source".to_string(),
5006                        entity_type: "doc".to_string(),
5007                        sections,
5008                        metadata: IndexMap::new(),
5009                        relations: Vec::new(),
5010                        dry_run: false,
5011                    },
5012                    actor,
5013                    Some(&client),
5014                    None,
5015                )
5016                .unwrap_err();
5017            match err {
5018                EngineError::InvalidWikiLinkMem { raw, section, .. } => {
5019                    assert_eq!(raw, "Other Mem");
5020                    assert_eq!(section, "body");
5021                }
5022                other => panic!(
5023                    "Tier-2 bad mem prefix must refuse with InvalidWikiLinkMem; got {other:?}"
5024                ),
5025            }
5026        }
5027
5028        /// Body wiki-link
5029        /// containing the ambiguous `[[<segments>/<segments>--<slug>]]`
5030        /// form refuses with `InvalidWikiLinkTarget` carrying the
5031        /// colon-form (`<prefix>:<slug>`) as `suggested`. Pre-fix the
5032        /// dash form silently produced a same-mem phantom stub at
5033        /// slug `team/sub-mem--target`, losing the agent's intent.
5034        #[test]
5035        fn hierarchical_dash_form_body_link_refuses_with_colon_suggestion() {
5036            let manifest = r#"name: aliased
5037version: 0.1.0
5038description: alias-synthesis fixture
5039when_to_use: tests prove hierarchical dash-form refusal at mutation entry
5040types:
5041  - doc
5042relationships:
5043  mode: strict
5044  definitions:
5045    - name: REFERENCES
5046      description: Reference — auto-emitted from body wiki-links
5047      default_weight: 0.5
5048    - name: PART_OF
5049      description: Hierarchy
5050      default_weight: 3.0
5051      acyclic: true
5052    - name: _default
5053      description: Fallback
5054      default_weight: 1.0
5055alias_target_rel_type: REFERENCES
5056community:
5057  resolution: 1.0
5058  seed: 42
5059"#;
5060            let (mut engine, _tmp) =
5061                engine_with_schema(manifest, "doc", "aliased", semver::Version::new(0, 1, 0));
5062            let (actor, client) = cli_actor();
5063
5064            let mut sections: IndexMap<String, String> = IndexMap::new();
5065            sections.insert(
5066                "body".to_string(),
5067                "see [[team/sub-mem--target]]".to_string(),
5068            );
5069            let err = engine
5070                .create_entity(
5071                    CreateEntityArgs {
5072                        anchors: Vec::new(),
5073                        mem: "v".to_string(),
5074                        title: "Source".to_string(),
5075                        entity_type: "doc".to_string(),
5076                        sections,
5077                        metadata: IndexMap::new(),
5078                        relations: Vec::new(),
5079                        dry_run: false,
5080                    },
5081                    actor,
5082                    Some(&client),
5083                    None,
5084                )
5085                .unwrap_err();
5086            match err {
5087                EngineError::InvalidWikiLinkTarget {
5088                    raw,
5089                    suggested,
5090                    section,
5091                    link_source,
5092                    ..
5093                } => {
5094                    assert_eq!(raw, "team/sub-mem--target");
5095                    assert_eq!(suggested.as_deref(), Some("team/sub-mem:target"));
5096                    assert_eq!(section, "body");
5097                    assert_eq!(link_source, "body_link");
5098                }
5099                other => panic!(
5100                    "hierarchical dash-form body link must refuse with InvalidWikiLinkTarget; got {other:?}"
5101                ),
5102            }
5103
5104            // The entity did not land — no phantom stub for the source,
5105            // no phantom stub for the would-be target.
5106            let listed = engine.store().all_entities().collect::<Vec<_>>();
5107            assert!(
5108                listed.is_empty(),
5109                "refused create must not leave any entity behind, got: {listed:?}"
5110            );
5111        }
5112    }
5113
5114    // ---- relations_unset repair-power gating -------------------------
5115
5116    /// Markdown for a deliberately non-conformant `spec`: carries an
5117    /// undeclared metadata field (`zzz_bogus_field`) plus one USES
5118    /// relation. Written straight to disk before engine construction —
5119    /// the write path refuses non-conformant entities, so out-of-band
5120    /// state is the only way to seed one (which is exactly the
5121    /// repair-power scenario: drift entered outside the engine).
5122    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";
5123
5124    fn repair_engine() -> (TempDir, Engine) {
5125        let tmp = TempDir::new().unwrap();
5126        let mem_dir = tmp.path().to_path_buf();
5127        std::fs::write(
5128            mem_dir.join("anchor.md"),
5129            "---\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",
5130        )
5131        .unwrap();
5132        std::fs::write(mem_dir.join("drifted.md"), DRIFTED_MD).unwrap();
5133        let writer = FilesystemMemWriter::new(mem_dir.clone());
5134        let engine = Engine::from_mounts(vec![(
5135            folder_mount("specs", mem_dir),
5136            Box::new(writer) as Box<dyn MemBackend>,
5137        )])
5138        .unwrap();
5139        (tmp, engine)
5140    }
5141
5142    fn repair_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5143        UpdateEntityArgs {
5144            anchors: Vec::new(),
5145            id,
5146            expected_hash: hash,
5147            sections: IndexMap::new(),
5148            append_sections: IndexMap::new(),
5149            patch_sections: IndexMap::new(),
5150            metadata: IndexMap::new(),
5151            metadata_unset: Vec::new(),
5152            declare_relations: Vec::new(),
5153            dry_run: false,
5154            relations_unset: vec![crate::ops::RelationUnsetArg {
5155                rel_type: "USES".to_string(),
5156                target: EntityId::new("specs", "anchor"),
5157            }],
5158            anchors_unset: Vec::new(),
5159        }
5160    }
5161
5162    /// A conformant entity refuses repair-shaped input with
5163    /// `REPAIR_NOT_NEEDED` and is not modified — even though it has a
5164    /// relation that `relations_unset` names. The recovery text points
5165    /// at the focused detach path.
5166    #[test]
5167    fn relations_unset_on_conformant_entity_refuses_repair_not_needed() {
5168        let (_tmp, mut engine) = repair_engine();
5169        // `anchor` is conformant. Give it a relation first via the
5170        // ordinary relate path so there is something to (not) remove.
5171        let anchor = EntityId::new("specs", "anchor");
5172        let drifted = EntityId::new("specs", "drifted");
5173        engine
5174            .relate_entity(
5175                RelateEntityArgs {
5176                    source: anchor.clone(),
5177                    expected_hash: None,
5178                    rel_type: "USES".to_string(),
5179                    target: drifted.clone(),
5180                    remove: false,
5181                    description: None,
5182                    dry_run: false,
5183                },
5184                Actor::Cli,
5185                None,
5186                None,
5187            )
5188            .expect("relate on conformant entity works");
5189        let mut args = repair_args(anchor.clone(), None);
5190        args.relations_unset[0].target = drifted.clone();
5191        let err = engine
5192            .update_entity(args, Actor::Cli, None, None)
5193            .unwrap_err();
5194        match err {
5195            EngineError::RepairNotNeeded { id, recovery } => {
5196                assert_eq!(id, anchor.to_string());
5197                assert!(
5198                    recovery.contains("memstead_relate"),
5199                    "recovery must point at the focused tool; got {recovery}"
5200                );
5201            }
5202            other => panic!("expected RepairNotNeeded, got {other:?}"),
5203        }
5204        // Entity unmodified — the relation is still there.
5205        let entity = engine.store().get(&anchor).unwrap();
5206        assert!(
5207            entity.relationships.iter().any(|r| r.target == drifted),
5208            "gate must not modify the entity"
5209        );
5210    }
5211
5212    /// A non-conformant entity accepts `relations_unset`: the named
5213    /// relation is removed atomically within the same update that also
5214    /// repairs the conformance break (`metadata_unset` on the
5215    /// undeclared field). The post-write entity is integral.
5216    #[test]
5217    fn relations_unset_repairs_non_conformant_entity_atomically() {
5218        let (_tmp, mut engine) = repair_engine();
5219        let drifted = EntityId::new("specs", "drifted");
5220        // Pre-state really is non-conformant.
5221        let pre = engine.conformance_findings("specs", None).unwrap();
5222        assert!(
5223            pre.iter().any(|f| f.id == drifted.to_string()),
5224            "fixture must lint non-conformant; got {pre:?}"
5225        );
5226        let mut args = repair_args(drifted.clone(), None);
5227        args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5228        engine
5229            .update_entity(args, Actor::Cli, None, None)
5230            .expect("repair update lands");
5231        let entity = engine.store().get(&drifted).unwrap();
5232        assert!(
5233            entity.relationships.is_empty(),
5234            "relation must be removed; got {:?}",
5235            entity.relationships
5236        );
5237        assert!(
5238            !entity.metadata.contains_key("zzz_bogus_field"),
5239            "conformance break must be repaired in the same update"
5240        );
5241        let post = engine.conformance_findings("specs", None).unwrap();
5242        assert!(
5243            post.iter().all(|f| f.id != drifted.to_string()),
5244            "post-repair entity must be conformant; got {post:?}"
5245        );
5246    }
5247
5248    /// Repair widens accepted inputs, never admissible outputs: a
5249    /// repair write whose post-state would violate the schema refuses
5250    /// with the relevant write-time code and nothing lands.
5251    #[test]
5252    fn relations_unset_post_state_must_still_validate() {
5253        let (_tmp, mut engine) = repair_engine();
5254        let drifted = EntityId::new("specs", "drifted");
5255        let mut args = repair_args(drifted.clone(), None);
5256        // Post-state violation: an unknown section alongside the
5257        // repair input.
5258        args.sections = IndexMap::from_iter([("nonexistent_section".to_string(), "x".to_string())]);
5259        let err = engine
5260            .update_entity(args, Actor::Cli, None, None)
5261            .unwrap_err();
5262        assert_eq!(
5263            err.code(),
5264            "UNKNOWN_SECTION",
5265            "strict-write post-condition must hold during repair; got {err:?}"
5266        );
5267        // Nothing landed: the relation survives.
5268        let entity = engine.store().get(&drifted).unwrap();
5269        assert!(
5270            !entity.relationships.is_empty(),
5271            "refused repair must not partially apply"
5272        );
5273    }
5274
5275    /// Absent `(rel_type, target)` pairs are silent no-ops — symmetric
5276    /// with `metadata_unset` — so a repair retry is idempotent.
5277    #[test]
5278    fn relations_unset_absent_pair_is_silent_noop() {
5279        let (_tmp, mut engine) = repair_engine();
5280        let drifted = EntityId::new("specs", "drifted");
5281        let mut args = repair_args(drifted.clone(), None);
5282        args.relations_unset[0].rel_type = "NEVER_DECLARED".to_string();
5283        // Also repair the field so the post-state is integral.
5284        args.metadata_unset = vec!["zzz_bogus_field".to_string()];
5285        engine
5286            .update_entity(args, Actor::Cli, None, None)
5287            .expect("absent pair no-ops, update lands");
5288        let entity = engine.store().get(&drifted).unwrap();
5289        assert_eq!(
5290            entity.relationships.len(),
5291            1,
5292            "the USES relation must survive an unmatched unset"
5293        );
5294    }
5295
5296    // ---- anchors merge / unset -------------------------------------------
5297
5298    fn anchor_input(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
5299        crate::anchor::AnchorInput {
5300            artifact: Some(artifact.to_string()),
5301            grain: Some("file".to_string()),
5302            class: Some("anchored".to_string()),
5303            hash: Some(hash.to_string()),
5304            hash_stability: Some("stable".to_string()),
5305            ..Default::default()
5306        }
5307    }
5308
5309    fn anchor_unset(artifact: &str) -> crate::anchor::AnchorUnsetInput {
5310        crate::anchor::AnchorUnsetInput {
5311            artifact: Some(artifact.to_string()),
5312            grain: None,
5313            class: None,
5314        }
5315    }
5316
5317    /// Bare update-args shell for anchor tests — no content mutation.
5318    fn anchor_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5319        UpdateEntityArgs {
5320            anchors: Vec::new(),
5321            anchors_unset: Vec::new(),
5322            id,
5323            expected_hash: hash,
5324            sections: IndexMap::new(),
5325            append_sections: IndexMap::new(),
5326            patch_sections: IndexMap::new(),
5327            metadata: IndexMap::new(),
5328            metadata_unset: Vec::new(),
5329            declare_relations: Vec::new(),
5330            dry_run: false,
5331            relations_unset: Vec::new(),
5332        }
5333    }
5334
5335    /// Engine over a folder mount, seeded with one entity carrying two
5336    /// anchors (a.rs, b.rs). Returns the engine, tempdir, id, and hash.
5337    fn anchored_engine() -> (Engine, TempDir, EntityId, String) {
5338        let tmp = TempDir::new().unwrap();
5339        let mem_dir = tmp.path().to_path_buf();
5340        let writer = FilesystemMemWriter::new(mem_dir.clone());
5341        let mut engine = Engine::from_mounts(vec![(
5342            folder_mount("specs", mem_dir),
5343            Box::new(writer) as Box<dyn MemBackend>,
5344        )])
5345        .unwrap();
5346        let (actor, client) = cli_actor();
5347        let mut args = empty_create_args("specs", "Anchored");
5348        args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
5349        let created = engine
5350            .create_entity(args, actor, Some(&client), None)
5351            .unwrap();
5352        let id = EntityId::new("specs", "anchored");
5353        assert_eq!(engine.entity_anchors(&id).len(), 2);
5354        (engine, tmp, id, created.content_hash)
5355    }
5356
5357    /// Merge acceptance: one new anchor on N existing yields N+1 with the
5358    /// others byte-identical; a same-`(artifact, grain, class)` write
5359    /// replaces exactly that one; the motivating regression is dead —
5360    /// "anchor batch A, later anchor batch B" leaves A ∪ B queryable.
5361    #[test]
5362    fn update_anchors_merge_appends_and_replaces_by_triple() {
5363        let (mut engine, _tmp, id, hash) = anchored_engine();
5364        let (actor, client) = cli_actor();
5365
5366        // Batch B: one new artifact. A ∪ B must survive.
5367        let mut args = anchor_args(id.clone(), Some(hash));
5368        args.anchors = vec![anchor_input("c.rs", "h-c")];
5369        let out = engine
5370            .update_entity(args, actor, Some(&client), None)
5371            .unwrap();
5372        let anchors = engine.entity_anchors(&id);
5373        assert_eq!(anchors.len(), 3, "N existing + 1 new = N+1");
5374        assert_eq!(anchors[0].artifact, "a.rs");
5375        assert_eq!(anchors[0].hash.as_deref(), Some("h-a"));
5376        assert_eq!(anchors[1].artifact, "b.rs");
5377        assert_eq!(anchors[2].artifact, "c.rs");
5378        assert!(!engine.anchors_referencing_artifact("a.rs").is_empty());
5379        assert!(!engine.anchors_referencing_artifact("c.rs").is_empty());
5380
5381        // Same-triple write replaces exactly that one, in place.
5382        let mut args = anchor_args(id.clone(), Some(out.content_hash));
5383        args.anchors = vec![anchor_input("a.rs", "h-a2")];
5384        engine
5385            .update_entity(args, actor, Some(&client), None)
5386            .unwrap();
5387        let anchors = engine.entity_anchors(&id);
5388        assert_eq!(anchors.len(), 3);
5389        assert_eq!(anchors[0].artifact, "a.rs");
5390        assert_eq!(anchors[0].hash.as_deref(), Some("h-a2"));
5391        assert_eq!(anchors[1].hash.as_deref(), Some("h-b"), "b untouched");
5392        assert_eq!(anchors[2].hash.as_deref(), Some("h-c"), "c untouched");
5393    }
5394
5395    /// Re-sending the full current set is a no-op on the stored sidecar
5396    /// bytes, and an update with an empty/absent `anchors` list leaves the
5397    /// stored set untouched.
5398    #[test]
5399    fn update_anchors_full_resend_and_absent_are_noops_on_stored_set() {
5400        let (mut engine, tmp, id, hash) = anchored_engine();
5401        let (actor, client) = cli_actor();
5402        let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
5403        let before = std::fs::read(&sidecar_path).unwrap();
5404
5405        // Full re-send of the current set.
5406        let mut args = anchor_args(id.clone(), Some(hash));
5407        args.anchors = vec![anchor_input("a.rs", "h-a"), anchor_input("b.rs", "h-b")];
5408        let out = engine
5409            .update_entity(args, actor, Some(&client), None)
5410            .unwrap();
5411        assert_eq!(
5412            std::fs::read(&sidecar_path).unwrap(),
5413            before,
5414            "full re-send keeps the stored bytes"
5415        );
5416
5417        // Absent anchors list + a real content change: set untouched.
5418        let mut args = anchor_args(id.clone(), Some(out.content_hash));
5419        args.sections
5420            .insert("identity".to_string(), "changed body".to_string());
5421        engine
5422            .update_entity(args, actor, Some(&client), None)
5423            .unwrap();
5424        assert_eq!(
5425            std::fs::read(&sidecar_path).unwrap(),
5426            before,
5427            "an anchorless update never touches the stored set"
5428        );
5429    }
5430
5431    /// Unset acceptance: bare-artifact unset removes all of that
5432    /// artifact's anchors and nothing else; a grain-narrowed unset removes
5433    /// only the match; a nonexistent target succeeds and changes nothing;
5434    /// unset + merge in one call apply unset-first.
5435    #[test]
5436    fn update_anchors_unset_bare_narrowed_idempotent_and_unset_first() {
5437        let (mut engine, _tmp, id, hash) = anchored_engine();
5438        let (actor, client) = cli_actor();
5439
5440        // Add a span-grain anchor on a.rs so a.rs carries two grains.
5441        let mut span = anchor_input("a.rs", "h-span");
5442        span.grain = Some("span".to_string());
5443        let mut args = anchor_args(id.clone(), Some(hash));
5444        args.anchors = vec![span];
5445        let out = engine
5446            .update_entity(args, actor, Some(&client), None)
5447            .unwrap();
5448        assert_eq!(engine.entity_anchors(&id).len(), 3);
5449
5450        // Narrowed unset: only the span-grain anchor goes.
5451        let mut narrowed = anchor_unset("a.rs");
5452        narrowed.grain = Some("span".to_string());
5453        let mut args = anchor_args(id.clone(), Some(out.content_hash));
5454        args.anchors_unset = vec![narrowed];
5455        let out = engine
5456            .update_entity(args, actor, Some(&client), None)
5457            .unwrap();
5458        let anchors = engine.entity_anchors(&id);
5459        assert_eq!(anchors.len(), 2);
5460        assert!(
5461            anchors
5462                .iter()
5463                .all(|a| a.grain == crate::anchor::AnchorGrain::File)
5464        );
5465
5466        // Nonexistent target: succeeds, changes nothing.
5467        let mut args = anchor_args(id.clone(), Some(out.content_hash.clone()));
5468        args.anchors_unset = vec![anchor_unset("never-there.rs")];
5469        engine
5470            .update_entity(args, actor, Some(&client), None)
5471            .expect("unset of a nonexistent target is a no-op, not an error");
5472        assert_eq!(engine.entity_anchors(&id).len(), 2);
5473
5474        // Unset + merge in one call: bare unset of a.rs plus a fresh a.rs
5475        // anchor — unset applies first, so the fresh anchor lands.
5476        let mut args = anchor_args(id.clone(), Some(out.content_hash));
5477        args.anchors_unset = vec![anchor_unset("a.rs")];
5478        args.anchors = vec![anchor_input("a.rs", "h-a-fresh")];
5479        engine
5480            .update_entity(args, actor, Some(&client), None)
5481            .unwrap();
5482        let anchors = engine.entity_anchors(&id);
5483        assert_eq!(anchors.len(), 2);
5484        assert_eq!(anchors[0].artifact, "b.rs", "b.rs untouched throughout");
5485        assert_eq!(anchors[1].hash.as_deref(), Some("h-a-fresh"));
5486    }
5487
5488    /// An anchor-write update never moves `_hash`, and an unset-only
5489    /// update is a real commit (not a no-op) that leaves the entity bytes
5490    /// untouched.
5491    #[test]
5492    fn update_anchor_only_and_unset_only_commit_without_hash_movement() {
5493        let (mut engine, _tmp, id, hash) = anchored_engine();
5494        let (actor, client) = cli_actor();
5495
5496        let mut args = anchor_args(id.clone(), Some(hash.clone()));
5497        args.anchors_unset = vec![anchor_unset("b.rs")];
5498        let out = engine
5499            .update_entity(args, actor, Some(&client), None)
5500            .unwrap();
5501        assert!(
5502            !out.commit_sha.is_empty(),
5503            "unset-only update commits the sidecar"
5504        );
5505        assert_eq!(out.content_hash, hash, "anchors never move `_hash`");
5506        assert_eq!(engine.entity_anchors(&id).len(), 1);
5507
5508        // Refusal complement: with no anchors, no unsets, and no content,
5509        // the empty-update guard still fires.
5510        let err = engine
5511            .update_entity(
5512                anchor_args(id.clone(), Some(hash)),
5513                actor,
5514                Some(&client),
5515                None,
5516            )
5517            .unwrap_err();
5518        assert!(matches!(err, EngineError::EmptyUpdate { .. }));
5519    }
5520
5521    /// The same contract across a wall-clock second boundary: an
5522    /// anchor-only update landing a full second after the create must
5523    /// not auto-stamp `last_modified` (which would change the entity
5524    /// bytes and move `_hash`). Pre-fix, the anchor-only leg fell past
5525    /// the no-op guard into the unconditional auto-stamp and this
5526    /// failed whenever create and update straddled a second — the
5527    /// pinned clock makes that straddle deterministic.
5528    #[test]
5529    fn anchor_only_update_across_second_boundary_never_moves_hash() {
5530        let (mut engine, _tmp, id, hash) = anchored_engine();
5531        let (actor, client) = cli_actor();
5532
5533        let t0 = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_778_243_696);
5534        engine.set_mutation_clock(std::sync::Arc::new(move || t0));
5535        // Re-create baseline under the pinned clock so the stamped
5536        // `last_modified` is exactly t0's second.
5537        let mut args = anchor_args(id.clone(), Some(hash));
5538        args.metadata = [("level".to_string(), "M1".to_string())]
5539            .into_iter()
5540            .collect();
5541        let restamped = engine
5542            .update_entity(args, actor, Some(&client), None)
5543            .unwrap();
5544
5545        // One second later: anchor-only update.
5546        let t1 = t0 + std::time::Duration::from_secs(1);
5547        engine.set_mutation_clock(std::sync::Arc::new(move || t1));
5548        let mut args = anchor_args(id.clone(), Some(restamped.content_hash.clone()));
5549        args.anchors = vec![anchor_input("c.rs", "h-c")];
5550        let out = engine
5551            .update_entity(args, actor, Some(&client), None)
5552            .unwrap();
5553        assert!(!out.commit_sha.is_empty(), "anchor-only update commits");
5554        assert_eq!(
5555            out.content_hash, restamped.content_hash,
5556            "anchors never move `_hash`, even across a second boundary"
5557        );
5558        // The preserved `last_modified` is observable on the entity too.
5559        let entity = engine.store().get(&id).unwrap();
5560        assert_eq!(
5561            entity
5562                .metadata
5563                .get("last_modified")
5564                .and_then(|v| v.as_str()),
5565            Some("2026-05-08T12:34:56Z"),
5566            "anchor-only update must not restamp last_modified"
5567        );
5568    }
5569
5570    /// A malformed `anchors_unset[]` selector refuses the whole update
5571    /// with the typed `INVALID_ANCHOR` envelope and nothing is written —
5572    /// merge introduces no partial-apply.
5573    #[test]
5574    fn malformed_anchor_unset_refuses_and_nothing_is_written() {
5575        let (mut engine, tmp, id, hash) = anchored_engine();
5576        let (actor, client) = cli_actor();
5577        let sidecar_path = tmp.path().join(crate::anchor::ANCHOR_SIDECAR_PATH);
5578        let before = std::fs::read(&sidecar_path).unwrap();
5579
5580        let mut bad = anchor_unset("a.rs");
5581        bad.grain = Some("paragraph".to_string()); // unknown grain
5582        let mut args = anchor_args(id.clone(), Some(hash));
5583        args.anchors_unset = vec![bad];
5584        // A valid incoming anchor rides the same call — it must not land.
5585        args.anchors = vec![anchor_input("c.rs", "h-c")];
5586        let err = engine
5587            .update_entity(args, actor, Some(&client), None)
5588            .unwrap_err();
5589        assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
5590        assert_eq!(engine.entity_anchors(&id).len(), 2, "no partial apply");
5591        assert_eq!(std::fs::read(&sidecar_path).unwrap(), before);
5592    }
5593
5594    // ---- reserved metadata keys: set refused, unset is the repair --------
5595
5596    /// Bare update-args shell for the reserved-key tests.
5597    fn bare_args(id: EntityId, hash: Option<String>) -> UpdateEntityArgs {
5598        UpdateEntityArgs {
5599            anchors: Vec::new(),
5600            anchors_unset: Vec::new(),
5601            id,
5602            expected_hash: hash,
5603            sections: IndexMap::new(),
5604            append_sections: IndexMap::new(),
5605            patch_sections: IndexMap::new(),
5606            metadata: IndexMap::new(),
5607            metadata_unset: Vec::new(),
5608            declare_relations: Vec::new(),
5609            dry_run: false,
5610            relations_unset: Vec::new(),
5611        }
5612    }
5613
5614    /// On an entity fixture carrying historically smuggled reserved
5615    /// keys (`mem` / `id` in its frontmatter, written before the write
5616    /// gates closed), `metadata_unset` naming them succeeds, removes
5617    /// them from the store and the on-disk file, and the entity
5618    /// round-trips cleanly thereafter. Refusal complement: `metadata`
5619    /// (set) with a reserved key still refuses on update — single and
5620    /// batch.
5621    #[test]
5622    fn reserved_key_unset_repairs_smuggled_entity_and_set_stays_refused() {
5623        let tmp = TempDir::new().unwrap();
5624        let mem_dir = tmp.path().to_path_buf();
5625        // Pre-gate fixture: frontmatter smuggles `mem` and `id`.
5626        std::fs::write(
5627            mem_dir.join("smuggled.md"),
5628            "---\ntype: spec\nmem: wrong-mem\nid: bogus-id\n---\n# Smuggled\n\n## Identity\n\nsmuggled identity.\n\n## Purpose\n\nsmuggled purpose.\n",
5629        )
5630        .unwrap();
5631        let writer = FilesystemMemWriter::new(mem_dir.clone());
5632        let mut engine = Engine::from_mounts(vec![(
5633            folder_mount("specs", mem_dir.clone()),
5634            Box::new(writer) as Box<dyn MemBackend>,
5635        )])
5636        .unwrap();
5637        let (actor, client) = cli_actor();
5638        let id = EntityId::new("specs", "smuggled");
5639        let entity = engine.get_entity(&id).expect("fixture boots");
5640        assert!(
5641            entity.metadata.contains_key("mem") && entity.metadata.contains_key("id"),
5642            "fixture must carry the smuggled keys after boot"
5643        );
5644        let hash = entity.content_hash.clone();
5645
5646        // Refusal complement, single: SET of a reserved key refuses.
5647        for reserved in ["type", "mem", "id"] {
5648            let mut args = bare_args(id.clone(), Some(hash.clone()));
5649            args.metadata
5650                .insert(reserved.to_string(), "resmuggled".to_string());
5651            let err = engine
5652                .update_entity(args, actor, Some(&client), None)
5653                .expect_err("reserved-key set must refuse on update");
5654            assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
5655        }
5656        // Refusal complement, batch: same refusal through batch_update
5657        // (atomic — nothing lands).
5658        let mut batch_item = bare_args(id.clone(), Some(hash.clone()));
5659        batch_item
5660            .metadata
5661            .insert("id".to_string(), "resmuggled".to_string());
5662        let batch = engine
5663            .batch_update(vec![(batch_item, None)], actor, Some(&client), false)
5664            .expect("batch returns a result envelope");
5665        assert!(
5666            !batch.applied,
5667            "batch with a reserved-key set must not apply"
5668        );
5669        assert_eq!(batch.failed, 1);
5670
5671        // The sanctioned repair: unset both smuggled keys in one call.
5672        let mut args = bare_args(id.clone(), Some(hash));
5673        args.metadata_unset = vec!["mem".to_string(), "id".to_string()];
5674        let out = engine
5675            .update_entity(args, actor, Some(&client), None)
5676            .expect("reserved-key unset is the sanctioned repair");
5677        assert!(!out.commit_sha.is_empty(), "repair is a real commit");
5678        assert_eq!(
5679            out.modified_metadata.unset,
5680            vec!["mem".to_string(), "id".to_string()]
5681        );
5682
5683        // Invariant restored: store and disk are clean, and the entity
5684        // round-trips through a further ordinary update.
5685        let entity = engine.get_entity(&id).expect("entity survives repair");
5686        assert!(
5687            !entity.metadata.contains_key("mem") && !entity.metadata.contains_key("id"),
5688            "smuggled keys must be gone from the store"
5689        );
5690        let on_disk = std::fs::read_to_string(mem_dir.join("smuggled.md")).unwrap();
5691        assert!(
5692            !on_disk.contains("wrong-mem") && !on_disk.contains("bogus-id"),
5693            "smuggled keys must be gone from the file: {on_disk}"
5694        );
5695        let mut args = bare_args(id.clone(), Some(entity.content_hash.clone()));
5696        args.sections
5697            .insert("identity".to_string(), "repaired identity".to_string());
5698        engine
5699            .update_entity(args, actor, Some(&client), None)
5700            .expect("post-repair entity round-trips cleanly");
5701    }
5702
5703    /// Unsetting `type` never leaves an entity typeless: the engine
5704    /// re-seeds the authoritative discriminator, so on a healthy entity
5705    /// the unset is a committed-nothing no-op (`UPDATE_NOOP`) and the
5706    /// type survives on disk. (A missing `type:` would silently re-type
5707    /// the entity to the mem's default on the next parse — the re-seed
5708    /// forecloses that.) Unset of a nonexistent reserved key is equally
5709    /// a no-op, not an error.
5710    #[test]
5711    fn reserved_type_unset_reseeds_and_is_a_noop_on_healthy_entities() {
5712        let tmp = TempDir::new().unwrap();
5713        let mem_dir = tmp.path().to_path_buf();
5714        let writer = FilesystemMemWriter::new(mem_dir.clone());
5715        let mut engine = Engine::from_mounts(vec![(
5716            folder_mount("specs", mem_dir.clone()),
5717            Box::new(writer) as Box<dyn MemBackend>,
5718        )])
5719        .unwrap();
5720        let (actor, client) = cli_actor();
5721        let created = engine
5722            .create_entity(
5723                empty_create_args("specs", "Healthy"),
5724                actor,
5725                Some(&client),
5726                None,
5727            )
5728            .unwrap();
5729        let id = EntityId::new("specs", "healthy");
5730
5731        for key in ["type", "mem", "id"] {
5732            let mut args = bare_args(id.clone(), Some(created.content_hash.clone()));
5733            args.metadata_unset = vec![key.to_string()];
5734            let out = engine
5735                .update_entity(args, actor, Some(&client), None)
5736                .unwrap_or_else(|e| panic!("unset '{key}' on a healthy entity must no-op: {e:?}"));
5737            assert!(
5738                out.commit_sha.is_empty(),
5739                "unset '{key}' on a healthy entity is a no-op, not a commit"
5740            );
5741            assert!(
5742                out.warnings.iter().any(|w| w.code() == "UPDATE_NOOP"),
5743                "no-op must carry the UPDATE_NOOP warning for '{key}'"
5744            );
5745        }
5746        let entity = engine.get_entity(&id).unwrap();
5747        assert_eq!(entity.entity_type, "spec");
5748        assert_eq!(
5749            entity.metadata.get("type").and_then(|v| v.as_str()),
5750            Some("spec"),
5751            "the discriminator survives a type unset"
5752        );
5753    }
5754
5755    // ---- cycle family on declare_relations -------------------------------
5756
5757    /// `update.declare_relations` runs the same cycle family as
5758    /// `memstead_relate`: a cycle-closing edge on an acyclic rel-type
5759    /// refuses `RELATIONSHIP_CYCLE` with the relate path's recovery
5760    /// detail, a self-loop on a listed no-self-loop rel-type refuses
5761    /// identically, and — refusal complement — a non-cycle edge on the
5762    /// acyclic type is accepted exactly as today.
5763    #[test]
5764    fn declare_relations_refuses_cycle_and_self_loop_like_relate() {
5765        let tmp = TempDir::new().unwrap();
5766        let mem_dir = tmp.path().to_path_buf();
5767        let writer = FilesystemMemWriter::new(mem_dir.clone());
5768        let mut engine = Engine::from_mounts(vec![(
5769            folder_mount("specs", mem_dir),
5770            Box::new(writer) as Box<dyn MemBackend>,
5771        )])
5772        .unwrap();
5773        let (actor, client) = cli_actor();
5774
5775        // alpha PART_OF beta lands via create + relate.
5776        let alpha = engine
5777            .create_entity(
5778                empty_create_args("specs", "Alpha"),
5779                actor,
5780                Some(&client),
5781                None,
5782            )
5783            .unwrap();
5784        let beta = engine
5785            .create_entity(
5786                empty_create_args("specs", "Beta"),
5787                actor,
5788                Some(&client),
5789                None,
5790            )
5791            .unwrap();
5792        engine
5793            .relate_entity(
5794                crate::engine::RelateEntityArgs {
5795                    source: alpha.id.clone(),
5796                    target: beta.id.clone(),
5797                    rel_type: "PART_OF".to_string(),
5798                    remove: false,
5799                    expected_hash: None,
5800                    description: None,
5801                    dry_run: false,
5802                },
5803                actor,
5804                Some(&client),
5805                None,
5806            )
5807            .unwrap();
5808
5809        let declare = |rel_type: &str, from: &EntityId, to: &EntityId, hash: String| {
5810            let mut args = bare_args(from.clone(), Some(hash));
5811            args.declare_relations = vec![crate::ops::RelateArg {
5812                to: to.clone(),
5813                rel_type: rel_type.to_string(),
5814                description: None,
5815            }];
5816            args
5817        };
5818
5819        // beta declaring PART_OF→alpha closes beta→alpha→beta.
5820        let err = engine
5821            .update_entity(
5822                declare("PART_OF", &beta.id, &alpha.id, beta.content_hash.clone()),
5823                actor,
5824                Some(&client),
5825                None,
5826            )
5827            .expect_err("cycle-closing declare_relations must refuse");
5828        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5829        let details = err.details();
5830        assert_eq!(details["rel_type"], "PART_OF");
5831        assert!(details["existing_path"].is_array());
5832        assert!(
5833            engine
5834                .get_entity(&beta.id)
5835                .unwrap()
5836                .relationships
5837                .is_empty(),
5838            "the refused edge must not land"
5839        );
5840
5841        // Self-loop on a listed no-self-loop rel-type (spec lists USES).
5842        // Alpha's hash moved with the relate above — read the live one.
5843        let alpha_hash = engine.get_entity(&alpha.id).unwrap().content_hash.clone();
5844        let err = engine
5845            .update_entity(
5846                declare("USES", &alpha.id, &alpha.id, alpha_hash),
5847                actor,
5848                Some(&client),
5849                None,
5850            )
5851            .expect_err("self-loop declare_relations must refuse");
5852        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5853
5854        // Refusal complement: a non-cycle PART_OF edge is accepted.
5855        engine
5856            .update_entity(
5857                declare(
5858                    "PART_OF",
5859                    &beta.id,
5860                    &EntityId::new("specs", "gamma"),
5861                    beta.content_hash.clone(),
5862                ),
5863                actor,
5864                Some(&client),
5865                None,
5866            )
5867            .expect("a non-cycle PART_OF declare must land as today");
5868    }
5869}