Skip to main content

memstead_base/engine/mutation/
relate.rs

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