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.
59pub(crate) fn unknown_type_error(schema: &memstead_schema::Schema, attempted: &str) -> EngineError {
60 let mut declared: Vec<String> = schema.types.keys().cloned().collect();
61 declared.sort();
62 let (sname, sver) = schema.id();
63 EngineError::UnknownType {
64 name: attempted.to_string(),
65 schema_ref: format!("{sname}@{sver}"),
66 declared,
67 suggestion: schema.suggest_type(attempted),
68 }
69}
70
71/// The lowercase wire string for a [`crate::pipeline::MediumType`] — the
72/// value the `INVALID_ANCHOR` recovery detail carries so an agent sees
73/// which medium's namespace rejected the grain. Matches the enum's
74/// `#[serde(rename_all = "lowercase")]` form.
75pub(crate) fn medium_type_wire(t: crate::pipeline::MediumType) -> &'static str {
76 use crate::pipeline::MediumType::*;
77 match t {
78 Codebase => "codebase",
79 Filesystem => "filesystem",
80 Graph => "graph",
81 Git => "git",
82 Web => "web",
83 }
84}
85
86impl super::Engine {
87 /// Resolve the single-source anchor-namespace context for `mem`, when
88 /// unambiguous. An `anchors[]` element carries no source name, so the
89 /// grain/namespace refusal ([`crate::anchor::AnchorValidationError::GrainNamespaceUnsupported`])
90 /// can only be fired deterministically when the mem's bindings declare
91 /// exactly one inline source; with zero or several the namespace check
92 /// is skipped (the vocabulary + hash-semantics rules still apply).
93 /// Returns the `(medium_type_wire, anchor_namespace)` pair the
94 /// validator consumes — the medium *half* of the lone source.
95 pub(crate) fn resolve_anchor_medium(&self, mem: &str) -> Option<(String, &'static str)> {
96 let mut sources = self
97 .pipeline_configs()
98 .bindings
99 .iter()
100 .filter(|r| r.mem == mem)
101 .flat_map(|r| r.config.sources.iter());
102 let first = sources.next()?;
103 if sources.next().is_some() {
104 // Ambiguous — the anchor does not name which source it targets;
105 // skip the namespace refinement rather than guess.
106 return None;
107 }
108 let caps = crate::binding::medium_capabilities(first.medium_type);
109 Some((
110 medium_type_wire(first.medium_type).to_string(),
111 caps.anchor_namespace,
112 ))
113 }
114
115 /// Validate the permissive `anchors[]` inputs for a mutation against
116 /// `mem`'s medium context into strict [`crate::anchor::Anchor`]s, or
117 /// refuse the whole mutation with a typed
118 /// [`EngineError::InvalidAnchor`]. Empty input yields an empty vec (no
119 /// sidecar write); a single malformed element aborts before any state
120 /// change so the entity is never written.
121 pub(crate) fn validate_anchor_inputs(
122 &self,
123 mem: &str,
124 inputs: &[crate::anchor::AnchorInput],
125 ) -> Result<Vec<crate::anchor::Anchor>, EngineError> {
126 if inputs.is_empty() {
127 return Ok(Vec::new());
128 }
129 let medium = self.resolve_anchor_medium(mem);
130 let medium_ref = medium.as_ref().map(|(t, ns)| (t.as_str(), *ns));
131 let anchors: Vec<crate::anchor::Anchor> = inputs
132 .iter()
133 .map(|i| i.validate(medium_ref).map_err(EngineError::from))
134 .collect::<Result<_, _>>()?;
135
136 // Source-vs-binding check: when an anchor names BOTH a producing
137 // binding and a source, and that binding hash still resolves in
138 // this workspace (reverse lookup — any later binding edit moves
139 // the hash and drops earlier anchors into the accept-any-name
140 // branch for good), the source must be one of the binding's
141 // declared names. The bindings load is skipped entirely unless
142 // some input needs it, and a missing workspace root or an
143 // unloadable store degrades to accept-any-name — validation
144 // must never require the binding to resolve.
145 if anchors
146 .iter()
147 .any(|a| a.binding.is_some() && a.source.is_some())
148 && let Some(root) = self.workspace_root()
149 && let Ok(configs) = crate::pipeline_store::load_pipeline_configs(root)
150 {
151 for a in &anchors {
152 let (Some(binding_hash), Some(source)) = (&a.binding, &a.source) else {
153 continue;
154 };
155 let Some(record) = configs
156 .bindings
157 .iter()
158 .find(|r| crate::binding::hash_binding(&r.config) == *binding_hash)
159 else {
160 continue; // unresolvable binding: accept any non-empty name
161 };
162 let declared: Vec<String> = record
163 .config
164 .sources
165 .iter()
166 .map(|s| s.name.clone())
167 .collect();
168 if !declared.iter().any(|n| n == source) {
169 return Err(EngineError::from(
170 crate::anchor::AnchorValidationError::SourceNotDeclared {
171 got: source.clone(),
172 declared,
173 },
174 ));
175 }
176 }
177 }
178
179 Ok(anchors)
180 }
181
182 /// Validate an update's `anchors_unset[]` payload up front — a
183 /// malformed selector (missing artifact, unknown grain/class wire
184 /// string) refuses the whole mutation with the same typed
185 /// `INVALID_ANCHOR` envelope as a malformed `anchors[]` element.
186 /// Empty payload → empty vec.
187 pub(crate) fn validate_anchor_unsets(
188 inputs: &[crate::anchor::AnchorUnsetInput],
189 ) -> Result<Vec<crate::anchor::AnchorUnset>, EngineError> {
190 inputs
191 .iter()
192 .map(|i| i.validate().map_err(EngineError::from))
193 .collect()
194 }
195
196 /// Record verify-observed prepared-content hashes onto **hash-less
197 /// hash-bearing** anchors in `mem_name`'s anchors sidecar — the
198 /// measurement-bookkeeping backfill the verify pass hands over via
199 /// [`crate::ingest::VerifyOutcome::hash_backfill`].
200 ///
201 /// This mutates **only** the engine-owned sidecar
202 /// ([`crate::anchor::ANCHOR_SIDECAR_PATH`]): no entity content, no
203 /// section, no `_hash` is touched — an anchor-only commit yields zero
204 /// entity deltas by construction. Guards enforced at this write seam,
205 /// not left to callers:
206 ///
207 /// - only a hash-bearing class (`anchored` / `derived`) may gain a hash —
208 /// an `authored` / `informed-by` anchor is never written, whatever the
209 /// caller observed;
210 /// - an anchor that already carries a hash is never overwritten — the
211 /// recorded hash is the drift baseline, so the backfill is idempotent
212 /// (a second identical call stages nothing and produces no commit).
213 ///
214 /// Returns how many anchors gained a hash. Zero writes ⇒ no commit.
215 pub fn record_anchor_observed_hashes(
216 &mut self,
217 mem_name: &str,
218 observed: &[crate::anchor::ObservedArtifactHash],
219 note: Option<&str>,
220 ) -> Result<usize, EngineError> {
221 if observed.is_empty() {
222 return Ok(0);
223 }
224 let mount_idx = self
225 .mounts
226 .iter()
227 .position(|m| m.mount.mem == mem_name)
228 .ok_or_else(|| self.unknown_mem_error(mem_name))?;
229 if self.mounts[mount_idx].mount.capability != crate::workspace::MountCapability::Write {
230 return Err(EngineError::ReadOnlyMount(mem_name.to_string()));
231 }
232 // Same posture as every other commit-producing write: probe for
233 // sibling-engine drift so the sidecar merge runs against current truth.
234 let _warnings = self.reload_if_stale(Some(mem_name));
235
236 let backend = self.mounts[mount_idx].backend.as_ref();
237 let mut sidecar = read_sidecar(backend)?;
238 let mut written = 0usize;
239 for obs in observed {
240 let Some(anchors) = sidecar.entities.get_mut(&obs.entity) else {
241 continue;
242 };
243 for a in anchors {
244 if a.class.is_hash_bearing() && a.hash.is_none() && a.artifact == obs.artifact {
245 a.hash = Some(obs.hash.clone());
246 written += 1;
247 }
248 }
249 }
250 if written == 0 {
251 return Ok(0);
252 }
253 backend.write_anchors_sidecar(&sidecar.to_bytes())?;
254 let ctx = crate::vcs::CommitContext {
255 actor: crate::vcs::Actor::Agent,
256 client: None,
257 tool: Some("record_anchor_observed_hashes"),
258 note: note.map(String::from),
259 role: self.current_role,
260 logical_operation_id: None,
261 entity_ids: None,
262 };
263 let commit_sha = backend.commit(
264 &format!("memstead: anchor-hash backfill ({written} anchor(s))"),
265 &ctx,
266 )?;
267 self.record_self_write(mount_idx, &commit_sha);
268 self.stamp_mutation_versions(mount_idx);
269 Ok(written)
270 }
271
272 /// Record on the mem which engine version and which resolved
273 /// schema performed the mutation that just committed
274 /// (`MemConfig.mutation_stamp`). Called by every mutation verb
275 /// after `record_self_write` — one shared implementation, per the
276 /// one-guard-on-all-write-paths principle — and deliberately NOT
277 /// by `apply_external_commit`: a replayed sibling commit was
278 /// stamped by the engine that performed it, and this engine must
279 /// not claim it.
280 ///
281 /// Write-cheap by construction: the config write happens only when
282 /// the stamp VALUE changes (a binary upgrade or a schema repin) —
283 /// steady-state mutations compare and return. The write is
284 /// best-effort: a failed stamp never fails the mutation that
285 /// preceded it. On git-branch backends the config rides the
286 /// `__MEMSTEAD` ref, so a stamp write never moves the mem branch
287 /// head — mutation `commit_sha` cursors stay valid.
288 pub(crate) fn stamp_mutation_versions(&mut self, mount_idx: usize) {
289 let Some(state) = self.mounts.get(mount_idx) else {
290 return;
291 };
292 let mem = state.mount.mem.clone();
293 let Some(schema) = self.schemas.get(&mem) else {
294 return;
295 };
296 let (name, version) = schema.id();
297 // Full build version (semver + git build sha when present) so
298 // a rebuild between mutations is a recordable — and hence
299 // skew-detectable — event even between releases.
300 let stamp = memstead_schema::MutationStamp {
301 engine_version: crate::build_info::full_version().to_string(),
302 schema: format!("{name}@{version}"),
303 };
304 let Some(state) = self.mounts.get_mut(mount_idx) else {
305 return;
306 };
307 // A mem with no loaded config has nowhere to carry the stamp;
308 // skip silently (in-memory sketches, minimal fixtures).
309 let Some(config) = state.mem_config.as_ref() else {
310 return;
311 };
312 if config.mutation_stamp.as_ref() == Some(&stamp) {
313 return;
314 }
315 let mut updated = config.clone();
316 updated.mutation_stamp = Some(stamp);
317 let Ok(mut bytes) = serde_json::to_vec_pretty(&updated) else {
318 return;
319 };
320 bytes.push(b'\n');
321 if state
322 .backend
323 .write_mem_config_with_note(&bytes, Some("engine version stamp"))
324 .is_ok()
325 {
326 state.mem_config = Some(updated);
327 }
328 }
329}
330
331/// Stage a write of `entity_id`'s anchors into the mem's anchors sidecar
332/// through `backend`, merged over the existing sidecar at BOTH levels —
333/// other entities' rows survive (document level), and within the entity's
334/// own row `unsets` apply first, then each incoming anchor replaces the
335/// existing anchor with the same `(artifact, grain, class)` triple or
336/// appends ([`crate::anchor::AnchorSidecar::merge`]). Writing never
337/// removes an anchor the call did not name in `unsets`. The write is
338/// buffered into the SAME pending op set the entity write used — so the
339/// next [`crate::backend::MemBackend::commit`] carries entity + anchors
340/// as one atomic commit. Reads honour pending-buffer precedence, so
341/// successive stages within one transaction compose.
342/// Stage a mutation of the engine-owned derivations sidecar
343/// (agent-trust plan 12) so it rides the SAME commit as the edge
344/// write that produced it — the anchors-sidecar atomicity precedent.
345/// The sidecar travels through the backend's normal entity-path
346/// read/write under `.memstead/`, which every backend filters from
347/// entity listings and every archive/export path carries as-is.
348pub(crate) fn stage_derivation_sidecar(
349 backend: &dyn crate::backend::MemBackend,
350 mutate: impl FnOnce(&mut crate::derivation::DerivationSidecar),
351) -> Result<(), EngineError> {
352 let path = std::path::Path::new(crate::derivation::DERIVATION_SIDECAR_PATH);
353 let mut sidecar = match backend.read_entity(path)? {
354 Some(bytes) => crate::derivation::DerivationSidecar::from_bytes(&bytes).map_err(|e| {
355 EngineError::Backend(crate::backend::BackendError::Other(format!(
356 "derivations sidecar parse: {e}"
357 )))
358 })?,
359 None => crate::derivation::DerivationSidecar::default(),
360 };
361 mutate(&mut sidecar);
362 backend.write_entity(path, &sidecar.to_bytes())?;
363 Ok(())
364}
365
366/// True when `schema` declares `rel_type` as a derivation
367/// (`derivation: true` on the relationship definition) — the
368/// predicate every write path shares, so baseline recording cannot
369/// fork per verb.
370pub(crate) fn rel_type_declares_derivation(
371 schema: &memstead_schema::Schema,
372 rel_type: &str,
373) -> bool {
374 schema
375 .manifest
376 .relationships
377 .definitions
378 .iter()
379 .any(|d| d.name == rel_type && d.derivation)
380}
381
382pub(crate) fn stage_anchors_sidecar(
383 backend: &dyn crate::backend::MemBackend,
384 entity_id: &EntityId,
385 unsets: &[crate::anchor::AnchorUnset],
386 anchors: Vec<crate::anchor::Anchor>,
387) -> Result<(), EngineError> {
388 let mut sidecar = match backend.read_anchors_sidecar()? {
389 Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
390 EngineError::Backend(crate::backend::BackendError::Other(format!(
391 "anchors sidecar parse: {e}"
392 )))
393 })?,
394 None => crate::anchor::AnchorSidecar::default(),
395 };
396 sidecar.merge(entity_id.as_ref(), unsets, anchors);
397 backend.write_anchors_sidecar(&sidecar.to_bytes())?;
398 Ok(())
399}
400
401/// Load the mem's anchors sidecar through `backend`, or the empty
402/// document when none exists yet. Shared by the delete / rename legs
403/// which must decide whether the entity actually has anchor rows before
404/// staging a sidecar write (so an entity with none stays byte-identical
405/// to a pre-anchor mutation).
406fn read_sidecar(
407 backend: &dyn crate::backend::MemBackend,
408) -> Result<crate::anchor::AnchorSidecar, EngineError> {
409 match backend.read_anchors_sidecar()? {
410 Some(bytes) => crate::anchor::AnchorSidecar::from_bytes(&bytes).map_err(|e| {
411 EngineError::Backend(crate::backend::BackendError::Other(format!(
412 "anchors sidecar parse: {e}"
413 )))
414 }),
415 None => Ok(crate::anchor::AnchorSidecar::default()),
416 }
417}
418
419/// Stage removal of `entity_id`'s anchor row into the same commit as an
420/// entity delete — a no-op (no sidecar write, so byte-identical to today)
421/// when the entity carries no anchors. Returns whether a write was staged.
422pub(crate) fn stage_anchors_removal(
423 backend: &dyn crate::backend::MemBackend,
424 entity_id: &EntityId,
425) -> Result<bool, EngineError> {
426 let mut sidecar = read_sidecar(backend)?;
427 if sidecar.get(entity_id.as_ref()).is_empty() {
428 return Ok(false);
429 }
430 sidecar.remove(entity_id.as_ref());
431 backend.write_anchors_sidecar(&sidecar.to_bytes())?;
432 Ok(true)
433}
434
435/// Stage a move of `from`'s anchor row to `to` into the same commit as an
436/// entity rename — leaving zero rows under the old id. A no-op (byte-
437/// identical to today) when the renamed entity carries no anchors. Returns
438/// whether a write was staged.
439pub(crate) fn stage_anchors_rename(
440 backend: &dyn crate::backend::MemBackend,
441 from: &EntityId,
442 to: &EntityId,
443) -> Result<bool, EngineError> {
444 let mut sidecar = read_sidecar(backend)?;
445 if sidecar.get(from.as_ref()).is_empty() {
446 return Ok(false);
447 }
448 sidecar.rename(from.as_ref(), to.as_ref());
449 backend.write_anchors_sidecar(&sidecar.to_bytes())?;
450 Ok(true)
451}
452
453/// System-clock convenience over [`iso_from_system_time`], for tests
454/// that compare against "roughly now". Mutation paths instead go
455/// through `Engine::now_iso`, which reads the engine's injectable
456/// clock — so canonical-bytes tests can pin the stamped value.
457#[cfg(test)]
458pub(super) fn today_iso() -> String {
459 iso_from_system_time(std::time::SystemTime::now())
460}
461
462/// An instant as a full ISO-8601 datetime string `YYYY-MM-DDTHH:MM:SSZ`
463/// (UTC). Used by mutation paths that auto-stamp metadata fields
464/// (e.g. `last_modified` on update, `created_date` on create).
465///
466/// This is second-resolution (rather than
467/// date-only `YYYY-MM-DD`) so intra-day
468/// updates produce distinguishable timestamps and drift / staleness
469/// queries become per-update aware. The strict-mode date validator
470/// already accepts both forms (`^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}Z)?$`)
471/// so existing entities written with the date-only form continue to
472/// load; new writes carry the wider form.
473///
474/// Pure function: no allocation outside the `format!` invocation,
475/// no error path (the fallback to UNIX epoch on an instant before
476/// the epoch is acceptable for a best-effort timestamp).
477/// Howard-Hinnant civil-from-days for the date half; trivial modular
478/// arithmetic for the time half.
479pub(super) fn iso_from_system_time(t: std::time::SystemTime) -> String {
480 let now = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
481 let secs = now.as_secs();
482 let days = secs / 86400;
483 let secs_of_day = secs % 86400;
484 let hh = secs_of_day / 3600;
485 let mm = (secs_of_day % 3600) / 60;
486 let ss = secs_of_day % 60;
487 let z = days + 719468;
488 let era = z / 146097;
489 let doe = z - era * 146097;
490 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
491 let y = yoe + era * 400;
492 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
493 let mp = (5 * doy + 2) / 153;
494 let d = doy - (153 * mp + 2) / 5 + 1;
495 let m = if mp < 10 { mp + 3 } else { mp - 9 };
496 let y = if m <= 2 { y + 1 } else { y };
497 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
498}
499
500/// Sweep stubs whose last incoming edge has just disappeared. Returns
501/// the dropped ids so callers can surface them to the agent (e.g. via
502/// [`DeleteEntityOutcome::orphan_stubs_removed`]).
503///
504/// Stubs are auto-created when a relate names an absent target — a
505/// "promise" that a real entity will land there later (see
506/// [`make_stub`]). When the last referrer drops its edge or is itself
507/// deleted, the promise has no holder and becomes pure bloat. Only
508/// stubs are eligible — real entities never count as orphans via this
509/// path.
510pub(super) fn gc_orphan_stubs(store: &mut Store) -> Vec<EntityId> {
511 let stub_ids: Vec<EntityId> = store
512 .all_entities()
513 .filter(|e| e.stub)
514 .map(|e| e.id.clone())
515 .collect();
516 gc_orphan_stubs_among(store, &stub_ids)
517}
518
519/// Scoped orphan-stub sweep: GC only the stubs *among `candidates`*
520/// whose last incoming edge has just disappeared, returning the dropped
521/// ids. This is the single home of the orphan-stub predicate (`stub &&
522/// no incoming`) — the three write paths that can sever a stub's last
523/// referrer all funnel through here so they cannot drift:
524/// [`gc_orphan_stubs`] (delete's full-store sweep) supplies every stub
525/// id; the `memstead_relate(remove)` path supplies the just-severed target;
526/// the `memstead_update` alias-resync path supplies the entity's
527/// pre-mutation body-link targets (the only edges that commit could
528/// have dropped). Scoping to a candidate set rather than walking the
529/// whole store keeps each path from GC'ing pre-existing orphans that
530/// aren't its responsibility. Candidates are de-duplicated; a candidate
531/// that is absent, not a stub, or still has a referrer is left
532/// untouched.
533pub(super) fn gc_orphan_stubs_among<'a>(
534 store: &mut Store,
535 candidates: impl IntoIterator<Item = &'a EntityId>,
536) -> Vec<EntityId> {
537 let mut removed: Vec<EntityId> = Vec::new();
538 let mut seen: std::collections::HashSet<&EntityId> = std::collections::HashSet::new();
539 for id in candidates {
540 if !seen.insert(id) {
541 continue;
542 }
543 if store.get(id).is_some_and(|e| e.stub) && store.incoming(id).is_empty() {
544 store.remove(id);
545 removed.push(id.clone());
546 }
547 }
548 removed
549}
550
551/// Shared target-id grammar validator. The wiki-link grammar gate
552/// runs on every relation-authoring path (`memstead_relate`,
553/// `memstead_create.relations[]`, future inline-relation surfaces) so a
554/// malformed target id (e.g. `bad@chars$here`) cannot land an
555/// auto-stub at the literal id — that stub would later fail every
556/// wiki-link parse that referenced it. Pre-Item-02 the gate lived
557/// only on `memstead_relate`; the create path admitted the same input
558/// silently.
559pub(super) fn validate_relation_target_grammar(target: &EntityId) -> Result<(), EngineError> {
560 if let Err(reason) = crate::entity::id::validate_mem_name_grammar(target.mem()) {
561 return Err(EngineError::InvalidEntityId {
562 id: target.to_string(),
563 reason,
564 });
565 }
566 if let Err(reason) = crate::entity::id::validate_id_path_grammar(target.path()) {
567 return Err(EngineError::InvalidEntityId {
568 id: target.to_string(),
569 reason,
570 });
571 }
572 Ok(())
573}
574
575/// Auto-stamp `auto_timestamp` metadata fields on an entity that's
576/// about to be re-written. Extracted from the update-path hot loop so
577/// the relate-path (add and remove) and the rename-path (the renaming
578/// entity plus every referrer the rewrite cascade touched) can
579/// invoke the same engine-driven stamp.
580///
581/// Walks the type's metadata-field declarations; any field flagged
582/// `auto_timestamp: true` (the default schema declares this on
583/// `last_modified`) is set to the supplied `today` ISO string. The
584/// helper is a no-op on schemas that declare no auto-timestamp
585/// fields. Callers pre-compute `today` via [`today_iso`] so a single
586/// mutation that touches multiple entities (rename's referrer rewrite
587/// cascade) stamps them all with the same value.
588pub(super) fn auto_stamp_timestamps(
589 entity: &mut Entity,
590 type_def: &memstead_schema::TypeDefinition,
591 today: &str,
592) {
593 for field_def in &type_def.metadata_fields {
594 if field_def.auto_timestamp {
595 entity.metadata.insert(
596 field_def.key.clone(),
597 crate::entity::MetadataValue::String(today.to_string()),
598 );
599 }
600 }
601}
602
603/// Build a stub [`Entity`] for an unresolved relate target. Callers
604/// declare the stub's origin via [`crate::entity::StubKind`] —
605/// `ForwardReference` for `memstead_relate` to an absent target,
606/// `Residual { since_commit, readonly_referrers }` for the
607/// delete/rename demote path. The kind persists for the engine
608/// instance's lifetime; a reload reduces every stub to `LoadTime`
609/// — the kind is annotation, not state.
610///
611/// The stub is in-store but unwritten to disk — `entity_type` empty,
612/// `file_path` empty, no metadata, no sections, `stub: true` and
613/// `stub_kind: Some(kind)` set together. A later
614/// [`Engine::create_entity`] at the same id promotes the stub to a
615/// real entity (loader / parse-result merge handles the upgrade
616/// path; `stub_kind` clears to `None`).
617pub(super) fn make_stub(id: &EntityId, kind: crate::entity::StubKind) -> Entity {
618 Entity {
619 id: id.clone(),
620 title: id.name().to_string(),
621 entity_type: String::new(),
622 mem: id.mem().to_string(),
623 file_path: String::new(),
624 metadata: IndexMap::new(),
625 sections: IndexMap::new(),
626 relationships: Vec::new(),
627 content_hash: String::new(),
628 stub: true,
629 stub_kind: Some(kind),
630 heading_spans: HashMap::new(),
631 raw_section_headings: Vec::new(),
632 }
633}
634
635/// Cross-mem add-path policy gate. Same-mem writes bypass; the
636/// `[cross_mem_links]` table only gates writes that cross the
637/// mem boundary. Cross-mem writes consult
638/// [`super::Engine::cross_mem_link_allowed`] in the edge's actual
639/// direction (`source_mem → target_mem`). Disallowed pairings
640/// surface [`EngineError::CrossMemLinkNotAllowed`] with the
641/// `(from_mem, to_mem)` payload an agent already sees on
642/// `memstead_relate`.
643///
644/// After the grant admits the pairing, a target absent from a
645/// `MountCapability::ReadOnly` mount refuses with
646/// [`EngineError::CrossMemTargetNotFound`]: the engine cannot
647/// persist a stub through the read-only boundary, and a read-only
648/// mem never gains the entity later — a missing target there is a
649/// wrong link, not a pending forward reference. Same-mem targets,
650/// cross-mem targets in Write mounts, and unmounted target mems all
651/// retain the auto-stub mechanic.
652///
653/// Funnel point for every add-shaped edge write — `memstead_relate`,
654/// `memstead_create.relations[]`, `memstead_update.declare_relations`,
655/// body-wiki-link alias synthesis, and any future add-path mutation
656/// surface route through one gate so the policy can't drift between
657/// sites. Remove-shaped writes (cleanup) remain permissive and call
658/// this helper not at all.
659pub(super) fn validate_cross_mem_add_policy(
660 engine: &super::Engine,
661 source_mem: &str,
662 target: &EntityId,
663) -> Result<(), EngineError> {
664 let target_mem = target.mem();
665 if source_mem == target_mem {
666 return Ok(());
667 }
668 if !engine.cross_mem_link_allowed(source_mem, target_mem) {
669 return Err(EngineError::CrossMemLinkNotAllowed {
670 from_mem: source_mem.to_string(),
671 to_mem: target_mem.to_string(),
672 });
673 }
674 if let Some(mount) = engine.mount(target_mem)
675 && mount.capability == crate::workspace::MountCapability::ReadOnly
676 && !engine.store.contains(target)
677 {
678 return Err(EngineError::CrossMemTargetNotFound {
679 target_id: target.to_string(),
680 target_mem: target_mem.to_string(),
681 });
682 }
683 Ok(())
684}
685
686/// Outcome of the engine's edge-validation router for a single
687/// inline / explicit relate. Carries the optional open-mode warning
688/// from the intra-mem flow; the cross-mem flow has no
689/// open-mode (cross-mem entries are declared vocabulary).
690pub(super) enum EdgeRouteOutcome {
691 Ok,
692 OpenModeWarning(Box<crate::ops::WarningHint>),
693}
694
695/// Run rel-type + shape validation for one edge, routing through
696/// intra-mem vocabulary or the source schema's
697/// `cross_mem_relationships:` section as appropriate.
698///
699/// The routing rule:
700/// when `source_mem != target_mem` AND the target mem's
701/// pinned schema differs from the source schema by name or by
702/// version, the source schema's `cross_mem_relationships:` entry
703/// for the target schema is the sole authority for both the
704/// vocabulary check (`INVALID_REL_TYPE`) and the shape check
705/// (`INVALID_REL_SHAPE`). If no matching entry exists, surface
706/// [`EngineError::CrossMemEdgeNotDeclared`].
707///
708/// Otherwise (same-mem, same-schema cross-mem, or target mem
709/// unmounted) the call falls through to the existing intra-mem
710/// validators — the same behaviour the intra-mem path always had.
711///
712/// `check_shape` mirrors the relate path's add-only shape posture:
713/// pass `false` to skip the shape check (currently only the
714/// `memstead_relate --remove` path). The vocabulary check still fires
715/// in that case, matching the intra-mem behaviour where
716/// `validate_rel_type` runs on both add and remove.
717// The nine parameters are one edge's full coordinates; a params struct
718// would restate the same fields at every call site without grouping
719// anything that travels together elsewhere.
720#[allow(clippy::too_many_arguments)]
721pub(super) fn route_edge_validation(
722 engine: &super::Engine,
723 rel_type: &str,
724 from_type: &str,
725 to_type: Option<&str>,
726 source_mem: &str,
727 target_mem: &str,
728 from_id: &EntityId,
729 to_id: &EntityId,
730 check_shape: bool,
731) -> Result<EdgeRouteOutcome, EngineError> {
732 use crate::runtime_validator::{
733 CrossMemRelCheck, RelationshipCheck, validate_cross_mem_edge, validate_rel_shape,
734 validate_rel_type,
735 };
736 use memstead_schema::SchemaRef;
737
738 let source_schema = engine
739 .schemas
740 .get(source_mem)
741 .expect("schema present for every registered mount");
742
743 let target_schema_arc = if source_mem == target_mem {
744 None
745 } else {
746 engine.schemas.get(target_mem).cloned()
747 };
748 let target_schema_ref: Option<SchemaRef> = target_schema_arc.as_ref().map(|s| {
749 let (name, version) = s.id();
750 SchemaRef::new(name, version)
751 });
752 let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
753 (Some(target), (src_name, _)) => target.name != src_name,
754 (None, _) => false,
755 };
756
757 if cross_mem_different {
758 let target_ref = target_schema_ref
759 .as_ref()
760 .expect("target_schema_ref is Some when cross_mem_different");
761 if !check_shape {
762 // Cleanup posture: cross-mem remove stays permissive so
763 // pre-tightening edges remain droppable without first
764 // re-declaring them. Mirrors the intra-mem shape gate's
765 // add-only stance.
766 return Ok(EdgeRouteOutcome::Ok);
767 }
768 match validate_cross_mem_edge(
769 rel_type,
770 from_type,
771 to_type,
772 source_schema.as_ref(),
773 target_ref,
774 ) {
775 CrossMemRelCheck::Ok => Ok(EdgeRouteOutcome::Ok),
776 CrossMemRelCheck::EdgeNotDeclared => {
777 let (src_name, src_version) = source_schema.id();
778 Err(EngineError::CrossMemEdgeNotDeclared {
779 source_schema: SchemaRef::new(src_name, src_version).as_display(),
780 target_schema: target_ref.as_display(),
781 rel_type: rel_type.to_string(),
782 from_id: from_id.to_string(),
783 to_id: to_id.to_string(),
784 })
785 }
786 CrossMemRelCheck::Invalid(v) => Err(EngineError::Validation(v)),
787 }
788 } else {
789 let warning_hint = match validate_rel_type(rel_type, source_schema.as_ref())? {
790 RelationshipCheck::Ok => None,
791 RelationshipCheck::OpenWarning(message) => {
792 Some(crate::ops::WarningHint::UndeclaredRelationshipOpen {
793 rel_type: rel_type.to_string(),
794 message,
795 })
796 }
797 };
798 if check_shape {
799 validate_rel_shape(rel_type, from_type, to_type, source_schema.as_ref())?;
800 }
801 Ok(match warning_hint {
802 Some(w) => EdgeRouteOutcome::OpenModeWarning(Box::new(w)),
803 None => EdgeRouteOutcome::Ok,
804 })
805 }
806}
807
808/// Cycle-family gate for one prospective edge — the single owner of
809/// both refusals, shared by every edge-writing verb (`memstead_relate`,
810/// `memstead_create.relations[]`, `memstead_update.declare_relations`, and the
811/// batch paths, which stage prior items' edges into `store` so an
812/// intra-batch cycle refuses like a stored one):
813///
814/// - **Self-loop on a listed no-self-loop rel-type.** `from == to` on
815/// any rel-type the source type lists in `no_self_loop_relationships`
816/// refuses, regardless of the `acyclic` flag — the declaration's one
817/// effect (see `TypeDefinition::no_self_loop_relationships`).
818/// - **Cycle on an acyclic rel-type.** An add closing a back-path
819/// `to → … → from` (via [`crate::graph::query::would_cycle`]) refuses
820/// with the existing path, capped at [`RELATIONSHIP_CYCLE_PATH_CAP`].
821///
822/// Both refuse [`EngineError::RelationshipCycle`] (`RELATIONSHIP_CYCLE`)
823/// with identical recovery detail on every path. Callers skip this on
824/// remove paths — removal can only break cycles, never close one.
825pub(super) fn validate_edge_acyclicity(
826 store: &Store,
827 schema: &memstead_schema::Schema,
828 from: &EntityId,
829 from_type: &str,
830 to: &EntityId,
831 rel_type: &str,
832) -> Result<(), EngineError> {
833 if from == to && schema.type_refuses_self_loop(from_type, rel_type) {
834 return Err(EngineError::RelationshipCycle {
835 rel_type: rel_type.to_string(),
836 from: from.clone(),
837 to: to.clone(),
838 existing_path: vec![from.clone()],
839 path_truncated: false,
840 });
841 }
842 if schema.relationship_acyclic(rel_type)
843 && let Some(path) = crate::graph::query::would_cycle(store, from, to, rel_type)
844 {
845 let truncated = path.len() > RELATIONSHIP_CYCLE_PATH_CAP;
846 let mut existing_path = path;
847 if truncated {
848 existing_path.truncate(RELATIONSHIP_CYCLE_PATH_CAP);
849 }
850 return Err(EngineError::RelationshipCycle {
851 rel_type: rel_type.to_string(),
852 from: from.clone(),
853 to: to.clone(),
854 existing_path,
855 path_truncated: truncated,
856 });
857 }
858 Ok(())
859}
860
861/// Validate the per-edge description posture declared on the rel-type
862/// in the routing-appropriate definition (intra-mem when source and
863/// target share the schema; cross-mem entry when they don't). Emits
864/// `MissingRequiredDescription` / `DescriptionNotPermitted` on
865/// violations; `optional` and unknown rel-types are no-ops (the
866/// vocabulary / shape gates already catch undeclared names — posture
867/// only fires for declared names).
868///
869/// `description` is the normalised value (empty / whitespace-only
870/// collapses to `None` before reaching this gate). Called from every
871/// add path: `memstead_relate`, `declare_relations` on `memstead_create` and
872/// `memstead_update`.
873pub(super) fn validate_description_posture(
874 engine: &super::Engine,
875 rel_type: &str,
876 description: Option<&str>,
877 source_mem: &str,
878 target_mem: &str,
879 from_id: &EntityId,
880 to_id: &EntityId,
881) -> Result<(), EngineError> {
882 use memstead_schema::{PerEdgeDescription, SchemaRef};
883
884 let source_schema = engine
885 .schemas
886 .get(source_mem)
887 .expect("schema present for every registered mount");
888 let target_schema_arc = if source_mem == target_mem {
889 None
890 } else {
891 engine.schemas.get(target_mem).cloned()
892 };
893 let target_schema_ref: Option<SchemaRef> = target_schema_arc.as_ref().map(|s| {
894 let (name, version) = s.id();
895 SchemaRef::new(name, version)
896 });
897 let cross_mem_different = match (&target_schema_ref, source_schema.id()) {
898 (Some(target), (src_name, _)) => target.name != src_name,
899 (None, _) => false,
900 };
901
902 let posture = if cross_mem_different {
903 // Look up the matching cross-mem entry's definition. If the
904 // entry exists but the rel-type isn't enumerated under it, the
905 // vocabulary gate (route_edge_validation) will surface
906 // `CROSS_MEM_EDGE_NOT_DECLARED`; posture is a no-op there.
907 let target_ref = target_schema_ref
908 .as_ref()
909 .expect("target_schema_ref is Some when cross_mem_different");
910 source_schema
911 .cross_mem_entries(&target_ref.name)
912 .iter()
913 .find_map(|entry| entry.definitions.iter().find(|d| d.name == rel_type))
914 .map(|d| d.per_edge_description)
915 } else {
916 source_schema
917 .relationship_def(rel_type)
918 .map(|d| d.per_edge_description)
919 };
920
921 match posture {
922 Some(PerEdgeDescription::Required) if description.is_none() => {
923 Err(EngineError::MissingRequiredDescription {
924 rel_type: rel_type.to_string(),
925 from_id: from_id.to_string(),
926 to_id: to_id.to_string(),
927 })
928 }
929 Some(PerEdgeDescription::Forbidden) if description.is_some() => {
930 Err(EngineError::DescriptionNotPermitted {
931 rel_type: rel_type.to_string(),
932 from_id: from_id.to_string(),
933 to_id: to_id.to_string(),
934 })
935 }
936 _ => Ok(()),
937 }
938}
939
940/// Validate the manual-authoring posture declared on the rel-type.
941/// Fires only on explicit-author paths (`memstead_relate`, inline
942/// `relations:` on `memstead_create`, `declare_relations` on
943/// `memstead_update`). The body-link → relation alias machinery
944/// synthesises relations from wiki-links — that path bypasses this
945/// gate by construction (it never calls this function), keeping the
946/// alias path for `manual_authoring: forbidden` rel-types (e.g.
947/// REFERENCES) intact.
948pub(super) fn validate_manual_authoring_posture(
949 engine: &super::Engine,
950 rel_type: &str,
951 source_mem: &str,
952 from_id: &EntityId,
953 to_id: &EntityId,
954) -> Result<(), EngineError> {
955 use memstead_schema::ManualAuthoring;
956
957 let source_schema = engine
958 .schemas
959 .get(source_mem)
960 .expect("schema present for every registered mount");
961 let posture = source_schema.relationship_manual_authoring(rel_type);
962 if matches!(posture, ManualAuthoring::Forbidden) {
963 let guidance = source_schema
964 .relationship_when_to_use(rel_type)
965 .unwrap_or_default();
966 return Err(EngineError::RelationManualAuthoringForbidden {
967 rel_type: rel_type.to_string(),
968 from_id: from_id.to_string(),
969 to_id: to_id.to_string(),
970 guidance,
971 });
972 }
973 Ok(())
974}
975
976/// Alias-synthesis pass — populates `next.relationships` with engine-
977/// emitted relations of the source schema's `alias_target_rel_type`
978/// pointer for every body wiki-link not already backed by an
979/// in-section-body explicit relation. Runs before the
980/// `scan_wikilinks_without_relation` validator; after this pass the
981/// validator finds zero missing wiki-links for the pointer rel-type.
982///
983/// Three cases:
984/// 1. Schema has no pointer (`alias_target_rel_type` absent): no-op.
985/// Caller's validator continues to refuse unbacked links exactly as
986/// today.
987/// 2. Schema has a pointer, body wiki-link target is in the same mem
988/// OR cross-mem policy admits it: append `Relationship { rel_type:
989/// pointer, target, description: None }` to `next.relationships` if
990/// no relation of `(pointer, target)` is already present. Dedupe is
991/// `(target, rel_type)` — a USES or DEPENDS_ON edge to the same
992/// target does not suppress synthesis of the pointer rel-type.
993/// 3. Schema has a pointer but a body wiki-link crosses a mem
994/// boundary the workspace doesn't grant — or targets an entity
995/// absent from a read-only mount: return the funnel's typed
996/// refusal ([`EngineError::CrossMemLinkNotAllowed`] /
997/// [`EngineError::CrossMemTargetNotFound`], via
998/// [`validate_cross_mem_add_policy`]). The entire mutation
999/// aborts — no partial state.
1000///
1001/// GC: when `prev` is `Some`, the pass also drops pointer-rel-type
1002/// relations whose target was a body wiki-link in `prev` but no longer
1003/// appears in `next.sections`. The loader forces `manual_authoring:
1004/// forbidden` on every schema's `alias_target_rel_type` pointer, so the
1005/// only path to a pointer-rel-type edge is the body-link channel; the
1006/// GC rule therefore reduces to "drop pointer-rel-type relations whose
1007/// target is not in the new body". Targeting prev's wiki-link set
1008/// specifically (rather than every pointer-rel-type relation) keeps the
1009/// pass correct even for an explicit-author relation that predates the
1010/// forbid posture.
1011///
1012/// Returns the list of relations the pass emitted (in body iteration
1013/// order) — `create.rs` / `update.rs` use it to surface
1014/// `relations_emitted` on the response envelope.
1015/// Returns the synthesised relations (in body iteration order) and a flag
1016/// signalling whether a body wiki-link to the entity's own id was dropped
1017/// (F11). The caller surfaces that as a `SELF_LINK_IGNORED` warning — the
1018/// pass has no warning channel of its own.
1019pub(super) fn synthesise_alias_relations(
1020 engine: &super::Engine,
1021 prev_body_targets: &std::collections::HashSet<EntityId>,
1022 next: &mut Entity,
1023) -> Result<(Vec<crate::entity::Relationship>, bool), super::EngineError> {
1024 let schema = engine
1025 .schemas
1026 .get(next.mem.as_str())
1027 .expect("schema present for every registered mount");
1028 let Some(pointer) = schema.alias_target_rel_type().map(str::to_string) else {
1029 return Ok((Vec::new(), false));
1030 };
1031
1032 // 1. GC: drop pointer-rel-type relations whose target was a body
1033 // wiki-link in the prev entity state but isn't in next. Targets
1034 // not in prev's wiki-link set are explicit-author relations and
1035 // are never touched — the rule preserves explicit edges even
1036 // while the 5 built-ins still admit explicit REFERENCES.
1037 //
1038 // `extract_inline_links` is strict — non-slug-form targets refuse
1039 // here with the typed `InvalidWikiLinkTarget` envelope rather
1040 // than silently flowing into the GC's retain set as malformed
1041 // EntityIds. Section context comes from the iteration key.
1042 let mut next_targets: std::collections::HashSet<EntityId> = std::collections::HashSet::new();
1043 for (section_key, body) in next.sections.iter() {
1044 let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1045 .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1046 next_targets.extend(ids);
1047 }
1048 next.relationships.retain(|r| {
1049 !(r.rel_type == pointer
1050 && prev_body_targets.contains(&r.target)
1051 && !next_targets.contains(&r.target))
1052 });
1053
1054 // 2. Walk body wiki-links in section iteration order and append
1055 // one relation per `(target, pointer)` pair not already
1056 // present. Cross-mem gate fires on the first refusal.
1057 let existing: std::collections::HashSet<(String, EntityId)> = next
1058 .relationships
1059 .iter()
1060 .map(|r| (r.rel_type.clone(), r.target.clone()))
1061 .collect();
1062 let mut emitted: Vec<crate::entity::Relationship> = Vec::new();
1063 let mut already_synthesised: std::collections::HashSet<EntityId> =
1064 std::collections::HashSet::new();
1065 let mut self_link_ignored = false;
1066 for (section_key, body) in next.sections.iter() {
1067 let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1068 .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1069 for target in ids {
1070 // F11: a body wiki-link to the entity's own id is a vacuous
1071 // self-edge (renders as both Outgoing and Incoming, inflates
1072 // connectivity). Drop it — but don't refuse: the author may
1073 // have written their own slug. The caller surfaces
1074 // `SELF_LINK_IGNORED` so the dropped link stays observable.
1075 if target == next.id {
1076 self_link_ignored = true;
1077 continue;
1078 }
1079 let key = (pointer.clone(), target.clone());
1080 if existing.contains(&key) || already_synthesised.contains(&target) {
1081 continue;
1082 }
1083 validate_cross_mem_add_policy(engine, &next.mem, &target)?;
1084 let rel = crate::entity::Relationship::new(pointer.clone(), target.clone());
1085 next.relationships.push(rel.clone());
1086 already_synthesised.insert(target);
1087 emitted.push(rel);
1088 }
1089 }
1090 Ok((emitted, self_link_ignored))
1091}
1092
1093/// Map the first [`crate::entity::id::WikiLinkError`] from a body
1094/// wiki-link extraction into the typed [`EngineError`] envelope,
1095/// attaching the offending section's key. Errors after the first are
1096/// dropped — the agent reads the error, fixes the link, retries, and
1097/// surfaces the next one on the follow-up call. Keeps the envelope
1098/// shape stable (single typed payload rather than a list) so MCP /
1099/// CLI / UniFFI clients don't need a fan-out renderer.
1100pub(super) fn map_wiki_link_errors(
1101 section_key: &str,
1102 errors: Vec<crate::entity::id::WikiLinkError>,
1103) -> EngineError {
1104 use crate::entity::id::WikiLinkError;
1105 let first = errors
1106 .into_iter()
1107 .next()
1108 .expect("map_wiki_link_errors called with non-empty error list");
1109 match first {
1110 WikiLinkError::InvalidTarget {
1111 raw,
1112 suggested,
1113 reason,
1114 } => EngineError::InvalidWikiLinkTarget {
1115 raw,
1116 suggested,
1117 section: section_key.to_string(),
1118 link_source: "body_link".to_string(),
1119 reason,
1120 },
1121 WikiLinkError::InvalidMemName { raw, reason } => EngineError::InvalidWikiLinkMem {
1122 raw,
1123 section: section_key.to_string(),
1124 reason,
1125 },
1126 }
1127}
1128
1129/// Compute the set of body wiki-link targets in an entity. Used by
1130/// callers of `synthesise_alias_relations` to capture the pre-mutation
1131/// state once, before any borrow conflicts re-enter the engine's
1132/// schemas / store maps. Uses the lenient decoder — this snapshot
1133/// must tolerate on-disk drift on pre-strict entities whose bodies
1134/// may still contain non-conformant links; the strict gate fires
1135/// only on the post-mutation `next` state.
1136pub(super) fn collect_body_link_targets(entity: &Entity) -> std::collections::HashSet<EntityId> {
1137 entity
1138 .sections
1139 .iter()
1140 .flat_map(|(_, body)| {
1141 crate::entity::parser::extract_inline_links_lenient(body, &entity.mem)
1142 })
1143 .collect()
1144}
1145
1146/// Alias-existence invariant validator. Given the post-mutation entity
1147/// state, scan every section body for wiki-links whose target has no
1148/// corresponding explicit relation in `entity.relationships`. Returns
1149/// the list of `(section_key, target_id)` pairs that violate the
1150/// invariant — empty when the post-mutation state is clean.
1151///
1152/// Used by [`Engine::create_entity`] and [`Engine::update_entity`]
1153/// (and `batch_update`). The validator runs unconditionally — under
1154/// the alias model body wiki-links are foreign-key references on the
1155/// `## Relationships` table and every reference must be backed.
1156///
1157/// Sections from the auto-managed `## Relationships` heading are
1158/// not scanned (the engine generates them from the relations list
1159/// at write time; the parser keeps them out of
1160/// `entity.sections` so they never reach this function).
1161///
1162/// Reuses [`crate::entity::parser::extract_inline_links`] so the
1163/// lexical discipline (fenced-code masking, inline-code masking,
1164/// alias handling, cross-mem forms) matches every other validator
1165/// surface in the engine.
1166pub(super) fn scan_wikilinks_without_relation(
1167 next: &Entity,
1168) -> Result<Vec<(String, EntityId)>, EngineError> {
1169 let explicit_targets: std::collections::HashSet<EntityId> = next
1170 .relationships
1171 .iter()
1172 .map(|r| r.target.clone())
1173 .collect();
1174 let mut missing: Vec<(String, EntityId)> = Vec::new();
1175 for (section_key, body) in next.sections.iter() {
1176 let ids = crate::entity::parser::extract_inline_links(body, &next.mem)
1177 .map_err(|errs| map_wiki_link_errors(section_key, errs))?;
1178 for target in ids {
1179 // A self-targeting body link is intentionally unbacked: the
1180 // alias pass drops its (vacuous) self-edge (F11), so it has no
1181 // backing relation by design and must not trip the
1182 // unbacked-link refusal here.
1183 if target == next.id {
1184 continue;
1185 }
1186 if !explicit_targets.contains(&target)
1187 && !missing
1188 .iter()
1189 .any(|(k, t)| k == section_key && t == &target)
1190 {
1191 missing.push((section_key.clone(), target));
1192 }
1193 }
1194 }
1195 Ok(missing)
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200
1201 use tempfile::TempDir;
1202
1203 use crate::backend::MemBackend;
1204 use crate::engine::test_helpers::*;
1205 use crate::engine::{CreateEntityArgs, Engine, UpdateEntityArgs};
1206
1207 use crate::storage::FilesystemMemWriter;
1208 use crate::vcs::CommitContext;
1209
1210 use indexmap::IndexMap;
1211
1212 #[test]
1213 fn with_ctx_wrappers_delegate_to_explicit_forms() {
1214 // Each *_with_ctx wrapper bundles a CommitContext and
1215 // routes through the corresponding 4-arg method. Verify
1216 // create → update → rename → delete via the wrappers
1217 // observably mutate the store the same way the explicit
1218 // forms would.
1219 let tmp = TempDir::new().unwrap();
1220 let mem_dir = tmp.path().to_path_buf();
1221 let writer = FilesystemMemWriter::new(mem_dir.clone());
1222 let mut engine = Engine::from_mounts(vec![(
1223 folder_mount("specs", mem_dir),
1224 Box::new(writer) as Box<dyn MemBackend>,
1225 )])
1226 .unwrap();
1227 let ctx = CommitContext::internal();
1228
1229 // create_entity_with_ctx
1230 let create_args = CreateEntityArgs {
1231 anchors: Vec::new(),
1232 mem: "specs".to_string(),
1233 title: "Seed".to_string(),
1234 entity_type: "spec".to_string(),
1235 sections: IndexMap::from_iter([
1236 ("identity".to_string(), "seed identity".to_string()),
1237 ("purpose".to_string(), "seed purpose".to_string()),
1238 ]),
1239 metadata: IndexMap::new(),
1240 relations: Vec::new(),
1241 dry_run: false,
1242 };
1243 let created = engine.create_entity_with_ctx(create_args, &ctx).unwrap();
1244 assert_eq!(created.title, "Seed");
1245 assert!(engine.store().get(&created.id).is_some());
1246
1247 // update_entity_with_ctx
1248 let update_args = UpdateEntityArgs {
1249 anchors: Vec::new(),
1250 id: created.id.clone(),
1251 expected_hash: Some(created.content_hash.clone()),
1252 sections: IndexMap::from_iter([("identity".to_string(), "updated".to_string())]),
1253 append_sections: IndexMap::new(),
1254 patch_sections: IndexMap::new(),
1255 metadata: IndexMap::new(),
1256 metadata_unset: Vec::new(),
1257 dry_run: false,
1258 declare_relations: Vec::new(),
1259 relations_unset: Vec::new(),
1260 anchors_unset: Vec::new(),
1261 };
1262 let updated = engine.update_entity_with_ctx(update_args, &ctx).unwrap();
1263 assert!(
1264 !updated.commit_sha.is_empty()
1265 || (updated.modified_sections.replaced.is_empty()
1266 && updated.modified_sections.appended.is_empty()
1267 && updated.modified_sections.patched.is_empty())
1268 );
1269
1270 // rename_entity_with_ctx
1271 let renamed = engine
1272 .rename_entity_with_ctx(&created.id, "Renamed", &updated.content_hash, &ctx)
1273 .unwrap();
1274 assert_ne!(renamed.old_id, renamed.new_id);
1275 assert!(engine.store().get(&renamed.new_id).is_some());
1276
1277 // delete_entity_with_ctx
1278 let deleted = engine
1279 .delete_entity_with_ctx(&renamed.new_id, &renamed.content_hash, &ctx)
1280 .unwrap();
1281 assert_eq!(deleted.id, renamed.new_id);
1282 assert!(engine.store().get(&renamed.new_id).is_none());
1283 }
1284
1285 /// Minimal on-disk MemConfig pinning `default@1.0.0`, with an
1286 /// optional pre-set mutation stamp — the carrier the stamp path
1287 /// and the boot skew check both read.
1288 fn write_config(dir: &std::path::Path, stamp: Option<memstead_schema::MutationStamp>) {
1289 let meta = dir.join(memstead_schema::MEM_META_DIR);
1290 std::fs::create_dir_all(&meta).unwrap();
1291 let mut config: memstead_schema::MemConfig =
1292 serde_json::from_str(r#"{"schema": "default@1.0.0"}"#).unwrap();
1293 config.mutation_stamp = stamp;
1294 std::fs::write(
1295 meta.join("config.json"),
1296 serde_json::to_vec_pretty(&config).unwrap(),
1297 )
1298 .unwrap();
1299 }
1300
1301 fn stamped_engine_fixture(mem_dir: std::path::PathBuf) -> Engine {
1302 Engine::from_mounts(vec![(
1303 folder_mount("specs", mem_dir.clone()),
1304 Box::new(FilesystemMemWriter::new(mem_dir)) as Box<dyn MemBackend>,
1305 )])
1306 .unwrap()
1307 }
1308
1309 fn disk_stamp(dir: &std::path::Path) -> Option<memstead_schema::MutationStamp> {
1310 let bytes =
1311 std::fs::read(dir.join(memstead_schema::MEM_META_DIR).join("config.json")).unwrap();
1312 let config: memstead_schema::MemConfig = serde_json::from_slice(&bytes).unwrap();
1313 config.mutation_stamp
1314 }
1315
1316 fn spec_create_args(title: &str) -> CreateEntityArgs {
1317 CreateEntityArgs {
1318 anchors: Vec::new(),
1319 mem: "specs".to_string(),
1320 title: title.to_string(),
1321 entity_type: "spec".to_string(),
1322 sections: IndexMap::from_iter([
1323 ("identity".to_string(), "seed identity".to_string()),
1324 ("purpose".to_string(), "seed purpose".to_string()),
1325 ]),
1326 metadata: IndexMap::new(),
1327 relations: Vec::new(),
1328 dry_run: false,
1329 }
1330 }
1331
1332 /// Criterion 3 (agent-trust plan 02): a mutation stamps the mem's
1333 /// engine-owned state with the running engine version and resolved
1334 /// schema; a read-only load writes nothing.
1335 #[test]
1336 fn mutation_writes_version_stamp_and_read_only_load_does_not() {
1337 let tmp = TempDir::new().unwrap();
1338 let mem_dir = tmp.path().to_path_buf();
1339 write_config(&mem_dir, None);
1340
1341 // Read-only session: boot and drop without mutating — the
1342 // stamp stays absent.
1343 drop(stamped_engine_fixture(mem_dir.clone()));
1344 assert!(
1345 disk_stamp(&mem_dir).is_none(),
1346 "a read-only load must not write a stamp"
1347 );
1348
1349 // A mutation stamps engine version + resolved schema.
1350 let mut engine = stamped_engine_fixture(mem_dir.clone());
1351 engine
1352 .create_entity_with_ctx(spec_create_args("Seed"), &CommitContext::internal())
1353 .unwrap();
1354 let stamp = disk_stamp(&mem_dir).expect("mutation must write the stamp");
1355 assert_eq!(stamp.engine_version, crate::build_info::full_version());
1356 assert_eq!(stamp.schema, "default@1.0.0");
1357
1358 // A second mutation under the same binary leaves the stamp at
1359 // the same value (the write path compares and no-ops).
1360 let mut engine = stamped_engine_fixture(mem_dir.clone());
1361 engine
1362 .create_entity_with_ctx(spec_create_args("Second"), &CommitContext::internal())
1363 .unwrap();
1364 let again = disk_stamp(&mem_dir).expect("stamp survives");
1365 assert_eq!(again, stamp);
1366 }
1367
1368 /// Criterion 3/4 (agent-trust plan 02): boot under a different
1369 /// binary version surfaces the warn-tier `ENGINE_VERSION_SKEW`
1370 /// naming both versions, on load warnings AND in `health()`;
1371 /// a stamp-less mem and a matching stamp are silent.
1372 #[test]
1373 fn boot_skew_warning_fires_only_on_disagreeing_stamp() {
1374 use crate::ops::WarningHint;
1375
1376 // Disagreeing stamp → warning on boot and in health.
1377 let tmp = TempDir::new().unwrap();
1378 let mem_dir = tmp.path().to_path_buf();
1379 write_config(
1380 &mem_dir,
1381 Some(memstead_schema::MutationStamp {
1382 engine_version: "0.0.1".to_string(),
1383 schema: "default@1.0.0".to_string(),
1384 }),
1385 );
1386 let engine = stamped_engine_fixture(mem_dir);
1387 let skew: Vec<_> = engine
1388 .load_warnings()
1389 .iter()
1390 .filter(|w| matches!(w, WarningHint::EngineVersionSkew { .. }))
1391 .collect();
1392 assert_eq!(skew.len(), 1, "one skewed mem, one warning: {skew:?}");
1393 if let WarningHint::EngineVersionSkew {
1394 mem,
1395 stamped_engine,
1396 running_engine,
1397 stamped_schema,
1398 } = skew[0]
1399 {
1400 assert_eq!(mem, "specs");
1401 assert_eq!(stamped_engine, "0.0.1");
1402 assert_eq!(running_engine, crate::build_info::full_version());
1403 assert_eq!(stamped_schema, "default@1.0.0");
1404 }
1405 let health = engine.health();
1406 assert!(
1407 health
1408 .warnings
1409 .iter()
1410 .any(|w| w.code() == "ENGINE_VERSION_SKEW"),
1411 "health() must surface the skew without an include gate: {:?}",
1412 health.warnings,
1413 );
1414
1415 // Matching stamp → silent.
1416 let tmp = TempDir::new().unwrap();
1417 let mem_dir = tmp.path().to_path_buf();
1418 write_config(
1419 &mem_dir,
1420 Some(memstead_schema::MutationStamp {
1421 engine_version: crate::build_info::full_version().to_string(),
1422 schema: "default@1.0.0".to_string(),
1423 }),
1424 );
1425 let engine = stamped_engine_fixture(mem_dir);
1426 assert!(
1427 !engine
1428 .load_warnings()
1429 .iter()
1430 .any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
1431 "a matching stamp is not skew"
1432 );
1433
1434 // No stamp → silent (absence of a stamp is not skew).
1435 let tmp = TempDir::new().unwrap();
1436 let mem_dir = tmp.path().to_path_buf();
1437 write_config(&mem_dir, None);
1438 let engine = stamped_engine_fixture(mem_dir);
1439 assert!(
1440 !engine
1441 .load_warnings()
1442 .iter()
1443 .any(|w| matches!(w, WarningHint::EngineVersionSkew { .. })),
1444 "a stamp-less (pre-plan) mem boots without warning noise"
1445 );
1446 }
1447}