Skip to main content

memstead_base/engine/mutation/
update.rs

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