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