Skip to main content

memstead_base/engine/mutation/
update.rs

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