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        // Pin the mutation clock. The auto-stamp is second-resolution,
3539        // and the assertions below compare it against a separately
3540        // computed "now" — so an unpinned run fails whenever a second
3541        // ticks between the create and the comparison. That is a real
3542        // flake, not a theoretical one: it fired on a suite run that
3543        // straddled midnight. The engine's injectable clock exists for
3544        // exactly this, and pinning it here also makes the expected
3545        // string a constant rather than a second read of the wall clock.
3546        const FROZEN_SECS: u64 = 1_754_000_000;
3547        let frozen = std::time::UNIX_EPOCH + std::time::Duration::from_secs(FROZEN_SECS);
3548        engine.set_mutation_clock(std::sync::Arc::new(move || frozen));
3549        let (actor, client) = cli_actor();
3550
3551        // Caller supplies a past value for the init_timestamp field
3552        // and the auto_timestamp field. The engine ignores both on
3553        // create.
3554        let mut args = empty_create_args("specs", "Stamped Today");
3555        args.metadata
3556            .insert("created_date".to_string(), "2020-01-01".to_string());
3557        args.metadata
3558            .insert("last_modified".to_string(), "2020-01-01".to_string());
3559
3560        let outcome = engine
3561            .create_entity(args, actor, Some(&client), None)
3562            .unwrap();
3563
3564        // Both timestamps should reflect the engine's own clock, not
3565        // the caller's `2020-01-01`.
3566        let today = crate::engine::mutation::iso_from_system_time(frozen);
3567        assert_eq!(outcome.created_date, today);
3568        let entity = engine
3569            .get_entity(&outcome.id)
3570            .expect("entity must be in store after create");
3571        assert_eq!(
3572            entity
3573                .metadata
3574                .get("created_date")
3575                .and_then(|v| v.as_str())
3576                .unwrap_or_default(),
3577            today,
3578            "init_timestamp field must be engine-determined on create, not user-supplied"
3579        );
3580        assert_eq!(
3581            entity
3582                .metadata
3583                .get("last_modified")
3584                .and_then(|v| v.as_str())
3585                .unwrap_or_default(),
3586            today,
3587            "auto_timestamp field must be engine-determined on create, not user-supplied"
3588        );
3589
3590        // F13/F14: update rejects a user-supplied value for either
3591        // init_timestamp or auto_timestamp metadata fields with
3592        // `READ_ONLY_FIELD`. Test both fields in turn.
3593        let attempt_update = |key: &str, value: &str| {
3594            let mut metadata = IndexMap::new();
3595            metadata.insert(key.to_string(), value.to_string());
3596            crate::engine::UpdateEntityArgs {
3597                anchors: Vec::new(),
3598                id: outcome.id.clone(),
3599                metadata,
3600                metadata_unset: Vec::new(),
3601                sections: IndexMap::new(),
3602                append_sections: IndexMap::new(),
3603                patch_sections: IndexMap::new(),
3604                expected_hash: Some(outcome.content_hash.clone()),
3605                dry_run: false,
3606                declare_relations: Vec::new(),
3607                relations_unset: Vec::new(),
3608                anchors_unset: Vec::new(),
3609            }
3610        };
3611        for key in ["created_date", "last_modified"] {
3612            let err = engine
3613                .update_entity(
3614                    attempt_update(key, "2019-12-31"),
3615                    actor,
3616                    Some(&client),
3617                    None,
3618                )
3619                .expect_err("schema-managed timestamp must be rejected on update");
3620            assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
3621        }
3622        // Stored value is unchanged after a rejected attempt.
3623        let entity = engine
3624            .get_entity(&outcome.id)
3625            .expect("entity must remain in store after rejected update");
3626        assert_eq!(
3627            entity
3628                .metadata
3629                .get("last_modified")
3630                .and_then(|v| v.as_str())
3631                .unwrap_or_default(),
3632            today,
3633            "rejected update must not mutate the auto_timestamp field"
3634        );
3635    }
3636
3637    #[test]
3638    fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
3639        let tmp = TempDir::new().unwrap();
3640        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
3641        let (actor, client) = cli_actor();
3642        let absent = crate::EntityId::new("specs", "future-target");
3643        assert!(!engine.store().contains(&absent));
3644
3645        let mut args = empty_create_args("specs", "Source With Relations");
3646        args.relations = vec![
3647            crate::ops::RelateArg {
3648                to: existing.id.clone(),
3649                rel_type: "USES".to_string(),
3650                description: None,
3651            },
3652            crate::ops::RelateArg {
3653                to: absent.clone(),
3654                rel_type: "USES".to_string(),
3655                description: None,
3656            },
3657        ];
3658
3659        let outcome = engine
3660            .create_entity(args, actor, Some(&client), None)
3661            .unwrap();
3662
3663        // New entity in store with both edges materialised.
3664        let source = engine
3665            .store()
3666            .get(&outcome.id)
3667            .expect("source must be in store");
3668        assert_eq!(source.relationships.len(), 2);
3669        assert!(
3670            source
3671                .relationships
3672                .iter()
3673                .any(|r| r.target == existing.id && r.rel_type == "USES")
3674        );
3675        assert!(
3676            source
3677                .relationships
3678                .iter()
3679                .any(|r| r.target == absent && r.rel_type == "USES")
3680        );
3681
3682        // Absent target was auto-stubbed (mirrors the relate path's
3683        // ensure_target).
3684        let stub = engine
3685            .store()
3686            .get(&absent)
3687            .expect("absent relation target must be auto-stubbed");
3688        assert!(stub.stub);
3689        // Existing target unchanged.
3690        let existing_after = engine.store().get(&existing.id).unwrap();
3691        assert!(!existing_after.stub);
3692    }
3693
3694    /// Build a folder-mount engine pinned to the `planning` schema, so
3695    /// tests can exercise `decision` — a type with `decided_on` (Date,
3696    /// required, no default / no init_timestamp) — without inventing a
3697    /// synthetic schema.
3698    fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
3699        use crate::workspace::Mount;
3700        use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
3701        let mem_dir = tmp.path().to_path_buf();
3702        let writer = FilesystemMemWriter::new(mem_dir.clone());
3703        let mount = Mount {
3704            mem: "planning".to_string(),
3705            schema: Some(memstead_schema::SchemaRef::new(
3706                "planning",
3707                semver::Version::new(0, 1, 0),
3708            )),
3709            storage: MountStorage::Folder { path: mem_dir },
3710            capability: MountCapability::Write,
3711            lifecycle: MountLifecycle::Eager,
3712            cross_linkable: true,
3713            migration_target: None,
3714        };
3715        Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
3716    }
3717
3718    /// A
3719    /// required metadata field the schema does not auto-fill
3720    /// (`default_value` / `init_timestamp` / `auto_timestamp` all
3721    /// absent) now triggers `REQUIRED_FIELD_UNSET` refusal on the
3722    /// create path. Pre-fix this surfaced as a `MissingRequiredField`
3723    /// warning and the generator silently wrote placeholder values
3724    /// that the install-time strict validator could later refuse,
3725    /// breaking the export-then-install round-trip.
3726    #[test]
3727    fn create_entity_refuses_unsupplied_no_default_required_field() {
3728        // The `planning.decision` schema declares `decided_on`
3729        // (Date, required, no default_value, no init_timestamp) and
3730        // `deciders` (String csv_array, required, no default).
3731        let tmp = TempDir::new().unwrap();
3732        let mut engine = engine_with_planning_schema(&tmp);
3733        let (actor, client) = cli_actor();
3734
3735        let mut args = CreateEntityArgs {
3736            anchors: Vec::new(),
3737            mem: "planning".to_string(),
3738            title: "Skip Postgres".to_string(),
3739            entity_type: "decision".to_string(),
3740            sections: IndexMap::from_iter([
3741                ("decision".to_string(), "Use SQLite locally.".to_string()),
3742                ("context".to_string(), "Single-user dev.".to_string()),
3743                ("consequences".to_string(), "Lose multi-writer.".to_string()),
3744            ]),
3745            metadata: IndexMap::new(),
3746            relations: Vec::new(),
3747            dry_run: false,
3748        };
3749
3750        // Real-write path: refuse on the first missing field
3751        // (declaration order).
3752        let err = engine
3753            .create_entity(args.clone(), actor, Some(&client), None)
3754            .unwrap_err();
3755        match err {
3756            EngineError::RequiredFieldUnset {
3757                field, entity_type, ..
3758            } => {
3759                assert!(
3760                    field == "decided_on" || field == "deciders",
3761                    "expected first missing field, got {field:?}"
3762                );
3763                assert_eq!(entity_type, "decision");
3764            }
3765            other => panic!("expected RequiredFieldUnset, got {other:?}"),
3766        }
3767
3768        // Dry-run path on the same shape (different title to avoid the
3769        // already-exists check). Must surface the same refusal — the
3770        // create dry-run is the agent's preview surface.
3771        args.title = "Different Title".to_string();
3772        args.dry_run = true;
3773        let dry_err = engine
3774            .create_entity(args, actor, Some(&client), None)
3775            .unwrap_err();
3776        assert!(
3777            matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
3778            "dry_run must surface the same refusal envelope, got {dry_err:?}"
3779        );
3780    }
3781
3782    /// A follow-up call with all required-no-default
3783    /// fields supplied succeeds. The refusal recovery is a single
3784    /// round-trip.
3785    #[test]
3786    fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
3787        let tmp = TempDir::new().unwrap();
3788        let mut engine = engine_with_planning_schema(&tmp);
3789        let (actor, client) = cli_actor();
3790
3791        let mut metadata = IndexMap::new();
3792        metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
3793        metadata.insert("deciders".to_string(), "alice, bob".to_string());
3794
3795        let outcome = engine
3796            .create_entity(
3797                CreateEntityArgs {
3798                    anchors: Vec::new(),
3799                    mem: "planning".to_string(),
3800                    title: "Complete Decision".to_string(),
3801                    entity_type: "decision".to_string(),
3802                    sections: IndexMap::from_iter([
3803                        ("decision".to_string(), "x".to_string()),
3804                        ("context".to_string(), "y".to_string()),
3805                        ("consequences".to_string(), "z".to_string()),
3806                    ]),
3807                    metadata,
3808                    relations: Vec::new(),
3809                    dry_run: false,
3810                },
3811                actor,
3812                Some(&client),
3813                None,
3814            )
3815            .expect("complete decision create succeeds");
3816        // No MissingRequiredField warnings on the success path —
3817        // refusal swallows the case before any warning could fire.
3818        let missing_field_warnings: Vec<&WarningHint> = outcome
3819            .warnings
3820            .iter()
3821            .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
3822            .collect();
3823        assert!(
3824            missing_field_warnings.is_empty(),
3825            "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
3826        );
3827    }
3828
3829    /// Item 02: `memstead_create.relations[]` runs the same target-id
3830    /// grammar gate as `memstead_relate`. Pre-fix the create path
3831    /// admitted malformed ids (auto-stub at `bad@chars$here`) even
3832    /// though `memstead_relate` rejected them.
3833    #[test]
3834    fn create_entity_rejects_inline_relation_with_malformed_target_id() {
3835        let tmp = TempDir::new().unwrap();
3836        let mem_dir = tmp.path().to_path_buf();
3837        let writer = FilesystemMemWriter::new(mem_dir.clone());
3838        let mut engine = Engine::from_mounts(vec![(
3839            folder_mount("specs", mem_dir),
3840            Box::new(writer) as Box<dyn MemBackend>,
3841        )])
3842        .unwrap();
3843        let (actor, client) = cli_actor();
3844
3845        let mut args = empty_create_args("specs", "Source");
3846        args.relations = vec![crate::ops::RelateArg {
3847            to: crate::EntityId("specs--bad target with spaces!!".to_string()),
3848            rel_type: "USES".to_string(),
3849            description: None,
3850        }];
3851        let err = engine
3852            .create_entity(args, actor, Some(&client), None)
3853            .unwrap_err();
3854        assert!(
3855            matches!(err, EngineError::InvalidEntityId { .. }),
3856            "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
3857        );
3858    }
3859
3860    /// Item 02: `memstead_create.relations[]` runs the same schema-shape
3861    /// gate as `memstead_relate`. The relate-path shape gate is already
3862    /// pinned by `memstead-mcp::tool_surface::INVALID_REL_SHAPE` and the
3863    /// schema-loader tests; the cross-path lock here exercises the
3864    /// `software` schema's `VIOLATES` rel-type, which declares
3865    /// `source_types: [incident]` — an inline create from a `spec`
3866    /// must trip the shape gate even though the rel-type itself is
3867    /// valid vocabulary.
3868    #[test]
3869    fn create_entity_rejects_inline_relation_with_shape_violation() {
3870        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
3871        let tmp = TempDir::new().unwrap();
3872        let mem_dir = tmp.path().to_path_buf();
3873        let writer = FilesystemMemWriter::new(mem_dir.clone());
3874        let mount = Mount {
3875            mem: "code".to_string(),
3876            schema: Some(memstead_schema::SchemaRef::new(
3877                "software",
3878                semver::Version::new(0, 1, 0),
3879            )),
3880            storage: MountStorage::Folder { path: mem_dir },
3881            capability: MountCapability::Write,
3882            lifecycle: MountLifecycle::Eager,
3883            cross_linkable: true,
3884            migration_target: None,
3885        };
3886        let mut engine =
3887            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
3888        let (actor, client) = cli_actor();
3889
3890        // Seed an existing target so the shape gate evaluates the
3891        // real target type (not `None`, which the gate admits as the
3892        // stub-bound case). The `requirement` type requires `statement` +
3893        // `rationale` sections plus `verified_on` + `source` metadata
3894        // (the schema lists these without `default_value` or
3895        // `optional: true`, so the strict-on-create gate refuses
3896        // unless supplied).
3897        let target = engine
3898            .create_entity(
3899                CreateEntityArgs {
3900                    anchors: Vec::new(),
3901                    mem: "code".to_string(),
3902                    title: "Target Requirement".to_string(),
3903                    entity_type: "requirement".to_string(),
3904                    sections: IndexMap::from_iter([
3905                        ("statement".to_string(), "MUST hold.".to_string()),
3906                        ("rationale".to_string(), "Because tests.".to_string()),
3907                    ]),
3908                    metadata: IndexMap::from_iter([
3909                        ("verified_on".to_string(), "2026-05-19".to_string()),
3910                        ("source".to_string(), "test fixture".to_string()),
3911                    ]),
3912                    relations: Vec::new(),
3913                    dry_run: false,
3914                },
3915                actor,
3916                Some(&client),
3917                None,
3918            )
3919            .unwrap();
3920
3921        // `VIOLATES` declares `source_types: [incident]`. A `spec`
3922        // create with `VIOLATES` violates the shape. The `spec` type
3923        // in the software schema requires `identity` + `purpose`;
3924        // supply both so the shape gate (not the missing-sections
3925        // gate) is what fires.
3926        let args = CreateEntityArgs {
3927            anchors: Vec::new(),
3928            mem: "code".to_string(),
3929            title: "Misshape Source".to_string(),
3930            entity_type: "spec".to_string(),
3931            sections: IndexMap::from_iter([
3932                ("identity".to_string(), "this spec".to_string()),
3933                (
3934                    "purpose".to_string(),
3935                    "exercising the shape gate".to_string(),
3936                ),
3937            ]),
3938            metadata: IndexMap::new(),
3939            relations: vec![crate::ops::RelateArg {
3940                to: target.id.clone(),
3941                rel_type: "VIOLATES".to_string(),
3942                description: None,
3943            }],
3944            dry_run: false,
3945        };
3946        let err = engine
3947            .create_entity(args, actor, Some(&client), None)
3948            .unwrap_err();
3949        assert!(
3950            matches!(err, EngineError::Validation(_)),
3951            "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
3952        );
3953    }
3954
3955    #[test]
3956    fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
3957        // Wire-level contract: rel_type on inline relations is
3958        // case-insensitive. The engine stores the relationship as
3959        // UPPER_SNAKE_CASE regardless of input case.
3960        let tmp = TempDir::new().unwrap();
3961        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
3962        let (actor, client) = cli_actor();
3963
3964        let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
3965        args.relations = vec![crate::ops::RelateArg {
3966            to: existing.id.clone(),
3967            rel_type: "uses".to_string(),
3968            description: None,
3969        }];
3970
3971        let outcome = engine
3972            .create_entity(args, actor, Some(&client), None)
3973            .unwrap();
3974
3975        let source = engine
3976            .store()
3977            .get(&outcome.id)
3978            .expect("source must be in store");
3979        assert_eq!(source.relationships.len(), 1);
3980        assert_eq!(
3981            source.relationships[0].rel_type, "USES",
3982            "inline relation rel_type must be stored UPPER_SNAKE_CASE",
3983        );
3984    }
3985
3986    #[test]
3987    fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
3988        let tmp = TempDir::new().unwrap();
3989        let mem_dir = tmp.path().to_path_buf();
3990        let writer = FilesystemMemWriter::new(mem_dir.clone());
3991        let mut engine = Engine::from_mounts(vec![(
3992            folder_mount("specs", mem_dir.clone()),
3993            Box::new(writer) as Box<dyn MemBackend>,
3994        )])
3995        .unwrap();
3996        let (actor, client) = cli_actor();
3997
3998        let mut args = empty_create_args("specs", "Preview Only");
3999        args.dry_run = true;
4000
4001        let outcome = engine
4002            .create_entity(args, actor, Some(&client), None)
4003            .unwrap();
4004
4005        // Wire shape: content_hash = prospective hash; commit_sha empty.
4006        assert_eq!(outcome.id.to_string(), "specs--preview-only");
4007        assert!(
4008            !outcome.content_hash.is_empty(),
4009            "prospective hash populated"
4010        );
4011        assert!(outcome.commit_sha.is_empty(), "no commit on dry_run");
4012        // No store entry — the engine didn't push.
4013        assert!(
4014            engine.store().get(&outcome.id).is_none(),
4015            "dry_run must not mutate the store",
4016        );
4017        // No file on disk.
4018        assert!(
4019            !mem_dir.join("preview-only.md").exists(),
4020            "dry_run must not touch disk",
4021        );
4022        // No provenance line.
4023        let log = mem_dir.join(".memstead").join("changes.jsonl");
4024        assert!(
4025            !log.exists()
4026                || !std::fs::read_to_string(&log)
4027                    .unwrap()
4028                    .contains("preview-only"),
4029            "dry_run must not append provenance",
4030        );
4031    }
4032
4033    #[test]
4034    fn create_entity_rejects_read_only_mount_before_backend() {
4035        let tmp = TempDir::new().unwrap();
4036        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
4037        let mut engine = Engine::from_mounts(vec![(
4038            archive_mount("external", archive_path.clone()),
4039            Box::new(ArchiveBackend::new(archive_path)),
4040        )])
4041        .unwrap();
4042        let (actor, client) = cli_actor();
4043
4044        let err = engine
4045            .create_entity(
4046                empty_create_args("external", "Should Fail"),
4047                actor,
4048                Some(&client),
4049                None,
4050            )
4051            .unwrap_err();
4052        match err {
4053            EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
4054            other => panic!("expected ReadOnlyMount, got {other:?}"),
4055        }
4056        // Capability gating runs before the backend → the typed
4057        // BackendError::Sealed variant never surfaces here. That's
4058        // the intended ordering.
4059    }
4060
4061    #[test]
4062    fn create_entity_rejects_unknown_mem() {
4063        let tmp = TempDir::new().unwrap();
4064        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
4065        let mut engine = Engine::from_mounts(vec![(
4066            folder_mount("specs", tmp.path().to_path_buf()),
4067            Box::new(writer) as Box<dyn MemBackend>,
4068        )])
4069        .unwrap();
4070        let (actor, client) = cli_actor();
4071
4072        let err = engine
4073            .create_entity(
4074                empty_create_args("does-not-exist", "Anything"),
4075                actor,
4076                Some(&client),
4077                None,
4078            )
4079            .unwrap_err();
4080        assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
4081    }
4082
4083    #[test]
4084    fn create_entity_rejects_unknown_type_against_pinned_schema() {
4085        let tmp = TempDir::new().unwrap();
4086        let mem_dir = tmp.path().to_path_buf();
4087        let writer = FilesystemMemWriter::new(mem_dir.clone());
4088        let mut engine = Engine::from_mounts(vec![(
4089            folder_mount("specs", mem_dir),
4090            Box::new(writer) as Box<dyn MemBackend>,
4091        )])
4092        .unwrap();
4093        let (actor, client) = cli_actor();
4094
4095        let mut args = empty_create_args("specs", "Anything");
4096        args.entity_type = "definitely-not-a-real-type".to_string();
4097        let err = engine
4098            .create_entity(args, actor, Some(&client), None)
4099            .unwrap_err();
4100        match err {
4101            EngineError::UnknownType { name, declared, .. } => {
4102                assert_eq!(name, "definitely-not-a-real-type");
4103                assert!(!declared.is_empty(), "declared types must be listed");
4104            }
4105            other => panic!("expected UnknownType, got {other:?}"),
4106        }
4107    }
4108
4109    #[test]
4110    fn create_entity_rejects_duplicate_id() {
4111        let tmp = TempDir::new().unwrap();
4112        let mem_dir = tmp.path().to_path_buf();
4113        let writer = FilesystemMemWriter::new(mem_dir.clone());
4114        let mut engine = Engine::from_mounts(vec![(
4115            folder_mount("specs", mem_dir),
4116            Box::new(writer) as Box<dyn MemBackend>,
4117        )])
4118        .unwrap();
4119        let (actor, client) = cli_actor();
4120
4121        engine
4122            .create_entity(
4123                empty_create_args("specs", "Same Slug"),
4124                actor,
4125                Some(&client),
4126                None,
4127            )
4128            .unwrap();
4129        let err = engine
4130            .create_entity(
4131                empty_create_args("specs", "Same Slug"),
4132                actor,
4133                Some(&client),
4134                None,
4135            )
4136            .unwrap_err();
4137        match err {
4138            EngineError::AlreadyExists {
4139                id,
4140                existing_title,
4141                existing_is_stub,
4142            } => {
4143                assert_eq!(id, "specs--same-slug");
4144                // The refusal names the occupying title so the caller
4145                // sees which existing title derived the colliding slug.
4146                assert!(!existing_title.is_empty());
4147                assert!(!existing_is_stub);
4148            }
4149            other => panic!("expected AlreadyExists, got {other:?}"),
4150        }
4151    }
4152
4153    #[test]
4154    fn create_entity_rejects_invalid_title() {
4155        let tmp = TempDir::new().unwrap();
4156        let mem_dir = tmp.path().to_path_buf();
4157        let writer = FilesystemMemWriter::new(mem_dir.clone());
4158        let mut engine = Engine::from_mounts(vec![(
4159            folder_mount("specs", mem_dir),
4160            Box::new(writer) as Box<dyn MemBackend>,
4161        )])
4162        .unwrap();
4163        let (actor, client) = cli_actor();
4164
4165        // F4: empty/whitespace-only titles now refuse with
4166        // `INVALID_TITLE` / reason `empty`. The earlier hash-fallback
4167        // behaviour applies only to the loader path (pre-gate
4168        // entities); the strict mutation gate rejects so the
4169        // structured-content envelope can carry actionable details.
4170        let err = engine
4171            .create_entity(empty_create_args("specs", "  "), actor, Some(&client), None)
4172            .unwrap_err();
4173        match err {
4174            EngineError::InvalidTitle(slug_err) => {
4175                assert_eq!(slug_err.reason(), "empty", "expected empty reason");
4176            }
4177            other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
4178        }
4179
4180        // Widened grammar: char-drop titles land, with the divergence
4181        // reported as the typed warning naming the dropped characters
4182        // and the derived slug.
4183        let outcome = engine
4184            .create_entity(
4185                empty_create_args("specs", "Hello, World!"),
4186                actor,
4187                Some(&client),
4188                None,
4189            )
4190            .expect("char-drop title lands under the widened grammar");
4191        assert_eq!(outcome.id.as_ref(), "specs--hello-world");
4192        let dropped = outcome
4193            .warnings
4194            .iter()
4195            .find_map(|w| match w {
4196                WarningHint::TitleCharsDroppedFromSlug {
4197                    dropped_chars,
4198                    slug,
4199                    ..
4200                } => Some((dropped_chars.clone(), slug.clone())),
4201                _ => None,
4202            })
4203            .expect("divergence warning rides the outcome");
4204        assert!(dropped.0.contains(&',') && dropped.0.contains(&'!'));
4205        assert_eq!(dropped.1, "hello-world");
4206
4207        // Path-traversal-shaped titles are display text too — the
4208        // dropped `/` and `.` never reach the slug, so the id stays
4209        // sanitised (no traversal), and the divergence is reported.
4210        let outcome = engine
4211            .create_entity(
4212                empty_create_args("specs", "../etc/passwd"),
4213                actor,
4214                Some(&client),
4215                None,
4216            )
4217            .expect("traversal-shaped title lands with a sanitised slug");
4218        assert_eq!(outcome.id.as_ref(), "specs--etcpasswd");
4219        assert!(
4220            outcome
4221                .warnings
4222                .iter()
4223                .any(|w| matches!(w, WarningHint::TitleCharsDroppedFromSlug { .. }))
4224        );
4225    }
4226
4227    #[test]
4228    fn create_entity_rejects_unknown_section_key() {
4229        let tmp = TempDir::new().unwrap();
4230        let mem_dir = tmp.path().to_path_buf();
4231        let writer = FilesystemMemWriter::new(mem_dir.clone());
4232        let mut engine = Engine::from_mounts(vec![(
4233            folder_mount("specs", mem_dir),
4234            Box::new(writer) as Box<dyn MemBackend>,
4235        )])
4236        .unwrap();
4237        let (actor, client) = cli_actor();
4238
4239        let mut args = empty_create_args("specs", "Bad Sections");
4240        args.sections
4241            .insert("not-a-real-section-key".to_string(), "body".to_string());
4242        let err = engine
4243            .create_entity(args, actor, Some(&client), None)
4244            .unwrap_err();
4245        assert!(matches!(err, EngineError::Validation(_)));
4246    }
4247
4248    #[test]
4249    fn create_entity_persists_across_engine_restart() {
4250        let tmp = TempDir::new().unwrap();
4251        let mem_dir = tmp.path().to_path_buf();
4252        {
4253            let writer = FilesystemMemWriter::new(mem_dir.clone());
4254            let mut engine = Engine::from_mounts(vec![(
4255                folder_mount("specs", mem_dir.clone()),
4256                Box::new(writer) as Box<dyn MemBackend>,
4257            )])
4258            .unwrap();
4259            let (actor, client) = cli_actor();
4260            engine
4261                .create_entity(
4262                    empty_create_args("specs", "Survives Restart"),
4263                    actor,
4264                    Some(&client),
4265                    None,
4266                )
4267                .unwrap();
4268        }
4269        // New engine reading the same mem must see the entity.
4270        let writer2 = FilesystemMemWriter::new(mem_dir.clone());
4271        let engine2 = Engine::from_mounts(vec![(
4272            folder_mount("specs", mem_dir),
4273            Box::new(writer2) as Box<dyn MemBackend>,
4274        )])
4275        .unwrap();
4276        let entity = engine2
4277            .get_entity(&crate::EntityId::new("specs", "survives-restart"))
4278            .expect("entity must persist across engine restart");
4279        assert_eq!(entity.title, "Survives Restart");
4280    }
4281
4282    // ---- Engine::update_entity --------------------------------------
4283
4284    /// Build a folder-mount Engine with one freshly-created entity.
4285    /// Returns the engine + the created outcome so tests have the
4286    /// id and current hash to use as `expected_hash` for the next
4287    /// mutation.
4288    fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
4289        let mem_dir = tmp.path().to_path_buf();
4290        let writer = FilesystemMemWriter::new(mem_dir.clone());
4291        let mut engine = Engine::from_mounts(vec![(
4292            folder_mount("specs", mem_dir),
4293            Box::new(writer) as Box<dyn MemBackend>,
4294        )])
4295        .unwrap();
4296        let (actor, client) = cli_actor();
4297        let outcome = engine
4298            .create_entity(
4299                empty_create_args("specs", title),
4300                actor,
4301                Some(&client),
4302                None,
4303            )
4304            .unwrap();
4305        (engine, outcome)
4306    }
4307
4308    /// Create with
4309    /// a body wiki-link to a non-existent target emits
4310    /// `INLINE_WIKI_LINK_AUTO_STUBBED` with the stubbed target id in
4311    /// `details.stubs`. Pre-fix the warning never fired because the
4312    /// emission walked `parse_markdown(generated_markdown).inline_links`,
4313    /// which the parser-side coverage filter had already emptied for
4314    /// the alias-synthesised body link.
4315    #[test]
4316    fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
4317        let tmp = TempDir::new().unwrap();
4318        let mem_dir = tmp.path().to_path_buf();
4319        let writer = FilesystemMemWriter::new(mem_dir.clone());
4320        let mut engine = Engine::from_mounts(vec![(
4321            folder_mount("specs", mem_dir),
4322            Box::new(writer) as Box<dyn MemBackend>,
4323        )])
4324        .unwrap();
4325        let (actor, client) = cli_actor();
4326
4327        let ghost = crate::EntityId::new("specs", "ghost-target");
4328        assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
4329
4330        let mut args = empty_create_args("specs", "Source With Body Link");
4331        args.sections.insert(
4332            "identity".to_string(),
4333            "ref [[ghost-target]] for context".to_string(),
4334        );
4335
4336        let outcome = engine
4337            .create_entity(args, actor, Some(&client), None)
4338            .unwrap();
4339        let stubbed: Vec<&crate::EntityId> = outcome
4340            .warnings
4341            .iter()
4342            .filter_map(|w| match w {
4343                WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
4344                _ => None,
4345            })
4346            .flatten()
4347            .collect();
4348        assert!(
4349            stubbed.contains(&&ghost),
4350            "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
4351            outcome.warnings,
4352        );
4353        // The stub also lands in the store and the REFERENCES edge exists.
4354        assert!(
4355            engine.store().contains(&ghost),
4356            "ghost stub must materialise"
4357        );
4358    }
4359
4360    /// CLI F11: a body wiki-link to the entity's own slug is dropped (no
4361    /// vacuous self-edge) with a `SELF_LINK_IGNORED` warning, while a body
4362    /// link to a *different* target in the same entity still synthesises
4363    /// its REFERENCES edge normally — only the self-target is dropped.
4364    #[test]
4365    fn create_entity_drops_self_link_keeps_other_links_and_warns() {
4366        let tmp = TempDir::new().unwrap();
4367        let mem_dir = tmp.path().to_path_buf();
4368        let writer = FilesystemMemWriter::new(mem_dir.clone());
4369        let mut engine = Engine::from_mounts(vec![(
4370            folder_mount("specs", mem_dir),
4371            Box::new(writer) as Box<dyn MemBackend>,
4372        )])
4373        .unwrap();
4374        let (actor, client) = cli_actor();
4375
4376        // Title "Selfie" → slug "selfie" → id "specs--selfie". The body
4377        // links its own slug AND a different target.
4378        let mut args = empty_create_args("specs", "Selfie");
4379        args.sections.insert(
4380            "identity".to_string(),
4381            "see [[selfie]] itself and also [[other-ref]]".to_string(),
4382        );
4383        let outcome = engine
4384            .create_entity(args, actor, Some(&client), None)
4385            .unwrap();
4386        let self_id = outcome.id.clone();
4387        assert_eq!(self_id.to_string(), "specs--selfie");
4388        let other_id = crate::EntityId::new("specs", "other-ref");
4389
4390        // SELF_LINK_IGNORED warning names the self-linking entity.
4391        assert!(
4392            outcome.warnings.iter().any(|w| matches!(
4393                w, WarningHint::SelfLinkIgnored { id } if *id == self_id
4394            )),
4395            "self-link must emit SELF_LINK_IGNORED; got: {:?}",
4396            outcome.warnings,
4397        );
4398
4399        // No self-edge: not in relationships, not Outgoing, not Incoming.
4400        let ent = engine.get_entity(&self_id).unwrap();
4401        assert!(
4402            ent.relationships.iter().all(|r| r.target != self_id),
4403            "no self-relation may be synthesised; got: {:?}",
4404            ent.relationships,
4405        );
4406        assert!(
4407            engine
4408                .store()
4409                .outgoing(&self_id)
4410                .iter()
4411                .all(|e| e.target != self_id),
4412            "self must not be its own Outgoing neighbour",
4413        );
4414        assert!(
4415            engine
4416                .store()
4417                .incoming(&self_id)
4418                .iter()
4419                .all(|e| e.from != self_id),
4420            "self must not be its own Incoming neighbour",
4421        );
4422
4423        // Complement: the link to a *different* target synthesised its
4424        // REFERENCES edge normally.
4425        assert!(
4426            ent.relationships
4427                .iter()
4428                .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
4429            "non-self body link must still synthesise its edge; got: {:?}",
4430            ent.relationships,
4431        );
4432    }
4433
4434    /// dry_run preview matches real-write outcome.
4435    #[test]
4436    fn create_entity_dry_run_emits_same_auto_stub_warning() {
4437        let tmp = TempDir::new().unwrap();
4438        let mem_dir = tmp.path().to_path_buf();
4439        let writer = FilesystemMemWriter::new(mem_dir.clone());
4440        let mut engine = Engine::from_mounts(vec![(
4441            folder_mount("specs", mem_dir),
4442            Box::new(writer) as Box<dyn MemBackend>,
4443        )])
4444        .unwrap();
4445        let (actor, client) = cli_actor();
4446
4447        let mut args = empty_create_args("specs", "Dry Run Body Link");
4448        args.dry_run = true;
4449        args.sections
4450            .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
4451
4452        let outcome = engine
4453            .create_entity(args, actor, Some(&client), None)
4454            .unwrap();
4455        let has_warning = outcome.warnings.iter().any(|w| {
4456            matches!(
4457                w,
4458                WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
4459                    if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
4460            )
4461        });
4462        assert!(
4463            has_warning,
4464            "dry_run must emit the same warning as real write: {:?}",
4465            outcome.warnings
4466        );
4467    }
4468
4469    /// Body wiki-link to a target that already exists
4470    /// in the store does NOT fire the warning — no stub was created.
4471    #[test]
4472    fn create_entity_no_auto_stub_warning_when_target_exists() {
4473        let tmp = TempDir::new().unwrap();
4474        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
4475        let (actor, client) = cli_actor();
4476
4477        let mut args = empty_create_args("specs", "Source Linking Existing");
4478        let body = format!("ref [[{}]]", existing.id.path());
4479        args.sections.insert("identity".to_string(), body);
4480
4481        let outcome = engine
4482            .create_entity(args, actor, Some(&client), None)
4483            .unwrap();
4484        let has_warning = outcome
4485            .warnings
4486            .iter()
4487            .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
4488        assert!(
4489            !has_warning,
4490            "no auto-stub warning when target pre-exists; got: {:?}",
4491            outcome.warnings
4492        );
4493    }
4494
4495    /// Obligation-schema counterpart of the ingest wildcard
4496    /// (first-author-path plan 09, criterion 5): an obligation mem
4497    /// body-links into a NON-SOFTWARE user-schema destination; the
4498    /// wildcard alias grant admits the auto-emitted REFERENCES edge.
4499    #[test]
4500    fn obligation_wildcard_links_into_arbitrary_destination_schema() {
4501        use crate::engine::test_helpers::write_schema_files_with_default_type;
4502        use memstead_schema::workspace_config::CrossLinkValue;
4503
4504        let tmp = TempDir::new().unwrap();
4505        let dest_dir = tmp.path().join("dest");
4506        let duties_dir = tmp.path().join("duties");
4507        std::fs::create_dir_all(&dest_dir).unwrap();
4508        std::fs::create_dir_all(&duties_dir).unwrap();
4509        let schemas_dir = tmp.path().join("schemas");
4510        let user_manifest = r#"name: casefiles
4511version: 0.1.0
4512description: a user-written, non-software destination schema
4513when_to_use: tests
4514types:
4515  - doc
4516relationships:
4517  mode: strict
4518  definitions:
4519    - name: _default
4520      description: fallback
4521      default_weight: 1.0
4522community:
4523  resolution: 1.0
4524  seed: 42
4525"#;
4526        write_schema_files_with_default_type(
4527            &schemas_dir,
4528            "casefiles@0.1.0",
4529            user_manifest,
4530            &["doc"],
4531        );
4532
4533        let mount = |mem: &str, dir: &std::path::Path, schema: &str| crate::workspace::Mount {
4534            mem: mem.to_string(),
4535            schema: Some(memstead_schema::SchemaRef::new(
4536                schema,
4537                semver::Version::new(0, 1, 0),
4538            )),
4539            storage: crate::workspace::MountStorage::Folder {
4540                path: dir.to_path_buf(),
4541            },
4542            capability: crate::workspace::MountCapability::Write,
4543            lifecycle: crate::workspace::MountLifecycle::Eager,
4544            cross_linkable: true,
4545            migration_target: None,
4546        };
4547        let mounts = vec![
4548            (
4549                mount("dest", &dest_dir, "casefiles"),
4550                Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
4551            ),
4552            (
4553                mount("duties", &duties_dir, "obligation"),
4554                Box::new(FilesystemMemWriter::new(duties_dir.clone())) as Box<dyn MemBackend>,
4555            ),
4556        ];
4557        let mut engine = Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
4558            .expect("obligation + user schema boot");
4559        let mut settings = crate::workspace::WorkspaceSettings::default();
4560        settings.cross_mem_links.insert(
4561            "duties".to_string(),
4562            CrossLinkValue::List(vec!["dest".to_string()]),
4563        );
4564        engine.set_settings(settings);
4565        let (actor, client) = cli_actor();
4566
4567        let target = engine
4568            .create_entity(
4569                CreateEntityArgs {
4570                    anchors: Vec::new(),
4571                    mem: "dest".to_string(),
4572                    title: "Case File 17".to_string(),
4573                    entity_type: "doc".to_string(),
4574                    sections: IndexMap::from_iter([(
4575                        "body".to_string(),
4576                        "destination content".to_string(),
4577                    )]),
4578                    metadata: IndexMap::new(),
4579                    relations: Vec::new(),
4580                    dry_run: false,
4581                },
4582                actor,
4583                Some(&client),
4584                None,
4585            )
4586            .unwrap();
4587
4588        let entry = engine
4589            .create_entity(
4590                CreateEntityArgs {
4591                    anchors: Vec::new(),
4592                    mem: "duties".to_string(),
4593                    title: "File Annual Report & Notice".to_string(),
4594                    entity_type: "obligation".to_string(),
4595                    sections: IndexMap::from_iter([
4596                        (
4597                            "duty".to_string(),
4598                            "File the report cited in [[dest--case-file-17]].".to_string(),
4599                        ),
4600                        (
4601                            "consequence".to_string(),
4602                            "Standing lapses at the deadline.".to_string(),
4603                        ),
4604                    ]),
4605                    metadata: IndexMap::from_iter([
4606                        ("due_date".to_string(), "2026-12-31".to_string()),
4607                        ("status".to_string(), "open".to_string()),
4608                    ]),
4609                    relations: vec![crate::ops::RelateArg {
4610                        to: crate::entity::EntityId::new("duties", "subject"),
4611                        rel_type: "CONCERNS".to_string(),
4612                        description: None,
4613                    }],
4614                    dry_run: false,
4615                },
4616                actor,
4617                Some(&client),
4618                None,
4619            )
4620            .expect("wildcard admits the alias link into the non-software destination");
4621        let stored = engine.get_entity(&entry.id).unwrap();
4622        assert!(
4623            stored
4624                .relationships
4625                .iter()
4626                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4627            "alias REFERENCES edge must emit cross-mem: {:?}",
4628            stored.relationships
4629        );
4630    }
4631
4632    /// Plan 11 end-to-end: an `ingest`-schema process mem body-links
4633    /// into a destination pinning an ARBITRARY user-written schema.
4634    /// The wildcard (bound to `alias_target_rel_type: REFERENCES`)
4635    /// admits the auto-emitted alias edge; the edge survives a fresh
4636    /// boot (the load path routes through the same matcher); explicit
4637    /// authoring of the alias type still refuses
4638    /// RELATION_MANUAL_AUTHORING_FORBIDDEN; a structural rel-type into
4639    /// the undeclared destination still refuses
4640    /// CROSS_MEM_EDGE_NOT_DECLARED; and the workspace policy gate
4641    /// still fires when the direction is not granted.
4642    #[test]
4643    fn ingest_wildcard_links_into_arbitrary_destination_schema() {
4644        use crate::engine::test_helpers::write_schema_files_with_default_type;
4645        use memstead_schema::workspace_config::CrossLinkValue;
4646
4647        let tmp = TempDir::new().unwrap();
4648        let dest_dir = tmp.path().join("dest");
4649        let proc_dir = tmp.path().join("proc");
4650        std::fs::create_dir_all(&dest_dir).unwrap();
4651        std::fs::create_dir_all(&proc_dir).unwrap();
4652
4653        // A user-written schema the engine has never shipped.
4654        let schemas_dir = tmp.path().join("schemas");
4655        let user_manifest = r#"name: debate
4656version: 0.1.0
4657description: a user-written destination schema
4658when_to_use: tests
4659types:
4660  - doc
4661relationships:
4662  mode: strict
4663  definitions:
4664    - name: _default
4665      description: fallback
4666      default_weight: 1.0
4667community:
4668  resolution: 1.0
4669  seed: 42
4670"#;
4671        write_schema_files_with_default_type(&schemas_dir, "debate@0.1.0", user_manifest, &["doc"]);
4672
4673        let mount = |mem: &str, dir: &std::path::Path, schema: &str, version: (u64, u64, u64)| {
4674            crate::workspace::Mount {
4675                mem: mem.to_string(),
4676                schema: Some(memstead_schema::SchemaRef::new(
4677                    schema,
4678                    semver::Version::new(version.0, version.1, version.2),
4679                )),
4680                storage: crate::workspace::MountStorage::Folder {
4681                    path: dir.to_path_buf(),
4682                },
4683                capability: crate::workspace::MountCapability::Write,
4684                lifecycle: crate::workspace::MountLifecycle::Eager,
4685                cross_linkable: true,
4686                migration_target: None,
4687            }
4688        };
4689        let boot = |grant: bool| -> Engine {
4690            let mounts = vec![
4691                (
4692                    mount("dest", &dest_dir, "debate", (0, 1, 0)),
4693                    Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
4694                ),
4695                (
4696                    mount("proc", &proc_dir, "ingest", (0, 2, 0)),
4697                    Box::new(FilesystemMemWriter::new(proc_dir.clone())) as Box<dyn MemBackend>,
4698                ),
4699            ];
4700            let mut engine =
4701                Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
4702                    .expect("ingest + user schema boot");
4703            let mut settings = crate::workspace::WorkspaceSettings::default();
4704            if grant {
4705                settings.cross_mem_links.insert(
4706                    "proc".to_string(),
4707                    CrossLinkValue::List(vec!["dest".to_string()]),
4708                );
4709            }
4710            engine.set_settings(settings);
4711            engine
4712        };
4713        let (actor, client) = cli_actor();
4714
4715        let mut engine = boot(true);
4716        // Destination entity in the user-schema mem.
4717        let target = engine
4718            .create_entity(
4719                CreateEntityArgs {
4720                    anchors: Vec::new(),
4721                    mem: "dest".to_string(),
4722                    title: "Target Doc".to_string(),
4723                    entity_type: "doc".to_string(),
4724                    sections: IndexMap::from_iter([(
4725                        "body".to_string(),
4726                        "destination content".to_string(),
4727                    )]),
4728                    metadata: IndexMap::new(),
4729                    relations: Vec::new(),
4730                    dry_run: false,
4731                },
4732                actor,
4733                Some(&client),
4734                None,
4735            )
4736            .unwrap();
4737
4738        // Process-mem entry body-linking the destination entity.
4739        let entry = engine
4740            .create_entity(
4741                CreateEntityArgs {
4742                    anchors: Vec::new(),
4743                    mem: "proc".to_string(),
4744                    title: "Check The Claim".to_string(),
4745                    entity_type: "verification_target".to_string(),
4746                    sections: IndexMap::from_iter([
4747                        (
4748                            "claim".to_string(),
4749                            "the claim under suspicion lives in [[dest--target-doc]]".to_string(),
4750                        ),
4751                        ("source_to_check".to_string(), "dest mem".to_string()),
4752                        (
4753                            "verifiable_when".to_string(),
4754                            "the linked entity still says so".to_string(),
4755                        ),
4756                    ]),
4757                    metadata: IndexMap::new(),
4758                    relations: Vec::new(),
4759                    dry_run: false,
4760                },
4761                actor,
4762                Some(&client),
4763                None,
4764            )
4765            .expect("wildcard admits the alias link into the user-schema destination");
4766        let stored = engine.get_entity(&entry.id).unwrap();
4767        assert!(
4768            stored
4769                .relationships
4770                .iter()
4771                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4772            "alias REFERENCES edge must emit: {:?}",
4773            stored.relationships
4774        );
4775
4776        // Explicit authoring of the alias rel-type: still forbidden.
4777        let err = engine
4778            .relate_entity(
4779                RelateEntityArgs {
4780                    source: entry.id.clone(),
4781                    expected_hash: None,
4782                    rel_type: "REFERENCES".to_string(),
4783                    target: target.id.clone(),
4784                    remove: false,
4785                    description: None,
4786                    dry_run: false,
4787                },
4788                actor,
4789                Some(&client),
4790                None,
4791            )
4792            .unwrap_err();
4793        assert_eq!(err.code(), "RELATION_MANUAL_AUTHORING_FORBIDDEN", "{err:?}");
4794
4795        // Structural rel-type into the undeclared destination: the
4796        // historical refusal, wildcard notwithstanding.
4797        let err = engine
4798            .relate_entity(
4799                RelateEntityArgs {
4800                    source: entry.id.clone(),
4801                    expected_hash: None,
4802                    rel_type: "PART_OF".to_string(),
4803                    target: target.id.clone(),
4804                    remove: false,
4805                    description: None,
4806                    dry_run: false,
4807                },
4808                actor,
4809                Some(&client),
4810                None,
4811            )
4812            .unwrap_err();
4813        assert_eq!(err.code(), "CROSS_MEM_EDGE_NOT_DECLARED", "{err:?}");
4814
4815        // Load-path survival: a FRESH boot over the same folders (the
4816        // store-builder path that previously dropped undeclared
4817        // cross-mem edges) keeps the alias edge.
4818        drop(engine);
4819        let rebooted = boot(true);
4820        let reloaded = rebooted.get_entity(&entry.id).unwrap();
4821        assert!(
4822            reloaded
4823                .relationships
4824                .iter()
4825                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
4826            "alias edge must survive reload: {:?}",
4827            reloaded.relationships
4828        );
4829
4830        // Policy gate intact: without the grant, the same wildcarded
4831        // link refuses CROSS_MEM_LINK_NOT_ALLOWED.
4832        let mut denied = boot(false);
4833        let err = denied
4834            .create_entity(
4835                CreateEntityArgs {
4836                    anchors: Vec::new(),
4837                    mem: "proc".to_string(),
4838                    title: "Denied Entry".to_string(),
4839                    entity_type: "verification_target".to_string(),
4840                    sections: IndexMap::from_iter([
4841                        (
4842                            "claim".to_string(),
4843                            "points at [[dest--target-doc]]".to_string(),
4844                        ),
4845                        ("source_to_check".to_string(), "dest mem".to_string()),
4846                        ("verifiable_when".to_string(), "never".to_string()),
4847                    ]),
4848                    metadata: IndexMap::new(),
4849                    relations: Vec::new(),
4850                    dry_run: false,
4851                },
4852                actor,
4853                Some(&client),
4854                None,
4855            )
4856            .unwrap_err();
4857        assert_eq!(err.code(), "CROSS_MEM_LINK_NOT_ALLOWED", "{err:?}");
4858    }
4859
4860    /// Two-mem Write-Write scaffold —
4861    /// `test` and `other` both pin the default schema, no
4862    /// `cross_mem_links` policy set yet (default deny-all). The
4863    /// caller installs the policy that matches each scenario.
4864    fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
4865        let tmp_test = TempDir::new().unwrap();
4866        let tmp_other = TempDir::new().unwrap();
4867        let test_dir = tmp_test.path().to_path_buf();
4868        let other_dir = tmp_other.path().to_path_buf();
4869        let writer_test = FilesystemMemWriter::new(test_dir.clone());
4870        let writer_other = FilesystemMemWriter::new(other_dir.clone());
4871        let engine = Engine::from_mounts(vec![
4872            (
4873                folder_mount("test", test_dir),
4874                Box::new(writer_test) as Box<dyn MemBackend>,
4875            ),
4876            (
4877                folder_mount("other", other_dir),
4878                Box::new(writer_other) as Box<dyn MemBackend>,
4879            ),
4880        ])
4881        .unwrap();
4882        (tmp_test, tmp_other, engine)
4883    }
4884
4885    /// `memstead_create` with an inline cross-mem relation refuses
4886    /// with `CROSS_MEM_LINK_NOT_ALLOWED` when policy denies the
4887    /// direction. The entity does not persist; the would-be id reads
4888    /// as `NotFound`.
4889    #[test]
4890    fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
4891        use crate::entity::EntityId;
4892        use crate::ops::RelateArg;
4893        use memstead_schema::workspace_config::CrossLinkValue;
4894
4895        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4896        let (actor, client) = cli_actor();
4897
4898        // Policy: `test → other` granted only. The inline create
4899        // request below is `other → test`, which must refuse.
4900        let mut settings = crate::workspace::WorkspaceSettings::default();
4901        settings.cross_mem_links.insert(
4902            "test".to_string(),
4903            CrossLinkValue::List(vec!["other".to_string()]),
4904        );
4905        engine.set_settings(settings);
4906
4907        // Seed a target in the `test` mem so the inline relation
4908        // names a real id (the policy gate fires before target
4909        // resolution regardless, but a real target removes any
4910        // ambiguity from the assertion).
4911        let target = engine
4912            .create_entity(
4913                empty_create_args("test", "Target"),
4914                actor,
4915                Some(&client),
4916                None,
4917            )
4918            .unwrap();
4919
4920        let mut args = empty_create_args("other", "Source");
4921        args.relations = vec![RelateArg {
4922            rel_type: "IMPLEMENTS".to_string(),
4923            to: target.id.clone(),
4924            description: None,
4925        }];
4926        let err = engine
4927            .create_entity(args, actor, Some(&client), None)
4928            .unwrap_err();
4929        match err {
4930            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
4931                assert_eq!(from_mem, "other");
4932                assert_eq!(to_mem, "test");
4933            }
4934            other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
4935        }
4936
4937        // No entity landed: the would-be id is absent.
4938        let would_be = EntityId::new("other", "source");
4939        assert!(
4940            engine.get_entity(&would_be).is_none(),
4941            "entity must not persist when inline relation refuses"
4942        );
4943    }
4944
4945    /// With the granted direction, the
4946    /// inline cross-mem relation succeeds and the edge persists.
4947    #[test]
4948    fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
4949        use crate::ops::RelateArg;
4950        use memstead_schema::workspace_config::CrossLinkValue;
4951
4952        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4953        let (actor, client) = cli_actor();
4954
4955        let mut settings = crate::workspace::WorkspaceSettings::default();
4956        settings.cross_mem_links.insert(
4957            "other".to_string(),
4958            CrossLinkValue::List(vec!["test".to_string()]),
4959        );
4960        engine.set_settings(settings);
4961
4962        let target = engine
4963            .create_entity(
4964                empty_create_args("test", "Target"),
4965                actor,
4966                Some(&client),
4967                None,
4968            )
4969            .unwrap();
4970
4971        let mut args = empty_create_args("other", "Source");
4972        args.relations = vec![RelateArg {
4973            rel_type: "IMPLEMENTS".to_string(),
4974            to: target.id.clone(),
4975            description: None,
4976        }];
4977        let outcome = engine
4978            .create_entity(args, actor, Some(&client), None)
4979            .unwrap();
4980        let stored = engine.get_entity(&outcome.id).expect("entity persists");
4981        assert!(
4982            stored
4983                .relationships
4984                .iter()
4985                .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
4986            "IMPLEMENTS edge must persist on the source's relationships",
4987        );
4988    }
4989
4990    /// A same-mem inline relation
4991    /// bypasses the policy gate entirely. Even with an empty policy
4992    /// (default deny-all for cross-mem), the create succeeds.
4993    #[test]
4994    fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
4995        use crate::ops::RelateArg;
4996
4997        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
4998        let (actor, client) = cli_actor();
4999        // No cross_mem_links set; same-mem writes must still work.
5000
5001        let target = engine
5002            .create_entity(
5003                empty_create_args("test", "Target"),
5004                actor,
5005                Some(&client),
5006                None,
5007            )
5008            .unwrap();
5009        let mut args = empty_create_args("test", "Source");
5010        args.relations = vec![RelateArg {
5011            rel_type: "USES".to_string(),
5012            to: target.id.clone(),
5013            description: None,
5014        }];
5015        let outcome = engine
5016            .create_entity(args, actor, Some(&client), None)
5017            .unwrap();
5018        let stored = engine.get_entity(&outcome.id).expect("entity persists");
5019        assert!(
5020            stored
5021                .relationships
5022                .iter()
5023                .any(|r| r.rel_type == "USES" && r.target == target.id),
5024            "same-mem USES edge must persist",
5025        );
5026    }
5027
5028    /// The existing `memstead_relate` path
5029    /// refuses the same scenario with the same typed code and
5030    /// payload shape — the two surfaces' refusals are
5031    /// indistinguishable to an agent.
5032    #[test]
5033    fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
5034        use crate::ops::RelateArg;
5035        use memstead_schema::workspace_config::CrossLinkValue;
5036
5037        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5038        let (actor, client) = cli_actor();
5039
5040        let mut settings = crate::workspace::WorkspaceSettings::default();
5041        settings.cross_mem_links.insert(
5042            "test".to_string(),
5043            CrossLinkValue::List(vec!["other".to_string()]),
5044        );
5045        engine.set_settings(settings);
5046
5047        let target = engine
5048            .create_entity(
5049                empty_create_args("test", "Target"),
5050                actor,
5051                Some(&client),
5052                None,
5053            )
5054            .unwrap();
5055        let src = engine
5056            .create_entity(
5057                empty_create_args("other", "Source"),
5058                actor,
5059                Some(&client),
5060                None,
5061            )
5062            .unwrap();
5063
5064        // memstead_relate refusal.
5065        let relate_err = engine
5066            .relate_entity(
5067                RelateEntityArgs {
5068                    source: src.id.clone(),
5069                    rel_type: "IMPLEMENTS".to_string(),
5070                    target: target.id.clone(),
5071                    expected_hash: Some(src.content_hash.clone()),
5072                    remove: false,
5073                    description: None,
5074                    dry_run: false,
5075                },
5076                actor,
5077                Some(&client),
5078                None,
5079            )
5080            .unwrap_err();
5081
5082        // memstead_create.relations[] refusal — fresh title so the create
5083        // attempt hasn't already landed.
5084        let mut create_args = empty_create_args("other", "Source Two");
5085        create_args.relations = vec![RelateArg {
5086            rel_type: "IMPLEMENTS".to_string(),
5087            to: target.id.clone(),
5088            description: None,
5089        }];
5090        let create_err = engine
5091            .create_entity(create_args, actor, Some(&client), None)
5092            .unwrap_err();
5093
5094        // Both refusals share the typed code, the payload shape, and
5095        // the (from_mem, to_mem) values.
5096        match (relate_err, create_err) {
5097            (
5098                EngineError::CrossMemLinkNotAllowed {
5099                    from_mem: rfv,
5100                    to_mem: rtv,
5101                },
5102                EngineError::CrossMemLinkNotAllowed {
5103                    from_mem: cfv,
5104                    to_mem: ctv,
5105                },
5106            ) => {
5107                assert_eq!(rfv, "other");
5108                assert_eq!(rtv, "test");
5109                assert_eq!(cfv, "other");
5110                assert_eq!(ctv, "test");
5111            }
5112            (a, b) => panic!(
5113                "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
5114            ),
5115        }
5116    }
5117
5118    /// Body wiki-link `[[other--target]]` in mem `test` (with
5119    /// `test → other` granted) creates the entity, auto-stubs at
5120    /// `other--target` (NOT `test--other--target` — that was the
5121    /// pre-fix phantom-stub bug), and emits one REFERENCES edge via
5122    /// the alias-synthesis path.
5123    #[test]
5124    fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
5125        use crate::entity::EntityId;
5126        use indexmap::IndexMap;
5127        use memstead_schema::workspace_config::CrossLinkValue;
5128
5129        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5130        let (actor, client) = cli_actor();
5131
5132        let mut settings = crate::workspace::WorkspaceSettings::default();
5133        settings.cross_mem_links.insert(
5134            "test".to_string(),
5135            CrossLinkValue::List(vec!["other".to_string()]),
5136        );
5137        engine.set_settings(settings);
5138
5139        let mut sections: IndexMap<String, String> = IndexMap::new();
5140        sections.insert(
5141            "identity".to_string(),
5142            "see [[other--target]] for details".to_string(),
5143        );
5144        sections.insert("purpose".to_string(), "source purpose".to_string());
5145        let outcome = engine
5146            .create_entity(
5147                crate::engine::CreateEntityArgs {
5148                    anchors: Vec::new(),
5149                    mem: "test".to_string(),
5150                    title: "Source".to_string(),
5151                    entity_type: "spec".to_string(),
5152                    sections,
5153                    metadata: IndexMap::new(),
5154                    relations: Vec::new(),
5155                    dry_run: false,
5156                },
5157                actor,
5158                Some(&client),
5159                None,
5160            )
5161            .unwrap();
5162
5163        // Auto-stub landed at `other--target`, NOT `test--other--target`.
5164        let canonical = EntityId::new("other", "target");
5165        assert!(
5166            engine.get_entity(&canonical).is_some(),
5167            "auto-stub must land at the canonical cross-mem id"
5168        );
5169        let phantom = EntityId::new("test", "other--target");
5170        assert!(
5171            engine.get_entity(&phantom).is_none(),
5172            "no double-prefixed phantom stub"
5173        );
5174
5175        // Exactly one REFERENCES edge to the cross-mem target.
5176        let source = engine.get_entity(&outcome.id).unwrap();
5177        let references_count = source
5178            .relationships
5179            .iter()
5180            .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
5181            .count();
5182        assert_eq!(
5183            references_count, 1,
5184            "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
5185        );
5186    }
5187
5188    /// Complement: body wiki-link cross-mem refusal when policy
5189    /// denies the direction. The auto-stub never lands, the entity
5190    /// never persists.
5191    #[test]
5192    fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
5193        use indexmap::IndexMap;
5194
5195        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
5196        let (actor, client) = cli_actor();
5197        // Empty cross-link policy — `test → other` denied.
5198
5199        let mut sections: IndexMap<String, String> = IndexMap::new();
5200        sections.insert(
5201            "identity".to_string(),
5202            "see [[other--target]] for details".to_string(),
5203        );
5204        sections.insert("purpose".to_string(), "source purpose".to_string());
5205        let err = engine
5206            .create_entity(
5207                crate::engine::CreateEntityArgs {
5208                    anchors: Vec::new(),
5209                    mem: "test".to_string(),
5210                    title: "Source".to_string(),
5211                    entity_type: "spec".to_string(),
5212                    sections,
5213                    metadata: IndexMap::new(),
5214                    relations: Vec::new(),
5215                    dry_run: false,
5216                },
5217                actor,
5218                Some(&client),
5219                None,
5220            )
5221            .unwrap_err();
5222        match err {
5223            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
5224                assert_eq!(from_mem, "test");
5225                assert_eq!(to_mem, "other");
5226            }
5227            other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
5228        }
5229    }
5230
5231    /// `[mutations].require_notes = true` drives a single `NOTE_MISSING`
5232    /// warning out of the engine mutation pipeline on every noteless
5233    /// mutation — the single enforcement point both the CLI and the MCP
5234    /// transport inherit. The mutation still commits (the policy nudges,
5235    /// it never blocks). Supplying a note suppresses it; turning the
5236    /// policy off silences it entirely. Covers create / update / relate
5237    /// in one engine instance.
5238    #[test]
5239    fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
5240        use crate::engine::UpdateEntityArgs;
5241        use crate::workspace::{MutationsSection, WorkspaceSettings};
5242        use indexmap::IndexMap;
5243
5244        let tmp = TempDir::new().unwrap();
5245        let mem_dir = tmp.path().to_path_buf();
5246        let writer = FilesystemMemWriter::new(mem_dir.clone());
5247        let mut engine = Engine::from_mounts(vec![(
5248            folder_mount("specs", mem_dir.clone()),
5249            Box::new(writer) as Box<dyn MemBackend>,
5250        )])
5251        .unwrap();
5252        engine.set_workspace_root(mem_dir.clone());
5253        engine.set_settings(WorkspaceSettings {
5254            mutations: MutationsSection {
5255                require_notes: Some(true),
5256            },
5257            ..Default::default()
5258        });
5259        let (actor, client) = cli_actor();
5260
5261        let note_missing = |ws: &[WarningHint]| -> usize {
5262            ws.iter()
5263                .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
5264                .count()
5265        };
5266
5267        // --- create, no note: exactly one NOTE_MISSING, commit landed ---
5268        let created = engine
5269            .create_entity(
5270                empty_create_args("specs", "Noteless"),
5271                actor,
5272                Some(&client),
5273                None,
5274            )
5275            .unwrap();
5276        assert_eq!(
5277            note_missing(&created.warnings),
5278            1,
5279            "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
5280            created.warnings,
5281        );
5282        assert!(
5283            matches!(
5284                created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
5285                Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
5286            ),
5287            "the warning names the engine-level verb",
5288        );
5289        assert!(
5290            !created.commit_sha.is_empty(),
5291            "create still commits (nudge, not block)"
5292        );
5293
5294        // --- update, no note: NOTE_MISSING + commit landed ---
5295        let mut edit: IndexMap<String, String> = IndexMap::new();
5296        edit.insert("identity".to_string(), "revised".to_string());
5297        let updated = engine
5298            .update_entity(
5299                UpdateEntityArgs {
5300                    anchors: Vec::new(),
5301                    id: created.id.clone(),
5302                    expected_hash: Some(created.content_hash.clone()),
5303                    sections: edit,
5304                    append_sections: IndexMap::new(),
5305                    patch_sections: IndexMap::new(),
5306                    metadata: IndexMap::new(),
5307                    metadata_unset: Vec::new(),
5308                    declare_relations: Vec::new(),
5309                    dry_run: false,
5310                    relations_unset: Vec::new(),
5311                    anchors_unset: Vec::new(),
5312                },
5313                actor,
5314                Some(&client),
5315                None,
5316            )
5317            .unwrap();
5318        assert_eq!(
5319            note_missing(&updated.warnings),
5320            1,
5321            "update emits NOTE_MISSING"
5322        );
5323        assert!(!updated.commit_sha.is_empty(), "update still commits");
5324
5325        // --- relate, no note: NOTE_MISSING + commit landed ---
5326        let target = engine
5327            .create_entity(
5328                empty_create_args("specs", "Target"),
5329                actor,
5330                Some(&client),
5331                Some("seed"),
5332            )
5333            .unwrap();
5334        let related = engine
5335            .relate_entity(
5336                RelateEntityArgs {
5337                    source: updated.id.clone(),
5338                    expected_hash: Some(updated.content_hash.clone()),
5339                    rel_type: "USES".to_string(),
5340                    target: target.id.clone(),
5341                    remove: false,
5342                    description: None,
5343                    dry_run: false,
5344                },
5345                actor,
5346                Some(&client),
5347                None,
5348            )
5349            .unwrap();
5350        assert_eq!(
5351            note_missing(&related.warnings),
5352            1,
5353            "relate emits NOTE_MISSING"
5354        );
5355        assert!(!related.commit_sha.is_empty(), "relate still commits");
5356
5357        // --- with a note: suppressed ---
5358        let with_note = engine
5359            .create_entity(
5360                empty_create_args("specs", "Documented"),
5361                actor,
5362                Some(&client),
5363                Some("a real provenance note"),
5364            )
5365            .unwrap();
5366        assert_eq!(
5367            note_missing(&with_note.warnings),
5368            0,
5369            "a supplied note suppresses the warning",
5370        );
5371
5372        // --- policy off: silent even without a note ---
5373        engine.set_settings(WorkspaceSettings::default());
5374        let after_off = engine
5375            .create_entity(
5376                empty_create_args("specs", "Quiet"),
5377                actor,
5378                Some(&client),
5379                None,
5380            )
5381            .unwrap();
5382        assert_eq!(
5383            note_missing(&after_off.warnings),
5384            0,
5385            "no NOTE_MISSING when require_notes is unset",
5386        );
5387    }
5388
5389    // ---- E3a anchors: create/persist/reload/isolation ------------------
5390
5391    fn file_anchor(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
5392        crate::anchor::AnchorInput {
5393            artifact: Some(artifact.to_string()),
5394            grain: Some("file".to_string()),
5395            class: Some("anchored".to_string()),
5396            hash: Some(hash.to_string()),
5397            hash_stability: Some("stable".to_string()),
5398            ..Default::default()
5399        }
5400    }
5401
5402    fn folder_engine(mem: &str) -> (Engine, TempDir) {
5403        let tmp = TempDir::new().unwrap();
5404        let dir = tmp.path().to_path_buf();
5405        let writer = FilesystemMemWriter::new(dir.clone());
5406        let engine = Engine::from_mounts(vec![(
5407            folder_mount(mem, dir.clone()),
5408            Box::new(writer) as Box<dyn MemBackend>,
5409        )])
5410        .unwrap();
5411        (engine, tmp)
5412    }
5413
5414    #[test]
5415    fn create_with_anchors_persists_and_survives_reload() {
5416        let (mut engine, tmp) = folder_engine("specs");
5417        let dir = tmp.path().to_path_buf();
5418        let (actor, client) = cli_actor();
5419        let mut args = empty_create_args("specs", "Anchored Entity");
5420        args.anchors = vec![file_anchor("src/lib.rs", "h1")];
5421        engine
5422            .create_entity(args, actor, Some(&client), None)
5423            .unwrap();
5424
5425        let id = crate::EntityId::new("specs", "anchored-entity");
5426        let anchors = engine.entity_anchors(&id);
5427        assert_eq!(anchors.len(), 1);
5428        assert_eq!(anchors[0].artifact, "src/lib.rs");
5429        assert_eq!(
5430            anchors[0].class,
5431            crate::anchor::AnchorProvenanceClass::Anchored
5432        );
5433
5434        // Survives a fresh boot from the same on-disk mem.
5435        let writer = FilesystemMemWriter::new(dir.clone());
5436        let reloaded = Engine::from_mounts(vec![(
5437            folder_mount("specs", dir.clone()),
5438            Box::new(writer) as Box<dyn MemBackend>,
5439        )])
5440        .unwrap();
5441        assert_eq!(reloaded.entity_anchors(&id).len(), 1);
5442        // Reverse lookup finds it by artifact path.
5443        assert_eq!(reloaded.anchors_referencing_artifact("src/lib.rs").len(), 1);
5444    }
5445
5446    #[test]
5447    fn malformed_anchor_refuses_and_entity_not_written() {
5448        let (mut engine, tmp) = folder_engine("specs");
5449        let (actor, client) = cli_actor();
5450        let mut args = empty_create_args("specs", "Bad Anchor");
5451        args.anchors = vec![crate::anchor::AnchorInput {
5452            artifact: Some("x".into()),
5453            grain: Some("paragraph".into()), // unknown grain
5454            class: Some("anchored".into()),
5455            ..Default::default()
5456        }];
5457        let err = engine
5458            .create_entity(args, actor, Some(&client), None)
5459            .unwrap_err();
5460        assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
5461        // Entity was not written (refusal fires before the disk write).
5462        assert!(
5463            engine
5464                .get_entity(&crate::EntityId::new("specs", "bad-anchor"))
5465                .is_none()
5466        );
5467        assert!(!tmp.path().join("bad-anchor.md").exists());
5468    }
5469
5470    #[test]
5471    fn anchors_are_not_folded_into_content_hash() {
5472        // Two identical creates — one anchored, one not — produce the same
5473        // `_hash`: the anchors sidecar lives under `.memstead/` and never
5474        // enters content hashing.
5475        let (mut anchored, _t1) = folder_engine("specs");
5476        let (mut plain, _t2) = folder_engine("specs");
5477        let (actor, client) = cli_actor();
5478
5479        let mut a = empty_create_args("specs", "Same Title");
5480        a.anchors = vec![file_anchor("src/lib.rs", "h1")];
5481        let with = anchored
5482            .create_entity(a, actor, Some(&client), None)
5483            .unwrap();
5484
5485        let p = empty_create_args("specs", "Same Title");
5486        let without = plain.create_entity(p, actor, Some(&client), None).unwrap();
5487
5488        assert_eq!(
5489            with.content_hash, without.content_hash,
5490            "anchors must not change the entity content hash"
5491        );
5492    }
5493
5494    #[test]
5495    fn anchorless_create_writes_no_sidecar() {
5496        let (mut engine, _tmp) = folder_engine("specs");
5497        let (actor, client) = cli_actor();
5498        engine
5499            .create_entity(
5500                empty_create_args("specs", "No Anchors"),
5501                actor,
5502                Some(&client),
5503                None,
5504            )
5505            .unwrap();
5506        assert!(
5507            engine
5508                .entity_anchors(&crate::EntityId::new("specs", "no-anchors"))
5509                .is_empty()
5510        );
5511    }
5512
5513    // ---- reserved metadata keys on create --------------------------------
5514
5515    /// A create carrying a reserved identity/discriminator metadata key
5516    /// (`type` / `mem` / `id`) refuses with the same deliberate
5517    /// `READ_ONLY_FIELD` the update path uses — not the incidental
5518    /// `UNKNOWN_METADATA_FIELD` — and the entity is not written.
5519    /// Refusal complement: a create with only declared, non-reserved
5520    /// keys lands exactly as today (covered pervasively by every other
5521    /// create test; the explicit control below re-asserts it beside
5522    /// the refusals).
5523    #[test]
5524    fn create_refuses_reserved_metadata_keys_deliberately() {
5525        let (mut engine, _tmp) = folder_engine("specs");
5526        let (actor, client) = cli_actor();
5527        for reserved in ["type", "mem", "id"] {
5528            let mut args = empty_create_args("specs", "Smuggler");
5529            args.metadata
5530                .insert(reserved.to_string(), "bogus".to_string());
5531            let err = engine
5532                .create_entity(args, actor, Some(&client), None)
5533                .expect_err("reserved key must refuse on create");
5534            assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
5535            assert!(
5536                engine
5537                    .get_entity(&crate::EntityId::new("specs", "smuggler"))
5538                    .is_none(),
5539                "entity must not be written after the '{reserved}' refusal"
5540            );
5541        }
5542        // Control: the same create without the smuggled key lands.
5543        engine
5544            .create_entity(
5545                empty_create_args("specs", "Smuggler"),
5546                actor,
5547                Some(&client),
5548                None,
5549            )
5550            .expect("a clean create is untouched by the reserved-key gate");
5551    }
5552
5553    // ---- cycle family on the create paths --------------------------------
5554
5555    fn create_with_relation(mem: &str, title: &str, rel_type: &str, to: &str) -> CreateEntityArgs {
5556        let mut args = empty_create_args(mem, title);
5557        args.relations = vec![crate::ops::RelateArg {
5558            to: crate::EntityId(to.to_string()),
5559            rel_type: rel_type.to_string(),
5560            description: None,
5561        }];
5562        args
5563    }
5564
5565    /// `create.relations[]` runs the same cycle family as
5566    /// `memstead_relate`: an edge closing a cycle through a promoted
5567    /// stub refuses `RELATIONSHIP_CYCLE` (acyclic rel-type), a
5568    /// self-loop on a listed no-self-loop rel-type refuses
5569    /// identically, and —
5570    /// refusal complement — a non-cycle edge on the acyclic type lands
5571    /// exactly as today.
5572    #[test]
5573    fn create_relations_refuse_cycle_and_self_loop_like_relate() {
5574        let (mut engine, _tmp) = folder_engine("specs");
5575        let (actor, client) = cli_actor();
5576
5577        // A PART_OF→ghost auto-stubs `ghost` with an incoming edge.
5578        engine
5579            .create_entity(
5580                create_with_relation("specs", "Alpha", "PART_OF", "specs--ghost"),
5581                actor,
5582                Some(&client),
5583                None,
5584            )
5585            .unwrap();
5586
5587        // Promoting the stub with a back-edge closes alpha→ghost→alpha.
5588        let err = engine
5589            .create_entity(
5590                create_with_relation("specs", "Ghost", "PART_OF", "specs--alpha"),
5591                actor,
5592                Some(&client),
5593                None,
5594            )
5595            .expect_err("cycle-closing create.relations[] must refuse");
5596        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5597        // Recovery detail matches the relate path's shape.
5598        let details = err.details();
5599        assert_eq!(details["rel_type"], "PART_OF");
5600        assert!(details["existing_path"].is_array());
5601        assert!(
5602            engine
5603                .get_entity(&crate::EntityId::new("specs", "ghost"))
5604                .is_none_or(|e| e.stub),
5605            "the refused entity must not be written"
5606        );
5607
5608        // Self-loop on a listed no-self-loop rel-type (spec lists USES).
5609        let err = engine
5610            .create_entity(
5611                create_with_relation("specs", "Selfy", "USES", "specs--selfy"),
5612                actor,
5613                Some(&client),
5614                None,
5615            )
5616            .expect_err("self-loop create.relations[] must refuse");
5617        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
5618
5619        // Refusal complement: a non-cycle edge on the acyclic type
5620        // lands (fresh chain link, no back-path).
5621        engine
5622            .create_entity(
5623                create_with_relation("specs", "Beta", "PART_OF", "specs--alpha"),
5624                actor,
5625                Some(&client),
5626                None,
5627            )
5628            .expect("a non-cycle PART_OF edge must land as today");
5629    }
5630
5631    /// An intra-batch cycle on an acyclic rel-type refuses the whole
5632    /// batch — the staged state IS the graph state the batch validates
5633    /// against. Refusal complement: an acyclic intra-batch chain lands.
5634    #[test]
5635    fn batch_create_refuses_intra_batch_cycle() {
5636        let (mut engine, _tmp) = folder_engine("specs");
5637        let (actor, client) = cli_actor();
5638
5639        let result = engine
5640            .batch_create(
5641                vec![
5642                    (
5643                        create_with_relation("specs", "Ping", "PART_OF", "specs--pong"),
5644                        None,
5645                    ),
5646                    (
5647                        create_with_relation("specs", "Pong", "PART_OF", "specs--ping"),
5648                        None,
5649                    ),
5650                ],
5651                actor,
5652                Some(&client),
5653                false,
5654            )
5655            .expect("batch returns a result envelope");
5656        assert!(!result.applied, "intra-batch cycle must refuse the batch");
5657        assert!(
5658            result.results.iter().any(|r| r
5659                .error
5660                .as_ref()
5661                .is_some_and(|e| e.code == "RELATIONSHIP_CYCLE")),
5662            "the refusal must carry RELATIONSHIP_CYCLE: {:?}",
5663            result.results
5664        );
5665        assert!(
5666            engine
5667                .get_entity(&crate::EntityId::new("specs", "ping"))
5668                .is_none(),
5669            "nothing lands from a refused batch"
5670        );
5671
5672        // Refusal complement: an acyclic intra-batch chain lands.
5673        let result = engine
5674            .batch_create(
5675                vec![
5676                    (
5677                        create_with_relation("specs", "Chain One", "PART_OF", "specs--chain-two"),
5678                        None,
5679                    ),
5680                    (empty_create_args("specs", "Chain Two"), None),
5681                ],
5682                actor,
5683                Some(&client),
5684                false,
5685            )
5686            .expect("acyclic batch lands");
5687        assert!(result.applied, "{:?}", result.results);
5688        assert_eq!(result.succeeded, 2);
5689    }
5690
5691    /// Refusal complement at depth: a deep-but-acyclic PART_OF chain
5692    /// past the cycle path cap is accepted on the create path — the cap
5693    /// bounds the *reported* path on refusal, never the legality of a
5694    /// long acyclic chain — and one closing edge at the far end still
5695    /// refuses.
5696    #[test]
5697    fn deep_acyclic_chain_near_path_cap_is_accepted() {
5698        let (mut engine, _tmp) = folder_engine("specs");
5699        let (actor, client) = cli_actor();
5700        let depth = crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP + 2;
5701
5702        // link-0 ← link-1 ← … each new entity PART_OF the previous.
5703        engine
5704            .create_entity(
5705                empty_create_args("specs", "Link 0"),
5706                actor,
5707                Some(&client),
5708                None,
5709            )
5710            .unwrap();
5711        for i in 1..depth {
5712            engine
5713                .create_entity(
5714                    create_with_relation(
5715                        "specs",
5716                        &format!("Link {i}"),
5717                        "PART_OF",
5718                        &format!("specs--link-{}", i - 1),
5719                    ),
5720                    actor,
5721                    Some(&client),
5722                    None,
5723                )
5724                .unwrap_or_else(|e| panic!("deep acyclic link {i} must land: {e:?}"));
5725        }
5726
5727        // Closing the loop end-to-end still refuses, with the reported
5728        // path truncated at the cap.
5729        let last = depth - 1;
5730        let err = engine
5731            .update_entity(
5732                {
5733                    let id = crate::EntityId::new("specs", "link-0");
5734                    let hash = engine.get_entity(&id).unwrap().content_hash.clone();
5735                    crate::engine::UpdateEntityArgs {
5736                        anchors: Vec::new(),
5737                        anchors_unset: Vec::new(),
5738                        id,
5739                        expected_hash: Some(hash),
5740                        sections: IndexMap::new(),
5741                        append_sections: IndexMap::new(),
5742                        patch_sections: IndexMap::new(),
5743                        metadata: IndexMap::new(),
5744                        metadata_unset: Vec::new(),
5745                        declare_relations: vec![crate::ops::RelateArg {
5746                            to: crate::EntityId::new("specs", &format!("link-{last}")),
5747                            rel_type: "PART_OF".to_string(),
5748                            description: None,
5749                        }],
5750                        dry_run: false,
5751                        relations_unset: Vec::new(),
5752                    }
5753                },
5754                actor,
5755                Some(&client),
5756                None,
5757            )
5758            .expect_err("closing the deep chain must refuse");
5759        assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
5760        let details = err.details();
5761        assert_eq!(details["path_truncated"], true);
5762        assert_eq!(
5763            details["existing_path"].as_array().unwrap().len(),
5764            crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP
5765        );
5766    }
5767
5768    const FORMAT_MANIFEST: &str = r#"name: formatproof
5769version: 0.1.0
5770description: section-format proof schema
5771when_to_use: format tests
5772types:
5773  - plan
5774relationships:
5775  mode: strict
5776  definitions:
5777    - name: PART_OF
5778      description: hier
5779      default_weight: 1.0
5780    - name: _default
5781      description: fallback
5782      default_weight: 1.0
5783community:
5784  resolution: 1.0
5785  seed: 42
5786"#;
5787
5788    const FORMAT_PLAN_TYPE: &str = r#"name: plan
5789description: a plan with formatted milestones
5790when_to_use: tests
5791sections:
5792  - key: body
5793    heading: Body
5794    required: true
5795    search_weight: 10.0
5796    catch_all: true
5797    write_rules: []
5798  - key: meilensteine
5799    heading: Meilensteine
5800    required: false
5801    search_weight: 5.0
5802    catch_all: false
5803    write_rules: []
5804    content: "(heading(3) list(bullet))+"
5805    item_pattern: '\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})'
5806    example: |
5807      ### Phase 1
5808      - **Kickoff** — 2026-09-01
5809  - key: notizen
5810    heading: Notizen
5811    required: false
5812    search_weight: 5.0
5813    catch_all: false
5814    write_rules: []
5815    content: "list(bullet)"
5816    format_severity: warn
5817metadata_fields: []
5818title_weight: 100.0
5819text_fields:
5820  - body
5821hierarchy_relationship: PART_OF
5822no_self_loop_relationships: []
5823updatable_fields:
5824  - title
5825  - body
5826  - meilensteine
5827  - notizen
5828health_required_fields:
5829  - body
5830staleness_threshold_days: 90
5831write_rules: []
5832"#;
5833
5834    fn format_engine(tmp: &TempDir) -> Engine {
5835        engine_with_proof_schema(
5836            tmp,
5837            "formatproof",
5838            FORMAT_MANIFEST,
5839            &[("plan", FORMAT_PLAN_TYPE)],
5840        )
5841    }
5842
5843    fn plan_create_args(
5844        title: &str,
5845        meilensteine: Option<&str>,
5846        notizen: Option<&str>,
5847    ) -> CreateEntityArgs {
5848        let mut sections = IndexMap::new();
5849        sections.insert("body".to_string(), "a plan body.".to_string());
5850        if let Some(m) = meilensteine {
5851            sections.insert("meilensteine".to_string(), m.to_string());
5852        }
5853        if let Some(n) = notizen {
5854            sections.insert("notizen".to_string(), n.to_string());
5855        }
5856        CreateEntityArgs {
5857            anchors: Vec::new(),
5858            mem: "proof".to_string(),
5859            title: title.to_string(),
5860            entity_type: "plan".to_string(),
5861            sections,
5862            metadata: IndexMap::new(),
5863            relations: vec![],
5864            dry_run: false,
5865        }
5866    }
5867
5868    /// Block-tier format enforcement on create: a nonconforming
5869    /// section refuses with the format code and the echoed example;
5870    /// the conforming write passes; a warn-tier section never refuses.
5871    #[test]
5872    fn create_enforces_declared_section_format() {
5873        let tmp = TempDir::new().unwrap();
5874        let mut engine = format_engine(&tmp);
5875        let (actor, client) = cli_actor();
5876
5877        let err = engine
5878            .create_entity(
5879                plan_create_args("Plan A", Some("### Phase 1\n\nprose statt liste\n"), None),
5880                actor,
5881                Some(&client),
5882                None,
5883            )
5884            .unwrap_err();
5885        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5886        let details = err.details();
5887        assert_eq!(details["section"], "meilensteine");
5888        assert!(
5889            details["example"].as_str().unwrap().contains("Kickoff"),
5890            "the conforming example is echoed: {details}"
5891        );
5892        assert_eq!(details["expected_next"][0], "list(bullet)");
5893
5894        // Item-pattern violation gets its own code.
5895        let err = engine
5896            .create_entity(
5897                plan_create_args("Plan B", Some("### Phase 1\n- kein format\n"), None),
5898                actor,
5899                Some(&client),
5900                None,
5901            )
5902            .unwrap_err();
5903        assert_eq!(err.code(), "SECTION_ITEM_PATTERN_MISMATCH");
5904
5905        // Conforming write passes.
5906        engine
5907            .create_entity(
5908                plan_create_args(
5909                    "Plan C",
5910                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5911                    None,
5912                ),
5913                actor,
5914                Some(&client),
5915                None,
5916            )
5917            .unwrap();
5918
5919        // Warn-tier section: nonconforming content commits.
5920        let outcome = engine
5921            .create_entity(
5922                plan_create_args(
5923                    "Plan D",
5924                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5925                    Some("kein listenpunkt\n"),
5926                ),
5927                actor,
5928                Some(&client),
5929                None,
5930            )
5931            .unwrap();
5932        assert!(!outcome.commit_sha.is_empty(), "warn tier never refuses");
5933
5934        // Absent-as-empty: omitting the block-tier section refuses
5935        // exactly like an explicit empty body — the generator renders
5936        // the empty heading either way, and write path and health
5937        // must agree about that on-disk state. `+` does not admit the
5938        // empty sequence, so the section is effectively required.
5939        let err = engine
5940            .create_entity(
5941                plan_create_args("Plan E", None, None),
5942                actor,
5943                Some(&client),
5944                None,
5945            )
5946            .unwrap_err();
5947        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
5948    }
5949
5950    /// Composed-body rule on update: an append whose delta is
5951    /// harmless refuses when the COMPOSED body violates; the
5952    /// conforming replacement passes.
5953    #[test]
5954    fn update_judges_format_on_composed_body() {
5955        let tmp = TempDir::new().unwrap();
5956        let mut engine = format_engine(&tmp);
5957        let (actor, client) = cli_actor();
5958        let created = engine
5959            .create_entity(
5960                plan_create_args(
5961                    "Plan A",
5962                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
5963                    None,
5964                ),
5965                actor,
5966                Some(&client),
5967                None,
5968            )
5969            .unwrap();
5970
5971        // Append a trailing paragraph: the delta alone is legal
5972        // markdown, the composed body no longer matches the shape.
5973        let current = engine.get_entity(&created.id).unwrap().content_hash.clone();
5974        let mut append = IndexMap::new();
5975        append.insert(
5976            "meilensteine".to_string(),
5977            "\n\nnachtrag als absatz\n".to_string(),
5978        );
5979        let err = engine
5980            .update_entity(
5981                crate::engine::UpdateEntityArgs {
5982                    anchors: Vec::new(),
5983                    id: created.id.clone(),
5984                    expected_hash: Some(current.clone()),
5985                    sections: IndexMap::new(),
5986                    append_sections: append,
5987                    patch_sections: IndexMap::new(),
5988                    metadata: IndexMap::new(),
5989                    metadata_unset: Vec::new(),
5990                    declare_relations: vec![],
5991                    dry_run: false,
5992                    relations_unset: Vec::new(),
5993                    anchors_unset: Vec::new(),
5994                },
5995                actor,
5996                Some(&client),
5997                None,
5998            )
5999            .unwrap_err();
6000        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
6001
6002        // A conforming append (another phase) passes.
6003        let mut append = IndexMap::new();
6004        append.insert(
6005            "meilensteine".to_string(),
6006            "\n\n### Phase 2\n- **Go-Live** — 2026-10-01\n".to_string(),
6007        );
6008        engine
6009            .update_entity(
6010                crate::engine::UpdateEntityArgs {
6011                    anchors: Vec::new(),
6012                    id: created.id.clone(),
6013                    expected_hash: Some(current),
6014                    sections: IndexMap::new(),
6015                    append_sections: append,
6016                    patch_sections: IndexMap::new(),
6017                    metadata: IndexMap::new(),
6018                    metadata_unset: Vec::new(),
6019                    declare_relations: vec![],
6020                    dry_run: false,
6021                    relations_unset: Vec::new(),
6022                    anchors_unset: Vec::new(),
6023                },
6024                actor,
6025                Some(&client),
6026                None,
6027            )
6028            .unwrap();
6029    }
6030
6031    /// Reserved-heading extension (criterion 4): `^# ` now refuses in
6032    /// any section body, exactly like `^## ` — free-form sections
6033    /// included, via the byte-class line guard.
6034    #[test]
6035    fn embedded_h1_refuses_in_any_section() {
6036        let tmp = TempDir::new().unwrap();
6037        let mut engine = format_engine(&tmp);
6038        let (actor, client) = cli_actor();
6039        let mut args = plan_create_args(
6040            "Plan H",
6041            Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
6042            None,
6043        );
6044        args.sections.insert(
6045            "body".to_string(),
6046            "intro\n# Injected Title\ntail".to_string(),
6047        );
6048        let err = engine
6049            .create_entity(args, actor, Some(&client), None)
6050            .unwrap_err();
6051        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
6052    }
6053}