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 retype;
26pub mod update;
27
28/// Look up an entity's `(title, entity_type)` pair in `store`. Both
29/// `None` for missing-from-store ids — matches full's `title_for` /
30/// `type_for` lossy-lookup contract. Used by [`Engine::changes_since`]
31/// to enrich id-only envelopes the backend returned with metadata
32/// from the in-memory store.
33pub(super) fn lookup_title_and_type(
34    store: &Store,
35    id: &EntityId,
36) -> (Option<String>, Option<String>) {
37    match store.get(id) {
38        Some(e) => (Some(e.title.clone()), Some(e.entity_type.clone())),
39        None => (None, None),
40    }
41}
42
43/// Maximum byte length of the truncated `current_content` snapshot
44/// that [`EngineError::PatchOldNotFound`] carries. Keeps the wire
45/// envelope bounded for sections with large bodies. Mirrors full's
46/// `memstead_git_branch::PATCH_OLD_NOT_FOUND_CONTENT_CAP`.
47pub const PATCH_OLD_NOT_FOUND_CONTENT_CAP: usize = 500;
48
49/// Maximum number of entity IDs retained in
50/// [`EngineError::RelationshipCycle::existing_path`]. Keeps the cycle
51/// envelope bounded for pathologically long chains. Mirrors full's
52/// `memstead_git_branch::RELATIONSHIP_CYCLE_PATH_CAP`.
53pub const RELATIONSHIP_CYCLE_PATH_CAP: usize = 20;
54
55/// Build an [`EngineError::UnknownType`] populated with the schema's
56/// declared type names (sorted) and a fuzzy suggestion. Mirrors full's
57/// `UnknownEntityType` recovery payload so MCP envelopes carry the
58/// same `name` / `schema_ref` / `declared` / `suggestion` keys
59/// regardless of which engine served the call.
60/// Render an entity for a write, refusing the one state the generator would
61/// silently make permanent.
62///
63/// **This is the mutation path's only way to bytes.** Calling
64/// `generate_markdown` directly from a mutation verb bypasses the guard, and
65/// the first version of this fix did exactly that: the check lived in
66/// `update_entity`, so `memstead relate` against the same entity walked
67/// straight past it and froze the absorption anyway (04/02, criterion 5,
68/// found by the plan's grade). A guard a new verb can miss by following the
69/// local idiom is not a guard; making the guarded call BE the idiom is.
70///
71/// The condition: a section whose stored body ends inside an unterminated
72/// fence has already absorbed every section after it, and the generator
73/// appends its closer AFTER those bytes. One write seals them inside a
74/// legitimately fenced block where nothing can tell them from prose the
75/// author meant to fence. It is refused rather than warned about because it
76/// is unrecoverable once it lands.
77///
78/// The way out needs no special case: a caller who replaces the absorbing
79/// section hands us an entity whose fence is closed, so the guard simply
80/// does not fire.
81pub(crate) fn render_for_write(
82    entity: &Entity,
83    type_def: &memstead_schema::TypeDefinition,
84) -> Result<String, EngineError> {
85    for (key, value) in &entity.sections {
86        if let Some(fence) = crate::markdown::closing_fence_if_unterminated(value.trim()) {
87            return Err(EngineError::UnterminatedFenceInStoredBody {
88                id: entity.id.to_string(),
89                section: key.clone(),
90                fence,
91                swallowed: crate::ops::integrity::swallowed_declared_sections(value, type_def),
92            });
93        }
94    }
95    Ok(crate::entity::generator::generate_markdown(
96        entity, type_def,
97    ))
98}
99
100pub(crate) fn unknown_type_error(schema: &memstead_schema::Schema, attempted: &str) -> EngineError {
101    let mut declared: Vec<String> = schema.types.keys().cloned().collect();
102    declared.sort();
103    let (sname, sver) = schema.id();
104    EngineError::UnknownType {
105        name: attempted.to_string(),
106        schema_ref: format!("{sname}@{sver}"),
107        declared,
108        suggestion: schema.suggest_type(attempted),
109    }
110}
111
112/// The lowercase wire string for a [`crate::pipeline::MediumType`] — the
113/// value the `INVALID_ANCHOR` recovery detail carries so an agent sees
114/// which medium's namespace rejected the grain. Matches the enum's
115/// `#[serde(rename_all = "lowercase")]` form.
116pub(crate) fn medium_type_wire(t: crate::pipeline::MediumType) -> &'static str {
117    use crate::pipeline::MediumType::*;
118    match t {
119        Codebase => "codebase",
120        Filesystem => "filesystem",
121        Graph => "graph",
122        Git => "git",
123        Web => "web",
124    }
125}
126
127impl super::Engine {
128    /// Resolve the single-source anchor-namespace context for `mem`, when
129    /// unambiguous. An `anchors[]` element carries no source name, so the
130    /// grain/namespace refusal ([`crate::anchor::AnchorValidationError::GrainNamespaceUnsupported`])
131    /// can only be fired deterministically when the mem's bindings declare
132    /// exactly one inline source; with zero or several the namespace check
133    /// is skipped (the vocabulary + hash-semantics rules still apply).
134    /// Returns the `(medium_type_wire, anchor_namespace)` pair the
135    /// validator consumes — the medium *half* of the lone source.
136    pub(crate) fn resolve_anchor_medium(&self, mem: &str) -> Option<(String, &'static str)> {
137        let mut sources = self
138            .pipeline_configs()
139            .bindings
140            .iter()
141            .filter(|r| r.mem == mem)
142            .flat_map(|r| r.config.sources.iter());
143        let first = sources.next()?;
144        if sources.next().is_some() {
145            // Ambiguous — the anchor does not name which source it targets;
146            // skip the namespace refinement rather than guess.
147            return None;
148        }
149        let caps = crate::binding::medium_capabilities(first.medium_type);
150        Some((
151            medium_type_wire(first.medium_type).to_string(),
152            caps.anchor_namespace,
153        ))
154    }
155
156    /// Validate the permissive `anchors[]` inputs for a mutation against
157    /// `mem`'s medium context into strict [`crate::anchor::Anchor`]s, or
158    /// refuse the whole mutation with a typed
159    /// [`EngineError::InvalidAnchor`]. Empty input yields an empty vec (no
160    /// sidecar write); a single malformed element aborts before any state
161    /// change so the entity is never written.
162    pub(crate) fn validate_anchor_inputs(
163        &self,
164        mem: &str,
165        inputs: &[crate::anchor::AnchorInput],
166    ) -> Result<Vec<crate::anchor::Anchor>, EngineError> {
167        if inputs.is_empty() {
168            return Ok(Vec::new());
169        }
170        let medium = self.resolve_anchor_medium(mem);
171        let medium_ref = medium.as_ref().map(|(t, ns)| (t.as_str(), *ns));
172        let mut anchors: Vec<crate::anchor::Anchor> = inputs
173            .iter()
174            .map(|i| i.validate(medium_ref).map_err(EngineError::from))
175            .collect::<Result<_, _>>()?;
176
177        // One payload, one row per triple (consistency-sweep 03/03,
178        // criterion 9). `(artifact, grain, class)` is the sidecar's merge
179        // identity, so a payload naming it twice used to collapse to the last
180        // occurrence and the caller was never told an anchor it wrote had
181        // vanished. The unit is THIS payload: the same triple arriving in a
182        // later call still replaces the stored row, which is what the
183        // carry-forward depends on.
184        {
185            let mut seen: std::collections::HashSet<(&str, &str, &str)> =
186                std::collections::HashSet::new();
187            for a in &anchors {
188                let key = (a.artifact.as_str(), a.grain.as_wire(), a.class.as_wire());
189                if !seen.insert(key) {
190                    return Err(EngineError::from(
191                        crate::anchor::AnchorValidationError::DuplicateAnchorTriple {
192                            artifact: a.artifact.clone(),
193                            grain: a.grain.as_wire(),
194                            class: a.class.as_wire(),
195                        },
196                    ));
197                }
198            }
199        }
200
201        // Supplied `content` under the source's preparation: the context-free
202        // validator hashed the bytes under the default canonicalization; the
203        // seam knows the anchor's source and re-hashes through the registry's
204        // rule for that source (touchpoint A at write time), so the recorded
205        // hash is the one a later observation computes.
206        if inputs.iter().any(|i| i.content.is_some()) {
207            let joins = self.anchor_source_roots(mem);
208            for (input, anchor) in inputs.iter().zip(anchors.iter_mut()) {
209                let (Some(content), Some(source)) = (&input.content, &anchor.source) else {
210                    continue;
211                };
212                let Some(join) = joins.get(source) else {
213                    continue;
214                };
215                if join.preparation.is_none() {
216                    continue;
217                }
218                match crate::preparation::path_prepared_hash(
219                    join.preparation.as_deref(),
220                    &anchor.artifact,
221                    anchor.grain,
222                    content.as_bytes(),
223                ) {
224                    crate::preparation::PathPrepared::Hash(h) => anchor.hash = Some(h),
225                    crate::preparation::PathPrepared::NoHash => {}
226                    crate::preparation::PathPrepared::UnitAbsent => {
227                        return Err(EngineError::from(
228                            crate::anchor::AnchorValidationError::UnitAbsentFromContent {
229                                artifact: anchor.artifact.clone(),
230                            },
231                        ));
232                    }
233                }
234            }
235        }
236
237        // Source-vs-binding check: when an anchor names BOTH a producing
238        // binding and a source, and that binding hash still resolves in
239        // this workspace (reverse lookup — any later binding edit moves
240        // the hash and drops earlier anchors into the accept-any-name
241        // branch for good), the source must be one of the binding's
242        // declared names. The bindings load is skipped entirely unless
243        // some input needs it, and a missing workspace root or an
244        // unloadable store degrades to accept-any-name — validation
245        // must never require the binding to resolve.
246        if anchors
247            .iter()
248            .any(|a| a.binding.is_some() && a.source.is_some())
249            && let Some(root) = self.workspace_root()
250            && let Ok(configs) = crate::pipeline_store::load_pipeline_configs(root)
251        {
252            for a in &anchors {
253                let (Some(binding_hash), Some(source)) = (&a.binding, &a.source) else {
254                    continue;
255                };
256                let Some(record) = configs
257                    .bindings
258                    .iter()
259                    .find(|r| crate::binding::hash_binding(&r.config) == *binding_hash)
260                else {
261                    continue; // unresolvable binding: accept any non-empty name
262                };
263                let declared: Vec<String> = record
264                    .config
265                    .sources
266                    .iter()
267                    .map(|s| s.name.clone())
268                    .collect();
269                if !declared.iter().any(|n| n == source) {
270                    return Err(EngineError::from(
271                        crate::anchor::AnchorValidationError::SourceNotDeclared {
272                            got: source.clone(),
273                            declared,
274                        },
275                    ));
276                }
277            }
278        }
279
280        // Write time resolves or refuses (decision 26, plan 03a): a
281        // path-grain anchor whose artifact resolves under NO candidate join
282        // is a silently dead reference — refuse now, while the write moment
283        // still has the binding context to say what the right dialect is.
284        // Candidates in decision-29 priority: the source-join (the anchor's
285        // `source` name resolved to its declared pointer) first, then the
286        // workspace-relative form. An anchor naming a source the LOADED
287        // roster does not declare (a typo, a renamed binding) gets no join
288        // candidate — its workspace-relative form must resolve or the write
289        // refuses, because resolution will make the same roster lookup and
290        // orphan it at birth. The gate skips entirely only when it cannot
291        // know: no workspace root, or a pipeline store whose LOAD fails as a
292        // whole — validation never requires the binding store to be
293        // readable. (A corrupted individual binding file does not fail the
294        // load; it yields a reduced roster, and the gate then fail-closes on
295        // paths that resolve under no surviving candidate.)
296        if let Some(root) = self.workspace_root()
297            && crate::pipeline_store::load_pipeline_configs(root).is_ok()
298        {
299            let source_roots = self.anchor_source_roots(mem);
300            for a in &anchors {
301                match a.grain {
302                    crate::anchor::AnchorGrain::Span
303                    | crate::anchor::AnchorGrain::File
304                    | crate::anchor::AnchorGrain::Tree => {}
305                    crate::anchor::AnchorGrain::Url | crate::anchor::AnchorGrain::Entity => {
306                        continue;
307                    }
308                }
309                let base = crate::engine::query::anchor_base_path(&a.artifact);
310                // The shared decision-29 candidate rule — the same set
311                // resolution will later read, so the gate cannot accept a
312                // path resolution would read differently.
313                let candidates: Vec<String> = match a
314                    .source
315                    .as_ref()
316                    .and_then(|source| source_roots.get(source))
317                {
318                    Some(join) => crate::engine::query::artifact_candidates(&join.pointer, base),
319                    None => vec![base.to_string()],
320                };
321                if !candidates.iter().any(|c| root.join(c).exists()) {
322                    return Err(EngineError::from(
323                        crate::anchor::AnchorValidationError::ArtifactUnresolvable {
324                            artifact: a.artifact.clone(),
325                            candidates,
326                        },
327                    ));
328                }
329            }
330        }
331
332        Ok(anchors)
333    }
334
335    /// Validate an update's `anchors_unset[]` payload up front — a
336    /// malformed selector (missing artifact, unknown grain/class wire
337    /// string) refuses the whole mutation with the same typed
338    /// `INVALID_ANCHOR` envelope as a malformed `anchors[]` element.
339    /// Empty payload → empty vec.
340    pub(crate) fn validate_anchor_unsets(
341        inputs: &[crate::anchor::AnchorUnsetInput],
342    ) -> Result<Vec<crate::anchor::AnchorUnset>, EngineError> {
343        inputs
344            .iter()
345            .map(|i| i.validate().map_err(EngineError::from))
346            .collect()
347    }
348
349    /// Record verify-observed prepared-content hashes onto **hash-less
350    /// hash-bearing** anchors in `mem_name`'s anchors sidecar — the
351    /// measurement-bookkeeping backfill the verify pass hands over via
352    /// [`crate::ingest::VerifyOutcome::hash_backfill`].
353    ///
354    /// This mutates **only** the engine-owned sidecar
355    /// ([`crate::anchor::ANCHOR_SIDECAR_PATH`]): no entity content, no
356    /// section, no `_hash` is touched — an anchor-only commit yields zero
357    /// entity deltas by construction. Guards enforced at this write seam,
358    /// not left to callers:
359    ///
360    /// - only a hash-bearing class (`anchored` / `derived`) may gain a hash —
361    ///   an `authored` / `informed-by` anchor is never written, whatever the
362    ///   caller observed;
363    /// - an anchor that already carries a hash is never overwritten — the
364    ///   recorded hash is the drift baseline, so the backfill is idempotent
365    ///   (a second identical call stages nothing and produces no commit).
366    ///
367    /// Returns how many anchors gained a hash. Zero writes ⇒ no commit.
368    pub fn record_anchor_observed_hashes(
369        &mut self,
370        mem_name: &str,
371        observed: &[crate::anchor::ObservedArtifactHash],
372        note: Option<&str>,
373    ) -> Result<usize, EngineError> {
374        if observed.is_empty() {
375            return Ok(0);
376        }
377        let mount_idx = self
378            .mounts
379            .iter()
380            .position(|m| m.mount.mem == mem_name)
381            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
382        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
383            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
384        }
385        // Same posture as every other commit-producing write: probe for
386        // sibling-engine drift so the sidecar merge runs against current truth.
387        let _warnings = self.reload_if_stale(Some(mem_name));
388
389        let backend = self.mounts[mount_idx].backend.as_ref();
390        let mut sidecar = read_sidecar(backend)?;
391        let mut written = 0usize;
392        for obs in observed {
393            let Some(anchors) = sidecar.entities.get_mut(&obs.entity) else {
394                continue;
395            };
396            for a in anchors {
397                if a.class.is_hash_bearing() && a.hash.is_none() && a.artifact == obs.artifact {
398                    a.hash = Some(obs.hash.clone());
399                    // Stamp the origin (consistency-sweep 03/03, criterion 8):
400                    // this baseline is the engine's inference from what it
401                    // observed, not something an author pinned, and a reader
402                    // comparing drift needs to know which.
403                    a.hash_source = Some(crate::anchor::AnchorHashSource::Backfill);
404                    written += 1;
405                }
406            }
407        }
408        if written == 0 {
409            return Ok(0);
410        }
411        backend.write_anchors_sidecar(&sidecar.to_bytes())?;
412        let ctx = crate::vcs::CommitContext {
413            actor: crate::vcs::Actor::Agent,
414            client: None,
415            tool: Some("record_anchor_observed_hashes"),
416            note: note.map(String::from),
417            role: self.current_role,
418            identity: self.current_identity.clone(),
419            logical_operation_id: None,
420            entity_ids: None,
421        };
422        let write_id = backend.commit(
423            &format!("memstead: anchor-hash backfill ({written} anchor(s))"),
424            &ctx,
425        )?;
426        self.record_self_write(mount_idx, &write_id);
427        // Anchor-hash backfill returns a count, not an agent-facing response,
428        // so an intervention report has nowhere to ride. Discarded knowingly:
429        // the merge itself still happened, so nothing was lost — only the
430        // notice that someone else had written (04/03, criterion 3).
431        let _intervention_has_no_channel_here = self.stamp_mutation_versions(mount_idx);
432        Ok(written)
433    }
434
435    /// Record observer-supplied observations onto `url` rows of `mem_name`'s
436    /// anchors sidecar as their `last_observed` record (sidecar version 2)
437    /// — the bookkeeping leg of `memstead verify-anchors --observations`.
438    /// Mutates only the engine-owned sidecar: no entity content, no `_hash`.
439    /// Guards at this seam:
440    ///
441    /// - only a `url` row is written: path and entity rows are observed live
442    ///   on every pass and never carry a recorded observation;
443    /// - a hash-bearing row that has no hash yet takes the observed hash as
444    ///   its baseline (`hash_source: backfill`), exactly as the path
445    ///   backfill does; a row that already carries a hash keeps it;
446    /// - a row that already carries the identical `last_observed` stages
447    ///   nothing, so re-running with the same file produces no commit.
448    ///
449    /// Returns how many rows changed. Zero writes ⇒ no commit.
450    pub fn record_anchor_observations(
451        &mut self,
452        mem_name: &str,
453        observations: &[crate::engine::query::RecordedObservation],
454        note: Option<&str>,
455    ) -> Result<usize, EngineError> {
456        if observations.is_empty() {
457            return Ok(0);
458        }
459        let mount_idx = self
460            .mounts
461            .iter()
462            .position(|m| m.mount.mem == mem_name)
463            .ok_or_else(|| self.unknown_mem_error(mem_name))?;
464        if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
465            return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
466        }
467        let _warnings = self.reload_if_stale(Some(mem_name));
468
469        let backend = self.mounts[mount_idx].backend.as_ref();
470        let mut sidecar = read_sidecar(backend)?;
471        let mut written = 0usize;
472        for obs in observations {
473            let Some(anchors) = sidecar.entities.get_mut(&obs.entity) else {
474                continue;
475            };
476            for a in anchors {
477                if a.grain != crate::anchor::AnchorGrain::Url || a.artifact != obs.artifact {
478                    continue;
479                }
480                let mut changed = false;
481                if a.last_observed.as_ref() != Some(&obs.observation) {
482                    a.last_observed = Some(obs.observation.clone());
483                    changed = true;
484                }
485                if a.class.is_hash_bearing()
486                    && a.hash.is_none()
487                    && let Some(hash) = &obs.observation.hash
488                {
489                    a.hash = Some(hash.clone());
490                    a.hash_source = Some(crate::anchor::AnchorHashSource::Backfill);
491                    changed = true;
492                }
493                if changed {
494                    written += 1;
495                }
496            }
497        }
498        if written == 0 {
499            return Ok(0);
500        }
501        backend.write_anchors_sidecar(&sidecar.to_bytes())?;
502        let ctx = crate::vcs::CommitContext {
503            actor: crate::vcs::Actor::Agent,
504            client: None,
505            tool: Some("record_anchor_observations"),
506            note: note.map(String::from),
507            role: self.current_role,
508            identity: self.current_identity.clone(),
509            logical_operation_id: None,
510            entity_ids: None,
511        };
512        let write_id = backend.commit(
513            &format!("memstead: anchor observations recorded ({written} row(s))"),
514            &ctx,
515        )?;
516        self.record_self_write(mount_idx, &write_id);
517        let _intervention_has_no_channel_here = self.stamp_mutation_versions(mount_idx);
518        Ok(written)
519    }
520
521    /// Record on the mem which engine version and which resolved
522    /// schema performed the mutation that just committed
523    /// (`MemConfig.mutation_stamp`). Called by every mutation verb
524    /// after `record_self_write` — one shared implementation, per the
525    /// one-guard-on-all-write-paths principle — and deliberately NOT
526    /// by `apply_external_commit`: a replayed sibling commit was
527    /// stamped by the engine that performed it, and this engine must
528    /// not claim it.
529    ///
530    /// Write-cheap by construction: the config write happens only when
531    /// the stamp VALUE changes (a binary upgrade or a schema repin) —
532    /// steady-state mutations compare and return. The write is
533    /// best-effort: a failed stamp never fails the mutation that
534    /// preceded it. On git-branch backends the config rides the
535    /// `__MEMSTEAD` ref, so a stamp write never moves the mem branch
536    /// head — so a git-branch mem's change cursor (the `head` a prior
537    /// `changes_since` returned) stays valid across a stamp write.
538    pub(crate) fn stamp_mutation_versions(
539        &mut self,
540        mount_idx: usize,
541    ) -> Vec<crate::ops::WarningHint> {
542        let Some(state) = self.mounts.get(mount_idx) else {
543            return Vec::new();
544        };
545        let mem = state.mount.mem.clone();
546        let Some(schema) = self.schemas.get(&mem) else {
547            return Vec::new();
548        };
549        let (name, version) = schema.id();
550        // Full build version (semver + git build sha when present) so
551        // a rebuild between mutations is a recordable — and hence
552        // skew-detectable — event even between releases.
553        let stamp = memstead_schema::MutationStamp {
554            engine_version: crate::build_info::full_version().to_string(),
555            schema: format!("{name}@{version}"),
556        };
557        // A mem with no loaded config has nowhere to carry the stamp;
558        // skip silently (in-memory sketches, minimal fixtures).
559        let Some(config) = self
560            .mounts
561            .get(mount_idx)
562            .and_then(|s| s.mem_config.as_ref())
563        else {
564            return Vec::new();
565        };
566        // Skew at WRITE time, and before the restamp below erases the evidence
567        // (04/04, criterion 9). Boot-only detection meant a long-lived server
568        // that started under one binary and was written to by another never
569        // said so, and the very write that would have revealed it wrote the
570        // stamp that hid it. The write is never refused (criterion 10): a
571        // deliberate downgrade is the operator's business.
572        let mut warnings = Vec::new();
573        if let Some(prior) = config.mutation_stamp.as_ref()
574            && let Some(direction) = crate::build_info::skew_direction(
575                &prior.engine_version,
576                crate::build_info::full_version(),
577            )
578        {
579            warnings.push(crate::ops::WarningHint::EngineVersionSkew {
580                mem: mem.clone(),
581                stamped_engine: prior.engine_version.clone(),
582                running_engine: crate::build_info::full_version().to_string(),
583                stamped_schema: prior.schema.clone(),
584                direction,
585            });
586        }
587        if config.mutation_stamp.as_ref() == Some(&stamp) {
588            return warnings;
589        }
590        // Through the shared writer like the seven lifecycle setters
591        // (04/03, criterion 7). This one is why the damage looked
592        // spontaneous: it rides ordinary create/update/relate/rename/delete,
593        // so an operator saw a config field vanish during an innocuous entity
594        // write with no lifecycle call in sight. It stays exactly as dormant
595        // as before, because the equality guard above still decides whether
596        // to write at all; what changed is only what it writes over.
597        // The intervention rides the ENTITY mutation's own response: this
598        // writer has no response of its own, and the operator who sees a
599        // config field move during an innocuous entity write is owed the
600        // reason there (04/03, criterion 3, found by the plan's grade —
601        // an earlier draft discarded this with `let _`).
602        match self.write_mem_config_merged(
603            mount_idx,
604            &mem,
605            Some("engine version stamp"),
606            &move |c: &mut memstead_schema::config::MemConfig| {
607                c.mutation_stamp = Some(stamp.clone());
608            },
609        ) {
610            Ok((_, intervened)) if !intervened.is_empty() => {
611                warnings.push(crate::ops::WarningHint::ConfigWriteIntervened {
612                    mem,
613                    fields: intervened,
614                });
615                warnings
616            }
617            _ => warnings,
618        }
619    }
620}
621
622/// Stage a write of `entity_id`'s anchors into the mem's anchors sidecar
623/// through `backend`, merged over the existing sidecar at BOTH levels —
624/// other entities' rows survive (document level), and within the entity's
625/// own row `unsets` apply first, then each incoming anchor replaces the
626/// existing anchor with the same `(artifact, grain, class)` triple or
627/// appends ([`crate::anchor::AnchorSidecar::merge`]). Writing never
628/// removes an anchor the call did not name in `unsets`. The write is
629/// buffered into the SAME pending op set the entity write used — so the
630/// next [`crate::backend::MemBackend::commit`] carries entity + anchors
631/// as one atomic commit. Reads honour pending-buffer precedence, so
632/// successive stages within one transaction compose.
633/// Stage a mutation of the engine-owned derivations sidecar
634/// (agent-trust plan 12) so it rides the SAME commit as the edge
635/// write that produced it — the anchors-sidecar atomicity precedent.
636/// The sidecar travels through the backend's normal entity-path
637/// read/write under `.memstead/`, which every backend filters from
638/// entity listings and every archive/export path carries as-is.
639pub(crate) fn stage_derivation_sidecar(
640    backend: &dyn crate::backend::MemBackend,
641    mutate: impl FnOnce(&mut crate::derivation::DerivationSidecar),
642) -> Result<(), EngineError> {
643    let path = std::path::Path::new(crate::derivation::DERIVATION_SIDECAR_PATH);
644    let mut sidecar = match backend.read_entity(path)? {
645        Some(bytes) => crate::derivation::DerivationSidecar::from_bytes(&bytes).map_err(|e| {
646            EngineError::Backend(crate::backend::BackendError::Other(format!(
647                "derivations sidecar parse: {e}"
648            )))
649        })?,
650        None => crate::derivation::DerivationSidecar::default(),
651    };
652    mutate(&mut sidecar);
653    backend.write_entity(path, &sidecar.to_bytes())?;
654    Ok(())
655}
656
657/// True when `schema` declares `rel_type` as a derivation
658/// (`derivation: true` on the relationship definition) — the
659/// predicate every write path shares, so baseline recording cannot
660/// fork per verb.
661pub(crate) fn rel_type_declares_derivation(
662    schema: &memstead_schema::Schema,
663    rel_type: &str,
664) -> bool {
665    schema
666        .manifest
667        .relationships
668        .definitions
669        .iter()
670        .any(|d| d.name == rel_type && d.derivation)
671}
672
673pub(crate) fn stage_anchors_sidecar(
674    backend: &dyn crate::backend::MemBackend,
675    entity_id: &EntityId,
676    unsets: &[crate::anchor::AnchorUnset],
677    anchors: Vec<crate::anchor::Anchor>,
678    rebaseline: bool,
679) -> Result<bool, EngineError> {
680    let mut sidecar = read_sidecar(backend)?;
681    // An anchors-only update whose rows restate the stored ones writes
682    // nothing: the sidecar bytes stay, and the caller is told the anchors
683    // did not change (backlog-decisions plan B10). `rebaseline` marks the
684    // update that also changed the entity's content, where a restated row
685    // is rewritten hash-less for the next verify to backfill.
686    if !sidecar.merge(entity_id.as_ref(), unsets, anchors, rebaseline) {
687        return Ok(false);
688    }
689    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
690    Ok(true)
691}
692
693/// Whether merging `anchors` / `unsets` into `entity_id`'s row would change
694/// the mem's sidecar, computed on a copy before anything is written, so
695/// the update path can answer truthfully (and skip the commit) when an
696/// anchors-only update restates what is stored.
697pub(crate) fn anchors_would_change(
698    backend: &dyn crate::backend::MemBackend,
699    entity_id: &EntityId,
700    unsets: &[crate::anchor::AnchorUnset],
701    anchors: &[crate::anchor::Anchor],
702    rebaseline: bool,
703) -> Result<bool, EngineError> {
704    let mut sidecar = read_sidecar(backend)?;
705    Ok(sidecar.merge(entity_id.as_ref(), unsets, anchors.to_vec(), rebaseline))
706}
707
708/// Load the mem's anchors sidecar through `backend`, or the empty
709/// document when none exists yet. Shared by the delete / rename legs
710/// which must decide whether the entity actually has anchor rows before
711/// staging a sidecar write (so an entity with none stays byte-identical
712/// to a pre-anchor mutation).
713fn read_sidecar(
714    backend: &dyn crate::backend::MemBackend,
715) -> Result<crate::anchor::AnchorSidecar, EngineError> {
716    match backend.read_anchors_sidecar()? {
717        Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
718            EngineError::Backend(crate::backend::BackendError::Other(format!(
719                "anchors sidecar parse: {e}"
720            )))
721        }),
722        None => Ok(crate::anchor::AnchorSidecar::default()),
723    }
724}
725
726/// Stage removal of `entity_id`'s anchor row into the same commit as an
727/// entity delete — a no-op (no sidecar write, so byte-identical to today)
728/// when the entity carries no anchors. Returns whether a write was staged.
729pub(crate) fn stage_anchors_removal(
730    backend: &dyn crate::backend::MemBackend,
731    entity_id: &EntityId,
732) -> Result<bool, EngineError> {
733    let mut sidecar = read_sidecar(backend)?;
734    if sidecar.get(entity_id.as_ref()).is_empty() {
735        return Ok(false);
736    }
737    sidecar.remove(entity_id.as_ref());
738    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
739    Ok(true)
740}
741
742/// Stage a move of `from`'s anchor row to `to` into the same commit as an
743/// entity rename — leaving zero rows under the old id. A no-op (byte-
744/// identical to today) when the renamed entity carries no anchors. Returns
745/// whether a write was staged.
746pub(crate) fn stage_anchors_rename(
747    backend: &dyn crate::backend::MemBackend,
748    from: &EntityId,
749    to: &EntityId,
750) -> Result<bool, EngineError> {
751    let mut sidecar = read_sidecar(backend)?;
752    if sidecar.get(from.as_ref()).is_empty() {
753        return Ok(false);
754    }
755    sidecar.rename(from.as_ref(), to.as_ref());
756    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
757    Ok(true)
758}
759
760// A `today_iso()` wall-clock convenience used to live here, for tests
761// comparing an auto-stamp against "roughly now". It is deliberately
762// gone: the stamp is second-resolution, so every such comparison races
763// the clock between the mutation and the assertion, and one of them
764// duly failed on a suite run that straddled midnight. Tests that need
765// a stamped value pin `Engine::set_mutation_clock` and derive the
766// expected string from the same instant via [`iso_from_system_time`].
767
768/// An instant as a full ISO-8601 datetime string `YYYY-MM-DDTHH:MM:SSZ`
769/// (UTC). Used by mutation paths that auto-stamp metadata fields
770/// (e.g. `last_modified` on update, `created_date` on create).
771///
772/// This is second-resolution (rather than
773/// date-only `YYYY-MM-DD`) so intra-day
774/// updates produce distinguishable timestamps and drift / staleness
775/// queries become per-update aware. The strict-mode date validator
776/// already accepts both forms (`^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$`)
777/// so existing entities written with the date-only form continue to
778/// load; new writes carry the wider form.
779///
780/// Pure function: no allocation outside the `format!` invocation,
781/// no error path (the fallback to UNIX epoch on an instant before
782/// the epoch is acceptable for a best-effort timestamp).
783/// Howard-Hinnant civil-from-days for the date half; trivial modular
784/// arithmetic for the time half.
785/// The current wall-clock time as the second-granularity ISO form every
786/// stamping path writes — for callers outside an engine (a CLI validating
787/// input before boot). Engine code uses `Engine::now_iso`, which honours a
788/// pinned test clock.
789pub fn iso_now() -> String {
790    iso_from_system_time(wall_clock_now())
791}
792
793/// The wall clock as a `SystemTime`, on every target. `SystemTime::now()`
794/// is unimplemented on `wasm32-unknown-unknown` — it traps with
795/// `RuntimeError: unreachable` and poisons the instance — so the wasm
796/// build derives the instant from the JS-backed clock instead
797/// (`UNIX_EPOCH` plus the millisecond offset; `SystemTime` arithmetic is
798/// implemented there, only `now()` is not). This is the engine's default
799/// mutation clock and the one every unpinned stamping or adjudication
800/// path reads; a health read that verifies anchors reaches it too, which
801/// is how the wasm health surface came to trap once the stale axis
802/// deferred to anchor state.
803pub fn wall_clock_now() -> std::time::SystemTime {
804    #[cfg(target_arch = "wasm32")]
805    {
806        std::time::UNIX_EPOCH + std::time::Duration::from_millis(js_sys::Date::now() as u64)
807    }
808    #[cfg(not(target_arch = "wasm32"))]
809    {
810        std::time::SystemTime::now()
811    }
812}
813
814pub(super) fn iso_from_system_time(t: std::time::SystemTime) -> String {
815    let now = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
816    let secs = now.as_secs();
817    let days = secs / 86400;
818    let secs_of_day = secs % 86400;
819    let hh = secs_of_day / 3600;
820    let mm = (secs_of_day % 3600) / 60;
821    let ss = secs_of_day % 60;
822    let z = days + 719468;
823    let era = z / 146097;
824    let doe = z - era * 146097;
825    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
826    let y = yoe + era * 400;
827    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
828    let mp = (5 * doy + 2) / 153;
829    let d = doy - (153 * mp + 2) / 5 + 1;
830    let m = if mp < 10 { mp + 3 } else { mp - 9 };
831    let y = if m <= 2 { y + 1 } else { y };
832    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
833}
834
835/// Sweep stubs whose last incoming edge has just disappeared. Returns
836/// the dropped ids so callers can surface them to the agent (e.g. via
837/// [`DeleteEntityOutcome::orphan_stubs_removed`]).
838///
839/// Stubs are auto-created when a relate names an absent target — a
840/// "promise" that a real entity will land there later (see
841/// [`make_stub`]). When the last referrer drops its edge or is itself
842/// deleted, the promise has no holder and becomes pure bloat. Only
843/// stubs are eligible — real entities never count as orphans via this
844/// path.
845pub(super) fn gc_orphan_stubs(store: &mut Store) -> Vec<EntityId> {
846    let stub_ids: Vec<EntityId> = store
847        .all_entities()
848        .filter(|e| e.stub)
849        .map(|e| e.id.clone())
850        .collect();
851    gc_orphan_stubs_among(store, &stub_ids)
852}
853
854/// Scoped orphan-stub sweep: GC only the stubs *among `candidates`*
855/// whose last incoming edge has just disappeared, returning the dropped
856/// ids. This is the single home of the orphan-stub predicate (`stub &&
857/// no incoming`) — the three write paths that can sever a stub's last
858/// referrer all funnel through here so they cannot drift:
859/// [`gc_orphan_stubs`] (delete's full-store sweep) supplies every stub
860/// id; the `memstead_relate(remove)` path supplies the just-severed target;
861/// the `memstead_update` alias-resync path supplies the entity's
862/// pre-mutation body-link targets (the only edges that commit could
863/// have dropped). Scoping to a candidate set rather than walking the
864/// whole store keeps each path from GC'ing pre-existing orphans that
865/// aren't its responsibility. Candidates are de-duplicated; a candidate
866/// that is absent, not a stub, or still has a referrer is left
867/// untouched.
868pub(super) fn gc_orphan_stubs_among<'a>(
869    store: &mut Store,
870    candidates: impl IntoIterator<Item = &'a EntityId>,
871) -> Vec<EntityId> {
872    let mut removed: Vec<EntityId> = Vec::new();
873    let mut seen: std::collections::HashSet<&EntityId> = std::collections::HashSet::new();
874    for id in candidates {
875        if !seen.insert(id) {
876            continue;
877        }
878        if store.get(id).is_some_and(|e| e.stub) && store.incoming(id).is_empty() {
879            store.remove(id);
880            removed.push(id.clone());
881        }
882    }
883    removed
884}
885
886/// Shared target-id grammar validator. The wiki-link grammar gate
887/// runs on every relation-authoring path (`memstead_relate`,
888/// `memstead_create.relations[]`, future inline-relation surfaces) so a
889/// malformed target id (e.g. `bad@chars$here`) cannot land an
890/// auto-stub at the literal id — that stub would later fail every
891/// wiki-link parse that referenced it. Pre-Item-02 the gate lived
892/// only on `memstead_relate`; the create path admitted the same input
893/// silently.
894pub(super) fn validate_relation_target_grammar(target: &EntityId) -> Result<(), EngineError> {
895    if let Err(reason) = crate::entity::id::validate_mem_name_grammar(target.mem()) {
896        return Err(EngineError::InvalidEntityId {
897            id: target.to_string(),
898            reason,
899        });
900    }
901    if let Err(reason) = crate::entity::id::validate_id_path_grammar(target.path()) {
902        return Err(EngineError::InvalidEntityId {
903            id: target.to_string(),
904            reason,
905        });
906    }
907    Ok(())
908}
909
910/// Auto-stamp `auto_timestamp` metadata fields on an entity that's
911/// about to be re-written. Extracted from the update-path hot loop so
912/// the relate-path (add and remove) and the rename-path (the renaming
913/// entity plus every referrer the rewrite cascade touched) can
914/// invoke the same engine-driven stamp.
915///
916/// Walks the type's metadata-field declarations; any field flagged
917/// `auto_timestamp: true` (the default schema declares this on
918/// `last_modified`) is set to the supplied `today` ISO string. The
919/// helper is a no-op on schemas that declare no auto-timestamp
920/// fields. Callers pre-compute `today` via [`today_iso`] so a single
921/// mutation that touches multiple entities (rename's referrer rewrite
922/// cascade) stamps them all with the same value.
923pub(super) fn auto_stamp_timestamps(
924    entity: &mut Entity,
925    type_def: &memstead_schema::TypeDefinition,
926    today: &str,
927) {
928    for field_def in &type_def.metadata_fields {
929        if field_def.auto_timestamp {
930            entity.metadata.insert(
931                field_def.key.clone(),
932                crate::entity::MetadataValue::String(today.to_string()),
933            );
934        }
935    }
936}
937
938/// Build a stub [`Entity`] for an unresolved relate target. Callers
939/// declare the stub's origin via [`crate::entity::StubKind`] —
940/// `ForwardReference` for `memstead_relate` to an absent target,
941/// `Residual { since_commit, readonly_referrers }` for the
942/// delete/rename demote path. The kind persists for the engine
943/// instance's lifetime; a reload reduces every stub to `LoadTime`
944/// — the kind is annotation, not state.
945///
946/// The stub is in-store but unwritten to disk — `entity_type` empty,
947/// `file_path` empty, no metadata, no sections, `stub: true` and
948/// `stub_kind: Some(kind)` set together. A later
949/// [`Engine::create_entity`] at the same id promotes the stub to a
950/// real entity (loader / parse-result merge handles the upgrade
951/// path; `stub_kind` clears to `None`).
952pub(super) fn make_stub(id: &EntityId, kind: crate::entity::StubKind) -> Entity {
953    Entity {
954        id: id.clone(),
955        title: id.name().to_string(),
956        entity_type: String::new(),
957        mem: id.mem().to_string(),
958        file_path: String::new(),
959        metadata: IndexMap::new(),
960        sections: IndexMap::new(),
961        relationships: Vec::new(),
962        content_hash: String::new(),
963        stub: true,
964        stub_kind: Some(kind),
965        heading_spans: HashMap::new(),
966        raw_section_headings: Vec::new(),
967    }
968}
969
970/// Cross-mem add-path policy gate. Same-mem writes bypass; the
971/// `[cross_mem_links]` table only gates writes that cross the
972/// mem boundary. Cross-mem writes consult
973/// [`super::Engine::cross_mem_link_allowed`] in the edge's actual
974/// direction (`source_mem → target_mem`). Disallowed pairings
975/// surface [`EngineError::CrossMemLinkNotAllowed`] with the
976/// `(from_mem, to_mem)` payload an agent already sees on
977/// `memstead_relate`.
978///
979/// After the grant admits the pairing, a target absent from a
980/// `MountCapability::ReadOnly` mount refuses with
981/// [`EngineError::CrossMemTargetNotFound`]: the engine cannot
982/// persist a stub through the read-only boundary, and a read-only
983/// mem never gains the entity later — a missing target there is a
984/// wrong link, not a pending forward reference. Same-mem targets,
985/// cross-mem targets in Write mounts, and unmounted target mems all
986/// retain the auto-stub mechanic.
987///
988/// Funnel point for every add-shaped edge write — `memstead_relate`,
989/// `memstead_create.relations[]`, `memstead_update.declare_relations`,
990/// body-wiki-link alias synthesis, and any future add-path mutation
991/// surface route through one gate so the policy can't drift between
992/// sites. Remove-shaped writes (cleanup) remain permissive and call
993/// this helper not at all.
994pub(super) fn validate_cross_mem_add_policy(
995    engine: &super::Engine,
996    source_mem: &str,
997    target: &EntityId,
998) -> Result<(), EngineError> {
999    let target_mem = target.mem();
1000    if source_mem == target_mem {
1001        return Ok(());
1002    }
1003    if !engine.cross_mem_link_allowed(source_mem, target_mem) {
1004        return Err(EngineError::CrossMemLinkNotAllowed {
1005            from_mem: source_mem.to_string(),
1006            to_mem: target_mem.to_string(),
1007        });
1008    }
1009    if let Some(mount) = engine.mount(target_mem)
1010        && mount.capability == crate::workspace::MountCapability::ReadOnly
1011        && !engine.store.contains(target)
1012        && !matches!(
1013            probe_deferred_target(engine, target)?,
1014            DeferredTargetProbe::Exists
1015        )
1016    {
1017        return Err(EngineError::CrossMemTargetNotFound {
1018            target_id: target.to_string(),
1019            target_mem: target_mem.to_string(),
1020        });
1021    }
1022    Ok(())
1023}
1024
1025/// Storage verdict for a cross-mem target whose mem is mounted but
1026/// DEFERRED (lazy, not yet loaded) — the write-time verification of
1027/// flywheel W7/02. The check asks the mem's real storage through the
1028/// cheap [`crate::backend::MemBackend::entity_exists`] probe
1029/// (tree-lookup-class on git-branch, metadata-class on folder) and
1030/// never triggers the mem's load: verification must not convert into
1031/// a full-load side effect (plan 01's seam).
1032pub(super) enum DeferredTargetProbe {
1033    /// The target's mem is loaded (the store is the truth) or not
1034    /// mounted at all (no storage handle to ask — the
1035    /// forward-reference mechanic governs).
1036    NotApplicable,
1037    /// Storage holds the entity: the reference is verified against
1038    /// real storage even though the mem is unloaded.
1039    Exists,
1040    /// Storage answers and the entity is not there.
1041    Absent,
1042}
1043
1044pub(super) fn probe_deferred_target(
1045    engine: &super::Engine,
1046    target: &EntityId,
1047) -> Result<DeferredTargetProbe, EngineError> {
1048    let rel_path = crate::entity::id::id_to_file_path(target);
1049    if engine.mem_is_deferred(target.mem()) {
1050        let Some(mounted) = engine.mounts.iter().find(|m| m.mount.mem == target.mem()) else {
1051            return Ok(DeferredTargetProbe::NotApplicable);
1052        };
1053        return Ok(
1054            if mounted
1055                .backend
1056                .entity_exists(std::path::Path::new(&rel_path))?
1057            {
1058                DeferredTargetProbe::Exists
1059            } else {
1060                DeferredTargetProbe::Absent
1061            },
1062        );
1063    }
1064    // UNMOUNTED mem: ask the workspace layer's discovery hook. No
1065    // hook, or no discoverable storage → NotApplicable (the
1066    // forward-reference mechanic governs, unchanged).
1067    if engine.mount(target.mem()).is_none()
1068        && let Some(prober) = &engine.unmounted_storage_prober
1069        && let Some(storage) = prober(target.mem())
1070    {
1071        return Ok(
1072            if storage
1073                .backend
1074                .entity_exists(std::path::Path::new(&rel_path))?
1075            {
1076                DeferredTargetProbe::Exists
1077            } else {
1078                DeferredTargetProbe::Absent
1079            },
1080        );
1081    }
1082    Ok(DeferredTargetProbe::NotApplicable)
1083}
1084
1085/// The one-blob type read for a storage-verified deferred target: the
1086/// shape check needs the target's real entity type, and a tree hit
1087/// proves existence, not type. Reads exactly the resolved path's
1088/// bytes and peeks the frontmatter `type:` — never the mem. Returns
1089/// `None` when the entity is absent or declares no type (the shape
1090/// gate then admits, the same posture the stub-bound case has
1091/// always had — the check never guesses).
1092/// The stub kind for a target being auto-stubbed on an add path: a
1093/// target that storage VERIFIES inside a deferred mem gets the
1094/// load-time kind — the stub is only plan 01's until-load
1095/// representation of a link into an unloaded mem, not a forward
1096/// reference to something that awaits creation. Everything else keeps
1097/// `ForwardReference`. Kinds stay annotation-not-state either way.
1098pub(super) fn deferred_verified_stub_kind(
1099    engine: &super::Engine,
1100    target: &EntityId,
1101) -> Result<crate::entity::StubKind, EngineError> {
1102    Ok(
1103        if matches!(
1104            probe_deferred_target(engine, target)?,
1105            DeferredTargetProbe::Exists
1106        ) {
1107            crate::entity::StubKind::LoadTime
1108        } else {
1109            crate::entity::StubKind::ForwardReference
1110        },
1111    )
1112}
1113
1114pub(super) fn peek_deferred_target_type(
1115    engine: &super::Engine,
1116    target: &EntityId,
1117) -> Result<Option<String>, EngineError> {
1118    let rel_path = crate::entity::id::id_to_file_path(target);
1119    let bytes = if engine.mem_is_deferred(target.mem()) {
1120        let Some(mounted) = engine.mounts.iter().find(|m| m.mount.mem == target.mem()) else {
1121            return Ok(None);
1122        };
1123        mounted
1124            .backend
1125            .read_entity(std::path::Path::new(&rel_path))?
1126    } else if engine.mount(target.mem()).is_none()
1127        && let Some(prober) = &engine.unmounted_storage_prober
1128        && let Some(storage) = prober(target.mem())
1129    {
1130        storage
1131            .backend
1132            .read_entity(std::path::Path::new(&rel_path))?
1133    } else {
1134        return Ok(None);
1135    };
1136    let Some(bytes) = bytes else {
1137        return Ok(None);
1138    };
1139    Ok(String::from_utf8(bytes)
1140        .ok()
1141        .and_then(|c| crate::entity::parser::peek_type_from_frontmatter(&c)))
1142}
1143
1144/// The target-schema REF for cross-schema edge routing. Loaded mems
1145/// answer from the engine's schema catalogue; an UNMOUNTED mem with
1146/// discoverable storage answers from its stored config's pin via the
1147/// discovery hook (flywheel W7/02) — the routing check
1148/// (`validate_cross_mem_edge`) needs only the ref, never the full
1149/// schema, so the source schema's `cross_mem_relationships:` entry
1150/// keeps its authority without a mount. `None` falls back to the
1151/// intra-mem path, exactly the pre-existing posture.
1152pub(super) fn target_schema_ref_for_routing(
1153    engine: &super::Engine,
1154    target_mem: &str,
1155) -> Option<memstead_schema::SchemaRef> {
1156    if let Some(s) = engine.schemas.get(target_mem) {
1157        let (name, version) = s.id();
1158        return Some(memstead_schema::SchemaRef::new(name, version));
1159    }
1160    if engine.mount(target_mem).is_none()
1161        && let Some(prober) = &engine.unmounted_storage_prober
1162        && let Some(storage) = prober(target_mem)
1163    {
1164        return storage.schema;
1165    }
1166    None
1167}
1168
1169/// Outcome of the engine's edge-validation router for a single
1170/// inline / explicit relate. Carries the optional open-mode warning
1171/// from the intra-mem flow; the cross-mem flow has no
1172/// open-mode (cross-mem entries are declared vocabulary).
1173pub(super) enum EdgeRouteOutcome {
1174    Ok,
1175    OpenModeWarning(Box<crate::ops::WarningHint>),
1176}
1177
1178/// Run rel-type + shape validation for one edge, routing through
1179/// intra-mem vocabulary or the source schema's
1180/// `cross_mem_relationships:` section as appropriate.
1181///
1182/// The routing rule:
1183/// when `source_mem != target_mem` AND the target mem's
1184/// pinned schema differs from the source schema by name or by
1185/// version, the source schema's `cross_mem_relationships:` entry
1186/// for the target schema is the sole authority for both the
1187/// vocabulary check (`INVALID_REL_TYPE`) and the shape check
1188/// (`INVALID_REL_SHAPE`). If no matching entry exists, surface
1189/// [`EngineError::CrossMemEdgeNotDeclared`].
1190///
1191/// Otherwise (same-mem, same-schema cross-mem, or target mem
1192/// unmounted) the call falls through to the existing intra-mem
1193/// validators — the same behaviour the intra-mem path always had.
1194///
1195/// `check_shape` mirrors the relate path's add-only shape posture:
1196/// pass `false` to skip the shape check (currently only the
1197/// `memstead_relate --remove` path). The vocabulary check still fires
1198/// in that case, matching the intra-mem behaviour where
1199/// `validate_rel_type` runs on both add and remove.
1200// The nine parameters are one edge's full coordinates; a params struct
1201// would restate the same fields at every call site without grouping
1202// anything that travels together elsewhere.
1203#[allow(clippy::too_many_arguments)]
1204pub(super) fn route_edge_validation(
1205    engine: &super::Engine,
1206    rel_type: &str,
1207    from_type: &str,
1208    to_type: Option<&str>,
1209    source_mem: &str,
1210    target_mem: &str,
1211    from_id: &EntityId,
1212    to_id: &EntityId,
1213    check_shape: bool,
1214) -> Result<EdgeRouteOutcome, EngineError> {
1215    use crate::runtime_validator::{
1216        CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
1217        validate_rel_type,
1218    };
1219    use memstead_schema::SchemaRef;
1220
1221    let source_schema = engine
1222        .schemas
1223        .get(source_mem)
1224        .expect("schema present for every registered mount");
1225
1226    let target_schema_ref: Option<SchemaRef> = if source_mem == target_mem {
1227        None
1228    } else {
1229        target_schema_ref_for_routing(engine, target_mem)
1230    };
1231    let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
1232        (Some(target), (src_name, _)) => target.name != src_name,
1233        (None, _) => false,
1234    };
1235
1236    if cross_mem_different {
1237        let target_ref = target_schema_ref
1238            .as_ref()
1239            .expect("target_schema_ref is Some when cross_mem_different");
1240        if !check_shape {
1241            // Cleanup posture: cross-mem remove stays permissive so
1242            // pre-tightening edges remain droppable without first
1243            // re-declaring them. Mirrors the intra-mem shape gate's
1244            // add-only stance.
1245            return Ok(EdgeRouteOutcome::Ok);
1246        }
1247        match validate_cross_mem_edge(
1248            rel_type,
1249            from_type,
1250            to_type,
1251            source_schema.as_ref(),
1252            target_ref,
1253        ) {
1254            CrossMemRelCheck::Ok => Ok(EdgeRouteOutcome::Ok),
1255            CrossMemRelCheck::EdgeNotDeclared => {
1256                let (src_name, src_version) = source_schema.id();
1257                Err(EngineError::CrossMemEdgeNotDeclared {
1258                    source_schema: SchemaRef::new(src_name, src_version).as_display(),
1259                    target_schema: target_ref.as_display(),
1260                    rel_type: rel_type.to_string(),
1261                    from_id: from_id.to_string(),
1262                    to_id: to_id.to_string(),
1263                })
1264            }
1265            CrossMemRelCheck::Invalid(v) => Err(EngineError::Validation(v)),
1266        }
1267    } else {
1268        let warning_hint = match validate_rel_type(rel_type, source_schema.as_ref())? {
1269            RelationshipCheck::Ok => None,
1270            RelationshipCheck::OpenWarning(message) => {
1271                Some(crate::ops::WarningHint::UndeclaredRelationshipOpen {
1272                    rel_type: rel_type.to_string(),
1273                    message,
1274                })
1275            }
1276        };
1277        if check_shape {
1278            validate_rel_shape(rel_type, from_type, to_type, source_schema.as_ref())?;
1279        }
1280        Ok(match warning_hint {
1281            Some(w) => EdgeRouteOutcome::OpenModeWarning(Box::new(w)),
1282            None => EdgeRouteOutcome::Ok,
1283        })
1284    }
1285}
1286
1287/// Cycle-family gate for one prospective edge — the single owner of
1288/// both refusals, shared by every edge-writing verb (`memstead_relate`,
1289/// `memstead_create.relations[]`, `memstead_update.declare_relations`, and the
1290/// batch paths, which stage prior items' edges into `store` so an
1291/// intra-batch cycle refuses like a stored one):
1292///
1293/// - **Self-loop on a listed no-self-loop rel-type.** `from == to` on
1294///   any rel-type the source type lists in `no_self_loop_relationships`
1295///   refuses, regardless of the `acyclic` flag — the declaration's one
1296///   effect (see `TypeDefinition::no_self_loop_relationships`).
1297/// - **Cycle on an acyclic rel-type.** An add closing a back-path
1298///   `to → … → from` (via [`crate::graph::query::would_cycle`]) refuses
1299///   with the existing path, capped at [`RELATIONSHIP_CYCLE_PATH_CAP`].
1300/// - **Cycle in a declared acyclicity set.** When the rel-type belongs
1301///   to a `relationships.acyclic_sets` set, an add closing a back-path
1302///   in the set's UNION subgraph (via
1303///   [`crate::graph::query::would_cycle_in_set`]) refuses; the payload
1304///   additionally echoes the set and the path's per-hop rel-types.
1305///
1306/// All refuse [`EngineError::RelationshipCycle`] (`RELATIONSHIP_CYCLE`)
1307/// with identical recovery detail on every path. Callers skip this on
1308/// remove paths — removal can only break cycles, never close one.
1309pub(super) fn validate_edge_acyclicity(
1310    store: &Store,
1311    schema: &memstead_schema::Schema,
1312    from: &EntityId,
1313    from_type: &str,
1314    to: &EntityId,
1315    rel_type: &str,
1316) -> Result<(), EngineError> {
1317    if from == to && schema.type_refuses_self_loop(from_type, rel_type) {
1318        return Err(EngineError::RelationshipCycle {
1319            rel_type: rel_type.to_string(),
1320            from: from.clone(),
1321            to: to.clone(),
1322            existing_path: vec![from.clone()],
1323            path_truncated: false,
1324            acyclic_set: None,
1325            existing_path_rel_types: None,
1326        });
1327    }
1328    if schema.relationship_acyclic(rel_type)
1329        && let Some(path) = crate::graph::query::would_cycle(store, from, to, rel_type)
1330    {
1331        let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
1332        let mut existing_path = path;
1333        if truncated {
1334            existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
1335        }
1336        return Err(EngineError::RelationshipCycle {
1337            rel_type: rel_type.to_string(),
1338            from: from.clone(),
1339            to: to.clone(),
1340            existing_path,
1341            path_truncated: truncated,
1342            acyclic_set: None,
1343            existing_path_rel_types: None,
1344        });
1345    }
1346    // Cycle in a declared acyclicity SET: the union subgraph of the
1347    // set must stay acyclic, so the back-path may mix rel-types. The
1348    // refusal is additive — it echoes the declared set and one
1349    // rel-type per hop of the path.
1350    if let Some(set) = schema.acyclic_set_containing(rel_type)
1351        && let Some((path, path_rels)) =
1352            crate::graph::query::would_cycle_in_set(store, from, to, set)
1353    {
1354        let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
1355        let mut existing_path = path;
1356        let mut existing_path_rel_types = path_rels;
1357        if truncated {
1358            existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
1359            existing_path_rel_types.truncate(existing_path.len().saturating_sub(1));
1360        }
1361        return Err(EngineError::RelationshipCycle {
1362            rel_type: rel_type.to_string(),
1363            from: from.clone(),
1364            to: to.clone(),
1365            existing_path,
1366            path_truncated: truncated,
1367            acyclic_set: Some(set.to_vec()),
1368            existing_path_rel_types: Some(existing_path_rel_types),
1369        });
1370    }
1371    Ok(())
1372}
1373
1374/// Validate the per-edge description posture declared on the rel-type
1375/// in the routing-appropriate definition (intra-mem when source and
1376/// target share the schema; cross-mem entry when they don't). Emits
1377/// `MissingRequiredDescription` / `DescriptionNotPermitted` on
1378/// violations; `optional` and unknown rel-types are no-ops (the
1379/// vocabulary / shape gates already catch undeclared names — posture
1380/// only fires for declared names).
1381///
1382/// `description` is the normalised value (empty / whitespace-only
1383/// collapses to `None` before reaching this gate). Called from every
1384/// add path: `memstead_relate`, `declare_relations` on `memstead_create` and
1385/// `memstead_update`.
1386pub(super) fn validate_description_posture(
1387    engine: &super::Engine,
1388    rel_type: &str,
1389    description: Option<&str>,
1390    source_mem: &str,
1391    target_mem: &str,
1392    from_id: &EntityId,
1393    to_id: &EntityId,
1394) -> Result<(), EngineError> {
1395    use memstead_schema::{PerEdgeDescription, SchemaRef};
1396
1397    let source_schema = engine
1398        .schemas
1399        .get(source_mem)
1400        .expect("schema present for every registered mount");
1401    let target_schema_ref: Option<SchemaRef> = if source_mem == target_mem {
1402        None
1403    } else {
1404        target_schema_ref_for_routing(engine, target_mem)
1405    };
1406    let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
1407        (Some(target), (src_name, _)) => target.name != src_name,
1408        (None, _) => false,
1409    };
1410
1411    let posture = if cross_mem_different {
1412        // Look up the matching cross-mem entry's definition. If the
1413        // entry exists but the rel-type isn't enumerated under it, the
1414        // vocabulary gate (route_edge_validation) will surface
1415        // `CROSS_MEM_EDGE_NOT_DECLARED`; posture is a no-op there.
1416        let target_ref = target_schema_ref
1417            .as_ref()
1418            .expect("target_schema_ref is Some when cross_mem_different");
1419        source_schema
1420            .cross_mem_entries(&target_ref.name)
1421            .iter()
1422            .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
1423            .map(|d| d.per_edge_description)
1424    } else {
1425        source_schema
1426            .relationship_def(rel_type)
1427            .map(|d| d.per_edge_description)
1428    };
1429
1430    match posture {
1431        Some(PerEdgeDescription::Required) if description.is_none() => {
1432            Err(EngineError::MissingRequiredDescription {
1433                rel_type: rel_type.to_string(),
1434                from_id: from_id.to_string(),
1435                to_id: to_id.to_string(),
1436            })
1437        }
1438        Some(PerEdgeDescription::Forbidden) if description.is_some() => {
1439            Err(EngineError::DescriptionNotPermitted {
1440                rel_type: rel_type.to_string(),
1441                from_id: from_id.to_string(),
1442                to_id: to_id.to_string(),
1443            })
1444        }
1445        _ => Ok(()),
1446    }
1447}
1448
1449/// Validate the manual-authoring posture declared on the rel-type.
1450/// Fires only on explicit-author paths (`memstead_relate`, inline
1451/// `relations:` on `memstead_create`, `declare_relations` on
1452/// `memstead_update`). The body-link → relation alias machinery
1453/// synthesises relations from wiki-links — that path bypasses this
1454/// gate by construction (it never calls this function), keeping the
1455/// alias path for `manual_authoring: forbidden` rel-types (e.g.
1456/// REFERENCES) intact.
1457pub(super) fn validate_manual_authoring_posture(
1458    engine: &super::Engine,
1459    rel_type: &str,
1460    source_mem: &str,
1461    from_id: &EntityId,
1462    to_id: &EntityId,
1463) -> Result<(), EngineError> {
1464    use memstead_schema::ManualAuthoring;
1465
1466    let source_schema = engine
1467        .schemas
1468        .get(source_mem)
1469        .expect("schema present for every registered mount");
1470    let posture = source_schema.relationship_manual_authoring(rel_type);
1471    if matches!(posture, ManualAuthoring::Forbidden) {
1472        let guidance = source_schema
1473            .relationship_when_to_use(rel_type)
1474            .unwrap_or_default();
1475        return Err(EngineError::RelationManualAuthoringForbidden {
1476            rel_type: rel_type.to_string(),
1477            from_id: from_id.to_string(),
1478            to_id: to_id.to_string(),
1479            guidance,
1480        });
1481    }
1482    Ok(())
1483}
1484
1485/// Alias-synthesis pass — populates `next.relationships` with engine-
1486/// emitted relations of the source schema's `alias_target_rel_type`
1487/// pointer for every body wiki-link not already backed by an
1488/// in-section-body explicit relation. Runs before the
1489/// `scan_wikilinks_without_relation` validator; after this pass the
1490/// validator finds zero missing wiki-links for the pointer rel-type.
1491///
1492/// Three cases:
1493/// 1. Schema has no pointer (`alias_target_rel_type` absent): no-op.
1494///    Caller's validator continues to refuse unbacked links exactly as
1495///    today.
1496/// 2. Schema has a pointer, body wiki-link target is in the same mem
1497///    OR cross-mem policy admits it: append `Relationship { rel_type:
1498///    pointer, target, description: None }` to `next.relationships` if
1499///    no relation of `(pointer, target)` is already present. Dedupe is
1500///    `(target, rel_type)` — a USES or DEPENDS_ON edge to the same
1501///    target does not suppress synthesis of the pointer rel-type.
1502/// 3. Schema has a pointer but a body wiki-link crosses a mem
1503///    boundary the workspace doesn't grant — or targets an entity
1504///    absent from a read-only mount: return the funnel's typed
1505///    refusal ([`EngineError::CrossMemLinkNotAllowed`] /
1506///    [`EngineError::CrossMemTargetNotFound`], via
1507///    [`validate_cross_mem_add_policy`]). The entire mutation
1508///    aborts — no partial state.
1509///
1510/// GC: when `prev` is `Some`, the pass also drops pointer-rel-type
1511/// relations whose target was a body wiki-link in `prev` but no longer
1512/// appears in `next.sections`. The loader forces `manual_authoring:
1513/// forbidden` on every schema's `alias_target_rel_type` pointer, so the
1514/// only path to a pointer-rel-type edge is the body-link channel; the
1515/// GC rule therefore reduces to "drop pointer-rel-type relations whose
1516/// target is not in the new body". Targeting prev's wiki-link set
1517/// specifically (rather than every pointer-rel-type relation) keeps the
1518/// pass correct even for an explicit-author relation that predates the
1519/// forbid posture.
1520///
1521/// Returns the list of relations the pass emitted (in body iteration
1522/// order) — `create.rs` / `update.rs` use it to surface
1523/// `relations_emitted` on the response envelope.
1524/// Returns the synthesised relations (in body iteration order) and a flag
1525/// signalling whether a body wiki-link to the entity's own id was dropped
1526/// (F11). The caller surfaces that as a `SELF_LINK_IGNORED` warning — the
1527/// pass has no warning channel of its own.
1528pub(super) fn synthesise_alias_relations(
1529    engine: &super::Engine,
1530    prev_body_targets: &std::collections::HashSet<EntityId>,
1531    next: &mut Entity,
1532) -> Result<AliasSynthesisOutcome, super::EngineError> {
1533    let schema = engine
1534        .schemas
1535        .get(next.mem.as_str())
1536        .expect("schema present for every registered mount");
1537    let Some(pointer) = schema.alias_target_rel_type().map(str::to_string) else {
1538        return Ok(AliasSynthesisOutcome::default());
1539    };
1540
1541    // 1. GC: drop pointer-rel-type relations whose target was a body
1542    //    wiki-link in the prev entity state but isn't in next. Targets
1543    //    not in prev's wiki-link set are explicit-author relations and
1544    //    are never touched — the rule preserves explicit edges even
1545    //    while the 5 built-ins still admit explicit REFERENCES.
1546    //
1547    //    `extract_inline_links` is strict — non-slug-form targets refuse
1548    //    here with the typed `InvalidWikiLinkTarget` envelope rather
1549    //    than silently flowing into the GC's retain set as malformed
1550    //    EntityIds. Section context comes from the iteration key.
1551    let mut next_targets: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
1552    for (section_key, body) in next.sections.iter() {
1553        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1554            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1555        next_targets.extend(ids);
1556    }
1557    next.relationships.retain(|r| {
1558        !(r.rel_type == pointer
1559            && prev_body_targets.contains(&r.target)
1560            && !next_targets.contains(&r.target))
1561    });
1562
1563    // 2. Walk body wiki-links in section iteration order and append
1564    //    one relation per `(target, pointer)` pair not already
1565    //    present. Cross-mem gate fires on the first refusal.
1566    let existing: std::collections::HashSet<(String, EntityId)> = next
1567        .relationships
1568        .iter()
1569        .map(|r| (r.rel_type.clone(), r.target.clone()))
1570        .collect();
1571    let mut emitted: Vec<crate::entity::Relationship> = Vec::new();
1572    let mut already_synthesised: std::collections::HashSet<EntityId> =
1573        std::collections::HashSet::new();
1574    let mut self_link_ignored = false;
1575    let mut undeclared_dropped: Vec<UndeclaredCrossSchemaLink> = Vec::new();
1576    let mut undeclared_seen: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
1577    for (section_key, body) in next.sections.iter() {
1578        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1579            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1580        for target in ids {
1581            // F11: a body wiki-link to the entity's own id is a vacuous
1582            // self-edge (renders as both Outgoing and Incoming, inflates
1583            // connectivity). Drop it — but don't refuse: the author may
1584            // have written their own slug. The caller surfaces
1585            // `SELF_LINK_IGNORED` so the dropped link stays observable.
1586            if target == next.id {
1587                self_link_ignored = true;
1588                continue;
1589            }
1590            let key = (pointer.clone(), target.clone());
1591            if existing.contains(&key) || already_synthesised.contains(&target) {
1592                continue;
1593            }
1594            validate_cross_mem_add_policy(engine, &next.mem, &target)?;
1595            // Schema-law gate: a link into a DIFFERENT schema is an edge
1596            // only where the source schema's cross_mem_relationships
1597            // declare the pointer rel-type for that destination (exact
1598            // entry or wildcard). Emitting anyway wrote an edge the
1599            // load-path filter then silently discarded on the next boot
1600            // — the write showed a relation the graph would not keep
1601            // (graph-plans 02 grading, 2026-08-28). Skip synthesis and
1602            // record the drop; the caller warns, typed, so the author
1603            // learns the citation is prose only. An unmounted or
1604            // schema-less target stays permissive (nothing to judge).
1605            if target.mem() != next.mem
1606                && let Some(target_ref) = target_schema_ref_for_routing(engine, target.mem())
1607                && target_ref.name != schema.id().0
1608                && !schema
1609                    .cross_mem_entries(&target_ref.name)
1610                    .iter()
1611                    .any(|entry| entry.definitions.iter().any(|d| d.name == pointer))
1612            {
1613                if undeclared_seen.insert(target.clone()) {
1614                    let (src_name, src_version) = schema.id();
1615                    undeclared_dropped.push(UndeclaredCrossSchemaLink {
1616                        target,
1617                        source_schema: memstead_schema::SchemaRef::new(src_name, src_version)
1618                            .as_display(),
1619                        target_schema: target_ref.name,
1620                    });
1621                }
1622                continue;
1623            }
1624            let rel = crate::entity::Relationship::new(pointer.clone(), target.clone());
1625            next.relationships.push(rel.clone());
1626            already_synthesised.insert(target);
1627            emitted.push(rel);
1628        }
1629    }
1630    Ok(AliasSynthesisOutcome {
1631        emitted,
1632        self_link_ignored,
1633        undeclared_dropped,
1634    })
1635}
1636
1637/// One body wiki-link the alias pass declined to turn into an edge
1638/// because the source schema declares no cross-mem entry carrying the
1639/// pointer rel-type for the target's schema. The caller surfaces each
1640/// as a `CROSS_SCHEMA_LINK_UNDECLARED` warning.
1641pub(super) struct UndeclaredCrossSchemaLink {
1642    pub target: EntityId,
1643    pub source_schema: String,
1644    pub target_schema: String,
1645}
1646
1647/// What [`synthesise_alias_relations`] did: the relations it emitted
1648/// (in body iteration order), whether a self-link was dropped (F11),
1649/// and the cross-schema links it declined for lack of a declaration.
1650#[derive(Default)]
1651pub(super) struct AliasSynthesisOutcome {
1652    pub emitted: Vec<crate::entity::Relationship>,
1653    pub self_link_ignored: bool,
1654    pub undeclared_dropped: Vec<UndeclaredCrossSchemaLink>,
1655}
1656
1657/// Map the first [`crate::entity::id::WikiLinkError`] from a body
1658/// wiki-link extraction into the typed [`EngineError`] envelope,
1659/// attaching the offending section's key. Errors after the first are
1660/// dropped — the agent reads the error, fixes the link, retries, and
1661/// surfaces the next one on the follow-up call. Keeps the envelope
1662/// shape stable (single typed payload rather than a list) so MCP /
1663/// CLI clients don't need a fan-out renderer.
1664pub(super) fn map_wiki_link_errors(
1665    section_key: &str,
1666    errors: Vec<crate::entity::id::WikiLinkError>,
1667) -> EngineError {
1668    use crate::entity::id::WikiLinkError;
1669    let first = errors
1670        .into_iter()
1671        .next()
1672        .expect("map_wiki_link_errors called with non-empty error list");
1673    match first {
1674        WikiLinkError::InvalidTarget {
1675            raw,
1676            suggested,
1677            reason,
1678        } => EngineError::InvalidWikiLinkTarget {
1679            raw,
1680            suggested,
1681            section: section_key.to_string(),
1682            link_source: "body_link".to_string(),
1683            reason,
1684        },
1685        WikiLinkError::InvalidMemName { raw, reason } => EngineError::InvalidWikiLinkMem {
1686            raw,
1687            section: section_key.to_string(),
1688            reason,
1689        },
1690    }
1691}
1692
1693/// Compute the set of body wiki-link targets in an entity. Used by
1694/// callers of `synthesise_alias_relations` to capture the pre-mutation
1695/// state once, before any borrow conflicts re-enter the engine's
1696/// schemas / store maps. Uses the lenient decoder — this snapshot
1697/// must tolerate on-disk drift on pre-strict entities whose bodies
1698/// may still contain non-conformant links; the strict gate fires
1699/// only on the post-mutation `next` state.
1700pub(super) fn collect_body_link_targets(entity: &Entity) -> std::collections::HashSet<EntityId> {
1701    entity
1702        .sections
1703        .iter()
1704        .flat_map(|(_, body)| {
1705            crate::entity::parser::extract_inline_links_lenient(body, &entity.mem)
1706        })
1707        .collect()
1708}
1709
1710/// Alias-existence invariant validator. Given the post-mutation entity
1711/// state, scan every section body for wiki-links whose target has no
1712/// corresponding explicit relation in `entity.relationships`. Returns
1713/// the list of `(section_key, target_id)` pairs that violate the
1714/// invariant — empty when the post-mutation state is clean.
1715///
1716/// Used by [`Engine::create_entity`] and [`Engine::update_entity`]
1717/// (and `batch_update`). The validator runs unconditionally — under
1718/// the alias model body wiki-links are foreign-key references on the
1719/// `## Relationships` table and every reference must be backed.
1720///
1721/// Sections from the auto-managed `## Relationships` heading are
1722/// not scanned (the engine generates them from the relations list
1723/// at write time; the parser keeps them out of
1724/// `entity.sections` so they never reach this function).
1725///
1726/// Reuses [`crate::entity::parser::extract_inline_links`] so the
1727/// lexical discipline (fenced-code masking, inline-code masking,
1728/// alias handling, cross-mem forms) matches every other validator
1729/// surface in the engine.
1730pub(super) fn scan_wikilinks_without_relation(
1731    next: &Entity,
1732    exempt: &std::collections::HashSet<EntityId>,
1733) -> Result<Vec<(String, EntityId)>, EngineError> {
1734    let explicit_targets: std::collections::HashSet<EntityId> = next
1735        .relationships
1736        .iter()
1737        .map(|r| r.target.clone())
1738        .collect();
1739    let mut missing: Vec<(String, EntityId)> = Vec::new();
1740    for (section_key, body) in next.sections.iter() {
1741        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1742            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1743        for target in ids {
1744            // A self-targeting body link is intentionally unbacked: the
1745            // alias pass drops its (vacuous) self-edge (F11), so it has no
1746            // backing relation by design and must not trip the
1747            // unbacked-link refusal here.
1748            if target == next.id {
1749                continue;
1750            }
1751            // A cross-schema link the alias pass declined for lack of a
1752            // declaration is likewise intentionally unbacked: the caller
1753            // warns (`CROSS_SCHEMA_LINK_UNDECLARED`) instead of the write
1754            // refusing over prose the schema deliberately keeps inert.
1755            if exempt.contains(&target) {
1756                continue;
1757            }
1758            if !explicit_targets.contains(&target)
1759                && !missing
1760                    .iter()
1761                    .any(|(k, t)| k == section_key && t == &target)
1762            {
1763                missing.push((section_key.clone(), target));
1764            }
1765        }
1766    }
1767    Ok(missing)
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772
1773    use tempfile::TempDir;
1774
1775    use crate::backend::MemBackend;
1776    use crate::engine::test_helpers::*;
1777    use crate::engine::{CreateEntityArgs, Engine, UpdateEntityArgs};
1778
1779    use crate::storage::FilesystemMemWriter;
1780    use crate::vcs::CommitContext;
1781
1782    use indexmap::IndexMap;
1783
1784    #[test]
1785    fn with_ctx_wrappers_delegate_to_explicit_forms() {
1786        // Each *_with_ctx wrapper bundles a CommitContext and
1787        // routes through the corresponding 4-arg method. Verify
1788        // create → update → rename → delete via the wrappers
1789        // observably mutate the store the same way the explicit
1790        // forms would.
1791        let tmp = TempDir::new().unwrap();
1792        let mem_dir = tmp.path().to_path_buf();
1793        let writer = FilesystemMemWriter::new(mem_dir.clone());
1794        let mut engine = Engine::from_mounts(vec![(
1795            folder_mount("specs", mem_dir),
1796            Box::new(writer) as Box<dyn MemBackend>,
1797        )])
1798        .unwrap();
1799        let ctx = CommitContext::internal();
1800
1801        // create_entity_with_ctx
1802        let create_args = CreateEntityArgs {
1803            anchors: Vec::new(),
1804            mem: "specs".to_string(),
1805            title: "Seed".to_string(),
1806            entity_type: "spec".to_string(),
1807            sections: IndexMap::from_iter([
1808                ("identity".to_string(), "seed identity".to_string()),
1809                ("purpose".to_string(), "seed purpose".to_string()),
1810            ]),
1811            metadata: IndexMap::new(),
1812            relations: Vec::new(),
1813            dry_run: false,
1814        };
1815        let created = engine.create_entity_with_ctx(create_args, &ctx).unwrap();
1816        assert_eq!(created.title, "Seed");
1817        assert!(engine.store().get(&created.id).is_some());
1818
1819        // update_entity_with_ctx
1820        let update_args = UpdateEntityArgs {
1821            anchors: Vec::new(),
1822            id: created.id.clone(),
1823            expected_hash: Some(created.content_hash.clone()),
1824            sections: IndexMap::from_iter([("identity".to_string(), "updated".to_string())]),
1825            append_sections: IndexMap::new(),
1826            patch_sections: IndexMap::new(),
1827            sections_unset: Vec::new(),
1828            metadata: IndexMap::new(),
1829            metadata_unset: Vec::new(),
1830            dry_run: false,
1831            declare_relations: Vec::new(),
1832            relations_unset: Vec::new(),
1833            anchors_unset: Vec::new(),
1834        };
1835        let updated = engine.update_entity_with_ctx(update_args, &ctx).unwrap();
1836        assert!(
1837            !updated.write_id.is_empty()
1838                || (updated.modified_sections.replaced.is_empty()
1839                    && updated.modified_sections.appended.is_empty()
1840                    && updated.modified_sections.patched.is_empty())
1841        );
1842
1843        // rename_entity_with_ctx
1844        let renamed = engine
1845            .rename_entity_with_ctx(&created.id, "Renamed", &updated.content_hash, &ctx)
1846            .unwrap();
1847        assert_ne!(renamed.old_id, renamed.new_id);
1848        assert!(engine.store().get(&renamed.new_id).is_some());
1849
1850        // delete_entity_with_ctx
1851        let deleted = engine
1852            .delete_entity_with_ctx(&renamed.new_id, &renamed.content_hash, &ctx)
1853            .unwrap();
1854        assert_eq!(deleted.id, renamed.new_id);
1855        assert!(engine.store().get(&renamed.new_id).is_none());
1856    }
1857
1858    /// Minimal on-disk MemConfig pinning `default@1.0.0`, with an
1859    /// optional pre-set mutation stamp — the carrier the stamp path
1860    /// and the boot skew check both read.
1861    fn write_config(dir: &std::path::Path, stamp: Option<memstead_schema::MutationStamp>) {
1862        let meta = dir.join(memstead_schema::MEM_META_DIR);
1863        std::fs::create_dir_all(&meta).unwrap();
1864        let mut config: memstead_schema::MemConfig =
1865            serde_json::from_str(r#"{"schema": "default@1.0.0"}"#).unwrap();
1866        config.mutation_stamp = stamp;
1867        std::fs::write(
1868            meta.join("config.json"),
1869            serde_json::to_vec_pretty(&config).unwrap(),
1870        )
1871        .unwrap();
1872    }
1873
1874    fn stamped_engine_fixture(mem_dir: std::path::PathBuf) -> Engine {
1875        Engine::from_mounts(vec![(
1876            folder_mount("specs", mem_dir.clone()),
1877            Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>,
1878        )])
1879        .unwrap()
1880    }
1881
1882    fn disk_stamp(dir: &std::path::Path) -> Option<memstead_schema::MutationStamp> {
1883        let bytes =
1884            std::fs::read(dir.join(memstead_schema::MEM_META_DIR).join("config.json")).unwrap();
1885        let config: memstead_schema::MemConfig = serde_json::from_slice(&bytes).unwrap();
1886        config.mutation_stamp
1887    }
1888
1889    fn spec_create_args(title: &str) -> CreateEntityArgs {
1890        CreateEntityArgs {
1891            anchors: Vec::new(),
1892            mem: "specs".to_string(),
1893            title: title.to_string(),
1894            entity_type: "spec".to_string(),
1895            sections: IndexMap::from_iter([
1896                ("identity".to_string(), "seed identity".to_string()),
1897                ("purpose".to_string(), "seed purpose".to_string()),
1898            ]),
1899            metadata: IndexMap::new(),
1900            relations: Vec::new(),
1901            dry_run: false,
1902        }
1903    }
1904
1905    /// Criterion 3 (agent-trust plan 02): a mutation stamps the mem's
1906    /// engine-owned state with the running engine version and resolved
1907    /// schema; a read-only load writes nothing.
1908    #[test]
1909    fn mutation_writes_version_stamp_and_read_only_load_does_not() {
1910        let tmp = TempDir::new().unwrap();
1911        let mem_dir = tmp.path().to_path_buf();
1912        write_config(&mem_dir, None);
1913
1914        // Read-only session: boot and drop without mutating — the
1915        // stamp stays absent.
1916        drop(stamped_engine_fixture(mem_dir.clone()));
1917        assert!(
1918            disk_stamp(&mem_dir).is_none(),
1919            "a read-only load must not write a stamp"
1920        );
1921
1922        // A mutation stamps engine version + resolved schema.
1923        let mut engine = stamped_engine_fixture(mem_dir.clone());
1924        engine
1925            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
1926            .unwrap();
1927        let stamp = disk_stamp(&mem_dir).expect("mutation must write the stamp");
1928        assert_eq!(stamp.engine_version, crate::build_info::full_version());
1929        assert_eq!(stamp.schema, "default@1.0.0");
1930
1931        // A second mutation under the same binary leaves the stamp at
1932        // the same value (the write path compares and no-ops).
1933        let mut engine = stamped_engine_fixture(mem_dir.clone());
1934        engine
1935            .create_entity_with_ctx(spec_create_args("Second"), &CommitContext::internal())
1936            .unwrap();
1937        let again = disk_stamp(&mem_dir).expect("stamp survives");
1938        assert_eq!(again, stamp);
1939    }
1940
1941    /// The reported damage, reproduced (04/03, criteria 7 and 8): a
1942    /// long-lived engine boots, a sibling writes the config out of band, and
1943    /// the engine's next ENTITY mutation stamps the version. Before the fix
1944    /// that stamp serialized the boot-time struct and the sibling's write was
1945    /// gone. No lifecycle call is involved anywhere in this test, which is why
1946    /// the loss looked spontaneous to the operator who reported it.
1947    ///
1948    /// The divergent stamp is written to disk rather than injected, because
1949    /// the running binary's version is a compile-time constant with no runtime
1950    /// seam; this is how the existing skew coverage reaches the condition too.
1951    #[test]
1952    fn a_sibling_config_write_survives_the_next_entity_mutation() {
1953        let tmp = TempDir::new().unwrap();
1954        let mem_dir = tmp.path().to_path_buf();
1955        // Seed a stamp that disagrees with this binary, so the stamp writer is
1956        // live rather than dormant: that is the two-binary topology the report
1957        // came from.
1958        write_config(
1959            &mem_dir,
1960            Some(memstead_schema::MutationStamp {
1961                engine_version: "0.0.1-other".to_string(),
1962                schema: "default@1.0.0".to_string(),
1963            }),
1964        );
1965
1966        // The long-lived engine boots and caches the config as it is now.
1967        let mut engine = stamped_engine_fixture(mem_dir.clone());
1968
1969        // A sibling process sets a description. The engine never learns:
1970        // a config-only write advances no entity head and appends no change
1971        // log line, so the staleness probe cannot see it.
1972        let path = mem_dir
1973            .join(memstead_schema::MEM_META_DIR)
1974            .join("config.json");
1975        let mut sibling: memstead_schema::MemConfig =
1976            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1977        sibling.description = Some("written by the sibling".to_string());
1978        std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
1979
1980        // An ordinary entity write. Nothing about it mentions config.
1981        engine
1982            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
1983            .unwrap();
1984
1985        let after: memstead_schema::MemConfig =
1986            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1987        assert_eq!(
1988            after.description.as_deref(),
1989            Some("written by the sibling"),
1990            "the sibling's description must survive an entity mutation"
1991        );
1992        assert_eq!(
1993            after.mutation_stamp.map(|s| s.engine_version),
1994            Some(crate::build_info::full_version().to_string()),
1995            "and the stamp this engine came to write must still land"
1996        );
1997    }
1998
1999    /// Criterion 3 for the stamp writer: the intervention reaches the ENTITY
2000    /// mutation's own response. The stamp has no response of its own, and an
2001    /// earlier draft discarded the report with `let _`, so an operator whose
2002    /// config moved during an innocuous entity write was told nothing.
2003    #[test]
2004    fn the_stamps_intervention_rides_the_entity_mutations_response() {
2005        let tmp = TempDir::new().unwrap();
2006        let mem_dir = tmp.path().to_path_buf();
2007        write_config(
2008            &mem_dir,
2009            Some(memstead_schema::MutationStamp {
2010                engine_version: "0.0.1-other".to_string(),
2011                schema: "default@1.0.0".to_string(),
2012            }),
2013        );
2014        let mut engine = stamped_engine_fixture(mem_dir.clone());
2015
2016        let path = mem_dir
2017            .join(memstead_schema::MEM_META_DIR)
2018            .join("config.json");
2019        let mut sibling: memstead_schema::MemConfig =
2020            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
2021        sibling.description = Some("theirs".to_string());
2022        std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
2023
2024        let outcome = engine
2025            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
2026            .unwrap();
2027        assert!(
2028            outcome
2029                .warnings
2030                .iter()
2031                .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
2032            "the entity mutation must report the config intervention: {:?}",
2033            outcome.warnings
2034        );
2035    }
2036
2037    /// Criterion 5: the folder backend's config write is a compare-and-set,
2038    /// not check-then-write. A write whose `expected` no longer matches the
2039    /// file must refuse rather than overwrite.
2040    #[test]
2041    fn the_folder_config_write_refuses_a_stale_expectation() {
2042        use crate::backend::MemBackend;
2043        let tmp = TempDir::new().unwrap();
2044        let mem_dir = tmp.path().to_path_buf();
2045        write_config(&mem_dir, None);
2046        let backend = FilesystemMemWriter::new(mem_dir.clone());
2047        let observed = backend.read_mem_config().unwrap().expect("config exists");
2048
2049        // Someone else writes.
2050        let path = mem_dir
2051            .join(memstead_schema::MEM_META_DIR)
2052            .join("config.json");
2053        std::fs::write(&path, br#"{"schema": "default@1.0.0", "title": "theirs"}"#).unwrap();
2054
2055        // A write against the stale expectation is refused, not applied.
2056        let wrote = backend
2057            .write_mem_config_cas(Some(&observed), b"{\"schema\": \"default@1.0.0\"}", None)
2058            .unwrap();
2059        assert!(!wrote, "a stale expectation must not overwrite");
2060        let on_disk = std::fs::read_to_string(&path).unwrap();
2061        assert!(
2062            on_disk.contains("theirs"),
2063            "their write survived: {on_disk}"
2064        );
2065
2066        // And against the current bytes it lands.
2067        let current = backend.read_mem_config().unwrap().unwrap();
2068        assert!(
2069            backend
2070                .write_mem_config_cas(Some(&current), b"{\"schema\": \"default@1.0.0\"}", None)
2071                .unwrap(),
2072            "a current expectation writes"
2073        );
2074    }
2075
2076    /// Criterion 7's complement: the stamp does not become a busy writer. With
2077    /// a stamp that already agrees, an entity mutation must not touch the
2078    /// config at all, so a sibling's write is untouched for the boring reason
2079    /// rather than the interesting one.
2080    #[test]
2081    fn a_matching_stamp_still_writes_no_config_at_all() {
2082        let tmp = TempDir::new().unwrap();
2083        let mem_dir = tmp.path().to_path_buf();
2084        write_config(
2085            &mem_dir,
2086            Some(memstead_schema::MutationStamp {
2087                engine_version: crate::build_info::full_version().to_string(),
2088                schema: "default@1.0.0".to_string(),
2089            }),
2090        );
2091        let mut engine = stamped_engine_fixture(mem_dir.clone());
2092        let path = mem_dir
2093            .join(memstead_schema::MEM_META_DIR)
2094            .join("config.json");
2095        let before = std::fs::read(&path).unwrap();
2096        engine
2097            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
2098            .unwrap();
2099        assert_eq!(
2100            std::fs::read(&path).unwrap(),
2101            before,
2102            "a mutation whose stamp already matches must write no config"
2103        );
2104    }
2105
2106    /// 04/04, criteria 9 and 10: skew reaches the write that meets it, before
2107    /// that write's own restamp erases the evidence, and the write still
2108    /// lands.
2109    ///
2110    /// Boot-only detection meant a long-lived server started under one binary
2111    /// and written to by another never said so, because the first mutation
2112    /// both revealed and hid the fact.
2113    #[test]
2114    fn skew_is_reported_at_the_write_and_the_write_still_lands() {
2115        let tmp = TempDir::new().unwrap();
2116        let mem_dir = tmp.path().to_path_buf();
2117        write_config(
2118            &mem_dir,
2119            Some(memstead_schema::MutationStamp {
2120                engine_version: "0.0.1".to_string(),
2121                schema: "default@1.0.0".to_string(),
2122            }),
2123        );
2124        let mut engine = stamped_engine_fixture(mem_dir.clone());
2125        let outcome = engine
2126            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
2127            .unwrap();
2128
2129        let skew: Vec<_> = outcome
2130            .warnings
2131            .iter()
2132            .filter(|w| w.code() == "ENGINE_VERSION_SKEW")
2133            .collect();
2134        assert_eq!(
2135            skew.len(),
2136            1,
2137            "the write that meets the skew must report it: {:?}",
2138            outcome.warnings
2139        );
2140        assert!(
2141            matches!(
2142                skew[0],
2143                crate::ops::WarningHint::EngineVersionSkew {
2144                    direction: crate::build_info::SkewDirection::StampedOlder,
2145                    ..
2146                }
2147            ),
2148            "and say which way: {:?}",
2149            skew[0]
2150        );
2151        // Criterion 10: it landed. An older engine is not prevented from
2152        // writing; a deliberate downgrade is the operator's business.
2153        assert!(engine.get_entity(&outcome.id).is_some());
2154        assert_eq!(
2155            disk_stamp(&mem_dir).map(|s| s.engine_version),
2156            Some(crate::build_info::full_version().to_string()),
2157            "and the restamp still happened"
2158        );
2159
2160        // Second write, same binary: nothing left to report.
2161        let again = engine
2162            .create_entity_with_ctx(spec_create_args("Second"), &CommitContext::internal())
2163            .unwrap();
2164        assert!(
2165            !again
2166                .warnings
2167                .iter()
2168                .any(|w| w.code() == "ENGINE_VERSION_SKEW"),
2169            "the skew is resolved once restamped: {:?}",
2170            again.warnings
2171        );
2172    }
2173
2174    /// Criterion 8's complement at the write tier: a stamp from the same
2175    /// release with a different build hash is not skew, so a workspace whose
2176    /// binary is rebuilt from source is not told its engine disagrees on
2177    /// every mutation.
2178    #[test]
2179    fn a_rebuild_of_the_same_release_is_not_skew_at_the_write() {
2180        let tmp = TempDir::new().unwrap();
2181        let mem_dir = tmp.path().to_path_buf();
2182        write_config(
2183            &mem_dir,
2184            Some(memstead_schema::MutationStamp {
2185                engine_version: format!("{}+gdeadbee", crate::ENGINE_VERSION),
2186                schema: "default@1.0.0".to_string(),
2187            }),
2188        );
2189        let mut engine = stamped_engine_fixture(mem_dir.clone());
2190        let outcome = engine
2191            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
2192            .unwrap();
2193        assert!(
2194            !outcome
2195                .warnings
2196                .iter()
2197                .any(|w| w.code() == "ENGINE_VERSION_SKEW"),
2198            "a differing build hash on the same version is not skew: {:?}",
2199            outcome.warnings
2200        );
2201    }
2202
2203    /// Criterion 3/4 (agent-trust plan 02): boot under a different
2204    /// binary version surfaces the warn-tier `ENGINE_VERSION_SKEW`
2205    /// naming both versions, on load warnings AND in `health()`;
2206    /// a stamp-less mem and a matching stamp are silent.
2207    #[test]
2208    fn boot_skew_warning_fires_only_on_disagreeing_stamp() {
2209        use crate::ops::WarningHint;
2210
2211        // Disagreeing stamp → warning on boot and in health.
2212        let tmp = TempDir::new().unwrap();
2213        let mem_dir = tmp.path().to_path_buf();
2214        write_config(
2215            &mem_dir,
2216            Some(memstead_schema::MutationStamp {
2217                engine_version: "0.0.1".to_string(),
2218                schema: "default@1.0.0".to_string(),
2219            }),
2220        );
2221        let engine = stamped_engine_fixture(mem_dir);
2222        let skew: Vec<_> = engine
2223            .load_warnings()
2224            .iter()
2225            .filter(|w| matches!(w, WarningHint::EngineVersionSkew { .. }))
2226            .collect();
2227        assert_eq!(skew.len(), 1, "one skewed mem, one warning: {skew:?}");
2228        if let WarningHint::EngineVersionSkew {
2229            mem,
2230            stamped_engine,
2231            running_engine,
2232            stamped_schema,
2233            direction,
2234        } = skew[0]
2235        {
2236            assert_eq!(mem, "specs");
2237            assert_eq!(stamped_engine, "0.0.1");
2238            assert_eq!(running_engine, crate::build_info::full_version());
2239            assert_eq!(stamped_schema, "default@1.0.0");
2240            // 0.0.1 against any shipped version: the mem is behind us.
2241            assert_eq!(*direction, crate::build_info::SkewDirection::StampedOlder);
2242        }
2243        let health = engine.health();
2244        assert!(
2245            health
2246                .warnings
2247                .iter()
2248                .any(|w| w.code() == "ENGINE_VERSION_SKEW"),
2249            "health() must surface the skew without an include gate: {:?}",
2250            health.warnings,
2251        );
2252
2253        // Matching stamp → silent.
2254        let tmp = TempDir::new().unwrap();
2255        let mem_dir = tmp.path().to_path_buf();
2256        write_config(
2257            &mem_dir,
2258            Some(memstead_schema::MutationStamp {
2259                engine_version: crate::build_info::full_version().to_string(),
2260                schema: "default@1.0.0".to_string(),
2261            }),
2262        );
2263        let engine = stamped_engine_fixture(mem_dir);
2264        assert!(
2265            !engine
2266                .load_warnings()
2267                .iter()
2268                .any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
2269            "a matching stamp is not skew"
2270        );
2271
2272        // No stamp → silent (absence of a stamp is not skew).
2273        let tmp = TempDir::new().unwrap();
2274        let mem_dir = tmp.path().to_path_buf();
2275        write_config(&mem_dir, None);
2276        let engine = stamped_engine_fixture(mem_dir);
2277        assert!(
2278            !engine
2279                .load_warnings()
2280                .iter()
2281                .any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
2282            "a stamp-less (pre-plan) mem boots without warning noise"
2283        );
2284    }
2285}