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