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    RELATIONSHIP_CYCLE_PATH_CAP, make_stub, unknown_type_error, validate_description_posture,
24    validate_relation_target_grammar,
25};
26
27impl Engine {
28    /// Add or remove a typed relationship on `args.source`.
29    ///
30    /// Cross-mem relate is policy-gated through
31    /// [`Engine::cross_mem_link_allowed`] — the workspace's
32    /// `[cross_mem_links]` table (or per-create-rule
33    /// `default_cross_links` synthesis) decides whether the edge is
34    /// permitted. Disallowed pairings surface
35    /// [`EngineError::CrossMemLinkNotAllowed`]. Cross-mem relate
36    /// only writes the source entity's markdown — the target mem is
37    /// never written to. Auto-stub for absent targets works for
38    /// Write target mems; ReadOnly target mems reject absent
39    /// targets with [`EngineError::CrossMemTargetNotFound`] because
40    /// the engine cannot persist a stub through the read-only
41    /// boundary.
42    ///
43    /// Schema-undeclared rel types surface either as validation
44    /// errors (strict mode) or as ride-along warnings on the outcome
45    /// (open mode).
46    pub fn relate_entity(
47        &mut self,
48        args: RelateEntityArgs,
49        actor: Actor,
50        client: Option<&ClientId>,
51        note: Option<&str>,
52    ) -> Result<RelateEntityOutcome, EngineError> {
53        let mut args = args;
54        let source_mem = args.source.mem().to_string();
55        let target_mem = args.target.mem().to_string();
56
57        // Reload-before-operation: reload the source mem (and the
58        // target mem, when distinct) if a sibling advanced either
59        // ref, so the source `expected_hash` compare and the target
60        // existence/stub decisions below run against current truth.
61        // The drift notice rides the outcome's `warnings`.
62        let mut drift_warnings = self.reload_if_stale(Some(&source_mem));
63        if target_mem != source_mem {
64            drift_warnings.append(&mut self.reload_if_stale(Some(&target_mem)));
65        }
66
67        // Target-id grammar gate (shared helper, also called from
68        // `Engine::create_entity` for inline relations so both
69        // gateways trip the same envelope). Source-id grammar is
70        // implicit — a malformed source surfaces as `ENTITY_NOT_FOUND`
71        // because it can never have been created.
72        //
73        // The grammar check runs BEFORE the cross-mem policy check:
74        // a bare-string target with no `--` separator (e.g.
75        // `bad target`) parses as `mem: ""`, `path: "bad target"`,
76        // and without this ordering would surface a cross-mem
77        // policy error against an empty mem name — pointing the
78        // agent at workspace policy when the actual fix is a
79        // malformed id. The grammar check is intrinsic to the target
80        // id; it doesn't need to know which mem the target lives
81        // in.
82        validate_relation_target_grammar(&args.target)?;
83
84        // Track whether the cross-mem target's mem is unmounted —
85        // we deferred the warning emission to the canonical
86        // `warnings` vec initialisation below, but the policy / RO
87        // gates fire first to keep the refusal-before-warning ordering:
88        // a policy refusal preempts the warning.
89        let mut target_mem_uncreated = false;
90        if source_mem != target_mem {
91            // Policy gates *new* edges only — remove is structurally
92            // cleanup. The same convention governs the acyclic, shape,
93            // and schema gates below (each one wraps `if !args.remove`).
94            // Without this bypass, a workspace whose cross-mem grant
95            // was revoked while edges still existed gets wedged: the
96            // grant must be re-introduced just to delete the data it
97            // permitted, then re-revoked. The gate-on-add
98            // rule holds because `cross_mem_links: named` semantically reads
99            // as "only these new edges may be created", not "these
100            // edges may exist."
101            if !args.remove {
102                super::validate_cross_mem_add_policy(self, &source_mem, &target_mem)?;
103            }
104            // ReadOnly target mem: the engine has no write access to
105            // persist a stub there, so the target must already exist
106            // before relate. (Same-mem and cross-mem-to-Write
107            // both retain the auto-stub mechanic below.)
108            if let Some(mount) = self.mount(&target_mem)
109                && mount.capability == MountCapability::ReadOnly
110                && !self.store.contains(&args.target)
111            {
112                return Err(EngineError::CrossMemTargetNotFound {
113                    target_id: args.target.to_string(),
114                    target_mem: target_mem.clone(),
115                });
116            }
117            // The target mem isn't mounted in the workspace
118            // at all. Policy admitted the edge so the relate must
119            // succeed and auto-stub; surface a warning so the operator
120            // can distinguish a typo from a deliberate forward
121            // reference. The auto-stub still lands via the
122            // `AutoStubCreated` path below; this layered warning is
123            // additive observability.
124            if self.mount(&target_mem).is_none() {
125                target_mem_uncreated = true;
126            }
127        }
128
129        // Canonicalise rel_type to UPPER_SNAKE_CASE so the schema lookup,
130        // stored edge, and response all see the same wire-contract form
131        // ("case-insensitive on input"). Syntax errors (non-letter
132        // characters) fall through to the strict-mode schema check below,
133        // which surfaces them as INVALID_REL_TYPE with the declared
134        // vocabulary.
135        if let Ok(canonical) = crate::entity::id::validate_rel_type(&args.rel_type) {
136            args.rel_type = canonical;
137        }
138        // Normalise the description at the boundary so empty /
139        // whitespace-only strings collapse to `None` before the
140        // posture check and before the renderer ever sees them.
141        args.description = normalise_description(args.description.as_deref());
142
143        let mount_idx = self
144            .mounts
145            .iter()
146            .position(|m| m.mount.mem == source_mem)
147            .ok_or_else(|| EngineError::UnknownMem(source_mem.clone()))?;
148        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
149            return Err(EngineError::ReadOnlyMount(source_mem));
150        }
151
152        let schema = self
153            .schemas
154            .get(&source_mem)
155            .expect("schema present for every registered mount");
156
157        // Determine whether this is a cross-mem edge to a mem
158        // pinning a schema with a *different name*. Same-name (any
159        // version pair — a schema name is a domain) and same-mem
160        // stay on the intra-mem validation path, governed by the
161        // source mem's pinned version; cross-different-schema
162        // routes vocabulary and shape checks through the source
163        // schema's `cross_mem_relationships:` section.
164        //
165        // If the target mem is not mounted (unknown to the engine
166        // — typically only in malformed callers), there is no target
167        // schema to consult and the validation falls back to the
168        // intra-mem path. Real workspaces always mount the target
169        // mem before relating.
170        let target_schema = if source_mem == target_mem {
171            None
172        } else {
173            self.schemas.get(&target_mem).cloned()
174        };
175        let target_schema_ref: Option<SchemaRef> = target_schema.as_ref().map(|s| {
176            let (name, version) = s.id();
177            SchemaRef::new(name, version)
178        });
179        let cross_mem_different = match (&target_schema_ref, schema.id()) {
180            (Some(target), (src_name, _)) => target.name != src_name,
181            (None, _) => false,
182        };
183
184        let mut warnings: Vec<WarningHint> = Vec::new();
185        // Reload-before-operation drift notice, surfaced first.
186        warnings.append(&mut drift_warnings);
187        // Vocabulary check: intra-mem flow consults the source
188        // schema's `relationships.definitions`; cross-different-schema
189        // skips this entirely (the cross-mem entry's `definitions`
190        // are the sole authority — see the add-path check below).
191        if !cross_mem_different {
192            match validate_rel_type(&args.rel_type, schema.as_ref())? {
193                RelationshipCheck::Ok => {}
194                RelationshipCheck::OpenWarning(message) => {
195                    warnings.push(WarningHint::UndeclaredRelationshipOpen {
196                        rel_type: args.rel_type.clone(),
197                        message,
198                    });
199                }
200            }
201        }
202
203        // Clone the source entity early so subsequent mutable
204        // operations on `self.store` (the stub-creation upsert below)
205        // don't conflict with the borrow.
206        let entity: Entity = self
207            .store
208            .get(&args.source)
209            .ok_or_else(|| EngineError::NotFound {
210                id: args.source.to_string(),
211            })?
212            .clone();
213
214        // Stubs have no `entity_type`, so the schema lookup below
215        // would surface a cryptic `UnknownType { name: "" }`. Surface
216        // the actual constraint instead — a stub source has no body
217        // to write to and no schema-resolved type to validate
218        // against. Promotion via `memstead_create` adopts the stub's
219        // incoming references and lets the agent re-issue the relate
220        // against a real entity.
221        if entity.stub {
222            return Err(EngineError::StubCannotRelate {
223                id: args.source.to_string(),
224            });
225        }
226
227        let target_type = self
228            .store
229            .get(&args.target)
230            .map(|e| e.entity_type.clone())
231            .filter(|t| !t.is_empty());
232        // Shape validation is add-only. Edges that violated the
233        // schema's shape before constraints landed must remain
234        // removable through `memstead_relate remove=true` — otherwise the
235        // graph carries unfixable shape drift. The health scan
236        // surfaces the existing violations so an agent can run the
237        // cleanup pass. The same posture applies to cross-mem
238        // vocabulary: the cleanup path stays permissive so
239        // pre-tightening edges can be dropped without first
240        // re-declaring them in the source schema.
241        // Per-edge description posture (intra-mem and cross-mem).
242        // Add-only — the remove path stays permissive so pre-tightening
243        // edges remain droppable (mirrors the shape-validation posture
244        // below). Posture is a no-op for rel-types not declared in the
245        // schema; the vocabulary gate runs first and surfaces those.
246        if !args.remove {
247            validate_description_posture(
248                self,
249                &args.rel_type,
250                args.description.as_deref(),
251                &source_mem,
252                &target_mem,
253                &args.source,
254                &args.target,
255            )?;
256            // Refuse
257            // explicit `memstead_relate` calls for rel-types whose schema
258            // declares `manual_authoring: forbidden`. The body-link →
259            // relation alias machinery synthesises these relations
260            // from wiki-links via a separate path that doesn't go
261            // through this validator, so the alias contract stays
262            // intact.
263            super::validate_manual_authoring_posture(
264                self,
265                &args.rel_type,
266                &source_mem,
267                &args.source,
268                &args.target,
269            )?;
270        }
271
272        if !args.remove {
273            if cross_mem_different {
274                // Safe-by-construction: `cross_mem_different` only
275                // becomes true when `target_schema_ref` is `Some`.
276                let target_ref = target_schema_ref
277                    .as_ref()
278                    .expect("target_schema_ref is Some when cross_mem_different");
279                match validate_cross_mem_edge(
280                    &args.rel_type,
281                    entity.entity_type.as_str(),
282                    target_type.as_deref(),
283                    schema.as_ref(),
284                    target_ref,
285                ) {
286                    CrossMemRelCheck::Ok => {}
287                    CrossMemRelCheck::EdgeNotDeclared => {
288                        let (src_name, src_version) = schema.id();
289                        return Err(EngineError::CrossMemEdgeNotDeclared {
290                            source_schema: SchemaRef::new(src_name, src_version).as_display(),
291                            target_schema: target_ref.as_display(),
292                            rel_type: args.rel_type.clone(),
293                            from_id: args.source.to_string(),
294                            to_id: args.target.to_string(),
295                        });
296                    }
297                    CrossMemRelCheck::Invalid(v) => {
298                        return Err(EngineError::Validation(v));
299                    }
300                }
301            } else {
302                validate_rel_shape(
303                    &args.rel_type,
304                    entity.entity_type.as_str(),
305                    target_type.as_deref(),
306                    schema.as_ref(),
307                )?;
308            }
309        }
310
311        if let Some(expected) = args.expected_hash.as_deref()
312            && entity.content_hash != expected
313        {
314            return Err(EngineError::HashMismatch {
315                id: args.source.to_string(),
316                current: entity.content_hash.clone(),
317                is_stub: entity.stub,
318            });
319        }
320
321        // Self-loops on
322        // any propagating-from-source rel-type are always a weight-
323        // bomb, regardless of whether the rel-type carries the
324        // `acyclic` flag. Gate on the source-type's
325        // `propagating_relationships` list — independent of the
326        // long-cycle `acyclic` check below so a non-acyclic
327        // propagating rel-type (e.g. USES from spec) still refuses
328        // `from == to`, matching the semantic agents can predict
329        // from `memstead_schema`'s exposed `propagating_relationships`.
330        if !args.remove
331            && args.source == args.target
332            && schema.type_propagates(entity.entity_type.as_str(), &args.rel_type)
333        {
334            return Err(EngineError::RelationshipCycle {
335                rel_type: args.rel_type.clone(),
336                from: args.source.clone(),
337                to: args.target.clone(),
338                existing_path: vec![args.source.clone()],
339                path_truncated: false,
340            });
341        }
342
343        // Cycle check on the real-add path: if the rel_type is
344        // declared acyclic in the mem's schema, an add closing a
345        // back-path through `to → … → from` is rejected with the
346        // existing path (capped). Skipped on the remove path and
347        // when the rel_type isn't declared acyclic. The acyclic-add
348        // guard runs here via `graph::query::would_cycle`.
349        if !args.remove
350            && schema.relationship_acyclic(&args.rel_type)
351            && let Some(path) = crate::graph::query::would_cycle(
352                &self.store,
353                &args.source,
354                &args.target,
355                &args.rel_type,
356            )
357        {
358            let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
359            let mut existing_path = path;
360            if truncated {
361                existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
362            }
363            return Err(EngineError::RelationshipCycle {
364                rel_type: args.rel_type.clone(),
365                from: args.source.clone(),
366                to: args.target.clone(),
367                existing_path,
368                path_truncated: truncated,
369            });
370        }
371
372        let type_def = schema
373            .get_type(&entity.entity_type)
374            .ok_or_else(|| unknown_type_error(schema, &entity.entity_type))?;
375
376        let mut next = entity.clone();
377        let already = next
378            .relationships
379            .iter()
380            .position(|r| r.rel_type == args.rel_type && r.target == args.target);
381
382        // Alias-existence RESTRICT semantics on the remove path. Under
383        // set-membership semantics a body wiki-link `[[X]]` aliases the
384        // *set* of relations to X; removing one relation is fine as
385        // long as another survives. Refuse only when the removal would
386        // empty the relation-set to `b` while body wiki-links to `b`
387        // are still present in the source entity's section bodies.
388        if args.remove && already.is_some() {
389            let other_relation_to_target_exists = entity
390                .relationships
391                .iter()
392                .any(|r| r.target == args.target && r.rel_type != args.rel_type);
393            if !other_relation_to_target_exists {
394                // Read-side scan over the source entity's existing
395                // body. Use the lenient decoder so on-disk drift on
396                // pre-strict entities continues to surface in the
397                // body-link survival check — the mutation gate sits
398                // on the create/update path, not on a relate-remove
399                // scan of historical state.
400                let mut surviving_sections: Vec<String> = Vec::new();
401                for (section_key, body) in entity.sections.iter() {
402                    let inline_targets =
403                        crate::entity::parser::extract_inline_links_lenient(body, &source_mem);
404                    if inline_targets.iter().any(|t| t == &args.target) {
405                        surviving_sections.push(section_key.clone());
406                    }
407                }
408                if !surviving_sections.is_empty() {
409                    return Err(EngineError::RelationHasBodyLinks {
410                        from_id: args.source.to_string(),
411                        to_id: args.target.to_string(),
412                        rel_type: args.rel_type.clone(),
413                        body_links: surviving_sections,
414                    });
415                }
416            }
417        }
418
419        let action = if args.remove {
420            match already {
421                Some(idx) => {
422                    next.relationships.remove(idx);
423                    RelateAction::Removed
424                }
425                None => RelateAction::NoOpAbsent,
426            }
427        } else {
428            match already {
429                Some(_) => RelateAction::NoOpAlreadyPresent,
430                None => {
431                    next.relationships.push(Relationship {
432                        rel_type: args.rel_type.clone(),
433                        target: args.target.clone(),
434                        description: normalise_description(args.description.as_deref()),
435                    });
436                    RelateAction::Added
437                }
438            }
439        };
440
441        // Materialise a stub for an absent target on the real-add path.
442        // Skipped on no-op paths (NoOpAlreadyPresent / NoOpAbsent — the
443        // edge isn't actually being added) and on the remove path (the
444        // edge being dropped, no need to manifest the target). This is
445        // the engine's target-materialisation step on the add path.
446        // The auto-stub surfaces as a typed `AutoStubCreated` warning
447        // on the response's `warnings[]` — the deprecated top-level
448        // `stub_warning` field that pre-Item-03 carried this fact has
449        // been removed, so every diagnostic now follows the uniform
450        // `{ code, message, details }` warning shape.
451        if matches!(action, RelateAction::Added) && !self.store.contains(&args.target) {
452            self.store.upsert(
453                args.target.clone(),
454                make_stub(&args.target, crate::entity::StubKind::ForwardReference),
455            );
456            warnings.push(WarningHint::AutoStubCreated {
457                stub_id: args.target.clone(),
458            });
459            // If the target mem is unmounted, the
460            // auto-stub above has no `_mem_schema` resolution. Layer
461            // the typed mem-uncreated warning alongside the
462            // `AutoStubCreated` so the operator sees both signals.
463            if target_mem_uncreated {
464                warnings.push(WarningHint::CrossMemTargetMemUncreated {
465                    from_mem: source_mem.clone(),
466                    to_mem: target_mem.clone(),
467                    target_id: args.target.clone(),
468                });
469            }
470        }
471
472        // No-op paths skip the disk write so the provenance log doesn't
473        // record a non-event. Return the live `content_hash` so callers
474        // can chain follow-ups without refetching. Surface the no-op as
475        // a typed warning so an agent re-running a pipeline can tell the
476        // call didn't change the graph (mirrors full's wire shape).
477        if matches!(
478            action,
479            RelateAction::NoOpAlreadyPresent | RelateAction::NoOpAbsent
480        ) {
481            match action {
482                RelateAction::NoOpAlreadyPresent => {
483                    warnings.push(WarningHint::DuplicateRelationship {
484                        rel_type: args.rel_type.clone(),
485                        from: args.source.clone(),
486                        to: args.target.clone(),
487                    });
488                }
489                RelateAction::NoOpAbsent => {
490                    warnings.push(WarningHint::NoSuchRelationship {
491                        rel_type: args.rel_type.clone(),
492                        from: args.source.clone(),
493                        to: args.target.clone(),
494                    });
495                }
496                _ => unreachable!(),
497            }
498            return Ok(RelateEntityOutcome {
499                from: args.source,
500                to: args.target,
501                rel_type: args.rel_type,
502                action,
503                content_hash: entity.content_hash.clone(),
504                commit_sha: String::new(),
505                source: "explicit".to_string(),
506                warnings,
507                // No-op branch: nothing changed in the graph, so the
508                // orphan-stub sweep can't have anything to collect.
509                orphan_stubs_removed: Vec::new(),
510            });
511        }
512
513        // The relate path rewrites the on-disk file (the
514        // `## Relationships` section materialises from
515        // `next.relationships`), so the schema's `auto_timestamp`
516        // metadata (default schema: `last_modified`) bumps to the
517        // current ISO. Only fires on the commit-producing branch —
518        // the no-op early-return above skips this block, so an
519        // idempotent re-add or NoOpAbsent never advances the stamp.
520        let today = super::today_iso();
521        super::auto_stamp_timestamps(&mut next, type_def.as_ref(), &today);
522
523        let file_path = next.file_path.clone();
524        let markdown = generate_markdown(&next, type_def.as_ref());
525
526        let backend = self.mounts[mount_idx].backend.as_ref();
527        backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
528        let commit_subject = format!("memstead: relate {}", args.source);
529        let ctx = CommitContext {
530            actor,
531            client: client.cloned(),
532            tool: Some("relate_entity"),
533            note: note.map(String::from),
534            logical_operation_id: None,
535            entity_ids: None,
536        };
537        let commit_sha = backend.commit(&commit_subject, &ctx)?;
538
539        backend.append_provenance(&Provenance::new(
540            std::time::SystemTime::now(),
541            ProvenanceKind::Relate,
542            Some(args.source.to_string()),
543            actor,
544            client.cloned(),
545            note.map(String::from),
546        ))?;
547
548        self.record_self_write(mount_idx, &commit_sha);
549
550        let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &source_mem)
551            .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
552        let content_hash = parse_result.entity.content_hash.clone();
553
554        let fallback = engine_fallback_type();
555        push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
556        crate::entity::store_builder::remap_alias_target_edge_sources(
557            &mut self.store,
558            &self.schemas,
559        );
560
561        // On the `--remove` path, the edge we just dropped may
562        // have been the last incoming edge to a stub. The orphan-stub
563        // GC hook fired from `memstead_delete` already; mirror it here so
564        // every mutation that can leave orphans cleans them up.
565        // Scoped sweep — only inspect the just-severed target. The
566        // only possible new orphan from a relate-remove is the
567        // target whose incoming edge we removed; checking the entire
568        // store would catch pre-existing orphans which aren't this
569        // mutation's responsibility (and which `memstead_delete`'s full
570        // sweep also leaves alone before its own removal). Funnels
571        // through the shared `gc_orphan_stubs_among` predicate so the
572        // relate-remove, delete, and update-via-alias-resync paths
573        // can't drift on what counts as a GC-able orphan.
574        let orphan_stubs_removed: Vec<EntityId> = if matches!(action, RelateAction::Removed) {
575            super::gc_orphan_stubs_among(&mut self.store, std::iter::once(&args.target))
576        } else {
577            Vec::new()
578        };
579
580        self.invalidate_communities();
581        self.invalidate_search_indexes();
582
583        // `require_notes` provenance nudge — single engine-level
584        // enforcement point. Only reached on the real-commit path
585        // (Added / Removed); the NoOpAlreadyPresent / NoOpAbsent branches
586        // return early above with an empty `commit_sha` and never demand
587        // a note (nothing landed to attribute).
588        if let Some(w) = self.note_missing_warning("relate_entity", note) {
589            warnings.push(w);
590        }
591
592        Ok(RelateEntityOutcome {
593            from: args.source,
594            to: args.target,
595            rel_type: args.rel_type,
596            action,
597            content_hash,
598            commit_sha,
599            source: "explicit".to_string(),
600            warnings,
601            orphan_stubs_removed,
602        })
603    }
604
605    /// Positional-args alias for [`Self::relate_entity`]. Bundles
606    /// the positional inputs into a [`RelateEntityArgs`] (with
607    /// `expected_hash: None`) and delegates to
608    /// [`Self::relate_entity`]. The `CommitContext` is destructured
609    /// into the 4-tuple (actor, client, note) the unified mutation
610    /// surface accepts.
611    pub fn relate(
612        &mut self,
613        from: &EntityId,
614        to: &EntityId,
615        rel_type: &str,
616        remove: bool,
617        ctx: &CommitContext<'_>,
618    ) -> Result<RelateEntityOutcome, EngineError> {
619        let args = RelateEntityArgs {
620            source: from.clone(),
621            expected_hash: None,
622            rel_type: rel_type.to_string(),
623            target: to.clone(),
624            remove,
625            description: None,
626        };
627        self.relate_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
628    }
629}
630
631#[cfg(test)]
632mod tests {
633
634    use indexmap::IndexMap;
635    use tempfile::TempDir;
636
637    use crate::backend::MemBackend;
638    use crate::engine::test_helpers::*;
639    use crate::engine::{CreateEntityArgs, Engine, EngineError, RelateAction, RelateEntityArgs};
640    use crate::ops::WarningHint;
641    use crate::storage::FilesystemMemWriter;
642    use crate::vcs::{Actor, CommitContext};
643
644    #[test]
645    fn relate_alias_delegates_to_relate_entity() {
646        // Positional-args alias mirrors full's signature
647        // `engine.relate(from, to, rel_type, remove, ctx)`. Add an
648        // edge via the alias and via `relate_entity` and assert
649        // they reach the same observable post-state.
650        let tmp = TempDir::new().unwrap();
651        let mem_dir = tmp.path().to_path_buf();
652        let writer = FilesystemMemWriter::new(mem_dir.clone());
653        let mut engine = Engine::from_mounts(vec![(
654            folder_mount("specs", mem_dir),
655            Box::new(writer) as Box<dyn MemBackend>,
656        )])
657        .unwrap();
658
659        // Seed two real entities (no stub).
660        let a = engine
661            .create_entity(
662                CreateEntityArgs {
663                    mem: "specs".to_string(),
664                    title: "A".to_string(),
665                    entity_type: "spec".to_string(),
666                    sections: IndexMap::from_iter([
667                        ("identity".to_string(), "seed identity".to_string()),
668                        ("purpose".to_string(), "seed purpose".to_string()),
669                    ]),
670                    metadata: IndexMap::new(),
671                    relations: Vec::new(),
672                    dry_run: false,
673                },
674                Actor::Cli,
675                None,
676                None,
677            )
678            .unwrap();
679        let b = engine
680            .create_entity(
681                CreateEntityArgs {
682                    mem: "specs".to_string(),
683                    title: "B".to_string(),
684                    entity_type: "spec".to_string(),
685                    sections: IndexMap::from_iter([
686                        ("identity".to_string(), "seed identity".to_string()),
687                        ("purpose".to_string(), "seed purpose".to_string()),
688                    ]),
689                    metadata: IndexMap::new(),
690                    relations: Vec::new(),
691                    dry_run: false,
692                },
693                Actor::Cli,
694                None,
695                None,
696            )
697            .unwrap();
698
699        // Use the positional `relate` alias.
700        let ctx = CommitContext::internal();
701        let result = engine.relate(&a.id, &b.id, "PART_OF", false, &ctx).unwrap();
702        assert_eq!(result.from, a.id);
703        assert_eq!(result.to, b.id);
704        assert_eq!(result.rel_type, "PART_OF");
705        // The edge is in the store post-call.
706        let outgoing: Vec<_> = engine.store().outgoing(&a.id).to_vec();
707        assert!(
708            outgoing
709                .iter()
710                .any(|e| e.target == b.id && e.rel_type == "PART_OF")
711        );
712    }
713
714    #[test]
715    fn relate_entity_appends_relationship_and_logs_provenance() {
716        let tmp = TempDir::new().unwrap();
717        let (mut engine, source) = engine_with_seed(&tmp, "Source");
718        let (actor, client) = cli_actor();
719        let target = engine
720            .create_entity(
721                empty_create_args("specs", "Target"),
722                actor,
723                Some(&client),
724                None,
725            )
726            .unwrap();
727
728        let outcome = engine
729            .relate_entity(
730                RelateEntityArgs {
731                    source: source.id.clone(),
732                    expected_hash: Some(source.content_hash.clone()),
733                    rel_type: "USES".to_string(),
734                    target: target.id.clone(),
735                    remove: false,
736                    description: None,
737                },
738                actor,
739                Some(&client),
740                None,
741            )
742            .unwrap();
743        assert_eq!(outcome.action, RelateAction::Added);
744        assert_ne!(outcome.content_hash, source.content_hash);
745        // Edge present in store.
746        let edges = engine.store().outgoing(&source.id);
747        assert!(
748            edges
749                .iter()
750                .any(|e| e.rel_type == "USES" && e.target == target.id),
751            "expected USES edge in store"
752        );
753        // Provenance log records relate.
754        let log = std::fs::read_to_string(tmp.path().join(".memstead/changes.jsonl")).unwrap();
755        assert!(log.contains("\"kind\":\"relate\""));
756    }
757
758    #[test]
759    fn relate_entity_no_op_when_already_present() {
760        let tmp = TempDir::new().unwrap();
761        let (mut engine, source) = engine_with_seed(&tmp, "Already");
762        let (actor, client) = cli_actor();
763        let target = engine
764            .create_entity(empty_create_args("specs", "T2"), actor, Some(&client), None)
765            .unwrap();
766        let first = engine
767            .relate_entity(
768                RelateEntityArgs {
769                    source: source.id.clone(),
770                    expected_hash: Some(source.content_hash.clone()),
771                    rel_type: "USES".to_string(),
772                    target: target.id.clone(),
773                    remove: false,
774                    description: None,
775                },
776                actor,
777                Some(&client),
778                None,
779            )
780            .unwrap();
781        let second = engine
782            .relate_entity(
783                RelateEntityArgs {
784                    source: source.id.clone(),
785                    expected_hash: Some(first.content_hash.clone()),
786                    rel_type: "USES".to_string(),
787                    target: target.id.clone(),
788                    remove: false,
789                    description: None,
790                },
791                actor,
792                Some(&client),
793                None,
794            )
795            .unwrap();
796        assert_eq!(second.action, RelateAction::NoOpAlreadyPresent);
797        // Hash unchanged on no-op.
798        assert_eq!(second.content_hash, first.content_hash);
799    }
800
801    #[test]
802    fn relate_entity_returns_commit_sha_on_real_write() {
803        let tmp = TempDir::new().unwrap();
804        let (mut engine, source) = engine_with_seed(&tmp, "Source");
805        let (actor, client) = cli_actor();
806        let target = engine
807            .create_entity(
808                empty_create_args("specs", "Target"),
809                actor,
810                Some(&client),
811                None,
812            )
813            .unwrap();
814
815        let outcome = engine
816            .relate_entity(
817                RelateEntityArgs {
818                    source: source.id.clone(),
819                    expected_hash: Some(source.content_hash.clone()),
820                    rel_type: "USES".to_string(),
821                    target: target.id.clone(),
822                    remove: false,
823                    description: None,
824                },
825                actor,
826                Some(&client),
827                None,
828            )
829            .unwrap();
830        // Folder backend returns a synthetic CommitId — non-empty string.
831        // Wire-equivalent to full's commit SHA: agents reading the field
832        // get a usable cursor regardless of which backend served the write.
833        assert!(
834            !outcome.commit_sha.is_empty(),
835            "commit_sha must be populated on a real write"
836        );
837    }
838
839    #[test]
840    fn relate_entity_no_op_paths_carry_typed_warnings_and_empty_commit_sha() {
841        let tmp = TempDir::new().unwrap();
842        let (mut engine, source) = engine_with_seed(&tmp, "S");
843        let (actor, client) = cli_actor();
844        let target = engine
845            .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
846            .unwrap();
847
848        // Add the edge once.
849        let first = engine
850            .relate_entity(
851                RelateEntityArgs {
852                    source: source.id.clone(),
853                    expected_hash: Some(source.content_hash.clone()),
854                    rel_type: "USES".to_string(),
855                    target: target.id.clone(),
856                    remove: false,
857                    description: None,
858                },
859                actor,
860                Some(&client),
861                None,
862            )
863            .unwrap();
864
865        // Duplicate-add — typed DuplicateRelationship warning, empty
866        // commit_sha (no disk write happened).
867        let dup = engine
868            .relate_entity(
869                RelateEntityArgs {
870                    source: source.id.clone(),
871                    expected_hash: Some(first.content_hash.clone()),
872                    rel_type: "USES".to_string(),
873                    target: target.id.clone(),
874                    remove: false,
875                    description: None,
876                },
877                actor,
878                Some(&client),
879                None,
880            )
881            .unwrap();
882        assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
883        assert!(dup.commit_sha.is_empty());
884        assert_eq!(dup.warnings.len(), 1);
885        assert!(matches!(
886            dup.warnings[0],
887            WarningHint::DuplicateRelationship { .. }
888        ));
889
890        // Remove a non-existent edge — typed NoSuchRelationship warning,
891        // empty commit_sha.
892        let no_such = engine
893            .relate_entity(
894                RelateEntityArgs {
895                    source: source.id.clone(),
896                    expected_hash: Some(first.content_hash.clone()),
897                    rel_type: "DEPENDS_ON".to_string(),
898                    target: target.id.clone(),
899                    remove: true,
900                    description: None,
901                },
902                actor,
903                Some(&client),
904                None,
905            )
906            .unwrap();
907        assert_eq!(no_such.action, RelateAction::NoOpAbsent);
908        assert!(no_such.commit_sha.is_empty());
909        assert_eq!(no_such.warnings.len(), 1);
910        assert!(matches!(
911            no_such.warnings[0],
912            WarningHint::NoSuchRelationship { .. }
913        ));
914    }
915
916    #[test]
917    fn relate_entity_creates_stub_for_absent_target_on_add_path() {
918        let tmp = TempDir::new().unwrap();
919        let (mut engine, source) = engine_with_seed(&tmp, "Source");
920        let (actor, client) = cli_actor();
921        let absent_target = crate::EntityId::new("specs", "ghost-target");
922        // Sanity: target not in store.
923        assert!(!engine.store().contains(&absent_target));
924
925        let outcome = engine
926            .relate_entity(
927                RelateEntityArgs {
928                    source: source.id.clone(),
929                    expected_hash: Some(source.content_hash.clone()),
930                    rel_type: "USES".to_string(),
931                    target: absent_target.clone(),
932                    remove: false,
933                    description: None,
934                },
935                actor,
936                Some(&client),
937                None,
938            )
939            .unwrap();
940
941        assert_eq!(outcome.action, RelateAction::Added);
942        assert_eq!(outcome.source, "explicit");
943        // Auto-stub now surfaces through the typed warning vocabulary
944        // (`AutoStubCreated`) on `warnings[]` — the deprecated
945        // top-level `stub_warning` field was retired in favour of the
946        // uniform diagnostic shape. Agents iterating `warnings[]` see
947        // the stub id without special-casing a sibling field.
948        let stub_warning = outcome
949            .warnings
950            .iter()
951            .find_map(|w| match w {
952                crate::ops::WarningHint::AutoStubCreated { stub_id } => Some(stub_id.clone()),
953                _ => None,
954            })
955            .expect("AutoStubCreated warning must surface when target was absent");
956        assert_eq!(stub_warning, absent_target);
957
958        // Stub now in-store, marked as stub, no body.
959        let stub = engine.store().get(&absent_target).expect("stub upserted");
960        assert!(stub.stub);
961        assert!(stub.entity_type.is_empty());
962        assert!(stub.file_path.is_empty());
963    }
964
965    #[test]
966    fn relate_entity_skips_stub_creation_when_target_already_exists() {
967        let tmp = TempDir::new().unwrap();
968        let (mut engine, source) = engine_with_seed(&tmp, "Src");
969        let (actor, client) = cli_actor();
970        let target = engine
971            .create_entity(
972                empty_create_args("specs", "Real"),
973                actor,
974                Some(&client),
975                None,
976            )
977            .unwrap();
978
979        let outcome = engine
980            .relate_entity(
981                RelateEntityArgs {
982                    source: source.id.clone(),
983                    expected_hash: Some(source.content_hash.clone()),
984                    rel_type: "USES".to_string(),
985                    target: target.id.clone(),
986                    remove: false,
987                    description: None,
988                },
989                actor,
990                Some(&client),
991                None,
992            )
993            .unwrap();
994
995        assert!(
996            !outcome
997                .warnings
998                .iter()
999                .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1000            "AutoStubCreated must not surface when target was already in store"
1001        );
1002        assert_eq!(outcome.source, "explicit");
1003        // Real entity remains a real entity (not coerced to stub).
1004        let target_after = engine.store().get(&target.id).unwrap();
1005        assert!(!target_after.stub);
1006    }
1007
1008    #[test]
1009    fn relate_entity_does_not_create_stub_on_remove_path() {
1010        let tmp = TempDir::new().unwrap();
1011        let (mut engine, source) = engine_with_seed(&tmp, "Src");
1012        let (actor, client) = cli_actor();
1013        let absent_target = crate::EntityId::new("specs", "never-existed");
1014
1015        let outcome = engine
1016            .relate_entity(
1017                RelateEntityArgs {
1018                    source: source.id.clone(),
1019                    expected_hash: Some(source.content_hash.clone()),
1020                    rel_type: "USES".to_string(),
1021                    target: absent_target.clone(),
1022                    remove: true,
1023                    description: None,
1024                },
1025                actor,
1026                Some(&client),
1027                None,
1028            )
1029            .unwrap();
1030
1031        // Remove of an absent edge — no stub creation, NoOpAbsent action,
1032        // typed NoSuchRelationship warning.
1033        assert_eq!(outcome.action, RelateAction::NoOpAbsent);
1034        assert!(
1035            !outcome
1036                .warnings
1037                .iter()
1038                .any(|w| matches!(w, crate::ops::WarningHint::AutoStubCreated { .. })),
1039            "remove path must never auto-stub the target",
1040        );
1041        assert!(!engine.store().contains(&absent_target));
1042    }
1043
1044    #[test]
1045    fn relate_entity_remove_refuses_when_source_body_still_references_target() {
1046        use crate::engine::CreateEntityArgs;
1047        use indexmap::IndexMap;
1048
1049        let tmp = TempDir::new().unwrap();
1050        let mem_dir = tmp.path().to_path_buf();
1051        let writer = FilesystemMemWriter::new(mem_dir.clone());
1052        let mut engine = Engine::from_mounts(vec![(
1053            folder_mount("specs", mem_dir.clone()),
1054            Box::new(writer) as Box<dyn MemBackend>,
1055        )])
1056        .unwrap();
1057        let (actor, client) = cli_actor();
1058
1059        let target = engine
1060            .create_entity(
1061                empty_create_args("specs", "Target"),
1062                actor,
1063                Some(&client),
1064                None,
1065            )
1066            .unwrap();
1067
1068        // Source entity carries a body wiki-link to the target — the
1069        // alias-synthesis pass emits the backing REFERENCES relation
1070        // (default schema's `alias_target_rel_type` points at
1071        // REFERENCES, so explicit `memstead_relate type=REFERENCES` is
1072        // refused; the body link alone produces the relation).
1073        let mut sections: IndexMap<String, String> = IndexMap::new();
1074        sections.insert("identity".to_string(), "source identity".to_string());
1075        sections.insert(
1076            "purpose".to_string(),
1077            "discussion stems from [[target]]".to_string(),
1078        );
1079        let source = engine
1080            .create_entity(
1081                CreateEntityArgs {
1082                    mem: "specs".to_string(),
1083                    title: "Source".to_string(),
1084                    entity_type: "spec".to_string(),
1085                    sections,
1086                    metadata: IndexMap::new(),
1087                    relations: Vec::new(),
1088                    dry_run: false,
1089                },
1090                actor,
1091                Some(&client),
1092                None,
1093            )
1094            .unwrap();
1095        let related = source.clone();
1096
1097        // Removing the explicit relation while the body still has
1098        // [[target]] must refuse with `RelationHasBodyLinks`, naming
1099        // the surviving section in `body_links`.
1100        let err = engine
1101            .relate_entity(
1102                RelateEntityArgs {
1103                    source: source.id.clone(),
1104                    expected_hash: Some(related.content_hash.clone()),
1105                    rel_type: "REFERENCES".to_string(),
1106                    target: target.id.clone(),
1107                    remove: true,
1108                    description: None,
1109                },
1110                actor,
1111                Some(&client),
1112                None,
1113            )
1114            .unwrap_err();
1115        match err {
1116            EngineError::RelationHasBodyLinks {
1117                from_id,
1118                to_id,
1119                rel_type,
1120                body_links,
1121            } => {
1122                assert_eq!(from_id, source.id.to_string());
1123                assert_eq!(to_id, target.id.to_string());
1124                assert_eq!(rel_type, "REFERENCES");
1125                assert_eq!(body_links, vec!["purpose".to_string()]);
1126            }
1127            other => panic!("expected RelationHasBodyLinks, got {other:?}"),
1128        }
1129        // Relation must still be present in-memory (refuse before any
1130        // store mutation).
1131        let in_mem = engine.get_entity(&source.id).unwrap();
1132        assert!(
1133            in_mem
1134                .relationships
1135                .iter()
1136                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
1137            "relation must survive the refused remove; got {:?}",
1138            in_mem.relationships
1139        );
1140    }
1141
1142    #[test]
1143    fn relate_entity_remove_succeeds_when_body_no_longer_references_target() {
1144        let tmp = TempDir::new().unwrap();
1145        let (mut engine, source) = engine_with_seed(&tmp, "Src");
1146        let (actor, client) = cli_actor();
1147        let target = engine
1148            .create_entity(
1149                empty_create_args("specs", "Other"),
1150                actor,
1151                Some(&client),
1152                None,
1153            )
1154            .unwrap();
1155        // Default seed has empty body sections, so the relation can be
1156        // added and removed without body-link interference. This locks
1157        // the happy path: when no body link survives, remove proceeds.
1158        // (USES instead of REFERENCES — REFERENCES is engine-emitted-only
1159        // under the default schema's alias_target_rel_type pointer.)
1160        let related = engine
1161            .relate_entity(
1162                RelateEntityArgs {
1163                    source: source.id.clone(),
1164                    expected_hash: Some(source.content_hash.clone()),
1165                    rel_type: "USES".to_string(),
1166                    target: target.id.clone(),
1167                    remove: false,
1168                    description: None,
1169                },
1170                actor,
1171                Some(&client),
1172                None,
1173            )
1174            .unwrap();
1175        let removed = engine
1176            .relate_entity(
1177                RelateEntityArgs {
1178                    source: source.id.clone(),
1179                    expected_hash: Some(related.content_hash.clone()),
1180                    rel_type: "USES".to_string(),
1181                    target: target.id.clone(),
1182                    remove: true,
1183                    description: None,
1184                },
1185                actor,
1186                Some(&client),
1187                None,
1188            )
1189            .unwrap();
1190        assert_eq!(removed.action, RelateAction::Removed);
1191    }
1192
1193    #[test]
1194    fn relate_entity_auto_stub_is_tagged_forward_reference() {
1195        // `memstead_relate` to an absent target auto-stubs it. The stub's
1196        // `stub_kind` records the origin (`ForwardReference`) so an
1197        // agent reading the stub later via `memstead_entity` sees the
1198        // typed provenance — not just `stub: true`.
1199        use crate::entity::StubKind;
1200
1201        let tmp = TempDir::new().unwrap();
1202        let (mut engine, source) = engine_with_seed(&tmp, "Src");
1203        let (actor, client) = cli_actor();
1204        let absent_target = crate::EntityId::new("specs", "absent-target");
1205
1206        let _ = engine
1207            .relate_entity(
1208                RelateEntityArgs {
1209                    source: source.id.clone(),
1210                    expected_hash: Some(source.content_hash.clone()),
1211                    rel_type: "USES".to_string(),
1212                    target: absent_target.clone(),
1213                    remove: false,
1214                    description: None,
1215                },
1216                actor,
1217                Some(&client),
1218                None,
1219            )
1220            .unwrap();
1221
1222        let stub = engine
1223            .get_entity(&absent_target)
1224            .expect("relate auto-stubbed target must be in the store");
1225        assert!(stub.stub, "auto-stubbed target must carry stub: true");
1226        assert_eq!(
1227            stub.stub_kind,
1228            Some(StubKind::ForwardReference),
1229            "auto-stub from relate must be tagged ForwardReference; got {:?}",
1230            stub.stub_kind
1231        );
1232    }
1233
1234    #[test]
1235    fn relate_entity_case_insensitive_rel_type_input_canonicalises_to_upper_snake_case() {
1236        // Wire-level contract: rel_type input is case-insensitive; the
1237        // engine stores it as UPPER_SNAKE_CASE and echoes the canonical
1238        // form back in the response. Same store-shape regardless of
1239        // input case.
1240        let tmp = TempDir::new().unwrap();
1241        let (mut engine, source) = engine_with_seed(&tmp, "Source");
1242        let (actor, client) = cli_actor();
1243        let target = engine
1244            .create_entity(
1245                empty_create_args("specs", "Target"),
1246                actor,
1247                Some(&client),
1248                None,
1249            )
1250            .unwrap();
1251
1252        // Lowercase input — must succeed and store as `USES`.
1253        let lower = engine
1254            .relate_entity(
1255                RelateEntityArgs {
1256                    source: source.id.clone(),
1257                    expected_hash: Some(source.content_hash.clone()),
1258                    rel_type: "uses".to_string(),
1259                    target: target.id.clone(),
1260                    remove: false,
1261                    description: None,
1262                },
1263                actor,
1264                Some(&client),
1265                None,
1266            )
1267            .unwrap();
1268        assert_eq!(lower.rel_type, "USES", "response must echo canonical form");
1269        assert_eq!(lower.action, RelateAction::Added);
1270        let edges = engine.store().outgoing(&source.id);
1271        assert!(
1272            edges
1273                .iter()
1274                .any(|e| e.rel_type == "USES" && e.target == target.id),
1275            "store must hold UPPER_SNAKE_CASE rel_type after lowercase input"
1276        );
1277
1278        // Adding via mixed-case input on the same edge is the canonical
1279        // duplicate — DuplicateRelationship warning, no second store
1280        // entry.
1281        let dup = engine
1282            .relate_entity(
1283                RelateEntityArgs {
1284                    source: source.id.clone(),
1285                    expected_hash: Some(lower.content_hash.clone()),
1286                    rel_type: "Uses".to_string(),
1287                    target: target.id.clone(),
1288                    remove: false,
1289                    description: None,
1290                },
1291                actor,
1292                Some(&client),
1293                None,
1294            )
1295            .unwrap();
1296        assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1297        assert_eq!(dup.rel_type, "USES");
1298        assert!(matches!(
1299            dup.warnings[0],
1300            WarningHint::DuplicateRelationship { .. }
1301        ));
1302    }
1303
1304    #[test]
1305    fn relate_entity_rejects_cross_mem_when_policy_denies() {
1306        // Default workspace settings carry no `cross_mem_links`
1307        // policy and no `default_cross_links` on the create rules, so
1308        // `cross_mem_link_allowed` returns false for any cross-mem
1309        // pair. The relate refuse now surfaces the typed
1310        // policy-denial code instead of the legacy categorical
1311        // `CrossMemRelate`.
1312        let tmp = TempDir::new().unwrap();
1313        let (mut engine, source) = engine_with_seed(&tmp, "S");
1314        let (actor, client) = cli_actor();
1315        let err = engine
1316            .relate_entity(
1317                RelateEntityArgs {
1318                    source: source.id.clone(),
1319                    expected_hash: Some(source.content_hash.clone()),
1320                    rel_type: "USES".to_string(),
1321                    target: crate::EntityId::new("other-mem", "thing"),
1322                    remove: false,
1323                    description: None,
1324                },
1325                actor,
1326                Some(&client),
1327                None,
1328            )
1329            .unwrap_err();
1330        match err {
1331            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
1332                assert_eq!(from_mem, "specs");
1333                assert_eq!(to_mem, "other-mem");
1334            }
1335            other => panic!("expected CrossMemLinkNotAllowed, got {other:?}"),
1336        }
1337    }
1338
1339    /// Bare-string target without a `mem--` separator is malformed
1340    /// (the wiki-link grammar requires `<mem>--<path>`). Pre-fix
1341    /// the cross-mem check fired first: the parser saw `mem: ""`,
1342    /// compared against the source mem, and produced
1343    /// `CROSS_MEM_RELATION` — pointing the agent at workspace
1344    /// `[cross_mem_links]` policy when the actual issue was a
1345    /// malformed id. Post-fix the grammar gate runs first; the
1346    /// envelope identifies the real problem.
1347    #[test]
1348    fn relate_entity_malformed_bare_target_surfaces_invalid_entity_id_not_cross_mem() {
1349        let tmp = TempDir::new().unwrap();
1350        let (mut engine, source) = engine_with_seed(&tmp, "S");
1351        let (actor, client) = cli_actor();
1352        let err = engine
1353            .relate_entity(
1354                RelateEntityArgs {
1355                    source: source.id.clone(),
1356                    expected_hash: Some(source.content_hash.clone()),
1357                    rel_type: "USES".to_string(),
1358                    // No `--` separator AND contains characters the
1359                    // grammar rejects. Parses as mem="", path=raw.
1360                    target: crate::EntityId("bad target with spaces!!".to_string()),
1361                    remove: false,
1362                    description: None,
1363                },
1364                actor,
1365                Some(&client),
1366                None,
1367            )
1368            .unwrap_err();
1369        assert!(
1370            matches!(err, EngineError::InvalidEntityId { .. }),
1371            "malformed bare-string target must surface INVALID_ENTITY_ID, got: {err:?}"
1372        );
1373    }
1374
1375    /// Companion case: target carries the source's mem prefix but a
1376    /// grammar-violating path. The grammar check fires (same path as
1377    /// the bare-string case); cross-mem stays out of the picture
1378    /// because mems match.
1379    #[test]
1380    fn relate_entity_malformed_prefixed_target_surfaces_invalid_entity_id() {
1381        let tmp = TempDir::new().unwrap();
1382        let (mut engine, source) = engine_with_seed(&tmp, "S");
1383        let (actor, client) = cli_actor();
1384        let source_mem = source.id.mem().to_string();
1385        let err = engine
1386            .relate_entity(
1387                RelateEntityArgs {
1388                    source: source.id.clone(),
1389                    expected_hash: Some(source.content_hash.clone()),
1390                    rel_type: "USES".to_string(),
1391                    target: crate::EntityId(format!("{source_mem}--bad target with spaces!!")),
1392                    remove: false,
1393                    description: None,
1394                },
1395                actor,
1396                Some(&client),
1397                None,
1398            )
1399            .unwrap_err();
1400        assert!(
1401            matches!(err, EngineError::InvalidEntityId { .. }),
1402            "prefixed malformed target must still surface INVALID_ENTITY_ID, got: {err:?}"
1403        );
1404    }
1405
1406    // ---- Auto-timestamp on relate add/remove ------------------------
1407
1408    /// `memstead_relate add` rewrites the
1409    /// source's on-disk file, so its `last_modified` auto-stamp must
1410    /// bump. The schema's default-stamped field is `last_modified`.
1411    #[test]
1412    fn relate_add_bumps_last_modified_on_source_entity() {
1413        let tmp = TempDir::new().unwrap();
1414        let (mut engine, source) = engine_with_seed(&tmp, "S");
1415        let (actor, client) = cli_actor();
1416        let target = engine
1417            .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1418            .unwrap();
1419
1420        let outcome = engine
1421            .relate_entity(
1422                RelateEntityArgs {
1423                    source: source.id.clone(),
1424                    expected_hash: Some(source.content_hash.clone()),
1425                    rel_type: "USES".to_string(),
1426                    target: target.id.clone(),
1427                    remove: false,
1428                    description: None,
1429                },
1430                actor,
1431                Some(&client),
1432                None,
1433            )
1434            .unwrap();
1435        assert_eq!(outcome.action, RelateAction::Added);
1436
1437        // last_modified now carries a fresh ISO timestamp on the
1438        // source entity. The auto-stamp helper sets every
1439        // `auto_timestamp: true` metadata field on each commit-
1440        // producing relate mutation.
1441        let post = engine.get_entity(&source.id).unwrap();
1442        let last_modified = post
1443            .metadata
1444            .get("last_modified")
1445            .map(|v| v.to_frontmatter_string())
1446            .unwrap_or_default();
1447        assert!(
1448            last_modified.starts_with("20"),
1449            "last_modified must carry an ISO timestamp post-relate; got: {last_modified:?}"
1450        );
1451    }
1452
1453    /// Relate-add no-op (idempotent
1454    /// re-add) skips the disk write and therefore does not advance
1455    /// `last_modified`. The auto-stamp fires only on commit-producing
1456    /// mutations — wired into the post-no-op-short-circuit branch.
1457    #[test]
1458    fn relate_add_noop_does_not_bump_last_modified() {
1459        let tmp = TempDir::new().unwrap();
1460        let (mut engine, source) = engine_with_seed(&tmp, "S");
1461        let (actor, client) = cli_actor();
1462        let target = engine
1463            .create_entity(empty_create_args("specs", "T"), actor, Some(&client), None)
1464            .unwrap();
1465        let first = engine
1466            .relate_entity(
1467                RelateEntityArgs {
1468                    source: source.id.clone(),
1469                    expected_hash: Some(source.content_hash.clone()),
1470                    rel_type: "USES".to_string(),
1471                    target: target.id.clone(),
1472                    remove: false,
1473                    description: None,
1474                },
1475                actor,
1476                Some(&client),
1477                None,
1478            )
1479            .unwrap();
1480        let pre = engine.get_entity(&source.id).unwrap();
1481        let pre_stamp = pre
1482            .metadata
1483            .get("last_modified")
1484            .map(|v| v.to_frontmatter_string())
1485            .unwrap_or_default();
1486
1487        // Second relate of same edge — NoOpAlreadyPresent.
1488        let dup = engine
1489            .relate_entity(
1490                RelateEntityArgs {
1491                    source: source.id.clone(),
1492                    expected_hash: Some(first.content_hash.clone()),
1493                    rel_type: "USES".to_string(),
1494                    target: target.id.clone(),
1495                    remove: false,
1496                    description: None,
1497                },
1498                actor,
1499                Some(&client),
1500                None,
1501            )
1502            .unwrap();
1503        assert_eq!(dup.action, RelateAction::NoOpAlreadyPresent);
1504
1505        let post = engine.get_entity(&source.id).unwrap();
1506        let post_stamp = post
1507            .metadata
1508            .get("last_modified")
1509            .map(|v| v.to_frontmatter_string())
1510            .unwrap_or_default();
1511        assert_eq!(
1512            pre_stamp, post_stamp,
1513            "last_modified must not advance on a duplicate-add no-op (no disk write happened)"
1514        );
1515    }
1516
1517    /// Cross-mem relate that policy admits
1518    /// but whose target mem is not mounted in the workspace emits
1519    /// `CROSS_MEM_TARGET_MEM_UNCREATED` alongside `AutoStubCreated`.
1520    /// The auto-stub still lands; the warning is layered observability.
1521    #[test]
1522    fn cross_mem_relate_to_uncreated_mem_emits_typed_warning() {
1523        use memstead_schema::workspace_config::CrossLinkValue;
1524        let tmp = TempDir::new().unwrap();
1525        let (mut engine, source) = engine_with_seed(&tmp, "S");
1526        let (actor, client) = cli_actor();
1527        // Grant `specs -> uncreated-mem` so the policy gate passes.
1528        // The target mem is intentionally not mounted; the auto-stub
1529        // should still land, with the typed warning attached.
1530        let mut settings = crate::workspace::WorkspaceSettings::default();
1531        settings.cross_mem_links.insert(
1532            "specs".to_string(),
1533            CrossLinkValue::List(vec!["uncreated-mem".to_string()]),
1534        );
1535        engine.set_settings(settings);
1536
1537        let absent = crate::EntityId::new("uncreated-mem", "ghost");
1538        let outcome = engine
1539            .relate_entity(
1540                RelateEntityArgs {
1541                    source: source.id.clone(),
1542                    expected_hash: Some(source.content_hash.clone()),
1543                    rel_type: "USES".to_string(),
1544                    target: absent.clone(),
1545                    remove: false,
1546                    description: None,
1547                },
1548                actor,
1549                Some(&client),
1550                None,
1551            )
1552            .unwrap();
1553        assert_eq!(outcome.action, RelateAction::Added);
1554
1555        // Auto-stub created plus uncreated-mem warning, side by side.
1556        let saw_uncreated = outcome.warnings.iter().any(|w| {
1557            matches!(
1558                w,
1559                WarningHint::CrossMemTargetMemUncreated {
1560                    from_mem,
1561                    to_mem,
1562                    target_id,
1563                } if from_mem == "specs"
1564                    && to_mem == "uncreated-mem"
1565                    && target_id == &absent
1566            )
1567        });
1568        assert!(
1569            saw_uncreated,
1570            "CrossMemTargetMemUncreated warning must surface; got: {:?}",
1571            outcome.warnings
1572        );
1573        // The auto-stub still landed.
1574        assert!(engine.store().contains(&absent));
1575    }
1576
1577    /// Policy refusal takes precedence
1578    /// over the uncreated-mem warning. When the cross-mem link
1579    /// isn't granted, the engine refuses with
1580    /// `CROSS_MEM_LINK_NOT_ALLOWED` and never reaches the warning
1581    /// emission point — there's no stub to warn about.
1582    #[test]
1583    fn cross_mem_relate_policy_refusal_preempts_uncreated_mem_warning() {
1584        let tmp = TempDir::new().unwrap();
1585        let (mut engine, source) = engine_with_seed(&tmp, "S");
1586        let (actor, client) = cli_actor();
1587        // No cross_mem_links entry → policy denies.
1588        let absent = crate::EntityId::new("uncreated-mem", "ghost");
1589        let err = engine
1590            .relate_entity(
1591                RelateEntityArgs {
1592                    source: source.id.clone(),
1593                    expected_hash: Some(source.content_hash.clone()),
1594                    rel_type: "USES".to_string(),
1595                    target: absent.clone(),
1596                    remove: false,
1597                    description: None,
1598                },
1599                actor,
1600                Some(&client),
1601                None,
1602            )
1603            .unwrap_err();
1604        assert!(matches!(err, EngineError::CrossMemLinkNotAllowed { .. }));
1605        // No stub created on the refusal path.
1606        assert!(!engine.store().contains(&absent));
1607    }
1608
1609    /// `memstead_relate --remove` that drops the
1610    /// last incoming edge to a stub GCs the now-orphan stub in the
1611    /// same call. The response carries the dropped ids in
1612    /// `orphan_stubs_removed`, mirroring the `memstead_delete` envelope's
1613    /// shape so consumers branch uniformly.
1614    #[test]
1615    fn relate_remove_garbage_collects_orphan_stub() {
1616        let tmp = TempDir::new().unwrap();
1617        let (mut engine, source) = engine_with_seed(&tmp, "Src");
1618        let (actor, client) = cli_actor();
1619        let stub_id = crate::EntityId::new("specs", "ghost-target");
1620
1621        // Auto-stub via relate-add.
1622        let added = engine
1623            .relate_entity(
1624                RelateEntityArgs {
1625                    source: source.id.clone(),
1626                    expected_hash: Some(source.content_hash.clone()),
1627                    rel_type: "USES".to_string(),
1628                    target: stub_id.clone(),
1629                    remove: false,
1630                    description: None,
1631                },
1632                actor,
1633                Some(&client),
1634                None,
1635            )
1636            .unwrap();
1637        assert!(engine.store().contains(&stub_id));
1638
1639        let removed = engine
1640            .relate_entity(
1641                RelateEntityArgs {
1642                    source: source.id.clone(),
1643                    expected_hash: Some(added.content_hash.clone()),
1644                    rel_type: "USES".to_string(),
1645                    target: stub_id.clone(),
1646                    remove: true,
1647                    description: None,
1648                },
1649                actor,
1650                Some(&client),
1651                None,
1652            )
1653            .unwrap();
1654        assert_eq!(removed.action, RelateAction::Removed);
1655        assert_eq!(
1656            removed.orphan_stubs_removed,
1657            vec![stub_id.clone()],
1658            "orphan stub must be GC'd in the same call"
1659        );
1660        assert!(
1661            !engine.store().contains(&stub_id),
1662            "stub must be gone from the store after GC"
1663        );
1664    }
1665
1666    /// When the stub has another
1667    /// surviving incoming edge, the relate-remove GCs nothing —
1668    /// the stub stays alive via the second referrer. The sweep is
1669    /// scoped to *just-orphaned* targets, not pre-existing orphans
1670    /// or stubs that still have referrers.
1671    #[test]
1672    fn relate_remove_does_not_gc_stub_with_surviving_incoming_edge() {
1673        let tmp = TempDir::new().unwrap();
1674        let (mut engine, source_a) = engine_with_seed(&tmp, "SrcA");
1675        let (actor, client) = cli_actor();
1676        let source_b = engine
1677            .create_entity(
1678                empty_create_args("specs", "SrcB"),
1679                actor,
1680                Some(&client),
1681                None,
1682            )
1683            .unwrap();
1684        let stub_id = crate::EntityId::new("specs", "ghost-target");
1685
1686        // Both sources relate to the same stub.
1687        let a_added = engine
1688            .relate_entity(
1689                RelateEntityArgs {
1690                    source: source_a.id.clone(),
1691                    expected_hash: Some(source_a.content_hash.clone()),
1692                    rel_type: "USES".to_string(),
1693                    target: stub_id.clone(),
1694                    remove: false,
1695                    description: None,
1696                },
1697                actor,
1698                Some(&client),
1699                None,
1700            )
1701            .unwrap();
1702        let _b_added = engine
1703            .relate_entity(
1704                RelateEntityArgs {
1705                    source: source_b.id.clone(),
1706                    expected_hash: Some(source_b.content_hash.clone()),
1707                    rel_type: "USES".to_string(),
1708                    target: stub_id.clone(),
1709                    remove: false,
1710                    description: None,
1711                },
1712                actor,
1713                Some(&client),
1714                None,
1715            )
1716            .unwrap();
1717
1718        // Drop source_a's edge only — source_b's edge survives,
1719        // so the stub is not orphaned.
1720        let removed = engine
1721            .relate_entity(
1722                RelateEntityArgs {
1723                    source: source_a.id.clone(),
1724                    expected_hash: Some(a_added.content_hash.clone()),
1725                    rel_type: "USES".to_string(),
1726                    target: stub_id.clone(),
1727                    remove: true,
1728                    description: None,
1729                },
1730                actor,
1731                Some(&client),
1732                None,
1733            )
1734            .unwrap();
1735        assert_eq!(removed.action, RelateAction::Removed);
1736        assert!(
1737            removed.orphan_stubs_removed.is_empty(),
1738            "stub with surviving referrer must not be GC'd; got: {:?}",
1739            removed.orphan_stubs_removed
1740        );
1741        assert!(
1742            engine.store().contains(&stub_id),
1743            "stub must remain in store while another referrer holds it"
1744        );
1745    }
1746
1747    // ---- Cross-mem vocabulary -------------------------------------
1748
1749    /// Two-mem test bench wired for cross-mem routing.
1750    /// Mem `src` pins `src-cv@0.1.0` whose `cross_mem_relationships`
1751    /// section declares an outbound entry to the `tgt-cv` domain with
1752    /// `ADDRESSES: doc → req`. Mem `tgt` pins `tgt-cv@0.1.0`, a
1753    /// schema with a different name. The workspace policy admits the
1754    /// cross-mem link so vocabulary failures surface independently
1755    /// of permission.
1756    mod cross_mem {
1757        use std::collections::BTreeMap;
1758        use std::path::Path;
1759
1760        use indexmap::IndexMap;
1761        use memstead_schema::SchemaRef;
1762        use memstead_schema::workspace_config::CrossLinkValue;
1763        use tempfile::TempDir;
1764
1765        use crate::backend::MemBackend;
1766        use crate::engine::test_helpers::*;
1767        use crate::engine::{
1768            CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateAction,
1769            RelateEntityArgs,
1770        };
1771        use crate::storage::FilesystemMemWriter;
1772
1773        use crate::workspace::{
1774            Mount, MountCapability, MountLifecycle, MountStorage, WorkspaceSettings,
1775        };
1776
1777        fn write_schema_files(root: &Path, name: &str, manifest: &str, types: &[(&str, &str)]) {
1778            let dir = root.join(name);
1779            std::fs::create_dir_all(dir.join("types")).unwrap();
1780            std::fs::write(dir.join("schema.yaml"), manifest).unwrap();
1781            for (type_name, body) in types {
1782                std::fs::write(dir.join("types").join(format!("{type_name}.yaml")), body).unwrap();
1783            }
1784        }
1785
1786        const TYPE_BODY: &str = r#"description: t
1787when_to_use: Here
1788sections:
1789  - key: body
1790    heading: Body
1791    required: true
1792    search_weight: 10.0
1793    catch_all: true
1794    write_rules: []
1795metadata_fields: []
1796title_weight: 100.0
1797text_fields:
1798  - body
1799hierarchy_relationship: _default
1800propagating_relationships: []
1801updatable_fields:
1802  - title
1803  - body
1804health_required_fields:
1805  - body
1806staleness_threshold_days: 90
1807write_rules: []
1808"#;
1809
1810        fn make_type_yaml(name: &str) -> String {
1811            format!("name: {name}\n{TYPE_BODY}")
1812        }
1813
1814        fn folder_mount_with_pin(mem: &str, path: std::path::PathBuf, pin: SchemaRef) -> Mount {
1815            Mount {
1816                mem: mem.to_string(),
1817                schema: Some(pin),
1818                storage: MountStorage::Folder { path },
1819                capability: MountCapability::Write,
1820                lifecycle: MountLifecycle::Eager,
1821                cross_linkable: true,
1822                migration_target: None,
1823            }
1824        }
1825
1826        /// Build an engine with two mems pinning two distinct schemas
1827        /// and a `cross_mem_links` policy admitting the cross-edge.
1828        fn two_mem_engine() -> (TempDir, Engine, CreateEntityOutcome, CreateEntityOutcome) {
1829            let tmp = TempDir::new().unwrap();
1830
1831            // Source schema with cross-mem declarations to the
1832            // tgt-cv domain.
1833            let src_manifest = r#"name: src-cv
1834version: 0.1.0
1835description: source schema
1836when_to_use: tests
1837types:
1838  - doc
1839relationships:
1840  mode: strict
1841  definitions:
1842    - name: IMPLEMENTS
1843      description: intra-mem only
1844      default_weight: 1.0
1845    - name: _default
1846      description: fallback
1847      default_weight: 1.0
1848cross_mem_relationships:
1849  - to_schema: tgt-cv
1850    definitions:
1851      - name: ADDRESSES
1852        description: outbound shape-pinned
1853        default_weight: 1.0
1854        source_types: [doc]
1855        target_types: [req]
1856community:
1857  resolution: 1.0
1858  seed: 42
1859"#;
1860            // Target schema declares no cross_mem_relationships (we
1861            // never relate from tgt → src in these tests).
1862            let tgt_manifest = r#"name: tgt-cv
1863version: 0.1.0
1864description: target schema
1865when_to_use: tests
1866types:
1867  - req
1868relationships:
1869  mode: strict
1870  definitions:
1871    - name: PART_OF
1872      description: hierarchy
1873      default_weight: 3.0
1874      acyclic: true
1875    - name: _default
1876      description: fallback
1877      default_weight: 1.0
1878community:
1879  resolution: 1.0
1880  seed: 42
1881"#;
1882            let schemas_dir = tmp.path().join("schemas");
1883            std::fs::create_dir_all(&schemas_dir).unwrap();
1884            write_schema_files(
1885                &schemas_dir,
1886                "src-cv",
1887                src_manifest,
1888                &[("doc", &make_type_yaml("doc"))],
1889            );
1890            write_schema_files(
1891                &schemas_dir,
1892                "tgt-cv",
1893                tgt_manifest,
1894                &[("req", &make_type_yaml("req"))],
1895            );
1896
1897            let src_dir = tmp.path().join("mem-src");
1898            let tgt_dir = tmp.path().join("mem-tgt");
1899            std::fs::create_dir_all(&src_dir).unwrap();
1900            std::fs::create_dir_all(&tgt_dir).unwrap();
1901
1902            let src_writer = FilesystemMemWriter::new(src_dir.clone());
1903            let tgt_writer = FilesystemMemWriter::new(tgt_dir.clone());
1904            let src_pin = SchemaRef::new("src-cv", semver::Version::new(0, 1, 0));
1905            let tgt_pin = SchemaRef::new("tgt-cv", semver::Version::new(0, 1, 0));
1906
1907            let mut engine = Engine::from_mounts_with_schemas_dir(
1908                vec![
1909                    (
1910                        folder_mount_with_pin("src", src_dir, src_pin),
1911                        Box::new(src_writer) as Box<dyn MemBackend>,
1912                    ),
1913                    (
1914                        folder_mount_with_pin("tgt", tgt_dir, tgt_pin),
1915                        Box::new(tgt_writer) as Box<dyn MemBackend>,
1916                    ),
1917                ],
1918                Some(&schemas_dir),
1919            )
1920            .expect("two-mem engine constructs");
1921
1922            // Wildcard permission so cross-mem edges aren't blocked
1923            // by the orthogonal policy gate (we exercise the vocabulary
1924            // gate here, not the permission gate).
1925            let mut settings = WorkspaceSettings::default();
1926            let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
1927            links.insert("src".to_string(), CrossLinkValue::Wildcard);
1928            settings.cross_mem_links = links;
1929            engine.set_settings(settings);
1930
1931            let (actor, client) = cli_actor();
1932            let src_entity = engine
1933                .create_entity(
1934                    CreateEntityArgs {
1935                        mem: "src".to_string(),
1936                        title: "Doc One".to_string(),
1937                        entity_type: "doc".to_string(),
1938                        sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
1939                        metadata: IndexMap::new(),
1940                        relations: Vec::new(),
1941                        dry_run: false,
1942                    },
1943                    actor,
1944                    Some(&client),
1945                    None,
1946                )
1947                .expect("source entity creates");
1948            let tgt_entity = engine
1949                .create_entity(
1950                    CreateEntityArgs {
1951                        mem: "tgt".to_string(),
1952                        title: "Req One".to_string(),
1953                        entity_type: "req".to_string(),
1954                        sections: IndexMap::from_iter([("body".to_string(), "seed".to_string())]),
1955                        metadata: IndexMap::new(),
1956                        relations: Vec::new(),
1957                        dry_run: false,
1958                    },
1959                    actor,
1960                    Some(&client),
1961                    None,
1962                )
1963                .expect("target entity creates");
1964
1965            (tmp, engine, src_entity, tgt_entity)
1966        }
1967
1968        #[test]
1969        fn cross_different_schema_admits_declared_edge() {
1970            let (_tmp, mut engine, src, tgt) = two_mem_engine();
1971            let (actor, client) = cli_actor();
1972            let outcome = engine
1973                .relate_entity(
1974                    RelateEntityArgs {
1975                        source: src.id.clone(),
1976                        expected_hash: Some(src.content_hash.clone()),
1977                        rel_type: "ADDRESSES".to_string(),
1978                        target: tgt.id.clone(),
1979                        remove: false,
1980                        description: None,
1981                    },
1982                    actor,
1983                    Some(&client),
1984                    None,
1985                )
1986                .expect("declared cross-mem edge admits");
1987            assert_eq!(outcome.rel_type, "ADDRESSES");
1988        }
1989
1990        /// Same schema name at different versions is the same domain:
1991        /// edges between two `same-dom`-pinned mems route through
1992        /// the intra-schema relationship vocabulary (governed by the
1993        /// source mem's pinned version) with no
1994        /// `cross_mem_relationships` declaration at all.
1995        #[test]
1996        fn same_name_different_version_uses_intra_mem_vocabulary() {
1997            let tmp = TempDir::new().unwrap();
1998
1999            let manifest_for = |version: &str| {
2000                format!(
2001                    r#"name: same-dom
2002version: {version}
2003description: same-domain schema
2004when_to_use: tests
2005types:
2006  - doc
2007relationships:
2008  mode: strict
2009  definitions:
2010    - name: IMPLEMENTS
2011      description: intra-mem vocabulary
2012      default_weight: 1.0
2013    - name: _default
2014      description: fallback
2015      default_weight: 1.0
2016community:
2017  resolution: 1.0
2018  seed: 42
2019"#
2020                )
2021            };
2022            let schemas_dir = tmp.path().join("schemas");
2023            std::fs::create_dir_all(&schemas_dir).unwrap();
2024            // Subdir names carry the version so both iterations of the
2025            // `same-dom` domain coexist in one schemas dir.
2026            write_schema_files(
2027                &schemas_dir,
2028                "same-dom-0.1.0",
2029                &manifest_for("0.1.0"),
2030                &[("doc", &make_type_yaml("doc"))],
2031            );
2032            write_schema_files(
2033                &schemas_dir,
2034                "same-dom-0.2.0",
2035                &manifest_for("0.2.0"),
2036                &[("doc", &make_type_yaml("doc"))],
2037            );
2038
2039            let src_dir = tmp.path().join("mem-src");
2040            let tgt_dir = tmp.path().join("mem-tgt");
2041            std::fs::create_dir_all(&src_dir).unwrap();
2042            std::fs::create_dir_all(&tgt_dir).unwrap();
2043            let src_pin = SchemaRef::new("same-dom", semver::Version::new(0, 1, 0));
2044            let tgt_pin = SchemaRef::new("same-dom", semver::Version::new(0, 2, 0));
2045            let mut engine = Engine::from_mounts_with_schemas_dir(
2046                vec![
2047                    (
2048                        folder_mount_with_pin("src", src_dir.clone(), src_pin),
2049                        Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2050                    ),
2051                    (
2052                        folder_mount_with_pin("tgt", tgt_dir.clone(), tgt_pin),
2053                        Box::new(FilesystemMemWriter::new(tgt_dir)) as Box<dyn MemBackend>,
2054                    ),
2055                ],
2056                Some(&schemas_dir),
2057            )
2058            .expect("same-domain two-version engine constructs");
2059
2060            let mut settings = WorkspaceSettings::default();
2061            let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2062            links.insert("src".to_string(), CrossLinkValue::Wildcard);
2063            settings.cross_mem_links = links;
2064            engine.set_settings(settings);
2065
2066            let (actor, client) = cli_actor();
2067            let mk_entity = |engine: &mut Engine, mem: &str, title: &str| {
2068                engine
2069                    .create_entity(
2070                        CreateEntityArgs {
2071                            mem: mem.to_string(),
2072                            title: title.to_string(),
2073                            entity_type: "doc".to_string(),
2074                            sections: IndexMap::from_iter([(
2075                                "body".to_string(),
2076                                "seed".to_string(),
2077                            )]),
2078                            metadata: IndexMap::new(),
2079                            relations: Vec::new(),
2080                            dry_run: false,
2081                        },
2082                        actor,
2083                        Some(&client),
2084                        None,
2085                    )
2086                    .expect("entity creates")
2087            };
2088            let src_entity = mk_entity(&mut engine, "src", "Doc A");
2089            let tgt_entity = mk_entity(&mut engine, "tgt", "Doc B");
2090
2091            let outcome = engine
2092                .relate_entity(
2093                    RelateEntityArgs {
2094                        source: src_entity.id.clone(),
2095                        expected_hash: Some(src_entity.content_hash.clone()),
2096                        rel_type: "IMPLEMENTS".to_string(),
2097                        target: tgt_entity.id.clone(),
2098                        remove: false,
2099                        description: None,
2100                    },
2101                    actor,
2102                    Some(&client),
2103                    None,
2104                )
2105                .expect("same-domain edge uses the intra-schema vocabulary across versions");
2106            assert_eq!(outcome.rel_type, "IMPLEMENTS");
2107        }
2108
2109        #[test]
2110        fn cross_different_schema_unknown_rel_type_returns_invalid_rel_type() {
2111            // `IMPLEMENTS` exists intra-mem but not in the cross-mem
2112            // entry — must refuse with INVALID_REL_TYPE against the
2113            // cross-mem entry's vocabulary (not intra-mem's).
2114            let (_tmp, mut engine, src, tgt) = two_mem_engine();
2115            let (actor, client) = cli_actor();
2116            let err = engine
2117                .relate_entity(
2118                    RelateEntityArgs {
2119                        source: src.id.clone(),
2120                        expected_hash: Some(src.content_hash.clone()),
2121                        rel_type: "IMPLEMENTS".to_string(),
2122                        target: tgt.id.clone(),
2123                        remove: false,
2124                        description: None,
2125                    },
2126                    actor,
2127                    Some(&client),
2128                    None,
2129                )
2130                .unwrap_err();
2131            match err {
2132                EngineError::Validation(
2133                    crate::runtime_validator::ValidationError::InvalidRelationshipType {
2134                        input,
2135                        allowed,
2136                        ..
2137                    },
2138                ) => {
2139                    assert_eq!(input, "IMPLEMENTS");
2140                    let names: Vec<String> = allowed.into_iter().map(|h| h.name).collect();
2141                    assert!(names.iter().any(|n| n == "ADDRESSES"));
2142                    assert!(!names.iter().any(|n| n == "IMPLEMENTS"));
2143                }
2144                other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
2145            }
2146        }
2147
2148        #[test]
2149        fn cross_different_schema_shape_violation_returns_invalid_rel_shape() {
2150            // ADDRESSES is shape-pinned to source=doc, target=req in
2151            // the cross-mem entry. Need a source whose type isn't doc.
2152            // src-cv only declares `doc`, so to provoke a shape miss we
2153            // build a third schema with type `note` and a fresh mem —
2154            // but that requires more plumbing than this test needs.
2155            // Instead: exercise a target-side shape miss by relating
2156            // ADDRESSES to a target that doesn't exist at all — the
2157            // target_type lookup returns None and the target check is
2158            // skipped (admits). So we exercise this via cross_mem
2159            // unit tests instead.
2160            //
2161            // What this integration test confirms: the source-side
2162            // shape check fires when the source type doesn't match —
2163            // here we'd need a non-`doc` source. Since src-cv only has
2164            // `doc`, the source-side admits trivially. Covered fully
2165            // by the runtime_validator unit tests.
2166        }
2167
2168        #[test]
2169        fn cross_different_schema_no_matching_entry_returns_edge_not_declared() {
2170            // Build a third mem pinning a schema not declared in
2171            // src-cv's cross_mem_relationships, then relate from src.
2172            let tmp = TempDir::new().unwrap();
2173            let src_manifest = r#"name: src-cv
2174version: 0.1.0
2175description: source schema
2176when_to_use: tests
2177types:
2178  - doc
2179relationships:
2180  mode: strict
2181  definitions:
2182    - name: IMPLEMENTS
2183      description: intra-mem
2184      default_weight: 1.0
2185    - name: _default
2186      description: fallback
2187      default_weight: 1.0
2188cross_mem_relationships:
2189  - to_schema: tgt-cv
2190    definitions:
2191      - name: ADDRESSES
2192        description: outbound
2193        default_weight: 1.0
2194        source_types: [doc]
2195        target_types: [req]
2196community:
2197  resolution: 1.0
2198  seed: 42
2199"#;
2200            // Different target schema NOT named in src's cross-mem list.
2201            let other_manifest = r#"name: other-cv
2202version: 0.1.0
2203description: foreign schema
2204when_to_use: tests
2205types:
2206  - thing
2207relationships:
2208  mode: strict
2209  definitions:
2210    - name: _default
2211      description: fallback
2212      default_weight: 1.0
2213community:
2214  resolution: 1.0
2215  seed: 42
2216"#;
2217            let schemas_dir = tmp.path().join("schemas");
2218            std::fs::create_dir_all(&schemas_dir).unwrap();
2219            write_schema_files(
2220                &schemas_dir,
2221                "src-cv",
2222                src_manifest,
2223                &[("doc", &make_type_yaml("doc"))],
2224            );
2225            write_schema_files(
2226                &schemas_dir,
2227                "other-cv",
2228                other_manifest,
2229                &[("thing", &make_type_yaml("thing"))],
2230            );
2231            let src_dir = tmp.path().join("mem-src");
2232            let other_dir = tmp.path().join("mem-other");
2233            std::fs::create_dir_all(&src_dir).unwrap();
2234            std::fs::create_dir_all(&other_dir).unwrap();
2235
2236            let mut engine = Engine::from_mounts_with_schemas_dir(
2237                vec![
2238                    (
2239                        folder_mount_with_pin(
2240                            "src",
2241                            src_dir.clone(),
2242                            SchemaRef::new("src-cv", semver::Version::new(0, 1, 0)),
2243                        ),
2244                        Box::new(FilesystemMemWriter::new(src_dir)) as Box<dyn MemBackend>,
2245                    ),
2246                    (
2247                        folder_mount_with_pin(
2248                            "other",
2249                            other_dir.clone(),
2250                            SchemaRef::new("other-cv", semver::Version::new(0, 1, 0)),
2251                        ),
2252                        Box::new(FilesystemMemWriter::new(other_dir)) as Box<dyn MemBackend>,
2253                    ),
2254                ],
2255                Some(&schemas_dir),
2256            )
2257            .expect("engine constructs");
2258
2259            let mut settings = WorkspaceSettings::default();
2260            let mut links: BTreeMap<String, CrossLinkValue> = BTreeMap::new();
2261            links.insert("src".to_string(), CrossLinkValue::Wildcard);
2262            settings.cross_mem_links = links;
2263            engine.set_settings(settings);
2264
2265            let (actor, client) = cli_actor();
2266            let src_entity = engine
2267                .create_entity(
2268                    CreateEntityArgs {
2269                        mem: "src".to_string(),
2270                        title: "D".to_string(),
2271                        entity_type: "doc".to_string(),
2272                        sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2273                        metadata: IndexMap::new(),
2274                        relations: Vec::new(),
2275                        dry_run: false,
2276                    },
2277                    actor,
2278                    Some(&client),
2279                    None,
2280                )
2281                .unwrap();
2282            let other_entity = engine
2283                .create_entity(
2284                    CreateEntityArgs {
2285                        mem: "other".to_string(),
2286                        title: "T".to_string(),
2287                        entity_type: "thing".to_string(),
2288                        sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2289                        metadata: IndexMap::new(),
2290                        relations: Vec::new(),
2291                        dry_run: false,
2292                    },
2293                    actor,
2294                    Some(&client),
2295                    None,
2296                )
2297                .unwrap();
2298
2299            let err = engine
2300                .relate_entity(
2301                    RelateEntityArgs {
2302                        source: src_entity.id.clone(),
2303                        expected_hash: Some(src_entity.content_hash.clone()),
2304                        rel_type: "ADDRESSES".to_string(),
2305                        target: other_entity.id.clone(),
2306                        remove: false,
2307                        description: None,
2308                    },
2309                    actor,
2310                    Some(&client),
2311                    None,
2312                )
2313                .unwrap_err();
2314            match err {
2315                EngineError::CrossMemEdgeNotDeclared {
2316                    source_schema,
2317                    target_schema,
2318                    rel_type,
2319                    from_id,
2320                    to_id,
2321                } => {
2322                    assert_eq!(source_schema, "src-cv@0.1.0");
2323                    assert_eq!(target_schema, "other-cv@0.1.0");
2324                    assert_eq!(rel_type, "ADDRESSES");
2325                    assert_eq!(from_id, src_entity.id.to_string());
2326                    assert_eq!(to_id, other_entity.id.to_string());
2327                }
2328                other => panic!("expected CrossMemEdgeNotDeclared, got {other:?}"),
2329            }
2330        }
2331
2332        #[test]
2333        fn intra_mem_with_cross_mem_only_rel_type_returns_invalid_rel_type() {
2334            // `ADDRESSES` is declared in src-cv's cross_mem_relationships
2335            // only — intra-mem relate must refuse with
2336            // INVALID_REL_TYPE since the intra-mem vocabulary
2337            // (`IMPLEMENTS` / `_default`) doesn't know it.
2338            let (_tmp, mut engine, src, _tgt) = two_mem_engine();
2339            let (actor, client) = cli_actor();
2340            // Create a same-mem target.
2341            let intra_target = engine
2342                .create_entity(
2343                    CreateEntityArgs {
2344                        mem: "src".to_string(),
2345                        title: "Doc Two".to_string(),
2346                        entity_type: "doc".to_string(),
2347                        sections: IndexMap::from_iter([("body".to_string(), "x".to_string())]),
2348                        metadata: IndexMap::new(),
2349                        relations: Vec::new(),
2350                        dry_run: false,
2351                    },
2352                    actor,
2353                    Some(&client),
2354                    None,
2355                )
2356                .unwrap();
2357            // Source's content_hash may have rotated due to incoming
2358            // edges from intra_target — fetch fresh.
2359            let src_fresh = engine.get_entity(&src.id).unwrap();
2360            let err = engine
2361                .relate_entity(
2362                    RelateEntityArgs {
2363                        source: src.id.clone(),
2364                        expected_hash: Some(src_fresh.content_hash.clone()),
2365                        rel_type: "ADDRESSES".to_string(),
2366                        target: intra_target.id.clone(),
2367                        remove: false,
2368                        description: None,
2369                    },
2370                    actor,
2371                    Some(&client),
2372                    None,
2373                )
2374                .unwrap_err();
2375            match err {
2376                EngineError::Validation(
2377                    crate::runtime_validator::ValidationError::InvalidRelationshipType {
2378                        input,
2379                        ..
2380                    },
2381                ) => {
2382                    assert_eq!(input, "ADDRESSES");
2383                }
2384                other => panic!("expected Validation(InvalidRelationshipType), got {other:?}"),
2385            }
2386        }
2387
2388        #[test]
2389        fn vocabulary_admissible_edge_blocked_by_policy_returns_cross_mem_link_not_allowed() {
2390            // Same fixture but flip the cross-mem policy to deny.
2391            // ADDRESSES is vocabulary-admissible but permission refuses
2392            // it independently — surfaces CROSS_MEM_LINK_NOT_ALLOWED.
2393            let (_tmp, mut engine, src, tgt) = two_mem_engine();
2394            // Replace the wildcard policy with default-deny.
2395            engine.set_settings(WorkspaceSettings::default());
2396            let (actor, client) = cli_actor();
2397            let err = engine
2398                .relate_entity(
2399                    RelateEntityArgs {
2400                        source: src.id.clone(),
2401                        expected_hash: Some(src.content_hash.clone()),
2402                        rel_type: "ADDRESSES".to_string(),
2403                        target: tgt.id.clone(),
2404                        remove: false,
2405                        description: None,
2406                    },
2407                    actor,
2408                    Some(&client),
2409                    None,
2410                )
2411                .unwrap_err();
2412            assert!(
2413                matches!(err, EngineError::CrossMemLinkNotAllowed { .. }),
2414                "expected CrossMemLinkNotAllowed, got {err:?}"
2415            );
2416        }
2417
2418        /// Cross-mem remove bypasses the `cross_mem_links` policy
2419        /// gate. Without this, a workspace whose grant was revoked
2420        /// while edges still existed gets wedged: the natural recovery
2421        /// (`memstead_relate ... --remove`) refuses, leaving the operator
2422        /// to re-grant just to delete the data that the grant once
2423        /// permitted.
2424        #[test]
2425        fn cross_mem_remove_bypasses_policy_after_revoke() {
2426            let (_tmp, mut engine, src, tgt) = two_mem_engine();
2427            let (actor, client) = cli_actor();
2428
2429            // 1. Edge admits under wildcard grant.
2430            let added = engine
2431                .relate_entity(
2432                    RelateEntityArgs {
2433                        source: src.id.clone(),
2434                        expected_hash: Some(src.content_hash.clone()),
2435                        rel_type: "ADDRESSES".to_string(),
2436                        target: tgt.id.clone(),
2437                        remove: false,
2438                        description: None,
2439                    },
2440                    actor,
2441                    Some(&client),
2442                    None,
2443                )
2444                .expect("declared cross-mem edge admits under grant");
2445            assert_eq!(added.action, RelateAction::Added);
2446
2447            // 2. Revoke the grant — default settings deny everything.
2448            engine.set_settings(WorkspaceSettings::default());
2449
2450            // 3. Re-attempting an *add* still refuses (constraint:
2451            //    the gate is unchanged for the add path).
2452            let add_err = engine
2453                .relate_entity(
2454                    RelateEntityArgs {
2455                        source: src.id.clone(),
2456                        expected_hash: Some(added.content_hash.clone()),
2457                        rel_type: "ADDRESSES".to_string(),
2458                        target: tgt.id.clone(),
2459                        remove: false,
2460                        description: None,
2461                    },
2462                    actor,
2463                    Some(&client),
2464                    None,
2465                )
2466                .unwrap_err();
2467            assert!(
2468                matches!(add_err, EngineError::CrossMemLinkNotAllowed { .. }),
2469                "add path must still refuse under denial, got {add_err:?}"
2470            );
2471
2472            // 4. Remove succeeds — the cleanup path bypasses the
2473            //    policy gate.
2474            let removed = engine
2475                .relate_entity(
2476                    RelateEntityArgs {
2477                        source: src.id.clone(),
2478                        expected_hash: Some(added.content_hash.clone()),
2479                        rel_type: "ADDRESSES".to_string(),
2480                        target: tgt.id.clone(),
2481                        remove: true,
2482                        description: None,
2483                    },
2484                    actor,
2485                    Some(&client),
2486                    None,
2487                )
2488                .expect("remove must bypass the policy gate post-revoke");
2489            assert_eq!(removed.action, RelateAction::Removed);
2490
2491            // 5. Edge is gone from the store's outgoing index.
2492            let outgoing = engine.store().outgoing(&src.id);
2493            assert!(
2494                !outgoing
2495                    .iter()
2496                    .any(|e| e.target == tgt.id && e.rel_type == "ADDRESSES"),
2497                "ADDRESSES edge must be gone after remove"
2498            );
2499        }
2500
2501        /// Remove on a non-existent cross-mem edge with no grant
2502        /// returns a no-op, not a policy refusal. The remove path is
2503        /// permissive on absence — same shape as same-mem remove.
2504        #[test]
2505        fn cross_mem_remove_of_absent_edge_under_denial_is_no_op() {
2506            let (_tmp, mut engine, src, tgt) = two_mem_engine();
2507            // Default-deny from the start: no edge ever existed.
2508            engine.set_settings(WorkspaceSettings::default());
2509            let (actor, client) = cli_actor();
2510            let outcome = engine
2511                .relate_entity(
2512                    RelateEntityArgs {
2513                        source: src.id.clone(),
2514                        expected_hash: Some(src.content_hash.clone()),
2515                        rel_type: "ADDRESSES".to_string(),
2516                        target: tgt.id.clone(),
2517                        remove: true,
2518                        description: None,
2519                    },
2520                    actor,
2521                    Some(&client),
2522                    None,
2523                )
2524                .expect("absent-edge remove must not refuse on policy");
2525            assert!(
2526                matches!(outcome.action, RelateAction::NoOpAbsent),
2527                "expected NoOpAbsent, got {:?}",
2528                outcome.action
2529            );
2530        }
2531    }
2532
2533    // ---- Engine::rename_entity --------------------------------------
2534}