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