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