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