Skip to main content

memstead_base/engine/mutation/
mod.rs

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