Skip to main content

memstead_base/engine/mutation/
create.rs

1//! `Engine::create_entity` — write a new entity into a mount's
2//! backend and update the in-memory store.
3
4use std::collections::{BTreeMap, HashMap};
5use std::path::Path;
6
7use indexmap::IndexMap;
8
9use memstead_schema::TypeDefinition;
10
11/// Build the per-mutation `type_guidance` map from the warnings that
12/// would otherwise carry the same type-level `write_rules` per entry.
13/// Each distinct `entity_type` named on a section / field warning
14/// contributes one entry holding the type's `write_rules`. Returns an
15/// empty map when no section/field warnings fire — the stable empty
16/// shape ships on the wire so consumers don't branch on field
17/// presence (F9).
18fn build_type_guidance(
19    warnings: &[WarningHint],
20    type_def: &TypeDefinition,
21) -> BTreeMap<String, Vec<String>> {
22    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
23    for w in warnings {
24        let entity_type = match w {
25            WarningHint::MissingRequiredSection { entity_type, .. }
26            | WarningHint::MissingRequiredField { entity_type, .. } => entity_type.as_str(),
27            _ => continue,
28        };
29        if !out.contains_key(entity_type) && entity_type == type_def.name {
30            out.insert(entity_type.to_string(), type_def.write_rules.clone());
31        }
32    }
33    out
34}
35
36use crate::engine_fallback_type;
37use crate::entity::generator::generate_markdown;
38use crate::entity::id::validate_and_derive_slug;
39use crate::entity::parser::parse_markdown;
40use crate::entity::store_builder::push_entities_into_store;
41use crate::entity::{Entity, EntityId, MetadataValue, Relationship, normalise_description};
42use crate::ops::{WarningHint, project_incoming};
43use crate::provenance::{Provenance, ProvenanceKind};
44use crate::runtime_validator::{
45    missing_required_fields, missing_required_sections, parse_metadata_value,
46    validate_section_content, validate_section_keys,
47};
48use crate::vcs::{Actor, ClientId, CommitContext};
49use crate::workspace::MountCapability;
50
51use super::super::{CreateEntityArgs, CreateEntityOutcome, Engine, EngineError};
52use super::{
53    EdgeRouteOutcome, make_stub, route_edge_validation, unknown_type_error,
54    validate_relation_target_grammar,
55};
56
57/// Everything a validated create needs to hit disk — the create-side
58/// twin of `PreparedUpdate`. Produced by `Engine::prepare_create`,
59/// consumed by `Engine::commit_prepared_create` (single item) and by
60/// `Engine::batch_create` (staged all-first, one commit per mem).
61struct PreparedCreate {
62    mount_idx: usize,
63    id: EntityId,
64    title: String,
65    mem: String,
66    file_path: String,
67    markdown: String,
68    anchors: Vec<crate::anchor::Anchor>,
69    warnings: Vec<WarningHint>,
70    type_guidance: std::collections::BTreeMap<String, Vec<String>>,
71    relations_declared: Vec<crate::engine::outcomes::RelationDeclared>,
72    /// Inline-relation targets — the commit tail materialises
73    /// forward-reference stubs for the ones the store still lacks.
74    relation_targets: Vec<EntityId>,
75    type_def: std::sync::Arc<memstead_schema::TypeDefinition>,
76}
77
78/// Outcome of `Engine::prepare_create`: a dry-run completes at prepare
79/// time; a real write returns the staged material.
80enum CreatePrepareOutcome {
81    Done(CreateEntityOutcome),
82    Prepared(PreparedCreate),
83}
84
85impl Engine {
86    /// Create a new entity in `args.mem`. Six concerns wired here
87    /// in one shape regardless of which backend serves the mount:
88    ///
89    /// 1. **Capability gating** — rejects mounts with `ReadOnly`
90    ///    capability before reaching the backend.
91    /// 2. **Validator pipeline** — `validate_section_keys` +
92    ///    `parse_metadata_value` enforce the pinned schema's strictness;
93    ///    typed `ValidationError` lifts to `EngineError::Validation`.
94    /// 3. **Provenance** — a `Provenance` record routes through
95    ///    `backend.append_provenance` (folder writes JSONL, git-branch
96    ///    no-ops since the commit subject + trailers carry the same
97    ///    fields).
98    /// 4. **Write + commit atomicity** — `backend.write_entity` then
99    ///    `backend.commit` with the canonical `memstead: create <id>`
100    ///    subject so the git-branch backend's `read_provenance` can
101    ///    recover the kind.
102    /// 5. **Store update** — re-parse the freshly-generated markdown
103    ///    so the in-memory `Store` mirrors disk (including
104    ///    generator-determined `content_hash`).
105    /// 6. **Error envelope** — `BackendError::Sealed` lifts via the
106    ///    `Backend` variant so MCP callers see the typed payload
107    ///    intact; `HashMismatch` propagates likewise.
108    pub fn create_entity(
109        &mut self,
110        args: CreateEntityArgs,
111        actor: Actor,
112        client: Option<&ClientId>,
113        note: Option<&str>,
114    ) -> Result<CreateEntityOutcome, EngineError> {
115        let drift_warnings = self.reload_if_stale(Some(&args.mem));
116        match self.prepare_create(args, None, drift_warnings)? {
117            CreatePrepareOutcome::Done(outcome) => Ok(outcome),
118            CreatePrepareOutcome::Prepared(prepared) => {
119                self.commit_prepared_create(prepared, actor, client, note)
120            }
121        }
122    }
123
124    /// Validate a create and compute everything up to (but not
125    /// including) the disk write — the create-side prepare of the
126    /// prepare-all-then-commit split `batch_update` established.
127    /// Returns [`CreatePrepareOutcome::Done`] for a dry-run (its
128    /// outcome is complete), [`CreatePrepareOutcome::Prepared`] for a
129    /// real write the caller commits via
130    /// [`Self::commit_prepared_create`].
131    ///
132    /// `batch_skeleton_ids` is the batch path's staging set: ids the
133    /// current batch has pre-inserted as skeleton entities so
134    /// intra-batch references validate as REAL targets. A create whose
135    /// id is in the set skips the already-exists refusal (the skeleton
136    /// is this very entry's placeholder — batch-side identity checks
137    /// have already refused genuine duplicates). Single-item callers
138    /// pass `None`.
139    /// NOTE: the reload-before-operation drift probe is the CALLER's
140    /// job (single-item: `create_entity` probes its one mem; batch:
141    /// `batch_create` probes every touched mem once, up front). A probe
142    /// inside prepare would reload mid-batch and wipe the staged
143    /// skeletons.
144    fn prepare_create(
145        &mut self,
146        args: CreateEntityArgs,
147        batch_skeleton_ids: Option<&std::collections::HashSet<EntityId>>,
148        mut drift_warnings: Vec<WarningHint>,
149    ) -> Result<CreatePrepareOutcome, EngineError> {
150        let mut args = args;
151        // Canonicalise rel_type on every inline relation — same contract
152        // as `relate_entity`: input is case-insensitive, storage and
153        // response are UPPER_SNAKE_CASE. Syntax errors fall through to
154        // the schema check, which surfaces them as INVALID_REL_TYPE.
155        for rel in &mut args.relations {
156            if let Ok(canonical) = crate::entity::id::validate_rel_type(&rel.rel_type) {
157                rel.rel_type = canonical;
158            }
159        }
160
161        // Trim surrounding whitespace from the title before slug
162        // derivation + storage. Internal whitespace is preserved.
163        // Fully-whitespace titles collapse to empty and fall through to
164        // the validator below (which already refuses empty). Without
165        // trimming, a caller-supplied
166        // `"   Foo   "` renders with leading/trailing spaces despite the
167        // slug being correct. We emit `TITLE_TRIMMED` whenever trimming
168        // changed the value so the audit trail records the drift.
169        let mut title_trimmed_warning: Option<crate::ops::WarningHint> = None;
170        let trimmed_title = args.title.trim();
171        if trimmed_title.len() != args.title.len() {
172            title_trimmed_warning = Some(crate::ops::WarningHint::TitleTrimmed {
173                original: args.title.clone(),
174                trimmed: trimmed_title.to_string(),
175            });
176            args.title = trimmed_title.to_string();
177        }
178
179        // 1. Resolve the mount and gate on capability.
180        let mount_idx = self
181            .mounts
182            .iter()
183            .position(|m| m.mount.mem == args.mem)
184            .ok_or_else(|| self.unknown_mem_error(&args.mem))?;
185        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
186            return Err(EngineError::ReadOnlyMount(args.mem));
187        }
188
189        // 1a. Reload-before-operation. Probe the mem ref and reload
190        //     if a sibling writer advanced it past our cached head, so
191        //     the duplicate-id check below and the eventual commit both
192        //     run against current truth. Any `MemReloaded` warning
193        //     rides the outcome's `warnings` (merged at the accumulator
194        //     below). This is what makes a create at an id a sibling
195        //     just created refuse as already-exists rather than
196        //     silently rebasing onto an unobserved commit.
197        // 2. Resolve schema + type. The schema map is populated for
198        //    every mount during `from_mounts`, so the lookup is total.
199        let schema = self
200            .schemas
201            .get(&args.mem)
202            .expect("schema present for every registered mount");
203        let type_def = schema
204            .get_type(&args.entity_type)
205            .ok_or_else(|| unknown_type_error(schema, &args.entity_type))?;
206
207        // 3. Pre-write validators: section keys and metadata values.
208        validate_section_keys(args.sections.keys().map(String::as_str), type_def.as_ref())?;
209        // Reserved identity/discriminator keys (`mem`/`id`/`type`)
210        // refuse deliberately (`READ_ONLY_FIELD`) before the metadata
211        // parse loop can refuse them incidentally as
212        // `UNKNOWN_METADATA_FIELD` — symmetric with the update path's
213        // set gate, so the two paths agree and the refusal names the
214        // real reason. Timestamp fields keep create's documented
215        // stamp-and-proceed posture (`IGNORED_READONLY_FIELD` warning,
216        // step 5a) — only the triple is checked here.
217        for key in args.metadata.keys() {
218            crate::runtime_validator::validate_reserved_metadata_key(key.as_str())?;
219        }
220        // 3a. Validate any `anchors[]` payload up front — a malformed
221        //     element (unknown class/grain, missing artifact, hash on a
222        //     non-hash class, grain unsupported by the resolving medium's
223        //     namespace) refuses the WHOLE create with a typed
224        //     `INVALID_ANCHOR` envelope BEFORE any disk write, so the
225        //     entity is never written. Empty payload → empty vec (no
226        //     sidecar write; byte-identical to a pre-anchor create). Runs
227        //     even on the dry_run path so validity agrees across preview
228        //     and real write.
229        let validated_anchors = self.validate_anchor_inputs(&args.mem, &args.anchors)?;
230        // Refuse section content with embedded `^## ` headings — the
231        // compose-then-reparse pipeline would split the value at the
232        // heading and silently move the trailing content into another
233        // section.
234        validate_section_content(args.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
235
236        // 4. Slug + id; reject duplicates against the in-memory store.
237        //    Stub adoption: a pre-existing stub at the same id is
238        //    *not* a duplicate — the create promotes the stub to a
239        //    real entity while preserving its incoming edges (store.
240        //    upsert leaves in_edges in place). Mirrors full's
241        //    `if let Some(existing) = store.get(&id) && !existing.stub`.
242        let derivation = validate_and_derive_slug(&args.title)?;
243        let slug = derivation.slug.clone();
244        let id = EntityId::new(&args.mem, &slug);
245        crate::entity::id::enforce_id_length(id.as_ref())?;
246        if let Some(existing) = self.store.get(&id)
247            && !existing.stub
248            && !batch_skeleton_ids.is_some_and(|set| set.contains(&id))
249        {
250            return Err(EngineError::AlreadyExists {
251                id: id.to_string(),
252                existing_title: existing.title.clone(),
253                existing_is_stub: false,
254            });
255        }
256        let file_path = format!("{slug}.md");
257
258        // 5. Build metadata. `type` is seeded so the generator emits
259        //    the canonical frontmatter; caller-provided overrides go
260        //    through `parse_metadata_value` for enum / type checks.
261        let mut metadata: IndexMap<String, MetadataValue> = IndexMap::new();
262        metadata.insert(
263            "type".to_string(),
264            MetadataValue::String(args.entity_type.clone()),
265        );
266        for (k, v) in &args.metadata {
267            let parsed = parse_metadata_value(k.as_str(), v.as_str(), type_def.as_ref())?;
268            metadata.insert(k.clone(), parsed);
269        }
270
271        // 5a. Engine-managed timestamps: schema-declared `init_timestamp`
272        //     and `auto_timestamp` fields take the engine value
273        //     regardless of any caller-supplied override. Symmetric with
274        //     the update path's `auto_timestamp` loop — both flags carry
275        //     a schema-promised meaning the user cannot override.
276        //     `init_timestamp` is create-only (set once, then stable);
277        //     `auto_timestamp` re-stamps on every update.
278        let today = self.now_iso();
279        // Accumulate `IGNORED_READONLY_FIELD` warnings: when the caller
280        // supplied a value for an auto-managed field, the engine value
281        // overwrites it below — surface that the input was discarded
282        // rather than swallowing it silently (the update path refuses
283        // these keys with `READ_ONLY_FIELD`; create's posture is
284        // stamp-and-proceed, so it warns). Built here, merged into the
285        // response `warnings` accumulator once that exists.
286        let mut ignored_readonly: Vec<WarningHint> = Vec::new();
287        for field_def in &type_def.metadata_fields {
288            if field_def.init_timestamp || field_def.auto_timestamp {
289                if let Some(supplied) = args.metadata.get(field_def.key.as_str()) {
290                    ignored_readonly.push(WarningHint::IgnoredReadonlyField {
291                        field: field_def.key.clone(),
292                        supplied: supplied.clone(),
293                    });
294                }
295                metadata.insert(field_def.key.clone(), MetadataValue::String(today.clone()));
296            }
297        }
298
299        // 6. Refuse — not warn — when required sections are absent or
300        //    empty. Pre-fix this branch emitted a `WarningHint` per
301        //    missing section and let the entity land with empty
302        //    placeholders; the resulting on-disk state then failed the
303        //    install-time strict validator, breaking the export-then-
304        //    install round-trip. The refusal carries every missing
305        //    section plus the type-level `type_guidance` map so the
306        //    agent recovers in a single round-trip via re-call with
307        //    the missing content filled in. Iterative authoring stays
308        //    available — the agent creates the entity with whatever
309        //    sections they have, then fills in the rest via
310        //    `memstead_update` (which retains its permissive posture on
311        //    `MISSING_REQUIRED_SECTION`).
312        let missing_sections = missing_required_sections(type_def.as_ref(), &args.sections);
313        if !missing_sections.is_empty() {
314            let mut type_guidance: BTreeMap<String, Vec<String>> = BTreeMap::new();
315            if missing_sections
316                .iter()
317                .any(|m| m.entity_type == type_def.name)
318            {
319                type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
320            }
321            return Err(EngineError::MissingRequiredSection {
322                entity_type: type_def.name.clone(),
323                missing_count: missing_sections.len(),
324                sections: missing_sections,
325                type_guidance,
326            });
327        }
328
329        // 6a. Parallel for metadata fields: refuse on the first
330        //     missing required field the schema does not auto-fill.
331        //     Same trust-boundary reasoning as the sections case —
332        //     pre-fix the generator silently wrote today's-date / ""
333        //     placeholders that the strict validator at install time
334        //     can refuse. The agent fixes one field per round-trip
335        //     (schema-declaration order); the recovery shape mirrors
336        //     the existing `RequiredFieldUnset` envelope on the update
337        //     path so a single decoder handles both surfaces.
338        let missing_fields = missing_required_fields(type_def.as_ref(), &args.metadata);
339        if !missing_fields.is_empty() {
340            // Surface the
341            // full accumulator (`details.missing[]`) so the agent
342            // fixes every required-no-default field unset in one
343            // retry. The singular `field` / `field_description` /
344            // `enum_values` echo the first entry for back-compat
345            // with consumers reading the singular shape.
346            let first = missing_fields[0].clone();
347            return Err(EngineError::RequiredFieldUnset {
348                field: first.key,
349                entity_type: first.entity_type,
350                field_description: Some(first.description),
351                enum_values: first.enum_values,
352                type_write_rules: type_def.write_rules.clone(),
353                // Create path — the caller never
354                // supplied this field. Display / prose_render flip to
355                // "not provided" wording so the prose matches the
356                // semantic. Recovery is unchanged; the typed code
357                // stays `REQUIRED_FIELD_UNSET`.
358                on_create: true,
359                missing: missing_fields,
360            });
361        }
362
363        let mut warnings: Vec<WarningHint> = Vec::new();
364
365        // Reload-before-operation drift notice (probed at the top, after
366        // the capability gate). Surfaced first so the agent sees the
367        // world moved before reading the rest of the outcome.
368        warnings.append(&mut drift_warnings);
369
370        // Auto-managed fields the caller tried to set (computed during
371        // the stamp loop above) — the supplied values were discarded.
372        warnings.append(&mut ignored_readonly);
373
374        // Title↔slug divergence: the widened title grammar admits
375        // characters the slug alphabet drops — visible, not fatal.
376        if !derivation.dropped_chars.is_empty() {
377            warnings.push(WarningHint::TitleCharsDroppedFromSlug {
378                title: args.title.trim().to_string(),
379                dropped_chars: derivation.dropped_chars.clone(),
380                slug: slug.clone(),
381            });
382        }
383
384        // Surface the title-trim drift (computed pre-validation) so the
385        // audit trail records what the caller sent.
386        if let Some(w) = title_trimmed_warning.take() {
387            warnings.push(w);
388        }
389
390        // 6c. Build `type_guidance` map for the response — one entry
391        //     per distinct entity_type referenced by warnings carrying
392        //     entity-type context (currently
393        //     `UndeclaredRelationshipOpen` etc). Empty when no such
394        //     warnings fire — the section/field cases now refuse
395        //     above. The stable empty shape always ships so callers
396        //     don't branch on field presence.
397        let type_guidance = build_type_guidance(&warnings, type_def.as_ref());
398
399        // 6b. Validate inline relationship inputs through the same
400        //     gates `memstead_relate` runs (Item 02): target-id grammar,
401        //     rel-type vocabulary, schema shape. Pre-fix the create
402        //     path ran only the rel-type check, so an agent could
403        //     sneak a malformed target id (auto-stub at
404        //     `bad@chars$here`) or a shape-violating
405        //     `(rel_type, source_type, target_type)` triple through
406        //     `memstead_create.relations[]` even though `memstead_relate`
407        //     rejected the same input. Strict-mode schemas reject
408        //     unknown rel-types with `INVALID_REL_TYPE`; open-mode
409        //     schemas admit them and surface a typed
410        //     `UndeclaredRelationshipOpen` warning. Stub-as-source
411        //     is impossible here — the source is the newly-created
412        //     entity, always real-by-construction.
413        for rel in &args.relations {
414            validate_relation_target_grammar(&rel.to)?;
415            let target_mem = rel.to.mem().to_string();
416            // Cross-mem policy gate. The funnel
417            // sits ahead of the rel-type / shape checks so the policy
418            // refusal is identical in shape and ordering to
419            // `memstead_relate` and `memstead_update.declare_relations`.
420            super::validate_cross_mem_add_policy(self, &args.mem, &rel.to)?;
421            // Target-type lookup mirrors the relate path: `None` for
422            // not-yet-present targets so the target gate admits the
423            // stub-bound case. The cross-mem router below consults
424            // it for both intra-mem shape and cross-mem-different
425            // shape checks.
426            let target_type = self
427                .store
428                .get(&rel.to)
429                .map(|e| e.entity_type.clone())
430                .filter(|t| !t.is_empty());
431            match route_edge_validation(
432                self,
433                &rel.rel_type,
434                args.entity_type.as_str(),
435                target_type.as_deref(),
436                &args.mem,
437                &target_mem,
438                &id,
439                &rel.to,
440                /* check_shape = */ true,
441            )? {
442                EdgeRouteOutcome::Ok => {}
443                EdgeRouteOutcome::OpenModeWarning(w) => warnings.push(*w),
444            }
445            // Per-edge description posture. Normalise first so empty
446            // strings collapse to `None` before the gate.
447            let normalised = normalise_description(rel.description.as_deref());
448            super::validate_description_posture(
449                self,
450                &rel.rel_type,
451                normalised.as_deref(),
452                &args.mem,
453                &target_mem,
454                &id,
455                &rel.to,
456            )?;
457            // Explicit inline-relations path is an
458            // explicit-author boundary — gate on the rel-type's
459            // `manual_authoring` posture.
460            super::validate_manual_authoring_posture(self, &rel.rel_type, &args.mem, &id, &rel.to)?;
461            // Cycle family — the same shared gate `memstead_relate` runs
462            // (self-loop on listed no-self-loop rel-types, long cycle
463            // on acyclic ones), against the current store. A stub being promoted
464            // by this create already carries its incoming edges, so a
465            // back-path through the new id is visible; on the batch
466            // path prior items' edges are staged into the store, so an
467            // intra-batch cycle refuses here too. Canonicalise the
468            // rel-type first (same derivation as
469            // `update.declare_relations`) so the schema lookups see
470            // the wire-contract form.
471            let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
472                .unwrap_or_else(|_| rel.rel_type.clone());
473            super::validate_edge_acyclicity(
474                &self.store,
475                schema.as_ref(),
476                &id,
477                args.entity_type.as_str(),
478                &rel.to,
479                &canonical,
480            )?;
481        }
482
483        // 7. Synthesise the in-memory entity for the generator. The
484        //    `content_hash` and `heading_spans` are derived; left
485        //    blank because we re-parse the generated bytes below.
486        //    Inline relations land in `relationships` so the
487        //    generator emits them and the post-parse re-ingest
488        //    rebuilds the edges in the store.
489        let relationships: Vec<Relationship> = args
490            .relations
491            .iter()
492            .map(|r| Relationship {
493                rel_type: r.rel_type.clone(),
494                target: r.to.clone(),
495                description: normalise_description(r.description.as_deref()),
496            })
497            .collect();
498        // Pre-compute the `relations_declared` outcome echo. Read
499        // `target_was_stubbed` against the pre-mutation store state
500        // (the post-parse `push_entities_into_store` step will
501        // auto-stub absent targets). Shape matches
502        // `memstead_update.relations_declared` so callers see a uniform
503        // wire shape across the two tools.
504        let relations_declared: Vec<crate::engine::outcomes::RelationDeclared> = args
505            .relations
506            .iter()
507            .map(|r| crate::engine::outcomes::RelationDeclared {
508                rel_type: r.rel_type.clone(),
509                target: r.to.clone(),
510                target_was_stubbed: !self.store.contains(&r.to),
511            })
512            .collect();
513        let mut entity_for_render = Entity {
514            id: id.clone(),
515            title: args.title.clone(),
516            entity_type: args.entity_type.clone(),
517            mem: args.mem.clone(),
518            file_path: file_path.clone(),
519            metadata,
520            sections: args.sections,
521            relationships,
522            content_hash: String::new(),
523            stub: false,
524            stub_kind: None,
525            heading_spans: HashMap::new(),
526            raw_section_headings: Vec::new(),
527        };
528        // Alias-synthesis pass: for schemas declaring
529        // `alias_target_rel_type`, append engine-emitted relations of
530        // that rel-type for every body wiki-link not already backed.
531        // Cross-mem refusal aborts the create — no partial state.
532        // Schemas without the pointer fall through unchanged and the
533        // validator below catches the missing relations.
534        //
535        // The returned `Vec<Relationship>` is the per-call set of
536        // relations the pass just emitted (in body iteration order).
537        // It feeds the `InlineWikiLinkAutoStubbed` emission below —
538        // using the post-mutation `entity.relationships` as the source
539        // via `parse_markdown` filters out the body-link targets
540        // because the parser-side `relationships`-coverage filter has
541        // already absorbed them.
542        let empty_prev_targets = std::collections::HashSet::new();
543        let (synthesised_relations, self_link_ignored) =
544            super::synthesise_alias_relations(self, &empty_prev_targets, &mut entity_for_render)?;
545        if self_link_ignored {
546            warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
547        }
548
549        // Alias-existence invariant: every body wiki-link must be
550        // backed by an entry in `entity.relationships` (the auto-managed
551        // `## Relationships` section). Runs unconditionally on every
552        // Write-Mem create. See [`scan_wikilinks_without_relation`].
553        let missing = super::scan_wikilinks_without_relation(&entity_for_render)?;
554        if !missing.is_empty() {
555            return Err(EngineError::WikiLinkWithoutRelation {
556                from_id: id.to_string(),
557                missing: missing
558                    .into_iter()
559                    .map(|(section_key, target)| crate::engine::MissingWikiLink {
560                        section_key,
561                        target_id: target.to_string(),
562                    })
563                    .collect(),
564            });
565        }
566
567        let markdown = generate_markdown(&entity_for_render, type_def.as_ref());
568
569        // 7a. Inline `[[wiki-link]]` patterns in section bodies that
570        //     point at non-existent targets get auto-stubbed by the
571        //     loader on re-ingest. Surface the would-be stubs as a
572        //     warning so prose-induced ghosts are reviewable. Mirrors
573        //     `memstead_relate`'s `AUTO_STUB_CREATED` observation
574        //     discipline.
575        //
576        //     The input set is the relations the alias-synthesis pass
577        //     emitted on this call — NOT a re-parse of the generated
578        //     markdown. `parse_markdown` filters its `inline_links`
579        //     against the entity's `relationships` vec (which the
580        //     synthesis pass has already appended to), so the
581        //     pre-fix path saw `inline_links: []` and never fired
582        //     the warning. The synthesised vec is the authoritative
583        //     per-call source.
584        let auto_stubbed: Vec<EntityId> = synthesised_relations
585            .iter()
586            .filter_map(|rel| {
587                if !self.store.contains(&rel.target) {
588                    Some(rel.target.clone())
589                } else {
590                    None
591                }
592            })
593            .collect();
594        if !auto_stubbed.is_empty() {
595            warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
596                from: id.clone(),
597                stubs: auto_stubbed,
598            });
599        }
600
601        // 7a-bis. Required-outgoing evaluation — the warning the tool
602        // descriptions have promised all along. Runs now that every
603        // edge this create carries (declared + alias-synthesised) is
604        // known, through the same evaluation the health sweep uses
605        // (one implementation; the two surfaces cannot disagree). A
606        // warning, never a refusal: entities are legitimately built up
607        // over several calls.
608        // Section-format evaluation (plan 08): each written section
609        // body against its declared markdown shape, judged by the
610        // real CommonMark reduction. Block-tier refuses with the
611        // first violation, pre-commit; warn-tier surfaces via the
612        // health sweep, never at write time.
613        for def in &type_def.sections {
614            if def.format_severity != memstead_schema::ConstraintSeverity::Block {
615                continue;
616            }
617            // Absent-as-empty: the generator renders every declared
618            // section heading (empty body when omitted), so the
619            // format judges the state that actually lands on disk —
620            // an expression that does not admit the empty sequence
621            // makes its section effectively required (declare a `?`
622            // or `*` form to admit omission). Without this, an
623            // omitting create passes while health flags the same
624            // on-disk state — write path and health must agree.
625            let body = entity_for_render
626                .sections
627                .get(def.key.as_str())
628                .map(String::as_str)
629                .unwrap_or("");
630            if let Some(first) = crate::section_format::check_section_format(def, body)
631                .into_iter()
632                .next()
633            {
634                return Err(EngineError::SectionFormatRefused {
635                    entity_type: entity_for_render.entity_type.clone(),
636                    entity_id: id.to_string(),
637                    violation: first,
638                });
639            }
640        }
641
642        let unsatisfied = crate::ops::health::unsatisfied_required_outgoing(
643            &entity_for_render,
644            type_def.as_ref(),
645        );
646        if !unsatisfied.is_empty() {
647            // A block declared `severity: block` promotes the warning
648            // to a refusal — evaluated here, before any disk or store
649            // effect, so a refused create leaves nothing behind.
650            let blocked: Vec<_> = unsatisfied
651                .iter()
652                .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
653                .cloned()
654                .collect();
655            if !blocked.is_empty() {
656                return Err(EngineError::RequiredOutgoingUnsatisfied {
657                    entity_type: entity_for_render.entity_type.clone(),
658                    entity_id: id.to_string(),
659                    missing: blocked,
660                });
661            }
662            warnings.push(WarningHint::MissingRequiredOutgoing {
663                entity_type: entity_for_render.entity_type.clone(),
664                entity_id: id.clone(),
665                missing: unsatisfied,
666            });
667        }
668
669        // Declared-constraints evaluation (`requires_when`, …) — the
670        // same single evaluation the health `constraints` include
671        // runs. Block-tier violations refuse; warn-tier violations
672        // warn and the write proceeds.
673        let violated = crate::ops::health::unsatisfied_constraints(
674            &self.store,
675            &entity_for_render,
676            type_def.as_ref(),
677            None,
678        );
679        if !violated.is_empty() {
680            let blocked: Vec<_> = violated
681                .iter()
682                .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
683                .cloned()
684                .collect();
685            if !blocked.is_empty() {
686                return Err(EngineError::ConstraintUnsatisfied {
687                    entity_type: entity_for_render.entity_type.clone(),
688                    entity_id: id.to_string(),
689                    violations: blocked,
690                });
691            }
692            warnings.push(WarningHint::ConstraintUnsatisfied {
693                entity_type: entity_for_render.entity_type.clone(),
694                entity_id: id.clone(),
695                violations: violated,
696            });
697        }
698
699        // 7b. Dry-run: compute prospective hash from the in-memory
700        //     entity and return without touching disk, store, or
701        //     edges. Mirrors full's `CreateArgs.dry_run` semantics —
702        //     `content_hash` carries the prospective hash since
703        //     there's no current to differentiate from. `commit_sha`
704        //     is empty. Stub creation is also skipped (no
705        //     in-memory side effects).
706        if args.dry_run {
707            let prospective_hash = crate::entity::parser::compute_hash(&markdown);
708            // `created_date` from the in-memory entity (the
709            // metadata-construction loop already set the
710            // init_timestamp default to `today_iso`-equivalent).
711            let created_date = entity_for_render
712                .metadata
713                .get("created_date")
714                .map(|v| v.to_frontmatter_string())
715                .unwrap_or_default();
716            // Full's dry_run computes incoming from the existing
717            // store state (the refs that *would* be adopted if a
718            // stub exists at this id). Read before any mutation.
719            let incoming = project_incoming(self.store.incoming(&id));
720            let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
721            return Ok(CreatePrepareOutcome::Done(CreateEntityOutcome {
722                id,
723                title: args.title,
724                mem: args.mem,
725                file_path,
726                content_hash: prospective_hash,
727                commit_sha: String::new(),
728                created_date,
729                warnings,
730                type_guidance,
731                incoming_count,
732                incoming,
733                relations_declared: relations_declared.clone(),
734            }));
735        }
736
737        Ok(CreatePrepareOutcome::Prepared(PreparedCreate {
738            mount_idx,
739            id,
740            title: args.title,
741            mem: args.mem,
742            file_path,
743            markdown,
744            anchors: validated_anchors,
745            warnings,
746            type_guidance,
747            relations_declared,
748            relation_targets: args.relations.iter().map(|r| r.to.clone()).collect(),
749            type_def,
750        }))
751    }
752
753    /// Stage the prepared disk write, commit it, append provenance, and
754    /// apply the change to the in-memory store — the single-create tail
755    /// of [`Self::create_entity`]. The batch path drives the same
756    /// steps but stages every item first and commits once per mem.
757    fn commit_prepared_create(
758        &mut self,
759        prepared: PreparedCreate,
760        actor: Actor,
761        client: Option<&ClientId>,
762        note: Option<&str>,
763    ) -> Result<CreateEntityOutcome, EngineError> {
764        let PreparedCreate {
765            mount_idx,
766            id,
767            title,
768            mem,
769            file_path,
770            markdown,
771            anchors: validated_anchors,
772            mut warnings,
773            type_guidance,
774            relations_declared,
775            relation_targets,
776            type_def,
777        } = prepared;
778
779        // 8. Write + commit through the backend. The commit subject
780        //    is `memstead: create <id>` so the git-branch backend's
781        //    `read_provenance` recovers the kind via the verb. The
782        //    folder backend's commit ignores the message; the
783        //    canonical form is harmless there.
784        let backend = self.mounts[mount_idx].backend.as_ref();
785        backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
786        // Stage the anchors sidecar into the SAME pending buffer so it
787        // rides the entity's commit atomically. Only when the create
788        // carried anchors — an anchorless create writes no sidecar and is
789        // byte-identical to a pre-anchor create.
790        if !validated_anchors.is_empty() {
791            super::stage_anchors_sidecar(backend, &id, &[], validated_anchors)?;
792        }
793        // Derivation baselines (agent-trust plan 12): each explicitly
794        // declared relation on a derivation rel-type records the
795        // target's current hash ("" for an absent/stubbed target),
796        // staged so baseline and entity ride one commit.
797        if let Some(schema) = self.schemas.get(&mem) {
798            for r in relations_declared
799                .iter()
800                .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
801            {
802                let hash = self
803                    .store
804                    .get(&r.target)
805                    .map(|e| e.content_hash.clone())
806                    .unwrap_or_default();
807                let (from, rel, to) = (id.to_string(), r.rel_type.clone(), r.target.to_string());
808                super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
809            }
810        }
811        let commit_subject = format!("memstead: create {id}");
812        let ctx = CommitContext {
813            actor,
814            client: client.cloned(),
815            tool: Some("create_entity"),
816            note: note.map(String::from),
817            role: self.current_role,
818            logical_operation_id: None,
819            entity_ids: None,
820        };
821        let commit_sha = backend.commit(&commit_subject, &ctx)?;
822
823        // 9. Append provenance. Folder writes a JSONL line; git-branch
824        //    no-ops (the commit object already carries the data).
825        backend.append_provenance(
826            &Provenance::new(
827                std::time::SystemTime::now(),
828                ProvenanceKind::Create,
829                Some(id.to_string()),
830                actor,
831                client.cloned(),
832                note.map(String::from),
833            )
834            .with_role(self.current_role),
835        )?;
836
837        // Self-write bookkeeping: jump `last_known_head` to the SHA
838        // we just produced so the next read doesn't surface
839        // `MEM_RELOADED` for our own commit.
840        self.record_self_write(mount_idx, &commit_sha);
841        self.stamp_mutation_versions(mount_idx);
842
843        // 10. Update the in-memory store via re-parse so the store
844        //     mirrors the on-disk shape (content_hash, heading_spans).
845        let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &mem)
846            .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
847        let content_hash = parse_result.entity.content_hash.clone();
848
849        // Extract `created_date` from the parsed entity's metadata
850        // before pushing into the store (after push, the entity is
851        // borrowed by the store and re-fetching costs a lookup).
852        // The default schema's auto-timestamp fills `created_date`
853        // with today's ISO date; the field is empty for schemas
854        // that don't declare it.
855        let created_date = parse_result
856            .entity
857            .metadata
858            .get("created_date")
859            .map(|v| v.to_frontmatter_string())
860            .unwrap_or_default();
861
862        let fallback = engine_fallback_type();
863        push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
864        crate::entity::store_builder::remap_alias_target_edge_sources(
865            &mut self.store,
866            &self.schemas,
867        );
868
869        // Materialise stubs for any inline-relation targets that
870        // weren't already in the store. Mirrors the relate path's
871        // ensure_target — full's create relies on the
872        // loader stubbing unresolved targets, but the unified
873        // store doesn't auto-stub on push, so the engine does it
874        // explicitly. Skipped when no relations were declared
875        // (the args.relations vec is empty).
876        for target in &relation_targets {
877            if !self.store.contains(target) {
878                self.store.upsert(
879                    target.clone(),
880                    make_stub(target, crate::entity::StubKind::ForwardReference),
881                );
882            }
883        }
884
885        self.invalidate_communities();
886        self.invalidate_search_indexes();
887
888        // Stub-adoption visibility: project the incoming edges that
889        // survived the upsert. Empty for a fresh create; populated
890        // when a pre-existing stub at this id had referrers.
891        let incoming = project_incoming(self.store.incoming(&id));
892        let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
893
894        // `require_notes` provenance nudge — single engine-level
895        // enforcement point (see `Engine::note_missing_warning`). Only
896        // reached on the real-write path (commit landed); the dry-run
897        // early return above never demands a note.
898        if let Some(w) = self.note_missing_warning("create_entity", note) {
899            warnings.push(w);
900        }
901
902        Ok(CreateEntityOutcome {
903            id,
904            title,
905            mem,
906            file_path,
907            content_hash,
908            commit_sha,
909            created_date,
910            warnings,
911            type_guidance,
912            incoming_count,
913            incoming,
914            relations_declared,
915        })
916    }
917
918    /// Atomic batch create — the create-side sibling of
919    /// [`Self::batch_update`], with one upgrade and one addition:
920    ///
921    /// - **Report-all refusal.** Every failing entry is identified with
922    ///   its index and typed `{code, message, details}` envelope (the
923    ///   family's upgraded contract) — bounded at
924    ///   [`Self::BATCH_ERROR_REPORT_CAP`] detailed envelopes, with
925    ///   `errors_suppressed` counting the rest. A refused batch writes
926    ///   NOTHING: no entity, no edge, no head movement.
927    /// - **Intra-batch references resolve as REAL targets.** Every
928    ///   entity in the batch is staged (a skeleton store entry carrying
929    ///   its declared type) before per-entry validation runs, so an
930    ///   edge to a sibling created in the same batch gets full
931    ///   target-type shape validation, no transient stub, and no stub
932    ///   warning — the batch validates as one graph state, cycles
933    ///   included where the schema permits them. Duplicates within the
934    ///   batch are refused in the identity pass.
935    ///
936    /// One workspace load (the caller's), one commit per touched mem
937    /// (subject `memstead: batch-create (N entities)`), per-entry
938    /// provenance notes exactly like `batch_update`.
939    ///
940    /// **Rehearsal** (`dry_run: true`): the FULL validation pass runs —
941    /// identity, skeleton staging (so intra-batch references resolve as
942    /// real targets, cycles included), per-entry prepare, report-all
943    /// refusals — then the batch stops before any write. A legal batch
944    /// returns the would-be receipt (`applied: true`, per-entry
945    /// `"created"` with the prospective ids) with the marker form's
946    /// empty `commit_sha`; an illegal one returns the same refusal a
947    /// real call would. Nothing is written, committed, or stubbed.
948    pub fn batch_create(
949        &mut self,
950        creates: Vec<(CreateEntityArgs, Option<String>)>,
951        actor: Actor,
952        client: Option<&ClientId>,
953        dry_run: bool,
954    ) -> Result<crate::ops::BatchResult, EngineError> {
955        use std::collections::HashSet;
956
957        if creates.is_empty() {
958            return Ok(crate::ops::BatchResult {
959                orphan_stubs_removed: Vec::new(),
960                errors_suppressed: 0,
961                applied: true,
962                results: Vec::new(),
963                succeeded: 0,
964                failed: 0,
965                commit_sha: String::new(),
966            });
967        }
968
969        // Reload every touched mem once, up front.
970        let mut touched_mems: Vec<String> = creates.iter().map(|(a, _)| a.mem.clone()).collect();
971        touched_mems.sort();
972        touched_mems.dedup();
973        for m in &touched_mems {
974            self.reload_if_stale(Some(m));
975        }
976
977        let store_snapshot = self.store.clone();
978
979        // --- Identity pass: derive every entry's id, refusing
980        // duplicates against the pre-batch store AND within the batch.
981        // Collect EVERY failure (report-all), never just the first.
982        struct IdentityRow {
983            id: Option<EntityId>,
984            error: Option<EngineError>,
985        }
986        let mut rows: Vec<IdentityRow> = Vec::with_capacity(creates.len());
987        // id → title of the batch entry that claimed it, so a
988        // within-batch duplicate can name the occupying title.
989        let mut batch_ids: HashMap<EntityId, String> = HashMap::new();
990        for (args, _) in &creates {
991            let identity = (|| -> Result<EntityId, EngineError> {
992                let title = args.title.trim();
993                // Divergence warnings ride the per-entry prepare pass
994                // below, which re-derives; this pass only needs the id.
995                let slug = validate_and_derive_slug(title)?.slug;
996                let id = EntityId::new(&args.mem, &slug);
997                crate::entity::id::enforce_id_length(id.as_ref())?;
998                if let Some(existing) = self.store.get(&id)
999                    && !existing.stub
1000                {
1001                    return Err(EngineError::AlreadyExists {
1002                        id: id.to_string(),
1003                        existing_title: existing.title.clone(),
1004                        existing_is_stub: false,
1005                    });
1006                }
1007                if let Some(prior_title) = batch_ids.get(&id) {
1008                    // Duplicate WITHIN the batch — same typed code as
1009                    // the store collision; the index in the report
1010                    // localises it.
1011                    return Err(EngineError::AlreadyExists {
1012                        id: id.to_string(),
1013                        existing_title: prior_title.clone(),
1014                        existing_is_stub: false,
1015                    });
1016                }
1017                Ok(id)
1018            })();
1019            match identity {
1020                Ok(id) => {
1021                    batch_ids.insert(id.clone(), args.title.trim().to_string());
1022                    rows.push(IdentityRow {
1023                        id: Some(id),
1024                        error: None,
1025                    });
1026                }
1027                Err(e) => rows.push(IdentityRow {
1028                    id: None,
1029                    error: Some(e),
1030                }),
1031            }
1032        }
1033
1034        // --- Skeleton staging: make every batch id a REAL, typed store
1035        // entry so sibling references validate against present targets.
1036        // A pre-existing stub at a batch id is replaced (its incoming
1037        // edges survive the upsert — the same adoption the single-item
1038        // create performs).
1039        for ((args, _), row) in creates.iter().zip(rows.iter()) {
1040            if let Some(id) = &row.id {
1041                let mut skeleton = make_stub(id, crate::entity::StubKind::ForwardReference);
1042                skeleton.stub = false;
1043                skeleton.stub_kind = None;
1044                skeleton.entity_type = args.entity_type.clone();
1045                skeleton.title = args.title.trim().to_string();
1046                self.store.upsert(id.clone(), skeleton);
1047            }
1048        }
1049
1050        // --- Full prepare pass, report-all. Skeletons make intra-batch
1051        // targets real; each entry's own skeleton is exempted from the
1052        // duplicate check via `batch_skeleton_ids`.
1053        let mut prepared: Vec<PreparedCreate> = Vec::new();
1054        let mut notes: Vec<Option<String>> = Vec::new();
1055        let mut errors: Vec<(usize, EngineError)> = Vec::new();
1056        let mut ids_in_order: Vec<EntityId> = Vec::new();
1057        let skeleton_ids: HashSet<EntityId> = batch_ids.keys().cloned().collect();
1058        for (i, ((args, note), row)) in creates.into_iter().zip(rows).enumerate() {
1059            let fallback_id = row
1060                .id
1061                .clone()
1062                .unwrap_or_else(|| EntityId::new(&args.mem, "invalid-entry"));
1063            ids_in_order.push(fallback_id);
1064            if let Some(e) = row.error {
1065                errors.push((i, e));
1066                continue;
1067            }
1068            // Rehearsal is batch-level (the `dry_run` parameter) —
1069            // per-entry dry-run stays forced off so the prepare pass
1070            // below never short-circuits into a per-entry preview.
1071            let mut args = args;
1072            args.dry_run = false;
1073            match self.prepare_create(args, Some(&skeleton_ids), Vec::new()) {
1074                Ok(CreatePrepareOutcome::Prepared(p)) => {
1075                    // Stage this item's declared edges onto its skeleton
1076                    // so later items validate against the batch's own
1077                    // graph state — an intra-batch cycle on an acyclic
1078                    // rel-type refuses exactly like a stored one
1079                    // (`validate_edge_acyclicity` walks the store). The
1080                    // snapshot rollback discards these on refusal; the
1081                    // apply pass replaces them with the parsed truth.
1082                    for r in &p.relations_declared {
1083                        self.store.add_edge(
1084                            p.id.clone(),
1085                            crate::store::Edge {
1086                                rel_type: r.rel_type.clone(),
1087                                target: r.target.clone(),
1088                                source: crate::store::EdgeSource::Explicit,
1089                            },
1090                        );
1091                    }
1092                    ids_in_order[i] = p.id.clone();
1093                    prepared.push(p);
1094                    notes.push(note);
1095                }
1096                Ok(CreatePrepareOutcome::Done(_)) => unreachable!("dry_run forced off"),
1097                Err(e) => errors.push((i, e)),
1098            }
1099        }
1100
1101        if !errors.is_empty() {
1102            // Refuse the whole batch; nothing was committed and the
1103            // store snapshot rolls back the skeletons.
1104            self.store = store_snapshot;
1105            self.discard_all_pending();
1106            let failed = errors.len();
1107            let mut error_map: std::collections::HashMap<usize, EngineError> =
1108                errors.into_iter().collect();
1109            let mut reported = 0usize;
1110            let mut suppressed = 0usize;
1111            let results: Vec<crate::ops::BatchEntry> = ids_in_order
1112                .into_iter()
1113                .enumerate()
1114                .map(|(i, id)| match error_map.remove(&i) {
1115                    Some(e) => {
1116                        if reported < Self::BATCH_ERROR_REPORT_CAP {
1117                            reported += 1;
1118                            crate::ops::BatchEntry {
1119                                id,
1120                                action: "error".to_string(),
1121                                error: Some(super::update::batch_error_envelope(&e)),
1122                            }
1123                        } else {
1124                            suppressed += 1;
1125                            crate::ops::BatchEntry {
1126                                id,
1127                                action: "error".to_string(),
1128                                error: None,
1129                            }
1130                        }
1131                    }
1132                    None => crate::ops::BatchEntry {
1133                        id,
1134                        action: "not_applied".to_string(),
1135                        error: None,
1136                    },
1137                })
1138                .collect();
1139            return Ok(crate::ops::BatchResult {
1140                orphan_stubs_removed: Vec::new(),
1141                errors_suppressed: suppressed,
1142                applied: false,
1143                results,
1144                succeeded: 0,
1145                failed,
1146                commit_sha: String::new(),
1147            });
1148        }
1149
1150        // Rehearsal: every entry validated against the batch's own
1151        // graph state (skeletons made intra-batch targets real) and
1152        // nothing failed — stop before any write. Roll back the
1153        // skeleton staging and return the would-be receipt with the
1154        // marker form's empty `commit_sha`.
1155        if dry_run {
1156            self.store = store_snapshot;
1157            self.discard_all_pending();
1158            let succeeded = prepared.len();
1159            let results: Vec<crate::ops::BatchEntry> = prepared
1160                .into_iter()
1161                .map(|p| crate::ops::BatchEntry {
1162                    id: p.id,
1163                    action: "created".to_string(),
1164                    error: None,
1165                })
1166                .collect();
1167            return Ok(crate::ops::BatchResult {
1168                orphan_stubs_removed: Vec::new(),
1169                errors_suppressed: 0,
1170                applied: true,
1171                results,
1172                succeeded,
1173                failed: 0,
1174                commit_sha: String::new(),
1175            });
1176        }
1177
1178        // --- Stage every write + anchors, then commit once per mem.
1179        for p in &prepared {
1180            if let Err(e) = self.mounts[p.mount_idx]
1181                .backend
1182                .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1183            {
1184                self.store = store_snapshot;
1185                self.discard_all_pending();
1186                return Err(e.into());
1187            }
1188            if !p.anchors.is_empty()
1189                && let Err(e) = super::stage_anchors_sidecar(
1190                    self.mounts[p.mount_idx].backend.as_ref(),
1191                    &p.id,
1192                    &[],
1193                    p.anchors.clone(),
1194                )
1195            {
1196                self.store = store_snapshot;
1197                self.discard_all_pending();
1198                return Err(e);
1199            }
1200            // Derivation baselines (plan 12) — same predicate and
1201            // staging as the single create; rides the batch commit.
1202            if let Some(schema) = self.schemas.get(&p.mem) {
1203                for r in p
1204                    .relations_declared
1205                    .iter()
1206                    .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1207                {
1208                    let hash = self
1209                        .store
1210                        .get(&r.target)
1211                        .map(|e| e.content_hash.clone())
1212                        .unwrap_or_default();
1213                    let (from, rel, to) =
1214                        (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1215                    if let Err(e) = super::stage_derivation_sidecar(
1216                        self.mounts[p.mount_idx].backend.as_ref(),
1217                        |s| s.set(&from, &rel, &to, &hash),
1218                    ) {
1219                        self.store = store_snapshot;
1220                        self.discard_all_pending();
1221                        return Err(e);
1222                    }
1223                }
1224            }
1225        }
1226        let mut distinct_mounts: Vec<usize> = Vec::new();
1227        for p in &prepared {
1228            if !distinct_mounts.contains(&p.mount_idx) {
1229                distinct_mounts.push(p.mount_idx);
1230            }
1231        }
1232        let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1233        for &m in &distinct_mounts {
1234            let entity_ids: Vec<String> = prepared
1235                .iter()
1236                .filter(|p| p.mount_idx == m)
1237                .map(|p| p.id.to_string())
1238                .collect();
1239            let count = entity_ids.len();
1240            let subject = format!("memstead: batch-create ({count} entities)");
1241            let ctx = CommitContext {
1242                actor,
1243                client: client.cloned(),
1244                tool: Some("batch_create"),
1245                note: None,
1246                role: self.current_role,
1247                logical_operation_id: None,
1248                entity_ids: Some(entity_ids),
1249            };
1250            match self.mounts[m].backend.commit(&subject, &ctx) {
1251                Ok(sha) => mount_commits.push((m, sha)),
1252                Err(e) => {
1253                    self.store = store_snapshot;
1254                    self.discard_all_pending();
1255                    return Err(e.into());
1256                }
1257            }
1258        }
1259
1260        // Provenance + store application (parse the generated bytes so
1261        // the store mirrors disk, replacing the skeletons).
1262        let fallback = engine_fallback_type();
1263        for (p, note) in prepared.iter().zip(notes.iter()) {
1264            let commit_sha = mount_commits
1265                .iter()
1266                .find(|(m, _)| *m == p.mount_idx)
1267                .map(|(_, s)| s.clone())
1268                .unwrap_or_default();
1269            self.mounts[p.mount_idx].backend.append_provenance(
1270                &Provenance::new(
1271                    std::time::SystemTime::now(),
1272                    ProvenanceKind::Create,
1273                    Some(p.id.to_string()),
1274                    actor,
1275                    client.cloned(),
1276                    note.clone(),
1277                )
1278                .with_role(self.current_role),
1279            )?;
1280            self.record_self_write(p.mount_idx, &commit_sha);
1281            self.stamp_mutation_versions(p.mount_idx);
1282            let parse_result =
1283                parse_markdown(&p.markdown, &p.file_path, p.type_def.as_ref(), &p.mem)
1284                    .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
1285            push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
1286        }
1287        crate::entity::store_builder::remap_alias_target_edge_sources(
1288            &mut self.store,
1289            &self.schemas,
1290        );
1291        // Forward-reference stubs for OUT-OF-BATCH targets only —
1292        // in-batch targets are real entities now.
1293        for p in &prepared {
1294            for target in &p.relation_targets {
1295                if !self.store.contains(target) {
1296                    self.store.upsert(
1297                        target.clone(),
1298                        make_stub(target, crate::entity::StubKind::ForwardReference),
1299                    );
1300                }
1301            }
1302        }
1303        self.invalidate_communities();
1304        self.invalidate_search_indexes();
1305
1306        let commit_sha = mount_commits
1307            .last()
1308            .map(|(_, s)| s.clone())
1309            .unwrap_or_default();
1310        let succeeded = prepared.len();
1311        let results: Vec<crate::ops::BatchEntry> = prepared
1312            .into_iter()
1313            .map(|p| crate::ops::BatchEntry {
1314                id: p.id,
1315                action: "created".to_string(),
1316                error: None,
1317            })
1318            .collect();
1319        Ok(crate::ops::BatchResult {
1320            orphan_stubs_removed: Vec::new(),
1321            errors_suppressed: 0,
1322            applied: true,
1323            results,
1324            succeeded,
1325            failed: 0,
1326            commit_sha,
1327        })
1328    }
1329
1330    /// Cap on fully-detailed error envelopes in a refused batch's
1331    /// report — bounded reporting for very large failing batches.
1332    /// Entries beyond the cap still carry `action: "error"`; the
1333    /// result's `errors_suppressed` counts them. Never a silent
1334    /// truncation.
1335    pub const BATCH_ERROR_REPORT_CAP: usize = 50;
1336
1337    /// CommitContext-bundling wrapper around [`Self::create_entity`].
1338    /// Destructures `CommitContext` into `(actor, client, note)`
1339    /// and delegates.
1340    pub fn create_entity_with_ctx(
1341        &mut self,
1342        args: CreateEntityArgs,
1343        ctx: &CommitContext<'_>,
1344    ) -> Result<CreateEntityOutcome, EngineError> {
1345        self.create_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1346    }
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351
1352    use indexmap::IndexMap;
1353    use tempfile::TempDir;
1354
1355    use crate::backend::MemBackend;
1356    use crate::engine::test_helpers::*;
1357    use crate::engine::{
1358        CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateEntityArgs,
1359    };
1360    use crate::ops::WarningHint;
1361    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1362
1363    /// Boot an engine whose mem pins a schema with one type (`task`)
1364    /// declaring `required_outgoing: [{relationships: [PART_OF],
1365    /// cardinality: at_least_one}]` — the fixture for the
1366    /// MISSING_REQUIRED_OUTGOING mutation-warning tests.
1367    fn engine_with_required_outgoing_schema(tmp: &TempDir) -> Engine {
1368        let schemas_dir = tmp.path().join("schemas");
1369        let pkg = schemas_dir.join("reqout");
1370        std::fs::create_dir_all(pkg.join("types")).unwrap();
1371        std::fs::write(
1372            pkg.join("schema.yaml"),
1373            r#"name: reqout
1374version: 0.1.0
1375description: required-outgoing fixture
1376when_to_use: tests
1377types:
1378  - task
1379relationships:
1380  mode: strict
1381  definitions:
1382    - name: PART_OF
1383      description: hier
1384      default_weight: 3.0
1385    - name: _default
1386      description: fallback
1387      default_weight: 1.0
1388community:
1389  resolution: 1.0
1390  seed: 42
1391"#,
1392        )
1393        .unwrap();
1394        std::fs::write(
1395            pkg.join("types").join("task.yaml"),
1396            r#"name: task
1397description: t
1398when_to_use: tests
1399sections:
1400  - key: body
1401    heading: Body
1402    required: true
1403    search_weight: 10.0
1404    catch_all: true
1405    write_rules: []
1406metadata_fields: []
1407title_weight: 100.0
1408text_fields:
1409  - body
1410hierarchy_relationship: PART_OF
1411no_self_loop_relationships: []
1412updatable_fields:
1413  - title
1414  - body
1415health_required_fields:
1416  - body
1417staleness_threshold_days: 90
1418required_outgoing:
1419  - relationships: [PART_OF]
1420    cardinality: at_least_one
1421write_rules: []
1422"#,
1423        )
1424        .unwrap();
1425        let mem_dir = tmp.path().join("mem");
1426        std::fs::create_dir_all(&mem_dir).unwrap();
1427        let writer = FilesystemMemWriter::new(mem_dir.clone());
1428        let mount = crate::workspace::Mount {
1429            mem: "tasks".to_string(),
1430            schema: Some(memstead_schema::SchemaRef::new(
1431                "reqout",
1432                semver::Version::new(0, 1, 0),
1433            )),
1434            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1435            capability: crate::workspace::MountCapability::Write,
1436            lifecycle: crate::workspace::MountLifecycle::Eager,
1437            cross_linkable: true,
1438            migration_target: None,
1439        };
1440        Engine::from_mounts_with_schemas_dir(
1441            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1442            Some(&schemas_dir),
1443        )
1444        .unwrap()
1445    }
1446
1447    fn task_create_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
1448        let mut sections = IndexMap::new();
1449        sections.insert("body".to_string(), "a task body.".to_string());
1450        CreateEntityArgs {
1451            anchors: Vec::new(),
1452            mem: "tasks".to_string(),
1453            title: title.to_string(),
1454            entity_type: "task".to_string(),
1455            sections,
1456            metadata: IndexMap::new(),
1457            relations,
1458            dry_run: false,
1459        }
1460    }
1461
1462    fn missing_outgoing_of(warnings: &[WarningHint]) -> Vec<(Vec<String>, String)> {
1463        warnings
1464            .iter()
1465            .filter_map(|w| match w {
1466                WarningHint::MissingRequiredOutgoing { missing, .. } => Some(
1467                    missing
1468                        .iter()
1469                        .map(|b| (b.relationships.clone(), b.cardinality.clone()))
1470                        .collect::<Vec<_>>(),
1471                ),
1472                _ => None,
1473            })
1474            .flatten()
1475            .collect()
1476    }
1477
1478    /// Fixture for the declared-constraints vertical: type `task`
1479    /// declares `requires_when` (checked → checked_by) at the given
1480    /// severity, plus a `required_outgoing` block at the given
1481    /// severity — so one schema exercises form 1 and form 4 at either
1482    /// tier.
1483    fn engine_with_constraints_schema(
1484        tmp: &TempDir,
1485        requires_when_severity: &str,
1486        required_outgoing_severity: &str,
1487    ) -> Engine {
1488        let schemas_dir = tmp.path().join("schemas");
1489        let pkg = schemas_dir.join("constr");
1490        std::fs::create_dir_all(pkg.join("types")).unwrap();
1491        std::fs::write(
1492            pkg.join("schema.yaml"),
1493            r#"name: constr
1494version: 0.1.0
1495description: constraint fixture
1496when_to_use: tests
1497types:
1498  - task
1499relationships:
1500  mode: strict
1501  definitions:
1502    - name: PART_OF
1503      description: hier
1504      default_weight: 3.0
1505    - name: _default
1506      description: fallback
1507      default_weight: 1.0
1508community:
1509  resolution: 1.0
1510  seed: 42
1511"#,
1512        )
1513        .unwrap();
1514        std::fs::write(
1515            pkg.join("types").join("task.yaml"),
1516            format!(
1517                r#"name: task
1518description: t
1519when_to_use: tests
1520sections:
1521  - key: body
1522    heading: Body
1523    required: true
1524    search_weight: 10.0
1525    catch_all: true
1526    write_rules: []
1527metadata_fields:
1528  - key: status
1529    description: workflow state
1530    field_type: string
1531    enum_values: [open, checked]
1532  - key: checked_by
1533    description: who checked
1534    field_type: string
1535title_weight: 100.0
1536text_fields:
1537  - body
1538hierarchy_relationship: PART_OF
1539no_self_loop_relationships: [PART_OF]
1540updatable_fields:
1541  - title
1542  - body
1543  - status
1544  - checked_by
1545health_required_fields:
1546  - body
1547staleness_threshold_days: 90
1548required_outgoing:
1549  - relationships: [PART_OF]
1550    cardinality: at_least_one
1551    severity: {required_outgoing_severity}
1552constraints:
1553  - kind: requires_when
1554    field: checked_by
1555    when_field: status
1556    when_value: checked
1557    severity: {requires_when_severity}
1558write_rules: []
1559"#
1560            ),
1561        )
1562        .unwrap();
1563        let mem_dir = tmp.path().join("mem");
1564        std::fs::create_dir_all(&mem_dir).unwrap();
1565        let writer = FilesystemMemWriter::new(mem_dir.clone());
1566        let mount = crate::workspace::Mount {
1567            mem: "tasks".to_string(),
1568            schema: Some(memstead_schema::SchemaRef::new(
1569                "constr",
1570                semver::Version::new(0, 1, 0),
1571            )),
1572            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1573            capability: crate::workspace::MountCapability::Write,
1574            lifecycle: crate::workspace::MountLifecycle::Eager,
1575            cross_linkable: true,
1576            migration_target: None,
1577        };
1578        Engine::from_mounts_with_schemas_dir(
1579            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1580            Some(&schemas_dir),
1581        )
1582        .unwrap()
1583    }
1584
1585    fn checked_task_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
1586        let mut args = task_create_args(title, relations);
1587        args.metadata
1588            .insert("status".to_string(), "checked".to_string());
1589        args
1590    }
1591
1592    /// Form 1 at warn: a create violating `requires_when` warns
1593    /// `CONSTRAINT_UNSATISFIED` and still commits; the health sweep
1594    /// reports the same violation (shared evaluation); a create
1595    /// satisfying the constraint emits neither.
1596    #[test]
1597    fn create_warns_requires_when_and_still_commits() {
1598        let tmp = TempDir::new().unwrap();
1599        let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1600        let (actor, client) = cli_actor();
1601
1602        let outcome = engine
1603            .create_entity(
1604                checked_task_args("Unbacked Judgment", vec![]),
1605                actor,
1606                Some(&client),
1607                None,
1608            )
1609            .unwrap();
1610        assert!(!outcome.commit_sha.is_empty(), "warn tier never blocks");
1611        let violation = outcome
1612            .warnings
1613            .iter()
1614            .find_map(|w| match w {
1615                WarningHint::ConstraintUnsatisfied { violations, .. } => Some(violations.clone()),
1616                _ => None,
1617            })
1618            .expect("CONSTRAINT_UNSATISFIED warning present");
1619        assert_eq!(violation.len(), 1);
1620        let crate::ops::health::UnsatisfiedConstraint::RequiresWhen {
1621            field,
1622            when_field,
1623            when_value,
1624            ..
1625        } = &violation[0]
1626        else {
1627            panic!("expected requires_when violation");
1628        };
1629        assert_eq!(field, "checked_by");
1630        assert_eq!(when_field, "status");
1631        assert_eq!(when_value, "checked");
1632
1633        // Health parity — same single evaluation.
1634        let reports =
1635            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
1636        assert_eq!(reports.len(), 1);
1637        assert_eq!(reports[0].id, outcome.id);
1638        assert_eq!(reports[0].violations.len(), 1);
1639        let crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. } =
1640            &reports[0].violations[0]
1641        else {
1642            panic!("expected requires_when finding");
1643        };
1644        assert_eq!(field, "checked_by");
1645
1646        // Complement 1: satisfying the constraint in the same create
1647        // emits no warning and no finding.
1648        let mut satisfied_args = checked_task_args("Backed Judgment", vec![]);
1649        satisfied_args
1650            .metadata
1651            .insert("checked_by".to_string(), "reviewer-a".to_string());
1652        let satisfied = engine
1653            .create_entity(satisfied_args, actor, Some(&client), None)
1654            .unwrap();
1655        assert!(
1656            !satisfied
1657                .warnings
1658                .iter()
1659                .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1660            "satisfied constraint emits no warning: {:?}",
1661            satisfied.warnings
1662        );
1663
1664        // Complement 2: an untriggered constraint (status != checked)
1665        // emits nothing even with checked_by unset.
1666        let untriggered = engine
1667            .create_entity(
1668                task_create_args("Open Task", vec![]),
1669                actor,
1670                Some(&client),
1671                None,
1672            )
1673            .unwrap();
1674        assert!(
1675            !untriggered
1676                .warnings
1677                .iter()
1678                .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1679            "untriggered constraint emits no warning"
1680        );
1681    }
1682
1683    /// Form 1 at block: the same violation refuses the create with
1684    /// `CONSTRAINT_UNSATISFIED`, leaves nothing behind, and the
1685    /// refusal payload restates the declaration.
1686    #[test]
1687    fn create_refuses_block_tier_requires_when() {
1688        let tmp = TempDir::new().unwrap();
1689        let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1690        let (actor, client) = cli_actor();
1691
1692        let err = engine
1693            .create_entity(
1694                checked_task_args("Unbacked Judgment", vec![]),
1695                actor,
1696                Some(&client),
1697                None,
1698            )
1699            .unwrap_err();
1700        assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1701        let details = err.details();
1702        assert_eq!(details["violations"][0]["field"], "checked_by");
1703        assert_eq!(details["violations"][0]["severity"], "block");
1704        assert_eq!(
1705            engine.store().all_entities().count(),
1706            0,
1707            "refused create leaves nothing behind"
1708        );
1709
1710        // The satisfying create passes under the same schema.
1711        let mut ok_args = checked_task_args("Backed Judgment", vec![]);
1712        ok_args
1713            .metadata
1714            .insert("checked_by".to_string(), "reviewer-a".to_string());
1715        engine
1716            .create_entity(ok_args, actor, Some(&client), None)
1717            .unwrap();
1718    }
1719
1720    /// Form 4 at block: a create leaving a `severity: block`
1721    /// `required_outgoing` block unsatisfied refuses with
1722    /// `MISSING_REQUIRED_OUTGOING` (the same code the warn tier
1723    /// warns with — one condition, one vocabulary); an inline
1724    /// relation satisfying the block lets the create pass.
1725    #[test]
1726    fn create_refuses_block_tier_required_outgoing() {
1727        let tmp = TempDir::new().unwrap();
1728        let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1729        let (actor, client) = cli_actor();
1730
1731        let err = engine
1732            .create_entity(
1733                task_create_args("Orphan Task", vec![]),
1734                actor,
1735                Some(&client),
1736                None,
1737            )
1738            .unwrap_err();
1739        assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
1740        let details = err.details();
1741        assert_eq!(details["missing"][0]["relationships"][0], "PART_OF");
1742        assert_eq!(details["missing"][0]["severity"], "block");
1743        assert_eq!(engine.store().all_entities().count(), 0);
1744
1745        // A create satisfying the block via an inline relation to an
1746        // auto-stubbed target passes — the stub itself has no type
1747        // definition under this schema's `task`-only vocabulary, so
1748        // wire the edge from the real entity.
1749        let outcome = engine.create_entity(
1750            task_create_args(
1751                "Child Task",
1752                vec![crate::ops::RelateArg {
1753                    to: crate::entity::EntityId("tasks--parent".to_string()),
1754                    rel_type: "PART_OF".to_string(),
1755                    description: None,
1756                }],
1757            ),
1758            actor,
1759            Some(&client),
1760            None,
1761        );
1762        assert!(
1763            outcome.is_ok(),
1764            "satisfied block-tier create passes: {:?}",
1765            outcome.err()
1766        );
1767    }
1768
1769    /// Update-side severity mirror for form 1: at warn, an update
1770    /// that makes the constraint trigger warns and commits; at block,
1771    /// the same update refuses and the entity keeps its prior state.
1772    #[test]
1773    fn update_enforces_requires_when_by_severity() {
1774        let (actor, client) = cli_actor();
1775        let set_checked = |engine: &mut Engine, id: &crate::entity::EntityId| {
1776            let current = engine.get_entity(id).unwrap().content_hash.clone();
1777            let mut metadata = IndexMap::new();
1778            metadata.insert("status".to_string(), "checked".to_string());
1779            engine.update_entity(
1780                crate::engine::UpdateEntityArgs {
1781                    anchors: Vec::new(),
1782                    id: id.clone(),
1783                    expected_hash: Some(current),
1784                    sections: IndexMap::new(),
1785                    append_sections: IndexMap::new(),
1786                    patch_sections: IndexMap::new(),
1787                    metadata,
1788                    metadata_unset: Vec::new(),
1789                    declare_relations: vec![],
1790                    dry_run: false,
1791                    relations_unset: Vec::new(),
1792                    anchors_unset: Vec::new(),
1793                },
1794                actor,
1795                Some(&client),
1796                None,
1797            )
1798        };
1799
1800        // Warn tier: the update commits with the typed warning.
1801        let tmp = TempDir::new().unwrap();
1802        let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1803        let a = engine
1804            .create_entity(
1805                task_create_args("Task A", vec![]),
1806                actor,
1807                Some(&client),
1808                None,
1809            )
1810            .unwrap();
1811        let outcome = set_checked(&mut engine, &a.id).unwrap();
1812        assert!(!outcome.commit_sha.is_empty());
1813        assert!(
1814            outcome
1815                .warnings
1816                .iter()
1817                .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1818            "warn-tier update carries the warning: {:?}",
1819            outcome.warnings
1820        );
1821
1822        // Block tier: the same update refuses; the entity keeps its
1823        // prior metadata.
1824        let tmp = TempDir::new().unwrap();
1825        let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1826        let b = engine
1827            .create_entity(
1828                task_create_args("Task B", vec![]),
1829                actor,
1830                Some(&client),
1831                None,
1832            )
1833            .unwrap();
1834        let err = set_checked(&mut engine, &b.id).unwrap_err();
1835        assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1836        assert!(
1837            !engine
1838                .get_entity(&b.id)
1839                .unwrap()
1840                .metadata
1841                .contains_key("status"),
1842            "refused update leaves the entity unchanged"
1843        );
1844    }
1845
1846    /// Form 4 at block on the relate surface: removing the edge that
1847    /// satisfies a `severity: block` `required_outgoing` block refuses
1848    /// with `MISSING_REQUIRED_OUTGOING`; the edge survives.
1849    #[test]
1850    fn relate_remove_refuses_block_tier_required_outgoing() {
1851        let tmp = TempDir::new().unwrap();
1852        let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1853        let (actor, client) = cli_actor();
1854        let parent_id = crate::entity::EntityId("tasks--parent".to_string());
1855        let child = engine
1856            .create_entity(
1857                task_create_args(
1858                    "Child Task",
1859                    vec![crate::ops::RelateArg {
1860                        to: parent_id.clone(),
1861                        rel_type: "PART_OF".to_string(),
1862                        description: None,
1863                    }],
1864                ),
1865                actor,
1866                Some(&client),
1867                None,
1868            )
1869            .unwrap();
1870
1871        let err = engine
1872            .relate_entity(
1873                crate::engine::RelateEntityArgs {
1874                    source: child.id.clone(),
1875                    target: parent_id.clone(),
1876                    rel_type: "PART_OF".to_string(),
1877                    description: None,
1878                    remove: true,
1879                    expected_hash: None,
1880                    dry_run: false,
1881                },
1882                actor,
1883                Some(&client),
1884                None,
1885            )
1886            .unwrap_err();
1887        assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
1888        assert!(
1889            engine
1890                .get_entity(&child.id)
1891                .unwrap()
1892                .relationships
1893                .iter()
1894                .any(|r| r.rel_type == "PART_OF" && r.target == parent_id),
1895            "refused remove leaves the edge in place"
1896        );
1897    }
1898
1899    /// Regression pin for `no_self_loop_relationships`' single
1900    /// functional behavior: a self-loop (`from == to`) on a rel-type
1901    /// the source type lists there refuses with `RELATIONSHIP_CYCLE`.
1902    /// The constraint vocabulary settles the field's semantics — the
1903    /// new propagation declaration gets a distinct name, and this pin
1904    /// guards that the old field keeps exactly this effect.
1905    #[test]
1906    fn no_self_loop_rel_type_self_loop_refusal_is_pinned() {
1907        let tmp = TempDir::new().unwrap();
1908        // The `constr` fixture declares `no_self_loop_relationships:
1909        // [PART_OF]` on `task`.
1910        let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1911        let (actor, client) = cli_actor();
1912        let a = engine
1913            .create_entity(
1914                task_create_args("Task A", vec![]),
1915                actor,
1916                Some(&client),
1917                None,
1918            )
1919            .unwrap();
1920        let err = engine
1921            .relate_entity(
1922                crate::engine::RelateEntityArgs {
1923                    source: a.id.clone(),
1924                    target: a.id.clone(),
1925                    rel_type: "PART_OF".to_string(),
1926                    description: None,
1927                    remove: false,
1928                    expected_hash: None,
1929                    dry_run: false,
1930                },
1931                actor,
1932                Some(&client),
1933                None,
1934            )
1935            .unwrap_err();
1936        assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
1937    }
1938
1939    /// Generic constraint-proof fixture: one folder-mounted mem
1940    /// (`proof`) pinned to a schema built from the given manifest and
1941    /// type YAMLs.
1942    fn engine_with_proof_schema(
1943        tmp: &TempDir,
1944        schema_name: &str,
1945        manifest_yaml: &str,
1946        types: &[(&str, &str)],
1947    ) -> Engine {
1948        let schemas_dir = tmp.path().join("schemas");
1949        let pkg = schemas_dir.join(schema_name);
1950        std::fs::create_dir_all(pkg.join("types")).unwrap();
1951        std::fs::write(pkg.join("schema.yaml"), manifest_yaml).unwrap();
1952        for (name, yaml) in types {
1953            std::fs::write(pkg.join("types").join(format!("{name}.yaml")), yaml).unwrap();
1954        }
1955        let mem_dir = tmp.path().join("mem");
1956        std::fs::create_dir_all(&mem_dir).unwrap();
1957        let writer = FilesystemMemWriter::new(mem_dir.clone());
1958        let mount = crate::workspace::Mount {
1959            mem: "proof".to_string(),
1960            schema: Some(memstead_schema::SchemaRef::new(
1961                schema_name,
1962                semver::Version::new(0, 1, 0),
1963            )),
1964            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1965            capability: crate::workspace::MountCapability::Write,
1966            lifecycle: crate::workspace::MountLifecycle::Eager,
1967            cross_linkable: true,
1968            migration_target: None,
1969        };
1970        Engine::from_mounts_with_schemas_dir(
1971            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1972            Some(&schemas_dir),
1973        )
1974        .unwrap()
1975    }
1976
1977    fn proof_create(
1978        engine: &mut Engine,
1979        entity_type: &str,
1980        title: &str,
1981        metadata: &[(&str, &str)],
1982        relations: Vec<crate::ops::RelateArg>,
1983    ) -> Result<CreateEntityOutcome, EngineError> {
1984        let (actor, client) = cli_actor();
1985        let mut sections = IndexMap::new();
1986        sections.insert("body".to_string(), format!("{title} body."));
1987        let mut md = IndexMap::new();
1988        for (k, v) in metadata {
1989            md.insert(k.to_string(), v.to_string());
1990        }
1991        engine.create_entity(
1992            CreateEntityArgs {
1993                anchors: Vec::new(),
1994                mem: "proof".to_string(),
1995                title: title.to_string(),
1996                entity_type: entity_type.to_string(),
1997                sections,
1998                metadata: md,
1999                relations,
2000                dry_run: false,
2001            },
2002            actor,
2003            Some(&client),
2004            None,
2005        )
2006    }
2007
2008    fn rel(to: &str, rel_type: &str) -> crate::ops::RelateArg {
2009        crate::ops::RelateArg {
2010            to: crate::entity::EntityId(to.to_string()),
2011            rel_type: rel_type.to_string(),
2012            description: None,
2013        }
2014    }
2015
2016    const GROUNDING_MANIFEST: &str = r#"name: grounding
2017version: 0.1.0
2018description: anker-shaped grounding proof schema
2019when_to_use: constraint-proof tests
2020types:
2021  - anchor
2022  - tradeoff
2023relationships:
2024  mode: strict
2025  definitions:
2026    - name: FOLLOWS_FROM
2027      description: stands on
2028      default_weight: 3.0
2029    - name: SUPPORTS
2030      description: pro
2031      default_weight: 1.0
2032    - name: OPPOSES
2033      description: contra
2034      default_weight: 1.0
2035    - name: PART_OF
2036      description: hier
2037      default_weight: 1.0
2038    - name: _default
2039      description: fallback
2040      default_weight: 1.0
2041community:
2042  resolution: 1.0
2043  seed: 42
2044"#;
2045
2046    const GROUNDING_ANCHOR: &str = r#"name: anchor
2047description: a judgment standing on others
2048when_to_use: tests
2049sections:
2050  - key: body
2051    heading: Body
2052    required: true
2053    search_weight: 10.0
2054    catch_all: true
2055    write_rules: []
2056metadata_fields:
2057  - key: status
2058    description: lifecycle
2059    field_type: string
2060    enum_values: [open, checked, fallen]
2061  - key: checked_by
2062    description: who checked
2063    field_type: string
2064title_weight: 100.0
2065text_fields:
2066  - body
2067hierarchy_relationship: PART_OF
2068no_self_loop_relationships: []
2069updatable_fields:
2070  - title
2071  - body
2072  - status
2073  - checked_by
2074health_required_fields:
2075  - body
2076staleness_threshold_days: 90
2077constraints:
2078  - kind: requires_when
2079    field: checked_by
2080    when_field: status
2081    when_value: checked
2082  - kind: status_propagation
2083    field: status
2084    value: fallen
2085    rel_type: FOLLOWS_FROM
2086    direction: incoming
2087write_rules: []
2088"#;
2089
2090    const GROUNDING_TRADEOFF: &str = r#"name: tradeoff
2091description: a claim with two sides
2092when_to_use: tests
2093sections:
2094  - key: body
2095    heading: Body
2096    required: true
2097    search_weight: 10.0
2098    catch_all: true
2099    write_rules: []
2100metadata_fields: []
2101title_weight: 100.0
2102text_fields:
2103  - body
2104hierarchy_relationship: PART_OF
2105no_self_loop_relationships: []
2106updatable_fields:
2107  - title
2108  - body
2109health_required_fields:
2110  - body
2111staleness_threshold_days: 90
2112required_outgoing:
2113  - relationships: [SUPPORTS]
2114    cardinality: at_least_one
2115  - relationships: [OPPOSES]
2116    cardinality: at_least_one
2117write_rules: []
2118"#;
2119
2120    /// The anker proof (plan 07, criterion 2): the grounding-shaped
2121    /// schema answers `pruefe_kette.py`'s check questions 1–3 from
2122    /// health output alone — no project Python.
2123    #[test]
2124    fn anker_proof_grounding_schema_answers_check_questions_from_health() {
2125        let tmp = TempDir::new().unwrap();
2126        let mut engine = engine_with_proof_schema(
2127            &tmp,
2128            "grounding",
2129            GROUNDING_MANIFEST,
2130            &[
2131                ("anchor", GROUNDING_ANCHOR),
2132                ("tradeoff", GROUNDING_TRADEOFF),
2133            ],
2134        );
2135
2136        // A fallen root, a child standing on it, a grandchild standing
2137        // on the child (transitive), plus an untainted sibling chain.
2138        let root = proof_create(
2139            &mut engine,
2140            "anchor",
2141            "Root",
2142            &[("status", "fallen")],
2143            vec![],
2144        )
2145        .unwrap();
2146        let child = proof_create(
2147            &mut engine,
2148            "anchor",
2149            "Child",
2150            &[],
2151            vec![rel(&root.id.0, "FOLLOWS_FROM")],
2152        )
2153        .unwrap();
2154        let grandchild = proof_create(
2155            &mut engine,
2156            "anchor",
2157            "Grandchild",
2158            &[],
2159            vec![rel(&child.id.0, "FOLLOWS_FROM")],
2160        )
2161        .unwrap();
2162        let standing = proof_create(
2163            &mut engine,
2164            "anchor",
2165            "Standing Root",
2166            &[("status", "open")],
2167            vec![],
2168        )
2169        .unwrap();
2170        let standing_child = proof_create(
2171            &mut engine,
2172            "anchor",
2173            "Standing Child",
2174            &[],
2175            vec![rel(&standing.id.0, "FOLLOWS_FROM")],
2176        )
2177        .unwrap();
2178        // Question 3's subject: checked without a checker.
2179        let unchecked = proof_create(
2180            &mut engine,
2181            "anchor",
2182            "Checked No Checker",
2183            &[("status", "checked")],
2184            vec![],
2185        )
2186        .unwrap();
2187        // Question 2's subject: a one-sided trade-off.
2188        let onesided = proof_create(
2189            &mut engine,
2190            "tradeoff",
2191            "One Sided",
2192            &[],
2193            vec![rel(&standing.id.0, "SUPPORTS")],
2194        )
2195        .unwrap();
2196
2197        // Question 1 — descendants of the fallen anchor are flagged,
2198        // naming their ancestor; the standing chain is not.
2199        let findings =
2200            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
2201        let tainted_of = |id: &crate::entity::EntityId| -> Vec<String> {
2202            findings
2203                .iter()
2204                .filter(|r| &r.id == id)
2205                .flat_map(|r| &r.violations)
2206                .filter_map(|v| match v {
2207                    crate::ops::health::UnsatisfiedConstraint::StatusPropagation {
2208                        tainted_by,
2209                        ..
2210                    } => Some(tainted_by.clone()),
2211                    _ => None,
2212                })
2213                .collect()
2214        };
2215        assert_eq!(tainted_of(&child.id), vec![root.id.to_string()]);
2216        assert_eq!(
2217            tainted_of(&grandchild.id),
2218            vec![root.id.to_string()],
2219            "the taint is transitive and names the terminal ancestor"
2220        );
2221        assert!(tainted_of(&standing_child.id).is_empty());
2222        assert!(
2223            tainted_of(&root.id).is_empty(),
2224            "the source is not its own finding"
2225        );
2226
2227        // Question 3 — checked-without-checker is flagged.
2228        assert!(
2229            findings.iter().any(|r| r.id == unchecked.id
2230                && r.violations.iter().any(|v| matches!(
2231                    v,
2232                    crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. }
2233                        if field == "checked_by"
2234                ))),
2235            "checked-without-checker must be a health finding"
2236        );
2237
2238        // Question 2 — the one-sided trade-off is flagged missing its
2239        // OPPOSES block (form 4 at warn), from health output alone.
2240        let missing = crate::ops::health::collect_missing_required_outgoing(
2241            engine.store(),
2242            None,
2243            engine.schemas(),
2244        );
2245        let onesided_report = missing
2246            .iter()
2247            .find(|r| r.id == onesided.id)
2248            .expect("one-sided trade-off flagged");
2249        assert_eq!(onesided_report.missing.len(), 1);
2250        assert_eq!(onesided_report.missing[0].relationships, vec!["OPPOSES"]);
2251    }
2252
2253    const PLENUM_MANIFEST: &str = r#"name: plenum-proof
2254version: 0.1.0
2255description: plenum-shaped uniqueness and vocabulary proof schema
2256when_to_use: constraint-proof tests
2257types:
2258  - rede
2259  - vocabulary
2260relationships:
2261  mode: strict
2262  definitions:
2263    - name: REFERENCES
2264      description: soft ref
2265      default_weight: 0.5
2266    - name: PART_OF
2267      description: hier
2268      default_weight: 1.0
2269    - name: _default
2270      description: fallback
2271      default_weight: 1.0
2272community:
2273  resolution: 1.0
2274  seed: 42
2275"#;
2276
2277    fn plenum_rede_type(unique_severity: &str) -> String {
2278        format!(
2279            r#"name: rede
2280description: one speech
2281when_to_use: tests
2282sections:
2283  - key: body
2284    heading: Body
2285    required: true
2286    search_weight: 10.0
2287    catch_all: true
2288    write_rules: []
2289metadata_fields:
2290  - key: rede_id
2291    description: source id
2292    field_type: string
2293  - key: rede_sha256
2294    description: content hash
2295    field_type: string
2296  - key: kategorie
2297    description: category from the shared vocabulary
2298    field_type: string
2299title_weight: 100.0
2300text_fields:
2301  - body
2302hierarchy_relationship: PART_OF
2303no_self_loop_relationships: []
2304updatable_fields:
2305  - title
2306  - body
2307  - rede_id
2308  - rede_sha256
2309  - kategorie
2310health_required_fields:
2311  - body
2312staleness_threshold_days: 90
2313constraints:
2314  - kind: unique
2315    fields: [rede_id, rede_sha256]
2316    severity: {unique_severity}
2317  - kind: enum_from_neighbour
2318    field: kategorie
2319    rel_type: REFERENCES
2320    section: terms
2321write_rules: []
2322"#
2323        )
2324    }
2325
2326    const PLENUM_VOCABULARY: &str = r#"name: vocabulary
2327description: the shared term list
2328when_to_use: tests
2329sections:
2330  - key: terms
2331    heading: Terms
2332    required: false
2333    search_weight: 5.0
2334    catch_all: false
2335    write_rules: []
2336  - key: body
2337    heading: Body
2338    required: true
2339    search_weight: 10.0
2340    catch_all: true
2341    write_rules: []
2342metadata_fields: []
2343title_weight: 100.0
2344text_fields:
2345  - body
2346hierarchy_relationship: PART_OF
2347no_self_loop_relationships: []
2348updatable_fields:
2349  - title
2350  - body
2351  - terms
2352health_required_fields:
2353  - body
2354staleness_threshold_days: 90
2355write_rules: []
2356"#;
2357
2358    /// The plenum proof, uniqueness half (plan 07, criterion 3): a
2359    /// second create with the same declared key tuple refuses with a
2360    /// typed code naming the colliding entity — the 37-duplicates
2361    /// scenario bounces at the engine. Health reports a pre-existing
2362    /// violation planted under a warn-tier variant.
2363    #[test]
2364    fn plenum_proof_uniqueness_refuses_duplicates_and_health_reports_planted_ones() {
2365        // Block tier: the duplicate refuses, naming the collider.
2366        let tmp = TempDir::new().unwrap();
2367        let rede = plenum_rede_type("block");
2368        let mut engine = engine_with_proof_schema(
2369            &tmp,
2370            "plenum-proof",
2371            PLENUM_MANIFEST,
2372            &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
2373        );
2374        let first = proof_create(
2375            &mut engine,
2376            "rede",
2377            "Speech One",
2378            &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
2379            vec![],
2380        )
2381        .unwrap();
2382        let err = proof_create(
2383            &mut engine,
2384            "rede",
2385            "Speech One Duplicate",
2386            &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
2387            vec![],
2388        )
2389        .unwrap_err();
2390        assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
2391        assert_eq!(
2392            err.details()["violations"][0]["colliding"],
2393            first.id.to_string(),
2394            "the refusal names the colliding entity"
2395        );
2396        // A different tuple passes.
2397        proof_create(
2398            &mut engine,
2399            "rede",
2400            "Speech Two",
2401            &[("rede_id", "19-43"), ("rede_sha256", "def456")],
2402            vec![],
2403        )
2404        .unwrap();
2405
2406        // Warn tier: plant the duplicate, health reports it.
2407        let tmp = TempDir::new().unwrap();
2408        let rede = plenum_rede_type("warn");
2409        let mut engine = engine_with_proof_schema(
2410            &tmp,
2411            "plenum-proof",
2412            PLENUM_MANIFEST,
2413            &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
2414        );
2415        proof_create(
2416            &mut engine,
2417            "rede",
2418            "Planted A",
2419            &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
2420            vec![],
2421        )
2422        .unwrap();
2423        let planted = proof_create(
2424            &mut engine,
2425            "rede",
2426            "Planted B",
2427            &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
2428            vec![],
2429        )
2430        .unwrap();
2431        assert!(
2432            planted
2433                .warnings
2434                .iter()
2435                .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
2436            "warn tier surfaces the duplicate as a warning and commits"
2437        );
2438        let findings =
2439            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
2440        assert_eq!(
2441            findings.len(),
2442            2,
2443            "both sides of the planted duplicate are findings: {findings:?}"
2444        );
2445    }
2446
2447    /// The plenum proof, enum-from-neighbour half (plan 07,
2448    /// criterion 3): renaming a value in the neighbour's section makes
2449    /// every stale holder a health finding.
2450    #[test]
2451    fn plenum_proof_enum_from_neighbour_flags_stale_holders_after_rename() {
2452        let tmp = TempDir::new().unwrap();
2453        let rede = plenum_rede_type("warn");
2454        let mut engine = engine_with_proof_schema(
2455            &tmp,
2456            "plenum-proof",
2457            PLENUM_MANIFEST,
2458            &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
2459        );
2460        let (actor, client) = cli_actor();
2461
2462        // The vocabulary entity enumerates the legal categories.
2463        let mut sections = IndexMap::new();
2464        sections.insert("body".to_string(), "the term list.".to_string());
2465        sections.insert("terms".to_string(), "- haushalt\n- verkehr\n".to_string());
2466        let vocab = engine
2467            .create_entity(
2468                CreateEntityArgs {
2469                    anchors: Vec::new(),
2470                    mem: "proof".to_string(),
2471                    title: "Kategorien".to_string(),
2472                    entity_type: "vocabulary".to_string(),
2473                    sections,
2474                    metadata: IndexMap::new(),
2475                    relations: vec![],
2476                    dry_run: false,
2477                },
2478                actor,
2479                Some(&client),
2480                None,
2481            )
2482            .unwrap();
2483
2484        // A holder whose value is backed: clean.
2485        let holder = proof_create(
2486            &mut engine,
2487            "rede",
2488            "Holder",
2489            &[("kategorie", "haushalt")],
2490            vec![rel(&vocab.id.0, "REFERENCES")],
2491        )
2492        .unwrap();
2493        let findings =
2494            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
2495        assert!(
2496            findings.iter().all(|r| r.id != holder.id),
2497            "backed value produces no finding: {findings:?}"
2498        );
2499
2500        // Rename the value in the neighbour's section — the holder
2501        // goes stale and health flags it.
2502        let current = engine.get_entity(&vocab.id).unwrap().content_hash.clone();
2503        let mut sections = IndexMap::new();
2504        sections.insert("terms".to_string(), "- finanzen\n- verkehr\n".to_string());
2505        engine
2506            .update_entity(
2507                crate::engine::UpdateEntityArgs {
2508                    anchors: Vec::new(),
2509                    id: vocab.id.clone(),
2510                    expected_hash: Some(current),
2511                    sections,
2512                    append_sections: IndexMap::new(),
2513                    patch_sections: IndexMap::new(),
2514                    metadata: IndexMap::new(),
2515                    metadata_unset: Vec::new(),
2516                    declare_relations: vec![],
2517                    dry_run: false,
2518                    relations_unset: Vec::new(),
2519                    anchors_unset: Vec::new(),
2520                },
2521                actor,
2522                Some(&client),
2523                None,
2524            )
2525            .unwrap();
2526        let findings =
2527            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
2528        let stale = findings
2529            .iter()
2530            .find(|r| r.id == holder.id)
2531            .expect("stale holder is flagged after the rename");
2532        assert!(stale.violations.iter().any(|v| matches!(
2533            v,
2534            crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { value, .. }
2535                if value == "haushalt"
2536        )));
2537    }
2538
2539    /// The advertised mutation warning is real: a create leaving a
2540    /// required-outgoing block unsatisfied returns
2541    /// `MISSING_REQUIRED_OUTGOING` naming the block with cardinality —
2542    /// and still commits. Complements: a create satisfying the block
2543    /// via inline `relations` emits no such warning; the health sweep
2544    /// reports exactly the same unsatisfied blocks (shared evaluation).
2545    #[test]
2546    fn create_warns_missing_required_outgoing_and_still_commits() {
2547        let tmp = TempDir::new().unwrap();
2548        let mut engine = engine_with_required_outgoing_schema(&tmp);
2549        let (actor, client) = cli_actor();
2550
2551        let outcome = engine
2552            .create_entity(
2553                task_create_args("Orphan Task", vec![]),
2554                actor,
2555                Some(&client),
2556                None,
2557            )
2558            .unwrap();
2559        assert!(
2560            !outcome.commit_sha.is_empty(),
2561            "the warning never blocks the mutation"
2562        );
2563        let blocks = missing_outgoing_of(&outcome.warnings);
2564        assert_eq!(
2565            blocks,
2566            vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())],
2567            "warning names the unsatisfied block with cardinality; warnings = {:?}",
2568            outcome.warnings
2569        );
2570
2571        // Health-path parity: the sweep reports the same entity with
2572        // the same block — the two surfaces share one evaluation.
2573        let reports = crate::ops::health::collect_missing_required_outgoing(
2574            engine.store(),
2575            None,
2576            engine.schemas(),
2577        );
2578        assert_eq!(reports.len(), 1);
2579        assert_eq!(reports[0].id, outcome.id);
2580        assert_eq!(reports[0].missing.len(), 1);
2581        assert_eq!(reports[0].missing[0].relationships, vec!["PART_OF"]);
2582        assert_eq!(reports[0].missing[0].cardinality, "at_least_one");
2583
2584        // Complement: a create whose inline relation satisfies the
2585        // block emits no MISSING_REQUIRED_OUTGOING.
2586        let satisfied = engine
2587            .create_entity(
2588                task_create_args(
2589                    "Child Task",
2590                    vec![crate::ops::RelateArg {
2591                        to: outcome.id.clone(),
2592                        rel_type: "PART_OF".to_string(),
2593                        description: None,
2594                    }],
2595                ),
2596                actor,
2597                Some(&client),
2598                None,
2599            )
2600            .unwrap();
2601        assert!(
2602            missing_outgoing_of(&satisfied.warnings).is_empty(),
2603            "satisfied block emits no warning: {:?}",
2604            satisfied.warnings
2605        );
2606    }
2607
2608    /// Update-side mirror: a section-only update on an entity with an
2609    /// unsatisfied block warns; declaring the satisfying relation in
2610    /// the same update clears it.
2611    #[test]
2612    fn update_warns_missing_required_outgoing_until_satisfied() {
2613        let tmp = TempDir::new().unwrap();
2614        let mut engine = engine_with_required_outgoing_schema(&tmp);
2615        let (actor, client) = cli_actor();
2616        let a = engine
2617            .create_entity(
2618                task_create_args("Task A", vec![]),
2619                actor,
2620                Some(&client),
2621                None,
2622            )
2623            .unwrap();
2624        let b = engine
2625            .create_entity(
2626                task_create_args("Task B", vec![]),
2627                actor,
2628                Some(&client),
2629                None,
2630            )
2631            .unwrap();
2632
2633        let update = |engine: &mut Engine,
2634                      id: &crate::entity::EntityId,
2635                      declare: Vec<crate::ops::RelateArg>| {
2636            let current = engine.get_entity(id).unwrap().content_hash.clone();
2637            let mut sections = IndexMap::new();
2638            sections.insert("body".to_string(), format!("edited at {:?}", declare.len()));
2639            engine
2640                .update_entity(
2641                    crate::engine::UpdateEntityArgs {
2642                        anchors: Vec::new(),
2643                        id: id.clone(),
2644                        expected_hash: Some(current),
2645                        sections,
2646                        append_sections: IndexMap::new(),
2647                        patch_sections: IndexMap::new(),
2648                        metadata: IndexMap::new(),
2649                        metadata_unset: Vec::new(),
2650                        declare_relations: declare,
2651                        dry_run: false,
2652                        relations_unset: Vec::new(),
2653                        anchors_unset: Vec::new(),
2654                    },
2655                    actor,
2656                    Some(&client),
2657                    None,
2658                )
2659                .unwrap()
2660        };
2661
2662        // Section-only update on an unsatisfied entity: warning fires,
2663        // mutation commits.
2664        let outcome = update(&mut engine, &a.id, vec![]);
2665        assert!(!outcome.commit_sha.is_empty());
2666        assert_eq!(
2667            missing_outgoing_of(&outcome.warnings),
2668            vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())]
2669        );
2670
2671        // Declaring the satisfying relation in the update clears it.
2672        let outcome = update(
2673            &mut engine,
2674            &a.id,
2675            vec![crate::ops::RelateArg {
2676                to: b.id.clone(),
2677                rel_type: "PART_OF".to_string(),
2678                description: None,
2679            }],
2680        );
2681        assert!(
2682            missing_outgoing_of(&outcome.warnings).is_empty(),
2683            "satisfied block emits no warning: {:?}",
2684            outcome.warnings
2685        );
2686    }
2687
2688    /// The source-vs-binding check at the engine seam: an anchor naming
2689    /// BOTH a producing binding (by hash) and a `source` refuses when
2690    /// the binding resolves in this workspace but does not declare the
2691    /// name — with the declared names in the recovery payload. A
2692    /// declared name is accepted; an unresolvable binding hash accepts
2693    /// any non-empty name (validation never requires resolution).
2694    #[test]
2695    fn anchor_source_validated_against_resolvable_binding() {
2696        use crate::binding::{
2697            BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, hash_binding,
2698        };
2699        use crate::pipeline::{IngestTrigger, PatternEntry, PatternMode, Source};
2700
2701        let tmp = TempDir::new().unwrap();
2702        let (mut engine, _seed) = engine_with_seed(&tmp, "Seed");
2703        let (actor, client) = cli_actor();
2704
2705        // A workspace root carrying one binding with two declared sources.
2706        let ws = TempDir::new().unwrap();
2707        let binding = Binding {
2708            version: BINDING_VERSION,
2709            intent: None,
2710            sources: ["api-docs", "guides"]
2711                .into_iter()
2712                .map(|n| Source {
2713                    name: n.to_string(),
2714                    medium_type: crate::pipeline::MediumType::Codebase,
2715                    pointer: "../src".to_string(),
2716                    change_detection: None,
2717                    scope: vec![PatternEntry {
2718                        path: "**/*".to_string(),
2719                        mode: PatternMode::Allow,
2720                    }],
2721                    engagement: None,
2722                    preparation: None,
2723                })
2724                .collect(),
2725            reference_mems: Vec::new(),
2726            destination_mem: "specs".to_string(),
2727            deny_paths: Vec::new(),
2728            coverage_semantics: None,
2729            rules: None,
2730            prune: None,
2731            operations: Operations {
2732                build: Some(BuildOperation {
2733                    mode: BuildMode::Discovery,
2734                    trigger: IngestTrigger::Loop,
2735                    batch_size: 20,
2736                    post_actions: None,
2737                }),
2738                sync: None,
2739                verify: None,
2740            },
2741        };
2742        let dir = ws
2743            .path()
2744            .join(".memstead")
2745            .join("projections")
2746            .join("specs");
2747        std::fs::create_dir_all(&dir).unwrap();
2748        std::fs::write(
2749            dir.join("docs.json"),
2750            serde_json::to_string_pretty(&binding).unwrap(),
2751        )
2752        .unwrap();
2753        engine.set_workspace_root(ws.path().to_path_buf());
2754        let binding_hash = hash_binding(&binding);
2755
2756        let anchor = |source: &str, binding: &str| crate::anchor::AnchorInput {
2757            artifact: Some("src/x.rs".into()),
2758            grain: Some("file".into()),
2759            class: Some("anchored".into()),
2760            binding: Some(binding.into()),
2761            source: Some(source.into()),
2762            ..Default::default()
2763        };
2764        let make_args = |title: &str, a: crate::anchor::AnchorInput| {
2765            let mut args = empty_create_args("specs", title);
2766            args.anchors = vec![a];
2767            args
2768        };
2769
2770        // Undeclared name against the RESOLVING binding: refuses with the
2771        // declared names in the payload.
2772        let err = engine
2773            .create_entity(
2774                make_args("Bad Source", anchor("front-page", &binding_hash)),
2775                actor,
2776                Some(&client),
2777                None,
2778            )
2779            .unwrap_err();
2780        assert_eq!(err.code(), "INVALID_ANCHOR", "got {err:?}");
2781        let details = err.details();
2782        assert_eq!(details["field"], "source");
2783        assert_eq!(details["got"], "front-page");
2784        assert_eq!(
2785            details["declared"],
2786            serde_json::json!(["api-docs", "guides"])
2787        );
2788
2789        // A declared name is accepted, and the anchor round-trips with it.
2790        let ok = engine
2791            .create_entity(
2792                make_args("Good Source", anchor("api-docs", &binding_hash)),
2793                actor,
2794                Some(&client),
2795                None,
2796            )
2797            .expect("declared source name accepted");
2798        let anchors = engine.mem_anchors_resolved("specs");
2799        let stored = anchors
2800            .iter()
2801            .find(|(id, _)| id == &ok.id)
2802            .map(|(_, a)| &a.anchor)
2803            .expect("anchor stored for the new entity");
2804        assert_eq!(stored.source.as_deref(), Some("api-docs"));
2805
2806        // An unresolvable binding hash accepts any non-empty name.
2807        engine
2808            .create_entity(
2809                make_args("Orphaned Binding", anchor("whatever", "deadbeef")),
2810                actor,
2811                Some(&client),
2812                None,
2813            )
2814            .expect("unresolvable binding accepts any non-empty name");
2815    }
2816
2817    /// Batch create: N mutually-referencing entities (cycle included —
2818    /// USES is not acyclic in the default schema) land in ONE
2819    /// invocation with every reference resolving to a REAL typed
2820    /// entity, never a stub, and no stub warnings.
2821    #[test]
2822    fn batch_create_intra_batch_references_resolve_real() {
2823        let tmp = TempDir::new().unwrap();
2824        let mem_dir = tmp.path().to_path_buf();
2825        let writer = FilesystemMemWriter::new(mem_dir.clone());
2826        let mut engine = Engine::from_mounts(vec![(
2827            folder_mount("specs", mem_dir),
2828            Box::new(writer) as Box<dyn MemBackend>,
2829        )])
2830        .unwrap();
2831        let (actor, client) = cli_actor();
2832
2833        let with_rel = |title: &str, to: &str| {
2834            let mut args = empty_create_args("specs", title);
2835            args.relations = vec![crate::ops::RelateArg {
2836                to: crate::entity::EntityId::new("specs", to),
2837                rel_type: "USES".to_string(),
2838                description: None,
2839            }];
2840            (args, Some(format!("note for {title}")))
2841        };
2842        // A → B → C → A: a cycle the schema permits.
2843        let result = engine
2844            .batch_create(
2845                vec![
2846                    with_rel("Alpha", "beta"),
2847                    with_rel("Beta", "gamma"),
2848                    with_rel("Gamma", "alpha"),
2849                ],
2850                actor,
2851                Some(&client),
2852                false,
2853            )
2854            .unwrap();
2855        assert!(result.applied, "{result:?}");
2856        assert_eq!(result.succeeded, 3);
2857        assert!(!result.commit_sha.is_empty(), "one real commit");
2858        assert!(
2859            result.results.iter().all(|r| r.action == "created"),
2860            "{result:?}"
2861        );
2862
2863        // Every reference resolves to a REAL entity of the right type.
2864        for name in ["alpha", "beta", "gamma"] {
2865            let e = engine
2866                .get_entity(&crate::entity::EntityId::new("specs", name))
2867                .unwrap();
2868            assert!(!e.stub, "{name} must be real, not a stub");
2869            assert_eq!(e.entity_type, "spec");
2870            assert_eq!(e.relationships.len(), 1, "{name} carries its edge");
2871        }
2872        // No stub warnings anywhere in the outcome (in-batch targets
2873        // never transit through the stub machinery).
2874        // (BatchResult carries no warnings channel; absence of stubs in
2875        // the store is the observable.)
2876    }
2877
2878    /// Rehearsal contract (agent-trust plan 07): `batch_create` with
2879    /// `dry_run: true` validates the whole batch — intra-batch
2880    /// references included — and reports the would-be receipt with the
2881    /// marker form's empty `commit_sha`, writing NOTHING. The
2882    /// follow-up real call on the unchanged mem succeeds.
2883    #[test]
2884    fn batch_create_dry_run_reports_receipt_and_writes_nothing() {
2885        let tmp = TempDir::new().unwrap();
2886        let mem_dir = tmp.path().to_path_buf();
2887        let writer = FilesystemMemWriter::new(mem_dir.clone());
2888        let mut engine = Engine::from_mounts(vec![(
2889            folder_mount("specs", mem_dir),
2890            Box::new(writer) as Box<dyn MemBackend>,
2891        )])
2892        .unwrap();
2893        let (actor, client) = cli_actor();
2894
2895        let with_rel = |title: &str, to: &str| {
2896            let mut args = empty_create_args("specs", title);
2897            args.relations = vec![crate::ops::RelateArg {
2898                to: crate::entity::EntityId::new("specs", to),
2899                rel_type: "USES".to_string(),
2900                description: None,
2901            }];
2902            (args, None)
2903        };
2904        let batch = || {
2905            vec![
2906                with_rel("Alpha", "beta"),
2907                with_rel("Beta", "gamma"),
2908                with_rel("Gamma", "alpha"),
2909            ]
2910        };
2911
2912        let rehearsed = engine
2913            .batch_create(batch(), actor, Some(&client), true)
2914            .unwrap();
2915        assert!(rehearsed.applied, "{rehearsed:?}");
2916        assert_eq!(rehearsed.succeeded, 3);
2917        assert!(
2918            rehearsed.commit_sha.is_empty(),
2919            "marker form: empty commit_sha"
2920        );
2921        assert!(rehearsed.results.iter().all(|r| r.action == "created"));
2922        // The receipt names the prospective ids; nothing landed.
2923        for name in ["alpha", "beta", "gamma"] {
2924            let id = crate::entity::EntityId::new("specs", name);
2925            assert!(
2926                rehearsed.results.iter().any(|r| r.id == id),
2927                "receipt must name {id}: {rehearsed:?}"
2928            );
2929            assert!(
2930                !engine.store().contains(&id),
2931                "rehearsal must create nothing"
2932            );
2933        }
2934        assert_eq!(engine.store().all_entities().count(), 0);
2935
2936        // Identical validation: the real call on the unchanged mem lands.
2937        let real = engine
2938            .batch_create(batch(), actor, Some(&client), false)
2939            .unwrap();
2940        assert!(real.applied, "{real:?}");
2941        assert!(!real.commit_sha.is_empty(), "the real batch commits");
2942        assert_eq!(real.succeeded, 3);
2943    }
2944
2945    /// Rehearsal refusal parity: a batch with failing entries refuses
2946    /// under `dry_run: true` with the SAME per-entry report-all
2947    /// envelope the real call returns — and both perform nothing, so
2948    /// the paired invocations are directly comparable.
2949    #[test]
2950    fn batch_create_dry_run_refuses_identically_to_real() {
2951        let tmp = TempDir::new().unwrap();
2952        let (mut engine, _seeded) = engine_with_seed(&tmp, "Existing");
2953        let (actor, client) = cli_actor();
2954        let plain = |title: &str| (empty_create_args("specs", title), None);
2955        let batch = || {
2956            vec![
2957                plain("Fine One"),
2958                plain("Existing"),  // duplicate vs pre-batch store
2959                plain("Bad/Title"), // invalid title character
2960            ]
2961        };
2962
2963        let rehearsed = engine
2964            .batch_create(batch(), actor, Some(&client), true)
2965            .unwrap();
2966        let real = engine
2967            .batch_create(batch(), actor, Some(&client), false)
2968            .unwrap();
2969        assert!(!rehearsed.applied && !real.applied);
2970        assert_eq!(rehearsed.failed, real.failed);
2971        assert_eq!(rehearsed.errors_suppressed, real.errors_suppressed);
2972        let envelope = |r: &crate::ops::BatchResult| {
2973            r.results
2974                .iter()
2975                .map(|e| {
2976                    (
2977                        e.id.to_string(),
2978                        e.action.clone(),
2979                        e.error.as_ref().map(|err| {
2980                            (err.code.clone(), err.message.clone(), err.details.clone())
2981                        }),
2982                    )
2983                })
2984                .collect::<Vec<_>>()
2985        };
2986        assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
2987        assert!(
2988            !engine
2989                .store()
2990                .contains(&crate::entity::EntityId::new("specs", "fine-one"))
2991        );
2992    }
2993
2994    /// Atomicity + report-all: a batch with several invalid entries
2995    /// writes NOTHING (no entity, no head movement) and names EVERY
2996    /// failing entry with its typed code — not only the first.
2997    #[test]
2998    fn batch_create_refuses_whole_batch_reporting_every_failure() {
2999        let tmp = TempDir::new().unwrap();
3000        let (mut engine, seeded) = engine_with_seed(&tmp, "Existing");
3001        let (actor, client) = cli_actor();
3002        let head_before = engine
3003            .mem_head_sha("specs")
3004            .ok()
3005            .flatten()
3006            .unwrap_or_default();
3007        let count_before = engine.store().all_entities().count();
3008
3009        let plain = |title: &str| (empty_create_args("specs", title), None);
3010        let result = engine
3011            .batch_create(
3012                vec![
3013                    plain("Fine One"),
3014                    plain("Existing"),   // duplicate vs pre-batch store
3015                    plain("Bad\nTitle"), // control character in title
3016                    plain("Fine Two"),
3017                    plain("Fine Two"), // duplicate WITHIN the batch
3018                ],
3019                actor,
3020                Some(&client),
3021                false,
3022            )
3023            .unwrap();
3024        assert!(!result.applied);
3025        assert_eq!(result.failed, 3, "{result:?}");
3026        assert!(result.commit_sha.is_empty());
3027        let codes: Vec<(usize, &str)> = result
3028            .results
3029            .iter()
3030            .enumerate()
3031            .filter(|(_, r)| r.action == "error")
3032            .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
3033            .collect();
3034        assert_eq!(
3035            codes,
3036            vec![
3037                (1, "ENTITY_ALREADY_EXISTS"),
3038                (2, "INVALID_TITLE"),
3039                (4, "ENTITY_ALREADY_EXISTS"),
3040            ],
3041            "every failing entry named with index + typed code: {result:?}"
3042        );
3043        // Valid entries are marked not_applied, and NOTHING was written.
3044        assert_eq!(result.results[0].action, "not_applied");
3045        assert_eq!(result.results[3].action, "not_applied");
3046        let head_after = engine
3047            .mem_head_sha("specs")
3048            .ok()
3049            .flatten()
3050            .unwrap_or_default();
3051        assert_eq!(head_before, head_after, "mem head unmoved");
3052        assert_eq!(
3053            engine.store().all_entities().count(),
3054            count_before,
3055            "no entity created, no skeleton left behind"
3056        );
3057        let _ = seeded;
3058    }
3059
3060    /// Bounded reporting: with more failing entries than the cap, the
3061    /// report carries the cap's worth of detailed envelopes and counts
3062    /// the suppressed remainder — never a silent truncation.
3063    #[test]
3064    fn batch_create_bounds_the_failure_report() {
3065        let tmp = TempDir::new().unwrap();
3066        let mem_dir = tmp.path().to_path_buf();
3067        let writer = FilesystemMemWriter::new(mem_dir.clone());
3068        let mut engine = Engine::from_mounts(vec![(
3069            folder_mount("specs", mem_dir),
3070            Box::new(writer) as Box<dyn MemBackend>,
3071        )])
3072        .unwrap();
3073        let (actor, client) = cli_actor();
3074        let n = Engine::BATCH_ERROR_REPORT_CAP + 10;
3075        let batch: Vec<_> = (0..n)
3076            .map(|i| (empty_create_args("specs", &format!("Bad\nTitle {i}")), None))
3077            .collect();
3078        let result = engine
3079            .batch_create(batch, actor, Some(&client), false)
3080            .unwrap();
3081        assert!(!result.applied);
3082        assert_eq!(result.failed, n);
3083        let detailed = result
3084            .results
3085            .iter()
3086            .filter(|r| r.action == "error" && r.error.is_some())
3087            .count();
3088        let bare = result
3089            .results
3090            .iter()
3091            .filter(|r| r.action == "error" && r.error.is_none())
3092            .count();
3093        assert_eq!(detailed, Engine::BATCH_ERROR_REPORT_CAP);
3094        assert_eq!(bare, 10);
3095        assert_eq!(
3096            result.errors_suppressed, 10,
3097            "suppression is counted, never silent"
3098        );
3099    }
3100
3101    #[test]
3102    fn create_entity_writes_through_folder_backend_and_updates_store() {
3103        let tmp = TempDir::new().unwrap();
3104        let mem_dir = tmp.path().to_path_buf();
3105        let writer = FilesystemMemWriter::new(mem_dir.clone());
3106        let mut engine = Engine::from_mounts(vec![(
3107            folder_mount("specs", mem_dir.clone()),
3108            Box::new(writer) as Box<dyn MemBackend>,
3109        )])
3110        .unwrap();
3111        let (actor, client) = cli_actor();
3112
3113        let outcome = engine
3114            .create_entity(
3115                empty_create_args("specs", "Hello World"),
3116                actor,
3117                Some(&client),
3118                Some("first draft"),
3119            )
3120            .unwrap();
3121
3122        // Outcome reports a real id, real file path, real hash.
3123        assert_eq!(outcome.id.to_string(), "specs--hello-world");
3124        assert_eq!(outcome.file_path, "hello-world.md");
3125        assert!(!outcome.content_hash.is_empty());
3126
3127        // Store has the new entity.
3128        let entity = engine
3129            .get_entity(&crate::EntityId::new("specs", "hello-world"))
3130            .expect("entity must be in the store after create");
3131        assert_eq!(entity.title, "Hello World");
3132        assert_eq!(entity.entity_type, "spec");
3133        assert_eq!(entity.content_hash, outcome.content_hash);
3134
3135        // On-disk markdown exists at the expected path.
3136        let on_disk = std::fs::read_to_string(mem_dir.join("hello-world.md")).unwrap();
3137        assert!(on_disk.contains("# Hello World"));
3138        assert!(on_disk.contains("type: spec"));
3139
3140        // Provenance log has the create record.
3141        let log_path = mem_dir.join(".memstead").join("changes.jsonl");
3142        let log = std::fs::read_to_string(&log_path).unwrap();
3143        assert!(log.contains("\"kind\":\"create\""));
3144        assert!(log.contains("\"entity\":\"specs--hello-world\""));
3145        assert!(log.contains("\"actor\":\"cli\""));
3146        assert!(log.contains("\"note\":\"first draft\""));
3147    }
3148
3149    /// Supplying a
3150    /// value for an auto-managed field (`created_date`) on create no
3151    /// longer silently discards it — the response carries an
3152    /// `IGNORED_READONLY_FIELD` warning, and the stored value is the
3153    /// engine-stamped one, not the supplied `2020-01-01`.
3154    #[test]
3155    fn create_entity_warns_on_supplied_auto_managed_field() {
3156        let tmp = TempDir::new().unwrap();
3157        let mem_dir = tmp.path().to_path_buf();
3158        let writer = FilesystemMemWriter::new(mem_dir.clone());
3159        let mut engine = Engine::from_mounts(vec![(
3160            folder_mount("specs", mem_dir),
3161            Box::new(writer) as Box<dyn MemBackend>,
3162        )])
3163        .unwrap();
3164        let (actor, client) = cli_actor();
3165
3166        let mut args = empty_create_args("specs", "Dated Entity");
3167        args.metadata
3168            .insert("created_date".to_string(), "2020-01-01".to_string());
3169
3170        let outcome = engine
3171            .create_entity(args, actor, Some(&client), None)
3172            .unwrap();
3173
3174        let warned = outcome.warnings.iter().any(|w| {
3175            w.code() == "IGNORED_READONLY_FIELD"
3176                && matches!(w, WarningHint::IgnoredReadonlyField { field, supplied }
3177                    if field == "created_date" && supplied == "2020-01-01")
3178        });
3179        assert!(
3180            warned,
3181            "expected IGNORED_READONLY_FIELD; got {:?}",
3182            outcome.warnings
3183        );
3184
3185        // The engine value was stamped, not the supplied 2020 date.
3186        assert_ne!(outcome.created_date, "2020-01-01");
3187    }
3188
3189    /// Complement: a create with no auto-managed field supplied emits no
3190    /// `IGNORED_READONLY_FIELD` warning.
3191    #[test]
3192    fn create_entity_no_warning_when_auto_managed_field_absent() {
3193        let tmp = TempDir::new().unwrap();
3194        let mem_dir = tmp.path().to_path_buf();
3195        let writer = FilesystemMemWriter::new(mem_dir.clone());
3196        let mut engine = Engine::from_mounts(vec![(
3197            folder_mount("specs", mem_dir),
3198            Box::new(writer) as Box<dyn MemBackend>,
3199        )])
3200        .unwrap();
3201        let (actor, client) = cli_actor();
3202
3203        let outcome = engine
3204            .create_entity(
3205                empty_create_args("specs", "Plain Entity"),
3206                actor,
3207                Some(&client),
3208                None,
3209            )
3210            .unwrap();
3211        assert!(
3212            !outcome
3213                .warnings
3214                .iter()
3215                .any(|w| w.code() == "IGNORED_READONLY_FIELD"),
3216            "no auto-managed field supplied — no warning expected; got {:?}",
3217            outcome.warnings
3218        );
3219    }
3220
3221    #[test]
3222    fn create_entity_returns_commit_sha_title_mem_on_real_write() {
3223        let tmp = TempDir::new().unwrap();
3224        let mem_dir = tmp.path().to_path_buf();
3225        let writer = FilesystemMemWriter::new(mem_dir.clone());
3226        let mut engine = Engine::from_mounts(vec![(
3227            folder_mount("specs", mem_dir),
3228            Box::new(writer) as Box<dyn MemBackend>,
3229        )])
3230        .unwrap();
3231        let (actor, client) = cli_actor();
3232
3233        let outcome = engine
3234            .create_entity(
3235                empty_create_args("specs", "Rich Shape"),
3236                actor,
3237                Some(&client),
3238                None,
3239            )
3240            .unwrap();
3241
3242        // Folder backend produces a synthetic CommitId — wire-equiv
3243        // to full's commit SHA.
3244        assert!(
3245            !outcome.commit_sha.is_empty(),
3246            "commit_sha must be populated on a real create"
3247        );
3248        // title + mem echoed from args (full CreateResult parity).
3249        assert_eq!(outcome.title, "Rich Shape");
3250        assert_eq!(outcome.mem, "specs");
3251        // The create path refuses on missing required sections, so
3252        // `empty_create_args` seeds identity + purpose and the
3253        // success path's warnings vec carries no
3254        // `MissingRequiredSection` entries. The dedicated refusal
3255        // tests below exercise the gate directly.
3256        assert!(
3257            !outcome
3258                .warnings
3259                .iter()
3260                .any(|w| matches!(w, WarningHint::MissingRequiredSection { .. })),
3261            "success path must not carry MissingRequiredSection warnings — those refuse on create now",
3262        );
3263    }
3264
3265    /// Missing
3266    /// required sections refuse on create. The error envelope names
3267    /// every missing key (in schema-declaration order), carries each
3268    /// section's `write_rules`, and surfaces the type-level
3269    /// `type_guidance` map keyed by `entity_type`.
3270    #[test]
3271    fn create_entity_refuses_missing_required_sections_with_typed_envelope() {
3272        let tmp = TempDir::new().unwrap();
3273        let mem_dir = tmp.path().to_path_buf();
3274        let writer = FilesystemMemWriter::new(mem_dir.clone());
3275        let mut engine = Engine::from_mounts(vec![(
3276            folder_mount("specs", mem_dir),
3277            Box::new(writer) as Box<dyn MemBackend>,
3278        )])
3279        .unwrap();
3280        let (actor, client) = cli_actor();
3281
3282        // `spec` requires `identity` + `purpose`. Supply neither.
3283        let args = CreateEntityArgs {
3284            anchors: Vec::new(),
3285            mem: "specs".to_string(),
3286            title: "Half Done".to_string(),
3287            entity_type: "spec".to_string(),
3288            sections: IndexMap::new(),
3289            metadata: IndexMap::new(),
3290            relations: Vec::new(),
3291            dry_run: false,
3292        };
3293        let err = engine
3294            .create_entity(args, actor, Some(&client), None)
3295            .unwrap_err();
3296        match err {
3297            EngineError::MissingRequiredSection {
3298                entity_type,
3299                missing_count,
3300                sections,
3301                type_guidance,
3302            } => {
3303                assert_eq!(entity_type, "spec");
3304                assert_eq!(missing_count, sections.len());
3305                assert!(
3306                    missing_count >= 2,
3307                    "expected ≥2 missing sections, got {missing_count}"
3308                );
3309                let keys: Vec<String> = sections.iter().map(|s| s.key.clone()).collect();
3310                assert!(
3311                    keys.contains(&"identity".to_string()),
3312                    "missing keys: {keys:?}"
3313                );
3314                assert!(
3315                    keys.contains(&"purpose".to_string()),
3316                    "missing keys: {keys:?}"
3317                );
3318                assert!(
3319                    type_guidance.contains_key("spec"),
3320                    "type_guidance must include `spec` entry, got: {type_guidance:?}",
3321                );
3322            }
3323            other => panic!("expected MissingRequiredSection, got {other:?}"),
3324        }
3325
3326        // No entity landed in the store.
3327        let id = crate::EntityId::new("specs", "half-done");
3328        assert!(
3329            engine.store().get(&id).is_none(),
3330            "refused create must not persist any entity"
3331        );
3332    }
3333
3334    /// `dry_run: true` returns the same refusal envelope
3335    /// the real call would. The preview surface doesn't admit content
3336    /// the real call would refuse.
3337    #[test]
3338    fn create_entity_dry_run_returns_same_refusal_envelope_as_real_call() {
3339        let tmp = TempDir::new().unwrap();
3340        let mem_dir = tmp.path().to_path_buf();
3341        let writer = FilesystemMemWriter::new(mem_dir.clone());
3342        let mut engine = Engine::from_mounts(vec![(
3343            folder_mount("specs", mem_dir),
3344            Box::new(writer) as Box<dyn MemBackend>,
3345        )])
3346        .unwrap();
3347        let (actor, client) = cli_actor();
3348
3349        let args = CreateEntityArgs {
3350            anchors: Vec::new(),
3351            mem: "specs".to_string(),
3352            title: "Half Done Dry".to_string(),
3353            entity_type: "spec".to_string(),
3354            sections: IndexMap::new(),
3355            metadata: IndexMap::new(),
3356            relations: Vec::new(),
3357            dry_run: true,
3358        };
3359        let err = engine
3360            .create_entity(args, actor, Some(&client), None)
3361            .unwrap_err();
3362        assert!(
3363            matches!(err, EngineError::MissingRequiredSection { .. }),
3364            "dry_run must surface the same refusal envelope, got {err:?}"
3365        );
3366    }
3367
3368    /// A follow-up call with the missing sections filled
3369    /// in succeeds. The refusal carries enough recovery information
3370    /// that the agent's next attempt resolves in one round-trip.
3371    #[test]
3372    fn create_entity_succeeds_after_filling_in_required_sections() {
3373        let tmp = TempDir::new().unwrap();
3374        let mem_dir = tmp.path().to_path_buf();
3375        let writer = FilesystemMemWriter::new(mem_dir.clone());
3376        let mut engine = Engine::from_mounts(vec![(
3377            folder_mount("specs", mem_dir),
3378            Box::new(writer) as Box<dyn MemBackend>,
3379        )])
3380        .unwrap();
3381        let (actor, client) = cli_actor();
3382
3383        let mut sections = IndexMap::new();
3384        sections.insert("identity".to_string(), "the identity body".to_string());
3385        sections.insert("purpose".to_string(), "the purpose body".to_string());
3386        let args = CreateEntityArgs {
3387            anchors: Vec::new(),
3388            mem: "specs".to_string(),
3389            title: "Complete".to_string(),
3390            entity_type: "spec".to_string(),
3391            sections,
3392            metadata: IndexMap::new(),
3393            relations: Vec::new(),
3394            dry_run: false,
3395        };
3396        let outcome = engine
3397            .create_entity(args, actor, Some(&client), None)
3398            .expect("complete create succeeds");
3399        assert_eq!(outcome.title, "Complete");
3400    }
3401
3402    #[test]
3403    fn create_entity_promotes_existing_stub_and_preserves_incoming_edges() {
3404        let tmp = TempDir::new().unwrap();
3405        let (mut engine, source) = engine_with_seed(&tmp, "Source");
3406        let (actor, client) = cli_actor();
3407
3408        // Step 1: relate source → "ghost-target" — creates a stub
3409        // entity at `specs--ghost-target` with one incoming edge.
3410        let stub_target = crate::EntityId::new("specs", "ghost-target");
3411        engine
3412            .relate_entity(
3413                RelateEntityArgs {
3414                    source: source.id.clone(),
3415                    expected_hash: Some(source.content_hash.clone()),
3416                    rel_type: "USES".to_string(),
3417                    target: stub_target.clone(),
3418                    remove: false,
3419                    description: None,
3420                    dry_run: false,
3421                },
3422                actor,
3423                Some(&client),
3424                None,
3425            )
3426            .unwrap();
3427        let stub = engine
3428            .store()
3429            .get(&stub_target)
3430            .expect("stub must be in store");
3431        assert!(stub.stub);
3432        assert_eq!(engine.store().incoming(&stub_target).len(), 1);
3433
3434        // Step 2: create a real entity with the same title — should
3435        // promote the stub and preserve the incoming edge.
3436        let outcome = engine
3437            .create_entity(
3438                empty_create_args("specs", "Ghost Target"),
3439                actor,
3440                Some(&client),
3441                None,
3442            )
3443            .unwrap();
3444
3445        // No error: stub adoption proceeded.
3446        assert_eq!(outcome.id, stub_target);
3447        // Entity is now a real entity, not a stub.
3448        let real = engine
3449            .store()
3450            .get(&stub_target)
3451            .expect("entity must still be in store");
3452        assert!(!real.stub);
3453        // Incoming edge survived the upsert.
3454        assert_eq!(engine.store().incoming(&stub_target).len(), 1);
3455        // Outcome surfaces stub adoption.
3456        assert_eq!(outcome.incoming_count, Some(1));
3457        assert_eq!(outcome.incoming.len(), 1);
3458        assert_eq!(outcome.incoming[0].from, source.id);
3459        assert_eq!(outcome.incoming[0].rel_type, "USES");
3460    }
3461
3462    #[test]
3463    fn create_entity_reports_no_incoming_on_greenfield_create() {
3464        let tmp = TempDir::new().unwrap();
3465        let mem_dir = tmp.path().to_path_buf();
3466        let writer = FilesystemMemWriter::new(mem_dir.clone());
3467        let mut engine = Engine::from_mounts(vec![(
3468            folder_mount("specs", mem_dir),
3469            Box::new(writer) as Box<dyn MemBackend>,
3470        )])
3471        .unwrap();
3472        let (actor, client) = cli_actor();
3473
3474        let outcome = engine
3475            .create_entity(
3476                empty_create_args("specs", "Greenfield"),
3477                actor,
3478                Some(&client),
3479                None,
3480            )
3481            .unwrap();
3482        // No pre-existing stub → incoming_count is None, incoming vec
3483        // is empty. Full's wire shape skip-serialises both.
3484        assert!(outcome.incoming_count.is_none());
3485        assert!(outcome.incoming.is_empty());
3486    }
3487
3488    #[test]
3489    fn create_entity_populates_created_date_from_schema_auto_stamp() {
3490        let tmp = TempDir::new().unwrap();
3491        let mem_dir = tmp.path().to_path_buf();
3492        let writer = FilesystemMemWriter::new(mem_dir.clone());
3493        let mut engine = Engine::from_mounts(vec![(
3494            folder_mount("specs", mem_dir),
3495            Box::new(writer) as Box<dyn MemBackend>,
3496        )])
3497        .unwrap();
3498        let (actor, client) = cli_actor();
3499
3500        let outcome = engine
3501            .create_entity(
3502                empty_create_args("specs", "Has Date"),
3503                actor,
3504                Some(&client),
3505                None,
3506            )
3507            .unwrap();
3508        // The default `spec` schema declares `created_date` with
3509        // an init_timestamp default. The parsed entity carries the
3510        // auto-stamped value; the outcome surfaces it for callers
3511        // who need it without a follow-up read.
3512        assert!(
3513            !outcome.created_date.is_empty(),
3514            "created_date must be populated when the schema auto-stamps it"
3515        );
3516    }
3517
3518    #[test]
3519    fn create_overrides_user_supplied_timestamps_update_rejects_them() {
3520        // Schema-declared `init_timestamp` (set on create) and
3521        // `auto_timestamp` (re-stamped on every update) fields are
3522        // engine-managed. On create the engine still silently
3523        // overrides any caller-supplied value (the entity must be
3524        // stampable in one shot from the user's perspective). On
3525        // update the writable-metadata validator rejects the write
3526        // up-front with `READ_ONLY_FIELD` — the agent gets a
3527        // structured rejection instead of a "set" response whose
3528        // value the auto-stamp pass silently discards (per the F13
3529        // / F14 contract).
3530        let tmp = TempDir::new().unwrap();
3531        let mem_dir = tmp.path().to_path_buf();
3532        let writer = FilesystemMemWriter::new(mem_dir.clone());
3533        let mut engine = Engine::from_mounts(vec![(
3534            folder_mount("specs", mem_dir),
3535            Box::new(writer) as Box<dyn MemBackend>,
3536        )])
3537        .unwrap();
3538        let (actor, client) = cli_actor();
3539
3540        // Caller supplies a past value for the init_timestamp field
3541        // and the auto_timestamp field. The engine ignores both on
3542        // create.
3543        let mut args = empty_create_args("specs", "Stamped Today");
3544        args.metadata
3545            .insert("created_date".to_string(), "2020-01-01".to_string());
3546        args.metadata
3547            .insert("last_modified".to_string(), "2020-01-01".to_string());
3548
3549        let outcome = engine
3550            .create_entity(args, actor, Some(&client), None)
3551            .unwrap();
3552
3553        // Both timestamps should reflect the engine's `today_iso()`,
3554        // not the caller's `2020-01-01`.
3555        let today = crate::engine::mutation::today_iso();
3556        assert_eq!(outcome.created_date, today);
3557        let entity = engine
3558            .get_entity(&outcome.id)
3559            .expect("entity must be in store after create");
3560        assert_eq!(
3561            entity
3562                .metadata
3563                .get("created_date")
3564                .and_then(|v| v.as_str())
3565                .unwrap_or_default(),
3566            today,
3567            "init_timestamp field must be engine-determined on create, not user-supplied"
3568        );
3569        assert_eq!(
3570            entity
3571                .metadata
3572                .get("last_modified")
3573                .and_then(|v| v.as_str())
3574                .unwrap_or_default(),
3575            today,
3576            "auto_timestamp field must be engine-determined on create, not user-supplied"
3577        );
3578
3579        // F13/F14: update rejects a user-supplied value for either
3580        // init_timestamp or auto_timestamp metadata fields with
3581        // `READ_ONLY_FIELD`. Test both fields in turn.
3582        let attempt_update = |key: &str, value: &str| {
3583            let mut metadata = IndexMap::new();
3584            metadata.insert(key.to_string(), value.to_string());
3585            crate::engine::UpdateEntityArgs {
3586                anchors: Vec::new(),
3587                id: outcome.id.clone(),
3588                metadata,
3589                metadata_unset: Vec::new(),
3590                sections: IndexMap::new(),
3591                append_sections: IndexMap::new(),
3592                patch_sections: IndexMap::new(),
3593                expected_hash: Some(outcome.content_hash.clone()),
3594                dry_run: false,
3595                declare_relations: Vec::new(),
3596                relations_unset: Vec::new(),
3597                anchors_unset: Vec::new(),
3598            }
3599        };
3600        for key in ["created_date", "last_modified"] {
3601            let err = engine
3602                .update_entity(
3603                    attempt_update(key, "2019-12-31"),
3604                    actor,
3605                    Some(&client),
3606                    None,
3607                )
3608                .expect_err("schema-managed timestamp must be rejected on update");
3609            assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
3610        }
3611        // Stored value is unchanged after a rejected attempt.
3612        let entity = engine
3613            .get_entity(&outcome.id)
3614            .expect("entity must remain in store after rejected update");
3615        assert_eq!(
3616            entity
3617                .metadata
3618                .get("last_modified")
3619                .and_then(|v| v.as_str())
3620                .unwrap_or_default(),
3621            today,
3622            "rejected update must not mutate the auto_timestamp field"
3623        );
3624    }
3625
3626    #[test]
3627    fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
3628        let tmp = TempDir::new().unwrap();
3629        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
3630        let (actor, client) = cli_actor();
3631        let absent = crate::EntityId::new("specs", "future-target");
3632        assert!(!engine.store().contains(&absent));
3633
3634        let mut args = empty_create_args("specs", "Source With Relations");
3635        args.relations = vec![
3636            crate::ops::RelateArg {
3637                to: existing.id.clone(),
3638                rel_type: "USES".to_string(),
3639                description: None,
3640            },
3641            crate::ops::RelateArg {
3642                to: absent.clone(),
3643                rel_type: "USES".to_string(),
3644                description: None,
3645            },
3646        ];
3647
3648        let outcome = engine
3649            .create_entity(args, actor, Some(&client), None)
3650            .unwrap();
3651
3652        // New entity in store with both edges materialised.
3653        let source = engine
3654            .store()
3655            .get(&outcome.id)
3656            .expect("source must be in store");
3657        assert_eq!(source.relationships.len(), 2);
3658        assert!(
3659            source
3660                .relationships
3661                .iter()
3662                .any(|r| r.target == existing.id && r.rel_type == "USES")
3663        );
3664        assert!(
3665            source
3666                .relationships
3667                .iter()
3668                .any(|r| r.target == absent && r.rel_type == "USES")
3669        );
3670
3671        // Absent target was auto-stubbed (mirrors the relate path's
3672        // ensure_target).
3673        let stub = engine
3674            .store()
3675            .get(&absent)
3676            .expect("absent relation target must be auto-stubbed");
3677        assert!(stub.stub);
3678        // Existing target unchanged.
3679        let existing_after = engine.store().get(&existing.id).unwrap();
3680        assert!(!existing_after.stub);
3681    }
3682
3683    /// Build a folder-mount engine pinned to the `planning` schema, so
3684    /// tests can exercise `decision` — a type with `decided_on` (Date,
3685    /// required, no default / no init_timestamp) — without inventing a
3686    /// synthetic schema.
3687    fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
3688        use crate::workspace::Mount;
3689        use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
3690        let mem_dir = tmp.path().to_path_buf();
3691        let writer = FilesystemMemWriter::new(mem_dir.clone());
3692        let mount = Mount {
3693            mem: "planning".to_string(),
3694            schema: Some(memstead_schema::SchemaRef::new(
3695                "planning",
3696                semver::Version::new(0, 1, 0),
3697            )),
3698            storage: MountStorage::Folder { path: mem_dir },
3699            capability: MountCapability::Write,
3700            lifecycle: MountLifecycle::Eager,
3701            cross_linkable: true,
3702            migration_target: None,
3703        };
3704        Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
3705    }
3706
3707    /// A
3708    /// required metadata field the schema does not auto-fill
3709    /// (`default_value` / `init_timestamp` / `auto_timestamp` all
3710    /// absent) now triggers `REQUIRED_FIELD_UNSET` refusal on the
3711    /// create path. Pre-fix this surfaced as a `MissingRequiredField`
3712    /// warning and the generator silently wrote placeholder values
3713    /// that the install-time strict validator could later refuse,
3714    /// breaking the export-then-install round-trip.
3715    #[test]
3716    fn create_entity_refuses_unsupplied_no_default_required_field() {
3717        // The `planning.decision` schema declares `decided_on`
3718        // (Date, required, no default_value, no init_timestamp) and
3719        // `deciders` (String csv_array, required, no default).
3720        let tmp = TempDir::new().unwrap();
3721        let mut engine = engine_with_planning_schema(&tmp);
3722        let (actor, client) = cli_actor();
3723
3724        let mut args = CreateEntityArgs {
3725            anchors: Vec::new(),
3726            mem: "planning".to_string(),
3727            title: "Skip Postgres".to_string(),
3728            entity_type: "decision".to_string(),
3729            sections: IndexMap::from_iter([
3730                ("decision".to_string(), "Use SQLite locally.".to_string()),
3731                ("context".to_string(), "Single-user dev.".to_string()),
3732                ("consequences".to_string(), "Lose multi-writer.".to_string()),
3733            ]),
3734            metadata: IndexMap::new(),
3735            relations: Vec::new(),
3736            dry_run: false,
3737        };
3738
3739        // Real-write path: refuse on the first missing field
3740        // (declaration order).
3741        let err = engine
3742            .create_entity(args.clone(), actor, Some(&client), None)
3743            .unwrap_err();
3744        match err {
3745            EngineError::RequiredFieldUnset {
3746                field, entity_type, ..
3747            } => {
3748                assert!(
3749                    field == "decided_on" || field == "deciders",
3750                    "expected first missing field, got {field:?}"
3751                );
3752                assert_eq!(entity_type, "decision");
3753            }
3754            other => panic!("expected RequiredFieldUnset, got {other:?}"),
3755        }
3756
3757        // Dry-run path on the same shape (different title to avoid the
3758        // already-exists check). Must surface the same refusal — the
3759        // create dry-run is the agent's preview surface.
3760        args.title = "Different Title".to_string();
3761        args.dry_run = true;
3762        let dry_err = engine
3763            .create_entity(args, actor, Some(&client), None)
3764            .unwrap_err();
3765        assert!(
3766            matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
3767            "dry_run must surface the same refusal envelope, got {dry_err:?}"
3768        );
3769    }
3770
3771    /// A follow-up call with all required-no-default
3772    /// fields supplied succeeds. The refusal recovery is a single
3773    /// round-trip.
3774    #[test]
3775    fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
3776        let tmp = TempDir::new().unwrap();
3777        let mut engine = engine_with_planning_schema(&tmp);
3778        let (actor, client) = cli_actor();
3779
3780        let mut metadata = IndexMap::new();
3781        metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
3782        metadata.insert("deciders".to_string(), "alice, bob".to_string());
3783
3784        let outcome = engine
3785            .create_entity(
3786                CreateEntityArgs {
3787                    anchors: Vec::new(),
3788                    mem: "planning".to_string(),
3789                    title: "Complete Decision".to_string(),
3790                    entity_type: "decision".to_string(),
3791                    sections: IndexMap::from_iter([
3792                        ("decision".to_string(), "x".to_string()),
3793                        ("context".to_string(), "y".to_string()),
3794                        ("consequences".to_string(), "z".to_string()),
3795                    ]),
3796                    metadata,
3797                    relations: Vec::new(),
3798                    dry_run: false,
3799                },
3800                actor,
3801                Some(&client),
3802                None,
3803            )
3804            .expect("complete decision create succeeds");
3805        // No MissingRequiredField warnings on the success path —
3806        // refusal swallows the case before any warning could fire.
3807        let missing_field_warnings: Vec<&WarningHint> = outcome
3808            .warnings
3809            .iter()
3810            .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
3811            .collect();
3812        assert!(
3813            missing_field_warnings.is_empty(),
3814            "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
3815        );
3816    }
3817
3818    /// Item 02: `memstead_create.relations[]` runs the same target-id
3819    /// grammar gate as `memstead_relate`. Pre-fix the create path
3820    /// admitted malformed ids (auto-stub at `bad@chars$here`) even
3821    /// though `memstead_relate` rejected them.
3822    #[test]
3823    fn create_entity_rejects_inline_relation_with_malformed_target_id() {
3824        let tmp = TempDir::new().unwrap();
3825        let mem_dir = tmp.path().to_path_buf();
3826        let writer = FilesystemMemWriter::new(mem_dir.clone());
3827        let mut engine = Engine::from_mounts(vec![(
3828            folder_mount("specs", mem_dir),
3829            Box::new(writer) as Box<dyn MemBackend>,
3830        )])
3831        .unwrap();
3832        let (actor, client) = cli_actor();
3833
3834        let mut args = empty_create_args("specs", "Source");
3835        args.relations = vec![crate::ops::RelateArg {
3836            to: crate::EntityId("specs--bad target with spaces!!".to_string()),
3837            rel_type: "USES".to_string(),
3838            description: None,
3839        }];
3840        let err = engine
3841            .create_entity(args, actor, Some(&client), None)
3842            .unwrap_err();
3843        assert!(
3844            matches!(err, EngineError::InvalidEntityId { .. }),
3845            "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
3846        );
3847    }
3848
3849    /// Item 02: `memstead_create.relations[]` runs the same schema-shape
3850    /// gate as `memstead_relate`. The relate-path shape gate is already
3851    /// pinned by `memstead-mcp::tool_surface::INVALID_REL_SHAPE` and the
3852    /// schema-loader tests; the cross-path lock here exercises the
3853    /// `software` schema's `VIOLATES` rel-type, which declares
3854    /// `source_types: [incident]` — an inline create from a `spec`
3855    /// must trip the shape gate even though the rel-type itself is
3856    /// valid vocabulary.
3857    #[test]
3858    fn create_entity_rejects_inline_relation_with_shape_violation() {
3859        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
3860        let tmp = TempDir::new().unwrap();
3861        let mem_dir = tmp.path().to_path_buf();
3862        let writer = FilesystemMemWriter::new(mem_dir.clone());
3863        let mount = Mount {
3864            mem: "code".to_string(),
3865            schema: Some(memstead_schema::SchemaRef::new(
3866                "software",
3867                semver::Version::new(0, 1, 0),
3868            )),
3869            storage: MountStorage::Folder { path: mem_dir },
3870            capability: MountCapability::Write,
3871            lifecycle: MountLifecycle::Eager,
3872            cross_linkable: true,
3873            migration_target: None,
3874        };
3875        let mut engine =
3876            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3877        let (actor, client) = cli_actor();
3878
3879        // Seed an existing target so the shape gate evaluates the
3880        // real target type (not `None`, which the gate admits as the
3881        // stub-bound case). The `requirement` type requires `statement` +
3882        // `rationale` sections plus `verified_on` + `source` metadata
3883        // (the schema lists these without `default_value` or
3884        // `optional: true`, so the strict-on-create gate refuses
3885        // unless supplied).
3886        let target = engine
3887            .create_entity(
3888                CreateEntityArgs {
3889                    anchors: Vec::new(),
3890                    mem: "code".to_string(),
3891                    title: "Target Requirement".to_string(),
3892                    entity_type: "requirement".to_string(),
3893                    sections: IndexMap::from_iter([
3894                        ("statement".to_string(), "MUST hold.".to_string()),
3895                        ("rationale".to_string(), "Because tests.".to_string()),
3896                    ]),
3897                    metadata: IndexMap::from_iter([
3898                        ("verified_on".to_string(), "2026-05-19".to_string()),
3899                        ("source".to_string(), "test fixture".to_string()),
3900                    ]),
3901                    relations: Vec::new(),
3902                    dry_run: false,
3903                },
3904                actor,
3905                Some(&client),
3906                None,
3907            )
3908            .unwrap();
3909
3910        // `VIOLATES` declares `source_types: [incident]`. A `spec`
3911        // create with `VIOLATES` violates the shape. The `spec` type
3912        // in the software schema requires `identity` + `purpose`;
3913        // supply both so the shape gate (not the missing-sections
3914        // gate) is what fires.
3915        let args = CreateEntityArgs {
3916            anchors: Vec::new(),
3917            mem: "code".to_string(),
3918            title: "Misshape Source".to_string(),
3919            entity_type: "spec".to_string(),
3920            sections: IndexMap::from_iter([
3921                ("identity".to_string(), "this spec".to_string()),
3922                (
3923                    "purpose".to_string(),
3924                    "exercising the shape gate".to_string(),
3925                ),
3926            ]),
3927            metadata: IndexMap::new(),
3928            relations: vec![crate::ops::RelateArg {
3929                to: target.id.clone(),
3930                rel_type: "VIOLATES".to_string(),
3931                description: None,
3932            }],
3933            dry_run: false,
3934        };
3935        let err = engine
3936            .create_entity(args, actor, Some(&client), None)
3937            .unwrap_err();
3938        assert!(
3939            matches!(err, EngineError::Validation(_)),
3940            "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
3941        );
3942    }
3943
3944    #[test]
3945    fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
3946        // Wire-level contract: rel_type on inline relations is
3947        // case-insensitive. The engine stores the relationship as
3948        // UPPER_SNAKE_CASE regardless of input case.
3949        let tmp = TempDir::new().unwrap();
3950        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
3951        let (actor, client) = cli_actor();
3952
3953        let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
3954        args.relations = vec![crate::ops::RelateArg {
3955            to: existing.id.clone(),
3956            rel_type: "uses".to_string(),
3957            description: None,
3958        }];
3959
3960        let outcome = engine
3961            .create_entity(args, actor, Some(&client), None)
3962            .unwrap();
3963
3964        let source = engine
3965            .store()
3966            .get(&outcome.id)
3967            .expect("source must be in store");
3968        assert_eq!(source.relationships.len(), 1);
3969        assert_eq!(
3970            source.relationships[0].rel_type, "USES",
3971            "inline relation rel_type must be stored UPPER_SNAKE_CASE",
3972        );
3973    }
3974
3975    #[test]
3976    fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
3977        let tmp = TempDir::new().unwrap();
3978        let mem_dir = tmp.path().to_path_buf();
3979        let writer = FilesystemMemWriter::new(mem_dir.clone());
3980        let mut engine = Engine::from_mounts(vec![(
3981            folder_mount("specs", mem_dir.clone()),
3982            Box::new(writer) as Box<dyn MemBackend>,
3983        )])
3984        .unwrap();
3985        let (actor, client) = cli_actor();
3986
3987        let mut args = empty_create_args("specs", "Preview Only");
3988        args.dry_run = true;
3989
3990        let outcome = engine
3991            .create_entity(args, actor, Some(&client), None)
3992            .unwrap();
3993
3994        // Wire shape: content_hash = prospective hash; commit_sha empty.
3995        assert_eq!(outcome.id.to_string(), "specs--preview-only");
3996        assert!(
3997            !outcome.content_hash.is_empty(),
3998            "prospective hash populated"
3999        );
4000        assert!(outcome.commit_sha.is_empty(), "no commit on dry_run");
4001        // No store entry — the engine didn't push.
4002        assert!(
4003            engine.store().get(&outcome.id).is_none(),
4004            "dry_run must not mutate the store",
4005        );
4006        // No file on disk.
4007        assert!(
4008            !mem_dir.join("preview-only.md").exists(),
4009            "dry_run must not touch disk",
4010        );
4011        // No provenance line.
4012        let log = mem_dir.join(".memstead").join("changes.jsonl");
4013        assert!(
4014            !log.exists()
4015                || !std::fs::read_to_string(&log)
4016                    .unwrap()
4017                    .contains("preview-only"),
4018            "dry_run must not append provenance",
4019        );
4020    }
4021
4022    #[test]
4023    fn create_entity_rejects_read_only_mount_before_backend() {
4024        let tmp = TempDir::new().unwrap();
4025        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
4026        let mut engine = Engine::from_mounts(vec![(
4027            archive_mount("external", archive_path.clone()),
4028            Box::new(ArchiveBackend::new(archive_path)),
4029        )])
4030        .unwrap();
4031        let (actor, client) = cli_actor();
4032
4033        let err = engine
4034            .create_entity(
4035                empty_create_args("external", "Should Fail"),
4036                actor,
4037                Some(&client),
4038                None,
4039            )
4040            .unwrap_err();
4041        match err {
4042            EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
4043            other => panic!("expected ReadOnlyMount, got {other:?}"),
4044        }
4045        // Capability gating runs before the backend → the typed
4046        // BackendError::Sealed variant never surfaces here. That's
4047        // the intended ordering.
4048    }
4049
4050    #[test]
4051    fn create_entity_rejects_unknown_mem() {
4052        let tmp = TempDir::new().unwrap();
4053        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
4054        let mut engine = Engine::from_mounts(vec![(
4055            folder_mount("specs", tmp.path().to_path_buf()),
4056            Box::new(writer) as Box<dyn MemBackend>,
4057        )])
4058        .unwrap();
4059        let (actor, client) = cli_actor();
4060
4061        let err = engine
4062            .create_entity(
4063                empty_create_args("does-not-exist", "Anything"),
4064                actor,
4065                Some(&client),
4066                None,
4067            )
4068            .unwrap_err();
4069        assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
4070    }
4071
4072    #[test]
4073    fn create_entity_rejects_unknown_type_against_pinned_schema() {
4074        let tmp = TempDir::new().unwrap();
4075        let mem_dir = tmp.path().to_path_buf();
4076        let writer = FilesystemMemWriter::new(mem_dir.clone());
4077        let mut engine = Engine::from_mounts(vec![(
4078            folder_mount("specs", mem_dir),
4079            Box::new(writer) as Box<dyn MemBackend>,
4080        )])
4081        .unwrap();
4082        let (actor, client) = cli_actor();
4083
4084        let mut args = empty_create_args("specs", "Anything");
4085        args.entity_type = "definitely-not-a-real-type".to_string();
4086        let err = engine
4087            .create_entity(args, actor, Some(&client), None)
4088            .unwrap_err();
4089        match err {
4090            EngineError::UnknownType { name, declared, .. } => {
4091                assert_eq!(name, "definitely-not-a-real-type");
4092                assert!(!declared.is_empty(), "declared types must be listed");
4093            }
4094            other => panic!("expected UnknownType, got {other:?}"),
4095        }
4096    }
4097
4098    #[test]
4099    fn create_entity_rejects_duplicate_id() {
4100        let tmp = TempDir::new().unwrap();
4101        let mem_dir = tmp.path().to_path_buf();
4102        let writer = FilesystemMemWriter::new(mem_dir.clone());
4103        let mut engine = Engine::from_mounts(vec![(
4104            folder_mount("specs", mem_dir),
4105            Box::new(writer) as Box<dyn MemBackend>,
4106        )])
4107        .unwrap();
4108        let (actor, client) = cli_actor();
4109
4110        engine
4111            .create_entity(
4112                empty_create_args("specs", "Same Slug"),
4113                actor,
4114                Some(&client),
4115                None,
4116            )
4117            .unwrap();
4118        let err = engine
4119            .create_entity(
4120                empty_create_args("specs", "Same Slug"),
4121                actor,
4122                Some(&client),
4123                None,
4124            )
4125            .unwrap_err();
4126        match err {
4127            EngineError::AlreadyExists {
4128                id,
4129                existing_title,
4130                existing_is_stub,
4131            } => {
4132                assert_eq!(id, "specs--same-slug");
4133                // The refusal names the occupying title so the caller
4134                // sees which existing title derived the colliding slug.
4135                assert!(!existing_title.is_empty());
4136                assert!(!existing_is_stub);
4137            }
4138            other => panic!("expected AlreadyExists, got {other:?}"),
4139        }
4140    }
4141
4142    #[test]
4143    fn create_entity_rejects_invalid_title() {
4144        let tmp = TempDir::new().unwrap();
4145        let mem_dir = tmp.path().to_path_buf();
4146        let writer = FilesystemMemWriter::new(mem_dir.clone());
4147        let mut engine = Engine::from_mounts(vec![(
4148            folder_mount("specs", mem_dir),
4149            Box::new(writer) as Box<dyn MemBackend>,
4150        )])
4151        .unwrap();
4152        let (actor, client) = cli_actor();
4153
4154        // F4: empty/whitespace-only titles now refuse with
4155        // `INVALID_TITLE` / reason `empty`. The earlier hash-fallback
4156        // behaviour applies only to the loader path (pre-gate
4157        // entities); the strict mutation gate rejects so the
4158        // structured-content envelope can carry actionable details.
4159        let err = engine
4160            .create_entity(empty_create_args("specs", "  "), actor, Some(&client), None)
4161            .unwrap_err();
4162        match err {
4163            EngineError::InvalidTitle(slug_err) => {
4164                assert_eq!(slug_err.reason(), "empty", "expected empty reason");
4165            }
4166            other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
4167        }
4168
4169        // Widened grammar: char-drop titles land, with the divergence
4170        // reported as the typed warning naming the dropped characters
4171        // and the derived slug.
4172        let outcome = engine
4173            .create_entity(
4174                empty_create_args("specs", "Hello, World!"),
4175                actor,
4176                Some(&client),
4177                None,
4178            )
4179            .expect("char-drop title lands under the widened grammar");
4180        assert_eq!(outcome.id.as_ref(), "specs--hello-world");
4181        let dropped = outcome
4182            .warnings
4183            .iter()
4184            .find_map(|w| match w {
4185                WarningHint::TitleCharsDroppedFromSlug {
4186                    dropped_chars,
4187                    slug,
4188                    ..
4189                } => Some((dropped_chars.clone(), slug.clone())),
4190                _ => None,
4191            })
4192            .expect("divergence warning rides the outcome");
4193        assert!(dropped.0.contains(&',') && dropped.0.contains(&'!'));
4194        assert_eq!(dropped.1, "hello-world");
4195
4196        // Path-traversal-shaped titles are display text too — the
4197        // dropped `/` and `.` never reach the slug, so the id stays
4198        // sanitised (no traversal), and the divergence is reported.
4199        let outcome = engine
4200            .create_entity(
4201                empty_create_args("specs", "../etc/passwd"),
4202                actor,
4203                Some(&client),
4204                None,
4205            )
4206            .expect("traversal-shaped title lands with a sanitised slug");
4207        assert_eq!(outcome.id.as_ref(), "specs--etcpasswd");
4208        assert!(
4209            outcome
4210                .warnings
4211                .iter()
4212                .any(|w| matches!(w, WarningHint::TitleCharsDroppedFromSlug { .. }))
4213        );
4214    }
4215
4216    #[test]
4217    fn create_entity_rejects_unknown_section_key() {
4218        let tmp = TempDir::new().unwrap();
4219        let mem_dir = tmp.path().to_path_buf();
4220        let writer = FilesystemMemWriter::new(mem_dir.clone());
4221        let mut engine = Engine::from_mounts(vec![(
4222            folder_mount("specs", mem_dir),
4223            Box::new(writer) as Box<dyn MemBackend>,
4224        )])
4225        .unwrap();
4226        let (actor, client) = cli_actor();
4227
4228        let mut args = empty_create_args("specs", "Bad Sections");
4229        args.sections
4230            .insert("not-a-real-section-key".to_string(), "body".to_string());
4231        let err = engine
4232            .create_entity(args, actor, Some(&client), None)
4233            .unwrap_err();
4234        assert!(matches!(err, EngineError::Validation(_)));
4235    }
4236
4237    #[test]
4238    fn create_entity_persists_across_engine_restart() {
4239        let tmp = TempDir::new().unwrap();
4240        let mem_dir = tmp.path().to_path_buf();
4241        {
4242            let writer = FilesystemMemWriter::new(mem_dir.clone());
4243            let mut engine = Engine::from_mounts(vec![(
4244                folder_mount("specs", mem_dir.clone()),
4245                Box::new(writer) as Box<dyn MemBackend>,
4246            )])
4247            .unwrap();
4248            let (actor, client) = cli_actor();
4249            engine
4250                .create_entity(
4251                    empty_create_args("specs", "Survives Restart"),
4252                    actor,
4253                    Some(&client),
4254                    None,
4255                )
4256                .unwrap();
4257        }
4258        // New engine reading the same mem must see the entity.
4259        let writer2 = FilesystemMemWriter::new(mem_dir.clone());
4260        let engine2 = Engine::from_mounts(vec![(
4261            folder_mount("specs", mem_dir),
4262            Box::new(writer2) as Box<dyn MemBackend>,
4263        )])
4264        .unwrap();
4265        let entity = engine2
4266            .get_entity(&crate::EntityId::new("specs", "survives-restart"))
4267            .expect("entity must persist across engine restart");
4268        assert_eq!(entity.title, "Survives Restart");
4269    }
4270
4271    // ---- Engine::update_entity --------------------------------------
4272
4273    /// Build a folder-mount Engine with one freshly-created entity.
4274    /// Returns the engine + the created outcome so tests have the
4275    /// id and current hash to use as `expected_hash` for the next
4276    /// mutation.
4277    fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
4278        let mem_dir = tmp.path().to_path_buf();
4279        let writer = FilesystemMemWriter::new(mem_dir.clone());
4280        let mut engine = Engine::from_mounts(vec![(
4281            folder_mount("specs", mem_dir),
4282            Box::new(writer) as Box<dyn MemBackend>,
4283        )])
4284        .unwrap();
4285        let (actor, client) = cli_actor();
4286        let outcome = engine
4287            .create_entity(
4288                empty_create_args("specs", title),
4289                actor,
4290                Some(&client),
4291                None,
4292            )
4293            .unwrap();
4294        (engine, outcome)
4295    }
4296
4297    /// Create with
4298    /// a body wiki-link to a non-existent target emits
4299    /// `INLINE_WIKI_LINK_AUTO_STUBBED` with the stubbed target id in
4300    /// `details.stubs`. Pre-fix the warning never fired because the
4301    /// emission walked `parse_markdown(generated_markdown).inline_links`,
4302    /// which the parser-side coverage filter had already emptied for
4303    /// the alias-synthesised body link.
4304    #[test]
4305    fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
4306        let tmp = TempDir::new().unwrap();
4307        let mem_dir = tmp.path().to_path_buf();
4308        let writer = FilesystemMemWriter::new(mem_dir.clone());
4309        let mut engine = Engine::from_mounts(vec![(
4310            folder_mount("specs", mem_dir),
4311            Box::new(writer) as Box<dyn MemBackend>,
4312        )])
4313        .unwrap();
4314        let (actor, client) = cli_actor();
4315
4316        let ghost = crate::EntityId::new("specs", "ghost-target");
4317        assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
4318
4319        let mut args = empty_create_args("specs", "Source With Body Link");
4320        args.sections.insert(
4321            "identity".to_string(),
4322            "ref [[ghost-target]] for context".to_string(),
4323        );
4324
4325        let outcome = engine
4326            .create_entity(args, actor, Some(&client), None)
4327            .unwrap();
4328        let stubbed: Vec<&crate::EntityId> = outcome
4329            .warnings
4330            .iter()
4331            .filter_map(|w| match w {
4332                WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
4333                _ => None,
4334            })
4335            .flatten()
4336            .collect();
4337        assert!(
4338            stubbed.contains(&&ghost),
4339            "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
4340            outcome.warnings,
4341        );
4342        // The stub also lands in the store and the REFERENCES edge exists.
4343        assert!(
4344            engine.store().contains(&ghost),
4345            "ghost stub must materialise"
4346        );
4347    }
4348
4349    /// CLI F11: a body wiki-link to the entity's own slug is dropped (no
4350    /// vacuous self-edge) with a `SELF_LINK_IGNORED` warning, while a body
4351    /// link to a *different* target in the same entity still synthesises
4352    /// its REFERENCES edge normally — only the self-target is dropped.
4353    #[test]
4354    fn create_entity_drops_self_link_keeps_other_links_and_warns() {
4355        let tmp = TempDir::new().unwrap();
4356        let mem_dir = tmp.path().to_path_buf();
4357        let writer = FilesystemMemWriter::new(mem_dir.clone());
4358        let mut engine = Engine::from_mounts(vec![(
4359            folder_mount("specs", mem_dir),
4360            Box::new(writer) as Box<dyn MemBackend>,
4361        )])
4362        .unwrap();
4363        let (actor, client) = cli_actor();
4364
4365        // Title "Selfie" → slug "selfie" → id "specs--selfie". The body
4366        // links its own slug AND a different target.
4367        let mut args = empty_create_args("specs", "Selfie");
4368        args.sections.insert(
4369            "identity".to_string(),
4370            "see [[selfie]] itself and also [[other-ref]]".to_string(),
4371        );
4372        let outcome = engine
4373            .create_entity(args, actor, Some(&client), None)
4374            .unwrap();
4375        let self_id = outcome.id.clone();
4376        assert_eq!(self_id.to_string(), "specs--selfie");
4377        let other_id = crate::EntityId::new("specs", "other-ref");
4378
4379        // SELF_LINK_IGNORED warning names the self-linking entity.
4380        assert!(
4381            outcome.warnings.iter().any(|w| matches!(
4382                w, WarningHint::SelfLinkIgnored { id } if *id == self_id
4383            )),
4384            "self-link must emit SELF_LINK_IGNORED; got: {:?}",
4385            outcome.warnings,
4386        );
4387
4388        // No self-edge: not in relationships, not Outgoing, not Incoming.
4389        let ent = engine.get_entity(&self_id).unwrap();
4390        assert!(
4391            ent.relationships.iter().all(|r| r.target != self_id),
4392            "no self-relation may be synthesised; got: {:?}",
4393            ent.relationships,
4394        );
4395        assert!(
4396            engine
4397                .store()
4398                .outgoing(&self_id)
4399                .iter()
4400                .all(|e| e.target != self_id),
4401            "self must not be its own Outgoing neighbour",
4402        );
4403        assert!(
4404            engine
4405                .store()
4406                .incoming(&self_id)
4407                .iter()
4408                .all(|e| e.from != self_id),
4409            "self must not be its own Incoming neighbour",
4410        );
4411
4412        // Complement: the link to a *different* target synthesised its
4413        // REFERENCES edge normally.
4414        assert!(
4415            ent.relationships
4416                .iter()
4417                .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
4418            "non-self body link must still synthesise its edge; got: {:?}",
4419            ent.relationships,
4420        );
4421    }
4422
4423    /// dry_run preview matches real-write outcome.
4424    #[test]
4425    fn create_entity_dry_run_emits_same_auto_stub_warning() {
4426        let tmp = TempDir::new().unwrap();
4427        let mem_dir = tmp.path().to_path_buf();
4428        let writer = FilesystemMemWriter::new(mem_dir.clone());
4429        let mut engine = Engine::from_mounts(vec![(
4430            folder_mount("specs", mem_dir),
4431            Box::new(writer) as Box<dyn MemBackend>,
4432        )])
4433        .unwrap();
4434        let (actor, client) = cli_actor();
4435
4436        let mut args = empty_create_args("specs", "Dry Run Body Link");
4437        args.dry_run = true;
4438        args.sections
4439            .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
4440
4441        let outcome = engine
4442            .create_entity(args, actor, Some(&client), None)
4443            .unwrap();
4444        let has_warning = outcome.warnings.iter().any(|w| {
4445            matches!(
4446                w,
4447                WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
4448                    if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
4449            )
4450        });
4451        assert!(
4452            has_warning,
4453            "dry_run must emit the same warning as real write: {:?}",
4454            outcome.warnings
4455        );
4456    }
4457
4458    /// Body wiki-link to a target that already exists
4459    /// in the store does NOT fire the warning — no stub was created.
4460    #[test]
4461    fn create_entity_no_auto_stub_warning_when_target_exists() {
4462        let tmp = TempDir::new().unwrap();
4463        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
4464        let (actor, client) = cli_actor();
4465
4466        let mut args = empty_create_args("specs", "Source Linking Existing");
4467        let body = format!("ref [[{}]]", existing.id.path());
4468        args.sections.insert("identity".to_string(), body);
4469
4470        let outcome = engine
4471            .create_entity(args, actor, Some(&client), None)
4472            .unwrap();
4473        let has_warning = outcome
4474            .warnings
4475            .iter()
4476            .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
4477        assert!(
4478            !has_warning,
4479            "no auto-stub warning when target pre-exists; got: {:?}",
4480            outcome.warnings
4481        );
4482    }
4483
4484    /// Obligation-schema counterpart of the ingest wildcard
4485    /// (first-author-path plan 09, criterion 5): an obligation mem
4486    /// body-links into a NON-SOFTWARE user-schema destination; the
4487    /// wildcard alias grant admits the auto-emitted REFERENCES edge.
4488    #[test]
4489    fn obligation_wildcard_links_into_arbitrary_destination_schema() {
4490        use crate::engine::test_helpers::write_schema_files_with_default_type;
4491        use memstead_schema::workspace_config::CrossLinkValue;
4492
4493        let tmp = TempDir::new().unwrap();
4494        let dest_dir = tmp.path().join("dest");
4495        let duties_dir = tmp.path().join("duties");
4496        std::fs::create_dir_all(&dest_dir).unwrap();
4497        std::fs::create_dir_all(&duties_dir).unwrap();
4498        let schemas_dir = tmp.path().join("schemas");
4499        let user_manifest = r#"name: casefiles
4500version: 0.1.0
4501description: a user-written, non-software destination schema
4502when_to_use: tests
4503types:
4504  - doc
4505relationships:
4506  mode: strict
4507  definitions:
4508    - name: _default
4509      description: fallback
4510      default_weight: 1.0
4511community:
4512  resolution: 1.0
4513  seed: 42
4514"#;
4515        write_schema_files_with_default_type(
4516            &schemas_dir,
4517            "casefiles@0.1.0",
4518            user_manifest,
4519            &["doc"],
4520        );
4521
4522        let mount = |mem: &str, dir: &std::path::Path, schema: &str| crate::workspace::Mount {
4523            mem: mem.to_string(),
4524            schema: Some(memstead_schema::SchemaRef::new(
4525                schema,
4526                semver::Version::new(0, 1, 0),
4527            )),
4528            storage: crate::workspace::MountStorage::Folder {
4529                path: dir.to_path_buf(),
4530            },
4531            capability: crate::workspace::MountCapability::Write,
4532            lifecycle: crate::workspace::MountLifecycle::Eager,
4533            cross_linkable: true,
4534            migration_target: None,
4535        };
4536        let mounts = vec![
4537            (
4538                mount("dest", &dest_dir, "casefiles"),
4539                Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
4540            ),
4541            (
4542                mount("duties", &duties_dir, "obligation"),
4543                Box::new(FilesystemMemWriter::new(duties_dir.clone())) as Box<dyn MemBackend>,
4544            ),
4545        ];
4546        let mut engine = Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
4547            .expect("obligation + user schema boot");
4548        let mut settings = crate::workspace::WorkspaceSettings::default();
4549        settings.cross_mem_links.insert(
4550            "duties".to_string(),
4551            CrossLinkValue::List(vec!["dest".to_string()]),
4552        );
4553        engine.set_settings(settings);
4554        let (actor, client) = cli_actor();
4555
4556        let target = engine
4557            .create_entity(
4558                CreateEntityArgs {
4559                    anchors: Vec::new(),
4560                    mem: "dest".to_string(),
4561                    title: "Case File 17".to_string(),
4562                    entity_type: "doc".to_string(),
4563                    sections: IndexMap::from_iter([(
4564                        "body".to_string(),
4565                        "destination content".to_string(),
4566                    )]),
4567                    metadata: IndexMap::new(),
4568                    relations: Vec::new(),
4569                    dry_run: false,
4570                },
4571                actor,
4572                Some(&client),
4573                None,
4574            )
4575            .unwrap();
4576
4577        let entry = engine
4578            .create_entity(
4579                CreateEntityArgs {
4580                    anchors: Vec::new(),
4581                    mem: "duties".to_string(),
4582                    title: "File Annual Report & Notice".to_string(),
4583                    entity_type: "obligation".to_string(),
4584                    sections: IndexMap::from_iter([
4585                        (
4586                            "duty".to_string(),
4587                            "File the report cited in [[dest--case-file-17]].".to_string(),
4588                        ),
4589                        (
4590                            "consequence".to_string(),
4591                            "Standing lapses at the deadline.".to_string(),
4592                        ),
4593                    ]),
4594                    metadata: IndexMap::from_iter([
4595                        ("due_date".to_string(), "2026-12-31".to_string()),
4596                        ("status".to_string(), "open".to_string()),
4597                    ]),
4598                    relations: vec![crate::ops::RelateArg {
4599                        to: crate::entity::EntityId::new("duties", "subject"),
4600                        rel_type: "CONCERNS".to_string(),
4601                        description: None,
4602                    }],
4603                    dry_run: false,
4604                },
4605                actor,
4606                Some(&client),
4607                None,
4608            )
4609            .expect("wildcard admits the alias link into the non-software destination");
4610        let stored = engine.get_entity(&entry.id).unwrap();
4611        assert!(
4612            stored
4613                .relationships
4614                .iter()
4615                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4616            "alias REFERENCES edge must emit cross-mem: {:?}",
4617            stored.relationships
4618        );
4619    }
4620
4621    /// Plan 11 end-to-end: an `ingest`-schema process mem body-links
4622    /// into a destination pinning an ARBITRARY user-written schema.
4623    /// The wildcard (bound to `alias_target_rel_type: REFERENCES`)
4624    /// admits the auto-emitted alias edge; the edge survives a fresh
4625    /// boot (the load path routes through the same matcher); explicit
4626    /// authoring of the alias type still refuses
4627    /// RELATION_MANUAL_AUTHORING_FORBIDDEN; a structural rel-type into
4628    /// the undeclared destination still refuses
4629    /// CROSS_MEM_EDGE_NOT_DECLARED; and the workspace policy gate
4630    /// still fires when the direction is not granted.
4631    #[test]
4632    fn ingest_wildcard_links_into_arbitrary_destination_schema() {
4633        use crate::engine::test_helpers::write_schema_files_with_default_type;
4634        use memstead_schema::workspace_config::CrossLinkValue;
4635
4636        let tmp = TempDir::new().unwrap();
4637        let dest_dir = tmp.path().join("dest");
4638        let proc_dir = tmp.path().join("proc");
4639        std::fs::create_dir_all(&dest_dir).unwrap();
4640        std::fs::create_dir_all(&proc_dir).unwrap();
4641
4642        // A user-written schema the engine has never shipped.
4643        let schemas_dir = tmp.path().join("schemas");
4644        let user_manifest = r#"name: debate
4645version: 0.1.0
4646description: a user-written destination schema
4647when_to_use: tests
4648types:
4649  - doc
4650relationships:
4651  mode: strict
4652  definitions:
4653    - name: _default
4654      description: fallback
4655      default_weight: 1.0
4656community:
4657  resolution: 1.0
4658  seed: 42
4659"#;
4660        write_schema_files_with_default_type(&schemas_dir, "debate@0.1.0", user_manifest, &["doc"]);
4661
4662        let mount = |mem: &str, dir: &std::path::Path, schema: &str, version: (u64, u64, u64)| {
4663            crate::workspace::Mount {
4664                mem: mem.to_string(),
4665                schema: Some(memstead_schema::SchemaRef::new(
4666                    schema,
4667                    semver::Version::new(version.0, version.1, version.2),
4668                )),
4669                storage: crate::workspace::MountStorage::Folder {
4670                    path: dir.to_path_buf(),
4671                },
4672                capability: crate::workspace::MountCapability::Write,
4673                lifecycle: crate::workspace::MountLifecycle::Eager,
4674                cross_linkable: true,
4675                migration_target: None,
4676            }
4677        };
4678        let boot = |grant: bool| -> Engine {
4679            let mounts = vec![
4680                (
4681                    mount("dest", &dest_dir, "debate", (0, 1, 0)),
4682                    Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
4683                ),
4684                (
4685                    mount("proc", &proc_dir, "ingest", (0, 2, 0)),
4686                    Box::new(FilesystemMemWriter::new(proc_dir.clone())) as Box<dyn MemBackend>,
4687                ),
4688            ];
4689            let mut engine =
4690                Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
4691                    .expect("ingest + user schema boot");
4692            let mut settings = crate::workspace::WorkspaceSettings::default();
4693            if grant {
4694                settings.cross_mem_links.insert(
4695                    "proc".to_string(),
4696                    CrossLinkValue::List(vec!["dest".to_string()]),
4697                );
4698            }
4699            engine.set_settings(settings);
4700            engine
4701        };
4702        let (actor, client) = cli_actor();
4703
4704        let mut engine = boot(true);
4705        // Destination entity in the user-schema mem.
4706        let target = engine
4707            .create_entity(
4708                CreateEntityArgs {
4709                    anchors: Vec::new(),
4710                    mem: "dest".to_string(),
4711                    title: "Target Doc".to_string(),
4712                    entity_type: "doc".to_string(),
4713                    sections: IndexMap::from_iter([(
4714                        "body".to_string(),
4715                        "destination content".to_string(),
4716                    )]),
4717                    metadata: IndexMap::new(),
4718                    relations: Vec::new(),
4719                    dry_run: false,
4720                },
4721                actor,
4722                Some(&client),
4723                None,
4724            )
4725            .unwrap();
4726
4727        // Process-mem entry body-linking the destination entity.
4728        let entry = engine
4729            .create_entity(
4730                CreateEntityArgs {
4731                    anchors: Vec::new(),
4732                    mem: "proc".to_string(),
4733                    title: "Check The Claim".to_string(),
4734                    entity_type: "verification_target".to_string(),
4735                    sections: IndexMap::from_iter([
4736                        (
4737                            "claim".to_string(),
4738                            "the claim under suspicion lives in [[dest--target-doc]]".to_string(),
4739                        ),
4740                        ("source_to_check".to_string(), "dest mem".to_string()),
4741                        (
4742                            "verifiable_when".to_string(),
4743                            "the linked entity still says so".to_string(),
4744                        ),
4745                    ]),
4746                    metadata: IndexMap::new(),
4747                    relations: Vec::new(),
4748                    dry_run: false,
4749                },
4750                actor,
4751                Some(&client),
4752                None,
4753            )
4754            .expect("wildcard admits the alias link into the user-schema destination");
4755        let stored = engine.get_entity(&entry.id).unwrap();
4756        assert!(
4757            stored
4758                .relationships
4759                .iter()
4760                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4761            "alias REFERENCES edge must emit: {:?}",
4762            stored.relationships
4763        );
4764
4765        // Explicit authoring of the alias rel-type: still forbidden.
4766        let err = engine
4767            .relate_entity(
4768                RelateEntityArgs {
4769                    source: entry.id.clone(),
4770                    expected_hash: None,
4771                    rel_type: "REFERENCES".to_string(),
4772                    target: target.id.clone(),
4773                    remove: false,
4774                    description: None,
4775                    dry_run: false,
4776                },
4777                actor,
4778                Some(&client),
4779                None,
4780            )
4781            .unwrap_err();
4782        assert_eq!(err.code(), "RELATION_MANUAL_AUTHORING_FORBIDDEN", "{err:?}");
4783
4784        // Structural rel-type into the undeclared destination: the
4785        // historical refusal, wildcard notwithstanding.
4786        let err = engine
4787            .relate_entity(
4788                RelateEntityArgs {
4789                    source: entry.id.clone(),
4790                    expected_hash: None,
4791                    rel_type: "PART_OF".to_string(),
4792                    target: target.id.clone(),
4793                    remove: false,
4794                    description: None,
4795                    dry_run: false,
4796                },
4797                actor,
4798                Some(&client),
4799                None,
4800            )
4801            .unwrap_err();
4802        assert_eq!(err.code(), "CROSS_MEM_EDGE_NOT_DECLARED", "{err:?}");
4803
4804        // Load-path survival: a FRESH boot over the same folders (the
4805        // store-builder path that previously dropped undeclared
4806        // cross-mem edges) keeps the alias edge.
4807        drop(engine);
4808        let rebooted = boot(true);
4809        let reloaded = rebooted.get_entity(&entry.id).unwrap();
4810        assert!(
4811            reloaded
4812                .relationships
4813                .iter()
4814                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4815            "alias edge must survive reload: {:?}",
4816            reloaded.relationships
4817        );
4818
4819        // Policy gate intact: without the grant, the same wildcarded
4820        // link refuses CROSS_MEM_LINK_NOT_ALLOWED.
4821        let mut denied = boot(false);
4822        let err = denied
4823            .create_entity(
4824                CreateEntityArgs {
4825                    anchors: Vec::new(),
4826                    mem: "proc".to_string(),
4827                    title: "Denied Entry".to_string(),
4828                    entity_type: "verification_target".to_string(),
4829                    sections: IndexMap::from_iter([
4830                        (
4831                            "claim".to_string(),
4832                            "points at [[dest--target-doc]]".to_string(),
4833                        ),
4834                        ("source_to_check".to_string(), "dest mem".to_string()),
4835                        ("verifiable_when".to_string(), "never".to_string()),
4836                    ]),
4837                    metadata: IndexMap::new(),
4838                    relations: Vec::new(),
4839                    dry_run: false,
4840                },
4841                actor,
4842                Some(&client),
4843                None,
4844            )
4845            .unwrap_err();
4846        assert_eq!(err.code(), "CROSS_MEM_LINK_NOT_ALLOWED", "{err:?}");
4847    }
4848
4849    /// Two-mem Write-Write scaffold —
4850    /// `test` and `other` both pin the default schema, no
4851    /// `cross_mem_links` policy set yet (default deny-all). The
4852    /// caller installs the policy that matches each scenario.
4853    fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
4854        let tmp_test = TempDir::new().unwrap();
4855        let tmp_other = TempDir::new().unwrap();
4856        let test_dir = tmp_test.path().to_path_buf();
4857        let other_dir = tmp_other.path().to_path_buf();
4858        let writer_test = FilesystemMemWriter::new(test_dir.clone());
4859        let writer_other = FilesystemMemWriter::new(other_dir.clone());
4860        let engine = Engine::from_mounts(vec![
4861            (
4862                folder_mount("test", test_dir),
4863                Box::new(writer_test) as Box<dyn MemBackend>,
4864            ),
4865            (
4866                folder_mount("other", other_dir),
4867                Box::new(writer_other) as Box<dyn MemBackend>,
4868            ),
4869        ])
4870        .unwrap();
4871        (tmp_test, tmp_other, engine)
4872    }
4873
4874    /// `memstead_create` with an inline cross-mem relation refuses
4875    /// with `CROSS_MEM_LINK_NOT_ALLOWED` when policy denies the
4876    /// direction. The entity does not persist; the would-be id reads
4877    /// as `NotFound`.
4878    #[test]
4879    fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
4880        use crate::entity::EntityId;
4881        use crate::ops::RelateArg;
4882        use memstead_schema::workspace_config::CrossLinkValue;
4883
4884        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4885        let (actor, client) = cli_actor();
4886
4887        // Policy: `test → other` granted only. The inline create
4888        // request below is `other → test`, which must refuse.
4889        let mut settings = crate::workspace::WorkspaceSettings::default();
4890        settings.cross_mem_links.insert(
4891            "test".to_string(),
4892            CrossLinkValue::List(vec!["other".to_string()]),
4893        );
4894        engine.set_settings(settings);
4895
4896        // Seed a target in the `test` mem so the inline relation
4897        // names a real id (the policy gate fires before target
4898        // resolution regardless, but a real target removes any
4899        // ambiguity from the assertion).
4900        let target = engine
4901            .create_entity(
4902                empty_create_args("test", "Target"),
4903                actor,
4904                Some(&client),
4905                None,
4906            )
4907            .unwrap();
4908
4909        let mut args = empty_create_args("other", "Source");
4910        args.relations = vec![RelateArg {
4911            rel_type: "IMPLEMENTS".to_string(),
4912            to: target.id.clone(),
4913            description: None,
4914        }];
4915        let err = engine
4916            .create_entity(args, actor, Some(&client), None)
4917            .unwrap_err();
4918        match err {
4919            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
4920                assert_eq!(from_mem, "other");
4921                assert_eq!(to_mem, "test");
4922            }
4923            other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
4924        }
4925
4926        // No entity landed: the would-be id is absent.
4927        let would_be = EntityId::new("other", "source");
4928        assert!(
4929            engine.get_entity(&would_be).is_none(),
4930            "entity must not persist when inline relation refuses"
4931        );
4932    }
4933
4934    /// With the granted direction, the
4935    /// inline cross-mem relation succeeds and the edge persists.
4936    #[test]
4937    fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
4938        use crate::ops::RelateArg;
4939        use memstead_schema::workspace_config::CrossLinkValue;
4940
4941        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4942        let (actor, client) = cli_actor();
4943
4944        let mut settings = crate::workspace::WorkspaceSettings::default();
4945        settings.cross_mem_links.insert(
4946            "other".to_string(),
4947            CrossLinkValue::List(vec!["test".to_string()]),
4948        );
4949        engine.set_settings(settings);
4950
4951        let target = engine
4952            .create_entity(
4953                empty_create_args("test", "Target"),
4954                actor,
4955                Some(&client),
4956                None,
4957            )
4958            .unwrap();
4959
4960        let mut args = empty_create_args("other", "Source");
4961        args.relations = vec![RelateArg {
4962            rel_type: "IMPLEMENTS".to_string(),
4963            to: target.id.clone(),
4964            description: None,
4965        }];
4966        let outcome = engine
4967            .create_entity(args, actor, Some(&client), None)
4968            .unwrap();
4969        let stored = engine.get_entity(&outcome.id).expect("entity persists");
4970        assert!(
4971            stored
4972                .relationships
4973                .iter()
4974                .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
4975            "IMPLEMENTS edge must persist on the source's relationships",
4976        );
4977    }
4978
4979    /// A same-mem inline relation
4980    /// bypasses the policy gate entirely. Even with an empty policy
4981    /// (default deny-all for cross-mem), the create succeeds.
4982    #[test]
4983    fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
4984        use crate::ops::RelateArg;
4985
4986        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4987        let (actor, client) = cli_actor();
4988        // No cross_mem_links set; same-mem writes must still work.
4989
4990        let target = engine
4991            .create_entity(
4992                empty_create_args("test", "Target"),
4993                actor,
4994                Some(&client),
4995                None,
4996            )
4997            .unwrap();
4998        let mut args = empty_create_args("test", "Source");
4999        args.relations = vec![RelateArg {
5000            rel_type: "USES".to_string(),
5001            to: target.id.clone(),
5002            description: None,
5003        }];
5004        let outcome = engine
5005            .create_entity(args, actor, Some(&client), None)
5006            .unwrap();
5007        let stored = engine.get_entity(&outcome.id).expect("entity persists");
5008        assert!(
5009            stored
5010                .relationships
5011                .iter()
5012                .any(|r| r.rel_type == "USES" && r.target == target.id),
5013            "same-mem USES edge must persist",
5014        );
5015    }
5016
5017    /// The existing `memstead_relate` path
5018    /// refuses the same scenario with the same typed code and
5019    /// payload shape — the two surfaces' refusals are
5020    /// indistinguishable to an agent.
5021    #[test]
5022    fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
5023        use crate::ops::RelateArg;
5024        use memstead_schema::workspace_config::CrossLinkValue;
5025
5026        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5027        let (actor, client) = cli_actor();
5028
5029        let mut settings = crate::workspace::WorkspaceSettings::default();
5030        settings.cross_mem_links.insert(
5031            "test".to_string(),
5032            CrossLinkValue::List(vec!["other".to_string()]),
5033        );
5034        engine.set_settings(settings);
5035
5036        let target = engine
5037            .create_entity(
5038                empty_create_args("test", "Target"),
5039                actor,
5040                Some(&client),
5041                None,
5042            )
5043            .unwrap();
5044        let src = engine
5045            .create_entity(
5046                empty_create_args("other", "Source"),
5047                actor,
5048                Some(&client),
5049                None,
5050            )
5051            .unwrap();
5052
5053        // memstead_relate refusal.
5054        let relate_err = engine
5055            .relate_entity(
5056                RelateEntityArgs {
5057                    source: src.id.clone(),
5058                    rel_type: "IMPLEMENTS".to_string(),
5059                    target: target.id.clone(),
5060                    expected_hash: Some(src.content_hash.clone()),
5061                    remove: false,
5062                    description: None,
5063                    dry_run: false,
5064                },
5065                actor,
5066                Some(&client),
5067                None,
5068            )
5069            .unwrap_err();
5070
5071        // memstead_create.relations[] refusal — fresh title so the create
5072        // attempt hasn't already landed.
5073        let mut create_args = empty_create_args("other", "Source Two");
5074        create_args.relations = vec![RelateArg {
5075            rel_type: "IMPLEMENTS".to_string(),
5076            to: target.id.clone(),
5077            description: None,
5078        }];
5079        let create_err = engine
5080            .create_entity(create_args, actor, Some(&client), None)
5081            .unwrap_err();
5082
5083        // Both refusals share the typed code, the payload shape, and
5084        // the (from_mem, to_mem) values.
5085        match (relate_err, create_err) {
5086            (
5087                EngineError::CrossMemLinkNotAllowed {
5088                    from_mem: rfv,
5089                    to_mem: rtv,
5090                },
5091                EngineError::CrossMemLinkNotAllowed {
5092                    from_mem: cfv,
5093                    to_mem: ctv,
5094                },
5095            ) => {
5096                assert_eq!(rfv, "other");
5097                assert_eq!(rtv, "test");
5098                assert_eq!(cfv, "other");
5099                assert_eq!(ctv, "test");
5100            }
5101            (a, b) => panic!(
5102                "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
5103            ),
5104        }
5105    }
5106
5107    /// Body wiki-link `[[other--target]]` in mem `test` (with
5108    /// `test → other` granted) creates the entity, auto-stubs at
5109    /// `other--target` (NOT `test--other--target` — that was the
5110    /// pre-fix phantom-stub bug), and emits one REFERENCES edge via
5111    /// the alias-synthesis path.
5112    #[test]
5113    fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
5114        use crate::entity::EntityId;
5115        use indexmap::IndexMap;
5116        use memstead_schema::workspace_config::CrossLinkValue;
5117
5118        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5119        let (actor, client) = cli_actor();
5120
5121        let mut settings = crate::workspace::WorkspaceSettings::default();
5122        settings.cross_mem_links.insert(
5123            "test".to_string(),
5124            CrossLinkValue::List(vec!["other".to_string()]),
5125        );
5126        engine.set_settings(settings);
5127
5128        let mut sections: IndexMap<String, String> = IndexMap::new();
5129        sections.insert(
5130            "identity".to_string(),
5131            "see [[other--target]] for details".to_string(),
5132        );
5133        sections.insert("purpose".to_string(), "source purpose".to_string());
5134        let outcome = engine
5135            .create_entity(
5136                crate::engine::CreateEntityArgs {
5137                    anchors: Vec::new(),
5138                    mem: "test".to_string(),
5139                    title: "Source".to_string(),
5140                    entity_type: "spec".to_string(),
5141                    sections,
5142                    metadata: IndexMap::new(),
5143                    relations: Vec::new(),
5144                    dry_run: false,
5145                },
5146                actor,
5147                Some(&client),
5148                None,
5149            )
5150            .unwrap();
5151
5152        // Auto-stub landed at `other--target`, NOT `test--other--target`.
5153        let canonical = EntityId::new("other", "target");
5154        assert!(
5155            engine.get_entity(&canonical).is_some(),
5156            "auto-stub must land at the canonical cross-mem id"
5157        );
5158        let phantom = EntityId::new("test", "other--target");
5159        assert!(
5160            engine.get_entity(&phantom).is_none(),
5161            "no double-prefixed phantom stub"
5162        );
5163
5164        // Exactly one REFERENCES edge to the cross-mem target.
5165        let source = engine.get_entity(&outcome.id).unwrap();
5166        let references_count = source
5167            .relationships
5168            .iter()
5169            .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
5170            .count();
5171        assert_eq!(
5172            references_count, 1,
5173            "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
5174        );
5175    }
5176
5177    /// Complement: body wiki-link cross-mem refusal when policy
5178    /// denies the direction. The auto-stub never lands, the entity
5179    /// never persists.
5180    #[test]
5181    fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
5182        use indexmap::IndexMap;
5183
5184        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5185        let (actor, client) = cli_actor();
5186        // Empty cross-link policy — `test → other` denied.
5187
5188        let mut sections: IndexMap<String, String> = IndexMap::new();
5189        sections.insert(
5190            "identity".to_string(),
5191            "see [[other--target]] for details".to_string(),
5192        );
5193        sections.insert("purpose".to_string(), "source purpose".to_string());
5194        let err = engine
5195            .create_entity(
5196                crate::engine::CreateEntityArgs {
5197                    anchors: Vec::new(),
5198                    mem: "test".to_string(),
5199                    title: "Source".to_string(),
5200                    entity_type: "spec".to_string(),
5201                    sections,
5202                    metadata: IndexMap::new(),
5203                    relations: Vec::new(),
5204                    dry_run: false,
5205                },
5206                actor,
5207                Some(&client),
5208                None,
5209            )
5210            .unwrap_err();
5211        match err {
5212            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
5213                assert_eq!(from_mem, "test");
5214                assert_eq!(to_mem, "other");
5215            }
5216            other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
5217        }
5218    }
5219
5220    /// `[mutations].require_notes = true` drives a single `NOTE_MISSING`
5221    /// warning out of the engine mutation pipeline on every noteless
5222    /// mutation — the single enforcement point both the CLI and the MCP
5223    /// transport inherit. The mutation still commits (the policy nudges,
5224    /// it never blocks). Supplying a note suppresses it; turning the
5225    /// policy off silences it entirely. Covers create / update / relate
5226    /// in one engine instance.
5227    #[test]
5228    fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
5229        use crate::engine::UpdateEntityArgs;
5230        use crate::workspace::{MutationsSection, WorkspaceSettings};
5231        use indexmap::IndexMap;
5232
5233        let tmp = TempDir::new().unwrap();
5234        let mem_dir = tmp.path().to_path_buf();
5235        let writer = FilesystemMemWriter::new(mem_dir.clone());
5236        let mut engine = Engine::from_mounts(vec![(
5237            folder_mount("specs", mem_dir.clone()),
5238            Box::new(writer) as Box<dyn MemBackend>,
5239        )])
5240        .unwrap();
5241        engine.set_workspace_root(mem_dir.clone());
5242        engine.set_settings(WorkspaceSettings {
5243            mutations: MutationsSection {
5244                require_notes: Some(true),
5245            },
5246            ..Default::default()
5247        });
5248        let (actor, client) = cli_actor();
5249
5250        let note_missing = |ws: &[WarningHint]| -> usize {
5251            ws.iter()
5252                .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
5253                .count()
5254        };
5255
5256        // --- create, no note: exactly one NOTE_MISSING, commit landed ---
5257        let created = engine
5258            .create_entity(
5259                empty_create_args("specs", "Noteless"),
5260                actor,
5261                Some(&client),
5262                None,
5263            )
5264            .unwrap();
5265        assert_eq!(
5266            note_missing(&created.warnings),
5267            1,
5268            "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
5269            created.warnings,
5270        );
5271        assert!(
5272            matches!(
5273                created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
5274                Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
5275            ),
5276            "the warning names the engine-level verb",
5277        );
5278        assert!(
5279            !created.commit_sha.is_empty(),
5280            "create still commits (nudge, not block)"
5281        );
5282
5283        // --- update, no note: NOTE_MISSING + commit landed ---
5284        let mut edit: IndexMap<String, String> = IndexMap::new();
5285        edit.insert("identity".to_string(), "revised".to_string());
5286        let updated = engine
5287            .update_entity(
5288                UpdateEntityArgs {
5289                    anchors: Vec::new(),
5290                    id: created.id.clone(),
5291                    expected_hash: Some(created.content_hash.clone()),
5292                    sections: edit,
5293                    append_sections: IndexMap::new(),
5294                    patch_sections: IndexMap::new(),
5295                    metadata: IndexMap::new(),
5296                    metadata_unset: Vec::new(),
5297                    declare_relations: Vec::new(),
5298                    dry_run: false,
5299                    relations_unset: Vec::new(),
5300                    anchors_unset: Vec::new(),
5301                },
5302                actor,
5303                Some(&client),
5304                None,
5305            )
5306            .unwrap();
5307        assert_eq!(
5308            note_missing(&updated.warnings),
5309            1,
5310            "update emits NOTE_MISSING"
5311        );
5312        assert!(!updated.commit_sha.is_empty(), "update still commits");
5313
5314        // --- relate, no note: NOTE_MISSING + commit landed ---
5315        let target = engine
5316            .create_entity(
5317                empty_create_args("specs", "Target"),
5318                actor,
5319                Some(&client),
5320                Some("seed"),
5321            )
5322            .unwrap();
5323        let related = engine
5324            .relate_entity(
5325                RelateEntityArgs {
5326                    source: updated.id.clone(),
5327                    expected_hash: Some(updated.content_hash.clone()),
5328                    rel_type: "USES".to_string(),
5329                    target: target.id.clone(),
5330                    remove: false,
5331                    description: None,
5332                    dry_run: false,
5333                },
5334                actor,
5335                Some(&client),
5336                None,
5337            )
5338            .unwrap();
5339        assert_eq!(
5340            note_missing(&related.warnings),
5341            1,
5342            "relate emits NOTE_MISSING"
5343        );
5344        assert!(!related.commit_sha.is_empty(), "relate still commits");
5345
5346        // --- with a note: suppressed ---
5347        let with_note = engine
5348            .create_entity(
5349                empty_create_args("specs", "Documented"),
5350                actor,
5351                Some(&client),
5352                Some("a real provenance note"),
5353            )
5354            .unwrap();
5355        assert_eq!(
5356            note_missing(&with_note.warnings),
5357            0,
5358            "a supplied note suppresses the warning",
5359        );
5360
5361        // --- policy off: silent even without a note ---
5362        engine.set_settings(WorkspaceSettings::default());
5363        let after_off = engine
5364            .create_entity(
5365                empty_create_args("specs", "Quiet"),
5366                actor,
5367                Some(&client),
5368                None,
5369            )
5370            .unwrap();
5371        assert_eq!(
5372            note_missing(&after_off.warnings),
5373            0,
5374            "no NOTE_MISSING when require_notes is unset",
5375        );
5376    }
5377
5378    // ---- E3a anchors: create/persist/reload/isolation ------------------
5379
5380    fn file_anchor(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
5381        crate::anchor::AnchorInput {
5382            artifact: Some(artifact.to_string()),
5383            grain: Some("file".to_string()),
5384            class: Some("anchored".to_string()),
5385            hash: Some(hash.to_string()),
5386            hash_stability: Some("stable".to_string()),
5387            ..Default::default()
5388        }
5389    }
5390
5391    fn folder_engine(mem: &str) -> (Engine, TempDir) {
5392        let tmp = TempDir::new().unwrap();
5393        let dir = tmp.path().to_path_buf();
5394        let writer = FilesystemMemWriter::new(dir.clone());
5395        let engine = Engine::from_mounts(vec![(
5396            folder_mount(mem, dir.clone()),
5397            Box::new(writer) as Box<dyn MemBackend>,
5398        )])
5399        .unwrap();
5400        (engine, tmp)
5401    }
5402
5403    #[test]
5404    fn create_with_anchors_persists_and_survives_reload() {
5405        let (mut engine, tmp) = folder_engine("specs");
5406        let dir = tmp.path().to_path_buf();
5407        let (actor, client) = cli_actor();
5408        let mut args = empty_create_args("specs", "Anchored Entity");
5409        args.anchors = vec![file_anchor("src/lib.rs", "h1")];
5410        engine
5411            .create_entity(args, actor, Some(&client), None)
5412            .unwrap();
5413
5414        let id = crate::EntityId::new("specs", "anchored-entity");
5415        let anchors = engine.entity_anchors(&id);
5416        assert_eq!(anchors.len(), 1);
5417        assert_eq!(anchors[0].artifact, "src/lib.rs");
5418        assert_eq!(
5419            anchors[0].class,
5420            crate::anchor::AnchorProvenanceClass::Anchored
5421        );
5422
5423        // Survives a fresh boot from the same on-disk mem.
5424        let writer = FilesystemMemWriter::new(dir.clone());
5425        let reloaded = Engine::from_mounts(vec![(
5426            folder_mount("specs", dir.clone()),
5427            Box::new(writer) as Box<dyn MemBackend>,
5428        )])
5429        .unwrap();
5430        assert_eq!(reloaded.entity_anchors(&id).len(), 1);
5431        // Reverse lookup finds it by artifact path.
5432        assert_eq!(reloaded.anchors_referencing_artifact("src/lib.rs").len(), 1);
5433    }
5434
5435    #[test]
5436    fn malformed_anchor_refuses_and_entity_not_written() {
5437        let (mut engine, tmp) = folder_engine("specs");
5438        let (actor, client) = cli_actor();
5439        let mut args = empty_create_args("specs", "Bad Anchor");
5440        args.anchors = vec![crate::anchor::AnchorInput {
5441            artifact: Some("x".into()),
5442            grain: Some("paragraph".into()), // unknown grain
5443            class: Some("anchored".into()),
5444            ..Default::default()
5445        }];
5446        let err = engine
5447            .create_entity(args, actor, Some(&client), None)
5448            .unwrap_err();
5449        assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
5450        // Entity was not written (refusal fires before the disk write).
5451        assert!(
5452            engine
5453                .get_entity(&crate::EntityId::new("specs", "bad-anchor"))
5454                .is_none()
5455        );
5456        assert!(!tmp.path().join("bad-anchor.md").exists());
5457    }
5458
5459    #[test]
5460    fn anchors_are_not_folded_into_content_hash() {
5461        // Two identical creates — one anchored, one not — produce the same
5462        // `_hash`: the anchors sidecar lives under `.memstead/` and never
5463        // enters content hashing.
5464        let (mut anchored, _t1) = folder_engine("specs");
5465        let (mut plain, _t2) = folder_engine("specs");
5466        let (actor, client) = cli_actor();
5467
5468        let mut a = empty_create_args("specs", "Same Title");
5469        a.anchors = vec![file_anchor("src/lib.rs", "h1")];
5470        let with = anchored
5471            .create_entity(a, actor, Some(&client), None)
5472            .unwrap();
5473
5474        let p = empty_create_args("specs", "Same Title");
5475        let without = plain.create_entity(p, actor, Some(&client), None).unwrap();
5476
5477        assert_eq!(
5478            with.content_hash, without.content_hash,
5479            "anchors must not change the entity content hash"
5480        );
5481    }
5482
5483    #[test]
5484    fn anchorless_create_writes_no_sidecar() {
5485        let (mut engine, _tmp) = folder_engine("specs");
5486        let (actor, client) = cli_actor();
5487        engine
5488            .create_entity(
5489                empty_create_args("specs", "No Anchors"),
5490                actor,
5491                Some(&client),
5492                None,
5493            )
5494            .unwrap();
5495        assert!(
5496            engine
5497                .entity_anchors(&crate::EntityId::new("specs", "no-anchors"))
5498                .is_empty()
5499        );
5500    }
5501
5502    // ---- reserved metadata keys on create --------------------------------
5503
5504    /// A create carrying a reserved identity/discriminator metadata key
5505    /// (`type` / `mem` / `id`) refuses with the same deliberate
5506    /// `READ_ONLY_FIELD` the update path uses — not the incidental
5507    /// `UNKNOWN_METADATA_FIELD` — and the entity is not written.
5508    /// Refusal complement: a create with only declared, non-reserved
5509    /// keys lands exactly as today (covered pervasively by every other
5510    /// create test; the explicit control below re-asserts it beside
5511    /// the refusals).
5512    #[test]
5513    fn create_refuses_reserved_metadata_keys_deliberately() {
5514        let (mut engine, _tmp) = folder_engine("specs");
5515        let (actor, client) = cli_actor();
5516        for reserved in ["type", "mem", "id"] {
5517            let mut args = empty_create_args("specs", "Smuggler");
5518            args.metadata
5519                .insert(reserved.to_string(), "bogus".to_string());
5520            let err = engine
5521                .create_entity(args, actor, Some(&client), None)
5522                .expect_err("reserved key must refuse on create");
5523            assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
5524            assert!(
5525                engine
5526                    .get_entity(&crate::EntityId::new("specs", "smuggler"))
5527                    .is_none(),
5528                "entity must not be written after the '{reserved}' refusal"
5529            );
5530        }
5531        // Control: the same create without the smuggled key lands.
5532        engine
5533            .create_entity(
5534                empty_create_args("specs", "Smuggler"),
5535                actor,
5536                Some(&client),
5537                None,
5538            )
5539            .expect("a clean create is untouched by the reserved-key gate");
5540    }
5541
5542    // ---- cycle family on the create paths --------------------------------
5543
5544    fn create_with_relation(mem: &str, title: &str, rel_type: &str, to: &str) -> CreateEntityArgs {
5545        let mut args = empty_create_args(mem, title);
5546        args.relations = vec![crate::ops::RelateArg {
5547            to: crate::EntityId(to.to_string()),
5548            rel_type: rel_type.to_string(),
5549            description: None,
5550        }];
5551        args
5552    }
5553
5554    /// `create.relations[]` runs the same cycle family as
5555    /// `memstead_relate`: an edge closing a cycle through a promoted
5556    /// stub refuses `RELATIONSHIP_CYCLE` (acyclic rel-type), a
5557    /// self-loop on a listed no-self-loop rel-type refuses
5558    /// identically, and —
5559    /// refusal complement — a non-cycle edge on the acyclic type lands
5560    /// exactly as today.
5561    #[test]
5562    fn create_relations_refuse_cycle_and_self_loop_like_relate() {
5563        let (mut engine, _tmp) = folder_engine("specs");
5564        let (actor, client) = cli_actor();
5565
5566        // A PART_OF→ghost auto-stubs `ghost` with an incoming edge.
5567        engine
5568            .create_entity(
5569                create_with_relation("specs", "Alpha", "PART_OF", "specs--ghost"),
5570                actor,
5571                Some(&client),
5572                None,
5573            )
5574            .unwrap();
5575
5576        // Promoting the stub with a back-edge closes alpha→ghost→alpha.
5577        let err = engine
5578            .create_entity(
5579                create_with_relation("specs", "Ghost", "PART_OF", "specs--alpha"),
5580                actor,
5581                Some(&client),
5582                None,
5583            )
5584            .expect_err("cycle-closing create.relations[] must refuse");
5585        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5586        // Recovery detail matches the relate path's shape.
5587        let details = err.details();
5588        assert_eq!(details["rel_type"], "PART_OF");
5589        assert!(details["existing_path"].is_array());
5590        assert!(
5591            engine
5592                .get_entity(&crate::EntityId::new("specs", "ghost"))
5593                .is_none_or(|e| e.stub),
5594            "the refused entity must not be written"
5595        );
5596
5597        // Self-loop on a listed no-self-loop rel-type (spec lists USES).
5598        let err = engine
5599            .create_entity(
5600                create_with_relation("specs", "Selfy", "USES", "specs--selfy"),
5601                actor,
5602                Some(&client),
5603                None,
5604            )
5605            .expect_err("self-loop create.relations[] must refuse");
5606        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5607
5608        // Refusal complement: a non-cycle edge on the acyclic type
5609        // lands (fresh chain link, no back-path).
5610        engine
5611            .create_entity(
5612                create_with_relation("specs", "Beta", "PART_OF", "specs--alpha"),
5613                actor,
5614                Some(&client),
5615                None,
5616            )
5617            .expect("a non-cycle PART_OF edge must land as today");
5618    }
5619
5620    /// An intra-batch cycle on an acyclic rel-type refuses the whole
5621    /// batch — the staged state IS the graph state the batch validates
5622    /// against. Refusal complement: an acyclic intra-batch chain lands.
5623    #[test]
5624    fn batch_create_refuses_intra_batch_cycle() {
5625        let (mut engine, _tmp) = folder_engine("specs");
5626        let (actor, client) = cli_actor();
5627
5628        let result = engine
5629            .batch_create(
5630                vec![
5631                    (
5632                        create_with_relation("specs", "Ping", "PART_OF", "specs--pong"),
5633                        None,
5634                    ),
5635                    (
5636                        create_with_relation("specs", "Pong", "PART_OF", "specs--ping"),
5637                        None,
5638                    ),
5639                ],
5640                actor,
5641                Some(&client),
5642                false,
5643            )
5644            .expect("batch returns a result envelope");
5645        assert!(!result.applied, "intra-batch cycle must refuse the batch");
5646        assert!(
5647            result.results.iter().any(|r| r
5648                .error
5649                .as_ref()
5650                .is_some_and(|e| e.code == "RELATIONSHIP_CYCLE")),
5651            "the refusal must carry RELATIONSHIP_CYCLE: {:?}",
5652            result.results
5653        );
5654        assert!(
5655            engine
5656                .get_entity(&crate::EntityId::new("specs", "ping"))
5657                .is_none(),
5658            "nothing lands from a refused batch"
5659        );
5660
5661        // Refusal complement: an acyclic intra-batch chain lands.
5662        let result = engine
5663            .batch_create(
5664                vec![
5665                    (
5666                        create_with_relation("specs", "Chain One", "PART_OF", "specs--chain-two"),
5667                        None,
5668                    ),
5669                    (empty_create_args("specs", "Chain Two"), None),
5670                ],
5671                actor,
5672                Some(&client),
5673                false,
5674            )
5675            .expect("acyclic batch lands");
5676        assert!(result.applied, "{:?}", result.results);
5677        assert_eq!(result.succeeded, 2);
5678    }
5679
5680    /// Refusal complement at depth: a deep-but-acyclic PART_OF chain
5681    /// past the cycle path cap is accepted on the create path — the cap
5682    /// bounds the *reported* path on refusal, never the legality of a
5683    /// long acyclic chain — and one closing edge at the far end still
5684    /// refuses.
5685    #[test]
5686    fn deep_acyclic_chain_near_path_cap_is_accepted() {
5687        let (mut engine, _tmp) = folder_engine("specs");
5688        let (actor, client) = cli_actor();
5689        let depth = crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP + 2;
5690
5691        // link-0 ← link-1 ← … each new entity PART_OF the previous.
5692        engine
5693            .create_entity(
5694                empty_create_args("specs", "Link 0"),
5695                actor,
5696                Some(&client),
5697                None,
5698            )
5699            .unwrap();
5700        for i in 1..depth {
5701            engine
5702                .create_entity(
5703                    create_with_relation(
5704                        "specs",
5705                        &format!("Link {i}"),
5706                        "PART_OF",
5707                        &format!("specs--link-{}", i - 1),
5708                    ),
5709                    actor,
5710                    Some(&client),
5711                    None,
5712                )
5713                .unwrap_or_else(|e| panic!("deep acyclic link {i} must land: {e:?}"));
5714        }
5715
5716        // Closing the loop end-to-end still refuses, with the reported
5717        // path truncated at the cap.
5718        let last = depth - 1;
5719        let err = engine
5720            .update_entity(
5721                {
5722                    let id = crate::EntityId::new("specs", "link-0");
5723                    let hash = engine.get_entity(&id).unwrap().content_hash.clone();
5724                    crate::engine::UpdateEntityArgs {
5725                        anchors: Vec::new(),
5726                        anchors_unset: Vec::new(),
5727                        id,
5728                        expected_hash: Some(hash),
5729                        sections: IndexMap::new(),
5730                        append_sections: IndexMap::new(),
5731                        patch_sections: IndexMap::new(),
5732                        metadata: IndexMap::new(),
5733                        metadata_unset: Vec::new(),
5734                        declare_relations: vec![crate::ops::RelateArg {
5735                            to: crate::EntityId::new("specs", &format!("link-{last}")),
5736                            rel_type: "PART_OF".to_string(),
5737                            description: None,
5738                        }],
5739                        dry_run: false,
5740                        relations_unset: Vec::new(),
5741                    }
5742                },
5743                actor,
5744                Some(&client),
5745                None,
5746            )
5747            .expect_err("closing the deep chain must refuse");
5748        assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
5749        let details = err.details();
5750        assert_eq!(details["path_truncated"], true);
5751        assert_eq!(
5752            details["existing_path"].as_array().unwrap().len(),
5753            crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP
5754        );
5755    }
5756
5757    const FORMAT_MANIFEST: &str = r#"name: formatproof
5758version: 0.1.0
5759description: section-format proof schema
5760when_to_use: format tests
5761types:
5762  - plan
5763relationships:
5764  mode: strict
5765  definitions:
5766    - name: PART_OF
5767      description: hier
5768      default_weight: 1.0
5769    - name: _default
5770      description: fallback
5771      default_weight: 1.0
5772community:
5773  resolution: 1.0
5774  seed: 42
5775"#;
5776
5777    const FORMAT_PLAN_TYPE: &str = r#"name: plan
5778description: a plan with formatted milestones
5779when_to_use: tests
5780sections:
5781  - key: body
5782    heading: Body
5783    required: true
5784    search_weight: 10.0
5785    catch_all: true
5786    write_rules: []
5787  - key: meilensteine
5788    heading: Meilensteine
5789    required: false
5790    search_weight: 5.0
5791    catch_all: false
5792    write_rules: []
5793    content: "(heading(3) list(bullet))+"
5794    item_pattern: '\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})'
5795    example: |
5796      ### Phase 1
5797      - **Kickoff** — 2026-09-01
5798  - key: notizen
5799    heading: Notizen
5800    required: false
5801    search_weight: 5.0
5802    catch_all: false
5803    write_rules: []
5804    content: "list(bullet)"
5805    format_severity: warn
5806metadata_fields: []
5807title_weight: 100.0
5808text_fields:
5809  - body
5810hierarchy_relationship: PART_OF
5811no_self_loop_relationships: []
5812updatable_fields:
5813  - title
5814  - body
5815  - meilensteine
5816  - notizen
5817health_required_fields:
5818  - body
5819staleness_threshold_days: 90
5820write_rules: []
5821"#;
5822
5823    fn format_engine(tmp: &TempDir) -> Engine {
5824        engine_with_proof_schema(
5825            tmp,
5826            "formatproof",
5827            FORMAT_MANIFEST,
5828            &[("plan", FORMAT_PLAN_TYPE)],
5829        )
5830    }
5831
5832    fn plan_create_args(
5833        title: &str,
5834        meilensteine: Option<&str>,
5835        notizen: Option<&str>,
5836    ) -> CreateEntityArgs {
5837        let mut sections = IndexMap::new();
5838        sections.insert("body".to_string(), "a plan body.".to_string());
5839        if let Some(m) = meilensteine {
5840            sections.insert("meilensteine".to_string(), m.to_string());
5841        }
5842        if let Some(n) = notizen {
5843            sections.insert("notizen".to_string(), n.to_string());
5844        }
5845        CreateEntityArgs {
5846            anchors: Vec::new(),
5847            mem: "proof".to_string(),
5848            title: title.to_string(),
5849            entity_type: "plan".to_string(),
5850            sections,
5851            metadata: IndexMap::new(),
5852            relations: vec![],
5853            dry_run: false,
5854        }
5855    }
5856
5857    /// Block-tier format enforcement on create: a nonconforming
5858    /// section refuses with the format code and the echoed example;
5859    /// the conforming write passes; a warn-tier section never refuses.
5860    #[test]
5861    fn create_enforces_declared_section_format() {
5862        let tmp = TempDir::new().unwrap();
5863        let mut engine = format_engine(&tmp);
5864        let (actor, client) = cli_actor();
5865
5866        let err = engine
5867            .create_entity(
5868                plan_create_args("Plan A", Some("### Phase 1\n\nprose statt liste\n"), None),
5869                actor,
5870                Some(&client),
5871                None,
5872            )
5873            .unwrap_err();
5874        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5875        let details = err.details();
5876        assert_eq!(details["section"], "meilensteine");
5877        assert!(
5878            details["example"].as_str().unwrap().contains("Kickoff"),
5879            "the conforming example is echoed: {details}"
5880        );
5881        assert_eq!(details["expected_next"][0], "list(bullet)");
5882
5883        // Item-pattern violation gets its own code.
5884        let err = engine
5885            .create_entity(
5886                plan_create_args("Plan B", Some("### Phase 1\n- kein format\n"), None),
5887                actor,
5888                Some(&client),
5889                None,
5890            )
5891            .unwrap_err();
5892        assert_eq!(err.code(), "SECTION_ITEM_PATTERN_MISMATCH");
5893
5894        // Conforming write passes.
5895        engine
5896            .create_entity(
5897                plan_create_args(
5898                    "Plan C",
5899                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5900                    None,
5901                ),
5902                actor,
5903                Some(&client),
5904                None,
5905            )
5906            .unwrap();
5907
5908        // Warn-tier section: nonconforming content commits.
5909        let outcome = engine
5910            .create_entity(
5911                plan_create_args(
5912                    "Plan D",
5913                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5914                    Some("kein listenpunkt\n"),
5915                ),
5916                actor,
5917                Some(&client),
5918                None,
5919            )
5920            .unwrap();
5921        assert!(!outcome.commit_sha.is_empty(), "warn tier never refuses");
5922
5923        // Absent-as-empty: omitting the block-tier section refuses
5924        // exactly like an explicit empty body — the generator renders
5925        // the empty heading either way, and write path and health
5926        // must agree about that on-disk state. `+` does not admit the
5927        // empty sequence, so the section is effectively required.
5928        let err = engine
5929            .create_entity(
5930                plan_create_args("Plan E", None, None),
5931                actor,
5932                Some(&client),
5933                None,
5934            )
5935            .unwrap_err();
5936        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5937    }
5938
5939    /// Composed-body rule on update: an append whose delta is
5940    /// harmless refuses when the COMPOSED body violates; the
5941    /// conforming replacement passes.
5942    #[test]
5943    fn update_judges_format_on_composed_body() {
5944        let tmp = TempDir::new().unwrap();
5945        let mut engine = format_engine(&tmp);
5946        let (actor, client) = cli_actor();
5947        let created = engine
5948            .create_entity(
5949                plan_create_args(
5950                    "Plan A",
5951                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5952                    None,
5953                ),
5954                actor,
5955                Some(&client),
5956                None,
5957            )
5958            .unwrap();
5959
5960        // Append a trailing paragraph: the delta alone is legal
5961        // markdown, the composed body no longer matches the shape.
5962        let current = engine.get_entity(&created.id).unwrap().content_hash.clone();
5963        let mut append = IndexMap::new();
5964        append.insert(
5965            "meilensteine".to_string(),
5966            "\n\nnachtrag als absatz\n".to_string(),
5967        );
5968        let err = engine
5969            .update_entity(
5970                crate::engine::UpdateEntityArgs {
5971                    anchors: Vec::new(),
5972                    id: created.id.clone(),
5973                    expected_hash: Some(current.clone()),
5974                    sections: IndexMap::new(),
5975                    append_sections: append,
5976                    patch_sections: IndexMap::new(),
5977                    metadata: IndexMap::new(),
5978                    metadata_unset: Vec::new(),
5979                    declare_relations: vec![],
5980                    dry_run: false,
5981                    relations_unset: Vec::new(),
5982                    anchors_unset: Vec::new(),
5983                },
5984                actor,
5985                Some(&client),
5986                None,
5987            )
5988            .unwrap_err();
5989        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5990
5991        // A conforming append (another phase) passes.
5992        let mut append = IndexMap::new();
5993        append.insert(
5994            "meilensteine".to_string(),
5995            "\n\n### Phase 2\n- **Go-Live** — 2026-10-01\n".to_string(),
5996        );
5997        engine
5998            .update_entity(
5999                crate::engine::UpdateEntityArgs {
6000                    anchors: Vec::new(),
6001                    id: created.id.clone(),
6002                    expected_hash: Some(current),
6003                    sections: IndexMap::new(),
6004                    append_sections: append,
6005                    patch_sections: IndexMap::new(),
6006                    metadata: IndexMap::new(),
6007                    metadata_unset: Vec::new(),
6008                    declare_relations: vec![],
6009                    dry_run: false,
6010                    relations_unset: Vec::new(),
6011                    anchors_unset: Vec::new(),
6012                },
6013                actor,
6014                Some(&client),
6015                None,
6016            )
6017            .unwrap();
6018    }
6019
6020    /// Reserved-heading extension (criterion 4): `^# ` now refuses in
6021    /// any section body, exactly like `^## ` — free-form sections
6022    /// included, via the byte-class line guard.
6023    #[test]
6024    fn embedded_h1_refuses_in_any_section() {
6025        let tmp = TempDir::new().unwrap();
6026        let mut engine = format_engine(&tmp);
6027        let (actor, client) = cli_actor();
6028        let mut args = plan_create_args(
6029            "Plan H",
6030            Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
6031            None,
6032        );
6033        args.sections.insert(
6034            "body".to_string(),
6035            "intro\n# Injected Title\ntail".to_string(),
6036        );
6037        let err = engine
6038            .create_entity(args, actor, Some(&client), None)
6039            .unwrap_err();
6040        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
6041    }
6042}