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