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