Skip to main content

memstead_base/engine/mutation/
mod.rs

1//! Engine mutation entrypoints — split per mutation kind.
2//!
3//! Each sub-module implements one mutation of `Engine`: `create`,
4//! `update` (with batch), `delete`, `relate`, `rename`. The shared
5//! helpers (`today_iso`, `make_stub`, `gc_orphan_stubs`,
6//! `lookup_title_and_type`, `unknown_type_error`) plus the typed
7//! constants `PATCH_OLD_NOT_FOUND_CONTENT_CAP` and
8//! `RELATIONSHIP_CYCLE_PATH_CAP` live here.
9
10use std::collections::HashMap;
11
12use indexmap::IndexMap;
13
14use crate::entity::{Entity, EntityId};
15use crate::store::Store;
16
17use super::EngineError;
18
19pub mod create;
20pub mod delete;
21pub mod mem_sweep;
22pub mod parse_recovery;
23pub mod relate;
24pub mod rename;
25pub mod update;
26
27/// Look up an entity's `(title, entity_type)` pair in `store`. Both
28/// `None` for missing-from-store ids — matches full's `title_for` /
29/// `type_for` lossy-lookup contract. Used by [`Engine::changes_since`]
30/// to enrich id-only envelopes the backend returned with metadata
31/// from the in-memory store.
32pub(super) fn lookup_title_and_type(
33    store: &Store,
34    id: &EntityId,
35) -> (Option<String>, Option<String>) {
36    match store.get(id) {
37        Some(e) => (Some(e.title.clone()), Some(e.entity_type.clone())),
38        None => (None, None),
39    }
40}
41
42/// Maximum byte length of the truncated `current_content` snapshot
43/// that [`EngineError::PatchOldNotFound`] carries. Keeps the wire
44/// envelope bounded for sections with large bodies. Mirrors full's
45/// `memstead_git_branch::PATCH_OLD_NOT_FOUND_CONTENT_CAP`.
46pub const PATCH_OLD_NOT_FOUND_CONTENT_CAP: usize = 500;
47
48/// Maximum number of entity IDs retained in
49/// [`EngineError::RelationshipCycle::existing_path`]. Keeps the cycle
50/// envelope bounded for pathologically long chains. Mirrors full's
51/// `memstead_git_branch::RELATIONSHIP_CYCLE_PATH_CAP`.
52pub const RELATIONSHIP_CYCLE_PATH_CAP: usize = 20;
53
54/// Build an [`EngineError::UnknownType`] populated with the schema's
55/// declared type names (sorted) and a fuzzy suggestion. Mirrors full's
56/// `UnknownEntityType` recovery payload so MCP envelopes carry the
57/// same `name` / `schema_ref` / `declared` / `suggestion` keys
58/// regardless of which engine served the call.
59pub(crate) fn unknown_type_error(schema: &memstead_schema::Schema, attempted: &str) -> EngineError {
60    let mut declared: Vec<String> = schema.types.keys().cloned().collect();
61    declared.sort();
62    let (sname, sver) = schema.id();
63    EngineError::UnknownType {
64        name: attempted.to_string(),
65        schema_ref: format!("{sname}@{sver}"),
66        declared,
67        suggestion: schema.suggest_type(attempted),
68    }
69}
70
71/// The lowercase wire string for a [`crate::pipeline::MediumType`] — the
72/// value the `INVALID_ANCHOR` recovery detail carries so an agent sees
73/// which medium's namespace rejected the grain. Matches the enum's
74/// `#[serde(rename_all = "lowercase")]` form.
75pub(crate) fn medium_type_wire(t: crate::pipeline::MediumType) -> &'static str {
76    use crate::pipeline::MediumType::*;
77    match t {
78        Codebase => "codebase",
79        Filesystem => "filesystem",
80        Graph => "graph",
81        Git => "git",
82        Web => "web",
83    }
84}
85
86impl super::Engine {
87    /// Resolve the single-source anchor-namespace context for `mem`, when
88    /// unambiguous. An `anchors[]` element carries no source name, so the
89    /// grain/namespace refusal ([`crate::anchor::AnchorValidationError::GrainNamespaceUnsupported`])
90    /// can only be fired deterministically when the mem's bindings declare
91    /// exactly one inline source; with zero or several the namespace check
92    /// is skipped (the vocabulary + hash-semantics rules still apply).
93    /// Returns the `(medium_type_wire, anchor_namespace)` pair the
94    /// validator consumes — the medium *half* of the lone source.
95    pub(crate) fn resolve_anchor_medium(&self, mem: &str) -> Option<(String, &'static str)> {
96        let mut sources = self
97            .pipeline_configs()
98            .bindings
99            .iter()
100            .filter(|r| r.mem == mem)
101            .flat_map(|r| r.config.sources.iter());
102        let first = sources.next()?;
103        if sources.next().is_some() {
104            // Ambiguous — the anchor does not name which source it targets;
105            // skip the namespace refinement rather than guess.
106            return None;
107        }
108        let caps = crate::binding::medium_capabilities(first.medium_type);
109        Some((
110            medium_type_wire(first.medium_type).to_string(),
111            caps.anchor_namespace,
112        ))
113    }
114
115    /// Validate the permissive `anchors[]` inputs for a mutation against
116    /// `mem`'s medium context into strict [`crate::anchor::Anchor`]s, or
117    /// refuse the whole mutation with a typed
118    /// [`EngineError::InvalidAnchor`]. Empty input yields an empty vec (no
119    /// sidecar write); a single malformed element aborts before any state
120    /// change so the entity is never written.
121    pub(crate) fn validate_anchor_inputs(
122        &self,
123        mem: &str,
124        inputs: &[crate::anchor::AnchorInput],
125    ) -> Result<Vec<crate::anchor::Anchor>, EngineError> {
126        if inputs.is_empty() {
127            return Ok(Vec::new());
128        }
129        let medium = self.resolve_anchor_medium(mem);
130        let medium_ref = medium.as_ref().map(|(t, ns)| (t.as_str(), *ns));
131        let anchors: Vec<crate::anchor::Anchor> = inputs
132            .iter()
133            .map(|i| i.validate(medium_ref).map_err(EngineError::from))
134            .collect::<Result<_, _>>()?;
135
136        // Source-vs-binding check: when an anchor names BOTH a producing
137        // binding and a source, and that binding hash still resolves in
138        // this workspace (reverse lookup — any later binding edit moves
139        // the hash and drops earlier anchors into the accept-any-name
140        // branch for good), the source must be one of the binding's
141        // declared names. The bindings load is skipped entirely unless
142        // some input needs it, and a missing workspace root or an
143        // unloadable store degrades to accept-any-name — validation
144        // must never require the binding to resolve.
145        if anchors
146            .iter()
147            .any(|a| a.binding.is_some() && a.source.is_some())
148            && let Some(root) = self.workspace_root()
149            && let Ok(configs) = crate::pipeline_store::load_pipeline_configs(root)
150        {
151            for a in &anchors {
152                let (Some(binding_hash), Some(source)) = (&a.binding, &a.source) else {
153                    continue;
154                };
155                let Some(record) = configs
156                    .bindings
157                    .iter()
158                    .find(|r| crate::binding::hash_binding(&r.config) == *binding_hash)
159                else {
160                    continue; // unresolvable binding: accept any non-empty name
161                };
162                let declared: Vec<String> = record
163                    .config
164                    .sources
165                    .iter()
166                    .map(|s| s.name.clone())
167                    .collect();
168                if !declared.iter().any(|n| n == source) {
169                    return Err(EngineError::from(
170                        crate::anchor::AnchorValidationError::SourceNotDeclared {
171                            got: source.clone(),
172                            declared,
173                        },
174                    ));
175                }
176            }
177        }
178
179        Ok(anchors)
180    }
181
182    /// Validate an update's `anchors_unset[]` payload up front — a
183    /// malformed selector (missing artifact, unknown grain/class wire
184    /// string) refuses the whole mutation with the same typed
185    /// `INVALID_ANCHOR` envelope as a malformed `anchors[]` element.
186    /// Empty payload → empty vec.
187    pub(crate) fn validate_anchor_unsets(
188        inputs: &[crate::anchor::AnchorUnsetInput],
189    ) -> Result<Vec<crate::anchor::AnchorUnset>, EngineError> {
190        inputs
191            .iter()
192            .map(|i| i.validate().map_err(EngineError::from))
193            .collect()
194    }
195
196    /// Record verify-observed prepared-content hashes onto **hash-less
197    /// hash-bearing** anchors in `mem_name`'s anchors sidecar — the
198    /// measurement-bookkeeping backfill the verify pass hands over via
199    /// [`crate::ingest::VerifyOutcome::hash_backfill`].
200    ///
201    /// This mutates **only** the engine-owned sidecar
202    /// ([`crate::anchor::ANCHOR_SIDECAR_PATH`]): no entity content, no
203    /// section, no `_hash` is touched — an anchor-only commit yields zero
204    /// entity deltas by construction. Guards enforced at this write seam,
205    /// not left to callers:
206    ///
207    /// - only a hash-bearing class (`anchored` / `derived`) may gain a hash —
208    ///   an `authored` / `informed-by` anchor is never written, whatever the
209    ///   caller observed;
210    /// - an anchor that already carries a hash is never overwritten — the
211    ///   recorded hash is the drift baseline, so the backfill is idempotent
212    ///   (a second identical call stages nothing and produces no commit).
213    ///
214    /// Returns how many anchors gained a hash. Zero writes ⇒ no commit.
215    pub fn record_anchor_observed_hashes(
216        &mut self,
217        mem_name: &str,
218        observed: &[crate::anchor::ObservedArtifactHash],
219        note: Option<&str>,
220    ) -> Result<usize, EngineError> {
221        if observed.is_empty() {
222            return Ok(0);
223        }
224        let mount_idx = self
225            .mounts
226            .iter()
227            .position(|m| m.mount.mem == mem_name)
228            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
229        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
230            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
231        }
232        // Same posture as every other commit-producing write: probe for
233        // sibling-engine drift so the sidecar merge runs against current truth.
234        let _warnings = self.reload_if_stale(Some(mem_name));
235
236        let backend = self.mounts[mount_idx].backend.as_ref();
237        let mut sidecar = read_sidecar(backend)?;
238        let mut written = 0usize;
239        for obs in observed {
240            let Some(anchors) = sidecar.entities.get_mut(&obs.entity) else {
241                continue;
242            };
243            for a in anchors {
244                if a.class.is_hash_bearing() && a.hash.is_none() && a.artifact == obs.artifact {
245                    a.hash = Some(obs.hash.clone());
246                    written += 1;
247                }
248            }
249        }
250        if written == 0 {
251            return Ok(0);
252        }
253        backend.write_anchors_sidecar(&sidecar.to_bytes())?;
254        let ctx = crate::vcs::CommitContext {
255            actor: crate::vcs::Actor::Agent,
256            client: None,
257            tool: Some("record_anchor_observed_hashes"),
258            note: note.map(String::from),
259            role: self.current_role,
260            logical_operation_id: None,
261            entity_ids: None,
262        };
263        let commit_sha = backend.commit(
264            &format!("memstead: anchor-hash backfill ({written} anchor(s))"),
265            &ctx,
266        )?;
267        self.record_self_write(mount_idx, &commit_sha);
268        self.stamp_mutation_versions(mount_idx);
269        Ok(written)
270    }
271
272    /// Record on the mem which engine version and which resolved
273    /// schema performed the mutation that just committed
274    /// (`MemConfig.mutation_stamp`). Called by every mutation verb
275    /// after `record_self_write` — one shared implementation, per the
276    /// one-guard-on-all-write-paths principle — and deliberately NOT
277    /// by `apply_external_commit`: a replayed sibling commit was
278    /// stamped by the engine that performed it, and this engine must
279    /// not claim it.
280    ///
281    /// Write-cheap by construction: the config write happens only when
282    /// the stamp VALUE changes (a binary upgrade or a schema repin) —
283    /// steady-state mutations compare and return. The write is
284    /// best-effort: a failed stamp never fails the mutation that
285    /// preceded it. On git-branch backends the config rides the
286    /// `__MEMSTEAD` ref, so a stamp write never moves the mem branch
287    /// head — mutation `commit_sha` cursors stay valid.
288    pub(crate) fn stamp_mutation_versions(&mut self, mount_idx: usize) {
289        let Some(state) = self.mounts.get(mount_idx) else {
290            return;
291        };
292        let mem = state.mount.mem.clone();
293        let Some(schema) = self.schemas.get(&mem) else {
294            return;
295        };
296        let (name, version) = schema.id();
297        // Full build version (semver + git build sha when present) so
298        // a rebuild between mutations is a recordable — and hence
299        // skew-detectable — event even between releases.
300        let stamp = memstead_schema::MutationStamp {
301            engine_version: crate::build_info::full_version().to_string(),
302            schema: format!("{name}@{version}"),
303        };
304        let Some(state) = self.mounts.get_mut(mount_idx) else {
305            return;
306        };
307        // A mem with no loaded config has nowhere to carry the stamp;
308        // skip silently (in-memory sketches, minimal fixtures).
309        let Some(config) = state.mem_config.as_ref() else {
310            return;
311        };
312        if config.mutation_stamp.as_ref() == Some(&stamp) {
313            return;
314        }
315        let mut updated = config.clone();
316        updated.mutation_stamp = Some(stamp);
317        let Ok(mut bytes) = serde_json::to_vec_pretty(&updated) else {
318            return;
319        };
320        bytes.push(b'\n');
321        if state
322            .backend
323            .write_mem_config_with_note(&bytes, Some("engine version stamp"))
324            .is_ok()
325        {
326            state.mem_config = Some(updated);
327        }
328    }
329}
330
331/// Stage a write of `entity_id`'s anchors into the mem's anchors sidecar
332/// through `backend`, merged over the existing sidecar at BOTH levels —
333/// other entities' rows survive (document level), and within the entity's
334/// own row `unsets` apply first, then each incoming anchor replaces the
335/// existing anchor with the same `(artifact, grain, class)` triple or
336/// appends ([`crate::anchor::AnchorSidecar::merge`]). Writing never
337/// removes an anchor the call did not name in `unsets`. The write is
338/// buffered into the SAME pending op set the entity write used — so the
339/// next [`crate::backend::MemBackend::commit`] carries entity + anchors
340/// as one atomic commit. Reads honour pending-buffer precedence, so
341/// successive stages within one transaction compose.
342/// Stage a mutation of the engine-owned derivations sidecar
343/// (agent-trust plan 12) so it rides the SAME commit as the edge
344/// write that produced it — the anchors-sidecar atomicity precedent.
345/// The sidecar travels through the backend's normal entity-path
346/// read/write under `.memstead/`, which every backend filters from
347/// entity listings and every archive/export path carries as-is.
348pub(crate) fn stage_derivation_sidecar(
349    backend: &dyn crate::backend::MemBackend,
350    mutate: impl FnOnce(&mut crate::derivation::DerivationSidecar),
351) -> Result<(), EngineError> {
352    let path = std::path::Path::new(crate::derivation::DERIVATION_SIDECAR_PATH);
353    let mut sidecar = match backend.read_entity(path)? {
354        Some(bytes) => crate::derivation::DerivationSidecar::from_bytes(&bytes).map_err(|e| {
355            EngineError::Backend(crate::backend::BackendError::Other(format!(
356                "derivations sidecar parse: {e}"
357            )))
358        })?,
359        None => crate::derivation::DerivationSidecar::default(),
360    };
361    mutate(&mut sidecar);
362    backend.write_entity(path, &sidecar.to_bytes())?;
363    Ok(())
364}
365
366/// True when `schema` declares `rel_type` as a derivation
367/// (`derivation: true` on the relationship definition) — the
368/// predicate every write path shares, so baseline recording cannot
369/// fork per verb.
370pub(crate) fn rel_type_declares_derivation(
371    schema: &memstead_schema::Schema,
372    rel_type: &str,
373) -> bool {
374    schema
375        .manifest
376        .relationships
377        .definitions
378        .iter()
379        .any(|d| d.name == rel_type && d.derivation)
380}
381
382pub(crate) fn stage_anchors_sidecar(
383    backend: &dyn crate::backend::MemBackend,
384    entity_id: &EntityId,
385    unsets: &[crate::anchor::AnchorUnset],
386    anchors: Vec<crate::anchor::Anchor>,
387) -> Result<(), EngineError> {
388    let mut sidecar = match backend.read_anchors_sidecar()? {
389        Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
390            EngineError::Backend(crate::backend::BackendError::Other(format!(
391                "anchors sidecar parse: {e}"
392            )))
393        })?,
394        None => crate::anchor::AnchorSidecar::default(),
395    };
396    sidecar.merge(entity_id.as_ref(), unsets, anchors);
397    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
398    Ok(())
399}
400
401/// Load the mem's anchors sidecar through `backend`, or the empty
402/// document when none exists yet. Shared by the delete / rename legs
403/// which must decide whether the entity actually has anchor rows before
404/// staging a sidecar write (so an entity with none stays byte-identical
405/// to a pre-anchor mutation).
406fn read_sidecar(
407    backend: &dyn crate::backend::MemBackend,
408) -> Result<crate::anchor::AnchorSidecar, EngineError> {
409    match backend.read_anchors_sidecar()? {
410        Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
411            EngineError::Backend(crate::backend::BackendError::Other(format!(
412                "anchors sidecar parse: {e}"
413            )))
414        }),
415        None => Ok(crate::anchor::AnchorSidecar::default()),
416    }
417}
418
419/// Stage removal of `entity_id`'s anchor row into the same commit as an
420/// entity delete — a no-op (no sidecar write, so byte-identical to today)
421/// when the entity carries no anchors. Returns whether a write was staged.
422pub(crate) fn stage_anchors_removal(
423    backend: &dyn crate::backend::MemBackend,
424    entity_id: &EntityId,
425) -> Result<bool, EngineError> {
426    let mut sidecar = read_sidecar(backend)?;
427    if sidecar.get(entity_id.as_ref()).is_empty() {
428        return Ok(false);
429    }
430    sidecar.remove(entity_id.as_ref());
431    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
432    Ok(true)
433}
434
435/// Stage a move of `from`'s anchor row to `to` into the same commit as an
436/// entity rename — leaving zero rows under the old id. A no-op (byte-
437/// identical to today) when the renamed entity carries no anchors. Returns
438/// whether a write was staged.
439pub(crate) fn stage_anchors_rename(
440    backend: &dyn crate::backend::MemBackend,
441    from: &EntityId,
442    to: &EntityId,
443) -> Result<bool, EngineError> {
444    let mut sidecar = read_sidecar(backend)?;
445    if sidecar.get(from.as_ref()).is_empty() {
446        return Ok(false);
447    }
448    sidecar.rename(from.as_ref(), to.as_ref());
449    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
450    Ok(true)
451}
452
453// A `today_iso()` wall-clock convenience used to live here, for tests
454// comparing an auto-stamp against "roughly now". It is deliberately
455// gone: the stamp is second-resolution, so every such comparison races
456// the clock between the mutation and the assertion, and one of them
457// duly failed on a suite run that straddled midnight. Tests that need
458// a stamped value pin `Engine::set_mutation_clock` and derive the
459// expected string from the same instant via [`iso_from_system_time`].
460
461/// An instant as a full ISO-8601 datetime string `YYYY-MM-DDTHH:MM:SSZ`
462/// (UTC). Used by mutation paths that auto-stamp metadata fields
463/// (e.g. `last_modified` on update, `created_date` on create).
464///
465/// This is second-resolution (rather than
466/// date-only `YYYY-MM-DD`) so intra-day
467/// updates produce distinguishable timestamps and drift / staleness
468/// queries become per-update aware. The strict-mode date validator
469/// already accepts both forms (`^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$`)
470/// so existing entities written with the date-only form continue to
471/// load; new writes carry the wider form.
472///
473/// Pure function: no allocation outside the `format!` invocation,
474/// no error path (the fallback to UNIX epoch on an instant before
475/// the epoch is acceptable for a best-effort timestamp).
476/// Howard-Hinnant civil-from-days for the date half; trivial modular
477/// arithmetic for the time half.
478pub(super) fn iso_from_system_time(t: std::time::SystemTime) -> String {
479    let now = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
480    let secs = now.as_secs();
481    let days = secs / 86400;
482    let secs_of_day = secs % 86400;
483    let hh = secs_of_day / 3600;
484    let mm = (secs_of_day % 3600) / 60;
485    let ss = secs_of_day % 60;
486    let z = days + 719468;
487    let era = z / 146097;
488    let doe = z - era * 146097;
489    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
490    let y = yoe + era * 400;
491    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
492    let mp = (5 * doy + 2) / 153;
493    let d = doy - (153 * mp + 2) / 5 + 1;
494    let m = if mp < 10 { mp + 3 } else { mp - 9 };
495    let y = if m <= 2 { y + 1 } else { y };
496    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
497}
498
499/// Sweep stubs whose last incoming edge has just disappeared. Returns
500/// the dropped ids so callers can surface them to the agent (e.g. via
501/// [`DeleteEntityOutcome::orphan_stubs_removed`]).
502///
503/// Stubs are auto-created when a relate names an absent target — a
504/// "promise" that a real entity will land there later (see
505/// [`make_stub`]). When the last referrer drops its edge or is itself
506/// deleted, the promise has no holder and becomes pure bloat. Only
507/// stubs are eligible — real entities never count as orphans via this
508/// path.
509pub(super) fn gc_orphan_stubs(store: &mut Store) -> Vec<EntityId> {
510    let stub_ids: Vec<EntityId> = store
511        .all_entities()
512        .filter(|e| e.stub)
513        .map(|e| e.id.clone())
514        .collect();
515    gc_orphan_stubs_among(store, &stub_ids)
516}
517
518/// Scoped orphan-stub sweep: GC only the stubs *among `candidates`*
519/// whose last incoming edge has just disappeared, returning the dropped
520/// ids. This is the single home of the orphan-stub predicate (`stub &&
521/// no incoming`) — the three write paths that can sever a stub's last
522/// referrer all funnel through here so they cannot drift:
523/// [`gc_orphan_stubs`] (delete's full-store sweep) supplies every stub
524/// id; the `memstead_relate(remove)` path supplies the just-severed target;
525/// the `memstead_update` alias-resync path supplies the entity's
526/// pre-mutation body-link targets (the only edges that commit could
527/// have dropped). Scoping to a candidate set rather than walking the
528/// whole store keeps each path from GC'ing pre-existing orphans that
529/// aren't its responsibility. Candidates are de-duplicated; a candidate
530/// that is absent, not a stub, or still has a referrer is left
531/// untouched.
532pub(super) fn gc_orphan_stubs_among<'a>(
533    store: &mut Store,
534    candidates: impl IntoIterator<Item = &'a EntityId>,
535) -> Vec<EntityId> {
536    let mut removed: Vec<EntityId> = Vec::new();
537    let mut seen: std::collections::HashSet<&EntityId> = std::collections::HashSet::new();
538    for id in candidates {
539        if !seen.insert(id) {
540            continue;
541        }
542        if store.get(id).is_some_and(|e| e.stub) && store.incoming(id).is_empty() {
543            store.remove(id);
544            removed.push(id.clone());
545        }
546    }
547    removed
548}
549
550/// Shared target-id grammar validator. The wiki-link grammar gate
551/// runs on every relation-authoring path (`memstead_relate`,
552/// `memstead_create.relations[]`, future inline-relation surfaces) so a
553/// malformed target id (e.g. `bad@chars$here`) cannot land an
554/// auto-stub at the literal id — that stub would later fail every
555/// wiki-link parse that referenced it. Pre-Item-02 the gate lived
556/// only on `memstead_relate`; the create path admitted the same input
557/// silently.
558pub(super) fn validate_relation_target_grammar(target: &EntityId) -> Result<(), EngineError> {
559    if let Err(reason) = crate::entity::id::validate_mem_name_grammar(target.mem()) {
560        return Err(EngineError::InvalidEntityId {
561            id: target.to_string(),
562            reason,
563        });
564    }
565    if let Err(reason) = crate::entity::id::validate_id_path_grammar(target.path()) {
566        return Err(EngineError::InvalidEntityId {
567            id: target.to_string(),
568            reason,
569        });
570    }
571    Ok(())
572}
573
574/// Auto-stamp `auto_timestamp` metadata fields on an entity that's
575/// about to be re-written. Extracted from the update-path hot loop so
576/// the relate-path (add and remove) and the rename-path (the renaming
577/// entity plus every referrer the rewrite cascade touched) can
578/// invoke the same engine-driven stamp.
579///
580/// Walks the type's metadata-field declarations; any field flagged
581/// `auto_timestamp: true` (the default schema declares this on
582/// `last_modified`) is set to the supplied `today` ISO string. The
583/// helper is a no-op on schemas that declare no auto-timestamp
584/// fields. Callers pre-compute `today` via [`today_iso`] so a single
585/// mutation that touches multiple entities (rename's referrer rewrite
586/// cascade) stamps them all with the same value.
587pub(super) fn auto_stamp_timestamps(
588    entity: &mut Entity,
589    type_def: &memstead_schema::TypeDefinition,
590    today: &str,
591) {
592    for field_def in &type_def.metadata_fields {
593        if field_def.auto_timestamp {
594            entity.metadata.insert(
595                field_def.key.clone(),
596                crate::entity::MetadataValue::String(today.to_string()),
597            );
598        }
599    }
600}
601
602/// Build a stub [`Entity`] for an unresolved relate target. Callers
603/// declare the stub's origin via [`crate::entity::StubKind`] —
604/// `ForwardReference` for `memstead_relate` to an absent target,
605/// `Residual { since_commit, readonly_referrers }` for the
606/// delete/rename demote path. The kind persists for the engine
607/// instance's lifetime; a reload reduces every stub to `LoadTime`
608/// — the kind is annotation, not state.
609///
610/// The stub is in-store but unwritten to disk — `entity_type` empty,
611/// `file_path` empty, no metadata, no sections, `stub: true` and
612/// `stub_kind: Some(kind)` set together. A later
613/// [`Engine::create_entity`] at the same id promotes the stub to a
614/// real entity (loader / parse-result merge handles the upgrade
615/// path; `stub_kind` clears to `None`).
616pub(super) fn make_stub(id: &EntityId, kind: crate::entity::StubKind) -> Entity {
617    Entity {
618        id: id.clone(),
619        title: id.name().to_string(),
620        entity_type: String::new(),
621        mem: id.mem().to_string(),
622        file_path: String::new(),
623        metadata: IndexMap::new(),
624        sections: IndexMap::new(),
625        relationships: Vec::new(),
626        content_hash: String::new(),
627        stub: true,
628        stub_kind: Some(kind),
629        heading_spans: HashMap::new(),
630        raw_section_headings: Vec::new(),
631    }
632}
633
634/// Cross-mem add-path policy gate. Same-mem writes bypass; the
635/// `[cross_mem_links]` table only gates writes that cross the
636/// mem boundary. Cross-mem writes consult
637/// [`super::Engine::cross_mem_link_allowed`] in the edge's actual
638/// direction (`source_mem → target_mem`). Disallowed pairings
639/// surface [`EngineError::CrossMemLinkNotAllowed`] with the
640/// `(from_mem, to_mem)` payload an agent already sees on
641/// `memstead_relate`.
642///
643/// After the grant admits the pairing, a target absent from a
644/// `MountCapability::ReadOnly` mount refuses with
645/// [`EngineError::CrossMemTargetNotFound`]: the engine cannot
646/// persist a stub through the read-only boundary, and a read-only
647/// mem never gains the entity later — a missing target there is a
648/// wrong link, not a pending forward reference. Same-mem targets,
649/// cross-mem targets in Write mounts, and unmounted target mems all
650/// retain the auto-stub mechanic.
651///
652/// Funnel point for every add-shaped edge write — `memstead_relate`,
653/// `memstead_create.relations[]`, `memstead_update.declare_relations`,
654/// body-wiki-link alias synthesis, and any future add-path mutation
655/// surface route through one gate so the policy can't drift between
656/// sites. Remove-shaped writes (cleanup) remain permissive and call
657/// this helper not at all.
658pub(super) fn validate_cross_mem_add_policy(
659    engine: &super::Engine,
660    source_mem: &str,
661    target: &EntityId,
662) -> Result<(), EngineError> {
663    let target_mem = target.mem();
664    if source_mem == target_mem {
665        return Ok(());
666    }
667    if !engine.cross_mem_link_allowed(source_mem, target_mem) {
668        return Err(EngineError::CrossMemLinkNotAllowed {
669            from_mem: source_mem.to_string(),
670            to_mem: target_mem.to_string(),
671        });
672    }
673    if let Some(mount) = engine.mount(target_mem)
674        && mount.capability == crate::workspace::MountCapability::ReadOnly
675        && !engine.store.contains(target)
676    {
677        return Err(EngineError::CrossMemTargetNotFound {
678            target_id: target.to_string(),
679            target_mem: target_mem.to_string(),
680        });
681    }
682    Ok(())
683}
684
685/// Outcome of the engine's edge-validation router for a single
686/// inline / explicit relate. Carries the optional open-mode warning
687/// from the intra-mem flow; the cross-mem flow has no
688/// open-mode (cross-mem entries are declared vocabulary).
689pub(super) enum EdgeRouteOutcome {
690    Ok,
691    OpenModeWarning(Box<crate::ops::WarningHint>),
692}
693
694/// Run rel-type + shape validation for one edge, routing through
695/// intra-mem vocabulary or the source schema's
696/// `cross_mem_relationships:` section as appropriate.
697///
698/// The routing rule:
699/// when `source_mem != target_mem` AND the target mem's
700/// pinned schema differs from the source schema by name or by
701/// version, the source schema's `cross_mem_relationships:` entry
702/// for the target schema is the sole authority for both the
703/// vocabulary check (`INVALID_REL_TYPE`) and the shape check
704/// (`INVALID_REL_SHAPE`). If no matching entry exists, surface
705/// [`EngineError::CrossMemEdgeNotDeclared`].
706///
707/// Otherwise (same-mem, same-schema cross-mem, or target mem
708/// unmounted) the call falls through to the existing intra-mem
709/// validators — the same behaviour the intra-mem path always had.
710///
711/// `check_shape` mirrors the relate path's add-only shape posture:
712/// pass `false` to skip the shape check (currently only the
713/// `memstead_relate --remove` path). The vocabulary check still fires
714/// in that case, matching the intra-mem behaviour where
715/// `validate_rel_type` runs on both add and remove.
716// The nine parameters are one edge's full coordinates; a params struct
717// would restate the same fields at every call site without grouping
718// anything that travels together elsewhere.
719#[allow(clippy::too_many_arguments)]
720pub(super) fn route_edge_validation(
721    engine: &super::Engine,
722    rel_type: &str,
723    from_type: &str,
724    to_type: Option<&str>,
725    source_mem: &str,
726    target_mem: &str,
727    from_id: &EntityId,
728    to_id: &EntityId,
729    check_shape: bool,
730) -> Result<EdgeRouteOutcome, EngineError> {
731    use crate::runtime_validator::{
732        CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
733        validate_rel_type,
734    };
735    use memstead_schema::SchemaRef;
736
737    let source_schema = engine
738        .schemas
739        .get(source_mem)
740        .expect("schema present for every registered mount");
741
742    let target_schema_arc = if source_mem == target_mem {
743        None
744    } else {
745        engine.schemas.get(target_mem).cloned()
746    };
747    let target_schema_ref: Option<SchemaRef> = target_schema_arc.as_ref().map(|s| {
748        let (name, version) = s.id();
749        SchemaRef::new(name, version)
750    });
751    let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
752        (Some(target), (src_name, _)) => target.name != src_name,
753        (None, _) => false,
754    };
755
756    if cross_mem_different {
757        let target_ref = target_schema_ref
758            .as_ref()
759            .expect("target_schema_ref is Some when cross_mem_different");
760        if !check_shape {
761            // Cleanup posture: cross-mem remove stays permissive so
762            // pre-tightening edges remain droppable without first
763            // re-declaring them. Mirrors the intra-mem shape gate's
764            // add-only stance.
765            return Ok(EdgeRouteOutcome::Ok);
766        }
767        match validate_cross_mem_edge(
768            rel_type,
769            from_type,
770            to_type,
771            source_schema.as_ref(),
772            target_ref,
773        ) {
774            CrossMemRelCheck::Ok => Ok(EdgeRouteOutcome::Ok),
775            CrossMemRelCheck::EdgeNotDeclared => {
776                let (src_name, src_version) = source_schema.id();
777                Err(EngineError::CrossMemEdgeNotDeclared {
778                    source_schema: SchemaRef::new(src_name, src_version).as_display(),
779                    target_schema: target_ref.as_display(),
780                    rel_type: rel_type.to_string(),
781                    from_id: from_id.to_string(),
782                    to_id: to_id.to_string(),
783                })
784            }
785            CrossMemRelCheck::Invalid(v) => Err(EngineError::Validation(v)),
786        }
787    } else {
788        let warning_hint = match validate_rel_type(rel_type, source_schema.as_ref())? {
789            RelationshipCheck::Ok => None,
790            RelationshipCheck::OpenWarning(message) => {
791                Some(crate::ops::WarningHint::UndeclaredRelationshipOpen {
792                    rel_type: rel_type.to_string(),
793                    message,
794                })
795            }
796        };
797        if check_shape {
798            validate_rel_shape(rel_type, from_type, to_type, source_schema.as_ref())?;
799        }
800        Ok(match warning_hint {
801            Some(w) => EdgeRouteOutcome::OpenModeWarning(Box::new(w)),
802            None => EdgeRouteOutcome::Ok,
803        })
804    }
805}
806
807/// Cycle-family gate for one prospective edge — the single owner of
808/// both refusals, shared by every edge-writing verb (`memstead_relate`,
809/// `memstead_create.relations[]`, `memstead_update.declare_relations`, and the
810/// batch paths, which stage prior items' edges into `store` so an
811/// intra-batch cycle refuses like a stored one):
812///
813/// - **Self-loop on a listed no-self-loop rel-type.** `from == to` on
814///   any rel-type the source type lists in `no_self_loop_relationships`
815///   refuses, regardless of the `acyclic` flag — the declaration's one
816///   effect (see `TypeDefinition::no_self_loop_relationships`).
817/// - **Cycle on an acyclic rel-type.** An add closing a back-path
818///   `to → … → from` (via [`crate::graph::query::would_cycle`]) refuses
819///   with the existing path, capped at [`RELATIONSHIP_CYCLE_PATH_CAP`].
820///
821/// Both refuse [`EngineError::RelationshipCycle`] (`RELATIONSHIP_CYCLE`)
822/// with identical recovery detail on every path. Callers skip this on
823/// remove paths — removal can only break cycles, never close one.
824pub(super) fn validate_edge_acyclicity(
825    store: &Store,
826    schema: &memstead_schema::Schema,
827    from: &EntityId,
828    from_type: &str,
829    to: &EntityId,
830    rel_type: &str,
831) -> Result<(), EngineError> {
832    if from == to && schema.type_refuses_self_loop(from_type, rel_type) {
833        return Err(EngineError::RelationshipCycle {
834            rel_type: rel_type.to_string(),
835            from: from.clone(),
836            to: to.clone(),
837            existing_path: vec![from.clone()],
838            path_truncated: false,
839        });
840    }
841    if schema.relationship_acyclic(rel_type)
842        && let Some(path) = crate::graph::query::would_cycle(store, from, to, rel_type)
843    {
844        let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
845        let mut existing_path = path;
846        if truncated {
847            existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
848        }
849        return Err(EngineError::RelationshipCycle {
850            rel_type: rel_type.to_string(),
851            from: from.clone(),
852            to: to.clone(),
853            existing_path,
854            path_truncated: truncated,
855        });
856    }
857    Ok(())
858}
859
860/// Validate the per-edge description posture declared on the rel-type
861/// in the routing-appropriate definition (intra-mem when source and
862/// target share the schema; cross-mem entry when they don't). Emits
863/// `MissingRequiredDescription` / `DescriptionNotPermitted` on
864/// violations; `optional` and unknown rel-types are no-ops (the
865/// vocabulary / shape gates already catch undeclared names — posture
866/// only fires for declared names).
867///
868/// `description` is the normalised value (empty / whitespace-only
869/// collapses to `None` before reaching this gate). Called from every
870/// add path: `memstead_relate`, `declare_relations` on `memstead_create` and
871/// `memstead_update`.
872pub(super) fn validate_description_posture(
873    engine: &super::Engine,
874    rel_type: &str,
875    description: Option<&str>,
876    source_mem: &str,
877    target_mem: &str,
878    from_id: &EntityId,
879    to_id: &EntityId,
880) -> Result<(), EngineError> {
881    use memstead_schema::{PerEdgeDescription, SchemaRef};
882
883    let source_schema = engine
884        .schemas
885        .get(source_mem)
886        .expect("schema present for every registered mount");
887    let target_schema_arc = if source_mem == target_mem {
888        None
889    } else {
890        engine.schemas.get(target_mem).cloned()
891    };
892    let target_schema_ref: Option<SchemaRef> = target_schema_arc.as_ref().map(|s| {
893        let (name, version) = s.id();
894        SchemaRef::new(name, version)
895    });
896    let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
897        (Some(target), (src_name, _)) => target.name != src_name,
898        (None, _) => false,
899    };
900
901    let posture = if cross_mem_different {
902        // Look up the matching cross-mem entry's definition. If the
903        // entry exists but the rel-type isn't enumerated under it, the
904        // vocabulary gate (route_edge_validation) will surface
905        // `CROSS_MEM_EDGE_NOT_DECLARED`; posture is a no-op there.
906        let target_ref = target_schema_ref
907            .as_ref()
908            .expect("target_schema_ref is Some when cross_mem_different");
909        source_schema
910            .cross_mem_entries(&target_ref.name)
911            .iter()
912            .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
913            .map(|d| d.per_edge_description)
914    } else {
915        source_schema
916            .relationship_def(rel_type)
917            .map(|d| d.per_edge_description)
918    };
919
920    match posture {
921        Some(PerEdgeDescription::Required) if description.is_none() => {
922            Err(EngineError::MissingRequiredDescription {
923                rel_type: rel_type.to_string(),
924                from_id: from_id.to_string(),
925                to_id: to_id.to_string(),
926            })
927        }
928        Some(PerEdgeDescription::Forbidden) if description.is_some() => {
929            Err(EngineError::DescriptionNotPermitted {
930                rel_type: rel_type.to_string(),
931                from_id: from_id.to_string(),
932                to_id: to_id.to_string(),
933            })
934        }
935        _ => Ok(()),
936    }
937}
938
939/// Validate the manual-authoring posture declared on the rel-type.
940/// Fires only on explicit-author paths (`memstead_relate`, inline
941/// `relations:` on `memstead_create`, `declare_relations` on
942/// `memstead_update`). The body-link → relation alias machinery
943/// synthesises relations from wiki-links — that path bypasses this
944/// gate by construction (it never calls this function), keeping the
945/// alias path for `manual_authoring: forbidden` rel-types (e.g.
946/// REFERENCES) intact.
947pub(super) fn validate_manual_authoring_posture(
948    engine: &super::Engine,
949    rel_type: &str,
950    source_mem: &str,
951    from_id: &EntityId,
952    to_id: &EntityId,
953) -> Result<(), EngineError> {
954    use memstead_schema::ManualAuthoring;
955
956    let source_schema = engine
957        .schemas
958        .get(source_mem)
959        .expect("schema present for every registered mount");
960    let posture = source_schema.relationship_manual_authoring(rel_type);
961    if matches!(posture, ManualAuthoring::Forbidden) {
962        let guidance = source_schema
963            .relationship_when_to_use(rel_type)
964            .unwrap_or_default();
965        return Err(EngineError::RelationManualAuthoringForbidden {
966            rel_type: rel_type.to_string(),
967            from_id: from_id.to_string(),
968            to_id: to_id.to_string(),
969            guidance,
970        });
971    }
972    Ok(())
973}
974
975/// Alias-synthesis pass — populates `next.relationships` with engine-
976/// emitted relations of the source schema's `alias_target_rel_type`
977/// pointer for every body wiki-link not already backed by an
978/// in-section-body explicit relation. Runs before the
979/// `scan_wikilinks_without_relation` validator; after this pass the
980/// validator finds zero missing wiki-links for the pointer rel-type.
981///
982/// Three cases:
983/// 1. Schema has no pointer (`alias_target_rel_type` absent): no-op.
984///    Caller's validator continues to refuse unbacked links exactly as
985///    today.
986/// 2. Schema has a pointer, body wiki-link target is in the same mem
987///    OR cross-mem policy admits it: append `Relationship { rel_type:
988///    pointer, target, description: None }` to `next.relationships` if
989///    no relation of `(pointer, target)` is already present. Dedupe is
990///    `(target, rel_type)` — a USES or DEPENDS_ON edge to the same
991///    target does not suppress synthesis of the pointer rel-type.
992/// 3. Schema has a pointer but a body wiki-link crosses a mem
993///    boundary the workspace doesn't grant — or targets an entity
994///    absent from a read-only mount: return the funnel's typed
995///    refusal ([`EngineError::CrossMemLinkNotAllowed`] /
996///    [`EngineError::CrossMemTargetNotFound`], via
997///    [`validate_cross_mem_add_policy`]). The entire mutation
998///    aborts — no partial state.
999///
1000/// GC: when `prev` is `Some`, the pass also drops pointer-rel-type
1001/// relations whose target was a body wiki-link in `prev` but no longer
1002/// appears in `next.sections`. The loader forces `manual_authoring:
1003/// forbidden` on every schema's `alias_target_rel_type` pointer, so the
1004/// only path to a pointer-rel-type edge is the body-link channel; the
1005/// GC rule therefore reduces to "drop pointer-rel-type relations whose
1006/// target is not in the new body". Targeting prev's wiki-link set
1007/// specifically (rather than every pointer-rel-type relation) keeps the
1008/// pass correct even for an explicit-author relation that predates the
1009/// forbid posture.
1010///
1011/// Returns the list of relations the pass emitted (in body iteration
1012/// order) — `create.rs` / `update.rs` use it to surface
1013/// `relations_emitted` on the response envelope.
1014/// Returns the synthesised relations (in body iteration order) and a flag
1015/// signalling whether a body wiki-link to the entity's own id was dropped
1016/// (F11). The caller surfaces that as a `SELF_LINK_IGNORED` warning — the
1017/// pass has no warning channel of its own.
1018pub(super) fn synthesise_alias_relations(
1019    engine: &super::Engine,
1020    prev_body_targets: &std::collections::HashSet<EntityId>,
1021    next: &mut Entity,
1022) -> Result<(Vec<crate::entity::Relationship>, bool), super::EngineError> {
1023    let schema = engine
1024        .schemas
1025        .get(next.mem.as_str())
1026        .expect("schema present for every registered mount");
1027    let Some(pointer) = schema.alias_target_rel_type().map(str::to_string) else {
1028        return Ok((Vec::new(), false));
1029    };
1030
1031    // 1. GC: drop pointer-rel-type relations whose target was a body
1032    //    wiki-link in the prev entity state but isn't in next. Targets
1033    //    not in prev's wiki-link set are explicit-author relations and
1034    //    are never touched — the rule preserves explicit edges even
1035    //    while the 5 built-ins still admit explicit REFERENCES.
1036    //
1037    //    `extract_inline_links` is strict — non-slug-form targets refuse
1038    //    here with the typed `InvalidWikiLinkTarget` envelope rather
1039    //    than silently flowing into the GC's retain set as malformed
1040    //    EntityIds. Section context comes from the iteration key.
1041    let mut next_targets: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
1042    for (section_key, body) in next.sections.iter() {
1043        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1044            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1045        next_targets.extend(ids);
1046    }
1047    next.relationships.retain(|r| {
1048        !(r.rel_type == pointer
1049            && prev_body_targets.contains(&r.target)
1050            && !next_targets.contains(&r.target))
1051    });
1052
1053    // 2. Walk body wiki-links in section iteration order and append
1054    //    one relation per `(target, pointer)` pair not already
1055    //    present. Cross-mem gate fires on the first refusal.
1056    let existing: std::collections::HashSet<(String, EntityId)> = next
1057        .relationships
1058        .iter()
1059        .map(|r| (r.rel_type.clone(), r.target.clone()))
1060        .collect();
1061    let mut emitted: Vec<crate::entity::Relationship> = Vec::new();
1062    let mut already_synthesised: std::collections::HashSet<EntityId> =
1063        std::collections::HashSet::new();
1064    let mut self_link_ignored = false;
1065    for (section_key, body) in next.sections.iter() {
1066        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1067            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1068        for target in ids {
1069            // F11: a body wiki-link to the entity's own id is a vacuous
1070            // self-edge (renders as both Outgoing and Incoming, inflates
1071            // connectivity). Drop it — but don't refuse: the author may
1072            // have written their own slug. The caller surfaces
1073            // `SELF_LINK_IGNORED` so the dropped link stays observable.
1074            if target == next.id {
1075                self_link_ignored = true;
1076                continue;
1077            }
1078            let key = (pointer.clone(), target.clone());
1079            if existing.contains(&key) || already_synthesised.contains(&target) {
1080                continue;
1081            }
1082            validate_cross_mem_add_policy(engine, &next.mem, &target)?;
1083            let rel = crate::entity::Relationship::new(pointer.clone(), target.clone());
1084            next.relationships.push(rel.clone());
1085            already_synthesised.insert(target);
1086            emitted.push(rel);
1087        }
1088    }
1089    Ok((emitted, self_link_ignored))
1090}
1091
1092/// Map the first [`crate::entity::id::WikiLinkError`] from a body
1093/// wiki-link extraction into the typed [`EngineError`] envelope,
1094/// attaching the offending section's key. Errors after the first are
1095/// dropped — the agent reads the error, fixes the link, retries, and
1096/// surfaces the next one on the follow-up call. Keeps the envelope
1097/// shape stable (single typed payload rather than a list) so MCP /
1098/// CLI / UniFFI clients don't need a fan-out renderer.
1099pub(super) fn map_wiki_link_errors(
1100    section_key: &str,
1101    errors: Vec<crate::entity::id::WikiLinkError>,
1102) -> EngineError {
1103    use crate::entity::id::WikiLinkError;
1104    let first = errors
1105        .into_iter()
1106        .next()
1107        .expect("map_wiki_link_errors called with non-empty error list");
1108    match first {
1109        WikiLinkError::InvalidTarget {
1110            raw,
1111            suggested,
1112            reason,
1113        } => EngineError::InvalidWikiLinkTarget {
1114            raw,
1115            suggested,
1116            section: section_key.to_string(),
1117            link_source: "body_link".to_string(),
1118            reason,
1119        },
1120        WikiLinkError::InvalidMemName { raw, reason } => EngineError::InvalidWikiLinkMem {
1121            raw,
1122            section: section_key.to_string(),
1123            reason,
1124        },
1125    }
1126}
1127
1128/// Compute the set of body wiki-link targets in an entity. Used by
1129/// callers of `synthesise_alias_relations` to capture the pre-mutation
1130/// state once, before any borrow conflicts re-enter the engine's
1131/// schemas / store maps. Uses the lenient decoder — this snapshot
1132/// must tolerate on-disk drift on pre-strict entities whose bodies
1133/// may still contain non-conformant links; the strict gate fires
1134/// only on the post-mutation `next` state.
1135pub(super) fn collect_body_link_targets(entity: &Entity) -> std::collections::HashSet<EntityId> {
1136    entity
1137        .sections
1138        .iter()
1139        .flat_map(|(_, body)| {
1140            crate::entity::parser::extract_inline_links_lenient(body, &entity.mem)
1141        })
1142        .collect()
1143}
1144
1145/// Alias-existence invariant validator. Given the post-mutation entity
1146/// state, scan every section body for wiki-links whose target has no
1147/// corresponding explicit relation in `entity.relationships`. Returns
1148/// the list of `(section_key, target_id)` pairs that violate the
1149/// invariant — empty when the post-mutation state is clean.
1150///
1151/// Used by [`Engine::create_entity`] and [`Engine::update_entity`]
1152/// (and `batch_update`). The validator runs unconditionally — under
1153/// the alias model body wiki-links are foreign-key references on the
1154/// `## Relationships` table and every reference must be backed.
1155///
1156/// Sections from the auto-managed `## Relationships` heading are
1157/// not scanned (the engine generates them from the relations list
1158/// at write time; the parser keeps them out of
1159/// `entity.sections` so they never reach this function).
1160///
1161/// Reuses [`crate::entity::parser::extract_inline_links`] so the
1162/// lexical discipline (fenced-code masking, inline-code masking,
1163/// alias handling, cross-mem forms) matches every other validator
1164/// surface in the engine.
1165pub(super) fn scan_wikilinks_without_relation(
1166    next: &Entity,
1167) -> Result<Vec<(String, EntityId)>, EngineError> {
1168    let explicit_targets: std::collections::HashSet<EntityId> = next
1169        .relationships
1170        .iter()
1171        .map(|r| r.target.clone())
1172        .collect();
1173    let mut missing: Vec<(String, EntityId)> = Vec::new();
1174    for (section_key, body) in next.sections.iter() {
1175        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1176            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1177        for target in ids {
1178            // A self-targeting body link is intentionally unbacked: the
1179            // alias pass drops its (vacuous) self-edge (F11), so it has no
1180            // backing relation by design and must not trip the
1181            // unbacked-link refusal here.
1182            if target == next.id {
1183                continue;
1184            }
1185            if !explicit_targets.contains(&target)
1186                && !missing
1187                    .iter()
1188                    .any(|(k, t)| k == section_key && t == &target)
1189            {
1190                missing.push((section_key.clone(), target));
1191            }
1192        }
1193    }
1194    Ok(missing)
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199
1200    use tempfile::TempDir;
1201
1202    use crate::backend::MemBackend;
1203    use crate::engine::test_helpers::*;
1204    use crate::engine::{CreateEntityArgs, Engine, UpdateEntityArgs};
1205
1206    use crate::storage::FilesystemMemWriter;
1207    use crate::vcs::CommitContext;
1208
1209    use indexmap::IndexMap;
1210
1211    #[test]
1212    fn with_ctx_wrappers_delegate_to_explicit_forms() {
1213        // Each *_with_ctx wrapper bundles a CommitContext and
1214        // routes through the corresponding 4-arg method. Verify
1215        // create → update → rename → delete via the wrappers
1216        // observably mutate the store the same way the explicit
1217        // forms would.
1218        let tmp = TempDir::new().unwrap();
1219        let mem_dir = tmp.path().to_path_buf();
1220        let writer = FilesystemMemWriter::new(mem_dir.clone());
1221        let mut engine = Engine::from_mounts(vec![(
1222            folder_mount("specs", mem_dir),
1223            Box::new(writer) as Box<dyn MemBackend>,
1224        )])
1225        .unwrap();
1226        let ctx = CommitContext::internal();
1227
1228        // create_entity_with_ctx
1229        let create_args = CreateEntityArgs {
1230            anchors: Vec::new(),
1231            mem: "specs".to_string(),
1232            title: "Seed".to_string(),
1233            entity_type: "spec".to_string(),
1234            sections: IndexMap::from_iter([
1235                ("identity".to_string(), "seed identity".to_string()),
1236                ("purpose".to_string(), "seed purpose".to_string()),
1237            ]),
1238            metadata: IndexMap::new(),
1239            relations: Vec::new(),
1240            dry_run: false,
1241        };
1242        let created = engine.create_entity_with_ctx(create_args, &ctx).unwrap();
1243        assert_eq!(created.title, "Seed");
1244        assert!(engine.store().get(&created.id).is_some());
1245
1246        // update_entity_with_ctx
1247        let update_args = UpdateEntityArgs {
1248            anchors: Vec::new(),
1249            id: created.id.clone(),
1250            expected_hash: Some(created.content_hash.clone()),
1251            sections: IndexMap::from_iter([("identity".to_string(), "updated".to_string())]),
1252            append_sections: IndexMap::new(),
1253            patch_sections: IndexMap::new(),
1254            metadata: IndexMap::new(),
1255            metadata_unset: Vec::new(),
1256            dry_run: false,
1257            declare_relations: Vec::new(),
1258            relations_unset: Vec::new(),
1259            anchors_unset: Vec::new(),
1260        };
1261        let updated = engine.update_entity_with_ctx(update_args, &ctx).unwrap();
1262        assert!(
1263            !updated.commit_sha.is_empty()
1264                || (updated.modified_sections.replaced.is_empty()
1265                    && updated.modified_sections.appended.is_empty()
1266                    && updated.modified_sections.patched.is_empty())
1267        );
1268
1269        // rename_entity_with_ctx
1270        let renamed = engine
1271            .rename_entity_with_ctx(&created.id, "Renamed", &updated.content_hash, &ctx)
1272            .unwrap();
1273        assert_ne!(renamed.old_id, renamed.new_id);
1274        assert!(engine.store().get(&renamed.new_id).is_some());
1275
1276        // delete_entity_with_ctx
1277        let deleted = engine
1278            .delete_entity_with_ctx(&renamed.new_id, &renamed.content_hash, &ctx)
1279            .unwrap();
1280        assert_eq!(deleted.id, renamed.new_id);
1281        assert!(engine.store().get(&renamed.new_id).is_none());
1282    }
1283
1284    /// Minimal on-disk MemConfig pinning `default@1.0.0`, with an
1285    /// optional pre-set mutation stamp — the carrier the stamp path
1286    /// and the boot skew check both read.
1287    fn write_config(dir: &std::path::Path, stamp: Option<memstead_schema::MutationStamp>) {
1288        let meta = dir.join(memstead_schema::MEM_META_DIR);
1289        std::fs::create_dir_all(&meta).unwrap();
1290        let mut config: memstead_schema::MemConfig =
1291            serde_json::from_str(r#"{"schema": "default@1.0.0"}"#).unwrap();
1292        config.mutation_stamp = stamp;
1293        std::fs::write(
1294            meta.join("config.json"),
1295            serde_json::to_vec_pretty(&config).unwrap(),
1296        )
1297        .unwrap();
1298    }
1299
1300    fn stamped_engine_fixture(mem_dir: std::path::PathBuf) -> Engine {
1301        Engine::from_mounts(vec![(
1302            folder_mount("specs", mem_dir.clone()),
1303            Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>,
1304        )])
1305        .unwrap()
1306    }
1307
1308    fn disk_stamp(dir: &std::path::Path) -> Option<memstead_schema::MutationStamp> {
1309        let bytes =
1310            std::fs::read(dir.join(memstead_schema::MEM_META_DIR).join("config.json")).unwrap();
1311        let config: memstead_schema::MemConfig = serde_json::from_slice(&bytes).unwrap();
1312        config.mutation_stamp
1313    }
1314
1315    fn spec_create_args(title: &str) -> CreateEntityArgs {
1316        CreateEntityArgs {
1317            anchors: Vec::new(),
1318            mem: "specs".to_string(),
1319            title: title.to_string(),
1320            entity_type: "spec".to_string(),
1321            sections: IndexMap::from_iter([
1322                ("identity".to_string(), "seed identity".to_string()),
1323                ("purpose".to_string(), "seed purpose".to_string()),
1324            ]),
1325            metadata: IndexMap::new(),
1326            relations: Vec::new(),
1327            dry_run: false,
1328        }
1329    }
1330
1331    /// Criterion 3 (agent-trust plan 02): a mutation stamps the mem's
1332    /// engine-owned state with the running engine version and resolved
1333    /// schema; a read-only load writes nothing.
1334    #[test]
1335    fn mutation_writes_version_stamp_and_read_only_load_does_not() {
1336        let tmp = TempDir::new().unwrap();
1337        let mem_dir = tmp.path().to_path_buf();
1338        write_config(&mem_dir, None);
1339
1340        // Read-only session: boot and drop without mutating — the
1341        // stamp stays absent.
1342        drop(stamped_engine_fixture(mem_dir.clone()));
1343        assert!(
1344            disk_stamp(&mem_dir).is_none(),
1345            "a read-only load must not write a stamp"
1346        );
1347
1348        // A mutation stamps engine version + resolved schema.
1349        let mut engine = stamped_engine_fixture(mem_dir.clone());
1350        engine
1351            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
1352            .unwrap();
1353        let stamp = disk_stamp(&mem_dir).expect("mutation must write the stamp");
1354        assert_eq!(stamp.engine_version, crate::build_info::full_version());
1355        assert_eq!(stamp.schema, "default@1.0.0");
1356
1357        // A second mutation under the same binary leaves the stamp at
1358        // the same value (the write path compares and no-ops).
1359        let mut engine = stamped_engine_fixture(mem_dir.clone());
1360        engine
1361            .create_entity_with_ctx(spec_create_args("Second"), &CommitContext::internal())
1362            .unwrap();
1363        let again = disk_stamp(&mem_dir).expect("stamp survives");
1364        assert_eq!(again, stamp);
1365    }
1366
1367    /// Criterion 3/4 (agent-trust plan 02): boot under a different
1368    /// binary version surfaces the warn-tier `ENGINE_VERSION_SKEW`
1369    /// naming both versions, on load warnings AND in `health()`;
1370    /// a stamp-less mem and a matching stamp are silent.
1371    #[test]
1372    fn boot_skew_warning_fires_only_on_disagreeing_stamp() {
1373        use crate::ops::WarningHint;
1374
1375        // Disagreeing stamp → warning on boot and in health.
1376        let tmp = TempDir::new().unwrap();
1377        let mem_dir = tmp.path().to_path_buf();
1378        write_config(
1379            &mem_dir,
1380            Some(memstead_schema::MutationStamp {
1381                engine_version: "0.0.1".to_string(),
1382                schema: "default@1.0.0".to_string(),
1383            }),
1384        );
1385        let engine = stamped_engine_fixture(mem_dir);
1386        let skew: Vec<_> = engine
1387            .load_warnings()
1388            .iter()
1389            .filter(|w| matches!(w, WarningHint::EngineVersionSkew { .. }))
1390            .collect();
1391        assert_eq!(skew.len(), 1, "one skewed mem, one warning: {skew:?}");
1392        if let WarningHint::EngineVersionSkew {
1393            mem,
1394            stamped_engine,
1395            running_engine,
1396            stamped_schema,
1397        } = skew[0]
1398        {
1399            assert_eq!(mem, "specs");
1400            assert_eq!(stamped_engine, "0.0.1");
1401            assert_eq!(running_engine, crate::build_info::full_version());
1402            assert_eq!(stamped_schema, "default@1.0.0");
1403        }
1404        let health = engine.health();
1405        assert!(
1406            health
1407                .warnings
1408                .iter()
1409                .any(|w| w.code() == "ENGINE_VERSION_SKEW"),
1410            "health() must surface the skew without an include gate: {:?}",
1411            health.warnings,
1412        );
1413
1414        // Matching stamp → silent.
1415        let tmp = TempDir::new().unwrap();
1416        let mem_dir = tmp.path().to_path_buf();
1417        write_config(
1418            &mem_dir,
1419            Some(memstead_schema::MutationStamp {
1420                engine_version: crate::build_info::full_version().to_string(),
1421                schema: "default@1.0.0".to_string(),
1422            }),
1423        );
1424        let engine = stamped_engine_fixture(mem_dir);
1425        assert!(
1426            !engine
1427                .load_warnings()
1428                .iter()
1429                .any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
1430            "a matching stamp is not skew"
1431        );
1432
1433        // No stamp → silent (absence of a stamp is not skew).
1434        let tmp = TempDir::new().unwrap();
1435        let mem_dir = tmp.path().to_path_buf();
1436        write_config(&mem_dir, None);
1437        let engine = stamped_engine_fixture(mem_dir);
1438        assert!(
1439            !engine
1440                .load_warnings()
1441                .iter()
1442                .any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
1443            "a stamp-less (pre-plan) mem boots without warning noise"
1444        );
1445    }
1446}