Skip to main content

memstead_base/engine/mutation/
relate.rs

1//! `Engine::relate_entity` and the `relate` alias — append / remove a
2//! single edge between two entities.
3
4use std::path::Path;
5
6use crate::engine_fallback_type;
7use crate::entity::parser::parse_markdown;
8use crate::entity::store_builder::push_entities_into_store;
9use crate::entity::{Entity, EntityId, Relationship, normalise_description};
10use crate::ops::WarningHint;
11use crate::provenance::{Provenance, ProvenanceKind};
12use crate::runtime_validator::{
13    CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
14    validate_rel_type,
15};
16use crate::vcs::{Actor, ClientId, CommitContext};
17use crate::workspace::MountCapability;
18use memstead_schema::SchemaRef;
19
20use super::super::{Engine, EngineError, RelateAction, RelateEntityArgs, RelateEntityOutcome};
21use super::{
22    make_stub, unknown_type_error, validate_description_posture, validate_relation_target_grammar,
23};
24
25/// A fully validated relate — every gate has passed and the source
26/// entity's next markdown is generated, but nothing has been written
27/// to the store, the disk, or the mem-repo yet. `stage_prepared_relate`
28/// performs the store-side effects and the (uncommitted) file write;
29/// the caller then commits and applies via
30/// `apply_prepared_relate_to_store`.
31pub(super) struct PreparedRelate {
32    pub(super) mount_idx: usize,
33    pub(super) source_mem: String,
34    pub(super) from: EntityId,
35    pub(super) to: EntityId,
36    pub(super) rel_type: String,
37    pub(super) action: RelateAction,
38    pub(super) file_path: String,
39    pub(super) markdown: String,
40    pub(super) warnings: Vec<WarningHint>,
41    /// `Some` when the add path must materialise a forward-reference
42    /// stub for an absent target. Prepare plans the stub; the stage
43    /// step upserts it — prepare itself never mutates the store, so a
44    /// refused batch has nothing to undo from prepares alone.
45    pub(super) stub_target: Option<EntityId>,
46    /// The planned stub's kind: `ForwardReference` for a genuinely
47    /// absent target, `LoadTime` for a storage-verified target in a
48    /// deferred mem (the stub is only the until-load representation —
49    /// flywheel W7/02). Meaningless when `stub_target` is `None`.
50    pub(super) stub_kind: crate::entity::StubKind,
51    pub(super) type_def: std::sync::Arc<memstead_schema::TypeDefinition>,
52}
53
54/// What `prepare_relate` resolved to.
55pub(super) enum RelatePrepareOutcome {
56    /// No-op path (idempotent re-add / absent remove): the complete
57    /// outcome, with its typed no-op warning and an empty
58    /// `write_id` — nothing to write or commit.
59    Done(RelateEntityOutcome),
60    /// A real edge change, validated and ready to stage.
61    Prepared(PreparedRelate),
62}
63
64impl Engine {
65    /// Add or remove a typed relationship on `args.source`.
66    ///
67    /// Cross-mem relate is policy-gated through
68    /// [`Engine::cross_mem_link_allowed`] — the workspace's
69    /// `[cross_mem_links]` table (or per-create-rule
70    /// `default_cross_links` synthesis) decides whether the edge is
71    /// permitted. Disallowed pairings surface
72    /// [`EngineError::CrossMemLinkNotAllowed`]. Cross-mem relate
73    /// only writes the source entity's markdown — the target mem is
74    /// never written to. Auto-stub for absent targets works for
75    /// Write target mems; ReadOnly target mems reject absent
76    /// targets with [`EngineError::CrossMemTargetNotFound`] because
77    /// the engine cannot persist a stub through the read-only
78    /// boundary.
79    ///
80    /// Schema-undeclared rel types surface either as validation
81    /// errors (strict mode) or as ride-along warnings on the outcome
82    /// (open mode).
83    pub fn relate_entity(
84        &mut self,
85        args: RelateEntityArgs,
86        actor: Actor,
87        client: Option<&ClientId>,
88        note: Option<&str>,
89    ) -> Result<RelateEntityOutcome, EngineError> {
90        // Short ids on either end resolve (or refuse) before anything
91        // reads their mems; the announcements lead the warnings.
92        let mut args = args;
93        let (source, source_hint) = self.resolve_entity_id(&args.source)?;
94        args.source = source;
95        let (target, target_hint) = self.resolve_entity_id(&args.target)?;
96        args.target = target;
97        let source_mem = args.source.mem().to_string();
98        let target_mem = args.target.mem().to_string();
99
100        // Reload-before-operation: reload the source mem (and the
101        // target mem, when distinct) if a sibling advanced either
102        // ref, so the source `expected_hash` compare and the target
103        // existence/stub decisions below run against current truth.
104        // The drift notice rides the outcome's `warnings`. Hoisted
105        // out of `prepare_relate` so `batch_relate` can probe every
106        // touched mem exactly once up front instead of per entry.
107        let mut drift_warnings: Vec<WarningHint> =
108            source_hint.into_iter().chain(target_hint).collect();
109        drift_warnings.extend(self.reload_if_stale(Some(&source_mem)));
110        if target_mem != source_mem && !self.mem_is_deferred(&target_mem) {
111            // A DEFERRED target mem is deliberately not probed here:
112            // the funnel's phase-0 trigger would full-load it, and
113            // target verification must never cost a mem's load
114            // (flywheel W7/02 — the storage check below answers the
115            // existence and type questions against the branch tree /
116            // filesystem instead).
117            drift_warnings.append(&mut self.reload_if_stale(Some(&target_mem)));
118        }
119        // An ACYCLIC rel-type's cycle guard walks the whole rel-type
120        // subgraph (`would_cycle`), and a cycle can pass through any
121        // mem — an edge on a deferred (lazy, unloaded) mem's entity is
122        // invisible to a walk over the endpoint mems alone, so an add
123        // an eager boot refuses would land silently and corrupt an
124        // innocent edge on the next full load (the fourth lazy-mount
125        // grade demonstrated exactly that on a three-mem chain). The
126        // same holds for a rel-type in an `acyclic_sets` set, whose
127        // guard walks the set's UNION subgraph, and for declared
128        // aggregate signals on either endpoint's schema: the
129        // threshold-crossing diff counts edges that can originate in
130        // any mem, so a partial store silently mis-levels it. Full
131        // load before the guard; writes touching neither skip the
132        // cost.
133        let cycle_guard_needs_full = !args.remove
134            && self.schemas.get(&source_mem).is_some_and(|s| {
135                s.relationship_acyclic(&args.rel_type)
136                    || s.acyclic_set_containing(&args.rel_type).is_some()
137            });
138        let signal_diff_needs_full = [source_mem.as_str(), args.target.mem()].iter().any(|m| {
139            self.schemas
140                .get(*m)
141                .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
142        });
143        if cycle_guard_needs_full || signal_diff_needs_full {
144            self.ensure_mems_loaded(None);
145        }
146
147        let dry_run = args.dry_run;
148        let prepared = match self.prepare_relate(args, drift_warnings)? {
149            RelatePrepareOutcome::Done(outcome) => {
150                // Derivation re-baseline (agent-trust plan 12): on a
151                // derivation-declared rel-type, the duplicate-add
152                // no-op's ONE effect is refreshing the edge's
153                // baseline to the target's current hash — the agent's
154                // explicit "reviewed, still holds". Sidecar-only:
155                // `_hash` unchanged, the edge unchanged; the response
156                // states the refresh (warning + the sidecar commit's
157                // sha) instead of a bare no-op. Undeclared rel-types
158                // keep today's exact no-op response; rehearsals
159                // refresh nothing.
160                if !dry_run
161                    && matches!(
162                        outcome.action,
163                        super::super::RelateAction::NoOpAlreadyPresent
164                    )
165                {
166                    return self.refresh_derivation_baseline_on_noop(outcome, actor, client, note);
167                }
168                return Ok(outcome);
169            }
170            RelatePrepareOutcome::Prepared(p) => p,
171        };
172
173        // Rehearsal: the full validation stage ran (identical refusals
174        // and warnings, would-be stub included via the prepared
175        // AUTO_STUB_CREATED warning) — stop before any write. `_hash`
176        // reports the PROSPECTIVE post-write hash; `write_id` stays
177        // empty (the marker form). Nothing staged, committed, or
178        // stubbed.
179        if dry_run {
180            let parse_result = parse_markdown(
181                &prepared.markdown,
182                &prepared.file_path,
183                prepared.type_def.as_ref(),
184                &prepared.source_mem,
185            )
186            .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
187            // Identical-warnings contract: the real path appends the
188            // `require_notes` nudge after its commit; the rehearsal of
189            // a would-be commit carries the same warning.
190            let mut warnings = prepared.warnings;
191            if let Some(w) = self.note_missing_warning("relate_entity", note) {
192                warnings.push(w);
193            }
194            return Ok(RelateEntityOutcome {
195                from: prepared.from,
196                to: prepared.to,
197                rel_type: prepared.rel_type,
198                action: prepared.action,
199                content_hash: parse_result.entity.content_hash,
200                write_id: String::new(),
201                source: "explicit".to_string(),
202                orphan_stubs_removed: Vec::new(),
203                warnings,
204            });
205        }
206
207        // Aggregate signals: capture both endpoints' levels before the
208        // store mutates — the entities a relate can move are exactly
209        // the edge's endpoints. Diffed after the write below.
210        let signal_snapshot = crate::ops::signals::snapshot_levels(
211            &self.store,
212            &self.schemas,
213            [&prepared.from, &prepared.to],
214        );
215
216        self.stage_prepared_relate(&prepared)?;
217
218        // Derivation baseline (agent-trust plan 12): an explicit add
219        // on a declared rel-type records the target's CURRENT content
220        // hash ("" for a just-stubbed absent target — deriving from
221        // nothing, honestly); a remove prunes the row. Staged into
222        // the same pending set so baseline and edge ride one commit.
223        if let Some(schema) = self.schemas.get(&prepared.source_mem)
224            && super::rel_type_declares_derivation(schema, &prepared.rel_type)
225        {
226            let backend = self.mounts[prepared.mount_idx].backend.as_ref();
227            let (from, rel, to) = (
228                prepared.from.to_string(),
229                prepared.rel_type.clone(),
230                prepared.to.to_string(),
231            );
232            match prepared.action {
233                RelateAction::Added => {
234                    let hash = self
235                        .store
236                        .get(&prepared.to)
237                        .map(|e| e.content_hash.clone())
238                        .unwrap_or_default();
239                    super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
240                }
241                RelateAction::Removed => {
242                    super::stage_derivation_sidecar(backend, |s| s.remove(&from, &rel, &to))?;
243                }
244                _ => {}
245            }
246        }
247
248        let backend = self.mounts[prepared.mount_idx].backend.as_ref();
249        let commit_subject = format!("memstead: relate {}", prepared.from);
250        let ctx = CommitContext {
251            actor,
252            client: client.cloned(),
253            tool: Some("relate_entity"),
254            note: note.map(String::from),
255            role: self.current_role,
256            identity: self.current_identity.clone(),
257            logical_operation_id: None,
258            entity_ids: None,
259        };
260        let write_id = backend.commit(&commit_subject, &ctx)?;
261
262        backend.append_provenance(
263            &Provenance::new(
264                std::time::SystemTime::now(),
265                ProvenanceKind::Relate,
266                Some(prepared.from.to_string()),
267                actor,
268                client.cloned(),
269                note.map(String::from),
270            )
271            .with_role(self.current_role)
272            .with_identity(self.current_identity.clone()),
273        )?;
274
275        self.record_self_write(prepared.mount_idx, &write_id);
276        let stamp_warnings = self.stamp_mutation_versions(prepared.mount_idx);
277
278        let content_hash = self.apply_prepared_relate_to_store(&prepared)?;
279
280        // On the `--remove` path, the edge we just dropped may
281        // have been the last incoming edge to a stub. The orphan-stub
282        // GC hook fired from `memstead_delete` already; mirror it here so
283        // every mutation that can leave orphans cleans them up.
284        // Scoped sweep — only inspect the just-severed target. The
285        // only possible new orphan from a relate-remove is the
286        // target whose incoming edge we removed; checking the entire
287        // store would catch pre-existing orphans which aren't this
288        // mutation's responsibility (and which `memstead_delete`'s full
289        // sweep also leaves alone before its own removal). Funnels
290        // through the shared `gc_orphan_stubs_among` predicate so the
291        // relate-remove, delete, and update-via-alias-resync paths
292        // can't drift on what counts as a GC-able orphan.
293        let orphan_stubs_removed: Vec<EntityId> =
294            if matches!(prepared.action, RelateAction::Removed) {
295                super::gc_orphan_stubs_among(&mut self.store, std::iter::once(&prepared.to))
296            } else {
297                Vec::new()
298            };
299
300        self.invalidate_communities();
301        // Incremental (flywheel W8/01): only the SOURCE entity's file
302        // was rewritten; stub targets and GC'd orphan stubs were never
303        // indexed.
304        self.maintain_search_indexes(std::slice::from_ref(&prepared.from));
305
306        let PreparedRelate {
307            from,
308            to,
309            rel_type,
310            action,
311            mut warnings,
312            ..
313        } = prepared;
314
315        warnings.extend(stamp_warnings);
316
317        // Signal crossings ride the out-of-band warning channel beside
318        // the success payload — never error-shaped, never changing the
319        // mutation's success semantics.
320        warnings.extend(crate::ops::signals::crossing_warnings(
321            &self.store,
322            &self.schemas,
323            &signal_snapshot,
324        ));
325
326        // `require_notes` provenance nudge — single engine-level
327        // enforcement point. Only reached on the real-commit path
328        // (Added / Removed); the NoOpAlreadyPresent / NoOpAbsent branches
329        // return early above with an empty `write_id` and never demand
330        // a note (nothing landed to attribute).
331        if let Some(w) = self.note_missing_warning("relate_entity", note) {
332            warnings.push(w);
333        }
334
335        Ok(RelateEntityOutcome {
336            from,
337            to,
338            rel_type,
339            action,
340            content_hash,
341            write_id,
342            source: "explicit".to_string(),
343            warnings,
344            orphan_stubs_removed,
345        })
346    }
347
348    /// The duplicate-add re-baseline (agent-trust plan 12). Called
349    /// from the `NoOpAlreadyPresent` path when the rel-type is
350    /// derivation-declared: stages a sidecar-only refresh of the
351    /// edge's baseline to the target's current hash and commits it
352    /// (the anchor-only-update precedent — a real persisted effect
353    /// rides a real commit), then returns the outcome with the
354    /// refresh STATED (`DERIVATION_BASELINE_REFRESHED` warning + the
355    /// sidecar commit's sha). `_hash` and the edge are untouched. On
356    /// an undeclared rel-type the outcome passes through unchanged —
357    /// today's exact no-op response.
358    fn refresh_derivation_baseline_on_noop(
359        &mut self,
360        mut outcome: RelateEntityOutcome,
361        actor: Actor,
362        client: Option<&ClientId>,
363        note: Option<&str>,
364    ) -> Result<RelateEntityOutcome, EngineError> {
365        let source_mem = outcome.from.mem().to_string();
366        let declared = self
367            .schemas
368            .get(&source_mem)
369            .is_some_and(|s| super::rel_type_declares_derivation(s, &outcome.rel_type));
370        if !declared {
371            return Ok(outcome);
372        }
373        let Some(mount_idx) = self.mounts.iter().position(|m| m.mount.mem == source_mem) else {
374            return Ok(outcome);
375        };
376        let hash = self
377            .store
378            .get(&outcome.to)
379            .map(|e| e.content_hash.clone())
380            .unwrap_or_default();
381        let (from, rel, to) = (
382            outcome.from.to_string(),
383            outcome.rel_type.clone(),
384            outcome.to.to_string(),
385        );
386        let backend = self.mounts[mount_idx].backend.as_ref();
387        super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
388        let ctx = CommitContext {
389            actor,
390            client: client.cloned(),
391            tool: Some("relate_entity"),
392            note: note.map(String::from),
393            role: self.current_role,
394            identity: self.current_identity.clone(),
395            logical_operation_id: None,
396            entity_ids: None,
397        };
398        let write_id = backend.commit(
399            &format!("memstead: derivation re-baseline {}", outcome.from),
400            &ctx,
401        )?;
402        self.record_self_write(mount_idx, &write_id);
403        // `relate_entity` returns early into this path, so the stamp call on
404        // the ordinary path never runs and its report has to be collected
405        // here (04/03, criterion 3, found by the plan's re-grade).
406        outcome
407            .warnings
408            .extend(self.stamp_mutation_versions(mount_idx));
409        outcome.write_id = write_id;
410        outcome
411            .warnings
412            .push(WarningHint::DerivationBaselineRefreshed {
413                from: outcome.from.clone(),
414                rel_type: outcome.rel_type.clone(),
415                to: outcome.to.clone(),
416            });
417        Ok(outcome)
418    }
419
420    /// Every validation gate and the mutation plan for one relate —
421    /// shared verbatim by the single-item path above and
422    /// [`Self::batch_relate`], so batching can never drift from the
423    /// single-item gates. Never mutates the store, writes no file,
424    /// commits nothing: refusals are side-effect-free by
425    /// construction. The reload-before-operation probe is the
426    /// caller's job (hoisted so a batch probes each mem once).
427    fn prepare_relate(
428        &mut self,
429        args: RelateEntityArgs,
430        mut drift_warnings: Vec<WarningHint>,
431    ) -> Result<RelatePrepareOutcome, EngineError> {
432        let mut args = args;
433        let source_mem = args.source.mem().to_string();
434        let target_mem = args.target.mem().to_string();
435
436        // Target-id grammar gate (shared helper, also called from
437        // `Engine::create_entity` for inline relations so both
438        // gateways trip the same envelope). Source-id grammar is
439        // implicit — a malformed source surfaces as `ENTITY_NOT_FOUND`
440        // because it can never have been created.
441        //
442        // The grammar check runs BEFORE the cross-mem policy check:
443        // a bare-string target with no `--` separator (e.g.
444        // `bad target`) parses as `mem: ""`, `path: "bad target"`,
445        // and without this ordering would surface a cross-mem
446        // policy error against an empty mem name — pointing the
447        // agent at workspace policy when the actual fix is a
448        // malformed id. The grammar check is intrinsic to the target
449        // id; it doesn't need to know which mem the target lives
450        // in.
451        validate_relation_target_grammar(&args.target)?;
452
453        // Track whether the cross-mem target's mem is unmounted —
454        // we deferred the warning emission to the canonical
455        // `warnings` vec initialisation below, but the policy / RO
456        // gates fire first to keep the refusal-before-warning ordering:
457        // a policy refusal preempts the warning.
458        let mut target_mem_uncreated = false;
459        if source_mem != target_mem {
460            // Policy gates *new* edges only — remove is structurally
461            // cleanup. The same convention governs the acyclic, shape,
462            // and schema gates below (each one wraps `if !args.remove`).
463            // Without this bypass, a workspace whose cross-mem grant
464            // was revoked while edges still existed gets wedged: the
465            // grant must be re-introduced just to delete the data it
466            // permitted, then re-revoked. The gate-on-add
467            // rule holds because `cross_mem_links: named` semantically reads
468            // as "only these new edges may be created", not "these
469            // edges may exist."
470            if !args.remove {
471                super::validate_cross_mem_add_policy(self, &source_mem, &args.target)?;
472            }
473            // ReadOnly target mem: the engine has no write access to
474            // persist a stub there, so the target must already exist
475            // before relate. (Same-mem and cross-mem-to-Write
476            // both retain the auto-stub mechanic below.) The add path
477            // already refused above via the shared funnel; this check
478            // stays unconditional so the remove path keeps its
479            // pre-funnel behaviour.
480            if let Some(mount) = self.mount(&target_mem)
481                && mount.capability == MountCapability::ReadOnly
482                && !self.store.contains(&args.target)
483                && !matches!(
484                    super::probe_deferred_target(self, &args.target)?,
485                    super::DeferredTargetProbe::Exists
486                )
487            {
488                return Err(EngineError::CrossMemTargetNotFound {
489                    target_id: args.target.to_string(),
490                    target_mem: target_mem.clone(),
491                });
492            }
493            // The target mem isn't mounted in the workspace
494            // at all. Policy admitted the edge so the relate must
495            // succeed and auto-stub; surface a warning so the operator
496            // can distinguish a typo from a deliberate forward
497            // reference. The auto-stub still lands via the
498            // `AutoStubCreated` path below; this layered warning is
499            // additive observability.
500            if self.mount(&target_mem).is_none() {
501                target_mem_uncreated = true;
502            }
503        }
504
505        // Canonicalise rel_type to UPPER_SNAKE_CASE so the schema lookup,
506        // stored edge, and response all see the same wire-contract form
507        // ("case-insensitive on input"). Syntax errors (non-letter
508        // characters) fall through to the strict-mode schema check below,
509        // which surfaces them as INVALID_REL_TYPE with the declared
510        // vocabulary.
511        if let Ok(canonical) = crate::entity::id::validate_rel_type(&args.rel_type) {
512            args.rel_type = canonical;
513        }
514        // Normalise the description at the boundary so empty /
515        // whitespace-only strings collapse to `None` before the
516        // posture check and before the renderer ever sees them.
517        args.description = normalise_description(args.description.as_deref());
518
519        let mount_idx = self
520            .mounts
521            .iter()
522            .position(|m| m.mount.mem == source_mem)
523            .ok_or_else(|| self.unknown_mem_error(&source_mem))?;
524        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
525            return Err(EngineError::ReadOnlyMount(source_mem));
526        }
527
528        let schema = self
529            .schemas
530            .get(&source_mem)
531            .expect("schema present for every registered mount");
532
533        // Determine whether this is a cross-mem edge to a mem
534        // pinning a schema with a *different name*. Same-name (any
535        // version pair — a schema name is a domain) and same-mem
536        // stay on the intra-mem validation path, governed by the
537        // source mem's pinned version; cross-different-schema
538        // routes vocabulary and shape checks through the source
539        // schema's `cross_mem_relationships:` section.
540        //
541        // If the target mem is not mounted (unknown to the engine
542        // — typically only in malformed callers), there is no target
543        // schema to consult and the validation falls back to the
544        // intra-mem path. Real workspaces always mount the target
545        // mem before relating.
546        let target_schema_ref: Option<SchemaRef> = if source_mem == target_mem {
547            None
548        } else {
549            // Loaded mems answer from the schema catalogue; an
550            // unmounted mem with discoverable storage answers from
551            // its stored config's pin (flywheel W7/02) — see
552            // `target_schema_ref_for_routing`.
553            super::target_schema_ref_for_routing(self, &target_mem)
554        };
555        let cross_mem_different = match (&target_schema_ref, schema.id()) {
556            (Some(target), (src_name, _)) => target.name != src_name,
557            (None, _) => false,
558        };
559
560        let mut warnings: Vec<WarningHint> = Vec::new();
561        // Reload-before-operation drift notice, surfaced first.
562        warnings.append(&mut drift_warnings);
563        // Vocabulary check: intra-mem flow consults the source
564        // schema's `relationships.definitions`; cross-different-schema
565        // skips this entirely (the cross-mem entry's `definitions`
566        // are the sole authority — see the add-path check below).
567        if !cross_mem_different {
568            match validate_rel_type(&args.rel_type, schema.as_ref())? {
569                RelationshipCheck::Ok => {}
570                RelationshipCheck::OpenWarning(message) => {
571                    warnings.push(WarningHint::UndeclaredRelationshipOpen {
572                        rel_type: args.rel_type.clone(),
573                        message,
574                    });
575                }
576            }
577        }
578
579        // Clone the source entity early so subsequent mutable
580        // operations on `self.store` (the stub-creation upsert below)
581        // don't conflict with the borrow.
582        let entity: Entity = self
583            .store
584            .get(&args.source)
585            .ok_or_else(|| EngineError::NotFound {
586                id: args.source.to_string(),
587            })?
588            .clone();
589
590        // Stubs have no `entity_type`, so the schema lookup below
591        // would surface a cryptic `UnknownType { name: "" }`. Surface
592        // the actual constraint instead — a stub source has no body
593        // to write to and no schema-resolved type to validate
594        // against. Promotion via `memstead_create` adopts the stub's
595        // incoming references and lets the agent re-issue the relate
596        // against a real entity.
597        if entity.stub {
598            return Err(EngineError::StubCannotRelate {
599                id: args.source.to_string(),
600            });
601        }
602
603        let target_type = self
604            .store
605            .get(&args.target)
606            .map(|e| e.entity_type.clone())
607            .filter(|t| !t.is_empty());
608        // Storage verification for a target in a DEFERRED mem
609        // (flywheel W7/02): the store cannot answer for an unloaded
610        // mem, so existence comes from the cheap storage probe and —
611        // when the shape check will need it — the type from the one
612        // resolved blob. Neither triggers the mem's load.
613        let target_verified_in_storage = if target_type.is_none() && target_mem != source_mem {
614            matches!(
615                super::probe_deferred_target(self, &args.target)?,
616                super::DeferredTargetProbe::Exists
617            )
618        } else {
619            false
620        };
621        let target_type = match target_type {
622            Some(t) => Some(t),
623            None if target_verified_in_storage && !args.remove => {
624                super::peek_deferred_target_type(self, &args.target)?
625            }
626            None => None,
627        };
628        if target_verified_in_storage {
629            // A verified target's mem is not "uncreated" — for an
630            // unmounted mem the discovery hook just found its storage
631            // and the entity in it; the layered warning would claim
632            // the opposite.
633            target_mem_uncreated = false;
634        }
635        // Shape validation is add-only. Edges that violated the
636        // schema's shape before constraints landed must remain
637        // removable through `memstead_relate remove=true` — otherwise the
638        // graph carries unfixable shape drift. The health scan
639        // surfaces the existing violations so an agent can run the
640        // cleanup pass. The same posture applies to cross-mem
641        // vocabulary: the cleanup path stays permissive so
642        // pre-tightening edges can be dropped without first
643        // re-declaring them in the source schema.
644        // Per-edge description posture (intra-mem and cross-mem).
645        // Add-only — the remove path stays permissive so pre-tightening
646        // edges remain droppable (mirrors the shape-validation posture
647        // below). Posture is a no-op for rel-types not declared in the
648        // schema; the vocabulary gate runs first and surfaces those.
649        if !args.remove {
650            validate_description_posture(
651                self,
652                &args.rel_type,
653                args.description.as_deref(),
654                &source_mem,
655                &target_mem,
656                &args.source,
657                &args.target,
658            )?;
659            // Refuse
660            // explicit `memstead_relate` calls for rel-types whose schema
661            // declares `manual_authoring: forbidden`. The body-link →
662            // relation alias machinery synthesises these relations
663            // from wiki-links via a separate path that doesn't go
664            // through this validator, so the alias contract stays
665            // intact.
666            super::validate_manual_authoring_posture(
667                self,
668                &args.rel_type,
669                &source_mem,
670                &args.source,
671                &args.target,
672            )?;
673        }
674
675        if !args.remove {
676            if cross_mem_different {
677                // Safe-by-construction: `cross_mem_different` only
678                // becomes true when `target_schema_ref` is `Some`.
679                let target_ref = target_schema_ref
680                    .as_ref()
681                    .expect("target_schema_ref is Some when cross_mem_different");
682                match validate_cross_mem_edge(
683                    &args.rel_type,
684                    entity.entity_type.as_str(),
685                    target_type.as_deref(),
686                    schema.as_ref(),
687                    target_ref,
688                ) {
689                    CrossMemRelCheck::Ok => {}
690                    CrossMemRelCheck::EdgeNotDeclared => {
691                        let (src_name, src_version) = schema.id();
692                        return Err(EngineError::CrossMemEdgeNotDeclared {
693                            source_schema: SchemaRef::new(src_name, src_version).as_display(),
694                            target_schema: target_ref.as_display(),
695                            rel_type: args.rel_type.clone(),
696                            from_id: args.source.to_string(),
697                            to_id: args.target.to_string(),
698                        });
699                    }
700                    CrossMemRelCheck::Invalid(v) => {
701                        return Err(EngineError::Validation(v));
702                    }
703                }
704            } else {
705                validate_rel_shape(
706                    &args.rel_type,
707                    entity.entity_type.as_str(),
708                    target_type.as_deref(),
709                    schema.as_ref(),
710                )?;
711            }
712        }
713
714        if let Some(expected) = args.expected_hash.as_deref()
715            && entity.content_hash != expected
716        {
717            return Err(EngineError::HashMismatch {
718                id: args.source.to_string(),
719                current: entity.content_hash.clone(),
720                is_stub: entity.stub,
721            });
722        }
723
724        // Cycle family on the real-add path — the self-loop refusal
725        // (listed no-self-loop rel-types) and the acyclic long-cycle refusal,
726        // via the shared gate every edge-writing verb runs
727        // (`validate_edge_acyclicity`). Skipped on the remove path:
728        // removal can only break cycles, never close one.
729        if !args.remove {
730            super::validate_edge_acyclicity(
731                &self.store,
732                schema,
733                &args.source,
734                entity.entity_type.as_str(),
735                &args.target,
736                &args.rel_type,
737            )?;
738        }
739
740        let type_def = schema
741            .get_type(&entity.entity_type)
742            .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
743
744        let mut next = entity.clone();
745        let already = next
746            .relationships
747            .iter()
748            .position(|r| r.rel_type == args.rel_type && r.target == args.target);
749
750        // Alias-existence RESTRICT semantics on the remove path. Under
751        // set-membership semantics a body wiki-link `[[X]]` aliases the
752        // *set* of relations to X; removing one relation is fine as
753        // long as another survives. Refuse only when the removal would
754        // empty the relation-set to `b` while body wiki-links to `b`
755        // are still present in the source entity's section bodies.
756        if args.remove && already.is_some() {
757            let other_relation_to_target_exists = entity
758                .relationships
759                .iter()
760                .any(|r| r.target == args.target && r.rel_type != args.rel_type);
761            if !other_relation_to_target_exists {
762                // Read-side scan over the source entity's existing
763                // body. Use the lenient decoder so on-disk drift on
764                // pre-strict entities continues to surface in the
765                // body-link survival check — the mutation gate sits
766                // on the create/update path, not on a relate-remove
767                // scan of historical state.
768                let mut surviving_sections: Vec<String> = Vec::new();
769                for (section_key, body) in entity.sections.iter() {
770                    let inline_targets =
771                        crate::entity::parser::extract_inline_links_lenient(body, &source_mem);
772                    if inline_targets.iter().any(|t| t == &args.target) {
773                        surviving_sections.push(section_key.clone());
774                    }
775                }
776                if !surviving_sections.is_empty() {
777                    return Err(EngineError::RelationHasBodyLinks {
778                        from_id: args.source.to_string(),
779                        to_id: args.target.to_string(),
780                        rel_type: args.rel_type.clone(),
781                        body_links: surviving_sections,
782                    });
783                }
784            }
785        }
786
787        let action = if args.remove {
788            match already {
789                Some(idx) => {
790                    next.relationships.remove(idx);
791                    RelateAction::Removed
792                }
793                None => RelateAction::NoOpAbsent,
794            }
795        } else {
796            match already {
797                Some(_) => RelateAction::NoOpAlreadyPresent,
798                None => {
799                    next.relationships.push(Relationship {
800                        rel_type: args.rel_type.clone(),
801                        target: args.target.clone(),
802                        description: normalise_description(args.description.as_deref()),
803                    });
804                    RelateAction::Added
805                }
806            }
807        };
808
809        // Block-tier `required_outgoing` on the remove path: dropping
810        // this edge must not leave a `severity: block` block
811        // unsatisfied — the same refusal create/update raise when the
812        // written edge set falls short. Warn-tier blocks stay silent
813        // here (the health sweep owns standing warn-tier findings;
814        // relate-remove has never warned and the no-noise rule keeps
815        // it that way).
816        if matches!(action, RelateAction::Removed) {
817            let blocked: Vec<_> =
818                crate::ops::health::unsatisfied_required_outgoing(&next, type_def.as_ref())
819                    .into_iter()
820                    .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
821                    .collect();
822            if !blocked.is_empty() {
823                return Err(EngineError::RequiredOutgoingUnsatisfied {
824                    entity_type: next.entity_type.clone(),
825                    entity_id: args.source.to_string(),
826                    missing: blocked,
827                });
828            }
829            // Edge-dependent declared constraints: removing this edge
830            // must not un-back a block-tier `enum_from_neighbour`
831            // value (the edge to the enumerating neighbour is what
832            // backs it). Edge-independent forms are filtered out —
833            // their verdict is identical before and after a relate,
834            // so refusing here would block unrelated repair work.
835            // `None` check provider: only `enum_from_neighbour` is
836            // kept below, so the checks-gated form never evaluates on
837            // this path (a relate changes edges, not the gated field).
838            let blocked: Vec<_> = crate::ops::health::unsatisfied_constraints(
839                &self.store,
840                &next,
841                type_def.as_ref(),
842                Some(&args.source),
843                None,
844            )
845            .into_iter()
846            .filter(|v| {
847                matches!(
848                    v,
849                    crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { .. }
850                ) && v.severity() == memstead_schema::ConstraintSeverity::Block
851            })
852            .collect();
853            if !blocked.is_empty() {
854                return Err(EngineError::ConstraintUnsatisfied {
855                    entity_type: next.entity_type.clone(),
856                    entity_id: args.source.to_string(),
857                    violations: blocked,
858                });
859            }
860        }
861
862        // Plan a stub for an absent target on the real-add path.
863        // Skipped on no-op paths (NoOpAlreadyPresent / NoOpAbsent — the
864        // edge isn't actually being added) and on the remove path (the
865        // edge being dropped, no need to manifest the target). This is
866        // the engine's target-materialisation step on the add path;
867        // prepare only records the decision — the upsert happens in
868        // `stage_prepared_relate` so prepare stays store-neutral.
869        // The auto-stub surfaces as a typed `AutoStubCreated` warning
870        // on the response's `warnings[]` — the deprecated top-level
871        // `stub_warning` field that pre-Item-03 carried this fact has
872        // been removed, so every diagnostic now follows the uniform
873        // `{ code, message, details }` warning shape.
874        let mut stub_target: Option<EntityId> = None;
875        let mut stub_kind = crate::entity::StubKind::ForwardReference;
876        if matches!(action, RelateAction::Added) && !self.store.contains(&args.target) {
877            stub_target = Some(args.target.clone());
878            if target_verified_in_storage {
879                // The target EXISTS — storage said so. The in-store
880                // stub is only plan 01's until-load representation of
881                // a cross-mem link into a deferred mem (it resolves
882                // when the mem loads), so it carries the load-time
883                // kind and no AUTO_STUB_CREATED warning: that warning
884                // tells an agent the target awaits creation, which
885                // would be false here.
886                stub_kind = crate::entity::StubKind::LoadTime;
887            } else {
888                // Rehearsal honesty: on the dry-run path nothing is
889                // written, so the warning's `pending` flag branches the
890                // message to the would-be form — the code stays
891                // AUTO_STUB_CREATED either way.
892                warnings.push(WarningHint::AutoStubCreated {
893                    stub_id: args.target.clone(),
894                    pending: args.dry_run,
895                });
896            }
897            // If the target mem is unmounted, the
898            // auto-stub above has no `_mem_schema` resolution. Layer
899            // the typed mem-uncreated warning alongside the
900            // `AutoStubCreated` so the operator sees both signals.
901            // (Never true on the verified branch — a deferred mem is
902            // mounted by definition.)
903            if target_mem_uncreated {
904                warnings.push(WarningHint::CrossMemTargetMemUncreated {
905                    from_mem: source_mem.clone(),
906                    to_mem: target_mem.clone(),
907                    target_id: args.target.clone(),
908                });
909            }
910        }
911
912        // No-op paths skip the disk write so the provenance log doesn't
913        // record a non-event. Return the live `content_hash` so callers
914        // can chain follow-ups without refetching. Surface the no-op as
915        // a typed warning so an agent re-running a pipeline can tell the
916        // call didn't change the graph (mirrors full's wire shape).
917        if matches!(
918            action,
919            RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent
920        ) {
921            match action {
922                RelateAction::NoOpAlreadyPresent => {
923                    warnings.push(WarningHint::DuplicateRelationship {
924                        rel_type: args.rel_type.clone(),
925                        from: args.source.clone(),
926                        to: args.target.clone(),
927                    });
928                }
929                RelateAction::NoOpAbsent => {
930                    warnings.push(WarningHint::NoSuchRelationship {
931                        rel_type: args.rel_type.clone(),
932                        from: args.source.clone(),
933                        to: args.target.clone(),
934                    });
935                }
936                _ => unreachable!(),
937            }
938            return Ok(RelatePrepareOutcome::Done(RelateEntityOutcome {
939                from: args.source,
940                to: args.target,
941                rel_type: args.rel_type,
942                action,
943                content_hash: entity.content_hash.clone(),
944                write_id: String::new(),
945                source: "explicit".to_string(),
946                warnings,
947                // No-op branch: nothing changed in the graph, so the
948                // orphan-stub sweep can't have anything to collect.
949                orphan_stubs_removed: Vec::new(),
950            }));
951        }
952
953        // The relate path rewrites the on-disk file (the
954        // `## Relationships` section materialises from
955        // `next.relationships`), so the schema's `auto_timestamp`
956        // metadata (default schema: `last_modified`) bumps to the
957        // current ISO. Only fires on the commit-producing branch —
958        // the no-op early-return above skips this block, so an
959        // idempotent re-add or NoOpAbsent never advances the stamp.
960        let today = self.now_iso();
961        super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
962
963        let file_path = next.file_path.clone();
964        let markdown = super::render_for_write(&next, type_def.as_ref())?;
965
966        Ok(RelatePrepareOutcome::Prepared(PreparedRelate {
967            mount_idx,
968            source_mem,
969            from: args.source,
970            to: args.target,
971            rel_type: args.rel_type,
972            action,
973            file_path,
974            markdown,
975            warnings,
976            stub_target,
977            stub_kind,
978            type_def,
979        }))
980    }
981
982    /// Perform a prepared relate's pre-commit side effects: upsert the
983    /// planned forward-reference stub (if any) and write the source
984    /// entity's regenerated markdown into the backend's pending
985    /// buffer. Nothing is committed; a caller that aborts afterwards
986    /// rolls back with a store snapshot + `discard_all_pending`.
987    fn stage_prepared_relate(&mut self, p: &PreparedRelate) -> Result<(), EngineError> {
988        if let Some(stub_id) = &p.stub_target {
989            self.store
990                .upsert(stub_id.clone(), make_stub(stub_id, p.stub_kind.clone()));
991        }
992        self.mounts[p.mount_idx]
993            .backend
994            .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())?;
995        Ok(())
996    }
997
998    /// Parse the prepared markdown back and push it into the store
999    /// (replacing the pre-mutation source entity), then re-run the
1000    /// alias-edge remap. Returns the new `content_hash`. In the
1001    /// single-item path this runs after the commit (preserving the
1002    /// pre-split ordering); `batch_relate` runs it immediately after
1003    /// staging each entry so later entries in the same batch validate
1004    /// against this entry's effect — applied-in-order semantics.
1005    fn apply_prepared_relate_to_store(
1006        &mut self,
1007        p: &PreparedRelate,
1008    ) -> Result<String, EngineError> {
1009        let parse_result = parse_markdown(
1010            &p.markdown,
1011            &p.file_path,
1012            p.type_def.as_ref(),
1013            &p.source_mem,
1014        )
1015        .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
1016        let content_hash = parse_result.entity.content_hash.clone();
1017        let fallback = engine_fallback_type();
1018        push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
1019        crate::entity::store_builder::remap_alias_target_edge_sources(
1020            &mut self.store,
1021            &self.schemas,
1022        );
1023        Ok(content_hash)
1024    }
1025
1026    /// Atomic batch relate — the edge-side sibling of
1027    /// [`Self::batch_create`] / [`Self::batch_update`]. One list
1028    /// carrying both additions and removals, **applied in order**:
1029    /// each entry validates against the graph state produced by every
1030    /// prior valid entry (an add followed by a remove of the same edge
1031    /// nets to no edge; an acyclic check sees edges added earlier in
1032    /// the same batch). Per-entry shape mirrors what `relate` accepts.
1033    ///
1034    /// - **All-or-nothing, report-all.** A single invalid entry
1035    ///   refuses the whole batch — no edge changes, no head movement —
1036    ///   and the refusal identifies EVERY failing entry with its typed
1037    ///   `{code, message, details}` envelope, bounded at
1038    ///   [`Self::BATCH_ERROR_REPORT_CAP`] with `errors_suppressed`
1039    ///   counting the rest. An entry after a failing one validates
1040    ///   against the state as of the prior *valid* entries, so a
1041    ///   dependent entry may cascade — every reported code is still a
1042    ///   true refusal of the submitted file.
1043    /// - **One commit per touched mem** (subject
1044    ///   `memstead: batch-relate (N edges)`), per-entry provenance
1045    ///   notes, exactly like the rest of the family. No-op entries
1046    ///   (idempotent re-add / absent remove) report `"noop"` and
1047    ///   produce no write.
1048    /// - Orphan-stub GC runs over every removed edge's target after
1049    ///   the commit, same predicate as the single-item path (the
1050    ///   collected ids are not part of `BatchResult`'s fixed family
1051    ///   shape).
1052    ///
1053    /// **Rehearsal** (`dry_run: true`): the FULL in-order validation
1054    /// pass runs — each entry staged against the state its
1055    /// predecessors produced, identical refusals, identical report-all
1056    /// envelope — then the batch stops before any commit and rolls the
1057    /// staged state back. A legal batch returns the would-be receipt
1058    /// (per-entry actions, would-be `orphan_stubs_removed` computed on
1059    /// the staged state) with the marker form's empty `write_id`; an
1060    /// illegal one returns the same refusal a real call would. No
1061    /// edge, stub, or commit lands.
1062    pub fn batch_relate(
1063        &mut self,
1064        relates: Vec<(RelateEntityArgs, Option<String>)>,
1065        actor: Actor,
1066        client: Option<&ClientId>,
1067        dry_run: bool,
1068    ) -> Result<crate::ops::BatchResult, EngineError> {
1069        if relates.is_empty() {
1070            return Ok(crate::ops::BatchResult {
1071                warnings: Vec::new(),
1072                orphan_stubs_removed: Vec::new(),
1073                errors_suppressed: 0,
1074                applied: true,
1075                results: Vec::new(),
1076                succeeded: 0,
1077                failed: 0,
1078                write_id: String::new(),
1079            });
1080        }
1081
1082        // Reload every touched mem (sources and targets) once, up
1083        // front — the per-entry probe is hoisted out of
1084        // `prepare_relate` for exactly this.
1085        // Short ids on either end resolve once, before the touched-mem
1086        // probe reads each item's mems; an item that does not resolve
1087        // fails as its own error below, never the whole batch.
1088        let mut short_hints: Vec<WarningHint> = Vec::new();
1089        let mut short_errors: Vec<(usize, EngineError)> = Vec::new();
1090        let relates: Vec<(RelateEntityArgs, Option<String>)> = relates
1091            .into_iter()
1092            .enumerate()
1093            .map(|(i, (mut a, n))| {
1094                match self.resolve_entity_id(&a.source) {
1095                    Ok((s, hint)) => {
1096                        a.source = s;
1097                        short_hints.extend(hint);
1098                    }
1099                    Err(e) => short_errors.push((i, e)),
1100                }
1101                match self.resolve_entity_id(&a.target) {
1102                    Ok((t, hint)) => {
1103                        a.target = t;
1104                        short_hints.extend(hint);
1105                    }
1106                    Err(e) => short_errors.push((i, e)),
1107                }
1108                (a, n)
1109            })
1110            .collect();
1111        let mut touched_mems: Vec<String> = relates
1112            .iter()
1113            .flat_map(|(a, _)| [a.source.mem().to_string(), a.target.mem().to_string()])
1114            .collect();
1115        touched_mems.sort();
1116        touched_mems.dedup();
1117        // A mem that is only ever a TARGET in this batch and is
1118        // deferred stays unloaded: target verification runs against
1119        // storage (flywheel W7/02), and the funnel's phase-0 trigger
1120        // would otherwise convert verification into a full load.
1121        // Source mems always reload — a write into a mem needs the mem.
1122        let source_mems: std::collections::HashSet<&str> =
1123            relates.iter().map(|(a, _)| a.source.mem()).collect();
1124        touched_mems.retain(|m| source_mems.contains(m.as_str()) || !self.mem_is_deferred(m));
1125        for m in &touched_mems {
1126            self.reload_if_stale(Some(m));
1127        }
1128        // Same acyclic-guard rule as the single-item path: any added
1129        // edge on an ACYCLIC rel-type (or a rel-type in an
1130        // `acyclic_sets` set, whose guard walks the set's UNION
1131        // subgraph) walks the whole subgraph, so the walk must see
1132        // every mem — deferred (lazy, unloaded) ones included — or a
1133        // cycle through an unloaded mem is admitted (the fifth
1134        // lazy-mount grade demonstrated exactly that through this
1135        // path). Declared signals on either endpoint's schema need
1136        // the full load for the same reason as the single-item path.
1137        if relates.iter().any(|(a, _)| {
1138            (!a.remove
1139                && self.schemas.get(a.source.mem()).is_some_and(|s| {
1140                    s.relationship_acyclic(&a.rel_type)
1141                        || s.acyclic_set_containing(&a.rel_type).is_some()
1142                }))
1143                || [a.source.mem(), a.target.mem()].iter().any(|m| {
1144                    self.schemas
1145                        .get(*m)
1146                        .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1147                })
1148        }) {
1149            self.ensure_mems_loaded(None);
1150        }
1151
1152        // Snapshot for the all-or-nothing rollback: staged entries
1153        // mutate the store as they apply (in-order semantics), so a
1154        // refusal restores this snapshot and discards every backend's
1155        // pending buffer. Any early-return added below MUST do both.
1156        let store_snapshot = self.store.clone();
1157
1158        enum ItemState {
1159            Applied(&'static str),
1160            Noop,
1161            Error,
1162        }
1163        let mut items: Vec<(EntityId, ItemState)> = Vec::with_capacity(relates.len());
1164        let mut prepared: Vec<PreparedRelate> = Vec::new();
1165        let mut notes: Vec<Option<String>> = Vec::new();
1166        let mut errors: Vec<(usize, EngineError)> = Vec::new();
1167
1168        for (i, (args, note)) in relates.into_iter().enumerate() {
1169            let source_id = args.source.clone();
1170            if let Some(pos) = short_errors.iter().position(|(j, _)| *j == i) {
1171                let (_, e) = short_errors.remove(pos);
1172                items.push((source_id, ItemState::Error));
1173                errors.push((i, e));
1174                continue;
1175            }
1176            // Rehearsal is batch-level (the `dry_run` parameter) —
1177            // per-entry dry-run stays forced off; the staging below is
1178            // what gives later entries in-order semantics, and the
1179            // batch-level rollback undoes it.
1180            let mut args = args;
1181            args.dry_run = false;
1182            match self.prepare_relate(args, Vec::new()) {
1183                Ok(RelatePrepareOutcome::Done(_)) => {
1184                    items.push((source_id, ItemState::Noop));
1185                }
1186                Ok(RelatePrepareOutcome::Prepared(p)) => {
1187                    // Stage + apply NOW so later entries validate
1188                    // against this entry's effect (applied-in-order).
1189                    if let Err(e) = self.stage_prepared_relate(&p) {
1190                        self.store = store_snapshot;
1191                        self.discard_all_pending();
1192                        return Err(e);
1193                    }
1194                    if let Err(e) = self.apply_prepared_relate_to_store(&p) {
1195                        self.store = store_snapshot;
1196                        self.discard_all_pending();
1197                        return Err(e);
1198                    }
1199                    // Derivation baseline (plan 12) — same predicate
1200                    // and staging as the single-item path; rides the
1201                    // batch commit, rolls back with a refusal. (Batch
1202                    // no-op entries do NOT re-baseline — the explicit
1203                    // "reviewed, still holds" gesture is the single
1204                    // relate / single-op MCP list.)
1205                    if let Some(schema) = self.schemas.get(&p.source_mem)
1206                        && super::rel_type_declares_derivation(schema, &p.rel_type)
1207                    {
1208                        let backend = self.mounts[p.mount_idx].backend.as_ref();
1209                        let (from, rel, to) =
1210                            (p.from.to_string(), p.rel_type.clone(), p.to.to_string());
1211                        let staged = match p.action {
1212                            RelateAction::Added => {
1213                                let hash = self
1214                                    .store
1215                                    .get(&p.to)
1216                                    .map(|e| e.content_hash.clone())
1217                                    .unwrap_or_default();
1218                                super::stage_derivation_sidecar(backend, |s| {
1219                                    s.set(&from, &rel, &to, &hash)
1220                                })
1221                            }
1222                            RelateAction::Removed => {
1223                                super::stage_derivation_sidecar(backend, |s| {
1224                                    s.remove(&from, &rel, &to)
1225                                })
1226                            }
1227                            _ => Ok(()),
1228                        };
1229                        if let Err(e) = staged {
1230                            self.store = store_snapshot;
1231                            self.discard_all_pending();
1232                            return Err(e);
1233                        }
1234                    }
1235                    let label = match p.action {
1236                        RelateAction::Added => "added",
1237                        RelateAction::Removed => "removed",
1238                        _ => unreachable!("no-ops resolve to Done"),
1239                    };
1240                    items.push((source_id, ItemState::Applied(label)));
1241                    prepared.push(p);
1242                    notes.push(note);
1243                }
1244                Err(e) => {
1245                    items.push((source_id, ItemState::Error));
1246                    errors.push((i, e));
1247                }
1248            }
1249        }
1250
1251        if !errors.is_empty() {
1252            // Refuse the whole batch; roll back every staged entry.
1253            self.store = store_snapshot;
1254            self.discard_all_pending();
1255            let failed = errors.len();
1256            let mut error_map: std::collections::HashMap<usize, EngineError> =
1257                errors.into_iter().collect();
1258            let mut reported = 0usize;
1259            let mut suppressed = 0usize;
1260            let results: Vec<crate::ops::BatchEntry> = items
1261                .into_iter()
1262                .enumerate()
1263                .map(|(i, (id, _))| match error_map.remove(&i) {
1264                    Some(e) => {
1265                        if reported < Self::BATCH_ERROR_REPORT_CAP {
1266                            reported += 1;
1267                            crate::ops::BatchEntry {
1268                                id,
1269                                action: "error".to_string(),
1270                                error: Some(super::update::batch_error_envelope(&e)),
1271                            }
1272                        } else {
1273                            suppressed += 1;
1274                            crate::ops::BatchEntry {
1275                                id,
1276                                action: "error".to_string(),
1277                                error: None,
1278                            }
1279                        }
1280                    }
1281                    None => crate::ops::BatchEntry {
1282                        id,
1283                        action: "not_applied".to_string(),
1284                        error: None,
1285                    },
1286                })
1287                .collect();
1288            return Ok(crate::ops::BatchResult {
1289                warnings: Vec::new(),
1290                orphan_stubs_removed: Vec::new(),
1291                errors_suppressed: suppressed,
1292                applied: false,
1293                results,
1294                succeeded: 0,
1295                failed,
1296                write_id: String::new(),
1297            });
1298        }
1299
1300        // Rehearsal: every entry validated in order against the state
1301        // its predecessors produced and nothing failed — stop before
1302        // any commit. The would-be orphan GC is computed on the staged
1303        // store (honest: it is exactly what the real call would
1304        // collect), then the whole staged state rolls back.
1305        if dry_run {
1306            let removed_targets: Vec<EntityId> = prepared
1307                .iter()
1308                .filter(|p| matches!(p.action, RelateAction::Removed))
1309                .map(|p| p.to.clone())
1310                .collect();
1311            let orphan_stubs_removed =
1312                super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1313            self.store = store_snapshot;
1314            self.discard_all_pending();
1315            let succeeded = items.len();
1316            let results: Vec<crate::ops::BatchEntry> = items
1317                .into_iter()
1318                .map(|(id, state)| crate::ops::BatchEntry {
1319                    id,
1320                    action: match state {
1321                        ItemState::Applied(label) => label.to_string(),
1322                        ItemState::Noop => "noop".to_string(),
1323                        ItemState::Error => unreachable!("refusal path returned above"),
1324                    },
1325                    error: None,
1326                })
1327                .collect();
1328            return Ok(crate::ops::BatchResult {
1329                warnings: Vec::new(),
1330                orphan_stubs_removed,
1331                errors_suppressed: 0,
1332                applied: true,
1333                results,
1334                succeeded,
1335                failed: 0,
1336                write_id: String::new(),
1337            });
1338        }
1339
1340        // --- Commit once per touched mount, in first-seen order. ---
1341        let mut distinct_mounts: Vec<usize> = Vec::new();
1342        for p in &prepared {
1343            if !distinct_mounts.contains(&p.mount_idx) {
1344                distinct_mounts.push(p.mount_idx);
1345            }
1346        }
1347        let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1348        for &m in &distinct_mounts {
1349            // Distinct source ids for this mount (an entity may carry
1350            // several edge changes in one batch).
1351            let mut entity_ids: Vec<String> = Vec::new();
1352            let mut edge_count = 0usize;
1353            for p in prepared.iter().filter(|p| p.mount_idx == m) {
1354                edge_count += 1;
1355                let s = p.from.to_string();
1356                if !entity_ids.contains(&s) {
1357                    entity_ids.push(s);
1358                }
1359            }
1360            let subject = format!("memstead: batch-relate ({edge_count} edges)");
1361            // Per-entry notes ride the batch commit's note record as
1362            // `<id>: <note>` lines (decision 3), keyed by the edge's
1363            // source entity — `append_provenance` is a no-op on the
1364            // git-branch backend. No notes → no note record.
1365            let note_lines: Vec<String> = prepared
1366                .iter()
1367                .zip(notes.iter())
1368                .filter(|(p, _)| p.mount_idx == m)
1369                .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.from)))
1370                .collect();
1371            let ctx = CommitContext {
1372                actor,
1373                client: client.cloned(),
1374                tool: Some("batch_relate"),
1375                note: if note_lines.is_empty() {
1376                    None
1377                } else {
1378                    Some(note_lines.join("\n"))
1379                },
1380                role: self.current_role,
1381                identity: self.current_identity.clone(),
1382                logical_operation_id: None,
1383                entity_ids: Some(entity_ids),
1384            };
1385            match self.mounts[m].backend.commit(&subject, &ctx) {
1386                Ok(sha) => mount_commits.push((m, sha)),
1387                Err(e) => {
1388                    // A commit failed: roll back the store and any
1389                    // still-pending backends. Mems already committed
1390                    // in this loop stay committed (the family's
1391                    // per-mem atomicity).
1392                    self.store = store_snapshot;
1393                    self.discard_all_pending();
1394                    return Err(e.into());
1395                }
1396            }
1397        }
1398
1399        // Provenance per entry, self-write markers per mount.
1400        let mut batch_warnings: Vec<WarningHint> = short_hints;
1401        for (p, note) in prepared.iter().zip(notes.iter()) {
1402            let write_id = mount_commits
1403                .iter()
1404                .find(|(m, _)| *m == p.mount_idx)
1405                .map(|(_, s)| s.clone())
1406                .unwrap_or_default();
1407            self.mounts[p.mount_idx].backend.append_provenance(
1408                &Provenance::new(
1409                    std::time::SystemTime::now(),
1410                    ProvenanceKind::Relate,
1411                    Some(p.from.to_string()),
1412                    actor,
1413                    client.cloned(),
1414                    note.clone(),
1415                )
1416                .with_role(self.current_role)
1417                .with_identity(self.current_identity.clone()),
1418            )?;
1419            self.record_self_write(p.mount_idx, &write_id);
1420            batch_warnings.extend(self.stamp_mutation_versions(p.mount_idx));
1421        }
1422
1423        // Orphan-stub GC over every removed edge's target — same
1424        // scoped sweep and shared predicate as the single-item path.
1425        let removed_targets: Vec<EntityId> = prepared
1426            .iter()
1427            .filter(|p| matches!(p.action, RelateAction::Removed))
1428            .map(|p| p.to.clone())
1429            .collect();
1430        let orphan_stubs_removed =
1431            super::gc_orphan_stubs_among(&mut self.store, removed_targets.iter());
1432
1433        self.invalidate_communities();
1434        self.invalidate_search_indexes();
1435
1436        let write_id = mount_commits
1437            .last()
1438            .map(|(_, s)| s.clone())
1439            .unwrap_or_default();
1440        let succeeded = items.len();
1441        let results: Vec<crate::ops::BatchEntry> = items
1442            .into_iter()
1443            .map(|(id, state)| crate::ops::BatchEntry {
1444                id,
1445                action: match state {
1446                    ItemState::Applied(label) => label.to_string(),
1447                    ItemState::Noop => "noop".to_string(),
1448                    ItemState::Error => unreachable!("refusal path returned above"),
1449                },
1450                error: None,
1451            })
1452            .collect();
1453
1454        Ok(crate::ops::BatchResult {
1455            warnings: batch_warnings,
1456            orphan_stubs_removed,
1457            errors_suppressed: 0,
1458            applied: true,
1459            results,
1460            succeeded,
1461            failed: 0,
1462            write_id,
1463        })
1464    }
1465
1466    /// Positional-args alias for [`Self::relate_entity`]. Bundles
1467    /// the positional inputs into a [`RelateEntityArgs`] (with
1468    /// `expected_hash: None`) and delegates to
1469    /// [`Self::relate_entity`]. The `CommitContext` is destructured
1470    /// into the 4-tuple (actor, client, note) the unified mutation
1471    /// surface accepts.
1472    pub fn relate(
1473        &mut self,
1474        from: &EntityId,
1475        to: &EntityId,
1476        rel_type: &str,
1477        remove: bool,
1478        ctx: &CommitContext<'_>,
1479    ) -> Result<RelateEntityOutcome, EngineError> {
1480        let args = RelateEntityArgs {
1481            source: from.clone(),
1482            expected_hash: None,
1483            rel_type: rel_type.to_string(),
1484            target: to.clone(),
1485            remove,
1486            description: None,
1487            dry_run: false,
1488        };
1489        self.relate_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1490    }
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495
1496    use indexmap::IndexMap;
1497    use tempfile::TempDir;
1498
1499    use crate::backend::MemBackend;
1500    use crate::engine::test_helpers::*;
1501    use crate::engine::{CreateEntityArgs, Engine, EngineError, RelateAction, RelateEntityArgs};
1502    use crate::ops::WarningHint;
1503    use crate::storage::FilesystemMemWriter;
1504    use crate::vcs::{Actor, CommitContext};
1505
1506    #[test]
1507    fn relate_alias_delegates_to_relate_entity() {
1508        // Positional-args alias mirrors full's signature
1509        // `engine.relate(from, to, rel_type, remove, ctx)`. Add an
1510        // edge via the alias and via `relate_entity` and assert
1511        // they reach the same observable post-state.
1512        let tmp = TempDir::new().unwrap();
1513        let mem_dir = tmp.path().to_path_buf();
1514        let writer = FilesystemMemWriter::new(mem_dir.clone());
1515        let mut engine = Engine::from_mounts(vec![(
1516            folder_mount("specs", mem_dir),
1517            Box::new(writer) as Box<dyn MemBackend>,
1518        )])
1519        .unwrap();
1520
1521        // Seed two real entities (no stub).
1522        let a = engine
1523            .create_entity(
1524                CreateEntityArgs {
1525                    anchors: Vec::new(),
1526                    mem: "specs".to_string(),
1527                    title: "A".to_string(),
1528                    entity_type: "spec".to_string(),
1529                    sections: IndexMap::from_iter([
1530                        ("identity".to_string(), "seed identity".to_string()),
1531                        ("purpose".to_string(), "seed purpose".to_string()),
1532                    ]),
1533                    metadata: IndexMap::new(),
1534                    relations: Vec::new(),
1535                    dry_run: false,
1536                },
1537                Actor::Cli,
1538                None,
1539                None,
1540            )
1541            .unwrap();
1542        let b = engine
1543            .create_entity(
1544                CreateEntityArgs {
1545                    anchors: Vec::new(),
1546                    mem: "specs".to_string(),
1547                    title: "B".to_string(),
1548                    entity_type: "spec".to_string(),
1549                    sections: IndexMap::from_iter([
1550                        ("identity".to_string(), "seed identity".to_string()),
1551                        ("purpose".to_string(), "seed purpose".to_string()),
1552                    ]),
1553                    metadata: IndexMap::new(),
1554                    relations: Vec::new(),
1555                    dry_run: false,
1556                },
1557                Actor::Cli,
1558                None,
1559                None,
1560            )
1561            .unwrap();
1562
1563        // Use the positional `relate` alias.
1564        let ctx = CommitContext::internal();
1565        let result = engine.relate(&a.id, &b.id, "PART_OF", false, &ctx).unwrap();
1566        assert_eq!(result.from, a.id);
1567        assert_eq!(result.to, b.id);
1568        assert_eq!(result.rel_type, "PART_OF");
1569        // The edge is in the store post-call.
1570        let outgoing: Vec<_> = engine.store().outgoing(&a.id).to_vec();
1571        assert!(
1572            outgoing
1573                .iter()
1574                .any(|e| e.target == b.id && e.rel_type == "PART_OF")
1575        );
1576    }
1577
1578    #[test]
1579    fn relate_entity_appends_relationship_and_logs_provenance() {
1580        let tmp = TempDir::new().unwrap();
1581        let (mut engine, source) = engine_with_seed(&tmp, "Source");
1582        let (actor, client) = cli_actor();
1583        let target = engine
1584            .create_entity(
1585                empty_create_args("specs", "Target"),
1586                actor,
1587                Some(&client),
1588                None,
1589            )
1590            .unwrap();
1591
1592        let outcome = engine
1593            .relate_entity(
1594                RelateEntityArgs {
1595                    source: source.id.clone(),
1596                    expected_hash: Some(source.content_hash.clone()),
1597                    rel_type: "USES".to_string(),
1598                    target: target.id.clone(),
1599                    remove: false,
1600                    description: None,
1601                    dry_run: false,
1602                },
1603                actor,
1604                Some(&client),
1605                None,
1606            )
1607            .unwrap();
1608        assert_eq!(outcome.action, RelateAction::Added);
1609        assert_ne!(outcome.content_hash, source.content_hash);
1610        // Edge present in store.
1611        let edges = engine.store().outgoing(&source.id);
1612        assert!(
1613            edges
1614                .iter()
1615                .any(|e| e.rel_type == "USES" && e.target == target.id),
1616            "expected USES edge in store"
1617        );
1618        // Provenance log records relate.
1619        let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
1620        assert!(log.contains("\"kind\":\"relate\""));
1621    }
1622
1623    #[test]
1624    fn relate_entity_no_op_when_already_present() {
1625        let tmp = TempDir::new().unwrap();
1626        let (mut engine, source) = engine_with_seed(&tmp, "Already");
1627        let (actor, client) = cli_actor();
1628        let target = engine
1629            .create_entity(empty_create_args("specs", "T2"), actor, Some(&client), None)
1630            .unwrap();
1631        let first = engine
1632            .relate_entity(
1633                RelateEntityArgs {
1634                    source: source.id.clone(),
1635                    expected_hash: Some(source.content_hash.clone()),
1636                    rel_type: "USES".to_string(),
1637                    target: target.id.clone(),
1638                    remove: false,
1639                    description: None,
1640                    dry_run: false,
1641                },
1642                actor,
1643                Some(&client),
1644                None,
1645            )
1646            .unwrap();
1647        let second = engine
1648            .relate_entity(
1649                RelateEntityArgs {
1650                    source: source.id.clone(),
1651                    expected_hash: Some(first.content_hash.clone()),
1652                    rel_type: "USES".to_string(),
1653                    target: target.id.clone(),
1654                    remove: false,
1655                    description: None,
1656                    dry_run: false,
1657                },
1658                actor,
1659                Some(&client),
1660                None,
1661            )
1662            .unwrap();
1663        assert_eq!(second.action, RelateAction::NoOpAlreadyPresent);
1664        // Hash unchanged on no-op.
1665        assert_eq!(second.content_hash, first.content_hash);
1666    }
1667
1668    #[test]
1669    fn relate_entity_returns_write_id_on_real_write() {
1670        let tmp = TempDir::new().unwrap();
1671        let (mut engine, source) = engine_with_seed(&tmp, "Source");
1672        let (actor, client) = cli_actor();
1673        let target = engine
1674            .create_entity(
1675                empty_create_args("specs", "Target"),
1676                actor,
1677                Some(&client),
1678                None,
1679            )
1680            .unwrap();
1681
1682        let outcome = engine
1683            .relate_entity(
1684                RelateEntityArgs {
1685                    source: source.id.clone(),
1686                    expected_hash: Some(source.content_hash.clone()),
1687                    rel_type: "USES".to_string(),
1688                    target: target.id.clone(),
1689                    remove: false,
1690                    description: None,
1691                    dry_run: false,
1692                },
1693                actor,
1694                Some(&client),
1695                None,
1696            )
1697            .unwrap();
1698        // Folder backend returns a synthetic CommitId — non-empty string.
1699        // Wire-equivalent to full's commit SHA: agents reading the field
1700        // get a usable cursor regardless of which backend served the write.
1701        assert!(
1702            !outcome.write_id.is_empty(),
1703            "write_id must be populated on a real write"
1704        );
1705    }
1706
1707    #[test]
1708    fn relate_entity_no_op_paths_carry_typed_warnings_and_empty_write_id() {
1709        let tmp = TempDir::new().unwrap();
1710        let (mut engine, source) = engine_with_seed(&tmp, "S");
1711        let (actor, client) = cli_actor();
1712        let target = engine
1713            .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1714            .unwrap();
1715
1716        // Add the edge once.
1717        let first = engine
1718            .relate_entity(
1719                RelateEntityArgs {
1720                    source: source.id.clone(),
1721                    expected_hash: Some(source.content_hash.clone()),
1722                    rel_type: "USES".to_string(),
1723                    target: target.id.clone(),
1724                    remove: false,
1725                    description: None,
1726                    dry_run: false,
1727                },
1728                actor,
1729                Some(&client),
1730                None,
1731            )
1732            .unwrap();
1733
1734        // Duplicate-add — typed DuplicateRelationship warning, empty
1735        // write_id (no disk write happened).
1736        let dup = engine
1737            .relate_entity(
1738                RelateEntityArgs {
1739                    source: source.id.clone(),
1740                    expected_hash: Some(first.content_hash.clone()),
1741                    rel_type: "USES".to_string(),
1742                    target: target.id.clone(),
1743                    remove: false,
1744                    description: None,
1745                    dry_run: false,
1746                },
1747                actor,
1748                Some(&client),
1749                None,
1750            )
1751            .unwrap();
1752        assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1753        assert!(dup.write_id.is_empty());
1754        assert_eq!(dup.warnings.len(), 1);
1755        assert!(matches!(
1756            dup.warnings[0],
1757            WarningHint::DuplicateRelationship { .. }
1758        ));
1759
1760        // Remove a non-existent edge — typed NoSuchRelationship warning,
1761        // empty write_id.
1762        let no_such = engine
1763            .relate_entity(
1764                RelateEntityArgs {
1765                    source: source.id.clone(),
1766                    expected_hash: Some(first.content_hash.clone()),
1767                    rel_type: "DEPENDS_ON".to_string(),
1768                    target: target.id.clone(),
1769                    remove: true,
1770                    description: None,
1771                    dry_run: false,
1772                },
1773                actor,
1774                Some(&client),
1775                None,
1776            )
1777            .unwrap();
1778        assert_eq!(no_such.action, RelateAction::NoOpAbsent);
1779        assert!(no_such.write_id.is_empty());
1780        assert_eq!(no_such.warnings.len(), 1);
1781        assert!(matches!(
1782            no_such.warnings[0],
1783            WarningHint::NoSuchRelationship { .. }
1784        ));
1785    }
1786
1787    #[test]
1788    fn relate_entity_creates_stub_for_absent_target_on_add_path() {
1789        let tmp = TempDir::new().unwrap();
1790        let (mut engine, source) = engine_with_seed(&tmp, "Source");
1791        let (actor, client) = cli_actor();
1792        let absent_target = crate::EntityId::new("specs", "ghost-target");
1793        // Sanity: target not in store.
1794        assert!(!engine.store().contains(&absent_target));
1795
1796        let outcome = engine
1797            .relate_entity(
1798                RelateEntityArgs {
1799                    source: source.id.clone(),
1800                    expected_hash: Some(source.content_hash.clone()),
1801                    rel_type: "USES".to_string(),
1802                    target: absent_target.clone(),
1803                    remove: false,
1804                    description: None,
1805                    dry_run: false,
1806                },
1807                actor,
1808                Some(&client),
1809                None,
1810            )
1811            .unwrap();
1812
1813        assert_eq!(outcome.action, RelateAction::Added);
1814        assert_eq!(outcome.source, "explicit");
1815        // Auto-stub now surfaces through the typed warning vocabulary
1816        // (`AutoStubCreated`) on `warnings[]` — the deprecated
1817        // top-level `stub_warning` field was retired in favour of the
1818        // uniform diagnostic shape. Agents iterating `warnings[]` see
1819        // the stub id without special-casing a sibling field.
1820        let stub_warning = outcome
1821            .warnings
1822            .iter()
1823            .find_map(|w| match w {
1824                crate::ops::WarningHint::AutoStubCreated { stub_id, .. } => Some(stub_id.clone()),
1825                _ => None,
1826            })
1827            .expect("AutoStubCreated warning must surface when target was absent");
1828        assert_eq!(stub_warning, absent_target);
1829        // The real path keeps the performed-effect wording exactly —
1830        // only the dry-run path carries the conditional form.
1831        let msg = outcome
1832            .warnings
1833            .iter()
1834            .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
1835            .unwrap()
1836            .message();
1837        assert!(
1838            msg.contains("stub auto-created"),
1839            "real relate keeps the performed-effect wording: {msg}"
1840        );
1841
1842        // Stub now in-store, marked as stub, no body.
1843        let stub = engine.store().get(&absent_target).expect("stub upserted");
1844        assert!(stub.stub);
1845        assert!(stub.entity_type.is_empty());
1846        assert!(stub.file_path.is_empty());
1847    }
1848
1849    #[test]
1850    fn relate_entity_skips_stub_creation_when_target_already_exists() {
1851        let tmp = TempDir::new().unwrap();
1852        let (mut engine, source) = engine_with_seed(&tmp, "Src");
1853        let (actor, client) = cli_actor();
1854        let target = engine
1855            .create_entity(
1856                empty_create_args("specs", "Real"),
1857                actor,
1858                Some(&client),
1859                None,
1860            )
1861            .unwrap();
1862
1863        let outcome = engine
1864            .relate_entity(
1865                RelateEntityArgs {
1866                    source: source.id.clone(),
1867                    expected_hash: Some(source.content_hash.clone()),
1868                    rel_type: "USES".to_string(),
1869                    target: target.id.clone(),
1870                    remove: false,
1871                    description: None,
1872                    dry_run: false,
1873                },
1874                actor,
1875                Some(&client),
1876                None,
1877            )
1878            .unwrap();
1879
1880        assert!(
1881            !outcome
1882                .warnings
1883                .iter()
1884                .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1885            "AutoStubCreated must not surface when target was already in store"
1886        );
1887        assert_eq!(outcome.source, "explicit");
1888        // Real entity remains a real entity (not coerced to stub).
1889        let target_after = engine.store().get(&target.id).unwrap();
1890        assert!(!target_after.stub);
1891    }
1892
1893    #[test]
1894    fn relate_entity_does_not_create_stub_on_remove_path() {
1895        let tmp = TempDir::new().unwrap();
1896        let (mut engine, source) = engine_with_seed(&tmp, "Src");
1897        let (actor, client) = cli_actor();
1898        let absent_target = crate::EntityId::new("specs", "never-existed");
1899
1900        let outcome = engine
1901            .relate_entity(
1902                RelateEntityArgs {
1903                    source: source.id.clone(),
1904                    expected_hash: Some(source.content_hash.clone()),
1905                    rel_type: "USES".to_string(),
1906                    target: absent_target.clone(),
1907                    remove: true,
1908                    description: None,
1909                    dry_run: false,
1910                },
1911                actor,
1912                Some(&client),
1913                None,
1914            )
1915            .unwrap();
1916
1917        // Remove of an absent edge — no stub creation, NoOpAbsent action,
1918        // typed NoSuchRelationship warning.
1919        assert_eq!(outcome.action, RelateAction::NoOpAbsent);
1920        assert!(
1921            !outcome
1922                .warnings
1923                .iter()
1924                .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1925            "remove path must never auto-stub the target",
1926        );
1927        assert!(!engine.store().contains(&absent_target));
1928    }
1929
1930    #[test]
1931    fn relate_entity_remove_refuses_when_source_body_still_references_target() {
1932        use crate::engine::CreateEntityArgs;
1933        use indexmap::IndexMap;
1934
1935        let tmp = TempDir::new().unwrap();
1936        let mem_dir = tmp.path().to_path_buf();
1937        let writer = FilesystemMemWriter::new(mem_dir.clone());
1938        let mut engine = Engine::from_mounts(vec![(
1939            folder_mount("specs", mem_dir.clone()),
1940            Box::new(writer) as Box<dyn MemBackend>,
1941        )])
1942        .unwrap();
1943        let (actor, client) = cli_actor();
1944
1945        let target = engine
1946            .create_entity(
1947                empty_create_args("specs", "Target"),
1948                actor,
1949                Some(&client),
1950                None,
1951            )
1952            .unwrap();
1953
1954        // Source entity carries a body wiki-link to the target — the
1955        // alias-synthesis pass emits the backing REFERENCES relation
1956        // (default schema's `alias_target_rel_type` points at
1957        // REFERENCES, so explicit `memstead_relate type=REFERENCES` is
1958        // refused; the body link alone produces the relation).
1959        let mut sections: IndexMap<String, String> = IndexMap::new();
1960        sections.insert("identity".to_string(), "source identity".to_string());
1961        sections.insert(
1962            "purpose".to_string(),
1963            "discussion stems from [[target]]".to_string(),
1964        );
1965        let source = engine
1966            .create_entity(
1967                CreateEntityArgs {
1968                    anchors: Vec::new(),
1969                    mem: "specs".to_string(),
1970                    title: "Source".to_string(),
1971                    entity_type: "spec".to_string(),
1972                    sections,
1973                    metadata: IndexMap::new(),
1974                    relations: Vec::new(),
1975                    dry_run: false,
1976                },
1977                actor,
1978                Some(&client),
1979                None,
1980            )
1981            .unwrap();
1982        let related = source.clone();
1983
1984        // Removing the explicit relation while the body still has
1985        // [[target]] must refuse with `RelationHasBodyLinks`, naming
1986        // the surviving section in `body_links`.
1987        let err = engine
1988            .relate_entity(
1989                RelateEntityArgs {
1990                    source: source.id.clone(),
1991                    expected_hash: Some(related.content_hash.clone()),
1992                    rel_type: "REFERENCES".to_string(),
1993                    target: target.id.clone(),
1994                    remove: true,
1995                    description: None,
1996                    dry_run: false,
1997                },
1998                actor,
1999                Some(&client),
2000                None,
2001            )
2002            .unwrap_err();
2003        match err {
2004            EngineError::RelationHasBodyLinks {
2005                from_id,
2006                to_id,
2007                rel_type,
2008                body_links,
2009            } => {
2010                assert_eq!(from_id, source.id.to_string());
2011                assert_eq!(to_id, target.id.to_string());
2012                assert_eq!(rel_type, "REFERENCES");
2013                assert_eq!(body_links, vec!["purpose".to_string()]);
2014            }
2015            other => panic!("expected RelationHasBodyLinks, got {other:?}"),
2016        }
2017        // Relation must still be present in-memory (refuse before any
2018        // store mutation).
2019        let in_mem = engine.get_entity(&source.id).unwrap();
2020        assert!(
2021            in_mem
2022                .relationships
2023                .iter()
2024                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
2025            "relation must survive the refused remove; got {:?}",
2026            in_mem.relationships
2027        );
2028    }
2029
2030    #[test]
2031    fn relate_entity_remove_succeeds_when_body_no_longer_references_target() {
2032        let tmp = TempDir::new().unwrap();
2033        let (mut engine, source) = engine_with_seed(&tmp, "Src");
2034        let (actor, client) = cli_actor();
2035        let target = engine
2036            .create_entity(
2037                empty_create_args("specs", "Other"),
2038                actor,
2039                Some(&client),
2040                None,
2041            )
2042            .unwrap();
2043        // Default seed has empty body sections, so the relation can be
2044        // added and removed without body-link interference. This locks
2045        // the happy path: when no body link survives, remove proceeds.
2046        // (USES instead of REFERENCES — REFERENCES is engine-emitted-only
2047        // under the default schema's alias_target_rel_type pointer.)
2048        let related = engine
2049            .relate_entity(
2050                RelateEntityArgs {
2051                    source: source.id.clone(),
2052                    expected_hash: Some(source.content_hash.clone()),
2053                    rel_type: "USES".to_string(),
2054                    target: target.id.clone(),
2055                    remove: false,
2056                    description: None,
2057                    dry_run: false,
2058                },
2059                actor,
2060                Some(&client),
2061                None,
2062            )
2063            .unwrap();
2064        let removed = engine
2065            .relate_entity(
2066                RelateEntityArgs {
2067                    source: source.id.clone(),
2068                    expected_hash: Some(related.content_hash.clone()),
2069                    rel_type: "USES".to_string(),
2070                    target: target.id.clone(),
2071                    remove: true,
2072                    description: None,
2073                    dry_run: false,
2074                },
2075                actor,
2076                Some(&client),
2077                None,
2078            )
2079            .unwrap();
2080        assert_eq!(removed.action, RelateAction::Removed);
2081    }
2082
2083    #[test]
2084    fn relate_entity_auto_stub_is_tagged_forward_reference() {
2085        // `memstead_relate` to an absent target auto-stubs it. The stub's
2086        // `stub_kind` records the origin (`ForwardReference`) so an
2087        // agent reading the stub later via `memstead_entity` sees the
2088        // typed provenance — not just `stub: true`.
2089        use crate::entity::StubKind;
2090
2091        let tmp = TempDir::new().unwrap();
2092        let (mut engine, source) = engine_with_seed(&tmp, "Src");
2093        let (actor, client) = cli_actor();
2094        let absent_target = crate::EntityId::new("specs", "absent-target");
2095
2096        let _ = engine
2097            .relate_entity(
2098                RelateEntityArgs {
2099                    source: source.id.clone(),
2100                    expected_hash: Some(source.content_hash.clone()),
2101                    rel_type: "USES".to_string(),
2102                    target: absent_target.clone(),
2103                    remove: false,
2104                    description: None,
2105                    dry_run: false,
2106                },
2107                actor,
2108                Some(&client),
2109                None,
2110            )
2111            .unwrap();
2112
2113        let stub = engine
2114            .get_entity(&absent_target)
2115            .expect("relate auto-stubbed target must be in the store");
2116        assert!(stub.stub, "auto-stubbed target must carry stub: true");
2117        assert_eq!(
2118            stub.stub_kind,
2119            Some(StubKind::ForwardReference),
2120            "auto-stub from relate must be tagged ForwardReference; got {:?}",
2121            stub.stub_kind
2122        );
2123    }
2124
2125    #[test]
2126    fn relate_entity_case_insensitive_rel_type_input_canonicalises_to_upper_snake_case() {
2127        // Wire-level contract: rel_type input is case-insensitive; the
2128        // engine stores it as UPPER_SNAKE_CASE and echoes the canonical
2129        // form back in the response. Same store-shape regardless of
2130        // input case.
2131        let tmp = TempDir::new().unwrap();
2132        let (mut engine, source) = engine_with_seed(&tmp, "Source");
2133        let (actor, client) = cli_actor();
2134        let target = engine
2135            .create_entity(
2136                empty_create_args("specs", "Target"),
2137                actor,
2138                Some(&client),
2139                None,
2140            )
2141            .unwrap();
2142
2143        // Lowercase input — must succeed and store as `USES`.
2144        let lower = engine
2145            .relate_entity(
2146                RelateEntityArgs {
2147                    source: source.id.clone(),
2148                    expected_hash: Some(source.content_hash.clone()),
2149                    rel_type: "uses".to_string(),
2150                    target: target.id.clone(),
2151                    remove: false,
2152                    description: None,
2153                    dry_run: false,
2154                },
2155                actor,
2156                Some(&client),
2157                None,
2158            )
2159            .unwrap();
2160        assert_eq!(lower.rel_type, "USES", "response must echo canonical form");
2161        assert_eq!(lower.action, RelateAction::Added);
2162        let edges = engine.store().outgoing(&source.id);
2163        assert!(
2164            edges
2165                .iter()
2166                .any(|e| e.rel_type == "USES" && e.target == target.id),
2167            "store must hold UPPER_SNAKE_CASE rel_type after lowercase input"
2168        );
2169
2170        // Adding via mixed-case input on the same edge is the canonical
2171        // duplicate — DuplicateRelationship warning, no second store
2172        // entry.
2173        let dup = engine
2174            .relate_entity(
2175                RelateEntityArgs {
2176                    source: source.id.clone(),
2177                    expected_hash: Some(lower.content_hash.clone()),
2178                    rel_type: "Uses".to_string(),
2179                    target: target.id.clone(),
2180                    remove: false,
2181                    description: None,
2182                    dry_run: false,
2183                },
2184                actor,
2185                Some(&client),
2186                None,
2187            )
2188            .unwrap();
2189        assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2190        assert_eq!(dup.rel_type, "USES");
2191        assert!(matches!(
2192            dup.warnings[0],
2193            WarningHint::DuplicateRelationship { .. }
2194        ));
2195    }
2196
2197    #[test]
2198    fn relate_entity_rejects_cross_mem_when_policy_denies() {
2199        // Default workspace settings carry no `cross_mem_links`
2200        // policy and no `default_cross_links` on the create rules, so
2201        // `cross_mem_link_allowed` returns false for any cross-mem
2202        // pair. The relate refuse now surfaces the typed
2203        // policy-denial code instead of the legacy categorical
2204        // `CrossMemRelate`.
2205        let tmp = TempDir::new().unwrap();
2206        let (mut engine, source) = engine_with_seed(&tmp, "S");
2207        let (actor, client) = cli_actor();
2208        let err = engine
2209            .relate_entity(
2210                RelateEntityArgs {
2211                    source: source.id.clone(),
2212                    expected_hash: Some(source.content_hash.clone()),
2213                    rel_type: "USES".to_string(),
2214                    target: crate::EntityId::new("other-mem", "thing"),
2215                    remove: false,
2216                    description: None,
2217                    dry_run: false,
2218                },
2219                actor,
2220                Some(&client),
2221                None,
2222            )
2223            .unwrap_err();
2224        match err {
2225            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2226                assert_eq!(from_mem, "specs");
2227                assert_eq!(to_mem, "other-mem");
2228            }
2229            other => panic!("expected CrossMemLinkNotAllowed, got {other:?}"),
2230        }
2231    }
2232
2233    /// Bare-string target without a `mem--` separator is malformed
2234    /// (the wiki-link grammar requires `<mem>--<path>`). Pre-fix
2235    /// the cross-mem check fired first: the parser saw `mem: ""`,
2236    /// compared against the source mem, and produced
2237    /// `CROSS_MEM_RELATION` — pointing the agent at workspace
2238    /// `[cross_mem_links]` policy when the actual issue was a
2239    /// malformed id. Post-fix the grammar gate runs first; the
2240    /// envelope identifies the real problem.
2241    #[test]
2242    fn relate_entity_malformed_bare_target_surfaces_invalid_entity_id_not_cross_mem() {
2243        let tmp = TempDir::new().unwrap();
2244        let (mut engine, source) = engine_with_seed(&tmp, "S");
2245        let (actor, client) = cli_actor();
2246        let err = engine
2247            .relate_entity(
2248                RelateEntityArgs {
2249                    source: source.id.clone(),
2250                    expected_hash: Some(source.content_hash.clone()),
2251                    rel_type: "USES".to_string(),
2252                    // No `--` separator AND contains characters the
2253                    // grammar rejects. Parses as mem="", path=raw.
2254                    target: crate::EntityId("bad target with spaces!!".to_string()),
2255                    remove: false,
2256                    description: None,
2257                    dry_run: false,
2258                },
2259                actor,
2260                Some(&client),
2261                None,
2262            )
2263            .unwrap_err();
2264        assert!(
2265            matches!(err, EngineError::InvalidEntityId { .. }),
2266            "malformed bare-string target must surface INVALID_ENTITY_ID, got: {err:?}"
2267        );
2268    }
2269
2270    /// Companion case: target carries the source's mem prefix but a
2271    /// grammar-violating path. The grammar check fires (same path as
2272    /// the bare-string case); cross-mem stays out of the picture
2273    /// because mems match.
2274    #[test]
2275    fn relate_entity_malformed_prefixed_target_surfaces_invalid_entity_id() {
2276        let tmp = TempDir::new().unwrap();
2277        let (mut engine, source) = engine_with_seed(&tmp, "S");
2278        let (actor, client) = cli_actor();
2279        let source_mem = source.id.mem().to_string();
2280        let err = engine
2281            .relate_entity(
2282                RelateEntityArgs {
2283                    source: source.id.clone(),
2284                    expected_hash: Some(source.content_hash.clone()),
2285                    rel_type: "USES".to_string(),
2286                    target: crate::EntityId(format!("{source_mem}--bad target with spaces!!")),
2287                    remove: false,
2288                    description: None,
2289                    dry_run: false,
2290                },
2291                actor,
2292                Some(&client),
2293                None,
2294            )
2295            .unwrap_err();
2296        assert!(
2297            matches!(err, EngineError::InvalidEntityId { .. }),
2298            "prefixed malformed target must still surface INVALID_ENTITY_ID, got: {err:?}"
2299        );
2300    }
2301
2302    // ---- Auto-timestamp on relate add/remove ------------------------
2303
2304    /// `memstead_relate add` rewrites the
2305    /// source's on-disk file, so its `last_modified` auto-stamp must
2306    /// bump. The schema's default-stamped field is `last_modified`.
2307    #[test]
2308    fn relate_add_bumps_last_modified_on_source_entity() {
2309        let tmp = TempDir::new().unwrap();
2310        let (mut engine, source) = engine_with_seed(&tmp, "S");
2311        let (actor, client) = cli_actor();
2312        let target = engine
2313            .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2314            .unwrap();
2315
2316        let outcome = engine
2317            .relate_entity(
2318                RelateEntityArgs {
2319                    source: source.id.clone(),
2320                    expected_hash: Some(source.content_hash.clone()),
2321                    rel_type: "USES".to_string(),
2322                    target: target.id.clone(),
2323                    remove: false,
2324                    description: None,
2325                    dry_run: false,
2326                },
2327                actor,
2328                Some(&client),
2329                None,
2330            )
2331            .unwrap();
2332        assert_eq!(outcome.action, RelateAction::Added);
2333
2334        // last_modified now carries a fresh ISO timestamp on the
2335        // source entity. The auto-stamp helper sets every
2336        // `auto_timestamp: true` metadata field on each commit-
2337        // producing relate mutation.
2338        let post = engine.get_entity(&source.id).unwrap();
2339        let last_modified = post
2340            .metadata
2341            .get("last_modified")
2342            .map(|v| v.to_frontmatter_string())
2343            .unwrap_or_default();
2344        assert!(
2345            last_modified.starts_with("20"),
2346            "last_modified must carry an ISO timestamp post-relate; got: {last_modified:?}"
2347        );
2348    }
2349
2350    /// Relate-add no-op (idempotent
2351    /// re-add) skips the disk write and therefore does not advance
2352    /// `last_modified`. The auto-stamp fires only on commit-producing
2353    /// mutations — wired into the post-no-op-short-circuit branch.
2354    #[test]
2355    fn relate_add_noop_does_not_bump_last_modified() {
2356        let tmp = TempDir::new().unwrap();
2357        let (mut engine, source) = engine_with_seed(&tmp, "S");
2358        let (actor, client) = cli_actor();
2359        let target = engine
2360            .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
2361            .unwrap();
2362        let first = engine
2363            .relate_entity(
2364                RelateEntityArgs {
2365                    source: source.id.clone(),
2366                    expected_hash: Some(source.content_hash.clone()),
2367                    rel_type: "USES".to_string(),
2368                    target: target.id.clone(),
2369                    remove: false,
2370                    description: None,
2371                    dry_run: false,
2372                },
2373                actor,
2374                Some(&client),
2375                None,
2376            )
2377            .unwrap();
2378        let pre = engine.get_entity(&source.id).unwrap();
2379        let pre_stamp = pre
2380            .metadata
2381            .get("last_modified")
2382            .map(|v| v.to_frontmatter_string())
2383            .unwrap_or_default();
2384
2385        // Second relate of same edge — NoOpAlreadyPresent.
2386        let dup = engine
2387            .relate_entity(
2388                RelateEntityArgs {
2389                    source: source.id.clone(),
2390                    expected_hash: Some(first.content_hash.clone()),
2391                    rel_type: "USES".to_string(),
2392                    target: target.id.clone(),
2393                    remove: false,
2394                    description: None,
2395                    dry_run: false,
2396                },
2397                actor,
2398                Some(&client),
2399                None,
2400            )
2401            .unwrap();
2402        assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
2403
2404        let post = engine.get_entity(&source.id).unwrap();
2405        let post_stamp = post
2406            .metadata
2407            .get("last_modified")
2408            .map(|v| v.to_frontmatter_string())
2409            .unwrap_or_default();
2410        assert_eq!(
2411            pre_stamp, post_stamp,
2412            "last_modified must not advance on a duplicate-add no-op (no disk write happened)"
2413        );
2414    }
2415
2416    /// Cross-mem relate that policy admits
2417    /// but whose target mem is not mounted in the workspace emits
2418    /// `CROSS_MEM_TARGET_MEM_UNCREATED` alongside `AutoStubCreated`.
2419    /// The auto-stub still lands; the warning is layered observability.
2420    #[test]
2421    fn cross_mem_relate_to_uncreated_mem_emits_typed_warning() {
2422        use memstead_schema::workspace_config::CrossLinkValue;
2423        let tmp = TempDir::new().unwrap();
2424        let (mut engine, source) = engine_with_seed(&tmp, "S");
2425        let (actor, client) = cli_actor();
2426        // Grant `specs -> uncreated-mem` so the policy gate passes.
2427        // The target mem is intentionally not mounted; the auto-stub
2428        // should still land, with the typed warning attached.
2429        let mut settings = crate::workspace::WorkspaceSettings::default();
2430        settings.cross_mem_links.insert(
2431            "specs".to_string(),
2432            CrossLinkValue::List(vec!["uncreated-mem".to_string()]),
2433        );
2434        engine.set_settings(settings);
2435
2436        let absent = crate::EntityId::new("uncreated-mem", "ghost");
2437        let outcome = engine
2438            .relate_entity(
2439                RelateEntityArgs {
2440                    source: source.id.clone(),
2441                    expected_hash: Some(source.content_hash.clone()),
2442                    rel_type: "USES".to_string(),
2443                    target: absent.clone(),
2444                    remove: false,
2445                    description: None,
2446                    dry_run: false,
2447                },
2448                actor,
2449                Some(&client),
2450                None,
2451            )
2452            .unwrap();
2453        assert_eq!(outcome.action, RelateAction::Added);
2454
2455        // Auto-stub created plus uncreated-mem warning, side by side.
2456        let saw_uncreated = outcome.warnings.iter().any(|w| {
2457            matches!(
2458                w,
2459                WarningHint::CrossMemTargetMemUncreated {
2460                    from_mem,
2461                    to_mem,
2462                    target_id,
2463                } if from_mem == "specs"
2464                    && to_mem == "uncreated-mem"
2465                    && target_id == &absent
2466            )
2467        });
2468        assert!(
2469            saw_uncreated,
2470            "CrossMemTargetMemUncreated warning must surface; got: {:?}",
2471            outcome.warnings
2472        );
2473        // The auto-stub still landed.
2474        assert!(engine.store().contains(&absent));
2475    }
2476
2477    /// Policy refusal takes precedence
2478    /// over the uncreated-mem warning. When the cross-mem link
2479    /// isn't granted, the engine refuses with
2480    /// `CROSS_MEM_LINK_NOT_ALLOWED` and never reaches the warning
2481    /// emission point — there's no stub to warn about.
2482    #[test]
2483    fn cross_mem_relate_policy_refusal_preempts_uncreated_mem_warning() {
2484        let tmp = TempDir::new().unwrap();
2485        let (mut engine, source) = engine_with_seed(&tmp, "S");
2486        let (actor, client) = cli_actor();
2487        // No cross_mem_links entry → policy denies.
2488        let absent = crate::EntityId::new("uncreated-mem", "ghost");
2489        let err = engine
2490            .relate_entity(
2491                RelateEntityArgs {
2492                    source: source.id.clone(),
2493                    expected_hash: Some(source.content_hash.clone()),
2494                    rel_type: "USES".to_string(),
2495                    target: absent.clone(),
2496                    remove: false,
2497                    description: None,
2498                    dry_run: false,
2499                },
2500                actor,
2501                Some(&client),
2502                None,
2503            )
2504            .unwrap_err();
2505        assert!(matches!(err, EngineError::CrossMemLinkNotAllowed { .. }));
2506        // No stub created on the refusal path.
2507        assert!(!engine.store().contains(&absent));
2508    }
2509
2510    /// `memstead_relate --remove` that drops the
2511    /// last incoming edge to a stub GCs the now-orphan stub in the
2512    /// same call. The response carries the dropped ids in
2513    /// `orphan_stubs_removed`, mirroring the `memstead_delete` envelope's
2514    /// shape so consumers branch uniformly.
2515    #[test]
2516    fn relate_remove_garbage_collects_orphan_stub() {
2517        let tmp = TempDir::new().unwrap();
2518        let (mut engine, source) = engine_with_seed(&tmp, "Src");
2519        let (actor, client) = cli_actor();
2520        let stub_id = crate::EntityId::new("specs", "ghost-target");
2521
2522        // Auto-stub via relate-add.
2523        let added = engine
2524            .relate_entity(
2525                RelateEntityArgs {
2526                    source: source.id.clone(),
2527                    expected_hash: Some(source.content_hash.clone()),
2528                    rel_type: "USES".to_string(),
2529                    target: stub_id.clone(),
2530                    remove: false,
2531                    description: None,
2532                    dry_run: false,
2533                },
2534                actor,
2535                Some(&client),
2536                None,
2537            )
2538            .unwrap();
2539        assert!(engine.store().contains(&stub_id));
2540
2541        let removed = engine
2542            .relate_entity(
2543                RelateEntityArgs {
2544                    source: source.id.clone(),
2545                    expected_hash: Some(added.content_hash.clone()),
2546                    rel_type: "USES".to_string(),
2547                    target: stub_id.clone(),
2548                    remove: true,
2549                    description: None,
2550                    dry_run: false,
2551                },
2552                actor,
2553                Some(&client),
2554                None,
2555            )
2556            .unwrap();
2557        assert_eq!(removed.action, RelateAction::Removed);
2558        assert_eq!(
2559            removed.orphan_stubs_removed,
2560            vec![stub_id.clone()],
2561            "orphan stub must be GC'd in the same call"
2562        );
2563        assert!(
2564            !engine.store().contains(&stub_id),
2565            "stub must be gone from the store after GC"
2566        );
2567    }
2568
2569    /// When the stub has another
2570    /// surviving incoming edge, the relate-remove GCs nothing —
2571    /// the stub stays alive via the second referrer. The sweep is
2572    /// scoped to *just-orphaned* targets, not pre-existing orphans
2573    /// or stubs that still have referrers.
2574    #[test]
2575    fn relate_remove_does_not_gc_stub_with_surviving_incoming_edge() {
2576        let tmp = TempDir::new().unwrap();
2577        let (mut engine, source_a) = engine_with_seed(&tmp, "SrcA");
2578        let (actor, client) = cli_actor();
2579        let source_b = engine
2580            .create_entity(
2581                empty_create_args("specs", "SrcB"),
2582                actor,
2583                Some(&client),
2584                None,
2585            )
2586            .unwrap();
2587        let stub_id = crate::EntityId::new("specs", "ghost-target");
2588
2589        // Both sources relate to the same stub.
2590        let a_added = engine
2591            .relate_entity(
2592                RelateEntityArgs {
2593                    source: source_a.id.clone(),
2594                    expected_hash: Some(source_a.content_hash.clone()),
2595                    rel_type: "USES".to_string(),
2596                    target: stub_id.clone(),
2597                    remove: false,
2598                    description: None,
2599                    dry_run: false,
2600                },
2601                actor,
2602                Some(&client),
2603                None,
2604            )
2605            .unwrap();
2606        let _b_added = engine
2607            .relate_entity(
2608                RelateEntityArgs {
2609                    source: source_b.id.clone(),
2610                    expected_hash: Some(source_b.content_hash.clone()),
2611                    rel_type: "USES".to_string(),
2612                    target: stub_id.clone(),
2613                    remove: false,
2614                    description: None,
2615                    dry_run: false,
2616                },
2617                actor,
2618                Some(&client),
2619                None,
2620            )
2621            .unwrap();
2622
2623        // Drop source_a's edge only — source_b's edge survives,
2624        // so the stub is not orphaned.
2625        let removed = engine
2626            .relate_entity(
2627                RelateEntityArgs {
2628                    source: source_a.id.clone(),
2629                    expected_hash: Some(a_added.content_hash.clone()),
2630                    rel_type: "USES".to_string(),
2631                    target: stub_id.clone(),
2632                    remove: true,
2633                    description: None,
2634                    dry_run: false,
2635                },
2636                actor,
2637                Some(&client),
2638                None,
2639            )
2640            .unwrap();
2641        assert_eq!(removed.action, RelateAction::Removed);
2642        assert!(
2643            removed.orphan_stubs_removed.is_empty(),
2644            "stub with surviving referrer must not be GC'd; got: {:?}",
2645            removed.orphan_stubs_removed
2646        );
2647        assert!(
2648            engine.store().contains(&stub_id),
2649            "stub must remain in store while another referrer holds it"
2650        );
2651    }
2652
2653    // ---- Cross-mem vocabulary -------------------------------------
2654
2655    /// Two-mem test bench wired for cross-mem routing.
2656    /// Mem `src` pins `src-cv@0.1.0` whose `cross_mem_relationships`
2657    /// section declares an outbound entry to the `tgt-cv` domain with
2658    /// `ADDRESSES: doc → req`. Mem `tgt` pins `tgt-cv@0.1.0`, a
2659    /// schema with a different name. The workspace policy admits the
2660    /// cross-mem link so vocabulary failures surface independently
2661    /// of permission.
2662    mod cross_mem {
2663        use std::collections::BTreeMap;
2664        use std::path::Path;
2665
2666        use indexmap::IndexMap;
2667        use memstead_schema::SchemaRef;
2668        use memstead_schema::workspace_config::CrossLinkValue;
2669        use tempfile::TempDir;
2670
2671        use crate::backend::MemBackend;
2672        use crate::engine::test_helpers::*;
2673        use crate::engine::{
2674            CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateAction,
2675            RelateEntityArgs,
2676        };
2677        use crate::storage::FilesystemMemWriter;
2678
2679        use crate::workspace::{
2680            Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
2681        };
2682
2683        fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
2684            let dir = root.join(name);
2685            std::fs::create_dir_all(dir.join("types")).unwrap();
2686            std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
2687            for (type_name, body) in types {
2688                std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
2689            }
2690        }
2691
2692        const TYPE_BODY: &str = r#"description: t
2693when_to_use: Here
2694sections:
2695  - key: body
2696    heading: Body
2697    required: true
2698    search_weight: 10.0
2699    catch_all: true
2700    write_rules: []
2701metadata_fields: []
2702title_weight: 100.0
2703text_fields:
2704  - body
2705hierarchy_relationship: _default
2706no_self_loop_relationships: []
2707updatable_fields:
2708  - title
2709  - body
2710health_required_fields:
2711  - body
2712staleness_threshold_days: 90
2713write_rules: []
2714"#;
2715
2716        fn make_type_yaml(name: &str) -> String {
2717            format!("name: {name}\n{TYPE_BODY}")
2718        }
2719
2720        fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
2721            Mount {
2722                mem: mem.to_string(),
2723                schema: Some(pin),
2724                storage: MountStorage::Folder { path },
2725                capability: MountCapability::Write,
2726                lifecycle: MountLifecycle::Eager,
2727                cross_linkable: true,
2728                migration_target: None,
2729            }
2730        }
2731
2732        /// Build an engine with two mems pinning two distinct schemas
2733        /// and a `cross_mem_links` policy admitting the cross-edge.
2734        fn two_mem_engine() -> (TempDir, Engine, CreateEntityOutcome, CreateEntityOutcome) {
2735            let tmp = TempDir::new().unwrap();
2736
2737            // Source schema with cross-mem declarations to the
2738            // tgt-cv domain.
2739            let src_manifest = r#"name: src-cv
2740version: 0.1.0
2741description: source schema
2742when_to_use: tests
2743types:
2744  - doc
2745relationships:
2746  mode: strict
2747  definitions:
2748    - name: IMPLEMENTS
2749      description: intra-mem only
2750      default_weight: 1.0
2751    - name: _default
2752      description: fallback
2753      default_weight: 1.0
2754cross_mem_relationships:
2755  - to_schema: tgt-cv
2756    definitions:
2757      - name: ADDRESSES
2758        description: outbound shape-pinned
2759        default_weight: 1.0
2760        source_types: [doc]
2761        target_types: [req]
2762community:
2763  resolution: 1.0
2764  seed: 42
2765"#;
2766            // Target schema declares no cross_mem_relationships (we
2767            // never relate from tgt → src in these tests).
2768            let tgt_manifest = r#"name: tgt-cv
2769version: 0.1.0
2770description: target schema
2771when_to_use: tests
2772types:
2773  - req
2774relationships:
2775  mode: strict
2776  definitions:
2777    - name: PART_OF
2778      description: hierarchy
2779      default_weight: 3.0
2780      acyclic: true
2781    - name: _default
2782      description: fallback
2783      default_weight: 1.0
2784community:
2785  resolution: 1.0
2786  seed: 42
2787"#;
2788            let schemas_dir = tmp.path().join("schemas");
2789            std::fs::create_dir_all(&schemas_dir).unwrap();
2790            write_schema_files(
2791                &schemas_dir,
2792                "src-cv",
2793                src_manifest,
2794                &[("doc", &make_type_yaml("doc"))],
2795            );
2796            write_schema_files(
2797                &schemas_dir,
2798                "tgt-cv",
2799                tgt_manifest,
2800                &[("req", &make_type_yaml("req"))],
2801            );
2802
2803            let src_dir = tmp.path().join("mem-src");
2804            let tgt_dir = tmp.path().join("mem-tgt");
2805            std::fs::create_dir_all(&src_dir).unwrap();
2806            std::fs::create_dir_all(&tgt_dir).unwrap();
2807
2808            let src_writer = FilesystemMemWriter::new(src_dir.clone());
2809            let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
2810            let src_pin = SchemaRef::new("src-cv", semver::Version::new(0, 1, 0));
2811            let tgt_pin = SchemaRef::new("tgt-cv", semver::Version::new(0, 1, 0));
2812
2813            let mut engine = Engine::from_mounts_with_schemas_dir(
2814                vec![
2815                    (
2816                        folder_mount_with_pin("src", src_dir, src_pin),
2817                        Box::new(src_writer) as Box<dyn MemBackend>,
2818                    ),
2819                    (
2820                        folder_mount_with_pin("tgt", tgt_dir, tgt_pin),
2821                        Box::new(tgt_writer) as Box<dyn MemBackend>,
2822                    ),
2823                ],
2824                Some(&schemas_dir),
2825            )
2826            .expect("two-mem engine constructs");
2827
2828            // Wildcard permission so cross-mem edges aren't blocked
2829            // by the orthogonal policy gate (we exercise the vocabulary
2830            // gate here, not the permission gate).
2831            let mut settings = WorkspaceSettings::default();
2832            let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2833            links.insert("src".to_string(), CrossLinkValue::Wildcard);
2834            settings.cross_mem_links = links;
2835            engine.set_settings(settings);
2836
2837            let (actor, client) = cli_actor();
2838            let src_entity = engine
2839                .create_entity(
2840                    CreateEntityArgs {
2841                        anchors: Vec::new(),
2842                        mem: "src".to_string(),
2843                        title: "Doc One".to_string(),
2844                        entity_type: "doc".to_string(),
2845                        sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2846                        metadata: IndexMap::new(),
2847                        relations: Vec::new(),
2848                        dry_run: false,
2849                    },
2850                    actor,
2851                    Some(&client),
2852                    None,
2853                )
2854                .expect("source entity creates");
2855            let tgt_entity = engine
2856                .create_entity(
2857                    CreateEntityArgs {
2858                        anchors: Vec::new(),
2859                        mem: "tgt".to_string(),
2860                        title: "Req One".to_string(),
2861                        entity_type: "req".to_string(),
2862                        sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
2863                        metadata: IndexMap::new(),
2864                        relations: Vec::new(),
2865                        dry_run: false,
2866                    },
2867                    actor,
2868                    Some(&client),
2869                    None,
2870                )
2871                .expect("target entity creates");
2872
2873            (tmp, engine, src_entity, tgt_entity)
2874        }
2875
2876        #[test]
2877        fn cross_different_schema_admits_declared_edge() {
2878            let (_tmp, mut engine, src, tgt) = two_mem_engine();
2879            let (actor, client) = cli_actor();
2880            let outcome = engine
2881                .relate_entity(
2882                    RelateEntityArgs {
2883                        source: src.id.clone(),
2884                        expected_hash: Some(src.content_hash.clone()),
2885                        rel_type: "ADDRESSES".to_string(),
2886                        target: tgt.id.clone(),
2887                        remove: false,
2888                        description: None,
2889                        dry_run: false,
2890                    },
2891                    actor,
2892                    Some(&client),
2893                    None,
2894                )
2895                .expect("declared cross-mem edge admits");
2896            assert_eq!(outcome.rel_type, "ADDRESSES");
2897        }
2898
2899        /// Same schema name at different versions is the same domain:
2900        /// edges between two `same-dom`-pinned mems route through
2901        /// the intra-schema relationship vocabulary (governed by the
2902        /// source mem's pinned version) with no
2903        /// `cross_mem_relationships` declaration at all.
2904        #[test]
2905        fn same_name_different_version_uses_intra_mem_vocabulary() {
2906            let tmp = TempDir::new().unwrap();
2907
2908            let manifest_for = |version: &str| {
2909                format!(
2910                    r#"name: same-dom
2911version: {version}
2912description: same-domain schema
2913when_to_use: tests
2914types:
2915  - doc
2916relationships:
2917  mode: strict
2918  definitions:
2919    - name: IMPLEMENTS
2920      description: intra-mem vocabulary
2921      default_weight: 1.0
2922    - name: _default
2923      description: fallback
2924      default_weight: 1.0
2925community:
2926  resolution: 1.0
2927  seed: 42
2928"#
2929                )
2930            };
2931            let schemas_dir = tmp.path().join("schemas");
2932            std::fs::create_dir_all(&schemas_dir).unwrap();
2933            // Subdir names carry the version so both iterations of the
2934            // `same-dom` domain coexist in one schemas dir.
2935            write_schema_files(
2936                &schemas_dir,
2937                "same-dom-0.1.0",
2938                &manifest_for("0.1.0"),
2939                &[("doc", &make_type_yaml("doc"))],
2940            );
2941            write_schema_files(
2942                &schemas_dir,
2943                "same-dom-0.2.0",
2944                &manifest_for("0.2.0"),
2945                &[("doc", &make_type_yaml("doc"))],
2946            );
2947
2948            let src_dir = tmp.path().join("mem-src");
2949            let tgt_dir = tmp.path().join("mem-tgt");
2950            std::fs::create_dir_all(&src_dir).unwrap();
2951            std::fs::create_dir_all(&tgt_dir).unwrap();
2952            let src_pin = SchemaRef::new("same-dom", semver::Version::new(0, 1, 0));
2953            let tgt_pin = SchemaRef::new("same-dom", semver::Version::new(0, 2, 0));
2954            let mut engine = Engine::from_mounts_with_schemas_dir(
2955                vec![
2956                    (
2957                        folder_mount_with_pin("src", src_dir.clone(), src_pin),
2958                        Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2959                    ),
2960                    (
2961                        folder_mount_with_pin("tgt", tgt_dir.clone(), tgt_pin),
2962                        Box::new(FilesystemMemWriter::new(tgt_dir)) as Box<dyn MemBackend>,
2963                    ),
2964                ],
2965                Some(&schemas_dir),
2966            )
2967            .expect("same-domain two-version engine constructs");
2968
2969            let mut settings = WorkspaceSettings::default();
2970            let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2971            links.insert("src".to_string(), CrossLinkValue::Wildcard);
2972            settings.cross_mem_links = links;
2973            engine.set_settings(settings);
2974
2975            let (actor, client) = cli_actor();
2976            let mk_entity = |engine: &mut Engine, mem: &str, title: &str| {
2977                engine
2978                    .create_entity(
2979                        CreateEntityArgs {
2980                            anchors: Vec::new(),
2981                            mem: mem.to_string(),
2982                            title: title.to_string(),
2983                            entity_type: "doc".to_string(),
2984                            sections: IndexMap::from_iter([(
2985                                "body".to_string(),
2986                                "seed".to_string(),
2987                            )]),
2988                            metadata: IndexMap::new(),
2989                            relations: Vec::new(),
2990                            dry_run: false,
2991                        },
2992                        actor,
2993                        Some(&client),
2994                        None,
2995                    )
2996                    .expect("entity creates")
2997            };
2998            let src_entity = mk_entity(&mut engine, "src", "Doc A");
2999            let tgt_entity = mk_entity(&mut engine, "tgt", "Doc B");
3000
3001            let outcome = engine
3002                .relate_entity(
3003                    RelateEntityArgs {
3004                        source: src_entity.id.clone(),
3005                        expected_hash: Some(src_entity.content_hash.clone()),
3006                        rel_type: "IMPLEMENTS".to_string(),
3007                        target: tgt_entity.id.clone(),
3008                        remove: false,
3009                        description: None,
3010                        dry_run: false,
3011                    },
3012                    actor,
3013                    Some(&client),
3014                    None,
3015                )
3016                .expect("same-domain edge uses the intra-schema vocabulary across versions");
3017            assert_eq!(outcome.rel_type, "IMPLEMENTS");
3018        }
3019
3020        #[test]
3021        fn cross_different_schema_unknown_rel_type_returns_invalid_rel_type() {
3022            // `IMPLEMENTS` exists intra-mem but not in the cross-mem
3023            // entry — must refuse with INVALID_REL_TYPE against the
3024            // cross-mem entry's vocabulary (not intra-mem's).
3025            let (_tmp, mut engine, src, tgt) = two_mem_engine();
3026            let (actor, client) = cli_actor();
3027            let err = engine
3028                .relate_entity(
3029                    RelateEntityArgs {
3030                        source: src.id.clone(),
3031                        expected_hash: Some(src.content_hash.clone()),
3032                        rel_type: "IMPLEMENTS".to_string(),
3033                        target: tgt.id.clone(),
3034                        remove: false,
3035                        description: None,
3036                        dry_run: false,
3037                    },
3038                    actor,
3039                    Some(&client),
3040                    None,
3041                )
3042                .unwrap_err();
3043            match err {
3044                EngineError::Validation(
3045                    crate::runtime_validator::ValidationError::InvalidRelationshipType {
3046                        input,
3047                        allowed,
3048                        ..
3049                    },
3050                ) => {
3051                    assert_eq!(input, "IMPLEMENTS");
3052                    let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
3053                    assert!(names.iter().any(|n| n == "ADDRESSES"));
3054                    assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
3055                }
3056                other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
3057            }
3058        }
3059
3060        #[test]
3061        fn cross_different_schema_shape_violation_returns_invalid_rel_shape() {
3062            // ADDRESSES is shape-pinned to source=doc, target=req in
3063            // the cross-mem entry. Need a source whose type isn't doc.
3064            // src-cv only declares `doc`, so to provoke a shape miss we
3065            // build a third schema with type `note` and a fresh mem —
3066            // but that requires more plumbing than this test needs.
3067            // Instead: exercise a target-side shape miss by relating
3068            // ADDRESSES to a target that doesn't exist at all — the
3069            // target_type lookup returns None and the target check is
3070            // skipped (admits). So we exercise this via cross_mem
3071            // unit tests instead.
3072            //
3073            // What this integration test confirms: the source-side
3074            // shape check fires when the source type doesn't match —
3075            // here we'd need a non-`doc` source. Since src-cv only has
3076            // `doc`, the source-side admits trivially. Covered fully
3077            // by the runtime_validator unit tests.
3078        }
3079
3080        #[test]
3081        fn cross_different_schema_no_matching_entry_returns_edge_not_declared() {
3082            // Build a third mem pinning a schema not declared in
3083            // src-cv's cross_mem_relationships, then relate from src.
3084            let tmp = TempDir::new().unwrap();
3085            let src_manifest = r#"name: src-cv
3086version: 0.1.0
3087description: source schema
3088when_to_use: tests
3089types:
3090  - doc
3091relationships:
3092  mode: strict
3093  definitions:
3094    - name: IMPLEMENTS
3095      description: intra-mem
3096      default_weight: 1.0
3097    - name: _default
3098      description: fallback
3099      default_weight: 1.0
3100cross_mem_relationships:
3101  - to_schema: tgt-cv
3102    definitions:
3103      - name: ADDRESSES
3104        description: outbound
3105        default_weight: 1.0
3106        source_types: [doc]
3107        target_types: [req]
3108community:
3109  resolution: 1.0
3110  seed: 42
3111"#;
3112            // Different target schema NOT named in src's cross-mem list.
3113            let other_manifest = r#"name: other-cv
3114version: 0.1.0
3115description: foreign schema
3116when_to_use: tests
3117types:
3118  - thing
3119relationships:
3120  mode: strict
3121  definitions:
3122    - name: _default
3123      description: fallback
3124      default_weight: 1.0
3125community:
3126  resolution: 1.0
3127  seed: 42
3128"#;
3129            let schemas_dir = tmp.path().join("schemas");
3130            std::fs::create_dir_all(&schemas_dir).unwrap();
3131            write_schema_files(
3132                &schemas_dir,
3133                "src-cv",
3134                src_manifest,
3135                &[("doc", &make_type_yaml("doc"))],
3136            );
3137            write_schema_files(
3138                &schemas_dir,
3139                "other-cv",
3140                other_manifest,
3141                &[("thing", &make_type_yaml("thing"))],
3142            );
3143            let src_dir = tmp.path().join("mem-src");
3144            let other_dir = tmp.path().join("mem-other");
3145            std::fs::create_dir_all(&src_dir).unwrap();
3146            std::fs::create_dir_all(&other_dir).unwrap();
3147
3148            let mut engine = Engine::from_mounts_with_schemas_dir(
3149                vec![
3150                    (
3151                        folder_mount_with_pin(
3152                            "src",
3153                            src_dir.clone(),
3154                            SchemaRef::new("src-cv", semver::Version::new(0, 1, 0)),
3155                        ),
3156                        Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
3157                    ),
3158                    (
3159                        folder_mount_with_pin(
3160                            "other",
3161                            other_dir.clone(),
3162                            SchemaRef::new("other-cv", semver::Version::new(0, 1, 0)),
3163                        ),
3164                        Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
3165                    ),
3166                ],
3167                Some(&schemas_dir),
3168            )
3169            .expect("engine constructs");
3170
3171            let mut settings = WorkspaceSettings::default();
3172            let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3173            links.insert("src".to_string(), CrossLinkValue::Wildcard);
3174            settings.cross_mem_links = links;
3175            engine.set_settings(settings);
3176
3177            let (actor, client) = cli_actor();
3178            let src_entity = engine
3179                .create_entity(
3180                    CreateEntityArgs {
3181                        anchors: Vec::new(),
3182                        mem: "src".to_string(),
3183                        title: "D".to_string(),
3184                        entity_type: "doc".to_string(),
3185                        sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3186                        metadata: IndexMap::new(),
3187                        relations: Vec::new(),
3188                        dry_run: false,
3189                    },
3190                    actor,
3191                    Some(&client),
3192                    None,
3193                )
3194                .unwrap();
3195            let other_entity = engine
3196                .create_entity(
3197                    CreateEntityArgs {
3198                        anchors: Vec::new(),
3199                        mem: "other".to_string(),
3200                        title: "T".to_string(),
3201                        entity_type: "thing".to_string(),
3202                        sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3203                        metadata: IndexMap::new(),
3204                        relations: Vec::new(),
3205                        dry_run: false,
3206                    },
3207                    actor,
3208                    Some(&client),
3209                    None,
3210                )
3211                .unwrap();
3212
3213            let err = engine
3214                .relate_entity(
3215                    RelateEntityArgs {
3216                        source: src_entity.id.clone(),
3217                        expected_hash: Some(src_entity.content_hash.clone()),
3218                        rel_type: "ADDRESSES".to_string(),
3219                        target: other_entity.id.clone(),
3220                        remove: false,
3221                        description: None,
3222                        dry_run: false,
3223                    },
3224                    actor,
3225                    Some(&client),
3226                    None,
3227                )
3228                .unwrap_err();
3229            match err {
3230                EngineError::CrossMemEdgeNotDeclared {
3231                    source_schema,
3232                    target_schema,
3233                    rel_type,
3234                    from_id,
3235                    to_id,
3236                } => {
3237                    assert_eq!(source_schema, "src-cv@0.1.0");
3238                    assert_eq!(target_schema, "other-cv@0.1.0");
3239                    assert_eq!(rel_type, "ADDRESSES");
3240                    assert_eq!(from_id, src_entity.id.to_string());
3241                    assert_eq!(to_id, other_entity.id.to_string());
3242                }
3243                other => panic!("expected CrossMemEdgeNotDeclared, got {other:?}"),
3244            }
3245        }
3246
3247        #[test]
3248        fn intra_mem_with_cross_mem_only_rel_type_returns_invalid_rel_type() {
3249            // `ADDRESSES` is declared in src-cv's cross_mem_relationships
3250            // only — intra-mem relate must refuse with
3251            // INVALID_REL_TYPE since the intra-mem vocabulary
3252            // (`IMPLEMENTS` / `_default`) doesn't know it.
3253            let (_tmp, mut engine, src, _tgt) = two_mem_engine();
3254            let (actor, client) = cli_actor();
3255            // Create a same-mem target.
3256            let intra_target = engine
3257                .create_entity(
3258                    CreateEntityArgs {
3259                        anchors: Vec::new(),
3260                        mem: "src".to_string(),
3261                        title: "Doc Two".to_string(),
3262                        entity_type: "doc".to_string(),
3263                        sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3264                        metadata: IndexMap::new(),
3265                        relations: Vec::new(),
3266                        dry_run: false,
3267                    },
3268                    actor,
3269                    Some(&client),
3270                    None,
3271                )
3272                .unwrap();
3273            // Source's content_hash may have rotated due to incoming
3274            // edges from intra_target — fetch fresh.
3275            let src_fresh = engine.get_entity(&src.id).unwrap();
3276            let err = engine
3277                .relate_entity(
3278                    RelateEntityArgs {
3279                        source: src.id.clone(),
3280                        expected_hash: Some(src_fresh.content_hash.clone()),
3281                        rel_type: "ADDRESSES".to_string(),
3282                        target: intra_target.id.clone(),
3283                        remove: false,
3284                        description: None,
3285                        dry_run: false,
3286                    },
3287                    actor,
3288                    Some(&client),
3289                    None,
3290                )
3291                .unwrap_err();
3292            match err {
3293                EngineError::Validation(
3294                    crate::runtime_validator::ValidationError::InvalidRelationshipType {
3295                        input,
3296                        ..
3297                    },
3298                ) => {
3299                    assert_eq!(input, "ADDRESSES");
3300                }
3301                other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
3302            }
3303        }
3304
3305        #[test]
3306        fn vocabulary_admissible_edge_blocked_by_policy_returns_cross_mem_link_not_allowed() {
3307            // Same fixture but flip the cross-mem policy to deny.
3308            // ADDRESSES is vocabulary-admissible but permission refuses
3309            // it independently — surfaces CROSS_MEM_LINK_NOT_ALLOWED.
3310            let (_tmp, mut engine, src, tgt) = two_mem_engine();
3311            // Replace the wildcard policy with default-deny.
3312            engine.set_settings(WorkspaceSettings::default());
3313            let (actor, client) = cli_actor();
3314            let err = engine
3315                .relate_entity(
3316                    RelateEntityArgs {
3317                        source: src.id.clone(),
3318                        expected_hash: Some(src.content_hash.clone()),
3319                        rel_type: "ADDRESSES".to_string(),
3320                        target: tgt.id.clone(),
3321                        remove: false,
3322                        description: None,
3323                        dry_run: false,
3324                    },
3325                    actor,
3326                    Some(&client),
3327                    None,
3328                )
3329                .unwrap_err();
3330            assert!(
3331                matches!(err, EngineError::CrossMemLinkNotAllowed { .. }),
3332                "expected CrossMemLinkNotAllowed, got {err:?}"
3333            );
3334        }
3335
3336        /// Cross-mem remove bypasses the `cross_mem_links` policy
3337        /// gate. Without this, a workspace whose grant was revoked
3338        /// while edges still existed gets wedged: the natural recovery
3339        /// (`memstead_relate ... --remove`) refuses, leaving the operator
3340        /// to re-grant just to delete the data that the grant once
3341        /// permitted.
3342        #[test]
3343        fn cross_mem_remove_bypasses_policy_after_revoke() {
3344            let (_tmp, mut engine, src, tgt) = two_mem_engine();
3345            let (actor, client) = cli_actor();
3346
3347            // 1. Edge admits under wildcard grant.
3348            let added = engine
3349                .relate_entity(
3350                    RelateEntityArgs {
3351                        source: src.id.clone(),
3352                        expected_hash: Some(src.content_hash.clone()),
3353                        rel_type: "ADDRESSES".to_string(),
3354                        target: tgt.id.clone(),
3355                        remove: false,
3356                        description: None,
3357                        dry_run: false,
3358                    },
3359                    actor,
3360                    Some(&client),
3361                    None,
3362                )
3363                .expect("declared cross-mem edge admits under grant");
3364            assert_eq!(added.action, RelateAction::Added);
3365
3366            // 2. Revoke the grant — default settings deny everything.
3367            engine.set_settings(WorkspaceSettings::default());
3368
3369            // 3. Re-attempting an *add* still refuses (constraint:
3370            //    the gate is unchanged for the add path).
3371            let add_err = engine
3372                .relate_entity(
3373                    RelateEntityArgs {
3374                        source: src.id.clone(),
3375                        expected_hash: Some(added.content_hash.clone()),
3376                        rel_type: "ADDRESSES".to_string(),
3377                        target: tgt.id.clone(),
3378                        remove: false,
3379                        description: None,
3380                        dry_run: false,
3381                    },
3382                    actor,
3383                    Some(&client),
3384                    None,
3385                )
3386                .unwrap_err();
3387            assert!(
3388                matches!(add_err, EngineError::CrossMemLinkNotAllowed { .. }),
3389                "add path must still refuse under denial, got {add_err:?}"
3390            );
3391
3392            // 4. Remove succeeds — the cleanup path bypasses the
3393            //    policy gate.
3394            let removed = engine
3395                .relate_entity(
3396                    RelateEntityArgs {
3397                        source: src.id.clone(),
3398                        expected_hash: Some(added.content_hash.clone()),
3399                        rel_type: "ADDRESSES".to_string(),
3400                        target: tgt.id.clone(),
3401                        remove: true,
3402                        description: None,
3403                        dry_run: false,
3404                    },
3405                    actor,
3406                    Some(&client),
3407                    None,
3408                )
3409                .expect("remove must bypass the policy gate post-revoke");
3410            assert_eq!(removed.action, RelateAction::Removed);
3411
3412            // 5. Edge is gone from the store's outgoing index.
3413            let outgoing = engine.store().outgoing(&src.id);
3414            assert!(
3415                !outgoing
3416                    .iter()
3417                    .any(|e| e.target == tgt.id && e.rel_type == "ADDRESSES"),
3418                "ADDRESSES edge must be gone after remove"
3419            );
3420        }
3421
3422        /// Remove on a non-existent cross-mem edge with no grant
3423        /// returns a no-op, not a policy refusal. The remove path is
3424        /// permissive on absence — same shape as same-mem remove.
3425        #[test]
3426        fn cross_mem_remove_of_absent_edge_under_denial_is_no_op() {
3427            let (_tmp, mut engine, src, tgt) = two_mem_engine();
3428            // Default-deny from the start: no edge ever existed.
3429            engine.set_settings(WorkspaceSettings::default());
3430            let (actor, client) = cli_actor();
3431            let outcome = engine
3432                .relate_entity(
3433                    RelateEntityArgs {
3434                        source: src.id.clone(),
3435                        expected_hash: Some(src.content_hash.clone()),
3436                        rel_type: "ADDRESSES".to_string(),
3437                        target: tgt.id.clone(),
3438                        remove: true,
3439                        description: None,
3440                        dry_run: false,
3441                    },
3442                    actor,
3443                    Some(&client),
3444                    None,
3445                )
3446                .expect("absent-edge remove must not refuse on policy");
3447            assert!(
3448                matches!(outcome.action, RelateAction::NoOpAbsent),
3449                "expected NoOpAbsent, got {:?}",
3450                outcome.action
3451            );
3452        }
3453
3454        // ---- ReadOnly-target refusal (shared add-path funnel) --------
3455
3456        /// Engine with mem `src` (Write, `alias_target_rel_type:
3457        /// REFERENCES`, cross-mem vocabulary into `tgt-al`) and mem
3458        /// `tgt` mounted with the given capability, pre-populated on
3459        /// disk with one entity `tgt--req-one`. Wildcard cross-mem
3460        /// grant for `src`. Exercises the funnel's ReadOnly-missing-
3461        /// target refusal across every add-shaped write path.
3462        fn engine_with_tgt_capability(
3463            capability: MountCapability,
3464        ) -> (TempDir, Engine, CreateEntityOutcome) {
3465            let tmp = TempDir::new().unwrap();
3466
3467            let src_manifest = r#"name: src-al
3468version: 0.1.0
3469description: source schema with alias pointer
3470when_to_use: tests
3471types:
3472  - doc
3473relationships:
3474  mode: strict
3475  definitions:
3476    - name: ADDRESSES
3477      description: explicit cross-mem
3478      default_weight: 1.0
3479    - name: REFERENCES
3480      description: alias pointer
3481      default_weight: 1.0
3482    - name: _default
3483      description: fallback
3484      default_weight: 1.0
3485cross_mem_relationships:
3486  - to_schema: tgt-al
3487    definitions:
3488      - name: ADDRESSES
3489        description: explicit cross-mem
3490        default_weight: 1.0
3491      - name: REFERENCES
3492        description: alias-emitted cross-mem
3493        default_weight: 1.0
3494alias_target_rel_type: REFERENCES
3495community:
3496  resolution: 1.0
3497  seed: 42
3498"#;
3499            let tgt_manifest = r#"name: tgt-al
3500version: 0.1.0
3501description: target schema
3502when_to_use: tests
3503types:
3504  - req
3505relationships:
3506  mode: strict
3507  definitions:
3508    - name: _default
3509      description: fallback
3510      default_weight: 1.0
3511community:
3512  resolution: 1.0
3513  seed: 42
3514"#;
3515            let schemas_dir = tmp.path().join("schemas");
3516            std::fs::create_dir_all(&schemas_dir).unwrap();
3517            write_schema_files(
3518                &schemas_dir,
3519                "src-al",
3520                src_manifest,
3521                &[("doc", &make_type_yaml("doc"))],
3522            );
3523            write_schema_files(
3524                &schemas_dir,
3525                "tgt-al",
3526                tgt_manifest,
3527                &[("req", &make_type_yaml("req"))],
3528            );
3529
3530            let src_dir = tmp.path().join("mem-src");
3531            let tgt_dir = tmp.path().join("mem-tgt");
3532            std::fs::create_dir_all(&src_dir).unwrap();
3533            std::fs::create_dir_all(&tgt_dir).unwrap();
3534            // The read-only mem is pre-populated on disk — the engine
3535            // never writes to it.
3536            std::fs::write(
3537                tgt_dir.join("req-one.md"),
3538                "---\ntype: req\n---\n# Req One\n\n## Body\n\nseed.\n",
3539            )
3540            .unwrap();
3541
3542            let src_writer = FilesystemMemWriter::new(src_dir.clone());
3543            let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
3544            let src_pin = SchemaRef::new("src-al", semver::Version::new(0, 1, 0));
3545            let tgt_pin = SchemaRef::new("tgt-al", semver::Version::new(0, 1, 0));
3546
3547            let tgt_mount = Mount {
3548                mem: "tgt".to_string(),
3549                schema: Some(tgt_pin),
3550                storage: MountStorage::Folder {
3551                    path: tgt_dir.clone(),
3552                },
3553                capability,
3554                lifecycle: MountLifecycle::Eager,
3555                cross_linkable: true,
3556                migration_target: None,
3557            };
3558            let mut engine = Engine::from_mounts_with_schemas_dir(
3559                vec![
3560                    (
3561                        folder_mount_with_pin("src", src_dir, src_pin),
3562                        Box::new(src_writer) as Box<dyn MemBackend>,
3563                    ),
3564                    (tgt_mount, Box::new(tgt_writer) as Box<dyn MemBackend>),
3565                ],
3566                Some(&schemas_dir),
3567            )
3568            .expect("two-mem engine constructs");
3569
3570            let mut settings = WorkspaceSettings::default();
3571            let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
3572            links.insert("src".to_string(), CrossLinkValue::Wildcard);
3573            settings.cross_mem_links = links;
3574            engine.set_settings(settings);
3575
3576            let (actor, client) = cli_actor();
3577            let src_entity = engine
3578                .create_entity(
3579                    CreateEntityArgs {
3580                        anchors: Vec::new(),
3581                        mem: "src".to_string(),
3582                        title: "Doc One".to_string(),
3583                        entity_type: "doc".to_string(),
3584                        sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
3585                        metadata: IndexMap::new(),
3586                        relations: Vec::new(),
3587                        dry_run: false,
3588                    },
3589                    actor,
3590                    Some(&client),
3591                    None,
3592                )
3593                .expect("source entity creates");
3594
3595            (tmp, engine, src_entity)
3596        }
3597
3598        fn assert_cross_mem_target_not_found(err: EngineError, expected_target: &str) {
3599            match err {
3600                EngineError::CrossMemTargetNotFound {
3601                    target_id,
3602                    target_mem,
3603                } => {
3604                    assert_eq!(target_id, expected_target);
3605                    assert_eq!(target_mem, "tgt");
3606                }
3607                other => panic!("expected CrossMemTargetNotFound, got {other:?}"),
3608            }
3609        }
3610
3611        /// Rehearsal complement (agent-trust plan 07): a rehearsed
3612        /// relate against a read-only boundary refuses EXACTLY as the
3613        /// real call would — same variant, same payload. Paired with
3614        /// `relate_to_missing_target_in_readonly_mem_refuses` below.
3615        #[test]
3616        fn relate_dry_run_to_missing_target_in_readonly_mem_refuses_identically() {
3617            let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3618            let (actor, client) = cli_actor();
3619            let args = |dry_run: bool| RelateEntityArgs {
3620                source: src.id.clone(),
3621                expected_hash: Some(src.content_hash.clone()),
3622                rel_type: "ADDRESSES".to_string(),
3623                target: crate::EntityId::new("tgt", "missing"),
3624                remove: false,
3625                description: None,
3626                dry_run,
3627            };
3628            let rehearsed = engine
3629                .relate_entity(args(true), actor, Some(&client), None)
3630                .unwrap_err();
3631            let real = engine
3632                .relate_entity(args(false), actor, Some(&client), None)
3633                .unwrap_err();
3634            assert_eq!(format!("{rehearsed:?}"), format!("{real:?}"));
3635            assert_cross_mem_target_not_found(rehearsed, "tgt--missing");
3636        }
3637
3638        #[test]
3639        fn relate_to_missing_target_in_readonly_mem_refuses() {
3640            let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3641            let (actor, client) = cli_actor();
3642            let err = engine
3643                .relate_entity(
3644                    RelateEntityArgs {
3645                        source: src.id.clone(),
3646                        expected_hash: Some(src.content_hash.clone()),
3647                        rel_type: "ADDRESSES".to_string(),
3648                        target: crate::EntityId::new("tgt", "missing"),
3649                        remove: false,
3650                        description: None,
3651                        dry_run: false,
3652                    },
3653                    actor,
3654                    Some(&client),
3655                    None,
3656                )
3657                .unwrap_err();
3658            assert_cross_mem_target_not_found(err, "tgt--missing");
3659        }
3660
3661        /// Pre-funnel, `memstead_create.relations[]` lacked the
3662        /// ReadOnly-missing-target check the relate path had — an
3663        /// inline relation to an absent read-only target auto-stubbed
3664        /// instead of refusing.
3665        #[test]
3666        fn create_inline_relation_to_missing_target_in_readonly_mem_refuses() {
3667            let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3668            let (actor, client) = cli_actor();
3669            let err = engine
3670                .create_entity(
3671                    CreateEntityArgs {
3672                        anchors: Vec::new(),
3673                        mem: "src".to_string(),
3674                        title: "Doc Two".to_string(),
3675                        entity_type: "doc".to_string(),
3676                        sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
3677                        metadata: IndexMap::new(),
3678                        relations: vec![crate::ops::RelateArg {
3679                            target: crate::EntityId::new("tgt", "missing"),
3680                            rel_type: "ADDRESSES".to_string(),
3681                            description: None,
3682                        }],
3683                        dry_run: false,
3684                    },
3685                    actor,
3686                    Some(&client),
3687                    None,
3688                )
3689                .unwrap_err();
3690            assert_cross_mem_target_not_found(err, "tgt--missing");
3691        }
3692
3693        /// The body-wiki-link channel (alias synthesis) — pre-funnel a
3694        /// granted body link to a missing read-only target silently
3695        /// auto-stubbed at load; `memstead_health` was the only signal.
3696        #[test]
3697        fn create_body_link_to_missing_target_in_readonly_mem_refuses() {
3698            let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3699            let (actor, client) = cli_actor();
3700            let err = engine
3701                .create_entity(
3702                    CreateEntityArgs {
3703                        anchors: Vec::new(),
3704                        mem: "src".to_string(),
3705                        title: "Doc Three".to_string(),
3706                        entity_type: "doc".to_string(),
3707                        sections: IndexMap::from_iter([(
3708                            "body".to_string(),
3709                            "see [[tgt--missing]].".to_string(),
3710                        )]),
3711                        metadata: IndexMap::new(),
3712                        relations: Vec::new(),
3713                        dry_run: false,
3714                    },
3715                    actor,
3716                    Some(&client),
3717                    None,
3718                )
3719                .unwrap_err();
3720            assert_cross_mem_target_not_found(err, "tgt--missing");
3721        }
3722
3723        #[test]
3724        fn update_body_link_to_missing_target_in_readonly_mem_refuses() {
3725            let (_tmp, mut engine, src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3726            let (actor, client) = cli_actor();
3727            let err = engine
3728                .update_entity(
3729                    crate::engine::UpdateEntityArgs {
3730                        anchors: Vec::new(),
3731                        id: src.id.clone(),
3732                        expected_hash: Some(src.content_hash.clone()),
3733                        sections: IndexMap::from_iter([(
3734                            "body".to_string(),
3735                            "now see [[tgt--missing]].".to_string(),
3736                        )]),
3737                        append_sections: IndexMap::new(),
3738                        patch_sections: IndexMap::new(),
3739                        sections_unset: Vec::new(),
3740                        metadata: IndexMap::new(),
3741                        metadata_unset: Vec::new(),
3742                        declare_relations: Vec::new(),
3743                        dry_run: false,
3744                        relations_unset: Vec::new(),
3745                        anchors_unset: Vec::new(),
3746                    },
3747                    actor,
3748                    Some(&client),
3749                    None,
3750                )
3751                .unwrap_err();
3752            assert_cross_mem_target_not_found(err, "tgt--missing");
3753        }
3754
3755        /// Positive control: a body link to a target that EXISTS in
3756        /// the read-only mem writes clean and materialises the typed
3757        /// alias edge — the seam's happy path.
3758        #[test]
3759        fn body_link_to_existing_target_in_readonly_mem_admits_and_emits_edge() {
3760            let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::ReadOnly);
3761            let (actor, client) = cli_actor();
3762            let created = engine
3763                .create_entity(
3764                    CreateEntityArgs {
3765                        anchors: Vec::new(),
3766                        mem: "src".to_string(),
3767                        title: "Doc Four".to_string(),
3768                        entity_type: "doc".to_string(),
3769                        sections: IndexMap::from_iter([(
3770                            "body".to_string(),
3771                            "see [[tgt--req-one]].".to_string(),
3772                        )]),
3773                        metadata: IndexMap::new(),
3774                        relations: Vec::new(),
3775                        dry_run: false,
3776                    },
3777                    actor,
3778                    Some(&client),
3779                    None,
3780                )
3781                .expect("body link to existing read-only target admits");
3782            let outgoing = engine.store().outgoing(&created.id);
3783            assert!(
3784                outgoing.iter().any(|e| e.rel_type == "REFERENCES"
3785                    && e.target == crate::EntityId::new("tgt", "req-one")),
3786                "alias REFERENCES edge to the read-only target must materialise; got {outgoing:?}"
3787            );
3788        }
3789
3790        /// Behaviour preserved: a missing target in a WRITE-mounted
3791        /// sibling mem is a legitimate forward reference and keeps
3792        /// the auto-stub mechanic on every path.
3793        #[test]
3794        fn body_link_to_missing_target_in_write_mem_still_stubs() {
3795            let (_tmp, mut engine, _src) = engine_with_tgt_capability(MountCapability::Write);
3796            let (actor, client) = cli_actor();
3797            let created = engine
3798                .create_entity(
3799                    CreateEntityArgs {
3800                        anchors: Vec::new(),
3801                        mem: "src".to_string(),
3802                        title: "Doc Five".to_string(),
3803                        entity_type: "doc".to_string(),
3804                        sections: IndexMap::from_iter([(
3805                            "body".to_string(),
3806                            "see [[tgt--missing]].".to_string(),
3807                        )]),
3808                        metadata: IndexMap::new(),
3809                        relations: Vec::new(),
3810                        dry_run: false,
3811                    },
3812                    actor,
3813                    Some(&client),
3814                    None,
3815                )
3816                .expect("forward reference into a Write sibling mem keeps stubbing");
3817            assert!(
3818                engine
3819                    .store()
3820                    .contains(&crate::EntityId::new("tgt", "missing")),
3821                "auto-stub must land for the Write-mem forward reference"
3822            );
3823            let outgoing = engine.store().outgoing(&created.id);
3824            assert!(
3825                outgoing.iter().any(|e| e.rel_type == "REFERENCES"),
3826                "alias edge must still emit for the stubbed target"
3827            );
3828        }
3829    }
3830
3831    // ---- Engine::rename_entity --------------------------------------
3832
3833    /// Batch relate: one list mixing additions and removals, applied
3834    /// IN ORDER in one invocation with one commit. The remove entry
3835    /// targets an edge added earlier in the same batch — if entries
3836    /// validated against the pre-batch state instead, that remove
3837    /// would resolve to `NoOpAbsent` ("noop"), so the asserted
3838    /// `"removed"` action is the in-order proof.
3839    #[test]
3840    fn batch_relate_applies_adds_and_removes_in_order_one_commit() {
3841        let tmp = TempDir::new().unwrap();
3842        let mem_dir = tmp.path().to_path_buf();
3843        let writer = FilesystemMemWriter::new(mem_dir.clone());
3844        let mut engine = Engine::from_mounts(vec![(
3845            folder_mount("specs", mem_dir),
3846            Box::new(writer) as Box<dyn MemBackend>,
3847        )])
3848        .unwrap();
3849        let (actor, client) = cli_actor();
3850
3851        for title in ["A", "B", "C"] {
3852            engine
3853                .create_entity(
3854                    empty_create_args("specs", title),
3855                    actor,
3856                    Some(&client),
3857                    None,
3858                )
3859                .unwrap();
3860        }
3861        let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
3862        let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
3863            source: id(from),
3864            expected_hash: None,
3865            rel_type: "USES".to_string(),
3866            target: id(to),
3867            remove,
3868            description: None,
3869            dry_run: false,
3870        };
3871
3872        let result = engine
3873            .batch_relate(
3874                vec![
3875                    (edge("a", "b", false), Some("add a-b".to_string())),
3876                    (edge("a", "c", false), Some("add a-c".to_string())),
3877                    (edge("a", "c", true), Some("undo a-c".to_string())),
3878                ],
3879                actor,
3880                Some(&client),
3881                false,
3882            )
3883            .unwrap();
3884        assert!(result.applied, "{result:?}");
3885        assert_eq!(result.succeeded, 3);
3886        assert_eq!(result.failed, 0);
3887        assert!(!result.write_id.is_empty(), "one real commit");
3888        let actions: Vec<&str> = result.results.iter().map(|r| r.action.as_str()).collect();
3889        assert_eq!(
3890            actions,
3891            vec!["added", "added", "removed"],
3892            "in-order application: the remove sees the same batch's add"
3893        );
3894
3895        // Net state: A carries exactly the surviving edge to B.
3896        let a = engine.get_entity(&id("a")).unwrap();
3897        assert_eq!(a.relationships.len(), 1, "{:?}", a.relationships);
3898        assert_eq!(a.relationships[0].rel_type, "USES");
3899        assert_eq!(a.relationships[0].target, id("b"));
3900    }
3901
3902    /// Rehearsal contract (agent-trust plan 07) — single relate:
3903    /// `dry_run: true` runs the FULL validation, reports the would-be
3904    /// edge and the would-be auto-stub (reported, never created) with
3905    /// the marker form's empty `write_id`, and writes nothing. The
3906    /// follow-up real call succeeds and lands EXACTLY the rehearsed
3907    /// prospective `_hash` — the strongest identical-validation
3908    /// observable.
3909    #[test]
3910    fn relate_dry_run_reports_would_be_stub_and_writes_nothing() {
3911        let tmp = TempDir::new().unwrap();
3912        let (mut engine, source) = engine_with_seed(&tmp, "Src");
3913        // Pin the mutation clock: the auto-stamped `last_modified`
3914        // enters the content hash, so the prospective-hash == real-hash
3915        // assertion below is only deterministic under a frozen clock
3916        // (unpinned, it fails whenever a wall-clock second ticks
3917        // between the rehearsal and the real call).
3918        engine.set_mutation_clock(std::sync::Arc::new(|| {
3919            std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000)
3920        }));
3921        let (actor, client) = cli_actor();
3922        let absent = crate::EntityId::new("specs", "ghost-target");
3923        let args = |dry_run: bool| RelateEntityArgs {
3924            source: source.id.clone(),
3925            expected_hash: Some(source.content_hash.clone()),
3926            rel_type: "USES".to_string(),
3927            target: absent.clone(),
3928            remove: false,
3929            description: None,
3930            dry_run,
3931        };
3932
3933        let rehearsed = engine
3934            .relate_entity(args(true), actor, Some(&client), None)
3935            .unwrap();
3936        assert_eq!(rehearsed.action, RelateAction::Added);
3937        assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
3938        assert!(
3939            rehearsed.warnings.iter().any(
3940                |w| matches!(w, crate::ops::WarningHint::AutoStubCreated { stub_id, pending: true } if *stub_id == absent)
3941            ),
3942            "would-be stub must be reported as pending: {:?}",
3943            rehearsed.warnings
3944        );
3945        // The rehearsed warning must not claim a performed effect —
3946        // conditional wording, code unchanged (AUTO_STUB_CREATED).
3947        let rehearsed_stub = rehearsed
3948            .warnings
3949            .iter()
3950            .find(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. }))
3951            .unwrap();
3952        assert_eq!(rehearsed_stub.code(), "AUTO_STUB_CREATED");
3953        let msg = rehearsed_stub.message();
3954        assert!(
3955            msg.contains("would be auto-created") && !msg.contains("stub auto-created."),
3956            "dry-run wording must be conditional: {msg}"
3957        );
3958        assert!(
3959            !engine.store().contains(&absent),
3960            "would-be stub reported, never created"
3961        );
3962        // Source untouched: stored hash still the pre-call hash, and
3963        // the reported hash is the PROSPECTIVE one (a real change).
3964        let stored = engine.store().get(&source.id).unwrap();
3965        assert_eq!(stored.content_hash, source.content_hash);
3966        assert_ne!(rehearsed.content_hash, source.content_hash);
3967        assert!(stored.relationships.is_empty(), "no edge landed");
3968
3969        // Follow-up real call: succeeds, commits, and the rehearsed
3970        // prospective hash IS the real post-write hash.
3971        let real = engine
3972            .relate_entity(args(false), actor, Some(&client), None)
3973            .unwrap();
3974        assert!(!real.write_id.is_empty(), "the real relate commits");
3975        assert_eq!(
3976            real.content_hash, rehearsed.content_hash,
3977            "prospective hash must equal the real post-write hash"
3978        );
3979        assert!(engine.store().get(&absent).expect("real call stubs").stub);
3980        // The real call keeps the performed-effect wording exactly.
3981        let real_msg = real
3982            .warnings
3983            .iter()
3984            .find(|w| {
3985                matches!(
3986                    w,
3987                    crate::ops::WarningHint::AutoStubCreated { pending: false, .. }
3988                )
3989            })
3990            .expect("real relate carries the non-pending stub warning")
3991            .message();
3992        assert!(
3993            real_msg.contains("did not exist — stub auto-created."),
3994            "real wording unchanged: {real_msg}"
3995        );
3996    }
3997
3998    /// Rehearsal refusal parity — single relate: an illegal rehearsed
3999    /// relate refuses with the IDENTICAL typed error the real call
4000    /// returns (same variant, same payload).
4001    #[test]
4002    fn relate_dry_run_refuses_identically_to_real() {
4003        let tmp = TempDir::new().unwrap();
4004        let (mut engine, source) = engine_with_seed(&tmp, "Src");
4005        let (actor, client) = cli_actor();
4006        // Malformed target id (no `--` separator) — INVALID_ENTITY_ID.
4007        let args = |dry_run: bool| RelateEntityArgs {
4008            source: source.id.clone(),
4009            expected_hash: None,
4010            rel_type: "USES".to_string(),
4011            target: crate::EntityId("bad target with spaces".to_string()),
4012            remove: false,
4013            description: None,
4014            dry_run,
4015        };
4016        let rehearsed = engine
4017            .relate_entity(args(true), actor, Some(&client), None)
4018            .unwrap_err();
4019        let real = engine
4020            .relate_entity(args(false), actor, Some(&client), None)
4021            .unwrap_err();
4022        assert_eq!(
4023            format!("{rehearsed:?}"),
4024            format!("{real:?}"),
4025            "identical typed refusal"
4026        );
4027        assert_eq!(rehearsed.code(), real.code());
4028    }
4029
4030    /// Rehearsal — batch relate: `dry_run: true` validates the whole
4031    /// list in order (a remove of an edge added earlier in the SAME
4032    /// batch reports `"removed"` — the in-order proof), reports the
4033    /// would-be receipt with empty `write_id`, and commits nothing:
4034    /// no edge, no stub, no head movement. The follow-up real batch
4035    /// succeeds.
4036    #[test]
4037    fn batch_relate_dry_run_reports_receipt_and_commits_nothing() {
4038        let tmp = TempDir::new().unwrap();
4039        let mem_dir = tmp.path().to_path_buf();
4040        let writer = FilesystemMemWriter::new(mem_dir.clone());
4041        let mut engine = Engine::from_mounts(vec![(
4042            folder_mount("specs", mem_dir),
4043            Box::new(writer) as Box<dyn MemBackend>,
4044        )])
4045        .unwrap();
4046        let (actor, client) = cli_actor();
4047        for title in ["A", "B"] {
4048            engine
4049                .create_entity(
4050                    empty_create_args("specs", title),
4051                    actor,
4052                    Some(&client),
4053                    None,
4054                )
4055                .unwrap();
4056        }
4057        let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4058        let edge = |from: &str, to: &str, remove: bool| RelateEntityArgs {
4059            source: id(from),
4060            expected_hash: None,
4061            rel_type: "USES".to_string(),
4062            target: id(to),
4063            remove,
4064            description: None,
4065            dry_run: false,
4066        };
4067        let batch = || {
4068            vec![
4069                (edge("a", "b", false), None),
4070                (edge("a", "ghost", false), None), // would-be auto-stub
4071                (edge("a", "b", true), None),      // in-order: sees entry 0's add
4072            ]
4073        };
4074        let head_before = engine
4075            .mem_head_sha("specs")
4076            .ok()
4077            .flatten()
4078            .unwrap_or_default();
4079
4080        let rehearsed = engine
4081            .batch_relate(batch(), actor, Some(&client), true)
4082            .unwrap();
4083        assert!(rehearsed.applied, "{rehearsed:?}");
4084        assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
4085        let actions: Vec<&str> = rehearsed
4086            .results
4087            .iter()
4088            .map(|r| r.action.as_str())
4089            .collect();
4090        assert_eq!(
4091            actions,
4092            vec!["added", "added", "removed"],
4093            "in-order rehearsal semantics"
4094        );
4095        // Nothing landed: no stub, no edge, no head movement.
4096        assert!(!engine.store().contains(&id("ghost")), "no stub created");
4097        assert!(
4098            engine
4099                .get_entity(&id("a"))
4100                .unwrap()
4101                .relationships
4102                .is_empty(),
4103            "no edge landed"
4104        );
4105        let head_after = engine
4106            .mem_head_sha("specs")
4107            .ok()
4108            .flatten()
4109            .unwrap_or_default();
4110        assert_eq!(head_before, head_after, "no commit landed");
4111
4112        // The real batch on the unchanged mem succeeds.
4113        let real = engine
4114            .batch_relate(batch(), actor, Some(&client), false)
4115            .unwrap();
4116        assert!(real.applied, "{real:?}");
4117        assert!(!real.write_id.is_empty());
4118    }
4119
4120    /// Rehearsal refusal parity — batch relate: a failing list refuses
4121    /// under `dry_run: true` with the SAME per-entry report-all
4122    /// envelope the real refusal carries.
4123    #[test]
4124    fn batch_relate_dry_run_refuses_identically_to_real() {
4125        let tmp = TempDir::new().unwrap();
4126        let mem_dir = tmp.path().to_path_buf();
4127        let writer = FilesystemMemWriter::new(mem_dir.clone());
4128        let mut engine = Engine::from_mounts(vec![(
4129            folder_mount("specs", mem_dir),
4130            Box::new(writer) as Box<dyn MemBackend>,
4131        )])
4132        .unwrap();
4133        let (actor, client) = cli_actor();
4134        for title in ["A", "B"] {
4135            engine
4136                .create_entity(
4137                    empty_create_args("specs", title),
4138                    actor,
4139                    Some(&client),
4140                    None,
4141                )
4142                .unwrap();
4143        }
4144        let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4145        let batch = || {
4146            vec![
4147                (
4148                    RelateEntityArgs {
4149                        source: id("a"),
4150                        expected_hash: None,
4151                        rel_type: "USES".to_string(),
4152                        target: id("b"),
4153                        remove: false,
4154                        description: None,
4155                        dry_run: false,
4156                    },
4157                    None,
4158                ),
4159                (
4160                    RelateEntityArgs {
4161                        source: id("a"),
4162                        expected_hash: None,
4163                        rel_type: "USES".to_string(),
4164                        target: crate::EntityId("bad target".to_string()),
4165                        remove: false,
4166                        description: None,
4167                        dry_run: false,
4168                    },
4169                    None,
4170                ),
4171            ]
4172        };
4173        let rehearsed = engine
4174            .batch_relate(batch(), actor, Some(&client), true)
4175            .unwrap();
4176        let real = engine
4177            .batch_relate(batch(), actor, Some(&client), false)
4178            .unwrap();
4179        assert!(!rehearsed.applied && !real.applied);
4180        let envelope = |r: &crate::ops::BatchResult| {
4181            r.results
4182                .iter()
4183                .map(|e| {
4184                    (
4185                        e.id.to_string(),
4186                        e.action.clone(),
4187                        e.error.as_ref().map(|err| {
4188                            (err.code.clone(), err.message.clone(), err.details.clone())
4189                        }),
4190                    )
4191                })
4192                .collect::<Vec<_>>()
4193        };
4194        assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
4195        assert!(
4196            engine
4197                .get_entity(&id("a"))
4198                .unwrap()
4199                .relationships
4200                .is_empty(),
4201            "neither run landed the valid entry"
4202        );
4203    }
4204
4205    /// Atomicity + report-all for batch relate: a batch with several
4206    /// invalid entries changes NOTHING (no edge lands, the head is
4207    /// unmoved, staged earlier entries roll back) and names EVERY
4208    /// failing entry with its typed code.
4209    #[test]
4210    fn batch_relate_refuses_whole_batch_reporting_every_failure() {
4211        let tmp = TempDir::new().unwrap();
4212        let mem_dir = tmp.path().to_path_buf();
4213        let writer = FilesystemMemWriter::new(mem_dir.clone());
4214        let mut engine = Engine::from_mounts(vec![(
4215            folder_mount("specs", mem_dir),
4216            Box::new(writer) as Box<dyn MemBackend>,
4217        )])
4218        .unwrap();
4219        let (actor, client) = cli_actor();
4220
4221        let a = engine
4222            .create_entity(empty_create_args("specs", "A"), actor, Some(&client), None)
4223            .unwrap();
4224        engine
4225            .create_entity(empty_create_args("specs", "B"), actor, Some(&client), None)
4226            .unwrap();
4227        let head_before = engine
4228            .mem_head_sha("specs")
4229            .ok()
4230            .flatten()
4231            .unwrap_or_default();
4232        let count_before = engine.store().all_entities().count();
4233
4234        let id = |slug: &str| crate::entity::EntityId::new("specs", slug);
4235        let result = engine
4236            .batch_relate(
4237                vec![
4238                    // Valid — stages an edge that must roll back.
4239                    (
4240                        RelateEntityArgs {
4241                            source: id("a"),
4242                            expected_hash: None,
4243                            rel_type: "USES".to_string(),
4244                            target: id("b"),
4245                            remove: false,
4246                            description: None,
4247                            dry_run: false,
4248                        },
4249                        None,
4250                    ),
4251                    // Missing source.
4252                    (
4253                        RelateEntityArgs {
4254                            source: id("ghost"),
4255                            expected_hash: None,
4256                            rel_type: "USES".to_string(),
4257                            target: id("b"),
4258                            remove: false,
4259                            description: None,
4260                            dry_run: false,
4261                        },
4262                        None,
4263                    ),
4264                    // Optimistic-lock mismatch.
4265                    (
4266                        RelateEntityArgs {
4267                            source: id("b"),
4268                            expected_hash: Some("definitely-wrong".to_string()),
4269                            rel_type: "USES".to_string(),
4270                            target: id("a"),
4271                            remove: false,
4272                            description: None,
4273                            dry_run: false,
4274                        },
4275                        None,
4276                    ),
4277                ],
4278                actor,
4279                Some(&client),
4280                false,
4281            )
4282            .unwrap();
4283        assert!(!result.applied);
4284        assert_eq!(result.failed, 2, "{result:?}");
4285        assert!(result.write_id.is_empty());
4286        let codes: Vec<(usize, &str)> = result
4287            .results
4288            .iter()
4289            .enumerate()
4290            .filter(|(_, r)| r.action == "error")
4291            .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
4292            .collect();
4293        assert_eq!(
4294            codes,
4295            vec![(1, "ENTITY_NOT_FOUND"), (2, "HASH_MISMATCH")],
4296            "every failing entry named with index + typed code: {result:?}"
4297        );
4298        assert_eq!(result.results[0].action, "not_applied");
4299
4300        // NOTHING changed: the staged first edge rolled back, the head
4301        // is unmoved, and no stub or entity appeared.
4302        let a_after = engine.get_entity(&a.id).unwrap();
4303        assert!(
4304            a_after.relationships.is_empty(),
4305            "staged edge must roll back: {:?}",
4306            a_after.relationships
4307        );
4308        assert_eq!(a_after.content_hash, a.content_hash);
4309        let head_after = engine
4310            .mem_head_sha("specs")
4311            .ok()
4312            .flatten()
4313            .unwrap_or_default();
4314        assert_eq!(head_before, head_after, "mem head unmoved");
4315        assert_eq!(engine.store().all_entities().count(), count_before);
4316    }
4317}
4318
4319#[cfg(test)]
4320mod derivation_tests {
4321    use indexmap::IndexMap;
4322    use tempfile::TempDir;
4323
4324    use crate::backend::MemBackend;
4325    use crate::engine::{
4326        CreateEntityArgs, Engine, RelateAction, RelateEntityArgs, UpdateEntityArgs,
4327    };
4328    use crate::ops::WarningHint;
4329    use crate::storage::FilesystemMemWriter;
4330    use crate::vcs::Actor;
4331    use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
4332
4333    /// A schema whose `DERIVED_FROM` declares `derivation: true` and
4334    /// whose `SUPPORTS` does not — the paired fixture every assertion
4335    /// here contrasts.
4336    fn deriv_schema() -> std::sync::Arc<memstead_schema::Schema> {
4337        let manifest = r#"name: deriv
4338version: 0.1.0
4339description: derivation fixture
4340when_to_use: tests
4341types:
4342  - note
4343relationships:
4344  mode: strict
4345  definitions:
4346    - name: PART_OF
4347      description: hier
4348      default_weight: 3.0
4349    - name: DERIVED_FROM
4350      description: source derives from target
4351      default_weight: 2.0
4352      derivation: true
4353    - name: SUPPORTS
4354      description: plain edge
4355      default_weight: 1.0
4356    - name: _default
4357      description: fallback
4358      default_weight: 1.0
4359community:
4360  resolution: 1.0
4361  seed: 42
4362"#;
4363        let type_yaml = r#"name: note
4364description: t
4365when_to_use: tests
4366sections:
4367  - key: body
4368    heading: Body
4369    required: true
4370    search_weight: 10.0
4371    catch_all: true
4372    write_rules: []
4373metadata_fields: []
4374title_weight: 100.0
4375text_fields:
4376  - body
4377hierarchy_relationship: PART_OF
4378no_self_loop_relationships: []
4379updatable_fields:
4380  - title
4381  - body
4382health_required_fields:
4383  - body
4384staleness_threshold_days: 90
4385write_rules: []
4386"#;
4387        std::sync::Arc::new(
4388            memstead_schema::loader::load_schema_from_memory(
4389                manifest,
4390                &[("note".to_string(), type_yaml.to_string())],
4391            )
4392            .expect("fixture schema loads"),
4393        )
4394    }
4395
4396    fn engine_at(tmp: &TempDir) -> Engine {
4397        let mem_dir = tmp.path().to_path_buf();
4398        let writer = FilesystemMemWriter::new(mem_dir.clone());
4399        let mount = Mount {
4400            mem: "m".to_string(),
4401            schema: Some("deriv@0.1.0".parse().unwrap()),
4402            storage: MountStorage::Folder { path: mem_dir },
4403            capability: MountCapability::Write,
4404            lifecycle: MountLifecycle::Eager,
4405            cross_linkable: true,
4406            migration_target: None,
4407        };
4408        Engine::from_mounts_with_schemas_dir_and_extra(
4409            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
4410            None,
4411            vec![deriv_schema()],
4412        )
4413        .unwrap()
4414    }
4415
4416    fn note(title: &str, body: &str) -> CreateEntityArgs {
4417        CreateEntityArgs {
4418            anchors: Vec::new(),
4419            mem: "m".to_string(),
4420            title: title.to_string(),
4421            entity_type: "note".to_string(),
4422            sections: IndexMap::from_iter([("body".to_string(), body.to_string())]),
4423            metadata: IndexMap::new(),
4424            relations: Vec::new(),
4425            dry_run: false,
4426        }
4427    }
4428
4429    fn relate_args(from: &crate::EntityId, rel: &str, to: &crate::EntityId) -> RelateEntityArgs {
4430        RelateEntityArgs {
4431            source: from.clone(),
4432            expected_hash: None,
4433            rel_type: rel.to_string(),
4434            target: to.clone(),
4435            remove: false,
4436            description: None,
4437            dry_run: false,
4438        }
4439    }
4440
4441    /// Criterion 1's fixture, one flow: write → edit target → report
4442    /// stale → re-assert → clear, with the refresh STATED. Plus the
4443    /// undeclared-rel-type no-op complement and the
4444    /// source-edit / markdown-invisibility complements.
4445    #[test]
4446    fn derivation_write_edit_report_reassert_clear() {
4447        let tmp = TempDir::new().unwrap();
4448        let mut engine = engine_at(&tmp);
4449        let target = engine
4450            .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4451            .unwrap();
4452        let source = engine
4453            .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4454            .unwrap();
4455
4456        // Write the derivation edge — baseline recorded, report clean.
4457        let added = engine
4458            .relate_entity(
4459                relate_args(&source.id, "DERIVED_FROM", &target.id),
4460                Actor::Cli,
4461                None,
4462                None,
4463            )
4464            .unwrap();
4465        assert_eq!(added.action, RelateAction::Added);
4466        assert!(
4467            engine.derivation_report("m").unwrap().is_empty(),
4468            "freshly baselined edge must not report"
4469        );
4470
4471        // Edit the TARGET → the edge reports stale, naming all three.
4472        let t_now = engine.get_entity(&target.id).unwrap().content_hash.clone();
4473        engine
4474            .update_entity(
4475                UpdateEntityArgs {
4476                    anchors: Vec::new(),
4477                    id: target.id.clone(),
4478                    expected_hash: Some(t_now),
4479                    sections: IndexMap::from_iter([("body".to_string(), "v2".to_string())]),
4480                    append_sections: IndexMap::new(),
4481                    patch_sections: IndexMap::new(),
4482                    sections_unset: Vec::new(),
4483                    metadata: IndexMap::new(),
4484                    metadata_unset: Vec::new(),
4485                    declare_relations: Vec::new(),
4486                    dry_run: false,
4487                    relations_unset: Vec::new(),
4488                    anchors_unset: Vec::new(),
4489                },
4490                Actor::Cli,
4491                None,
4492                None,
4493            )
4494            .unwrap();
4495        let report = engine.derivation_report("m").unwrap();
4496        assert_eq!(report.len(), 1, "{report:?}");
4497        assert_eq!(report[0].source, source.id);
4498        assert_eq!(report[0].rel_type, "DERIVED_FROM");
4499        assert_eq!(report[0].target, target.id);
4500        assert_eq!(report[0].state, "stale");
4501        assert!(report[0].baseline.is_some());
4502
4503        // Editing the SOURCE does not mark its own derivation stale
4504        // (already covered: the edit above touched only the target;
4505        // now touch the source and assert the report is unchanged in
4506        // meaning — still exactly the one stale edge).
4507        let s_now = engine.get_entity(&source.id).unwrap().content_hash.clone();
4508        engine
4509            .update_entity(
4510                UpdateEntityArgs {
4511                    anchors: Vec::new(),
4512                    id: source.id.clone(),
4513                    expected_hash: Some(s_now),
4514                    sections: IndexMap::from_iter([(
4515                        "body".to_string(),
4516                        "conclusion v2".to_string(),
4517                    )]),
4518                    append_sections: IndexMap::new(),
4519                    patch_sections: IndexMap::new(),
4520                    sections_unset: Vec::new(),
4521                    metadata: IndexMap::new(),
4522                    metadata_unset: Vec::new(),
4523                    declare_relations: Vec::new(),
4524                    dry_run: false,
4525                    relations_unset: Vec::new(),
4526                    anchors_unset: Vec::new(),
4527                },
4528                Actor::Cli,
4529                None,
4530                None,
4531            )
4532            .unwrap();
4533        let report = engine.derivation_report("m").unwrap();
4534        assert_eq!(report.len(), 1, "source edit adds nothing: {report:?}");
4535        assert_eq!(report[0].state, "stale");
4536
4537        // Re-assert: duplicate-add refreshes the baseline as its ONE
4538        // effect — action noop, `_hash` unchanged, markdown
4539        // byte-identical, the refresh STATED, a real commit sha.
4540        let md_before = std::fs::read(tmp.path().join("source.md")).unwrap();
4541        let hash_before = engine.get_entity(&source.id).unwrap().content_hash.clone();
4542        let refreshed = engine
4543            .relate_entity(
4544                relate_args(&source.id, "DERIVED_FROM", &target.id),
4545                Actor::Cli,
4546                None,
4547                None,
4548            )
4549            .unwrap();
4550        assert_eq!(refreshed.action, RelateAction::NoOpAlreadyPresent);
4551        assert_eq!(refreshed.content_hash, hash_before, "_hash unchanged");
4552        assert!(
4553            !refreshed.write_id.is_empty(),
4554            "the sidecar refresh persists via a real commit"
4555        );
4556        assert!(
4557            refreshed
4558                .warnings
4559                .iter()
4560                .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4561            "the refresh is STATED, never a bare no-op: {:?}",
4562            refreshed.warnings
4563        );
4564        let md_after = std::fs::read(tmp.path().join("source.md")).unwrap();
4565        assert_eq!(md_before, md_after, "baselines never touch the markdown");
4566        assert!(
4567            engine.derivation_report("m").unwrap().is_empty(),
4568            "re-assert clears the staleness"
4569        );
4570
4571        // Undeclared rel-type: duplicate-add keeps today's EXACT
4572        // no-op — empty write_id, no refresh warning.
4573        engine
4574            .relate_entity(
4575                relate_args(&source.id, "SUPPORTS", &target.id),
4576                Actor::Cli,
4577                None,
4578                None,
4579            )
4580            .unwrap();
4581        let noop = engine
4582            .relate_entity(
4583                relate_args(&source.id, "SUPPORTS", &target.id),
4584                Actor::Cli,
4585                None,
4586                None,
4587            )
4588            .unwrap();
4589        assert_eq!(noop.action, RelateAction::NoOpAlreadyPresent);
4590        assert!(noop.write_id.is_empty(), "undeclared no-op stays bare");
4591        assert!(
4592            !noop
4593                .warnings
4594                .iter()
4595                .any(|w| matches!(w, WarningHint::DerivationBaselineRefreshed { .. })),
4596        );
4597        // And undeclared edges never enter the axis: still empty.
4598        assert!(engine.derivation_report("m").unwrap().is_empty());
4599    }
4600
4601    /// A derivation edge with NO recorded baseline (pre-declaration
4602    /// legacy, simulated by removing the sidecar out of band) reports
4603    /// `unbaselined` — distinct from both fresh and stale, never
4604    /// fabricated. The sidecar file itself never lists as an entity.
4605    #[test]
4606    fn missing_baseline_reports_unbaselined_never_fabricated() {
4607        let tmp = TempDir::new().unwrap();
4608        let mut engine = engine_at(&tmp);
4609        let target = engine
4610            .create_entity(note("Target", "v1"), Actor::Cli, None, None)
4611            .unwrap();
4612        let source = engine
4613            .create_entity(note("Source", "conclusion"), Actor::Cli, None, None)
4614            .unwrap();
4615        engine
4616            .relate_entity(
4617                relate_args(&source.id, "DERIVED_FROM", &target.id),
4618                Actor::Cli,
4619                None,
4620                None,
4621            )
4622            .unwrap();
4623        // The sidecar exists on disk but is not an entity.
4624        let sidecar = tmp.path().join(".memstead").join("derivations.json");
4625        assert!(sidecar.exists(), "baseline persisted to the sidecar");
4626        assert!(
4627            engine
4628                .get_entity(&crate::EntityId::new("m", "derivations"))
4629                .is_none()
4630        );
4631
4632        // Simulate a pre-declaration edge: drop the sidecar out of
4633        // band (fixture surgery, the mounts.json precedent).
4634        std::fs::remove_file(&sidecar).unwrap();
4635        let report = engine.derivation_report("m").unwrap();
4636        assert_eq!(report.len(), 1, "{report:?}");
4637        assert_eq!(report[0].state, "unbaselined");
4638        assert!(report[0].baseline.is_none());
4639    }
4640}