Skip to main content

memstead_base/engine/mutation/
relate.rs

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