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) -> Result<(), EngineError> {
679    let mut sidecar = match backend.read_anchors_sidecar()? {
680        Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
681            EngineError::Backend(crate::backend::BackendError::Other(format!(
682                "anchors sidecar parse: {e}"
683            )))
684        })?,
685        None => crate::anchor::AnchorSidecar::default(),
686    };
687    sidecar.merge(entity_id.as_ref(), unsets, anchors);
688    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
689    Ok(())
690}
691
692/// Load the mem's anchors sidecar through `backend`, or the empty
693/// document when none exists yet. Shared by the delete / rename legs
694/// which must decide whether the entity actually has anchor rows before
695/// staging a sidecar write (so an entity with none stays byte-identical
696/// to a pre-anchor mutation).
697fn read_sidecar(
698    backend: &dyn crate::backend::MemBackend,
699) -> Result<crate::anchor::AnchorSidecar, EngineError> {
700    match backend.read_anchors_sidecar()? {
701        Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
702            EngineError::Backend(crate::backend::BackendError::Other(format!(
703                "anchors sidecar parse: {e}"
704            )))
705        }),
706        None => Ok(crate::anchor::AnchorSidecar::default()),
707    }
708}
709
710/// Stage removal of `entity_id`'s anchor row into the same commit as an
711/// entity delete — a no-op (no sidecar write, so byte-identical to today)
712/// when the entity carries no anchors. Returns whether a write was staged.
713pub(crate) fn stage_anchors_removal(
714    backend: &dyn crate::backend::MemBackend,
715    entity_id: &EntityId,
716) -> Result<bool, EngineError> {
717    let mut sidecar = read_sidecar(backend)?;
718    if sidecar.get(entity_id.as_ref()).is_empty() {
719        return Ok(false);
720    }
721    sidecar.remove(entity_id.as_ref());
722    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
723    Ok(true)
724}
725
726/// Stage a move of `from`'s anchor row to `to` into the same commit as an
727/// entity rename — leaving zero rows under the old id. A no-op (byte-
728/// identical to today) when the renamed entity carries no anchors. Returns
729/// whether a write was staged.
730pub(crate) fn stage_anchors_rename(
731    backend: &dyn crate::backend::MemBackend,
732    from: &EntityId,
733    to: &EntityId,
734) -> Result<bool, EngineError> {
735    let mut sidecar = read_sidecar(backend)?;
736    if sidecar.get(from.as_ref()).is_empty() {
737        return Ok(false);
738    }
739    sidecar.rename(from.as_ref(), to.as_ref());
740    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
741    Ok(true)
742}
743
744// A `today_iso()` wall-clock convenience used to live here, for tests
745// comparing an auto-stamp against "roughly now". It is deliberately
746// gone: the stamp is second-resolution, so every such comparison races
747// the clock between the mutation and the assertion, and one of them
748// duly failed on a suite run that straddled midnight. Tests that need
749// a stamped value pin `Engine::set_mutation_clock` and derive the
750// expected string from the same instant via [`iso_from_system_time`].
751
752/// An instant as a full ISO-8601 datetime string `YYYY-MM-DDTHH:MM:SSZ`
753/// (UTC). Used by mutation paths that auto-stamp metadata fields
754/// (e.g. `last_modified` on update, `created_date` on create).
755///
756/// This is second-resolution (rather than
757/// date-only `YYYY-MM-DD`) so intra-day
758/// updates produce distinguishable timestamps and drift / staleness
759/// queries become per-update aware. The strict-mode date validator
760/// already accepts both forms (`^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$`)
761/// so existing entities written with the date-only form continue to
762/// load; new writes carry the wider form.
763///
764/// Pure function: no allocation outside the `format!` invocation,
765/// no error path (the fallback to UNIX epoch on an instant before
766/// the epoch is acceptable for a best-effort timestamp).
767/// Howard-Hinnant civil-from-days for the date half; trivial modular
768/// arithmetic for the time half.
769/// The current wall-clock time as the second-granularity ISO form every
770/// stamping path writes — for callers outside an engine (a CLI validating
771/// input before boot). Engine code uses `Engine::now_iso`, which honours a
772/// pinned test clock.
773pub fn iso_now() -> String {
774    iso_from_system_time(std::time::SystemTime::now())
775}
776
777pub(super) fn iso_from_system_time(t: std::time::SystemTime) -> String {
778    let now = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
779    let secs = now.as_secs();
780    let days = secs / 86400;
781    let secs_of_day = secs % 86400;
782    let hh = secs_of_day / 3600;
783    let mm = (secs_of_day % 3600) / 60;
784    let ss = secs_of_day % 60;
785    let z = days + 719468;
786    let era = z / 146097;
787    let doe = z - era * 146097;
788    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
789    let y = yoe + era * 400;
790    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
791    let mp = (5 * doy + 2) / 153;
792    let d = doy - (153 * mp + 2) / 5 + 1;
793    let m = if mp < 10 { mp + 3 } else { mp - 9 };
794    let y = if m <= 2 { y + 1 } else { y };
795    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
796}
797
798/// Sweep stubs whose last incoming edge has just disappeared. Returns
799/// the dropped ids so callers can surface them to the agent (e.g. via
800/// [`DeleteEntityOutcome::orphan_stubs_removed`]).
801///
802/// Stubs are auto-created when a relate names an absent target — a
803/// "promise" that a real entity will land there later (see
804/// [`make_stub`]). When the last referrer drops its edge or is itself
805/// deleted, the promise has no holder and becomes pure bloat. Only
806/// stubs are eligible — real entities never count as orphans via this
807/// path.
808pub(super) fn gc_orphan_stubs(store: &mut Store) -> Vec<EntityId> {
809    let stub_ids: Vec<EntityId> = store
810        .all_entities()
811        .filter(|e| e.stub)
812        .map(|e| e.id.clone())
813        .collect();
814    gc_orphan_stubs_among(store, &stub_ids)
815}
816
817/// Scoped orphan-stub sweep: GC only the stubs *among `candidates`*
818/// whose last incoming edge has just disappeared, returning the dropped
819/// ids. This is the single home of the orphan-stub predicate (`stub &&
820/// no incoming`) — the three write paths that can sever a stub's last
821/// referrer all funnel through here so they cannot drift:
822/// [`gc_orphan_stubs`] (delete's full-store sweep) supplies every stub
823/// id; the `memstead_relate(remove)` path supplies the just-severed target;
824/// the `memstead_update` alias-resync path supplies the entity's
825/// pre-mutation body-link targets (the only edges that commit could
826/// have dropped). Scoping to a candidate set rather than walking the
827/// whole store keeps each path from GC'ing pre-existing orphans that
828/// aren't its responsibility. Candidates are de-duplicated; a candidate
829/// that is absent, not a stub, or still has a referrer is left
830/// untouched.
831pub(super) fn gc_orphan_stubs_among<'a>(
832    store: &mut Store,
833    candidates: impl IntoIterator<Item = &'a EntityId>,
834) -> Vec<EntityId> {
835    let mut removed: Vec<EntityId> = Vec::new();
836    let mut seen: std::collections::HashSet<&EntityId> = std::collections::HashSet::new();
837    for id in candidates {
838        if !seen.insert(id) {
839            continue;
840        }
841        if store.get(id).is_some_and(|e| e.stub) && store.incoming(id).is_empty() {
842            store.remove(id);
843            removed.push(id.clone());
844        }
845    }
846    removed
847}
848
849/// Shared target-id grammar validator. The wiki-link grammar gate
850/// runs on every relation-authoring path (`memstead_relate`,
851/// `memstead_create.relations[]`, future inline-relation surfaces) so a
852/// malformed target id (e.g. `bad@chars$here`) cannot land an
853/// auto-stub at the literal id — that stub would later fail every
854/// wiki-link parse that referenced it. Pre-Item-02 the gate lived
855/// only on `memstead_relate`; the create path admitted the same input
856/// silently.
857pub(super) fn validate_relation_target_grammar(target: &EntityId) -> Result<(), EngineError> {
858    if let Err(reason) = crate::entity::id::validate_mem_name_grammar(target.mem()) {
859        return Err(EngineError::InvalidEntityId {
860            id: target.to_string(),
861            reason,
862        });
863    }
864    if let Err(reason) = crate::entity::id::validate_id_path_grammar(target.path()) {
865        return Err(EngineError::InvalidEntityId {
866            id: target.to_string(),
867            reason,
868        });
869    }
870    Ok(())
871}
872
873/// Auto-stamp `auto_timestamp` metadata fields on an entity that's
874/// about to be re-written. Extracted from the update-path hot loop so
875/// the relate-path (add and remove) and the rename-path (the renaming
876/// entity plus every referrer the rewrite cascade touched) can
877/// invoke the same engine-driven stamp.
878///
879/// Walks the type's metadata-field declarations; any field flagged
880/// `auto_timestamp: true` (the default schema declares this on
881/// `last_modified`) is set to the supplied `today` ISO string. The
882/// helper is a no-op on schemas that declare no auto-timestamp
883/// fields. Callers pre-compute `today` via [`today_iso`] so a single
884/// mutation that touches multiple entities (rename's referrer rewrite
885/// cascade) stamps them all with the same value.
886pub(super) fn auto_stamp_timestamps(
887    entity: &mut Entity,
888    type_def: &memstead_schema::TypeDefinition,
889    today: &str,
890) {
891    for field_def in &type_def.metadata_fields {
892        if field_def.auto_timestamp {
893            entity.metadata.insert(
894                field_def.key.clone(),
895                crate::entity::MetadataValue::String(today.to_string()),
896            );
897        }
898    }
899}
900
901/// Build a stub [`Entity`] for an unresolved relate target. Callers
902/// declare the stub's origin via [`crate::entity::StubKind`] —
903/// `ForwardReference` for `memstead_relate` to an absent target,
904/// `Residual { since_commit, readonly_referrers }` for the
905/// delete/rename demote path. The kind persists for the engine
906/// instance's lifetime; a reload reduces every stub to `LoadTime`
907/// — the kind is annotation, not state.
908///
909/// The stub is in-store but unwritten to disk — `entity_type` empty,
910/// `file_path` empty, no metadata, no sections, `stub: true` and
911/// `stub_kind: Some(kind)` set together. A later
912/// [`Engine::create_entity`] at the same id promotes the stub to a
913/// real entity (loader / parse-result merge handles the upgrade
914/// path; `stub_kind` clears to `None`).
915pub(super) fn make_stub(id: &EntityId, kind: crate::entity::StubKind) -> Entity {
916    Entity {
917        id: id.clone(),
918        title: id.name().to_string(),
919        entity_type: String::new(),
920        mem: id.mem().to_string(),
921        file_path: String::new(),
922        metadata: IndexMap::new(),
923        sections: IndexMap::new(),
924        relationships: Vec::new(),
925        content_hash: String::new(),
926        stub: true,
927        stub_kind: Some(kind),
928        heading_spans: HashMap::new(),
929        raw_section_headings: Vec::new(),
930    }
931}
932
933/// Cross-mem add-path policy gate. Same-mem writes bypass; the
934/// `[cross_mem_links]` table only gates writes that cross the
935/// mem boundary. Cross-mem writes consult
936/// [`super::Engine::cross_mem_link_allowed`] in the edge's actual
937/// direction (`source_mem → target_mem`). Disallowed pairings
938/// surface [`EngineError::CrossMemLinkNotAllowed`] with the
939/// `(from_mem, to_mem)` payload an agent already sees on
940/// `memstead_relate`.
941///
942/// After the grant admits the pairing, a target absent from a
943/// `MountCapability::ReadOnly` mount refuses with
944/// [`EngineError::CrossMemTargetNotFound`]: the engine cannot
945/// persist a stub through the read-only boundary, and a read-only
946/// mem never gains the entity later — a missing target there is a
947/// wrong link, not a pending forward reference. Same-mem targets,
948/// cross-mem targets in Write mounts, and unmounted target mems all
949/// retain the auto-stub mechanic.
950///
951/// Funnel point for every add-shaped edge write — `memstead_relate`,
952/// `memstead_create.relations[]`, `memstead_update.declare_relations`,
953/// body-wiki-link alias synthesis, and any future add-path mutation
954/// surface route through one gate so the policy can't drift between
955/// sites. Remove-shaped writes (cleanup) remain permissive and call
956/// this helper not at all.
957pub(super) fn validate_cross_mem_add_policy(
958    engine: &super::Engine,
959    source_mem: &str,
960    target: &EntityId,
961) -> Result<(), EngineError> {
962    let target_mem = target.mem();
963    if source_mem == target_mem {
964        return Ok(());
965    }
966    if !engine.cross_mem_link_allowed(source_mem, target_mem) {
967        return Err(EngineError::CrossMemLinkNotAllowed {
968            from_mem: source_mem.to_string(),
969            to_mem: target_mem.to_string(),
970        });
971    }
972    if let Some(mount) = engine.mount(target_mem)
973        && mount.capability == crate::workspace::MountCapability::ReadOnly
974        && !engine.store.contains(target)
975        && !matches!(
976            probe_deferred_target(engine, target)?,
977            DeferredTargetProbe::Exists
978        )
979    {
980        return Err(EngineError::CrossMemTargetNotFound {
981            target_id: target.to_string(),
982            target_mem: target_mem.to_string(),
983        });
984    }
985    Ok(())
986}
987
988/// Storage verdict for a cross-mem target whose mem is mounted but
989/// DEFERRED (lazy, not yet loaded) — the write-time verification of
990/// flywheel W7/02. The check asks the mem's real storage through the
991/// cheap [`crate::backend::MemBackend::entity_exists`] probe
992/// (tree-lookup-class on git-branch, metadata-class on folder) and
993/// never triggers the mem's load: verification must not convert into
994/// a full-load side effect (plan 01's seam).
995pub(super) enum DeferredTargetProbe {
996    /// The target's mem is loaded (the store is the truth) or not
997    /// mounted at all (no storage handle to ask — the
998    /// forward-reference mechanic governs).
999    NotApplicable,
1000    /// Storage holds the entity: the reference is verified against
1001    /// real storage even though the mem is unloaded.
1002    Exists,
1003    /// Storage answers and the entity is not there.
1004    Absent,
1005}
1006
1007pub(super) fn probe_deferred_target(
1008    engine: &super::Engine,
1009    target: &EntityId,
1010) -> Result<DeferredTargetProbe, EngineError> {
1011    let rel_path = crate::entity::id::id_to_file_path(target);
1012    if engine.mem_is_deferred(target.mem()) {
1013        let Some(mounted) = engine.mounts.iter().find(|m| m.mount.mem == target.mem()) else {
1014            return Ok(DeferredTargetProbe::NotApplicable);
1015        };
1016        return Ok(
1017            if mounted
1018                .backend
1019                .entity_exists(std::path::Path::new(&rel_path))?
1020            {
1021                DeferredTargetProbe::Exists
1022            } else {
1023                DeferredTargetProbe::Absent
1024            },
1025        );
1026    }
1027    // UNMOUNTED mem: ask the workspace layer's discovery hook. No
1028    // hook, or no discoverable storage → NotApplicable (the
1029    // forward-reference mechanic governs, unchanged).
1030    if engine.mount(target.mem()).is_none()
1031        && let Some(prober) = &engine.unmounted_storage_prober
1032        && let Some(storage) = prober(target.mem())
1033    {
1034        return Ok(
1035            if storage
1036                .backend
1037                .entity_exists(std::path::Path::new(&rel_path))?
1038            {
1039                DeferredTargetProbe::Exists
1040            } else {
1041                DeferredTargetProbe::Absent
1042            },
1043        );
1044    }
1045    Ok(DeferredTargetProbe::NotApplicable)
1046}
1047
1048/// The one-blob type read for a storage-verified deferred target: the
1049/// shape check needs the target's real entity type, and a tree hit
1050/// proves existence, not type. Reads exactly the resolved path's
1051/// bytes and peeks the frontmatter `type:` — never the mem. Returns
1052/// `None` when the entity is absent or declares no type (the shape
1053/// gate then admits, the same posture the stub-bound case has
1054/// always had — the check never guesses).
1055/// The stub kind for a target being auto-stubbed on an add path: a
1056/// target that storage VERIFIES inside a deferred mem gets the
1057/// load-time kind — the stub is only plan 01's until-load
1058/// representation of a link into an unloaded mem, not a forward
1059/// reference to something that awaits creation. Everything else keeps
1060/// `ForwardReference`. Kinds stay annotation-not-state either way.
1061pub(super) fn deferred_verified_stub_kind(
1062    engine: &super::Engine,
1063    target: &EntityId,
1064) -> Result<crate::entity::StubKind, EngineError> {
1065    Ok(
1066        if matches!(
1067            probe_deferred_target(engine, target)?,
1068            DeferredTargetProbe::Exists
1069        ) {
1070            crate::entity::StubKind::LoadTime
1071        } else {
1072            crate::entity::StubKind::ForwardReference
1073        },
1074    )
1075}
1076
1077pub(super) fn peek_deferred_target_type(
1078    engine: &super::Engine,
1079    target: &EntityId,
1080) -> Result<Option<String>, EngineError> {
1081    let rel_path = crate::entity::id::id_to_file_path(target);
1082    let bytes = if engine.mem_is_deferred(target.mem()) {
1083        let Some(mounted) = engine.mounts.iter().find(|m| m.mount.mem == target.mem()) else {
1084            return Ok(None);
1085        };
1086        mounted
1087            .backend
1088            .read_entity(std::path::Path::new(&rel_path))?
1089    } else if engine.mount(target.mem()).is_none()
1090        && let Some(prober) = &engine.unmounted_storage_prober
1091        && let Some(storage) = prober(target.mem())
1092    {
1093        storage
1094            .backend
1095            .read_entity(std::path::Path::new(&rel_path))?
1096    } else {
1097        return Ok(None);
1098    };
1099    let Some(bytes) = bytes else {
1100        return Ok(None);
1101    };
1102    Ok(String::from_utf8(bytes)
1103        .ok()
1104        .and_then(|c| crate::entity::parser::peek_type_from_frontmatter(&c)))
1105}
1106
1107/// The target-schema REF for cross-schema edge routing. Loaded mems
1108/// answer from the engine's schema catalogue; an UNMOUNTED mem with
1109/// discoverable storage answers from its stored config's pin via the
1110/// discovery hook (flywheel W7/02) — the routing check
1111/// (`validate_cross_mem_edge`) needs only the ref, never the full
1112/// schema, so the source schema's `cross_mem_relationships:` entry
1113/// keeps its authority without a mount. `None` falls back to the
1114/// intra-mem path, exactly the pre-existing posture.
1115pub(super) fn target_schema_ref_for_routing(
1116    engine: &super::Engine,
1117    target_mem: &str,
1118) -> Option<memstead_schema::SchemaRef> {
1119    if let Some(s) = engine.schemas.get(target_mem) {
1120        let (name, version) = s.id();
1121        return Some(memstead_schema::SchemaRef::new(name, version));
1122    }
1123    if engine.mount(target_mem).is_none()
1124        && let Some(prober) = &engine.unmounted_storage_prober
1125        && let Some(storage) = prober(target_mem)
1126    {
1127        return storage.schema;
1128    }
1129    None
1130}
1131
1132/// Outcome of the engine's edge-validation router for a single
1133/// inline / explicit relate. Carries the optional open-mode warning
1134/// from the intra-mem flow; the cross-mem flow has no
1135/// open-mode (cross-mem entries are declared vocabulary).
1136pub(super) enum EdgeRouteOutcome {
1137    Ok,
1138    OpenModeWarning(Box<crate::ops::WarningHint>),
1139}
1140
1141/// Run rel-type + shape validation for one edge, routing through
1142/// intra-mem vocabulary or the source schema's
1143/// `cross_mem_relationships:` section as appropriate.
1144///
1145/// The routing rule:
1146/// when `source_mem != target_mem` AND the target mem's
1147/// pinned schema differs from the source schema by name or by
1148/// version, the source schema's `cross_mem_relationships:` entry
1149/// for the target schema is the sole authority for both the
1150/// vocabulary check (`INVALID_REL_TYPE`) and the shape check
1151/// (`INVALID_REL_SHAPE`). If no matching entry exists, surface
1152/// [`EngineError::CrossMemEdgeNotDeclared`].
1153///
1154/// Otherwise (same-mem, same-schema cross-mem, or target mem
1155/// unmounted) the call falls through to the existing intra-mem
1156/// validators — the same behaviour the intra-mem path always had.
1157///
1158/// `check_shape` mirrors the relate path's add-only shape posture:
1159/// pass `false` to skip the shape check (currently only the
1160/// `memstead_relate --remove` path). The vocabulary check still fires
1161/// in that case, matching the intra-mem behaviour where
1162/// `validate_rel_type` runs on both add and remove.
1163// The nine parameters are one edge's full coordinates; a params struct
1164// would restate the same fields at every call site without grouping
1165// anything that travels together elsewhere.
1166#[allow(clippy::too_many_arguments)]
1167pub(super) fn route_edge_validation(
1168    engine: &super::Engine,
1169    rel_type: &str,
1170    from_type: &str,
1171    to_type: Option<&str>,
1172    source_mem: &str,
1173    target_mem: &str,
1174    from_id: &EntityId,
1175    to_id: &EntityId,
1176    check_shape: bool,
1177) -> Result<EdgeRouteOutcome, EngineError> {
1178    use crate::runtime_validator::{
1179        CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
1180        validate_rel_type,
1181    };
1182    use memstead_schema::SchemaRef;
1183
1184    let source_schema = engine
1185        .schemas
1186        .get(source_mem)
1187        .expect("schema present for every registered mount");
1188
1189    let target_schema_ref: Option<SchemaRef> = if source_mem == target_mem {
1190        None
1191    } else {
1192        target_schema_ref_for_routing(engine, target_mem)
1193    };
1194    let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
1195        (Some(target), (src_name, _)) => target.name != src_name,
1196        (None, _) => false,
1197    };
1198
1199    if cross_mem_different {
1200        let target_ref = target_schema_ref
1201            .as_ref()
1202            .expect("target_schema_ref is Some when cross_mem_different");
1203        if !check_shape {
1204            // Cleanup posture: cross-mem remove stays permissive so
1205            // pre-tightening edges remain droppable without first
1206            // re-declaring them. Mirrors the intra-mem shape gate's
1207            // add-only stance.
1208            return Ok(EdgeRouteOutcome::Ok);
1209        }
1210        match validate_cross_mem_edge(
1211            rel_type,
1212            from_type,
1213            to_type,
1214            source_schema.as_ref(),
1215            target_ref,
1216        ) {
1217            CrossMemRelCheck::Ok => Ok(EdgeRouteOutcome::Ok),
1218            CrossMemRelCheck::EdgeNotDeclared => {
1219                let (src_name, src_version) = source_schema.id();
1220                Err(EngineError::CrossMemEdgeNotDeclared {
1221                    source_schema: SchemaRef::new(src_name, src_version).as_display(),
1222                    target_schema: target_ref.as_display(),
1223                    rel_type: rel_type.to_string(),
1224                    from_id: from_id.to_string(),
1225                    to_id: to_id.to_string(),
1226                })
1227            }
1228            CrossMemRelCheck::Invalid(v) => Err(EngineError::Validation(v)),
1229        }
1230    } else {
1231        let warning_hint = match validate_rel_type(rel_type, source_schema.as_ref())? {
1232            RelationshipCheck::Ok => None,
1233            RelationshipCheck::OpenWarning(message) => {
1234                Some(crate::ops::WarningHint::UndeclaredRelationshipOpen {
1235                    rel_type: rel_type.to_string(),
1236                    message,
1237                })
1238            }
1239        };
1240        if check_shape {
1241            validate_rel_shape(rel_type, from_type, to_type, source_schema.as_ref())?;
1242        }
1243        Ok(match warning_hint {
1244            Some(w) => EdgeRouteOutcome::OpenModeWarning(Box::new(w)),
1245            None => EdgeRouteOutcome::Ok,
1246        })
1247    }
1248}
1249
1250/// Cycle-family gate for one prospective edge — the single owner of
1251/// both refusals, shared by every edge-writing verb (`memstead_relate`,
1252/// `memstead_create.relations[]`, `memstead_update.declare_relations`, and the
1253/// batch paths, which stage prior items' edges into `store` so an
1254/// intra-batch cycle refuses like a stored one):
1255///
1256/// - **Self-loop on a listed no-self-loop rel-type.** `from == to` on
1257///   any rel-type the source type lists in `no_self_loop_relationships`
1258///   refuses, regardless of the `acyclic` flag — the declaration's one
1259///   effect (see `TypeDefinition::no_self_loop_relationships`).
1260/// - **Cycle on an acyclic rel-type.** An add closing a back-path
1261///   `to → … → from` (via [`crate::graph::query::would_cycle`]) refuses
1262///   with the existing path, capped at [`RELATIONSHIP_CYCLE_PATH_CAP`].
1263/// - **Cycle in a declared acyclicity set.** When the rel-type belongs
1264///   to a `relationships.acyclic_sets` set, an add closing a back-path
1265///   in the set's UNION subgraph (via
1266///   [`crate::graph::query::would_cycle_in_set`]) refuses; the payload
1267///   additionally echoes the set and the path's per-hop rel-types.
1268///
1269/// All refuse [`EngineError::RelationshipCycle`] (`RELATIONSHIP_CYCLE`)
1270/// with identical recovery detail on every path. Callers skip this on
1271/// remove paths — removal can only break cycles, never close one.
1272pub(super) fn validate_edge_acyclicity(
1273    store: &Store,
1274    schema: &memstead_schema::Schema,
1275    from: &EntityId,
1276    from_type: &str,
1277    to: &EntityId,
1278    rel_type: &str,
1279) -> Result<(), EngineError> {
1280    if from == to && schema.type_refuses_self_loop(from_type, rel_type) {
1281        return Err(EngineError::RelationshipCycle {
1282            rel_type: rel_type.to_string(),
1283            from: from.clone(),
1284            to: to.clone(),
1285            existing_path: vec![from.clone()],
1286            path_truncated: false,
1287            acyclic_set: None,
1288            existing_path_rel_types: None,
1289        });
1290    }
1291    if schema.relationship_acyclic(rel_type)
1292        && let Some(path) = crate::graph::query::would_cycle(store, from, to, rel_type)
1293    {
1294        let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
1295        let mut existing_path = path;
1296        if truncated {
1297            existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
1298        }
1299        return Err(EngineError::RelationshipCycle {
1300            rel_type: rel_type.to_string(),
1301            from: from.clone(),
1302            to: to.clone(),
1303            existing_path,
1304            path_truncated: truncated,
1305            acyclic_set: None,
1306            existing_path_rel_types: None,
1307        });
1308    }
1309    // Cycle in a declared acyclicity SET: the union subgraph of the
1310    // set must stay acyclic, so the back-path may mix rel-types. The
1311    // refusal is additive — it echoes the declared set and one
1312    // rel-type per hop of the path.
1313    if let Some(set) = schema.acyclic_set_containing(rel_type)
1314        && let Some((path, path_rels)) =
1315            crate::graph::query::would_cycle_in_set(store, from, to, set)
1316    {
1317        let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
1318        let mut existing_path = path;
1319        let mut existing_path_rel_types = path_rels;
1320        if truncated {
1321            existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
1322            existing_path_rel_types.truncate(existing_path.len().saturating_sub(1));
1323        }
1324        return Err(EngineError::RelationshipCycle {
1325            rel_type: rel_type.to_string(),
1326            from: from.clone(),
1327            to: to.clone(),
1328            existing_path,
1329            path_truncated: truncated,
1330            acyclic_set: Some(set.to_vec()),
1331            existing_path_rel_types: Some(existing_path_rel_types),
1332        });
1333    }
1334    Ok(())
1335}
1336
1337/// Validate the per-edge description posture declared on the rel-type
1338/// in the routing-appropriate definition (intra-mem when source and
1339/// target share the schema; cross-mem entry when they don't). Emits
1340/// `MissingRequiredDescription` / `DescriptionNotPermitted` on
1341/// violations; `optional` and unknown rel-types are no-ops (the
1342/// vocabulary / shape gates already catch undeclared names — posture
1343/// only fires for declared names).
1344///
1345/// `description` is the normalised value (empty / whitespace-only
1346/// collapses to `None` before reaching this gate). Called from every
1347/// add path: `memstead_relate`, `declare_relations` on `memstead_create` and
1348/// `memstead_update`.
1349pub(super) fn validate_description_posture(
1350    engine: &super::Engine,
1351    rel_type: &str,
1352    description: Option<&str>,
1353    source_mem: &str,
1354    target_mem: &str,
1355    from_id: &EntityId,
1356    to_id: &EntityId,
1357) -> Result<(), EngineError> {
1358    use memstead_schema::{PerEdgeDescription, SchemaRef};
1359
1360    let source_schema = engine
1361        .schemas
1362        .get(source_mem)
1363        .expect("schema present for every registered mount");
1364    let target_schema_ref: Option<SchemaRef> = if source_mem == target_mem {
1365        None
1366    } else {
1367        target_schema_ref_for_routing(engine, target_mem)
1368    };
1369    let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
1370        (Some(target), (src_name, _)) => target.name != src_name,
1371        (None, _) => false,
1372    };
1373
1374    let posture = if cross_mem_different {
1375        // Look up the matching cross-mem entry's definition. If the
1376        // entry exists but the rel-type isn't enumerated under it, the
1377        // vocabulary gate (route_edge_validation) will surface
1378        // `CROSS_MEM_EDGE_NOT_DECLARED`; posture is a no-op there.
1379        let target_ref = target_schema_ref
1380            .as_ref()
1381            .expect("target_schema_ref is Some when cross_mem_different");
1382        source_schema
1383            .cross_mem_entries(&target_ref.name)
1384            .iter()
1385            .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
1386            .map(|d| d.per_edge_description)
1387    } else {
1388        source_schema
1389            .relationship_def(rel_type)
1390            .map(|d| d.per_edge_description)
1391    };
1392
1393    match posture {
1394        Some(PerEdgeDescription::Required) if description.is_none() => {
1395            Err(EngineError::MissingRequiredDescription {
1396                rel_type: rel_type.to_string(),
1397                from_id: from_id.to_string(),
1398                to_id: to_id.to_string(),
1399            })
1400        }
1401        Some(PerEdgeDescription::Forbidden) if description.is_some() => {
1402            Err(EngineError::DescriptionNotPermitted {
1403                rel_type: rel_type.to_string(),
1404                from_id: from_id.to_string(),
1405                to_id: to_id.to_string(),
1406            })
1407        }
1408        _ => Ok(()),
1409    }
1410}
1411
1412/// Validate the manual-authoring posture declared on the rel-type.
1413/// Fires only on explicit-author paths (`memstead_relate`, inline
1414/// `relations:` on `memstead_create`, `declare_relations` on
1415/// `memstead_update`). The body-link → relation alias machinery
1416/// synthesises relations from wiki-links — that path bypasses this
1417/// gate by construction (it never calls this function), keeping the
1418/// alias path for `manual_authoring: forbidden` rel-types (e.g.
1419/// REFERENCES) intact.
1420pub(super) fn validate_manual_authoring_posture(
1421    engine: &super::Engine,
1422    rel_type: &str,
1423    source_mem: &str,
1424    from_id: &EntityId,
1425    to_id: &EntityId,
1426) -> Result<(), EngineError> {
1427    use memstead_schema::ManualAuthoring;
1428
1429    let source_schema = engine
1430        .schemas
1431        .get(source_mem)
1432        .expect("schema present for every registered mount");
1433    let posture = source_schema.relationship_manual_authoring(rel_type);
1434    if matches!(posture, ManualAuthoring::Forbidden) {
1435        let guidance = source_schema
1436            .relationship_when_to_use(rel_type)
1437            .unwrap_or_default();
1438        return Err(EngineError::RelationManualAuthoringForbidden {
1439            rel_type: rel_type.to_string(),
1440            from_id: from_id.to_string(),
1441            to_id: to_id.to_string(),
1442            guidance,
1443        });
1444    }
1445    Ok(())
1446}
1447
1448/// Alias-synthesis pass — populates `next.relationships` with engine-
1449/// emitted relations of the source schema's `alias_target_rel_type`
1450/// pointer for every body wiki-link not already backed by an
1451/// in-section-body explicit relation. Runs before the
1452/// `scan_wikilinks_without_relation` validator; after this pass the
1453/// validator finds zero missing wiki-links for the pointer rel-type.
1454///
1455/// Three cases:
1456/// 1. Schema has no pointer (`alias_target_rel_type` absent): no-op.
1457///    Caller's validator continues to refuse unbacked links exactly as
1458///    today.
1459/// 2. Schema has a pointer, body wiki-link target is in the same mem
1460///    OR cross-mem policy admits it: append `Relationship { rel_type:
1461///    pointer, target, description: None }` to `next.relationships` if
1462///    no relation of `(pointer, target)` is already present. Dedupe is
1463///    `(target, rel_type)` — a USES or DEPENDS_ON edge to the same
1464///    target does not suppress synthesis of the pointer rel-type.
1465/// 3. Schema has a pointer but a body wiki-link crosses a mem
1466///    boundary the workspace doesn't grant — or targets an entity
1467///    absent from a read-only mount: return the funnel's typed
1468///    refusal ([`EngineError::CrossMemLinkNotAllowed`] /
1469///    [`EngineError::CrossMemTargetNotFound`], via
1470///    [`validate_cross_mem_add_policy`]). The entire mutation
1471///    aborts — no partial state.
1472///
1473/// GC: when `prev` is `Some`, the pass also drops pointer-rel-type
1474/// relations whose target was a body wiki-link in `prev` but no longer
1475/// appears in `next.sections`. The loader forces `manual_authoring:
1476/// forbidden` on every schema's `alias_target_rel_type` pointer, so the
1477/// only path to a pointer-rel-type edge is the body-link channel; the
1478/// GC rule therefore reduces to "drop pointer-rel-type relations whose
1479/// target is not in the new body". Targeting prev's wiki-link set
1480/// specifically (rather than every pointer-rel-type relation) keeps the
1481/// pass correct even for an explicit-author relation that predates the
1482/// forbid posture.
1483///
1484/// Returns the list of relations the pass emitted (in body iteration
1485/// order) — `create.rs` / `update.rs` use it to surface
1486/// `relations_emitted` on the response envelope.
1487/// Returns the synthesised relations (in body iteration order) and a flag
1488/// signalling whether a body wiki-link to the entity's own id was dropped
1489/// (F11). The caller surfaces that as a `SELF_LINK_IGNORED` warning — the
1490/// pass has no warning channel of its own.
1491pub(super) fn synthesise_alias_relations(
1492    engine: &super::Engine,
1493    prev_body_targets: &std::collections::HashSet<EntityId>,
1494    next: &mut Entity,
1495) -> Result<AliasSynthesisOutcome, super::EngineError> {
1496    let schema = engine
1497        .schemas
1498        .get(next.mem.as_str())
1499        .expect("schema present for every registered mount");
1500    let Some(pointer) = schema.alias_target_rel_type().map(str::to_string) else {
1501        return Ok(AliasSynthesisOutcome::default());
1502    };
1503
1504    // 1. GC: drop pointer-rel-type relations whose target was a body
1505    //    wiki-link in the prev entity state but isn't in next. Targets
1506    //    not in prev's wiki-link set are explicit-author relations and
1507    //    are never touched — the rule preserves explicit edges even
1508    //    while the 5 built-ins still admit explicit REFERENCES.
1509    //
1510    //    `extract_inline_links` is strict — non-slug-form targets refuse
1511    //    here with the typed `InvalidWikiLinkTarget` envelope rather
1512    //    than silently flowing into the GC's retain set as malformed
1513    //    EntityIds. Section context comes from the iteration key.
1514    let mut next_targets: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
1515    for (section_key, body) in next.sections.iter() {
1516        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1517            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1518        next_targets.extend(ids);
1519    }
1520    next.relationships.retain(|r| {
1521        !(r.rel_type == pointer
1522            && prev_body_targets.contains(&r.target)
1523            && !next_targets.contains(&r.target))
1524    });
1525
1526    // 2. Walk body wiki-links in section iteration order and append
1527    //    one relation per `(target, pointer)` pair not already
1528    //    present. Cross-mem gate fires on the first refusal.
1529    let existing: std::collections::HashSet<(String, EntityId)> = next
1530        .relationships
1531        .iter()
1532        .map(|r| (r.rel_type.clone(), r.target.clone()))
1533        .collect();
1534    let mut emitted: Vec<crate::entity::Relationship> = Vec::new();
1535    let mut already_synthesised: std::collections::HashSet<EntityId> =
1536        std::collections::HashSet::new();
1537    let mut self_link_ignored = false;
1538    let mut undeclared_dropped: Vec<UndeclaredCrossSchemaLink> = Vec::new();
1539    let mut undeclared_seen: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
1540    for (section_key, body) in next.sections.iter() {
1541        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1542            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1543        for target in ids {
1544            // F11: a body wiki-link to the entity's own id is a vacuous
1545            // self-edge (renders as both Outgoing and Incoming, inflates
1546            // connectivity). Drop it — but don't refuse: the author may
1547            // have written their own slug. The caller surfaces
1548            // `SELF_LINK_IGNORED` so the dropped link stays observable.
1549            if target == next.id {
1550                self_link_ignored = true;
1551                continue;
1552            }
1553            let key = (pointer.clone(), target.clone());
1554            if existing.contains(&key) || already_synthesised.contains(&target) {
1555                continue;
1556            }
1557            validate_cross_mem_add_policy(engine, &next.mem, &target)?;
1558            // Schema-law gate: a link into a DIFFERENT schema is an edge
1559            // only where the source schema's cross_mem_relationships
1560            // declare the pointer rel-type for that destination (exact
1561            // entry or wildcard). Emitting anyway wrote an edge the
1562            // load-path filter then silently discarded on the next boot
1563            // — the write showed a relation the graph would not keep
1564            // (graph-plans 02 grading, 2026-08-28). Skip synthesis and
1565            // record the drop; the caller warns, typed, so the author
1566            // learns the citation is prose only. An unmounted or
1567            // schema-less target stays permissive (nothing to judge).
1568            if target.mem() != next.mem
1569                && let Some(target_ref) = target_schema_ref_for_routing(engine, target.mem())
1570                && target_ref.name != schema.id().0
1571                && !schema
1572                    .cross_mem_entries(&target_ref.name)
1573                    .iter()
1574                    .any(|entry| entry.definitions.iter().any(|d| d.name == pointer))
1575            {
1576                if undeclared_seen.insert(target.clone()) {
1577                    let (src_name, src_version) = schema.id();
1578                    undeclared_dropped.push(UndeclaredCrossSchemaLink {
1579                        target,
1580                        source_schema: memstead_schema::SchemaRef::new(src_name, src_version)
1581                            .as_display(),
1582                        target_schema: target_ref.name,
1583                    });
1584                }
1585                continue;
1586            }
1587            let rel = crate::entity::Relationship::new(pointer.clone(), target.clone());
1588            next.relationships.push(rel.clone());
1589            already_synthesised.insert(target);
1590            emitted.push(rel);
1591        }
1592    }
1593    Ok(AliasSynthesisOutcome {
1594        emitted,
1595        self_link_ignored,
1596        undeclared_dropped,
1597    })
1598}
1599
1600/// One body wiki-link the alias pass declined to turn into an edge
1601/// because the source schema declares no cross-mem entry carrying the
1602/// pointer rel-type for the target's schema. The caller surfaces each
1603/// as a `CROSS_SCHEMA_LINK_UNDECLARED` warning.
1604pub(super) struct UndeclaredCrossSchemaLink {
1605    pub target: EntityId,
1606    pub source_schema: String,
1607    pub target_schema: String,
1608}
1609
1610/// What [`synthesise_alias_relations`] did: the relations it emitted
1611/// (in body iteration order), whether a self-link was dropped (F11),
1612/// and the cross-schema links it declined for lack of a declaration.
1613#[derive(Default)]
1614pub(super) struct AliasSynthesisOutcome {
1615    pub emitted: Vec<crate::entity::Relationship>,
1616    pub self_link_ignored: bool,
1617    pub undeclared_dropped: Vec<UndeclaredCrossSchemaLink>,
1618}
1619
1620/// Map the first [`crate::entity::id::WikiLinkError`] from a body
1621/// wiki-link extraction into the typed [`EngineError`] envelope,
1622/// attaching the offending section's key. Errors after the first are
1623/// dropped — the agent reads the error, fixes the link, retries, and
1624/// surfaces the next one on the follow-up call. Keeps the envelope
1625/// shape stable (single typed payload rather than a list) so MCP /
1626/// CLI clients don't need a fan-out renderer.
1627pub(super) fn map_wiki_link_errors(
1628    section_key: &str,
1629    errors: Vec<crate::entity::id::WikiLinkError>,
1630) -> EngineError {
1631    use crate::entity::id::WikiLinkError;
1632    let first = errors
1633        .into_iter()
1634        .next()
1635        .expect("map_wiki_link_errors called with non-empty error list");
1636    match first {
1637        WikiLinkError::InvalidTarget {
1638            raw,
1639            suggested,
1640            reason,
1641        } => EngineError::InvalidWikiLinkTarget {
1642            raw,
1643            suggested,
1644            section: section_key.to_string(),
1645            link_source: "body_link".to_string(),
1646            reason,
1647        },
1648        WikiLinkError::InvalidMemName { raw, reason } => EngineError::InvalidWikiLinkMem {
1649            raw,
1650            section: section_key.to_string(),
1651            reason,
1652        },
1653    }
1654}
1655
1656/// Compute the set of body wiki-link targets in an entity. Used by
1657/// callers of `synthesise_alias_relations` to capture the pre-mutation
1658/// state once, before any borrow conflicts re-enter the engine's
1659/// schemas / store maps. Uses the lenient decoder — this snapshot
1660/// must tolerate on-disk drift on pre-strict entities whose bodies
1661/// may still contain non-conformant links; the strict gate fires
1662/// only on the post-mutation `next` state.
1663pub(super) fn collect_body_link_targets(entity: &Entity) -> std::collections::HashSet<EntityId> {
1664    entity
1665        .sections
1666        .iter()
1667        .flat_map(|(_, body)| {
1668            crate::entity::parser::extract_inline_links_lenient(body, &entity.mem)
1669        })
1670        .collect()
1671}
1672
1673/// Alias-existence invariant validator. Given the post-mutation entity
1674/// state, scan every section body for wiki-links whose target has no
1675/// corresponding explicit relation in `entity.relationships`. Returns
1676/// the list of `(section_key, target_id)` pairs that violate the
1677/// invariant — empty when the post-mutation state is clean.
1678///
1679/// Used by [`Engine::create_entity`] and [`Engine::update_entity`]
1680/// (and `batch_update`). The validator runs unconditionally — under
1681/// the alias model body wiki-links are foreign-key references on the
1682/// `## Relationships` table and every reference must be backed.
1683///
1684/// Sections from the auto-managed `## Relationships` heading are
1685/// not scanned (the engine generates them from the relations list
1686/// at write time; the parser keeps them out of
1687/// `entity.sections` so they never reach this function).
1688///
1689/// Reuses [`crate::entity::parser::extract_inline_links`] so the
1690/// lexical discipline (fenced-code masking, inline-code masking,
1691/// alias handling, cross-mem forms) matches every other validator
1692/// surface in the engine.
1693pub(super) fn scan_wikilinks_without_relation(
1694    next: &Entity,
1695    exempt: &std::collections::HashSet<EntityId>,
1696) -> Result<Vec<(String, EntityId)>, EngineError> {
1697    let explicit_targets: std::collections::HashSet<EntityId> = next
1698        .relationships
1699        .iter()
1700        .map(|r| r.target.clone())
1701        .collect();
1702    let mut missing: Vec<(String, EntityId)> = Vec::new();
1703    for (section_key, body) in next.sections.iter() {
1704        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1705            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1706        for target in ids {
1707            // A self-targeting body link is intentionally unbacked: the
1708            // alias pass drops its (vacuous) self-edge (F11), so it has no
1709            // backing relation by design and must not trip the
1710            // unbacked-link refusal here.
1711            if target == next.id {
1712                continue;
1713            }
1714            // A cross-schema link the alias pass declined for lack of a
1715            // declaration is likewise intentionally unbacked: the caller
1716            // warns (`CROSS_SCHEMA_LINK_UNDECLARED`) instead of the write
1717            // refusing over prose the schema deliberately keeps inert.
1718            if exempt.contains(&target) {
1719                continue;
1720            }
1721            if !explicit_targets.contains(&target)
1722                && !missing
1723                    .iter()
1724                    .any(|(k, t)| k == section_key && t == &target)
1725            {
1726                missing.push((section_key.clone(), target));
1727            }
1728        }
1729    }
1730    Ok(missing)
1731}
1732
1733#[cfg(test)]
1734mod tests {
1735
1736    use tempfile::TempDir;
1737
1738    use crate::backend::MemBackend;
1739    use crate::engine::test_helpers::*;
1740    use crate::engine::{CreateEntityArgs, Engine, UpdateEntityArgs};
1741
1742    use crate::storage::FilesystemMemWriter;
1743    use crate::vcs::CommitContext;
1744
1745    use indexmap::IndexMap;
1746
1747    #[test]
1748    fn with_ctx_wrappers_delegate_to_explicit_forms() {
1749        // Each *_with_ctx wrapper bundles a CommitContext and
1750        // routes through the corresponding 4-arg method. Verify
1751        // create → update → rename → delete via the wrappers
1752        // observably mutate the store the same way the explicit
1753        // forms would.
1754        let tmp = TempDir::new().unwrap();
1755        let mem_dir = tmp.path().to_path_buf();
1756        let writer = FilesystemMemWriter::new(mem_dir.clone());
1757        let mut engine = Engine::from_mounts(vec![(
1758            folder_mount("specs", mem_dir),
1759            Box::new(writer) as Box<dyn MemBackend>,
1760        )])
1761        .unwrap();
1762        let ctx = CommitContext::internal();
1763
1764        // create_entity_with_ctx
1765        let create_args = CreateEntityArgs {
1766            anchors: Vec::new(),
1767            mem: "specs".to_string(),
1768            title: "Seed".to_string(),
1769            entity_type: "spec".to_string(),
1770            sections: IndexMap::from_iter([
1771                ("identity".to_string(), "seed identity".to_string()),
1772                ("purpose".to_string(), "seed purpose".to_string()),
1773            ]),
1774            metadata: IndexMap::new(),
1775            relations: Vec::new(),
1776            dry_run: false,
1777        };
1778        let created = engine.create_entity_with_ctx(create_args, &ctx).unwrap();
1779        assert_eq!(created.title, "Seed");
1780        assert!(engine.store().get(&created.id).is_some());
1781
1782        // update_entity_with_ctx
1783        let update_args = UpdateEntityArgs {
1784            anchors: Vec::new(),
1785            id: created.id.clone(),
1786            expected_hash: Some(created.content_hash.clone()),
1787            sections: IndexMap::from_iter([("identity".to_string(), "updated".to_string())]),
1788            append_sections: IndexMap::new(),
1789            patch_sections: IndexMap::new(),
1790            sections_unset: Vec::new(),
1791            metadata: IndexMap::new(),
1792            metadata_unset: Vec::new(),
1793            dry_run: false,
1794            declare_relations: Vec::new(),
1795            relations_unset: Vec::new(),
1796            anchors_unset: Vec::new(),
1797        };
1798        let updated = engine.update_entity_with_ctx(update_args, &ctx).unwrap();
1799        assert!(
1800            !updated.write_id.is_empty()
1801                || (updated.modified_sections.replaced.is_empty()
1802                    && updated.modified_sections.appended.is_empty()
1803                    && updated.modified_sections.patched.is_empty())
1804        );
1805
1806        // rename_entity_with_ctx
1807        let renamed = engine
1808            .rename_entity_with_ctx(&created.id, "Renamed", &updated.content_hash, &ctx)
1809            .unwrap();
1810        assert_ne!(renamed.old_id, renamed.new_id);
1811        assert!(engine.store().get(&renamed.new_id).is_some());
1812
1813        // delete_entity_with_ctx
1814        let deleted = engine
1815            .delete_entity_with_ctx(&renamed.new_id, &renamed.content_hash, &ctx)
1816            .unwrap();
1817        assert_eq!(deleted.id, renamed.new_id);
1818        assert!(engine.store().get(&renamed.new_id).is_none());
1819    }
1820
1821    /// Minimal on-disk MemConfig pinning `default@1.0.0`, with an
1822    /// optional pre-set mutation stamp — the carrier the stamp path
1823    /// and the boot skew check both read.
1824    fn write_config(dir: &std::path::Path, stamp: Option<memstead_schema::MutationStamp>) {
1825        let meta = dir.join(memstead_schema::MEM_META_DIR);
1826        std::fs::create_dir_all(&meta).unwrap();
1827        let mut config: memstead_schema::MemConfig =
1828            serde_json::from_str(r#"{"schema": "default@1.0.0"}"#).unwrap();
1829        config.mutation_stamp = stamp;
1830        std::fs::write(
1831            meta.join("config.json"),
1832            serde_json::to_vec_pretty(&config).unwrap(),
1833        )
1834        .unwrap();
1835    }
1836
1837    fn stamped_engine_fixture(mem_dir: std::path::PathBuf) -> Engine {
1838        Engine::from_mounts(vec![(
1839            folder_mount("specs", mem_dir.clone()),
1840            Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>,
1841        )])
1842        .unwrap()
1843    }
1844
1845    fn disk_stamp(dir: &std::path::Path) -> Option<memstead_schema::MutationStamp> {
1846        let bytes =
1847            std::fs::read(dir.join(memstead_schema::MEM_META_DIR).join("config.json")).unwrap();
1848        let config: memstead_schema::MemConfig = serde_json::from_slice(&bytes).unwrap();
1849        config.mutation_stamp
1850    }
1851
1852    fn spec_create_args(title: &str) -> CreateEntityArgs {
1853        CreateEntityArgs {
1854            anchors: Vec::new(),
1855            mem: "specs".to_string(),
1856            title: title.to_string(),
1857            entity_type: "spec".to_string(),
1858            sections: IndexMap::from_iter([
1859                ("identity".to_string(), "seed identity".to_string()),
1860                ("purpose".to_string(), "seed purpose".to_string()),
1861            ]),
1862            metadata: IndexMap::new(),
1863            relations: Vec::new(),
1864            dry_run: false,
1865        }
1866    }
1867
1868    /// Criterion 3 (agent-trust plan 02): a mutation stamps the mem's
1869    /// engine-owned state with the running engine version and resolved
1870    /// schema; a read-only load writes nothing.
1871    #[test]
1872    fn mutation_writes_version_stamp_and_read_only_load_does_not() {
1873        let tmp = TempDir::new().unwrap();
1874        let mem_dir = tmp.path().to_path_buf();
1875        write_config(&mem_dir, None);
1876
1877        // Read-only session: boot and drop without mutating — the
1878        // stamp stays absent.
1879        drop(stamped_engine_fixture(mem_dir.clone()));
1880        assert!(
1881            disk_stamp(&mem_dir).is_none(),
1882            "a read-only load must not write a stamp"
1883        );
1884
1885        // A mutation stamps engine version + resolved schema.
1886        let mut engine = stamped_engine_fixture(mem_dir.clone());
1887        engine
1888            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
1889            .unwrap();
1890        let stamp = disk_stamp(&mem_dir).expect("mutation must write the stamp");
1891        assert_eq!(stamp.engine_version, crate::build_info::full_version());
1892        assert_eq!(stamp.schema, "default@1.0.0");
1893
1894        // A second mutation under the same binary leaves the stamp at
1895        // the same value (the write path compares and no-ops).
1896        let mut engine = stamped_engine_fixture(mem_dir.clone());
1897        engine
1898            .create_entity_with_ctx(spec_create_args("Second"), &CommitContext::internal())
1899            .unwrap();
1900        let again = disk_stamp(&mem_dir).expect("stamp survives");
1901        assert_eq!(again, stamp);
1902    }
1903
1904    /// The reported damage, reproduced (04/03, criteria 7 and 8): a
1905    /// long-lived engine boots, a sibling writes the config out of band, and
1906    /// the engine's next ENTITY mutation stamps the version. Before the fix
1907    /// that stamp serialized the boot-time struct and the sibling's write was
1908    /// gone. No lifecycle call is involved anywhere in this test, which is why
1909    /// the loss looked spontaneous to the operator who reported it.
1910    ///
1911    /// The divergent stamp is written to disk rather than injected, because
1912    /// the running binary's version is a compile-time constant with no runtime
1913    /// seam; this is how the existing skew coverage reaches the condition too.
1914    #[test]
1915    fn a_sibling_config_write_survives_the_next_entity_mutation() {
1916        let tmp = TempDir::new().unwrap();
1917        let mem_dir = tmp.path().to_path_buf();
1918        // Seed a stamp that disagrees with this binary, so the stamp writer is
1919        // live rather than dormant: that is the two-binary topology the report
1920        // came from.
1921        write_config(
1922            &mem_dir,
1923            Some(memstead_schema::MutationStamp {
1924                engine_version: "0.0.1-other".to_string(),
1925                schema: "default@1.0.0".to_string(),
1926            }),
1927        );
1928
1929        // The long-lived engine boots and caches the config as it is now.
1930        let mut engine = stamped_engine_fixture(mem_dir.clone());
1931
1932        // A sibling process sets a description. The engine never learns:
1933        // a config-only write advances no entity head and appends no change
1934        // log line, so the staleness probe cannot see it.
1935        let path = mem_dir
1936            .join(memstead_schema::MEM_META_DIR)
1937            .join("config.json");
1938        let mut sibling: memstead_schema::MemConfig =
1939            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1940        sibling.description = Some("written by the sibling".to_string());
1941        std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
1942
1943        // An ordinary entity write. Nothing about it mentions config.
1944        engine
1945            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
1946            .unwrap();
1947
1948        let after: memstead_schema::MemConfig =
1949            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1950        assert_eq!(
1951            after.description.as_deref(),
1952            Some("written by the sibling"),
1953            "the sibling's description must survive an entity mutation"
1954        );
1955        assert_eq!(
1956            after.mutation_stamp.map(|s| s.engine_version),
1957            Some(crate::build_info::full_version().to_string()),
1958            "and the stamp this engine came to write must still land"
1959        );
1960    }
1961
1962    /// Criterion 3 for the stamp writer: the intervention reaches the ENTITY
1963    /// mutation's own response. The stamp has no response of its own, and an
1964    /// earlier draft discarded the report with `let _`, so an operator whose
1965    /// config moved during an innocuous entity write was told nothing.
1966    #[test]
1967    fn the_stamps_intervention_rides_the_entity_mutations_response() {
1968        let tmp = TempDir::new().unwrap();
1969        let mem_dir = tmp.path().to_path_buf();
1970        write_config(
1971            &mem_dir,
1972            Some(memstead_schema::MutationStamp {
1973                engine_version: "0.0.1-other".to_string(),
1974                schema: "default@1.0.0".to_string(),
1975            }),
1976        );
1977        let mut engine = stamped_engine_fixture(mem_dir.clone());
1978
1979        let path = mem_dir
1980            .join(memstead_schema::MEM_META_DIR)
1981            .join("config.json");
1982        let mut sibling: memstead_schema::MemConfig =
1983            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1984        sibling.description = Some("theirs".to_string());
1985        std::fs::write(&path, serde_json::to_vec_pretty(&sibling).unwrap()).unwrap();
1986
1987        let outcome = engine
1988            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
1989            .unwrap();
1990        assert!(
1991            outcome
1992                .warnings
1993                .iter()
1994                .any(|w| w.code() == "CONFIG_WRITE_INTERVENED"),
1995            "the entity mutation must report the config intervention: {:?}",
1996            outcome.warnings
1997        );
1998    }
1999
2000    /// Criterion 5: the folder backend's config write is a compare-and-set,
2001    /// not check-then-write. A write whose `expected` no longer matches the
2002    /// file must refuse rather than overwrite.
2003    #[test]
2004    fn the_folder_config_write_refuses_a_stale_expectation() {
2005        use crate::backend::MemBackend;
2006        let tmp = TempDir::new().unwrap();
2007        let mem_dir = tmp.path().to_path_buf();
2008        write_config(&mem_dir, None);
2009        let backend = FilesystemMemWriter::new(mem_dir.clone());
2010        let observed = backend.read_mem_config().unwrap().expect("config exists");
2011
2012        // Someone else writes.
2013        let path = mem_dir
2014            .join(memstead_schema::MEM_META_DIR)
2015            .join("config.json");
2016        std::fs::write(&path, br#"{"schema": "default@1.0.0", "title": "theirs"}"#).unwrap();
2017
2018        // A write against the stale expectation is refused, not applied.
2019        let wrote = backend
2020            .write_mem_config_cas(Some(&observed), b"{\"schema\": \"default@1.0.0\"}", None)
2021            .unwrap();
2022        assert!(!wrote, "a stale expectation must not overwrite");
2023        let on_disk = std::fs::read_to_string(&path).unwrap();
2024        assert!(
2025            on_disk.contains("theirs"),
2026            "their write survived: {on_disk}"
2027        );
2028
2029        // And against the current bytes it lands.
2030        let current = backend.read_mem_config().unwrap().unwrap();
2031        assert!(
2032            backend
2033                .write_mem_config_cas(Some(&current), b"{\"schema\": \"default@1.0.0\"}", None)
2034                .unwrap(),
2035            "a current expectation writes"
2036        );
2037    }
2038
2039    /// Criterion 7's complement: the stamp does not become a busy writer. With
2040    /// a stamp that already agrees, an entity mutation must not touch the
2041    /// config at all, so a sibling's write is untouched for the boring reason
2042    /// rather than the interesting one.
2043    #[test]
2044    fn a_matching_stamp_still_writes_no_config_at_all() {
2045        let tmp = TempDir::new().unwrap();
2046        let mem_dir = tmp.path().to_path_buf();
2047        write_config(
2048            &mem_dir,
2049            Some(memstead_schema::MutationStamp {
2050                engine_version: crate::build_info::full_version().to_string(),
2051                schema: "default@1.0.0".to_string(),
2052            }),
2053        );
2054        let mut engine = stamped_engine_fixture(mem_dir.clone());
2055        let path = mem_dir
2056            .join(memstead_schema::MEM_META_DIR)
2057            .join("config.json");
2058        let before = std::fs::read(&path).unwrap();
2059        engine
2060            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
2061            .unwrap();
2062        assert_eq!(
2063            std::fs::read(&path).unwrap(),
2064            before,
2065            "a mutation whose stamp already matches must write no config"
2066        );
2067    }
2068
2069    /// 04/04, criteria 9 and 10: skew reaches the write that meets it, before
2070    /// that write's own restamp erases the evidence, and the write still
2071    /// lands.
2072    ///
2073    /// Boot-only detection meant a long-lived server started under one binary
2074    /// and written to by another never said so, because the first mutation
2075    /// both revealed and hid the fact.
2076    #[test]
2077    fn skew_is_reported_at_the_write_and_the_write_still_lands() {
2078        let tmp = TempDir::new().unwrap();
2079        let mem_dir = tmp.path().to_path_buf();
2080        write_config(
2081            &mem_dir,
2082            Some(memstead_schema::MutationStamp {
2083                engine_version: "0.0.1".to_string(),
2084                schema: "default@1.0.0".to_string(),
2085            }),
2086        );
2087        let mut engine = stamped_engine_fixture(mem_dir.clone());
2088        let outcome = engine
2089            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
2090            .unwrap();
2091
2092        let skew: Vec<_> = outcome
2093            .warnings
2094            .iter()
2095            .filter(|w| w.code() == "ENGINE_VERSION_SKEW")
2096            .collect();
2097        assert_eq!(
2098            skew.len(),
2099            1,
2100            "the write that meets the skew must report it: {:?}",
2101            outcome.warnings
2102        );
2103        assert!(
2104            matches!(
2105                skew[0],
2106                crate::ops::WarningHint::EngineVersionSkew {
2107                    direction: crate::build_info::SkewDirection::StampedOlder,
2108                    ..
2109                }
2110            ),
2111            "and say which way: {:?}",
2112            skew[0]
2113        );
2114        // Criterion 10: it landed. An older engine is not prevented from
2115        // writing; a deliberate downgrade is the operator's business.
2116        assert!(engine.get_entity(&outcome.id).is_some());
2117        assert_eq!(
2118            disk_stamp(&mem_dir).map(|s| s.engine_version),
2119            Some(crate::build_info::full_version().to_string()),
2120            "and the restamp still happened"
2121        );
2122
2123        // Second write, same binary: nothing left to report.
2124        let again = engine
2125            .create_entity_with_ctx(spec_create_args("Second"), &CommitContext::internal())
2126            .unwrap();
2127        assert!(
2128            !again
2129                .warnings
2130                .iter()
2131                .any(|w| w.code() == "ENGINE_VERSION_SKEW"),
2132            "the skew is resolved once restamped: {:?}",
2133            again.warnings
2134        );
2135    }
2136
2137    /// Criterion 8's complement at the write tier: a stamp from the same
2138    /// release with a different build hash is not skew, so a workspace whose
2139    /// binary is rebuilt from source is not told its engine disagrees on
2140    /// every mutation.
2141    #[test]
2142    fn a_rebuild_of_the_same_release_is_not_skew_at_the_write() {
2143        let tmp = TempDir::new().unwrap();
2144        let mem_dir = tmp.path().to_path_buf();
2145        write_config(
2146            &mem_dir,
2147            Some(memstead_schema::MutationStamp {
2148                engine_version: format!("{}+gdeadbee", crate::ENGINE_VERSION),
2149                schema: "default@1.0.0".to_string(),
2150            }),
2151        );
2152        let mut engine = stamped_engine_fixture(mem_dir.clone());
2153        let outcome = engine
2154            .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
2155            .unwrap();
2156        assert!(
2157            !outcome
2158                .warnings
2159                .iter()
2160                .any(|w| w.code() == "ENGINE_VERSION_SKEW"),
2161            "a differing build hash on the same version is not skew: {:?}",
2162            outcome.warnings
2163        );
2164    }
2165
2166    /// Criterion 3/4 (agent-trust plan 02): boot under a different
2167    /// binary version surfaces the warn-tier `ENGINE_VERSION_SKEW`
2168    /// naming both versions, on load warnings AND in `health()`;
2169    /// a stamp-less mem and a matching stamp are silent.
2170    #[test]
2171    fn boot_skew_warning_fires_only_on_disagreeing_stamp() {
2172        use crate::ops::WarningHint;
2173
2174        // Disagreeing stamp → warning on boot and in health.
2175        let tmp = TempDir::new().unwrap();
2176        let mem_dir = tmp.path().to_path_buf();
2177        write_config(
2178            &mem_dir,
2179            Some(memstead_schema::MutationStamp {
2180                engine_version: "0.0.1".to_string(),
2181                schema: "default@1.0.0".to_string(),
2182            }),
2183        );
2184        let engine = stamped_engine_fixture(mem_dir);
2185        let skew: Vec<_> = engine
2186            .load_warnings()
2187            .iter()
2188            .filter(|w| matches!(w, WarningHint::EngineVersionSkew { .. }))
2189            .collect();
2190        assert_eq!(skew.len(), 1, "one skewed mem, one warning: {skew:?}");
2191        if let WarningHint::EngineVersionSkew {
2192            mem,
2193            stamped_engine,
2194            running_engine,
2195            stamped_schema,
2196            direction,
2197        } = skew[0]
2198        {
2199            assert_eq!(mem, "specs");
2200            assert_eq!(stamped_engine, "0.0.1");
2201            assert_eq!(running_engine, crate::build_info::full_version());
2202            assert_eq!(stamped_schema, "default@1.0.0");
2203            // 0.0.1 against any shipped version: the mem is behind us.
2204            assert_eq!(*direction, crate::build_info::SkewDirection::StampedOlder);
2205        }
2206        let health = engine.health();
2207        assert!(
2208            health
2209                .warnings
2210                .iter()
2211                .any(|w| w.code() == "ENGINE_VERSION_SKEW"),
2212            "health() must surface the skew without an include gate: {:?}",
2213            health.warnings,
2214        );
2215
2216        // Matching stamp → silent.
2217        let tmp = TempDir::new().unwrap();
2218        let mem_dir = tmp.path().to_path_buf();
2219        write_config(
2220            &mem_dir,
2221            Some(memstead_schema::MutationStamp {
2222                engine_version: crate::build_info::full_version().to_string(),
2223                schema: "default@1.0.0".to_string(),
2224            }),
2225        );
2226        let engine = stamped_engine_fixture(mem_dir);
2227        assert!(
2228            !engine
2229                .load_warnings()
2230                .iter()
2231                .any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
2232            "a matching stamp is not skew"
2233        );
2234
2235        // No stamp → silent (absence of a stamp is not skew).
2236        let tmp = TempDir::new().unwrap();
2237        let mem_dir = tmp.path().to_path_buf();
2238        write_config(&mem_dir, None);
2239        let engine = stamped_engine_fixture(mem_dir);
2240        assert!(
2241            !engine
2242                .load_warnings()
2243                .iter()
2244                .any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
2245            "a stamp-less (pre-plan) mem boots without warning noise"
2246        );
2247    }
2248}