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