Skip to main content

memstead_base/engine/mutation/
update.rs

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