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