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 parse_recovery;
22pub mod relate;
23pub mod rename;
24pub mod update;
25
26/// Look up an entity's `(title, entity_type)` pair in `store`. Both
27/// `None` for missing-from-store ids — matches full's `title_for` /
28/// `type_for` lossy-lookup contract. Used by [`Engine::changes_since`]
29/// to enrich id-only envelopes the backend returned with metadata
30/// from the in-memory store.
31pub(super) fn lookup_title_and_type(
32    store: &Store,
33    id: &EntityId,
34) -> (Option<String>, Option<String>) {
35    match store.get(id) {
36        Some(e) => (Some(e.title.clone()), Some(e.entity_type.clone())),
37        None => (None, None),
38    }
39}
40
41/// Maximum byte length of the truncated `current_content` snapshot
42/// that [`EngineError::PatchOldNotFound`] carries. Keeps the wire
43/// envelope bounded for sections with large bodies. Mirrors full's
44/// `memstead_git_branch::PATCH_OLD_NOT_FOUND_CONTENT_CAP`.
45pub const PATCH_OLD_NOT_FOUND_CONTENT_CAP: usize = 500;
46
47/// Maximum number of entity IDs retained in
48/// [`EngineError::RelationshipCycle::existing_path`]. Keeps the cycle
49/// envelope bounded for pathologically long chains. Mirrors full's
50/// `memstead_git_branch::RELATIONSHIP_CYCLE_PATH_CAP`.
51pub const RELATIONSHIP_CYCLE_PATH_CAP: usize = 20;
52
53/// Build an [`EngineError::UnknownType`] populated with the schema's
54/// declared type names (sorted) and a fuzzy suggestion. Mirrors full's
55/// `UnknownEntityType` recovery payload so MCP envelopes carry the
56/// same `name` / `schema_ref` / `declared` / `suggestion` keys
57/// regardless of which engine served the call.
58pub(crate) fn unknown_type_error(schema: &memstead_schema::Schema, attempted: &str) -> EngineError {
59    let mut declared: Vec<String> = schema.types.keys().cloned().collect();
60    declared.sort();
61    let (sname, sver) = schema.id();
62    EngineError::UnknownType {
63        name: attempted.to_string(),
64        schema_ref: format!("{sname}@{sver}"),
65        declared,
66        suggestion: schema.suggest_type(attempted),
67    }
68}
69
70/// The lowercase wire string for a [`crate::pipeline::MediumType`] — the
71/// value the `INVALID_ANCHOR` recovery detail carries so an agent sees
72/// which medium's namespace rejected the grain. Matches the enum's
73/// `#[serde(rename_all = "lowercase")]` form.
74pub(crate) fn medium_type_wire(t: crate::pipeline::MediumType) -> &'static str {
75    use crate::pipeline::MediumType::*;
76    match t {
77        Codebase => "codebase",
78        Filesystem => "filesystem",
79        Graph => "graph",
80        Git => "git",
81        Web => "web",
82    }
83}
84
85impl super::Engine {
86    /// Resolve the single-medium anchor-namespace context for `mem`, when
87    /// unambiguous. An `anchors[]` element carries no medium name, so the
88    /// grain/namespace refusal ([`crate::anchor::AnchorValidationError::GrainNamespaceUnsupported`])
89    /// can only be fired deterministically when the mem declares exactly
90    /// one medium; with zero or several the namespace check is skipped
91    /// (the vocabulary + hash-semantics rules still apply). Returns the
92    /// `(medium_type_wire, anchor_namespace)` pair the validator consumes.
93    pub(crate) fn resolve_anchor_medium(&self, mem: &str) -> Option<(String, &'static str)> {
94        let mut mediums = self
95            .pipeline_configs()
96            .mediums
97            .iter()
98            .filter(|r| r.mem == mem);
99        let first = mediums.next()?;
100        if mediums.next().is_some() {
101            // Ambiguous — the anchor does not name which medium it targets;
102            // skip the namespace refinement rather than guess.
103            return None;
104        }
105        let caps = crate::binding::medium_capabilities(first.config.medium_type);
106        Some((
107            medium_type_wire(first.config.medium_type).to_string(),
108            caps.anchor_namespace,
109        ))
110    }
111
112    /// Validate the permissive `anchors[]` inputs for a mutation against
113    /// `mem`'s medium context into strict [`crate::anchor::Anchor`]s, or
114    /// refuse the whole mutation with a typed
115    /// [`EngineError::InvalidAnchor`]. Empty input yields an empty vec (no
116    /// sidecar write); a single malformed element aborts before any state
117    /// change so the entity is never written.
118    pub(crate) fn validate_anchor_inputs(
119        &self,
120        mem: &str,
121        inputs: &[crate::anchor::AnchorInput],
122    ) -> Result<Vec<crate::anchor::Anchor>, EngineError> {
123        if inputs.is_empty() {
124            return Ok(Vec::new());
125        }
126        let medium = self.resolve_anchor_medium(mem);
127        let medium_ref = medium.as_ref().map(|(t, ns)| (t.as_str(), *ns));
128        inputs
129            .iter()
130            .map(|i| i.validate(medium_ref).map_err(EngineError::from))
131            .collect()
132    }
133}
134
135/// Stage a write of `entity_id`'s anchors into the mem's anchors sidecar
136/// through `backend`, merged over the existing sidecar and buffered into
137/// the SAME pending op set the entity write used — so the next
138/// [`crate::backend::MemBackend::commit`] carries entity + anchors as one
139/// atomic commit. An empty `anchors` vec prunes the entity's row (the
140/// delete / rename-residual legs lean on this). Reads honour pending-buffer
141/// precedence, so successive stages within one transaction compose.
142pub(crate) fn stage_anchors_sidecar(
143    backend: &dyn crate::backend::MemBackend,
144    entity_id: &EntityId,
145    anchors: Vec<crate::anchor::Anchor>,
146) -> Result<(), EngineError> {
147    let mut sidecar = match backend.read_anchors_sidecar()? {
148        Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
149            EngineError::Backend(crate::backend::BackendError::Other(format!(
150                "anchors sidecar parse: {e}"
151            )))
152        })?,
153        None => crate::anchor::AnchorSidecar::default(),
154    };
155    sidecar.set(entity_id.as_ref(), anchors);
156    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
157    Ok(())
158}
159
160/// Load the mem's anchors sidecar through `backend`, or the empty
161/// document when none exists yet. Shared by the delete / rename legs
162/// which must decide whether the entity actually has anchor rows before
163/// staging a sidecar write (so an entity with none stays byte-identical
164/// to a pre-anchor mutation).
165fn read_sidecar(
166    backend: &dyn crate::backend::MemBackend,
167) -> Result<crate::anchor::AnchorSidecar, EngineError> {
168    match backend.read_anchors_sidecar()? {
169        Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
170            EngineError::Backend(crate::backend::BackendError::Other(format!(
171                "anchors sidecar parse: {e}"
172            )))
173        }),
174        None => Ok(crate::anchor::AnchorSidecar::default()),
175    }
176}
177
178/// Stage removal of `entity_id`'s anchor row into the same commit as an
179/// entity delete — a no-op (no sidecar write, so byte-identical to today)
180/// when the entity carries no anchors. Returns whether a write was staged.
181pub(crate) fn stage_anchors_removal(
182    backend: &dyn crate::backend::MemBackend,
183    entity_id: &EntityId,
184) -> Result<bool, EngineError> {
185    let mut sidecar = read_sidecar(backend)?;
186    if sidecar.get(entity_id.as_ref()).is_empty() {
187        return Ok(false);
188    }
189    sidecar.remove(entity_id.as_ref());
190    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
191    Ok(true)
192}
193
194/// Stage a move of `from`'s anchor row to `to` into the same commit as an
195/// entity rename — leaving zero rows under the old id. A no-op (byte-
196/// identical to today) when the renamed entity carries no anchors. Returns
197/// whether a write was staged.
198pub(crate) fn stage_anchors_rename(
199    backend: &dyn crate::backend::MemBackend,
200    from: &EntityId,
201    to: &EntityId,
202) -> Result<bool, EngineError> {
203    let mut sidecar = read_sidecar(backend)?;
204    if sidecar.get(from.as_ref()).is_empty() {
205        return Ok(false);
206    }
207    sidecar.rename(from.as_ref(), to.as_ref());
208    backend.write_anchors_sidecar(&sidecar.to_bytes())?;
209    Ok(true)
210}
211
212/// Now as a full ISO-8601 datetime string `YYYY-MM-DDTHH:MM:SSZ`
213/// (UTC). Used by mutation paths that auto-stamp metadata fields
214/// (e.g. `last_modified` on update, `created_date` on create).
215///
216/// This is second-resolution (rather than
217/// date-only `YYYY-MM-DD`) so intra-day
218/// updates produce distinguishable timestamps and drift / staleness
219/// queries become per-update aware. The strict-mode date validator
220/// already accepts both forms (`^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$`)
221/// so existing entities written with the date-only form continue to
222/// load; new writes carry the wider form.
223///
224/// Pure function: no allocation outside the `format!` invocation,
225/// no error path (the system-clock fallback to UNIX epoch on a
226/// clock that hasn't been set yet is acceptable for a best-effort
227/// timestamp). Howard-Hinnant civil-from-days for the date half;
228/// trivial modular arithmetic for the time half.
229pub(super) fn today_iso() -> String {
230    let now = std::time::SystemTime::now()
231        .duration_since(std::time::UNIX_EPOCH)
232        .unwrap_or_default();
233    let secs = now.as_secs();
234    let days = secs / 86400;
235    let secs_of_day = secs % 86400;
236    let hh = secs_of_day / 3600;
237    let mm = (secs_of_day % 3600) / 60;
238    let ss = secs_of_day % 60;
239    let z = days + 719468;
240    let era = z / 146097;
241    let doe = z - era * 146097;
242    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
243    let y = yoe + era * 400;
244    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
245    let mp = (5 * doy + 2) / 153;
246    let d = doy - (153 * mp + 2) / 5 + 1;
247    let m = if mp < 10 { mp + 3 } else { mp - 9 };
248    let y = if m <= 2 { y + 1 } else { y };
249    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
250}
251
252/// Sweep stubs whose last incoming edge has just disappeared. Returns
253/// the dropped ids so callers can surface them to the agent (e.g. via
254/// [`DeleteEntityOutcome::orphan_stubs_removed`]).
255///
256/// Stubs are auto-created when a relate names an absent target — a
257/// "promise" that a real entity will land there later (see
258/// [`make_stub`]). When the last referrer drops its edge or is itself
259/// deleted, the promise has no holder and becomes pure bloat. Only
260/// stubs are eligible — real entities never count as orphans via this
261/// path.
262pub(super) fn gc_orphan_stubs(store: &mut Store) -> Vec<EntityId> {
263    let stub_ids: Vec<EntityId> = store
264        .all_entities()
265        .filter(|e| e.stub)
266        .map(|e| e.id.clone())
267        .collect();
268    gc_orphan_stubs_among(store, &stub_ids)
269}
270
271/// Scoped orphan-stub sweep: GC only the stubs *among `candidates`*
272/// whose last incoming edge has just disappeared, returning the dropped
273/// ids. This is the single home of the orphan-stub predicate (`stub &&
274/// no incoming`) — the three write paths that can sever a stub's last
275/// referrer all funnel through here so they cannot drift:
276/// [`gc_orphan_stubs`] (delete's full-store sweep) supplies every stub
277/// id; the `memstead_relate(remove)` path supplies the just-severed target;
278/// the `memstead_update` alias-resync path supplies the entity's
279/// pre-mutation body-link targets (the only edges that commit could
280/// have dropped). Scoping to a candidate set rather than walking the
281/// whole store keeps each path from GC'ing pre-existing orphans that
282/// aren't its responsibility. Candidates are de-duplicated; a candidate
283/// that is absent, not a stub, or still has a referrer is left
284/// untouched.
285pub(super) fn gc_orphan_stubs_among<'a>(
286    store: &mut Store,
287    candidates: impl IntoIterator<Item = &'a EntityId>,
288) -> Vec<EntityId> {
289    let mut removed: Vec<EntityId> = Vec::new();
290    let mut seen: std::collections::HashSet<&EntityId> = std::collections::HashSet::new();
291    for id in candidates {
292        if !seen.insert(id) {
293            continue;
294        }
295        if store.get(id).is_some_and(|e| e.stub) && store.incoming(id).is_empty() {
296            store.remove(id);
297            removed.push(id.clone());
298        }
299    }
300    removed
301}
302
303/// Shared target-id grammar validator. The wiki-link grammar gate
304/// runs on every relation-authoring path (`memstead_relate`,
305/// `memstead_create.relations[]`, future inline-relation surfaces) so a
306/// malformed target id (e.g. `bad@chars$here`) cannot land an
307/// auto-stub at the literal id — that stub would later fail every
308/// wiki-link parse that referenced it. Pre-Item-02 the gate lived
309/// only on `memstead_relate`; the create path admitted the same input
310/// silently.
311pub(super) fn validate_relation_target_grammar(target: &EntityId) -> Result<(), EngineError> {
312    if let Err(reason) = crate::entity::id::validate_mem_name_grammar(target.mem()) {
313        return Err(EngineError::InvalidEntityId {
314            id: target.to_string(),
315            reason,
316        });
317    }
318    if let Err(reason) = crate::entity::id::validate_id_path_grammar(target.path()) {
319        return Err(EngineError::InvalidEntityId {
320            id: target.to_string(),
321            reason,
322        });
323    }
324    Ok(())
325}
326
327/// Auto-stamp `auto_timestamp` metadata fields on an entity that's
328/// about to be re-written. Extracted from the update-path hot loop so
329/// the relate-path (add and remove) and the rename-path (the renaming
330/// entity plus every referrer the rewrite cascade touched) can
331/// invoke the same engine-driven stamp.
332///
333/// Walks the type's metadata-field declarations; any field flagged
334/// `auto_timestamp: true` (the default schema declares this on
335/// `last_modified`) is set to the supplied `today` ISO string. The
336/// helper is a no-op on schemas that declare no auto-timestamp
337/// fields. Callers pre-compute `today` via [`today_iso`] so a single
338/// mutation that touches multiple entities (rename's referrer rewrite
339/// cascade) stamps them all with the same value.
340pub(super) fn auto_stamp_timestamps(
341    entity: &mut Entity,
342    type_def: &memstead_schema::TypeDefinition,
343    today: &str,
344) {
345    for field_def in &type_def.metadata_fields {
346        if field_def.auto_timestamp {
347            entity.metadata.insert(
348                field_def.key.clone(),
349                crate::entity::MetadataValue::String(today.to_string()),
350            );
351        }
352    }
353}
354
355/// Build a stub [`Entity`] for an unresolved relate target. Callers
356/// declare the stub's origin via [`crate::entity::StubKind`] —
357/// `ForwardReference` for `memstead_relate` to an absent target,
358/// `Residual { since_commit, readonly_referrers }` for the
359/// delete/rename demote path. The kind persists for the engine
360/// instance's lifetime; a reload reduces every stub to `LoadTime`
361/// — the kind is annotation, not state.
362///
363/// The stub is in-store but unwritten to disk — `entity_type` empty,
364/// `file_path` empty, no metadata, no sections, `stub: true` and
365/// `stub_kind: Some(kind)` set together. A later
366/// [`Engine::create_entity`] at the same id promotes the stub to a
367/// real entity (loader / parse-result merge handles the upgrade
368/// path; `stub_kind` clears to `None`).
369pub(super) fn make_stub(id: &EntityId, kind: crate::entity::StubKind) -> Entity {
370    Entity {
371        id: id.clone(),
372        title: id.name().to_string(),
373        entity_type: String::new(),
374        mem: id.mem().to_string(),
375        file_path: String::new(),
376        metadata: IndexMap::new(),
377        sections: IndexMap::new(),
378        relationships: Vec::new(),
379        content_hash: String::new(),
380        stub: true,
381        stub_kind: Some(kind),
382        heading_spans: HashMap::new(),
383    }
384}
385
386/// Cross-mem add-path policy gate. Same-mem writes bypass; the
387/// `[cross_mem_links]` table only gates writes that cross the
388/// mem boundary. Cross-mem writes consult
389/// [`super::Engine::cross_mem_link_allowed`] in the edge's actual
390/// direction (`source_mem → target_mem`). Disallowed pairings
391/// surface [`EngineError::CrossMemLinkNotAllowed`] with the
392/// `(from_mem, to_mem)` payload an agent already sees on
393/// `memstead_relate`.
394///
395/// After the grant admits the pairing, a target absent from a
396/// `MountCapability::ReadOnly` mount refuses with
397/// [`EngineError::CrossMemTargetNotFound`]: the engine cannot
398/// persist a stub through the read-only boundary, and a read-only
399/// mem never gains the entity later — a missing target there is a
400/// wrong link, not a pending forward reference. Same-mem targets,
401/// cross-mem targets in Write mounts, and unmounted target mems all
402/// retain the auto-stub mechanic.
403///
404/// Funnel point for every add-shaped edge write — `memstead_relate`,
405/// `memstead_create.relations[]`, `memstead_update.declare_relations`,
406/// body-wiki-link alias synthesis, and any future add-path mutation
407/// surface route through one gate so the policy can't drift between
408/// sites. Remove-shaped writes (cleanup) remain permissive and call
409/// this helper not at all.
410pub(super) fn validate_cross_mem_add_policy(
411    engine: &super::Engine,
412    source_mem: &str,
413    target: &EntityId,
414) -> Result<(), EngineError> {
415    let target_mem = target.mem();
416    if source_mem == target_mem {
417        return Ok(());
418    }
419    if !engine.cross_mem_link_allowed(source_mem, target_mem) {
420        return Err(EngineError::CrossMemLinkNotAllowed {
421            from_mem: source_mem.to_string(),
422            to_mem: target_mem.to_string(),
423        });
424    }
425    if let Some(mount) = engine.mount(target_mem)
426        && mount.capability == crate::workspace::MountCapability::ReadOnly
427        && !engine.store.contains(target)
428    {
429        return Err(EngineError::CrossMemTargetNotFound {
430            target_id: target.to_string(),
431            target_mem: target_mem.to_string(),
432        });
433    }
434    Ok(())
435}
436
437/// Outcome of the engine's edge-validation router for a single
438/// inline / explicit relate. Carries the optional open-mode warning
439/// from the intra-mem flow; the cross-mem flow has no
440/// open-mode (cross-mem entries are declared vocabulary).
441pub(super) enum EdgeRouteOutcome {
442    Ok,
443    OpenModeWarning(Box<crate::ops::WarningHint>),
444}
445
446/// Run rel-type + shape validation for one edge, routing through
447/// intra-mem vocabulary or the source schema's
448/// `cross_mem_relationships:` section as appropriate.
449///
450/// The routing rule:
451/// when `source_mem != target_mem` AND the target mem's
452/// pinned schema differs from the source schema by name or by
453/// version, the source schema's `cross_mem_relationships:` entry
454/// for the target schema is the sole authority for both the
455/// vocabulary check (`INVALID_REL_TYPE`) and the shape check
456/// (`INVALID_REL_SHAPE`). If no matching entry exists, surface
457/// [`EngineError::CrossMemEdgeNotDeclared`].
458///
459/// Otherwise (same-mem, same-schema cross-mem, or target mem
460/// unmounted) the call falls through to the existing intra-mem
461/// validators — the same behaviour the intra-mem path always had.
462///
463/// `check_shape` mirrors the relate path's add-only shape posture:
464/// pass `false` to skip the shape check (currently only the
465/// `memstead_relate --remove` path). The vocabulary check still fires
466/// in that case, matching the intra-mem behaviour where
467/// `validate_rel_type` runs on both add and remove.
468// The nine parameters are one edge's full coordinates; a params struct
469// would restate the same fields at every call site without grouping
470// anything that travels together elsewhere.
471#[allow(clippy::too_many_arguments)]
472pub(super) fn route_edge_validation(
473    engine: &super::Engine,
474    rel_type: &str,
475    from_type: &str,
476    to_type: Option<&str>,
477    source_mem: &str,
478    target_mem: &str,
479    from_id: &EntityId,
480    to_id: &EntityId,
481    check_shape: bool,
482) -> Result<EdgeRouteOutcome, EngineError> {
483    use crate::runtime_validator::{
484        CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
485        validate_rel_type,
486    };
487    use memstead_schema::SchemaRef;
488
489    let source_schema = engine
490        .schemas
491        .get(source_mem)
492        .expect("schema present for every registered mount");
493
494    let target_schema_arc = if source_mem == target_mem {
495        None
496    } else {
497        engine.schemas.get(target_mem).cloned()
498    };
499    let target_schema_ref: Option<SchemaRef> = target_schema_arc.as_ref().map(|s| {
500        let (name, version) = s.id();
501        SchemaRef::new(name, version)
502    });
503    let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
504        (Some(target), (src_name, _)) => target.name != src_name,
505        (None, _) => false,
506    };
507
508    if cross_mem_different {
509        let target_ref = target_schema_ref
510            .as_ref()
511            .expect("target_schema_ref is Some when cross_mem_different");
512        if !check_shape {
513            // Cleanup posture: cross-mem remove stays permissive so
514            // pre-tightening edges remain droppable without first
515            // re-declaring them. Mirrors the intra-mem shape gate's
516            // add-only stance.
517            return Ok(EdgeRouteOutcome::Ok);
518        }
519        match validate_cross_mem_edge(
520            rel_type,
521            from_type,
522            to_type,
523            source_schema.as_ref(),
524            target_ref,
525        ) {
526            CrossMemRelCheck::Ok => Ok(EdgeRouteOutcome::Ok),
527            CrossMemRelCheck::EdgeNotDeclared => {
528                let (src_name, src_version) = source_schema.id();
529                Err(EngineError::CrossMemEdgeNotDeclared {
530                    source_schema: SchemaRef::new(src_name, src_version).as_display(),
531                    target_schema: target_ref.as_display(),
532                    rel_type: rel_type.to_string(),
533                    from_id: from_id.to_string(),
534                    to_id: to_id.to_string(),
535                })
536            }
537            CrossMemRelCheck::Invalid(v) => Err(EngineError::Validation(v)),
538        }
539    } else {
540        let warning_hint = match validate_rel_type(rel_type, source_schema.as_ref())? {
541            RelationshipCheck::Ok => None,
542            RelationshipCheck::OpenWarning(message) => {
543                Some(crate::ops::WarningHint::UndeclaredRelationshipOpen {
544                    rel_type: rel_type.to_string(),
545                    message,
546                })
547            }
548        };
549        if check_shape {
550            validate_rel_shape(rel_type, from_type, to_type, source_schema.as_ref())?;
551        }
552        Ok(match warning_hint {
553            Some(w) => EdgeRouteOutcome::OpenModeWarning(Box::new(w)),
554            None => EdgeRouteOutcome::Ok,
555        })
556    }
557}
558
559/// Validate the per-edge description posture declared on the rel-type
560/// in the routing-appropriate definition (intra-mem when source and
561/// target share the schema; cross-mem entry when they don't). Emits
562/// `MissingRequiredDescription` / `DescriptionNotPermitted` on
563/// violations; `optional` and unknown rel-types are no-ops (the
564/// vocabulary / shape gates already catch undeclared names — posture
565/// only fires for declared names).
566///
567/// `description` is the normalised value (empty / whitespace-only
568/// collapses to `None` before reaching this gate). Called from every
569/// add path: `memstead_relate`, `declare_relations` on `memstead_create` and
570/// `memstead_update`.
571pub(super) fn validate_description_posture(
572    engine: &super::Engine,
573    rel_type: &str,
574    description: Option<&str>,
575    source_mem: &str,
576    target_mem: &str,
577    from_id: &EntityId,
578    to_id: &EntityId,
579) -> Result<(), EngineError> {
580    use memstead_schema::{PerEdgeDescription, SchemaRef};
581
582    let source_schema = engine
583        .schemas
584        .get(source_mem)
585        .expect("schema present for every registered mount");
586    let target_schema_arc = if source_mem == target_mem {
587        None
588    } else {
589        engine.schemas.get(target_mem).cloned()
590    };
591    let target_schema_ref: Option<SchemaRef> = target_schema_arc.as_ref().map(|s| {
592        let (name, version) = s.id();
593        SchemaRef::new(name, version)
594    });
595    let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
596        (Some(target), (src_name, _)) => target.name != src_name,
597        (None, _) => false,
598    };
599
600    let posture = if cross_mem_different {
601        // Look up the matching cross-mem entry's definition. If the
602        // entry exists but the rel-type isn't enumerated under it, the
603        // vocabulary gate (route_edge_validation) will surface
604        // `CROSS_MEM_EDGE_NOT_DECLARED`; posture is a no-op there.
605        let target_ref = target_schema_ref
606            .as_ref()
607            .expect("target_schema_ref is Some when cross_mem_different");
608        source_schema
609            .cross_mem_entry(&target_ref.name)
610            .and_then(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
611            .map(|d| d.per_edge_description)
612    } else {
613        source_schema
614            .relationship_def(rel_type)
615            .map(|d| d.per_edge_description)
616    };
617
618    match posture {
619        Some(PerEdgeDescription::Required) if description.is_none() => {
620            Err(EngineError::MissingRequiredDescription {
621                rel_type: rel_type.to_string(),
622                from_id: from_id.to_string(),
623                to_id: to_id.to_string(),
624            })
625        }
626        Some(PerEdgeDescription::Forbidden) if description.is_some() => {
627            Err(EngineError::DescriptionNotPermitted {
628                rel_type: rel_type.to_string(),
629                from_id: from_id.to_string(),
630                to_id: to_id.to_string(),
631            })
632        }
633        _ => Ok(()),
634    }
635}
636
637/// Validate the manual-authoring posture declared on the rel-type.
638/// Fires only on explicit-author paths (`memstead_relate`, inline
639/// `relations:` on `memstead_create`, `declare_relations` on
640/// `memstead_update`). The body-link → relation alias machinery
641/// synthesises relations from wiki-links — that path bypasses this
642/// gate by construction (it never calls this function), keeping the
643/// alias path for `manual_authoring: forbidden` rel-types (e.g.
644/// REFERENCES) intact.
645pub(super) fn validate_manual_authoring_posture(
646    engine: &super::Engine,
647    rel_type: &str,
648    source_mem: &str,
649    from_id: &EntityId,
650    to_id: &EntityId,
651) -> Result<(), EngineError> {
652    use memstead_schema::ManualAuthoring;
653
654    let source_schema = engine
655        .schemas
656        .get(source_mem)
657        .expect("schema present for every registered mount");
658    let posture = source_schema.relationship_manual_authoring(rel_type);
659    if matches!(posture, ManualAuthoring::Forbidden) {
660        let guidance = source_schema
661            .relationship_when_to_use(rel_type)
662            .unwrap_or_default();
663        return Err(EngineError::RelationManualAuthoringForbidden {
664            rel_type: rel_type.to_string(),
665            from_id: from_id.to_string(),
666            to_id: to_id.to_string(),
667            guidance,
668        });
669    }
670    Ok(())
671}
672
673/// Alias-synthesis pass — populates `next.relationships` with engine-
674/// emitted relations of the source schema's `alias_target_rel_type`
675/// pointer for every body wiki-link not already backed by an
676/// in-section-body explicit relation. Runs before the
677/// `scan_wikilinks_without_relation` validator; after this pass the
678/// validator finds zero missing wiki-links for the pointer rel-type.
679///
680/// Three cases:
681/// 1. Schema has no pointer (`alias_target_rel_type` absent): no-op.
682///    Caller's validator continues to refuse unbacked links exactly as
683///    today.
684/// 2. Schema has a pointer, body wiki-link target is in the same mem
685///    OR cross-mem policy admits it: append `Relationship { rel_type:
686///    pointer, target, description: None }` to `next.relationships` if
687///    no relation of `(pointer, target)` is already present. Dedupe is
688///    `(target, rel_type)` — a USES or DEPENDS_ON edge to the same
689///    target does not suppress synthesis of the pointer rel-type.
690/// 3. Schema has a pointer but a body wiki-link crosses a mem
691///    boundary the workspace doesn't grant — or targets an entity
692///    absent from a read-only mount: return the funnel's typed
693///    refusal ([`EngineError::CrossMemLinkNotAllowed`] /
694///    [`EngineError::CrossMemTargetNotFound`], via
695///    [`validate_cross_mem_add_policy`]). The entire mutation
696///    aborts — no partial state.
697///
698/// GC: when `prev` is `Some`, the pass also drops pointer-rel-type
699/// relations whose target was a body wiki-link in `prev` but no longer
700/// appears in `next.sections`. The loader forces `manual_authoring:
701/// forbidden` on every schema's `alias_target_rel_type` pointer, so the
702/// only path to a pointer-rel-type edge is the body-link channel; the
703/// GC rule therefore reduces to "drop pointer-rel-type relations whose
704/// target is not in the new body". Targeting prev's wiki-link set
705/// specifically (rather than every pointer-rel-type relation) keeps the
706/// pass correct even for an explicit-author relation that predates the
707/// forbid posture.
708///
709/// Returns the list of relations the pass emitted (in body iteration
710/// order) — `create.rs` / `update.rs` use it to surface
711/// `relations_emitted` on the response envelope.
712/// Returns the synthesised relations (in body iteration order) and a flag
713/// signalling whether a body wiki-link to the entity's own id was dropped
714/// (F11). The caller surfaces that as a `SELF_LINK_IGNORED` warning — the
715/// pass has no warning channel of its own.
716pub(super) fn synthesise_alias_relations(
717    engine: &super::Engine,
718    prev_body_targets: &std::collections::HashSet<EntityId>,
719    next: &mut Entity,
720) -> Result<(Vec<crate::entity::Relationship>, bool), super::EngineError> {
721    let schema = engine
722        .schemas
723        .get(next.mem.as_str())
724        .expect("schema present for every registered mount");
725    let Some(pointer) = schema.alias_target_rel_type().map(str::to_string) else {
726        return Ok((Vec::new(), false));
727    };
728
729    // 1. GC: drop pointer-rel-type relations whose target was a body
730    //    wiki-link in the prev entity state but isn't in next. Targets
731    //    not in prev's wiki-link set are explicit-author relations and
732    //    are never touched — the rule preserves explicit edges even
733    //    while the 5 built-ins still admit explicit REFERENCES.
734    //
735    //    `extract_inline_links` is strict — non-slug-form targets refuse
736    //    here with the typed `InvalidWikiLinkTarget` envelope rather
737    //    than silently flowing into the GC's retain set as malformed
738    //    EntityIds. Section context comes from the iteration key.
739    let mut next_targets: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
740    for (section_key, body) in next.sections.iter() {
741        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
742            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
743        next_targets.extend(ids);
744    }
745    next.relationships.retain(|r| {
746        !(r.rel_type == pointer
747            && prev_body_targets.contains(&r.target)
748            && !next_targets.contains(&r.target))
749    });
750
751    // 2. Walk body wiki-links in section iteration order and append
752    //    one relation per `(target, pointer)` pair not already
753    //    present. Cross-mem gate fires on the first refusal.
754    let existing: std::collections::HashSet<(String, EntityId)> = next
755        .relationships
756        .iter()
757        .map(|r| (r.rel_type.clone(), r.target.clone()))
758        .collect();
759    let mut emitted: Vec<crate::entity::Relationship> = Vec::new();
760    let mut already_synthesised: std::collections::HashSet<EntityId> =
761        std::collections::HashSet::new();
762    let mut self_link_ignored = false;
763    for (section_key, body) in next.sections.iter() {
764        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
765            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
766        for target in ids {
767            // F11: a body wiki-link to the entity's own id is a vacuous
768            // self-edge (renders as both Outgoing and Incoming, inflates
769            // connectivity). Drop it — but don't refuse: the author may
770            // have written their own slug. The caller surfaces
771            // `SELF_LINK_IGNORED` so the dropped link stays observable.
772            if target == next.id {
773                self_link_ignored = true;
774                continue;
775            }
776            let key = (pointer.clone(), target.clone());
777            if existing.contains(&key) || already_synthesised.contains(&target) {
778                continue;
779            }
780            validate_cross_mem_add_policy(engine, &next.mem, &target)?;
781            let rel = crate::entity::Relationship::new(pointer.clone(), target.clone());
782            next.relationships.push(rel.clone());
783            already_synthesised.insert(target);
784            emitted.push(rel);
785        }
786    }
787    Ok((emitted, self_link_ignored))
788}
789
790/// Map the first [`crate::entity::id::WikiLinkError`] from a body
791/// wiki-link extraction into the typed [`EngineError`] envelope,
792/// attaching the offending section's key. Errors after the first are
793/// dropped — the agent reads the error, fixes the link, retries, and
794/// surfaces the next one on the follow-up call. Keeps the envelope
795/// shape stable (single typed payload rather than a list) so MCP /
796/// CLI / UniFFI clients don't need a fan-out renderer.
797pub(super) fn map_wiki_link_errors(
798    section_key: &str,
799    errors: Vec<crate::entity::id::WikiLinkError>,
800) -> EngineError {
801    use crate::entity::id::WikiLinkError;
802    let first = errors
803        .into_iter()
804        .next()
805        .expect("map_wiki_link_errors called with non-empty error list");
806    match first {
807        WikiLinkError::InvalidTarget {
808            raw,
809            suggested,
810            reason,
811        } => EngineError::InvalidWikiLinkTarget {
812            raw,
813            suggested,
814            section: section_key.to_string(),
815            link_source: "body_link".to_string(),
816            reason,
817        },
818        WikiLinkError::InvalidMemName { raw, reason } => EngineError::InvalidWikiLinkMem {
819            raw,
820            section: section_key.to_string(),
821            reason,
822        },
823    }
824}
825
826/// Compute the set of body wiki-link targets in an entity. Used by
827/// callers of `synthesise_alias_relations` to capture the pre-mutation
828/// state once, before any borrow conflicts re-enter the engine's
829/// schemas / store maps. Uses the lenient decoder — this snapshot
830/// must tolerate on-disk drift on pre-strict entities whose bodies
831/// may still contain non-conformant links; the strict gate fires
832/// only on the post-mutation `next` state.
833pub(super) fn collect_body_link_targets(entity: &Entity) -> std::collections::HashSet<EntityId> {
834    entity
835        .sections
836        .iter()
837        .flat_map(|(_, body)| {
838            crate::entity::parser::extract_inline_links_lenient(body, &entity.mem)
839        })
840        .collect()
841}
842
843/// Alias-existence invariant validator. Given the post-mutation entity
844/// state, scan every section body for wiki-links whose target has no
845/// corresponding explicit relation in `entity.relationships`. Returns
846/// the list of `(section_key, target_id)` pairs that violate the
847/// invariant — empty when the post-mutation state is clean.
848///
849/// Used by [`Engine::create_entity`] and [`Engine::update_entity`]
850/// (and `batch_update`). The validator runs unconditionally — under
851/// the alias model body wiki-links are foreign-key references on the
852/// `## Relationships` table and every reference must be backed.
853///
854/// Sections from the auto-managed `## Relationships` heading are
855/// not scanned (the engine generates them from the relations list
856/// at write time; the parser keeps them out of
857/// `entity.sections` so they never reach this function).
858///
859/// Reuses [`crate::entity::parser::extract_inline_links`] so the
860/// lexical discipline (fenced-code masking, inline-code masking,
861/// alias handling, cross-mem forms) matches every other validator
862/// surface in the engine.
863pub(super) fn scan_wikilinks_without_relation(
864    next: &Entity,
865) -> Result<Vec<(String, EntityId)>, EngineError> {
866    let explicit_targets: std::collections::HashSet<EntityId> = next
867        .relationships
868        .iter()
869        .map(|r| r.target.clone())
870        .collect();
871    let mut missing: Vec<(String, EntityId)> = Vec::new();
872    for (section_key, body) in next.sections.iter() {
873        let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
874            .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
875        for target in ids {
876            // A self-targeting body link is intentionally unbacked: the
877            // alias pass drops its (vacuous) self-edge (F11), so it has no
878            // backing relation by design and must not trip the
879            // unbacked-link refusal here.
880            if target == next.id {
881                continue;
882            }
883            if !explicit_targets.contains(&target)
884                && !missing
885                    .iter()
886                    .any(|(k, t)| k == section_key && t == &target)
887            {
888                missing.push((section_key.clone(), target));
889            }
890        }
891    }
892    Ok(missing)
893}
894
895#[cfg(test)]
896mod tests {
897
898    use tempfile::TempDir;
899
900    use crate::backend::MemBackend;
901    use crate::engine::test_helpers::*;
902    use crate::engine::{CreateEntityArgs, Engine, UpdateEntityArgs};
903
904    use crate::storage::FilesystemMemWriter;
905    use crate::vcs::CommitContext;
906
907    use indexmap::IndexMap;
908
909    #[test]
910    fn with_ctx_wrappers_delegate_to_explicit_forms() {
911        // Each *_with_ctx wrapper bundles a CommitContext and
912        // routes through the corresponding 4-arg method. Verify
913        // create → update → rename → delete via the wrappers
914        // observably mutate the store the same way the explicit
915        // forms would.
916        let tmp = TempDir::new().unwrap();
917        let mem_dir = tmp.path().to_path_buf();
918        let writer = FilesystemMemWriter::new(mem_dir.clone());
919        let mut engine = Engine::from_mounts(vec![(
920            folder_mount("specs", mem_dir),
921            Box::new(writer) as Box<dyn MemBackend>,
922        )])
923        .unwrap();
924        let ctx = CommitContext::internal();
925
926        // create_entity_with_ctx
927        let create_args = CreateEntityArgs {
928            anchors: Vec::new(),
929            mem: "specs".to_string(),
930            title: "Seed".to_string(),
931            entity_type: "spec".to_string(),
932            sections: IndexMap::from_iter([
933                ("identity".to_string(), "seed identity".to_string()),
934                ("purpose".to_string(), "seed purpose".to_string()),
935            ]),
936            metadata: IndexMap::new(),
937            relations: Vec::new(),
938            dry_run: false,
939        };
940        let created = engine.create_entity_with_ctx(create_args, &ctx).unwrap();
941        assert_eq!(created.title, "Seed");
942        assert!(engine.store().get(&created.id).is_some());
943
944        // update_entity_with_ctx
945        let update_args = UpdateEntityArgs {
946            anchors: Vec::new(),
947            id: created.id.clone(),
948            expected_hash: Some(created.content_hash.clone()),
949            sections: IndexMap::from_iter([("identity".to_string(), "updated".to_string())]),
950            append_sections: IndexMap::new(),
951            patch_sections: IndexMap::new(),
952            metadata: IndexMap::new(),
953            metadata_unset: Vec::new(),
954            dry_run: false,
955            declare_relations: Vec::new(),
956            relations_unset: Vec::new(),
957        };
958        let updated = engine.update_entity_with_ctx(update_args, &ctx).unwrap();
959        assert!(
960            !updated.commit_sha.is_empty()
961                || (updated.modified_sections.replaced.is_empty()
962                    && updated.modified_sections.appended.is_empty()
963                    && updated.modified_sections.patched.is_empty())
964        );
965
966        // rename_entity_with_ctx
967        let renamed = engine
968            .rename_entity_with_ctx(&created.id, "Renamed", &updated.content_hash, &ctx)
969            .unwrap();
970        assert_ne!(renamed.old_id, renamed.new_id);
971        assert!(engine.store().get(&renamed.new_id).is_some());
972
973        // delete_entity_with_ctx
974        let deleted = engine
975            .delete_entity_with_ctx(&renamed.new_id, &renamed.content_hash, &ctx)
976            .unwrap();
977        assert_eq!(deleted.id, renamed.new_id);
978        assert!(engine.store().get(&renamed.new_id).is_none());
979    }
980}