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::id::validate_and_derive_slug;
38use crate::entity::parser::parse_markdown;
39use crate::entity::store_builder::push_entities_into_store;
40use crate::entity::{Entity, EntityId, MetadataValue, Relationship, normalise_description};
41use crate::ops::{WarningHint, project_incoming};
42use crate::provenance::{Provenance, ProvenanceKind};
43use crate::runtime_validator::{
44    missing_required_fields, missing_required_sections, parse_metadata_value,
45    validate_section_content, validate_section_keys,
46};
47use crate::vcs::{Actor, ClientId, CommitContext};
48use crate::workspace::MountCapability;
49
50use super::super::{CreateEntityArgs, CreateEntityOutcome, Engine, EngineError};
51use super::{
52    EdgeRouteOutcome, make_stub, route_edge_validation, unknown_type_error,
53    validate_relation_target_grammar,
54};
55
56/// Everything a validated create needs to hit disk — the create-side
57/// twin of `PreparedUpdate`. Produced by `Engine::prepare_create`,
58/// consumed by `Engine::commit_prepared_create` (single item) and by
59/// `Engine::batch_create` (staged all-first, one commit per mem).
60struct PreparedCreate {
61    mount_idx: usize,
62    id: EntityId,
63    title: String,
64    mem: String,
65    file_path: String,
66    markdown: String,
67    anchors: Vec<crate::anchor::Anchor>,
68    warnings: Vec<WarningHint>,
69    type_guidance: std::collections::BTreeMap<String, Vec<String>>,
70    relations_declared: Vec<crate::engine::outcomes::RelationDeclared>,
71    /// Inline-relation targets — the commit tail materialises
72    /// forward-reference stubs for the ones the store still lacks.
73    relation_targets: Vec<EntityId>,
74    type_def: std::sync::Arc<memstead_schema::TypeDefinition>,
75}
76
77/// Outcome of `Engine::prepare_create`: a dry-run completes at prepare
78/// time; a real write returns the staged material.
79enum CreatePrepareOutcome {
80    Done(CreateEntityOutcome),
81    Prepared(PreparedCreate),
82}
83
84impl Engine {
85    /// Create a new entity in `args.mem`. Six concerns wired here
86    /// in one shape regardless of which backend serves the mount:
87    ///
88    /// 1. **Capability gating** — rejects mounts with `ReadOnly`
89    ///    capability before reaching the backend.
90    /// 2. **Validator pipeline** — `validate_section_keys` +
91    ///    `parse_metadata_value` enforce the pinned schema's strictness;
92    ///    typed `ValidationError` lifts to `EngineError::Validation`.
93    /// 3. **Provenance** — a `Provenance` record routes through
94    ///    `backend.append_provenance` (folder writes JSONL, git-branch
95    ///    no-ops since the commit subject + trailers carry the same
96    ///    fields).
97    /// 4. **Write + commit atomicity** — `backend.write_entity` then
98    ///    `backend.commit` with the canonical `memstead: create <id>`
99    ///    subject so the git-branch backend's `read_provenance` can
100    ///    recover the kind.
101    /// 5. **Store update** — re-parse the freshly-generated markdown
102    ///    so the in-memory `Store` mirrors disk (including
103    ///    generator-determined `content_hash`).
104    /// 6. **Error envelope** — `BackendError::Sealed` lifts via the
105    ///    `Backend` variant so MCP callers see the typed payload
106    ///    intact; `HashMismatch` propagates likewise.
107    pub fn create_entity(
108        &mut self,
109        args: CreateEntityArgs,
110        actor: Actor,
111        client: Option<&ClientId>,
112        note: Option<&str>,
113    ) -> Result<CreateEntityOutcome, EngineError> {
114        let drift_warnings = self.reload_if_stale(Some(&args.mem));
115        // Declared relations on an ACYCLIC rel-type (or one in an
116        // `acyclic_sets` set, whose guard walks the set's UNION
117        // subgraph) run the same whole-subgraph cycle guard relate
118        // runs — full load first, or a cycle through a deferred mem's
119        // edge is invisible (see the relate path's comment for the
120        // demonstrated failure). Declared signals on the new entity's
121        // schema or any relation target's schema need the full load
122        // too: the threshold-crossing diff counts edges that can
123        // originate in any mem.
124        if args.relations.iter().any(|r| {
125            self.schemas.get(&args.mem).is_some_and(|s| {
126                s.relationship_acyclic(&r.rel_type)
127                    || s.acyclic_set_containing(&r.rel_type).is_some()
128            }) || self
129                .schemas
130                .get(r.target.mem())
131                .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
132        }) || self
133            .schemas
134            .get(&args.mem)
135            .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
136        {
137            self.ensure_mems_loaded(None);
138        }
139        match self.prepare_create(args, None, drift_warnings)? {
140            CreatePrepareOutcome::Done(outcome) => Ok(outcome),
141            CreatePrepareOutcome::Prepared(prepared) => {
142                self.commit_prepared_create(prepared, actor, client, note)
143            }
144        }
145    }
146
147    /// Validate a create and compute everything up to (but not
148    /// including) the disk write — the create-side prepare of the
149    /// prepare-all-then-commit split `batch_update` established.
150    /// Returns [`CreatePrepareOutcome::Done`] for a dry-run (its
151    /// outcome is complete), [`CreatePrepareOutcome::Prepared`] for a
152    /// real write the caller commits via
153    /// [`Self::commit_prepared_create`].
154    ///
155    /// `batch_skeleton_ids` is the batch path's staging set: ids the
156    /// current batch has pre-inserted as skeleton entities so
157    /// intra-batch references validate as REAL targets. A create whose
158    /// id is in the set skips the already-exists refusal (the skeleton
159    /// is this very entry's placeholder — batch-side identity checks
160    /// have already refused genuine duplicates). Single-item callers
161    /// pass `None`.
162    /// NOTE: the reload-before-operation drift probe is the CALLER's
163    /// job (single-item: `create_entity` probes its one mem; batch:
164    /// `batch_create` probes every touched mem once, up front). A probe
165    /// inside prepare would reload mid-batch and wipe the staged
166    /// skeletons.
167    fn prepare_create(
168        &mut self,
169        args: CreateEntityArgs,
170        batch_skeleton_ids: Option<&std::collections::HashSet<EntityId>>,
171        mut drift_warnings: Vec<WarningHint>,
172    ) -> Result<CreatePrepareOutcome, EngineError> {
173        let mut args = args;
174        // Canonicalise rel_type on every inline relation — same contract
175        // as `relate_entity`: input is case-insensitive, storage and
176        // response are UPPER_SNAKE_CASE. Syntax errors fall through to
177        // the schema check, which surfaces them as INVALID_REL_TYPE.
178        for rel in &mut args.relations {
179            if let Ok(canonical) = crate::entity::id::validate_rel_type(&rel.rel_type) {
180                rel.rel_type = canonical;
181            }
182        }
183
184        // Trim surrounding whitespace from the title before slug
185        // derivation + storage. Internal whitespace is preserved.
186        // Fully-whitespace titles collapse to empty and fall through to
187        // the validator below (which already refuses empty). Without
188        // trimming, a caller-supplied
189        // `"   Foo   "` renders with leading/trailing spaces despite the
190        // slug being correct. We emit `TITLE_TRIMMED` whenever trimming
191        // changed the value so the audit trail records the drift.
192        let mut title_trimmed_warning: Option<crate::ops::WarningHint> = None;
193        let trimmed_title = args.title.trim();
194        if trimmed_title.len() != args.title.len() {
195            title_trimmed_warning = Some(crate::ops::WarningHint::TitleTrimmed {
196                original: args.title.clone(),
197                trimmed: trimmed_title.to_string(),
198            });
199            args.title = trimmed_title.to_string();
200        }
201
202        // 1. Resolve the mount and gate on capability.
203        let mount_idx = self
204            .mounts
205            .iter()
206            .position(|m| m.mount.mem == args.mem)
207            .ok_or_else(|| self.unknown_mem_error(&args.mem))?;
208        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
209            return Err(EngineError::ReadOnlyMount(args.mem));
210        }
211
212        // 1a. Reload-before-operation. Probe the mem ref and reload
213        //     if a sibling writer advanced it past our cached head, so
214        //     the duplicate-id check below and the eventual commit both
215        //     run against current truth. Any `MemReloaded` warning
216        //     rides the outcome's `warnings` (merged at the accumulator
217        //     below). This is what makes a create at an id a sibling
218        //     just created refuse as already-exists rather than
219        //     silently rebasing onto an unobserved commit.
220        // 2. Resolve schema + type. The schema map is populated for
221        //    every mount during `from_mounts`, so the lookup is total.
222        let schema = self
223            .schemas
224            .get(&args.mem)
225            .expect("schema present for every registered mount");
226        let type_def = schema
227            .get_type(&args.entity_type)
228            .ok_or_else(|| unknown_type_error(schema, &args.entity_type))?;
229
230        // 3. Pre-write validators: section keys and metadata values.
231        validate_section_keys(args.sections.keys().map(String::as_str), type_def.as_ref())?;
232        // Reserved identity/discriminator keys (`mem`/`id`/`type`)
233        // refuse deliberately (`READ_ONLY_FIELD`) before the metadata
234        // parse loop can refuse them incidentally as
235        // `UNKNOWN_METADATA_FIELD` — symmetric with the update path's
236        // set gate, so the two paths agree and the refusal names the
237        // real reason. Timestamp fields keep create's documented
238        // stamp-and-proceed posture (`IGNORED_READONLY_FIELD` warning,
239        // step 5a) — only the triple is checked here.
240        for key in args.metadata.keys() {
241            crate::runtime_validator::validate_reserved_metadata_key(key.as_str())?;
242        }
243        // 3a. Validate any `anchors[]` payload up front — a malformed
244        //     element (unknown class/grain, missing artifact, hash on a
245        //     non-hash class, grain unsupported by the resolving medium's
246        //     namespace) refuses the WHOLE create with a typed
247        //     `INVALID_ANCHOR` envelope BEFORE any disk write, so the
248        //     entity is never written. Empty payload → empty vec (no
249        //     sidecar write; byte-identical to a pre-anchor create). Runs
250        //     even on the dry_run path so validity agrees across preview
251        //     and real write.
252        let validated_anchors = self.validate_anchor_inputs(&args.mem, &args.anchors)?;
253        // Refuse section content with embedded `^## ` headings — the
254        // compose-then-reparse pipeline would split the value at the
255        // heading and silently move the trailing content into another
256        // section.
257        let mut heading_buf: Vec<&str> = Vec::new();
258        let catch_all = crate::runtime_validator::catch_all_context(&type_def, &mut heading_buf);
259        validate_section_content(
260            args.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())),
261            catch_all,
262        )?;
263
264        // 4. Slug + id; reject duplicates against the in-memory store.
265        //    Stub adoption: a pre-existing stub at the same id is
266        //    *not* a duplicate — the create promotes the stub to a
267        //    real entity while preserving its incoming edges (store.
268        //    upsert leaves in_edges in place). Mirrors full's
269        //    `if let Some(existing) = store.get(&id) && !existing.stub`.
270        let derivation = validate_and_derive_slug(&args.title)?;
271        let slug = derivation.slug.clone();
272        let id = EntityId::new(&args.mem, &slug);
273        crate::entity::id::enforce_id_length(id.as_ref())?;
274        if let Some(existing) = self.store.get(&id)
275            && !existing.stub
276            && !batch_skeleton_ids.is_some_and(|set| set.contains(&id))
277        {
278            return Err(EngineError::AlreadyExists {
279                id: id.to_string(),
280                existing_title: existing.title.clone(),
281                existing_is_stub: false,
282            });
283        }
284        let file_path = format!("{slug}.md");
285
286        // 5. Build metadata. `type` is seeded so the generator emits
287        //    the canonical frontmatter; caller-provided overrides go
288        //    through `parse_metadata_value` for enum / type checks.
289        let mut metadata: IndexMap<String, MetadataValue> = IndexMap::new();
290        metadata.insert(
291            "type".to_string(),
292            MetadataValue::String(args.entity_type.clone()),
293        );
294        for (k, v) in &args.metadata {
295            let parsed = parse_metadata_value(k.as_str(), v.as_str(), type_def.as_ref())?;
296            metadata.insert(k.clone(), parsed);
297        }
298
299        // 5a. Engine-managed timestamps: schema-declared `init_timestamp`
300        //     and `auto_timestamp` fields take the engine value
301        //     regardless of any caller-supplied override. Symmetric with
302        //     the update path's `auto_timestamp` loop — both flags carry
303        //     a schema-promised meaning the user cannot override.
304        //     `init_timestamp` is create-only (set once, then stable);
305        //     `auto_timestamp` re-stamps on every update.
306        let today = self.now_iso();
307        // Accumulate `IGNORED_READONLY_FIELD` warnings: when the caller
308        // supplied a value for an auto-managed field, the engine value
309        // overwrites it below — surface that the input was discarded
310        // rather than swallowing it silently (the update path refuses
311        // these keys with `READ_ONLY_FIELD`; create's posture is
312        // stamp-and-proceed, so it warns). Built here, merged into the
313        // response `warnings` accumulator once that exists.
314        let mut ignored_readonly: Vec<WarningHint> = Vec::new();
315        for field_def in &type_def.metadata_fields {
316            if field_def.init_timestamp || field_def.auto_timestamp {
317                if let Some(supplied) = args.metadata.get(field_def.key.as_str()) {
318                    ignored_readonly.push(WarningHint::IgnoredReadonlyField {
319                        field: field_def.key.clone(),
320                        supplied: supplied.clone(),
321                    });
322                }
323                metadata.insert(field_def.key.clone(), MetadataValue::String(today.clone()));
324            }
325        }
326
327        // 6. Refuse — not warn — when required sections are absent or
328        //    empty. Pre-fix this branch emitted a `WarningHint` per
329        //    missing section and let the entity land with empty
330        //    placeholders; the resulting on-disk state then failed the
331        //    install-time strict validator, breaking the export-then-
332        //    install round-trip. The refusal carries every missing
333        //    section plus the type-level `type_guidance` map so the
334        //    agent recovers in a single round-trip via re-call with
335        //    the missing content filled in. Iterative authoring stays
336        //    available — the agent creates the entity with whatever
337        //    sections they have, then fills in the rest via
338        //    `memstead_update` (which retains its permissive posture on
339        //    `MISSING_REQUIRED_SECTION`).
340        let missing_sections = missing_required_sections(type_def.as_ref(), &args.sections);
341        if !missing_sections.is_empty() {
342            let mut type_guidance: BTreeMap<String, Vec<String>> = BTreeMap::new();
343            if missing_sections
344                .iter()
345                .any(|m| m.entity_type == type_def.name)
346            {
347                type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
348            }
349            return Err(EngineError::MissingRequiredSection {
350                entity_type: type_def.name.clone(),
351                missing_count: missing_sections.len(),
352                sections: missing_sections,
353                type_guidance,
354                // Cross-gate pre-announcement: step 6a's demand set
355                // depends only on the type definition and the supplied
356                // metadata keys — both fully knowable here — so the
357                // refusal announces it now and the fixed-everything
358                // retry clears both gates in one round-trip. The same
359                // computation runs again at 6a when the sections pass,
360                // which is what keeps the announcement true rather
361                // than merely plausible.
362                pre_announced_missing_fields: missing_required_fields(
363                    type_def.as_ref(),
364                    &args.metadata,
365                ),
366            });
367        }
368
369        // 6a. Parallel for metadata fields: refuse on the first
370        //     missing required field the schema does not auto-fill.
371        //     Same trust-boundary reasoning as the sections case —
372        //     pre-fix the generator silently wrote today's-date / ""
373        //     placeholders that the strict validator at install time
374        //     can refuse. The agent fixes one field per round-trip
375        //     (schema-declaration order); the recovery shape mirrors
376        //     the existing `RequiredFieldUnset` envelope on the update
377        //     path so a single decoder handles both surfaces.
378        let missing_fields = missing_required_fields(type_def.as_ref(), &args.metadata);
379        if !missing_fields.is_empty() {
380            // Surface the
381            // full accumulator (`details.missing[]`) so the agent
382            // fixes every required-no-default field unset in one
383            // retry. The singular `field` / `field_description` /
384            // `enum_values` echo the first entry for back-compat
385            // with consumers reading the singular shape.
386            let first = missing_fields[0].clone();
387            return Err(EngineError::RequiredFieldUnset {
388                field: first.key,
389                entity_type: first.entity_type,
390                field_description: Some(first.description),
391                enum_values: first.enum_values,
392                type_write_rules: type_def.write_rules.clone(),
393                // Create path — the caller never
394                // supplied this field. Display / prose_render flip to
395                // "not provided" wording so the prose matches the
396                // semantic. Recovery is unchanged; the typed code
397                // stays `REQUIRED_FIELD_UNSET`.
398                on_create: true,
399                missing: missing_fields,
400            });
401        }
402
403        let mut warnings: Vec<WarningHint> = Vec::new();
404
405        // Reload-before-operation drift notice (probed at the top, after
406        // the capability gate). Surfaced first so the agent sees the
407        // world moved before reading the rest of the outcome.
408        warnings.append(&mut drift_warnings);
409
410        // Auto-managed fields the caller tried to set (computed during
411        // the stamp loop above) — the supplied values were discarded.
412        warnings.append(&mut ignored_readonly);
413
414        // Title↔slug divergence: the widened title grammar admits
415        // characters the slug alphabet drops — visible, not fatal.
416        if !derivation.dropped_chars.is_empty() {
417            warnings.push(WarningHint::TitleCharsDroppedFromSlug {
418                title: args.title.trim().to_string(),
419                dropped_chars: derivation.dropped_chars.clone(),
420                slug: slug.clone(),
421            });
422        }
423
424        // Surface the title-trim drift (computed pre-validation) so the
425        // audit trail records what the caller sent.
426        if let Some(w) = title_trimmed_warning.take() {
427            warnings.push(w);
428        }
429
430        // 6c. Build `type_guidance` map for the response — one entry
431        //     per distinct entity_type referenced by warnings carrying
432        //     entity-type context (currently
433        //     `UndeclaredRelationshipOpen` etc). Empty when no such
434        //     warnings fire — the section/field cases now refuse
435        //     above. The stable empty shape always ships so callers
436        //     don't branch on field presence.
437        let type_guidance = build_type_guidance(&warnings, type_def.as_ref());
438
439        // 6b. Validate inline relationship inputs through the same
440        //     gates `memstead_relate` runs (Item 02): target-id grammar,
441        //     rel-type vocabulary, schema shape. Pre-fix the create
442        //     path ran only the rel-type check, so an agent could
443        //     sneak a malformed target id (auto-stub at
444        //     `bad@chars$here`) or a shape-violating
445        //     `(rel_type, source_type, target_type)` triple through
446        //     `memstead_create.relations[]` even though `memstead_relate`
447        //     rejected the same input. Strict-mode schemas reject
448        //     unknown rel-types with `INVALID_REL_TYPE`; open-mode
449        //     schemas admit them and surface a typed
450        //     `UndeclaredRelationshipOpen` warning. Stub-as-source
451        //     is impossible here — the source is the newly-created
452        //     entity, always real-by-construction.
453        for rel in &args.relations {
454            validate_relation_target_grammar(&rel.target)?;
455            let target_mem = rel.target.mem().to_string();
456            // Cross-mem policy gate. The funnel
457            // sits ahead of the rel-type / shape checks so the policy
458            // refusal is identical in shape and ordering to
459            // `memstead_relate` and `memstead_update.declare_relations`.
460            super::validate_cross_mem_add_policy(self, &args.mem, &rel.target)?;
461            // Target-type lookup mirrors the relate path: `None` for
462            // not-yet-present targets so the target gate admits the
463            // stub-bound case. The cross-mem router below consults
464            // it for both intra-mem shape and cross-mem-different
465            // shape checks.
466            let target_type = self
467                .store
468                .get(&rel.target)
469                .map(|e| e.entity_type.clone())
470                .filter(|t| !t.is_empty());
471            // Deferred-mem target (flywheel W7/02): the store cannot
472            // answer for an unloaded mem — the real type comes from
473            // the one resolved blob, without loading the mem. `None`
474            // for non-deferred or absent targets, unchanged posture.
475            let target_type = match target_type {
476                Some(t) => Some(t),
477                None => super::peek_deferred_target_type(self, &rel.target)?,
478            };
479            match route_edge_validation(
480                self,
481                &rel.rel_type,
482                args.entity_type.as_str(),
483                target_type.as_deref(),
484                &args.mem,
485                &target_mem,
486                &id,
487                &rel.target,
488                /* check_shape = */ true,
489            )? {
490                EdgeRouteOutcome::Ok => {}
491                EdgeRouteOutcome::OpenModeWarning(w) => warnings.push(*w),
492            }
493            // Per-edge description posture. Normalise first so empty
494            // strings collapse to `None` before the gate.
495            let normalised = normalise_description(rel.description.as_deref());
496            super::validate_description_posture(
497                self,
498                &rel.rel_type,
499                normalised.as_deref(),
500                &args.mem,
501                &target_mem,
502                &id,
503                &rel.target,
504            )?;
505            // Explicit inline-relations path is an
506            // explicit-author boundary — gate on the rel-type's
507            // `manual_authoring` posture.
508            super::validate_manual_authoring_posture(
509                self,
510                &rel.rel_type,
511                &args.mem,
512                &id,
513                &rel.target,
514            )?;
515            // Cycle family — the same shared gate `memstead_relate` runs
516            // (self-loop on listed no-self-loop rel-types, long cycle
517            // on acyclic ones), against the current store. A stub being promoted
518            // by this create already carries its incoming edges, so a
519            // back-path through the new id is visible; on the batch
520            // path prior items' edges are staged into the store, so an
521            // intra-batch cycle refuses here too. Canonicalise the
522            // rel-type first (same derivation as
523            // `update.declare_relations`) so the schema lookups see
524            // the wire-contract form.
525            let canonical = crate::entity::id::validate_rel_type(&rel.rel_type)
526                .unwrap_or_else(|_| rel.rel_type.clone());
527            super::validate_edge_acyclicity(
528                &self.store,
529                schema.as_ref(),
530                &id,
531                args.entity_type.as_str(),
532                &rel.target,
533                &canonical,
534            )?;
535        }
536
537        // 7. Synthesise the in-memory entity for the generator. The
538        //    `content_hash` and `heading_spans` are derived; left
539        //    blank because we re-parse the generated bytes below.
540        //    Inline relations land in `relationships` so the
541        //    generator emits them and the post-parse re-ingest
542        //    rebuilds the edges in the store.
543        let relationships: Vec<Relationship> = args
544            .relations
545            .iter()
546            .map(|r| Relationship {
547                rel_type: r.rel_type.clone(),
548                target: r.target.clone(),
549                description: normalise_description(r.description.as_deref()),
550            })
551            .collect();
552        // Pre-compute the `relations_declared` outcome echo. Read
553        // `target_was_stubbed` against the pre-mutation store state
554        // (the post-parse `push_entities_into_store` step will
555        // auto-stub absent targets). Shape matches
556        // `memstead_update.relations_declared` so callers see a uniform
557        // wire shape across the two tools.
558        let relations_declared: Vec<crate::engine::outcomes::RelationDeclared> = args
559            .relations
560            .iter()
561            .map(|r| crate::engine::outcomes::RelationDeclared {
562                rel_type: r.rel_type.clone(),
563                target: r.target.clone(),
564                target_was_stubbed: !self.store.contains(&r.target),
565            })
566            .collect();
567        let mut entity_for_render = Entity {
568            id: id.clone(),
569            title: args.title.clone(),
570            entity_type: args.entity_type.clone(),
571            mem: args.mem.clone(),
572            file_path: file_path.clone(),
573            metadata,
574            sections: args.sections,
575            relationships,
576            content_hash: String::new(),
577            stub: false,
578            stub_kind: None,
579            heading_spans: HashMap::new(),
580            raw_section_headings: Vec::new(),
581        };
582        // Alias-synthesis pass: for schemas declaring
583        // `alias_target_rel_type`, append engine-emitted relations of
584        // that rel-type for every body wiki-link not already backed.
585        // Cross-mem refusal aborts the create — no partial state.
586        // Schemas without the pointer fall through unchanged and the
587        // validator below catches the missing relations.
588        //
589        // The returned `Vec<Relationship>` is the per-call set of
590        // relations the pass just emitted (in body iteration order).
591        // It feeds the `InlineWikiLinkAutoStubbed` emission below —
592        // using the post-mutation `entity.relationships` as the source
593        // via `parse_markdown` filters out the body-link targets
594        // because the parser-side `relationships`-coverage filter has
595        // already absorbed them.
596        let empty_prev_targets = std::collections::HashSet::new();
597        let (synthesised_relations, self_link_ignored) =
598            super::synthesise_alias_relations(self, &empty_prev_targets, &mut entity_for_render)?;
599        if self_link_ignored {
600            warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
601        }
602
603        // Alias-existence invariant: every body wiki-link must be
604        // backed by an entry in `entity.relationships` (the auto-managed
605        // `## Relationships` section). Runs unconditionally on every
606        // Write-Mem create. See [`scan_wikilinks_without_relation`].
607        let missing = super::scan_wikilinks_without_relation(&entity_for_render)?;
608        if !missing.is_empty() {
609            return Err(EngineError::WikiLinkWithoutRelation {
610                from_id: id.to_string(),
611                missing: missing
612                    .into_iter()
613                    .map(|(section_key, target)| crate::engine::MissingWikiLink {
614                        section_key,
615                        target_id: target.to_string(),
616                    })
617                    .collect(),
618            });
619        }
620
621        let markdown = super::render_for_write(&entity_for_render, type_def.as_ref())?;
622
623        // 7a. Inline `[[wiki-link]]` patterns in section bodies that
624        //     point at non-existent targets get auto-stubbed by the
625        //     loader on re-ingest. Surface the would-be stubs as a
626        //     warning so prose-induced ghosts are reviewable. Mirrors
627        //     `memstead_relate`'s `AUTO_STUB_CREATED` observation
628        //     discipline.
629        //
630        //     The input set is the relations the alias-synthesis pass
631        //     emitted on this call — NOT a re-parse of the generated
632        //     markdown. `parse_markdown` filters its `inline_links`
633        //     against the entity's `relationships` vec (which the
634        //     synthesis pass has already appended to), so the
635        //     pre-fix path saw `inline_links: []` and never fired
636        //     the warning. The synthesised vec is the authoritative
637        //     per-call source.
638        let auto_stubbed: Vec<EntityId> = synthesised_relations
639            .iter()
640            .filter_map(|rel| {
641                if !self.store.contains(&rel.target) {
642                    Some(rel.target.clone())
643                } else {
644                    None
645                }
646            })
647            .collect();
648        if !auto_stubbed.is_empty() {
649            warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
650                from: id.clone(),
651                stubs: auto_stubbed,
652            });
653        }
654
655        // 7a-bis. Required-outgoing evaluation — the warning the tool
656        // descriptions have promised all along. Runs now that every
657        // edge this create carries (declared + alias-synthesised) is
658        // known, through the same evaluation the health sweep uses
659        // (one implementation; the two surfaces cannot disagree). A
660        // warning, never a refusal: entities are legitimately built up
661        // over several calls.
662        // Section-format evaluation (plan 08): each written section
663        // body against its declared markdown shape, judged by the
664        // real CommonMark reduction. Block-tier refuses with the
665        // first violation, pre-commit; warn-tier surfaces via the
666        // health sweep, never at write time.
667        for def in &type_def.sections {
668            if def.format_severity != memstead_schema::ConstraintSeverity::Block {
669                continue;
670            }
671            // Absent-as-empty: the generator renders every declared
672            // section heading (empty body when omitted), so the
673            // format judges the state that actually lands on disk —
674            // an expression that does not admit the empty sequence
675            // makes its section effectively required (declare a `?`
676            // or `*` form to admit omission). Without this, an
677            // omitting create passes while health flags the same
678            // on-disk state — write path and health must agree.
679            let body = entity_for_render
680                .sections
681                .get(def.key.as_str())
682                .map(String::as_str)
683                .unwrap_or("");
684            if let Some(first) = crate::section_format::check_section_format(def, body)
685                .into_iter()
686                .next()
687            {
688                return Err(EngineError::SectionFormatRefused {
689                    entity_type: entity_for_render.entity_type.clone(),
690                    entity_id: id.to_string(),
691                    violation: first,
692                });
693            }
694        }
695
696        let unsatisfied = crate::ops::health::unsatisfied_required_outgoing(
697            &entity_for_render,
698            type_def.as_ref(),
699        );
700        if !unsatisfied.is_empty() {
701            // A block declared `severity: block` promotes the warning
702            // to a refusal — evaluated here, before any disk or store
703            // effect, so a refused create leaves nothing behind.
704            let blocked: Vec<_> = unsatisfied
705                .iter()
706                .filter(|b| b.severity == memstead_schema::ConstraintSeverity::Block)
707                .cloned()
708                .collect();
709            if !blocked.is_empty() {
710                return Err(EngineError::RequiredOutgoingUnsatisfied {
711                    entity_type: entity_for_render.entity_type.clone(),
712                    entity_id: id.to_string(),
713                    missing: blocked,
714                });
715            }
716            warnings.push(WarningHint::MissingRequiredOutgoing {
717                entity_type: entity_for_render.entity_type.clone(),
718                entity_id: id.clone(),
719                missing: unsatisfied,
720            });
721        }
722
723        // Declared-constraints evaluation (`requires_when`, …) — the
724        // same single evaluation the health `constraints` include
725        // runs. Block-tier violations refuse; warn-tier violations
726        // warn and the write proceeds.
727        let violated = crate::ops::health::unsatisfied_constraints(
728            &self.store,
729            &entity_for_render,
730            type_def.as_ref(),
731            None,
732        );
733        if !violated.is_empty() {
734            let blocked: Vec<_> = violated
735                .iter()
736                .filter(|v| v.severity() == memstead_schema::ConstraintSeverity::Block)
737                .cloned()
738                .collect();
739            if !blocked.is_empty() {
740                return Err(EngineError::ConstraintUnsatisfied {
741                    entity_type: entity_for_render.entity_type.clone(),
742                    entity_id: id.to_string(),
743                    violations: blocked,
744                });
745            }
746            warnings.push(WarningHint::ConstraintUnsatisfied {
747                entity_type: entity_for_render.entity_type.clone(),
748                entity_id: id.clone(),
749                violations: violated,
750            });
751        }
752
753        // 7b. Dry-run: compute prospective hash from the in-memory
754        //     entity and return without touching disk, store, or
755        //     edges. Mirrors full's `CreateArgs.dry_run` semantics —
756        //     `content_hash` carries the prospective hash since
757        //     there's no current to differentiate from. `write_id`
758        //     is empty. Stub creation is also skipped (no
759        //     in-memory side effects).
760        if args.dry_run {
761            let prospective_hash = crate::entity::parser::compute_hash(&markdown);
762            // `created_date` from the in-memory entity (the
763            // metadata-construction loop already set the
764            // init_timestamp default to `today_iso`-equivalent).
765            let created_date = entity_for_render
766                .metadata
767                .get("created_date")
768                .map(|v| v.to_frontmatter_string())
769                .unwrap_or_default();
770            // Full's dry_run computes incoming from the existing
771            // store state (the refs that *would* be adopted if a
772            // stub exists at this id). Read before any mutation.
773            let incoming = project_incoming(self.store.incoming(&id));
774            let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
775            return Ok(CreatePrepareOutcome::Done(CreateEntityOutcome {
776                id,
777                title: args.title,
778                mem: args.mem,
779                file_path,
780                content_hash: prospective_hash,
781                write_id: String::new(),
782                created_date,
783                warnings,
784                type_guidance,
785                incoming_count,
786                incoming,
787                relations_declared: relations_declared.clone(),
788            }));
789        }
790
791        Ok(CreatePrepareOutcome::Prepared(PreparedCreate {
792            mount_idx,
793            id,
794            title: args.title,
795            mem: args.mem,
796            file_path,
797            markdown,
798            anchors: validated_anchors,
799            warnings,
800            type_guidance,
801            relations_declared,
802            relation_targets: args.relations.iter().map(|r| r.target.clone()).collect(),
803            type_def,
804        }))
805    }
806
807    /// Stage the prepared disk write, commit it, append provenance, and
808    /// apply the change to the in-memory store — the single-create tail
809    /// of [`Self::create_entity`]. The batch path drives the same
810    /// steps but stages every item first and commits once per mem.
811    fn commit_prepared_create(
812        &mut self,
813        prepared: PreparedCreate,
814        actor: Actor,
815        client: Option<&ClientId>,
816        note: Option<&str>,
817    ) -> Result<CreateEntityOutcome, EngineError> {
818        let PreparedCreate {
819            mount_idx,
820            id,
821            title,
822            mem,
823            file_path,
824            markdown,
825            anchors: validated_anchors,
826            mut warnings,
827            type_guidance,
828            relations_declared,
829            relation_targets,
830            type_def,
831        } = prepared;
832
833        // Aggregate signals: the entities a create can move are the
834        // new entity itself (baseline all-`none`) and the targets of
835        // its inline relations. Captured before the store mutates,
836        // diffed after the push below.
837        let signal_snapshot = {
838            let mut candidates: Vec<&EntityId> = vec![&id];
839            candidates.extend(relation_targets.iter());
840            crate::ops::signals::snapshot_levels(&self.store, &self.schemas, candidates)
841        };
842
843        // 8. Write + commit through the backend. The commit subject
844        //    is `memstead: create <id>` so the git-branch backend's
845        //    `read_provenance` recovers the kind via the verb. The
846        //    folder backend's commit ignores the message; the
847        //    canonical form is harmless there.
848        let backend = self.mounts[mount_idx].backend.as_ref();
849        backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
850        // Stage the anchors sidecar into the SAME pending buffer so it
851        // rides the entity's commit atomically. Only when the create
852        // carried anchors — an anchorless create writes no sidecar and is
853        // byte-identical to a pre-anchor create.
854        if !validated_anchors.is_empty() {
855            super::stage_anchors_sidecar(backend, &id, &[], validated_anchors)?;
856        }
857        // Derivation baselines (agent-trust plan 12): each explicitly
858        // declared relation on a derivation rel-type records the
859        // target's current hash ("" for an absent/stubbed target),
860        // staged so baseline and entity ride one commit.
861        if let Some(schema) = self.schemas.get(&mem) {
862            for r in relations_declared
863                .iter()
864                .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
865            {
866                let hash = self
867                    .store
868                    .get(&r.target)
869                    .map(|e| e.content_hash.clone())
870                    .unwrap_or_default();
871                let (from, rel, to) = (id.to_string(), r.rel_type.clone(), r.target.to_string());
872                super::stage_derivation_sidecar(backend, |s| s.set(&from, &rel, &to, &hash))?;
873            }
874        }
875        let commit_subject = format!("memstead: create {id}");
876        let ctx = CommitContext {
877            actor,
878            client: client.cloned(),
879            tool: Some("create_entity"),
880            note: note.map(String::from),
881            role: self.current_role,
882            logical_operation_id: None,
883            entity_ids: None,
884        };
885        let write_id = backend.commit(&commit_subject, &ctx)?;
886
887        // 9. Append provenance. Folder writes a JSONL line; git-branch
888        //    no-ops (the commit object already carries the data).
889        backend.append_provenance(
890            &Provenance::new(
891                std::time::SystemTime::now(),
892                ProvenanceKind::Create,
893                Some(id.to_string()),
894                actor,
895                client.cloned(),
896                note.map(String::from),
897            )
898            .with_role(self.current_role),
899        )?;
900
901        // Self-write bookkeeping: jump `last_known_head` to the SHA
902        // we just produced so the next read doesn't surface
903        // `MEM_RELOADED` for our own commit.
904        self.record_self_write(mount_idx, &write_id);
905        let stamp_warnings = self.stamp_mutation_versions(mount_idx);
906
907        // 10. Update the in-memory store via re-parse so the store
908        //     mirrors the on-disk shape (content_hash, heading_spans).
909        let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &mem)
910            .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
911        let content_hash = parse_result.entity.content_hash.clone();
912
913        // Extract `created_date` from the parsed entity's metadata
914        // before pushing into the store (after push, the entity is
915        // borrowed by the store and re-fetching costs a lookup).
916        // The default schema's auto-timestamp fills `created_date`
917        // with today's ISO date; the field is empty for schemas
918        // that don't declare it.
919        let created_date = parse_result
920            .entity
921            .metadata
922            .get("created_date")
923            .map(|v| v.to_frontmatter_string())
924            .unwrap_or_default();
925
926        let fallback = engine_fallback_type();
927        push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
928        crate::entity::store_builder::remap_alias_target_edge_sources(
929            &mut self.store,
930            &self.schemas,
931        );
932
933        // Materialise stubs for any inline-relation targets that
934        // weren't already in the store. Mirrors the relate path's
935        // ensure_target — full's create relies on the
936        // loader stubbing unresolved targets, but the unified
937        // store doesn't auto-stub on push, so the engine does it
938        // explicitly. Skipped when no relations were declared
939        // (the args.relations vec is empty).
940        for target in &relation_targets {
941            if !self.store.contains(target) {
942                let kind = super::deferred_verified_stub_kind(self, target)?;
943                self.store.upsert(target.clone(), make_stub(target, kind));
944            }
945        }
946
947        self.invalidate_communities();
948        // Incremental (flywheel W8/01): the new entity is the whole
949        // touched set — its stub targets are never indexed.
950        self.maintain_search_indexes(std::slice::from_ref(&id));
951
952        // Stub-adoption visibility: project the incoming edges that
953        // survived the upsert. Empty for a fresh create; populated
954        // when a pre-existing stub at this id had referrers.
955        let incoming = project_incoming(self.store.incoming(&id));
956        let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
957
958        // Signal crossings — out-of-band beside the success payload,
959        // never error-shaped.
960        warnings.extend(stamp_warnings);
961        warnings.extend(crate::ops::signals::crossing_warnings(
962            &self.store,
963            &self.schemas,
964            &signal_snapshot,
965        ));
966
967        // `require_notes` provenance nudge — single engine-level
968        // enforcement point (see `Engine::note_missing_warning`). Only
969        // reached on the real-write path (commit landed); the dry-run
970        // early return above never demands a note.
971        if let Some(w) = self.note_missing_warning("create_entity", note) {
972            warnings.push(w);
973        }
974
975        Ok(CreateEntityOutcome {
976            id,
977            title,
978            mem,
979            file_path,
980            content_hash,
981            write_id,
982            created_date,
983            warnings,
984            type_guidance,
985            incoming_count,
986            incoming,
987            relations_declared,
988        })
989    }
990
991    /// Atomic batch create — the create-side sibling of
992    /// [`Self::batch_update`], with one upgrade and one addition:
993    ///
994    /// - **Report-all refusal.** Every failing entry is identified with
995    ///   its index and typed `{code, message, details}` envelope (the
996    ///   family's upgraded contract) — bounded at
997    ///   [`Self::BATCH_ERROR_REPORT_CAP`] detailed envelopes, with
998    ///   `errors_suppressed` counting the rest. A refused batch writes
999    ///   NOTHING: no entity, no edge, no head movement.
1000    /// - **Intra-batch references resolve as REAL targets.** Every
1001    ///   entity in the batch is staged (a skeleton store entry carrying
1002    ///   its declared type) before per-entry validation runs, so an
1003    ///   edge to a sibling created in the same batch gets full
1004    ///   target-type shape validation, no transient stub, and no stub
1005    ///   warning — the batch validates as one graph state, cycles
1006    ///   included where the schema permits them. Duplicates within the
1007    ///   batch are refused in the identity pass.
1008    ///
1009    /// One workspace load (the caller's), one commit per touched mem
1010    /// (subject `memstead: batch-create (N entities)`), per-entry
1011    /// provenance notes exactly like `batch_update`.
1012    ///
1013    /// **Rehearsal** (`dry_run: true`): the FULL validation pass runs —
1014    /// identity, skeleton staging (so intra-batch references resolve as
1015    /// real targets, cycles included), per-entry prepare, report-all
1016    /// refusals — then the batch stops before any write. A legal batch
1017    /// returns the would-be receipt (`applied: true`, per-entry
1018    /// `"created"` with the prospective ids) with the marker form's
1019    /// empty `write_id`; an illegal one returns the same refusal a
1020    /// real call would. Nothing is written, committed, or stubbed.
1021    pub fn batch_create(
1022        &mut self,
1023        creates: Vec<(CreateEntityArgs, Option<String>)>,
1024        actor: Actor,
1025        client: Option<&ClientId>,
1026        dry_run: bool,
1027    ) -> Result<crate::ops::BatchResult, EngineError> {
1028        use std::collections::HashSet;
1029
1030        if creates.is_empty() {
1031            return Ok(crate::ops::BatchResult {
1032                warnings: Vec::new(),
1033                orphan_stubs_removed: Vec::new(),
1034                errors_suppressed: 0,
1035                applied: true,
1036                results: Vec::new(),
1037                succeeded: 0,
1038                failed: 0,
1039                write_id: String::new(),
1040            });
1041        }
1042
1043        // Reload every touched mem once, up front.
1044        let mut touched_mems: Vec<String> = creates.iter().map(|(a, _)| a.mem.clone()).collect();
1045        touched_mems.sort();
1046        touched_mems.dedup();
1047        for m in &touched_mems {
1048            self.reload_if_stale(Some(m));
1049        }
1050        // Same acyclic-guard rule as the single-item path: declared
1051        // relations on an ACYCLIC rel-type (or one in an
1052        // `acyclic_sets` set) walk the whole subgraph, so the walk
1053        // must see every mem — deferred ones included (see the
1054        // batch_relate comment). Declared signals on any involved
1055        // schema need the full load too (see the single-item path).
1056        if creates.iter().any(|(a, _)| {
1057            a.relations.iter().any(|r| {
1058                self.schemas.get(&a.mem).is_some_and(|s| {
1059                    s.relationship_acyclic(&r.rel_type)
1060                        || s.acyclic_set_containing(&r.rel_type).is_some()
1061                }) || self
1062                    .schemas
1063                    .get(r.target.mem())
1064                    .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1065            }) || self
1066                .schemas
1067                .get(&a.mem)
1068                .is_some_and(|s| s.types.values().any(|td| !td.signals.is_empty()))
1069        }) {
1070            self.ensure_mems_loaded(None);
1071        }
1072
1073        let store_snapshot = self.store.clone();
1074
1075        // --- Identity pass: derive every entry's id, refusing
1076        // duplicates against the pre-batch store AND within the batch.
1077        // Collect EVERY failure (report-all), never just the first.
1078        struct IdentityRow {
1079            id: Option<EntityId>,
1080            error: Option<EngineError>,
1081        }
1082        let mut rows: Vec<IdentityRow> = Vec::with_capacity(creates.len());
1083        // id → title of the batch entry that claimed it, so a
1084        // within-batch duplicate can name the occupying title.
1085        let mut batch_ids: HashMap<EntityId, String> = HashMap::new();
1086        for (args, _) in &creates {
1087            let identity = (|| -> Result<EntityId, EngineError> {
1088                let title = args.title.trim();
1089                // Divergence warnings ride the per-entry prepare pass
1090                // below, which re-derives; this pass only needs the id.
1091                let slug = validate_and_derive_slug(title)?.slug;
1092                let id = EntityId::new(&args.mem, &slug);
1093                crate::entity::id::enforce_id_length(id.as_ref())?;
1094                if let Some(existing) = self.store.get(&id)
1095                    && !existing.stub
1096                {
1097                    return Err(EngineError::AlreadyExists {
1098                        id: id.to_string(),
1099                        existing_title: existing.title.clone(),
1100                        existing_is_stub: false,
1101                    });
1102                }
1103                if let Some(prior_title) = batch_ids.get(&id) {
1104                    // Duplicate WITHIN the batch — same typed code as
1105                    // the store collision; the index in the report
1106                    // localises it.
1107                    return Err(EngineError::AlreadyExists {
1108                        id: id.to_string(),
1109                        existing_title: prior_title.clone(),
1110                        existing_is_stub: false,
1111                    });
1112                }
1113                Ok(id)
1114            })();
1115            match identity {
1116                Ok(id) => {
1117                    batch_ids.insert(id.clone(), args.title.trim().to_string());
1118                    rows.push(IdentityRow {
1119                        id: Some(id),
1120                        error: None,
1121                    });
1122                }
1123                Err(e) => rows.push(IdentityRow {
1124                    id: None,
1125                    error: Some(e),
1126                }),
1127            }
1128        }
1129
1130        // --- Skeleton staging: make every batch id a REAL, typed store
1131        // entry so sibling references validate against present targets.
1132        // A pre-existing stub at a batch id is replaced (its incoming
1133        // edges survive the upsert — the same adoption the single-item
1134        // create performs).
1135        for ((args, _), row) in creates.iter().zip(rows.iter()) {
1136            if let Some(id) = &row.id {
1137                let mut skeleton = make_stub(id, crate::entity::StubKind::ForwardReference);
1138                skeleton.stub = false;
1139                skeleton.stub_kind = None;
1140                skeleton.entity_type = args.entity_type.clone();
1141                skeleton.title = args.title.trim().to_string();
1142                self.store.upsert(id.clone(), skeleton);
1143            }
1144        }
1145
1146        // --- Full prepare pass, report-all. Skeletons make intra-batch
1147        // targets real; each entry's own skeleton is exempted from the
1148        // duplicate check via `batch_skeleton_ids`.
1149        let mut prepared: Vec<PreparedCreate> = Vec::new();
1150        let mut notes: Vec<Option<String>> = Vec::new();
1151        let mut errors: Vec<(usize, EngineError)> = Vec::new();
1152        let mut ids_in_order: Vec<EntityId> = Vec::new();
1153        let skeleton_ids: HashSet<EntityId> = batch_ids.keys().cloned().collect();
1154        for (i, ((args, note), row)) in creates.into_iter().zip(rows).enumerate() {
1155            let fallback_id = row
1156                .id
1157                .clone()
1158                .unwrap_or_else(|| EntityId::new(&args.mem, "invalid-entry"));
1159            ids_in_order.push(fallback_id);
1160            if let Some(e) = row.error {
1161                errors.push((i, e));
1162                continue;
1163            }
1164            // Rehearsal is batch-level (the `dry_run` parameter) —
1165            // per-entry dry-run stays forced off so the prepare pass
1166            // below never short-circuits into a per-entry preview.
1167            let mut args = args;
1168            args.dry_run = false;
1169            match self.prepare_create(args, Some(&skeleton_ids), Vec::new()) {
1170                Ok(CreatePrepareOutcome::Prepared(p)) => {
1171                    // Stage this item's declared edges onto its skeleton
1172                    // so later items validate against the batch's own
1173                    // graph state — an intra-batch cycle on an acyclic
1174                    // rel-type refuses exactly like a stored one
1175                    // (`validate_edge_acyclicity` walks the store). The
1176                    // snapshot rollback discards these on refusal; the
1177                    // apply pass replaces them with the parsed truth.
1178                    for r in &p.relations_declared {
1179                        self.store.add_edge(
1180                            p.id.clone(),
1181                            crate::store::Edge {
1182                                rel_type: r.rel_type.clone(),
1183                                target: r.target.clone(),
1184                                source: crate::store::EdgeSource::Explicit,
1185                            },
1186                        );
1187                    }
1188                    ids_in_order[i] = p.id.clone();
1189                    prepared.push(p);
1190                    notes.push(note);
1191                }
1192                Ok(CreatePrepareOutcome::Done(_)) => unreachable!("dry_run forced off"),
1193                Err(e) => errors.push((i, e)),
1194            }
1195        }
1196
1197        if !errors.is_empty() {
1198            // Refuse the whole batch; nothing was committed and the
1199            // store snapshot rolls back the skeletons.
1200            self.store = store_snapshot;
1201            self.discard_all_pending();
1202            let failed = errors.len();
1203            let mut error_map: std::collections::HashMap<usize, EngineError> =
1204                errors.into_iter().collect();
1205            let mut reported = 0usize;
1206            let mut suppressed = 0usize;
1207            let results: Vec<crate::ops::BatchEntry> = ids_in_order
1208                .into_iter()
1209                .enumerate()
1210                .map(|(i, id)| match error_map.remove(&i) {
1211                    Some(e) => {
1212                        if reported < Self::BATCH_ERROR_REPORT_CAP {
1213                            reported += 1;
1214                            crate::ops::BatchEntry {
1215                                id,
1216                                action: "error".to_string(),
1217                                error: Some(super::update::batch_error_envelope(&e)),
1218                            }
1219                        } else {
1220                            suppressed += 1;
1221                            crate::ops::BatchEntry {
1222                                id,
1223                                action: "error".to_string(),
1224                                error: None,
1225                            }
1226                        }
1227                    }
1228                    None => crate::ops::BatchEntry {
1229                        id,
1230                        action: "not_applied".to_string(),
1231                        error: None,
1232                    },
1233                })
1234                .collect();
1235            return Ok(crate::ops::BatchResult {
1236                warnings: Vec::new(),
1237                orphan_stubs_removed: Vec::new(),
1238                errors_suppressed: suppressed,
1239                applied: false,
1240                results,
1241                succeeded: 0,
1242                failed,
1243                write_id: String::new(),
1244            });
1245        }
1246
1247        // Rehearsal: every entry validated against the batch's own
1248        // graph state (skeletons made intra-batch targets real) and
1249        // nothing failed — stop before any write. Roll back the
1250        // skeleton staging and return the would-be receipt with the
1251        // marker form's empty `write_id`.
1252        if dry_run {
1253            self.store = store_snapshot;
1254            self.discard_all_pending();
1255            let succeeded = prepared.len();
1256            let results: Vec<crate::ops::BatchEntry> = prepared
1257                .into_iter()
1258                .map(|p| crate::ops::BatchEntry {
1259                    id: p.id,
1260                    action: "created".to_string(),
1261                    error: None,
1262                })
1263                .collect();
1264            return Ok(crate::ops::BatchResult {
1265                warnings: Vec::new(),
1266                orphan_stubs_removed: Vec::new(),
1267                errors_suppressed: 0,
1268                applied: true,
1269                results,
1270                succeeded,
1271                failed: 0,
1272                write_id: String::new(),
1273            });
1274        }
1275
1276        // --- Stage every write + anchors, then commit once per mem.
1277        for p in &prepared {
1278            if let Err(e) = self.mounts[p.mount_idx]
1279                .backend
1280                .write_entity(Path::new(&p.file_path), p.markdown.as_bytes())
1281            {
1282                self.store = store_snapshot;
1283                self.discard_all_pending();
1284                return Err(e.into());
1285            }
1286            if !p.anchors.is_empty()
1287                && let Err(e) = super::stage_anchors_sidecar(
1288                    self.mounts[p.mount_idx].backend.as_ref(),
1289                    &p.id,
1290                    &[],
1291                    p.anchors.clone(),
1292                )
1293            {
1294                self.store = store_snapshot;
1295                self.discard_all_pending();
1296                return Err(e);
1297            }
1298            // Derivation baselines (plan 12) — same predicate and
1299            // staging as the single create; rides the batch commit.
1300            if let Some(schema) = self.schemas.get(&p.mem) {
1301                for r in p
1302                    .relations_declared
1303                    .iter()
1304                    .filter(|r| super::rel_type_declares_derivation(schema, &r.rel_type))
1305                {
1306                    let hash = self
1307                        .store
1308                        .get(&r.target)
1309                        .map(|e| e.content_hash.clone())
1310                        .unwrap_or_default();
1311                    let (from, rel, to) =
1312                        (p.id.to_string(), r.rel_type.clone(), r.target.to_string());
1313                    if let Err(e) = super::stage_derivation_sidecar(
1314                        self.mounts[p.mount_idx].backend.as_ref(),
1315                        |s| s.set(&from, &rel, &to, &hash),
1316                    ) {
1317                        self.store = store_snapshot;
1318                        self.discard_all_pending();
1319                        return Err(e);
1320                    }
1321                }
1322            }
1323        }
1324        let mut distinct_mounts: Vec<usize> = Vec::new();
1325        for p in &prepared {
1326            if !distinct_mounts.contains(&p.mount_idx) {
1327                distinct_mounts.push(p.mount_idx);
1328            }
1329        }
1330        let mut mount_commits: Vec<(usize, String)> = Vec::with_capacity(distinct_mounts.len());
1331        for &m in &distinct_mounts {
1332            let entity_ids: Vec<String> = prepared
1333                .iter()
1334                .filter(|p| p.mount_idx == m)
1335                .map(|p| p.id.to_string())
1336                .collect();
1337            let count = entity_ids.len();
1338            let subject = format!("memstead: batch-create ({count} entities)");
1339            // Per-entry notes ride the ONE batch commit's note record as
1340            // `<id>: <note>` lines (decision 3, backlog-sweep plan 05):
1341            // `append_provenance` below is a documented no-op on the
1342            // git-branch backend, so without this the notes survived
1343            // nowhere exactly where most writes happen. A batch with no
1344            // notes carries no note record at all.
1345            let note_lines: Vec<String> = prepared
1346                .iter()
1347                .zip(notes.iter())
1348                .filter(|(p, _)| p.mount_idx == m)
1349                .filter_map(|(p, n)| n.as_ref().map(|n| format!("{}: {n}", p.id)))
1350                .collect();
1351            let ctx = CommitContext {
1352                actor,
1353                client: client.cloned(),
1354                tool: Some("batch_create"),
1355                note: if note_lines.is_empty() {
1356                    None
1357                } else {
1358                    Some(note_lines.join("\n"))
1359                },
1360                role: self.current_role,
1361                logical_operation_id: None,
1362                entity_ids: Some(entity_ids),
1363            };
1364            match self.mounts[m].backend.commit(&subject, &ctx) {
1365                Ok(sha) => mount_commits.push((m, sha)),
1366                Err(e) => {
1367                    self.store = store_snapshot;
1368                    self.discard_all_pending();
1369                    return Err(e.into());
1370                }
1371            }
1372        }
1373
1374        // Provenance + store application (parse the generated bytes so
1375        // the store mirrors disk, replacing the skeletons).
1376        let fallback = engine_fallback_type();
1377        let mut batch_warnings: Vec<WarningHint> = Vec::new();
1378        for (p, note) in prepared.iter().zip(notes.iter()) {
1379            let write_id = mount_commits
1380                .iter()
1381                .find(|(m, _)| *m == p.mount_idx)
1382                .map(|(_, s)| s.clone())
1383                .unwrap_or_default();
1384            self.mounts[p.mount_idx].backend.append_provenance(
1385                &Provenance::new(
1386                    std::time::SystemTime::now(),
1387                    ProvenanceKind::Create,
1388                    Some(p.id.to_string()),
1389                    actor,
1390                    client.cloned(),
1391                    note.clone(),
1392                )
1393                .with_role(self.current_role),
1394            )?;
1395            self.record_self_write(p.mount_idx, &write_id);
1396            batch_warnings.extend(self.stamp_mutation_versions(p.mount_idx));
1397            let parse_result =
1398                parse_markdown(&p.markdown, &p.file_path, p.type_def.as_ref(), &p.mem)
1399                    .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
1400            push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
1401        }
1402        crate::entity::store_builder::remap_alias_target_edge_sources(
1403            &mut self.store,
1404            &self.schemas,
1405        );
1406        // Forward-reference stubs for OUT-OF-BATCH targets only —
1407        // in-batch targets are real entities now.
1408        let mut out_of_batch_stubs: Vec<(EntityId, crate::entity::StubKind)> = Vec::new();
1409        for p in &prepared {
1410            for target in &p.relation_targets {
1411                if !self.store.contains(target) {
1412                    let kind = super::deferred_verified_stub_kind(self, target)?;
1413                    out_of_batch_stubs.push((target.clone(), kind));
1414                }
1415            }
1416        }
1417        for (target, kind) in out_of_batch_stubs {
1418            self.store.upsert(target.clone(), make_stub(&target, kind));
1419        }
1420        self.invalidate_communities();
1421        self.invalidate_search_indexes();
1422
1423        let write_id = mount_commits
1424            .last()
1425            .map(|(_, s)| s.clone())
1426            .unwrap_or_default();
1427        let succeeded = prepared.len();
1428        let results: Vec<crate::ops::BatchEntry> = prepared
1429            .into_iter()
1430            .map(|p| crate::ops::BatchEntry {
1431                id: p.id,
1432                action: "created".to_string(),
1433                error: None,
1434            })
1435            .collect();
1436        Ok(crate::ops::BatchResult {
1437            warnings: batch_warnings,
1438            orphan_stubs_removed: Vec::new(),
1439            errors_suppressed: 0,
1440            applied: true,
1441            results,
1442            succeeded,
1443            failed: 0,
1444            write_id,
1445        })
1446    }
1447
1448    /// Cap on fully-detailed error envelopes in a refused batch's
1449    /// report — bounded reporting for very large failing batches.
1450    /// Entries beyond the cap still carry `action: "error"`; the
1451    /// result's `errors_suppressed` counts them. Never a silent
1452    /// truncation.
1453    pub const BATCH_ERROR_REPORT_CAP: usize = 50;
1454
1455    /// CommitContext-bundling wrapper around [`Self::create_entity`].
1456    /// Destructures `CommitContext` into `(actor, client, note)`
1457    /// and delegates.
1458    pub fn create_entity_with_ctx(
1459        &mut self,
1460        args: CreateEntityArgs,
1461        ctx: &CommitContext<'_>,
1462    ) -> Result<CreateEntityOutcome, EngineError> {
1463        self.create_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
1464    }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469
1470    use indexmap::IndexMap;
1471    use tempfile::TempDir;
1472
1473    use crate::backend::MemBackend;
1474    use crate::engine::test_helpers::*;
1475    use crate::engine::{
1476        CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateEntityArgs,
1477    };
1478    use crate::ops::WarningHint;
1479    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
1480
1481    /// Boot an engine whose mem pins a schema with one type (`task`)
1482    /// declaring `required_outgoing: [{relationships: [PART_OF],
1483    /// cardinality: at_least_one}]` — the fixture for the
1484    /// MISSING_REQUIRED_OUTGOING mutation-warning tests.
1485    fn engine_with_required_outgoing_schema(tmp: &TempDir) -> Engine {
1486        let schemas_dir = tmp.path().join("schemas");
1487        let pkg = schemas_dir.join("reqout");
1488        std::fs::create_dir_all(pkg.join("types")).unwrap();
1489        std::fs::write(
1490            pkg.join("schema.yaml"),
1491            r#"name: reqout
1492version: 0.1.0
1493description: required-outgoing fixture
1494when_to_use: tests
1495types:
1496  - task
1497relationships:
1498  mode: strict
1499  definitions:
1500    - name: PART_OF
1501      description: hier
1502      default_weight: 3.0
1503    - name: _default
1504      description: fallback
1505      default_weight: 1.0
1506community:
1507  resolution: 1.0
1508  seed: 42
1509"#,
1510        )
1511        .unwrap();
1512        std::fs::write(
1513            pkg.join("types").join("task.yaml"),
1514            r#"name: task
1515description: t
1516when_to_use: tests
1517sections:
1518  - key: body
1519    heading: Body
1520    required: true
1521    search_weight: 10.0
1522    catch_all: true
1523    write_rules: []
1524metadata_fields: []
1525title_weight: 100.0
1526text_fields:
1527  - body
1528hierarchy_relationship: PART_OF
1529no_self_loop_relationships: []
1530updatable_fields:
1531  - title
1532  - body
1533health_required_fields:
1534  - body
1535staleness_threshold_days: 90
1536required_outgoing:
1537  - relationships: [PART_OF]
1538    cardinality: at_least_one
1539write_rules: []
1540"#,
1541        )
1542        .unwrap();
1543        let mem_dir = tmp.path().join("mem");
1544        std::fs::create_dir_all(&mem_dir).unwrap();
1545        let writer = FilesystemMemWriter::new(mem_dir.clone());
1546        let mount = crate::workspace::Mount {
1547            mem: "tasks".to_string(),
1548            schema: Some(memstead_schema::SchemaRef::new(
1549                "reqout",
1550                semver::Version::new(0, 1, 0),
1551            )),
1552            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1553            capability: crate::workspace::MountCapability::Write,
1554            lifecycle: crate::workspace::MountLifecycle::Eager,
1555            cross_linkable: true,
1556            migration_target: None,
1557        };
1558        Engine::from_mounts_with_schemas_dir(
1559            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1560            Some(&schemas_dir),
1561        )
1562        .unwrap()
1563    }
1564
1565    fn task_create_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
1566        let mut sections = IndexMap::new();
1567        sections.insert("body".to_string(), "a task body.".to_string());
1568        CreateEntityArgs {
1569            anchors: Vec::new(),
1570            mem: "tasks".to_string(),
1571            title: title.to_string(),
1572            entity_type: "task".to_string(),
1573            sections,
1574            metadata: IndexMap::new(),
1575            relations,
1576            dry_run: false,
1577        }
1578    }
1579
1580    fn missing_outgoing_of(warnings: &[WarningHint]) -> Vec<(Vec<String>, String)> {
1581        warnings
1582            .iter()
1583            .filter_map(|w| match w {
1584                WarningHint::MissingRequiredOutgoing { missing, .. } => Some(
1585                    missing
1586                        .iter()
1587                        .map(|b| (b.relationships.clone(), b.cardinality.clone()))
1588                        .collect::<Vec<_>>(),
1589                ),
1590                _ => None,
1591            })
1592            .flatten()
1593            .collect()
1594    }
1595
1596    /// Fixture for the declared-constraints vertical: type `task`
1597    /// declares `requires_when` (checked → checked_by) at the given
1598    /// severity, plus a `required_outgoing` block at the given
1599    /// severity — so one schema exercises form 1 and form 4 at either
1600    /// tier.
1601    fn engine_with_constraints_schema(
1602        tmp: &TempDir,
1603        requires_when_severity: &str,
1604        required_outgoing_severity: &str,
1605    ) -> Engine {
1606        let schemas_dir = tmp.path().join("schemas");
1607        let pkg = schemas_dir.join("constr");
1608        std::fs::create_dir_all(pkg.join("types")).unwrap();
1609        std::fs::write(
1610            pkg.join("schema.yaml"),
1611            r#"name: constr
1612version: 0.1.0
1613description: constraint fixture
1614when_to_use: tests
1615types:
1616  - task
1617relationships:
1618  mode: strict
1619  definitions:
1620    - name: PART_OF
1621      description: hier
1622      default_weight: 3.0
1623    - name: _default
1624      description: fallback
1625      default_weight: 1.0
1626community:
1627  resolution: 1.0
1628  seed: 42
1629"#,
1630        )
1631        .unwrap();
1632        std::fs::write(
1633            pkg.join("types").join("task.yaml"),
1634            format!(
1635                r#"name: task
1636description: t
1637when_to_use: tests
1638sections:
1639  - key: body
1640    heading: Body
1641    required: true
1642    search_weight: 10.0
1643    catch_all: true
1644    write_rules: []
1645metadata_fields:
1646  - key: status
1647    description: workflow state
1648    field_type: string
1649    enum_values: [open, checked]
1650  - key: checked_by
1651    description: who checked
1652    field_type: string
1653title_weight: 100.0
1654text_fields:
1655  - body
1656hierarchy_relationship: PART_OF
1657no_self_loop_relationships: [PART_OF]
1658updatable_fields:
1659  - title
1660  - body
1661  - status
1662  - checked_by
1663health_required_fields:
1664  - body
1665staleness_threshold_days: 90
1666required_outgoing:
1667  - relationships: [PART_OF]
1668    cardinality: at_least_one
1669    severity: {required_outgoing_severity}
1670constraints:
1671  - kind: requires_when
1672    field: checked_by
1673    when_field: status
1674    when_value: checked
1675    severity: {requires_when_severity}
1676write_rules: []
1677"#
1678            ),
1679        )
1680        .unwrap();
1681        let mem_dir = tmp.path().join("mem");
1682        std::fs::create_dir_all(&mem_dir).unwrap();
1683        let writer = FilesystemMemWriter::new(mem_dir.clone());
1684        let mount = crate::workspace::Mount {
1685            mem: "tasks".to_string(),
1686            schema: Some(memstead_schema::SchemaRef::new(
1687                "constr",
1688                semver::Version::new(0, 1, 0),
1689            )),
1690            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
1691            capability: crate::workspace::MountCapability::Write,
1692            lifecycle: crate::workspace::MountLifecycle::Eager,
1693            cross_linkable: true,
1694            migration_target: None,
1695        };
1696        Engine::from_mounts_with_schemas_dir(
1697            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
1698            Some(&schemas_dir),
1699        )
1700        .unwrap()
1701    }
1702
1703    fn checked_task_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
1704        let mut args = task_create_args(title, relations);
1705        args.metadata
1706            .insert("status".to_string(), "checked".to_string());
1707        args
1708    }
1709
1710    /// Form 1 at warn: a create violating `requires_when` warns
1711    /// `CONSTRAINT_UNSATISFIED` and still commits; the health sweep
1712    /// reports the same violation (shared evaluation); a create
1713    /// satisfying the constraint emits neither.
1714    #[test]
1715    fn create_warns_requires_when_and_still_commits() {
1716        let tmp = TempDir::new().unwrap();
1717        let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1718        let (actor, client) = cli_actor();
1719
1720        let outcome = engine
1721            .create_entity(
1722                checked_task_args("Unbacked Judgment", vec![]),
1723                actor,
1724                Some(&client),
1725                None,
1726            )
1727            .unwrap();
1728        assert!(!outcome.write_id.is_empty(), "warn tier never blocks");
1729        let violation = outcome
1730            .warnings
1731            .iter()
1732            .find_map(|w| match w {
1733                WarningHint::ConstraintUnsatisfied { violations, .. } => Some(violations.clone()),
1734                _ => None,
1735            })
1736            .expect("CONSTRAINT_UNSATISFIED warning present");
1737        assert_eq!(violation.len(), 1);
1738        let crate::ops::health::UnsatisfiedConstraint::RequiresWhen {
1739            field,
1740            when_field,
1741            when_value,
1742            ..
1743        } = &violation[0]
1744        else {
1745            panic!("expected requires_when violation");
1746        };
1747        assert_eq!(field, "checked_by");
1748        assert_eq!(when_field, "status");
1749        assert_eq!(when_value, "checked");
1750
1751        // Health parity — same single evaluation.
1752        let reports =
1753            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
1754        assert_eq!(reports.len(), 1);
1755        assert_eq!(reports[0].id, outcome.id);
1756        assert_eq!(reports[0].violations.len(), 1);
1757        let crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. } =
1758            &reports[0].violations[0]
1759        else {
1760            panic!("expected requires_when finding");
1761        };
1762        assert_eq!(field, "checked_by");
1763
1764        // Complement 1: satisfying the constraint in the same create
1765        // emits no warning and no finding.
1766        let mut satisfied_args = checked_task_args("Backed Judgment", vec![]);
1767        satisfied_args
1768            .metadata
1769            .insert("checked_by".to_string(), "reviewer-a".to_string());
1770        let satisfied = engine
1771            .create_entity(satisfied_args, actor, Some(&client), None)
1772            .unwrap();
1773        assert!(
1774            !satisfied
1775                .warnings
1776                .iter()
1777                .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1778            "satisfied constraint emits no warning: {:?}",
1779            satisfied.warnings
1780        );
1781
1782        // Complement 2: an untriggered constraint (status != checked)
1783        // emits nothing even with checked_by unset.
1784        let untriggered = engine
1785            .create_entity(
1786                task_create_args("Open Task", vec![]),
1787                actor,
1788                Some(&client),
1789                None,
1790            )
1791            .unwrap();
1792        assert!(
1793            !untriggered
1794                .warnings
1795                .iter()
1796                .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1797            "untriggered constraint emits no warning"
1798        );
1799    }
1800
1801    /// Form 1 at block: the same violation refuses the create with
1802    /// `CONSTRAINT_UNSATISFIED`, leaves nothing behind, and the
1803    /// refusal payload restates the declaration.
1804    #[test]
1805    fn create_refuses_block_tier_requires_when() {
1806        let tmp = TempDir::new().unwrap();
1807        let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1808        let (actor, client) = cli_actor();
1809
1810        let err = engine
1811            .create_entity(
1812                checked_task_args("Unbacked Judgment", vec![]),
1813                actor,
1814                Some(&client),
1815                None,
1816            )
1817            .unwrap_err();
1818        assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1819        let details = err.details();
1820        assert_eq!(details["violations"][0]["field"], "checked_by");
1821        assert_eq!(details["violations"][0]["severity"], "block");
1822        assert_eq!(
1823            engine.store().all_entities().count(),
1824            0,
1825            "refused create leaves nothing behind"
1826        );
1827
1828        // The satisfying create passes under the same schema.
1829        let mut ok_args = checked_task_args("Backed Judgment", vec![]);
1830        ok_args
1831            .metadata
1832            .insert("checked_by".to_string(), "reviewer-a".to_string());
1833        engine
1834            .create_entity(ok_args, actor, Some(&client), None)
1835            .unwrap();
1836    }
1837
1838    /// Form 4 at block: a create leaving a `severity: block`
1839    /// `required_outgoing` block unsatisfied refuses with
1840    /// `MISSING_REQUIRED_OUTGOING` (the same code the warn tier
1841    /// warns with — one condition, one vocabulary); an inline
1842    /// relation satisfying the block lets the create pass.
1843    #[test]
1844    fn create_refuses_block_tier_required_outgoing() {
1845        let tmp = TempDir::new().unwrap();
1846        let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1847        let (actor, client) = cli_actor();
1848
1849        let err = engine
1850            .create_entity(
1851                task_create_args("Orphan Task", vec![]),
1852                actor,
1853                Some(&client),
1854                None,
1855            )
1856            .unwrap_err();
1857        assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
1858        let details = err.details();
1859        assert_eq!(details["missing"][0]["relationships"][0], "PART_OF");
1860        assert_eq!(details["missing"][0]["severity"], "block");
1861        assert_eq!(engine.store().all_entities().count(), 0);
1862
1863        // A create satisfying the block via an inline relation to an
1864        // auto-stubbed target passes — the stub itself has no type
1865        // definition under this schema's `task`-only vocabulary, so
1866        // wire the edge from the real entity.
1867        let outcome = engine.create_entity(
1868            task_create_args(
1869                "Child Task",
1870                vec![crate::ops::RelateArg {
1871                    target: crate::entity::EntityId("tasks--parent".to_string()),
1872                    rel_type: "PART_OF".to_string(),
1873                    description: None,
1874                }],
1875            ),
1876            actor,
1877            Some(&client),
1878            None,
1879        );
1880        assert!(
1881            outcome.is_ok(),
1882            "satisfied block-tier create passes: {:?}",
1883            outcome.err()
1884        );
1885    }
1886
1887    /// Update-side severity mirror for form 1: at warn, an update
1888    /// that makes the constraint trigger warns and commits; at block,
1889    /// the same update refuses and the entity keeps its prior state.
1890    #[test]
1891    fn update_enforces_requires_when_by_severity() {
1892        let (actor, client) = cli_actor();
1893        let set_checked = |engine: &mut Engine, id: &crate::entity::EntityId| {
1894            let current = engine.get_entity(id).unwrap().content_hash.clone();
1895            let mut metadata = IndexMap::new();
1896            metadata.insert("status".to_string(), "checked".to_string());
1897            engine.update_entity(
1898                crate::engine::UpdateEntityArgs {
1899                    anchors: Vec::new(),
1900                    id: id.clone(),
1901                    expected_hash: Some(current),
1902                    sections: IndexMap::new(),
1903                    append_sections: IndexMap::new(),
1904                    patch_sections: IndexMap::new(),
1905                    metadata,
1906                    metadata_unset: Vec::new(),
1907                    declare_relations: vec![],
1908                    dry_run: false,
1909                    relations_unset: Vec::new(),
1910                    anchors_unset: Vec::new(),
1911                },
1912                actor,
1913                Some(&client),
1914                None,
1915            )
1916        };
1917
1918        // Warn tier: the update commits with the typed warning.
1919        let tmp = TempDir::new().unwrap();
1920        let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
1921        let a = engine
1922            .create_entity(
1923                task_create_args("Task A", vec![]),
1924                actor,
1925                Some(&client),
1926                None,
1927            )
1928            .unwrap();
1929        let outcome = set_checked(&mut engine, &a.id).unwrap();
1930        assert!(!outcome.write_id.is_empty());
1931        assert!(
1932            outcome
1933                .warnings
1934                .iter()
1935                .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
1936            "warn-tier update carries the warning: {:?}",
1937            outcome.warnings
1938        );
1939
1940        // Block tier: the same update refuses; the entity keeps its
1941        // prior metadata.
1942        let tmp = TempDir::new().unwrap();
1943        let mut engine = engine_with_constraints_schema(&tmp, "block", "warn");
1944        let b = engine
1945            .create_entity(
1946                task_create_args("Task B", vec![]),
1947                actor,
1948                Some(&client),
1949                None,
1950            )
1951            .unwrap();
1952        let err = set_checked(&mut engine, &b.id).unwrap_err();
1953        assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
1954        assert!(
1955            !engine
1956                .get_entity(&b.id)
1957                .unwrap()
1958                .metadata
1959                .contains_key("status"),
1960            "refused update leaves the entity unchanged"
1961        );
1962    }
1963
1964    /// Form 4 at block on the relate surface: removing the edge that
1965    /// satisfies a `severity: block` `required_outgoing` block refuses
1966    /// with `MISSING_REQUIRED_OUTGOING`; the edge survives.
1967    #[test]
1968    fn relate_remove_refuses_block_tier_required_outgoing() {
1969        let tmp = TempDir::new().unwrap();
1970        let mut engine = engine_with_constraints_schema(&tmp, "warn", "block");
1971        let (actor, client) = cli_actor();
1972        let parent_id = crate::entity::EntityId("tasks--parent".to_string());
1973        let child = engine
1974            .create_entity(
1975                task_create_args(
1976                    "Child Task",
1977                    vec![crate::ops::RelateArg {
1978                        target: parent_id.clone(),
1979                        rel_type: "PART_OF".to_string(),
1980                        description: None,
1981                    }],
1982                ),
1983                actor,
1984                Some(&client),
1985                None,
1986            )
1987            .unwrap();
1988
1989        let err = engine
1990            .relate_entity(
1991                crate::engine::RelateEntityArgs {
1992                    source: child.id.clone(),
1993                    target: parent_id.clone(),
1994                    rel_type: "PART_OF".to_string(),
1995                    description: None,
1996                    remove: true,
1997                    expected_hash: None,
1998                    dry_run: false,
1999                },
2000                actor,
2001                Some(&client),
2002                None,
2003            )
2004            .unwrap_err();
2005        assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
2006        assert!(
2007            engine
2008                .get_entity(&child.id)
2009                .unwrap()
2010                .relationships
2011                .iter()
2012                .any(|r| r.rel_type == "PART_OF" && r.target == parent_id),
2013            "refused remove leaves the edge in place"
2014        );
2015    }
2016
2017    /// Fixture for the conditional `required_outgoing` form: type
2018    /// `task` requires a PART_OF edge only while `status` holds
2019    /// `checked`, at the given severity. No unconditional blocks, no
2020    /// `constraints` — the conditional block is the only obligation.
2021    fn engine_with_conditional_ro_schema(tmp: &TempDir, severity: &str) -> Engine {
2022        let schemas_dir = tmp.path().join("schemas");
2023        let pkg = schemas_dir.join("condro");
2024        std::fs::create_dir_all(pkg.join("types")).unwrap();
2025        std::fs::write(
2026            pkg.join("schema.yaml"),
2027            r#"name: condro
2028version: 0.1.0
2029description: conditional required_outgoing fixture
2030when_to_use: tests
2031types:
2032  - task
2033relationships:
2034  mode: strict
2035  definitions:
2036    - name: PART_OF
2037      description: hier
2038      default_weight: 3.0
2039    - name: _default
2040      description: fallback
2041      default_weight: 1.0
2042community:
2043  resolution: 1.0
2044  seed: 42
2045"#,
2046        )
2047        .unwrap();
2048        std::fs::write(
2049            pkg.join("types").join("task.yaml"),
2050            format!(
2051                r#"name: task
2052description: t
2053when_to_use: tests
2054sections:
2055  - key: body
2056    heading: Body
2057    required: true
2058    search_weight: 10.0
2059    catch_all: true
2060    write_rules: []
2061metadata_fields:
2062  - key: status
2063    description: workflow state
2064    field_type: string
2065    enum_values: [open, checked]
2066title_weight: 100.0
2067text_fields:
2068  - body
2069hierarchy_relationship: PART_OF
2070no_self_loop_relationships: []
2071updatable_fields:
2072  - title
2073  - body
2074  - status
2075health_required_fields:
2076  - body
2077staleness_threshold_days: 90
2078required_outgoing:
2079  - relationships: [PART_OF]
2080    cardinality: at_least_one
2081    severity: {severity}
2082    when_field: status
2083    when_value: checked
2084write_rules: []
2085"#
2086            ),
2087        )
2088        .unwrap();
2089        let mem_dir = tmp.path().join("mem");
2090        std::fs::create_dir_all(&mem_dir).unwrap();
2091        let writer = FilesystemMemWriter::new(mem_dir.clone());
2092        let mount = crate::workspace::Mount {
2093            mem: "tasks".to_string(),
2094            schema: Some(memstead_schema::SchemaRef::new(
2095                "condro",
2096                semver::Version::new(0, 1, 0),
2097            )),
2098            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2099            capability: crate::workspace::MountCapability::Write,
2100            lifecycle: crate::workspace::MountLifecycle::Eager,
2101            cross_linkable: true,
2102            migration_target: None,
2103        };
2104        Engine::from_mounts_with_schemas_dir(
2105            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
2106            Some(&schemas_dir),
2107        )
2108        .unwrap()
2109    }
2110
2111    /// An unarmed conditional block never fires: entities whose
2112    /// trigger field is unset or holds another enum value create
2113    /// cleanly without the edge, with no `MISSING_REQUIRED_OUTGOING`
2114    /// warning, even at block tier.
2115    #[test]
2116    fn create_ignores_conditional_required_outgoing_when_unarmed() {
2117        let tmp = TempDir::new().unwrap();
2118        let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2119        let (actor, client) = cli_actor();
2120
2121        let unset = engine
2122            .create_entity(
2123                task_create_args("Unset Status", vec![]),
2124                actor,
2125                Some(&client),
2126                None,
2127            )
2128            .expect("unset trigger field must create");
2129        let mut open_args = task_create_args("Open Task", vec![]);
2130        open_args
2131            .metadata
2132            .insert("status".to_string(), "open".to_string());
2133        let open = engine
2134            .create_entity(open_args, actor, Some(&client), None)
2135            .expect("non-trigger value must create");
2136        for outcome in [&unset, &open] {
2137            assert!(
2138                !outcome
2139                    .warnings
2140                    .iter()
2141                    .any(|w| matches!(w, WarningHint::MissingRequiredOutgoing { .. })),
2142                "unarmed block emits no warning: {:?}",
2143                outcome.warnings
2144            );
2145        }
2146    }
2147
2148    /// Armed at block tier: a create whose trigger field holds the
2149    /// trigger value and lacks the edge refuses with
2150    /// `MISSING_REQUIRED_OUTGOING`, and the payload names the trigger
2151    /// (`when_field` / `when_value`); an inline relation satisfying
2152    /// the armed block lets the same create pass.
2153    #[test]
2154    fn create_refuses_block_tier_conditional_required_outgoing() {
2155        let tmp = TempDir::new().unwrap();
2156        let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2157        let (actor, client) = cli_actor();
2158
2159        let err = engine
2160            .create_entity(
2161                checked_task_args("Checked Orphan", vec![]),
2162                actor,
2163                Some(&client),
2164                None,
2165            )
2166            .unwrap_err();
2167        assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
2168        let details = err.details();
2169        assert_eq!(details["missing"][0]["relationships"][0], "PART_OF");
2170        assert_eq!(details["missing"][0]["when_field"], "status");
2171        assert_eq!(details["missing"][0]["when_value"], "checked");
2172        assert_eq!(engine.store().all_entities().count(), 0);
2173
2174        let outcome = engine.create_entity(
2175            checked_task_args(
2176                "Checked Child",
2177                vec![crate::ops::RelateArg {
2178                    target: crate::entity::EntityId("tasks--parent".to_string()),
2179                    rel_type: "PART_OF".to_string(),
2180                    description: None,
2181                }],
2182            ),
2183            actor,
2184            Some(&client),
2185            None,
2186        );
2187        assert!(
2188            outcome.is_ok(),
2189            "satisfied armed block passes: {:?}",
2190            outcome.err()
2191        );
2192    }
2193
2194    /// Armed at warn tier: the create lands and carries the
2195    /// `MISSING_REQUIRED_OUTGOING` warning whose block entry names
2196    /// the trigger.
2197    #[test]
2198    fn create_warns_conditional_required_outgoing_at_warn_tier() {
2199        let tmp = TempDir::new().unwrap();
2200        let mut engine = engine_with_conditional_ro_schema(&tmp, "warn");
2201        let (actor, client) = cli_actor();
2202
2203        let outcome = engine
2204            .create_entity(
2205                checked_task_args("Checked Orphan", vec![]),
2206                actor,
2207                Some(&client),
2208                None,
2209            )
2210            .expect("warn tier lands the write");
2211        assert!(!outcome.write_id.is_empty());
2212        let block = outcome
2213            .warnings
2214            .iter()
2215            .find_map(|w| match w {
2216                WarningHint::MissingRequiredOutgoing { missing, .. } => missing.first(),
2217                _ => None,
2218            })
2219            .expect("warning carries the unsatisfied block");
2220        assert_eq!(block.relationships, vec!["PART_OF".to_string()]);
2221        assert_eq!(block.when_field.as_deref(), Some("status"));
2222        assert_eq!(block.when_value.as_deref(), Some("checked"));
2223    }
2224
2225    /// The metadata flip that arms the block is caught on update: at
2226    /// block tier the update refuses and the entity keeps its prior
2227    /// value; at warn tier the same flip commits with the warning.
2228    #[test]
2229    fn update_flip_to_trigger_value_enforces_conditional_block() {
2230        let (actor, client) = cli_actor();
2231        let flip_to_checked = |engine: &mut Engine, id: &crate::entity::EntityId| {
2232            let current = engine.get_entity(id).unwrap().content_hash.clone();
2233            let mut metadata = IndexMap::new();
2234            metadata.insert("status".to_string(), "checked".to_string());
2235            engine.update_entity(
2236                crate::engine::UpdateEntityArgs {
2237                    anchors: Vec::new(),
2238                    id: id.clone(),
2239                    expected_hash: Some(current),
2240                    sections: IndexMap::new(),
2241                    append_sections: IndexMap::new(),
2242                    patch_sections: IndexMap::new(),
2243                    metadata,
2244                    metadata_unset: Vec::new(),
2245                    declare_relations: vec![],
2246                    dry_run: false,
2247                    relations_unset: Vec::new(),
2248                    anchors_unset: Vec::new(),
2249                },
2250                actor,
2251                Some(&client),
2252                None,
2253            )
2254        };
2255
2256        // Block tier: the flip refuses; the entity keeps `open`.
2257        let tmp = TempDir::new().unwrap();
2258        let mut engine = engine_with_conditional_ro_schema(&tmp, "block");
2259        let mut open_args = task_create_args("Task A", vec![]);
2260        open_args
2261            .metadata
2262            .insert("status".to_string(), "open".to_string());
2263        let a = engine
2264            .create_entity(open_args, actor, Some(&client), None)
2265            .unwrap();
2266        let err = flip_to_checked(&mut engine, &a.id).unwrap_err();
2267        assert_eq!(err.code(), "MISSING_REQUIRED_OUTGOING");
2268        assert_eq!(
2269            engine.get_entity(&a.id).unwrap().metadata["status"].to_frontmatter_string(),
2270            "open",
2271            "refused update leaves the entity unchanged"
2272        );
2273
2274        // Warn tier: the same flip commits with the typed warning.
2275        let tmp = TempDir::new().unwrap();
2276        let mut engine = engine_with_conditional_ro_schema(&tmp, "warn");
2277        let b = engine
2278            .create_entity(
2279                task_create_args("Task B", vec![]),
2280                actor,
2281                Some(&client),
2282                None,
2283            )
2284            .unwrap();
2285        let outcome = flip_to_checked(&mut engine, &b.id).unwrap();
2286        assert!(!outcome.write_id.is_empty());
2287        assert!(
2288            outcome
2289                .warnings
2290                .iter()
2291                .any(|w| matches!(w, WarningHint::MissingRequiredOutgoing { .. })),
2292            "warn-tier flip carries the warning: {:?}",
2293            outcome.warnings
2294        );
2295    }
2296
2297    /// Fixture for declared acyclicity sets: one `claim` type over
2298    /// GROUNDS / CONCLUDES, with `acyclic_sets: [[GROUNDS, CONCLUDES]]`
2299    /// when `with_set` (neither rel-type carries the per-definition
2300    /// `acyclic` flag, so without the set every cycle is legal).
2301    fn engine_with_acyclic_set_schema(tmp: &TempDir, with_set: bool) -> Engine {
2302        let schemas_dir = tmp.path().join("schemas");
2303        let pkg = schemas_dir.join("argchain");
2304        std::fs::create_dir_all(pkg.join("types")).unwrap();
2305        let sets = if with_set {
2306            "  acyclic_sets:\n    - [GROUNDS, CONCLUDES]\n"
2307        } else {
2308            ""
2309        };
2310        std::fs::write(
2311            pkg.join("schema.yaml"),
2312            format!(
2313                r#"name: argchain
2314version: 0.1.0
2315description: acyclicity-set fixture
2316when_to_use: tests
2317types:
2318  - claim
2319relationships:
2320  mode: strict
2321{sets}  definitions:
2322    - name: GROUNDS
2323      description: g
2324      default_weight: 3.0
2325    - name: CONCLUDES
2326      description: c
2327      default_weight: 3.0
2328    - name: PART_OF
2329      description: hier
2330      default_weight: 1.0
2331    - name: _default
2332      description: fallback
2333      default_weight: 1.0
2334community:
2335  resolution: 1.0
2336  seed: 42
2337"#
2338            ),
2339        )
2340        .unwrap();
2341        std::fs::write(
2342            pkg.join("types").join("claim.yaml"),
2343            r#"name: claim
2344description: t
2345when_to_use: tests
2346sections:
2347  - key: body
2348    heading: Body
2349    required: true
2350    search_weight: 10.0
2351    catch_all: true
2352    write_rules: []
2353metadata_fields: []
2354title_weight: 100.0
2355text_fields:
2356  - body
2357hierarchy_relationship: PART_OF
2358no_self_loop_relationships: []
2359updatable_fields:
2360  - title
2361  - body
2362health_required_fields:
2363  - body
2364staleness_threshold_days: 90
2365write_rules: []
2366"#,
2367        )
2368        .unwrap();
2369        let mem_dir = tmp.path().join("mem");
2370        std::fs::create_dir_all(&mem_dir).unwrap();
2371        let writer = FilesystemMemWriter::new(mem_dir.clone());
2372        let mount = crate::workspace::Mount {
2373            mem: "arg".to_string(),
2374            schema: Some(memstead_schema::SchemaRef::new(
2375                "argchain",
2376                semver::Version::new(0, 1, 0),
2377            )),
2378            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2379            capability: crate::workspace::MountCapability::Write,
2380            lifecycle: crate::workspace::MountLifecycle::Eager,
2381            cross_linkable: true,
2382            migration_target: None,
2383        };
2384        Engine::from_mounts_with_schemas_dir(
2385            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
2386            Some(&schemas_dir),
2387        )
2388        .unwrap()
2389    }
2390
2391    fn claim_args(title: &str, relations: Vec<crate::ops::RelateArg>) -> CreateEntityArgs {
2392        let mut sections = IndexMap::new();
2393        sections.insert("body".to_string(), "a claim body.".to_string());
2394        CreateEntityArgs {
2395            anchors: Vec::new(),
2396            mem: "arg".to_string(),
2397            title: title.to_string(),
2398            entity_type: "claim".to_string(),
2399            sections,
2400            metadata: IndexMap::new(),
2401            relations,
2402            dry_run: false,
2403        }
2404    }
2405
2406    fn relate(
2407        engine: &mut Engine,
2408        from: &str,
2409        rel: &str,
2410        to: &str,
2411    ) -> Result<crate::engine::RelateEntityOutcome, crate::engine::EngineError> {
2412        let (actor, client) = cli_actor();
2413        engine.relate_entity(
2414            crate::engine::RelateEntityArgs {
2415                source: crate::entity::EntityId(from.to_string()),
2416                target: crate::entity::EntityId(to.to_string()),
2417                rel_type: rel.to_string(),
2418                description: None,
2419                remove: false,
2420                expected_hash: None,
2421                dry_run: false,
2422            },
2423            actor,
2424            Some(&client),
2425            None,
2426        )
2427    }
2428
2429    /// The experiment's alternating cycle: with `[GROUNDS, CONCLUDES]`
2430    /// declared as one acyclicity set, the relate that closes a cycle
2431    /// mixing both rel-types refuses with `RELATIONSHIP_CYCLE`; the
2432    /// payload echoes the set and names each hop's rel-type. The same
2433    /// graph WITHOUT the set declaration accepts the cycle (no
2434    /// implicit derivation from anything else).
2435    #[test]
2436    fn relate_refuses_mixed_type_cycle_in_declared_set() {
2437        let tmp = TempDir::new().unwrap();
2438        let mut engine = engine_with_acyclic_set_schema(&tmp, true);
2439        let (actor, client) = cli_actor();
2440        for t in ["A", "B", "C"] {
2441            engine
2442                .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2443                .unwrap();
2444        }
2445        relate(&mut engine, "arg--a", "GROUNDS", "arg--b").unwrap();
2446        relate(&mut engine, "arg--b", "CONCLUDES", "arg--c").unwrap();
2447
2448        let err = relate(&mut engine, "arg--c", "GROUNDS", "arg--a").unwrap_err();
2449        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2450        let details = err.details();
2451        assert_eq!(
2452            details["acyclic_set"],
2453            serde_json::json!(["GROUNDS", "CONCLUDES"])
2454        );
2455        assert_eq!(
2456            details["existing_path"],
2457            serde_json::json!(["arg--a", "arg--b", "arg--c"])
2458        );
2459        assert_eq!(
2460            details["existing_path_rel_types"],
2461            serde_json::json!(["GROUNDS", "CONCLUDES"]),
2462            "one rel-type per hop, mixing both members"
2463        );
2464
2465        // Complement: the identical graph without the declaration
2466        // accepts the cycle.
2467        let tmp = TempDir::new().unwrap();
2468        let mut engine = engine_with_acyclic_set_schema(&tmp, false);
2469        for t in ["A", "B", "C"] {
2470            engine
2471                .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2472                .unwrap();
2473        }
2474        relate(&mut engine, "arg--a", "GROUNDS", "arg--b").unwrap();
2475        relate(&mut engine, "arg--b", "CONCLUDES", "arg--c").unwrap();
2476        relate(&mut engine, "arg--c", "GROUNDS", "arg--a")
2477            .expect("without the set declaration the cycle is legal");
2478    }
2479
2480    /// The set refusal fires identically for inline relations on
2481    /// create (through a promoted stub) and declared relations on
2482    /// update.
2483    #[test]
2484    fn create_inline_and_update_declared_refuse_set_cycle() {
2485        let tmp = TempDir::new().unwrap();
2486        let mut engine = engine_with_acyclic_set_schema(&tmp, true);
2487        let (actor, client) = cli_actor();
2488
2489        // create.relations[]: A → GROUNDS → ghost auto-stubs `ghost`;
2490        // promoting the stub with a CONCLUDES back-edge closes a
2491        // mixed-type cycle.
2492        engine
2493            .create_entity(
2494                claim_args(
2495                    "Alpha",
2496                    vec![crate::ops::RelateArg {
2497                        target: crate::entity::EntityId("arg--ghost".to_string()),
2498                        rel_type: "GROUNDS".to_string(),
2499                        description: None,
2500                    }],
2501                ),
2502                actor,
2503                Some(&client),
2504                None,
2505            )
2506            .unwrap();
2507        let err = engine
2508            .create_entity(
2509                claim_args(
2510                    "Ghost",
2511                    vec![crate::ops::RelateArg {
2512                        target: crate::entity::EntityId("arg--alpha".to_string()),
2513                        rel_type: "CONCLUDES".to_string(),
2514                        description: None,
2515                    }],
2516                ),
2517                actor,
2518                Some(&client),
2519                None,
2520            )
2521            .expect_err("cycle-closing create.relations[] must refuse");
2522        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2523        assert_eq!(
2524            err.details()["acyclic_set"],
2525            serde_json::json!(["GROUNDS", "CONCLUDES"])
2526        );
2527
2528        // update.declare_relations: D → GROUNDS → E exists; updating E
2529        // with CONCLUDES → D closes the mixed cycle.
2530        for t in ["D", "E"] {
2531            engine
2532                .create_entity(claim_args(t, vec![]), actor, Some(&client), None)
2533                .unwrap();
2534        }
2535        relate(&mut engine, "arg--d", "GROUNDS", "arg--e").unwrap();
2536        let e_id = crate::entity::EntityId("arg--e".to_string());
2537        let current = engine.get_entity(&e_id).unwrap().content_hash.clone();
2538        let err = engine
2539            .update_entity(
2540                crate::engine::UpdateEntityArgs {
2541                    anchors: Vec::new(),
2542                    id: e_id,
2543                    expected_hash: Some(current),
2544                    sections: IndexMap::new(),
2545                    append_sections: IndexMap::new(),
2546                    patch_sections: IndexMap::new(),
2547                    metadata: IndexMap::new(),
2548                    metadata_unset: Vec::new(),
2549                    declare_relations: vec![crate::ops::RelateArg {
2550                        target: crate::entity::EntityId("arg--d".to_string()),
2551                        rel_type: "CONCLUDES".to_string(),
2552                        description: None,
2553                    }],
2554                    dry_run: false,
2555                    relations_unset: Vec::new(),
2556                    anchors_unset: Vec::new(),
2557                },
2558                actor,
2559                Some(&client),
2560                None,
2561            )
2562            .expect_err("cycle-closing declare_relations must refuse");
2563        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
2564        assert_eq!(
2565            err.details()["acyclic_set"],
2566            serde_json::json!(["GROUNDS", "CONCLUDES"])
2567        );
2568    }
2569
2570    /// A package whose on-disk edges already close a mixed-type cycle
2571    /// boots with one cycle-closing edge dropped and warned, exactly
2572    /// as the single-type sweep does.
2573    #[test]
2574    fn boot_drops_cycle_closing_edge_in_declared_acyclic_set() {
2575        let tmp = TempDir::new().unwrap();
2576        // Write the schema and two claim files closing a GROUNDS /
2577        // CONCLUDES cycle BEFORE boot, then reuse the fixture builder
2578        // (same schemas dir and mem dir layout).
2579        let mem_dir = tmp.path().join("mem");
2580        std::fs::create_dir_all(&mem_dir).unwrap();
2581        std::fs::write(
2582            mem_dir.join("a.md"),
2583            "---\ntype: claim\n---\n# A\n\n## Body\n\nfirst.\n\n## Relationships\n\n- **GROUNDS**: [[arg--b]]\n",
2584        )
2585        .unwrap();
2586        std::fs::write(
2587            mem_dir.join("b.md"),
2588            "---\ntype: claim\n---\n# B\n\n## Body\n\nsecond.\n\n## Relationships\n\n- **CONCLUDES**: [[arg--a]]\n",
2589        )
2590        .unwrap();
2591        let engine = engine_with_acyclic_set_schema(&tmp, true);
2592
2593        let surviving: usize = engine
2594            .store()
2595            .all_entities()
2596            .map(|e| {
2597                engine
2598                    .store()
2599                    .outgoing(&e.id)
2600                    .iter()
2601                    .filter(|edge| edge.rel_type == "GROUNDS" || edge.rel_type == "CONCLUDES")
2602                    .count()
2603            })
2604            .sum();
2605        assert_eq!(surviving, 1, "exactly one edge survives the cycle break");
2606        let cycle_warnings: Vec<_> = engine
2607            .load_warnings()
2608            .iter()
2609            .filter(|w| {
2610                matches!(
2611                    w,
2612                    WarningHint::ParsedRelationInvalid { reason, .. } if reason == "cycle"
2613                )
2614            })
2615            .cloned()
2616            .collect();
2617        assert_eq!(
2618            cycle_warnings.len(),
2619            1,
2620            "exactly one cycle warning fires: {cycle_warnings:?}"
2621        );
2622    }
2623
2624    /// Fixture for aggregate signals: `claim` declares `attack_load`
2625    /// (in-REBUTS count, notice at 1, warn at 3) and
2626    /// `open_objections` (same set, counterpart `state: open` only,
2627    /// notice at 1); `objection` declares the `state` enum.
2628    /// Fixture for the grounded labelling: `arglab` declares
2629    /// `labelling.attack: [REBUTS]` and a support walk over GROUNDS
2630    /// (direction out, terminal `evidence`); mem `arg` (and, when the
2631    /// test mounts it, mem `other`) pin it.
2632    fn engine_with_labelling_schema(tmp: &TempDir, with_other_mem: bool) -> Engine {
2633        engine_with_labelling_schema_support(tmp, with_other_mem, true)
2634    }
2635
2636    fn engine_with_labelling_schema_support(
2637        tmp: &TempDir,
2638        with_other_mem: bool,
2639        with_support: bool,
2640    ) -> Engine {
2641        let schemas_dir = tmp.path().join("schemas");
2642        let pkg = schemas_dir.join("arglab");
2643        std::fs::create_dir_all(pkg.join("types")).unwrap();
2644        std::fs::write(
2645            pkg.join("schema.yaml"),
2646            format!(
2647                r#"name: arglab
2648version: 0.1.0
2649description: grounded-labelling fixture
2650when_to_use: tests
2651types:
2652  - claim
2653  - evidence
2654relationships:
2655  mode: strict
2656  labelling:
2657    attack: [REBUTS]
2658{support}  definitions:
2659    - name: REBUTS
2660      description: attack
2661      default_weight: 3.0
2662    - name: GROUNDS
2663      description: support
2664      default_weight: 3.0
2665    - name: PART_OF
2666      description: hier
2667      default_weight: 1.0
2668    - name: _default
2669      description: fallback
2670      default_weight: 1.0
2671community:
2672  resolution: 1.0
2673  seed: 42
2674cross_mem_relationships:
2675  - to_schema: arglab
2676    definitions:
2677      - name: REBUTS
2678        description: cross-mem attack
2679        default_weight: 3.0
2680"#,
2681                support = if with_support {
2682                    "    support:\n      relationships: [GROUNDS]\n      direction: out\n      terminal_types: [evidence]\n"
2683                } else {
2684                    ""
2685                },
2686            ),
2687        )
2688        .unwrap();
2689        let body = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\nmetadata_fields: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nupdatable_fields:\n  - title\n  - body\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
2690        for t in ["claim", "evidence"] {
2691            std::fs::write(
2692                pkg.join("types").join(format!("{t}.yaml")),
2693                format!("name: {t}\ndescription: t\nwhen_to_use: tests\n{body}"),
2694            )
2695            .unwrap();
2696        }
2697        let mut mounts: Vec<(crate::workspace::Mount, Box<dyn MemBackend>)> = Vec::new();
2698        for mem in std::iter::once("arg").chain(with_other_mem.then_some("other")) {
2699            let mem_dir = tmp.path().join(format!("mem-{mem}"));
2700            std::fs::create_dir_all(&mem_dir).unwrap();
2701            let writer = FilesystemMemWriter::new(mem_dir.clone());
2702            mounts.push((
2703                crate::workspace::Mount {
2704                    mem: mem.to_string(),
2705                    schema: Some(memstead_schema::SchemaRef::new(
2706                        "arglab",
2707                        semver::Version::new(0, 1, 0),
2708                    )),
2709                    storage: crate::workspace::MountStorage::Folder { path: mem_dir },
2710                    capability: crate::workspace::MountCapability::Write,
2711                    lifecycle: crate::workspace::MountLifecycle::Eager,
2712                    cross_linkable: true,
2713                    migration_target: None,
2714                },
2715                Box::new(writer) as Box<dyn MemBackend>,
2716            ));
2717        }
2718        Engine::from_mounts_with_schemas_dir(mounts, Some(&schemas_dir)).unwrap()
2719    }
2720
2721    fn lab_create(engine: &mut Engine, mem: &str, title: &str, entity_type: &str) {
2722        let (actor, client) = cli_actor();
2723        let mut sections = IndexMap::new();
2724        sections.insert("body".to_string(), "a body.".to_string());
2725        engine
2726            .create_entity(
2727                CreateEntityArgs {
2728                    anchors: Vec::new(),
2729                    mem: mem.to_string(),
2730                    title: title.to_string(),
2731                    entity_type: entity_type.to_string(),
2732                    sections,
2733                    metadata: IndexMap::new(),
2734                    relations: vec![],
2735                    dry_run: false,
2736                },
2737                actor,
2738                Some(&client),
2739                None,
2740            )
2741            .unwrap();
2742    }
2743
2744    fn lab_relate(engine: &mut Engine, from: &str, rel: &str, to: &str) {
2745        let (actor, client) = cli_actor();
2746        engine
2747            .relate_entity(
2748                crate::engine::RelateEntityArgs {
2749                    source: crate::entity::EntityId(from.to_string()),
2750                    target: crate::entity::EntityId(to.to_string()),
2751                    rel_type: rel.to_string(),
2752                    description: None,
2753                    remove: false,
2754                    expected_hash: None,
2755                    dry_run: false,
2756                },
2757                actor,
2758                Some(&client),
2759                None,
2760            )
2761            .unwrap();
2762    }
2763
2764    fn label_of(engine: &Engine, id: &str) -> (String, Vec<String>, Vec<String>) {
2765        let entity = engine
2766            .get_entity(&crate::entity::EntityId(id.to_string()))
2767            .unwrap()
2768            .clone();
2769        let view = engine.computed_labelling(&entity).unwrap();
2770        (
2771            view.label.wire().to_string(),
2772            view.defeated_by.clone(),
2773            view.undecided_by.clone(),
2774        )
2775    }
2776
2777    /// The hand-computed grounded extension: an unattacked claim is
2778    /// accepted, a chain defeats, a defeated attacker reinstates, a
2779    /// cycle stays undecided (and keeps its victims undecided); the
2780    /// evidence names the accepted / undecided direct attackers; two
2781    /// instances serve identical labels; the memo invalidates on the
2782    /// in-process mutation path AND the reload path; labels never
2783    /// gate writes; the health axis serves counts with evidence.
2784    #[test]
2785    fn grounded_labelling_extension_evidence_and_invalidation() {
2786        let tmp = TempDir::new().unwrap();
2787        let mut engine = engine_with_labelling_schema(&tmp, false);
2788        for t in ["A", "B", "C", "D", "E", "F"] {
2789            lab_create(&mut engine, "arg", t, "claim");
2790        }
2791        lab_relate(&mut engine, "arg--a", "REBUTS", "arg--b");
2792        lab_relate(&mut engine, "arg--b", "REBUTS", "arg--c");
2793        lab_relate(&mut engine, "arg--d", "REBUTS", "arg--e");
2794        lab_relate(&mut engine, "arg--e", "REBUTS", "arg--d");
2795        lab_relate(&mut engine, "arg--d", "REBUTS", "arg--f");
2796
2797        assert_eq!(label_of(&engine, "arg--a").0, "accepted", "unattacked");
2798        let (label, defeated_by, _) = label_of(&engine, "arg--b");
2799        assert_eq!(label, "defeated");
2800        assert_eq!(defeated_by, vec!["arg--a".to_string()], "evidence ships");
2801        assert_eq!(
2802            label_of(&engine, "arg--c").0,
2803            "accepted",
2804            "reinstatement: the only attacker is itself defeated"
2805        );
2806        let (label, _, undecided_by) = label_of(&engine, "arg--d");
2807        assert_eq!(label, "undecided", "cycle member");
2808        assert_eq!(undecided_by, vec!["arg--e".to_string()]);
2809        let (label, _, undecided_by) = label_of(&engine, "arg--f");
2810        assert_eq!(label, "undecided", "victim of an undecided attacker");
2811        assert_eq!(undecided_by, vec!["arg--d".to_string()]);
2812
2813        // Determinism: a second instance over the same on-disk state.
2814        let engine_b = engine_with_labelling_schema(&tmp, false);
2815        for id in ["arg--a", "arg--b", "arg--c", "arg--d", "arg--e", "arg--f"] {
2816            assert_eq!(label_of(&engine, id), label_of(&engine_b, id));
2817        }
2818
2819        // Health axis: counts per label, evidence on the lists.
2820        let axis = engine.health_labelling_axis(None);
2821        assert_eq!(axis["arg"]["counts"]["accepted"], 2);
2822        assert_eq!(axis["arg"]["counts"]["defeated"], 1);
2823        assert_eq!(axis["arg"]["counts"]["undecided"], 3);
2824        assert_eq!(axis["arg"]["defeated"][0]["id"], "arg--b");
2825        assert_eq!(axis["arg"]["defeated"][0]["defeated_by"][0], "arg--a");
2826
2827        // Labels never gate writes: updating a defeated entity and
2828        // adding an edge into it both succeed with the normal shapes.
2829        let (actor, client) = cli_actor();
2830        let b_id = crate::entity::EntityId("arg--b".to_string());
2831        let current = engine.get_entity(&b_id).unwrap().content_hash.clone();
2832        let mut sections = IndexMap::new();
2833        sections.insert("body".to_string(), "updated body.".to_string());
2834        let outcome = engine
2835            .update_entity(
2836                crate::engine::UpdateEntityArgs {
2837                    anchors: Vec::new(),
2838                    id: b_id,
2839                    expected_hash: Some(current),
2840                    sections,
2841                    append_sections: IndexMap::new(),
2842                    patch_sections: IndexMap::new(),
2843                    metadata: IndexMap::new(),
2844                    metadata_unset: Vec::new(),
2845                    declare_relations: vec![],
2846                    dry_run: false,
2847                    relations_unset: Vec::new(),
2848                    anchors_unset: Vec::new(),
2849                },
2850                actor,
2851                Some(&client),
2852                None,
2853            )
2854            .expect("updating a defeated entity succeeds");
2855        assert!(!outcome.write_id.is_empty());
2856        lab_relate(&mut engine, "arg--f", "REBUTS", "arg--b");
2857
2858        // In-process invalidation: a fresh unattacked attacker flips
2859        // A on the next read.
2860        lab_create(&mut engine, "arg", "H", "claim");
2861        lab_relate(&mut engine, "arg--h", "REBUTS", "arg--a");
2862        let (label, defeated_by, _) = label_of(&engine, "arg--a");
2863        assert_eq!(
2864            label, "defeated",
2865            "memo invalidated by the in-process mutation"
2866        );
2867        assert_eq!(defeated_by, vec!["arg--h".to_string()]);
2868
2869        // Reload-path invalidation: an out-of-band file change plus an
2870        // explicit mem reload serves the new labelling.
2871        let g_path = tmp.path().join("mem-arg").join("g.md");
2872        std::fs::write(
2873            &g_path,
2874            "---\ntype: claim\n---\n# G\n\n## Body\n\nout of band.\n\n## Relationships\n\n- **REBUTS**: [[arg--c]]\n",
2875        )
2876        .unwrap();
2877        engine.reload_one_mem("arg").expect("reload succeeds");
2878        let (label, defeated_by, _) = label_of(&engine, "arg--c");
2879        assert_eq!(label, "defeated", "reload invalidated the memo");
2880        assert!(defeated_by.contains(&"arg--g".to_string()));
2881    }
2882
2883    /// Support-blindness, chain shape, cross-mem exclusion, and the
2884    /// no-declaration complement.
2885    #[test]
2886    fn labelling_support_blindness_shape_and_cross_mem() {
2887        let tmp = TempDir::new().unwrap();
2888        let mut engine = engine_with_labelling_schema(&tmp, true);
2889        // Support chain: conclusion → inference → evidence; an
2890        // undercutter defeats the inference.
2891        for (t, ty) in [
2892            ("Conclusion", "claim"),
2893            ("Inference", "claim"),
2894            ("Undercutter", "claim"),
2895        ] {
2896            lab_create(&mut engine, "arg", t, ty);
2897        }
2898        lab_create(&mut engine, "arg", "Ev One", "evidence");
2899        lab_relate(&mut engine, "arg--conclusion", "GROUNDS", "arg--inference");
2900        lab_relate(&mut engine, "arg--inference", "GROUNDS", "arg--ev-one");
2901        lab_relate(&mut engine, "arg--undercutter", "REBUTS", "arg--inference");
2902
2903        // Support-blindness: the defeated inference leaves its
2904        // conclusion accepted; the defeat shows in the shape count.
2905        let conclusion = engine
2906            .get_entity(&crate::entity::EntityId("arg--conclusion".to_string()))
2907            .unwrap()
2908            .clone();
2909        let view = engine.computed_labelling(&conclusion).unwrap();
2910        assert_eq!(view.label.wire(), "accepted", "support-blind by design");
2911        let shape = view.shape.expect("support declared, shape served");
2912        assert_eq!(shape.depth, 2);
2913        assert!((shape.branching - 1.0).abs() < 1e-9);
2914        assert_eq!(shape.terminal_share, Some(1.0));
2915        assert_eq!(shape.defeated_in_support, 1, "the defeated inference");
2916        assert_eq!(shape.undecided_in_support, 0);
2917
2918        // Isolated entity: zeros and a null share.
2919        lab_create(&mut engine, "arg", "Loner", "claim");
2920        let loner = engine
2921            .get_entity(&crate::entity::EntityId("arg--loner".to_string()))
2922            .unwrap()
2923            .clone();
2924        let view = engine.computed_labelling(&loner).unwrap();
2925        let shape = view.shape.unwrap();
2926        assert_eq!(
2927            (shape.depth, shape.branching, shape.terminal_share),
2928            (0, 0.0, None)
2929        );
2930        assert_eq!(
2931            (shape.defeated_in_support, shape.undecided_in_support),
2932            (0, 0)
2933        );
2934
2935        // Cross-mem: an attack edge from `other` into `arg` (granted
2936        // by workspace policy) is excluded from the computation and
2937        // counted; the target stays accepted.
2938        let mut settings = engine.settings().clone();
2939        settings.cross_mem_links.insert(
2940            "other".to_string(),
2941            memstead_schema::workspace_config::CrossLinkValue::Wildcard,
2942        );
2943        engine.set_settings(settings);
2944        lab_create(&mut engine, "other", "Foreign", "claim");
2945        lab_relate(&mut engine, "other--foreign", "REBUTS", "arg--conclusion");
2946        let conclusion = engine
2947            .get_entity(&crate::entity::EntityId("arg--conclusion".to_string()))
2948            .unwrap()
2949            .clone();
2950        let view = engine.computed_labelling(&conclusion).unwrap();
2951        assert_eq!(
2952            view.label.wire(),
2953            "accepted",
2954            "the cross-mem attack is excluded, never guessed"
2955        );
2956        let axis = engine.health_labelling_axis(Some("arg"));
2957        assert_eq!(axis["arg"]["cross_mem_edges_excluded"], 1);
2958        assert!(axis.get("other").is_none(), "mem filter narrows");
2959
2960        // Serving channels: envelope `_labelling` and the text
2961        // channel's `_label` + `## Labelling`; the canonical form
2962        // stays projection-free.
2963        let inference = engine
2964            .get_entity(&crate::entity::EntityId("arg--inference".to_string()))
2965            .unwrap()
2966            .clone();
2967        let view = engine.computed_labelling(&inference).unwrap();
2968        let md =
2969            crate::render::render_entity_markdown_with_signals(&inference, None, None, Some(&view));
2970        assert!(md.contains("_label: defeated"), "{md}");
2971        assert!(md.contains("## Labelling"), "{md}");
2972        assert!(md.contains("defeated_by: arg--undercutter"), "{md}");
2973        let env = crate::render::build_entity_envelope(
2974            &inference,
2975            0,
2976            None,
2977            None,
2978            None,
2979            crate::render::OriginClass::FirstParty,
2980            engine
2981                .store()
2982                .outgoing(&crate::entity::EntityId("arg--inference".to_string())),
2983            None,
2984            None,
2985            Some(&view),
2986        );
2987        assert_eq!(env["_labelling"]["label"], "defeated");
2988        assert_eq!(env["_labelling"]["defeated_by"][0], "arg--undercutter");
2989        assert!(env["_labelling"]["shape"].is_object());
2990        let canonical = crate::render::render_entity_markdown(&inference, None);
2991        assert!(
2992            !canonical.contains("_label") && !canonical.contains("## Labelling"),
2993            "canonical form is projection-free"
2994        );
2995
2996        // No-declaration complement: a signals-fixture entity (schema
2997        // without labelling) serves no labelling view at all.
2998        let tmp2 = TempDir::new().unwrap();
2999        let mut plain = engine_with_signals_schema(&tmp2);
3000        let (actor, client) = cli_actor();
3001        let mut sections = IndexMap::new();
3002        sections.insert("body".to_string(), "a claim body.".to_string());
3003        plain
3004            .create_entity(
3005                CreateEntityArgs {
3006                    anchors: Vec::new(),
3007                    mem: "arg".to_string(),
3008                    title: "Plain".to_string(),
3009                    entity_type: "claim".to_string(),
3010                    sections,
3011                    metadata: IndexMap::new(),
3012                    relations: vec![],
3013                    dry_run: false,
3014                },
3015                actor,
3016                Some(&client),
3017                None,
3018            )
3019            .unwrap();
3020        let plain_entity = plain
3021            .get_entity(&crate::entity::EntityId("arg--plain".to_string()))
3022            .unwrap()
3023            .clone();
3024        assert!(plain.computed_labelling(&plain_entity).is_none());
3025    }
3026
3027    /// An attack-only declaration (no `support` walk) serves labels
3028    /// but nothing shape-shaped on any channel.
3029    #[test]
3030    fn labelling_without_support_serves_no_shape() {
3031        let tmp = TempDir::new().unwrap();
3032        let mut engine = engine_with_labelling_schema_support(&tmp, false, false);
3033        lab_create(&mut engine, "arg", "Solo", "claim");
3034        let entity = engine
3035            .get_entity(&crate::entity::EntityId("arg--solo".to_string()))
3036            .unwrap()
3037            .clone();
3038        let view = engine.computed_labelling(&entity).expect("labels served");
3039        assert_eq!(view.label.wire(), "accepted");
3040        assert!(view.shape.is_none(), "no support declaration, no shape");
3041        assert!(view.to_json().get("shape").is_none());
3042        let md =
3043            crate::render::render_entity_markdown_with_signals(&entity, None, None, Some(&view));
3044        assert!(!md.contains("- shape:"), "{md}");
3045    }
3046
3047    fn engine_with_signals_schema(tmp: &TempDir) -> Engine {
3048        let schemas_dir = tmp.path().join("schemas");
3049        let pkg = schemas_dir.join("argsig");
3050        std::fs::create_dir_all(pkg.join("types")).unwrap();
3051        std::fs::write(
3052            pkg.join("schema.yaml"),
3053            r#"name: argsig
3054version: 0.1.0
3055description: aggregate-signal fixture
3056when_to_use: tests
3057types:
3058  - claim
3059  - objection
3060relationships:
3061  mode: strict
3062  definitions:
3063    - name: REBUTS
3064      description: r
3065      default_weight: 3.0
3066    - name: PART_OF
3067      description: hier
3068      default_weight: 1.0
3069    - name: _default
3070      description: fallback
3071      default_weight: 1.0
3072community:
3073  resolution: 1.0
3074  seed: 42
3075"#,
3076        )
3077        .unwrap();
3078        let body = "sections:\n  - key: body\n    heading: Body\n    required: true\n    search_weight: 10.0\n    catch_all: true\n    write_rules: []\ntitle_weight: 100.0\ntext_fields:\n  - body\nhierarchy_relationship: PART_OF\nno_self_loop_relationships: []\nhealth_required_fields:\n  - body\nstaleness_threshold_days: 90\nwrite_rules: []\n";
3079        std::fs::write(
3080            pkg.join("types").join("claim.yaml"),
3081            format!(
3082                "name: claim\ndescription: t\nwhen_to_use: tests\nmetadata_fields: []\nupdatable_fields:\n  - title\n  - body\n{body}signals:\n  - name: attack_load\n    kind: edge_load\n    relationships: [REBUTS]\n    direction: in\n    thresholds:\n      - at_least: 1\n        level: notice\n      - at_least: 3\n        level: warn\n  - name: open_objections\n    kind: edge_load\n    relationships: [REBUTS]\n    direction: in\n    neighbour_field: state\n    neighbour_value: open\n    thresholds:\n      - at_least: 1\n        level: notice\n"
3083            ),
3084        )
3085        .unwrap();
3086        std::fs::write(
3087            pkg.join("types").join("objection.yaml"),
3088            format!(
3089                "name: objection\ndescription: t\nwhen_to_use: tests\nmetadata_fields:\n  - key: state\n    description: objection lifecycle\n    field_type: string\n    enum_values: [open, closed]\nupdatable_fields:\n  - title\n  - body\n  - state\n{body}"
3090            ),
3091        )
3092        .unwrap();
3093        let mem_dir = tmp.path().join("mem");
3094        std::fs::create_dir_all(&mem_dir).unwrap();
3095        let writer = FilesystemMemWriter::new(mem_dir.clone());
3096        let mount = crate::workspace::Mount {
3097            mem: "arg".to_string(),
3098            schema: Some(memstead_schema::SchemaRef::new(
3099                "argsig",
3100                semver::Version::new(0, 1, 0),
3101            )),
3102            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
3103            capability: crate::workspace::MountCapability::Write,
3104            lifecycle: crate::workspace::MountLifecycle::Eager,
3105            cross_linkable: true,
3106            migration_target: None,
3107        };
3108        Engine::from_mounts_with_schemas_dir(
3109            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3110            Some(&schemas_dir),
3111        )
3112        .unwrap()
3113    }
3114
3115    fn objection_args(title: &str, state: Option<&str>, rebuts: Option<&str>) -> CreateEntityArgs {
3116        let mut sections = IndexMap::new();
3117        sections.insert("body".to_string(), "an objection body.".to_string());
3118        let mut metadata = IndexMap::new();
3119        if let Some(s) = state {
3120            metadata.insert("state".to_string(), s.to_string());
3121        }
3122        let relations = rebuts
3123            .map(|to| {
3124                vec![crate::ops::RelateArg {
3125                    target: crate::entity::EntityId(to.to_string()),
3126                    rel_type: "REBUTS".to_string(),
3127                    description: None,
3128                }]
3129            })
3130            .unwrap_or_default();
3131        CreateEntityArgs {
3132            anchors: Vec::new(),
3133            mem: "arg".to_string(),
3134            title: title.to_string(),
3135            entity_type: "objection".to_string(),
3136            sections,
3137            metadata,
3138            relations,
3139            dry_run: false,
3140        }
3141    }
3142
3143    fn crossing_warnings_of(
3144        warnings: &[WarningHint],
3145    ) -> Vec<(String, String, u64, String, String)> {
3146        warnings
3147            .iter()
3148            .filter_map(|w| match w {
3149                WarningHint::SignalThresholdCrossed {
3150                    entity_id,
3151                    signal,
3152                    value,
3153                    old_level,
3154                    new_level,
3155                } => Some((
3156                    entity_id.to_string(),
3157                    signal.clone(),
3158                    *value,
3159                    old_level.clone(),
3160                    new_level.clone(),
3161                )),
3162                _ => None,
3163            })
3164            .collect()
3165    }
3166
3167    /// Signals on reads: thresholds map boundary counts to levels, the
3168    /// neighbour filter counts only qualifying counterparts, the two
3169    /// serving channels carry headline + contributors, a flip of the
3170    /// counterpart's field changes the count on the next read, and two
3171    /// engine instances over the same on-disk state serve identical
3172    /// payloads.
3173    #[test]
3174    fn signals_reads_thresholds_neighbour_filter_and_determinism() {
3175        let tmp = TempDir::new().unwrap();
3176        let mut engine = engine_with_signals_schema(&tmp);
3177        let (actor, client) = cli_actor();
3178
3179        let mut claim_sections = IndexMap::new();
3180        claim_sections.insert("body".to_string(), "a claim body.".to_string());
3181        engine
3182            .create_entity(
3183                CreateEntityArgs {
3184                    anchors: Vec::new(),
3185                    mem: "arg".to_string(),
3186                    title: "Claim".to_string(),
3187                    entity_type: "claim".to_string(),
3188                    sections: claim_sections,
3189                    metadata: IndexMap::new(),
3190                    relations: vec![],
3191                    dry_run: false,
3192                },
3193                actor,
3194                Some(&client),
3195                None,
3196            )
3197            .unwrap();
3198        let claim_id = crate::entity::EntityId("arg--claim".to_string());
3199        let sig_of = |engine: &Engine, name: &str| {
3200            let entity = engine.get_entity(&claim_id).unwrap().clone();
3201            engine
3202                .computed_signals(&entity)
3203                .unwrap()
3204                .into_iter()
3205                .find(|s| s.name == name)
3206                .unwrap()
3207        };
3208
3209        // Below the first threshold: value 0, level none.
3210        let s = sig_of(&engine, "attack_load");
3211        assert_eq!((s.value, s.level_wire()), (0, "none"));
3212
3213        // First objection (open, inline REBUTS): the create's crossing
3214        // warnings name BOTH signals moving none → notice on the claim.
3215        let outcome = engine
3216            .create_entity(
3217                objection_args("Obj One", Some("open"), Some("arg--claim")),
3218                actor,
3219                Some(&client),
3220                None,
3221            )
3222            .unwrap();
3223        let crossings = crossing_warnings_of(&outcome.warnings);
3224        assert!(
3225            crossings.contains(&(
3226                "arg--claim".to_string(),
3227                "attack_load".to_string(),
3228                1,
3229                "none".to_string(),
3230                "notice".to_string()
3231            )),
3232            "create with inline relation crosses attack_load: {crossings:?}"
3233        );
3234        assert!(
3235            crossings.iter().any(|c| c.1 == "open_objections"),
3236            "open counterpart crosses open_objections too: {crossings:?}"
3237        );
3238
3239        // Closed and field-less counterparts count for attack_load,
3240        // never for open_objections.
3241        engine
3242            .create_entity(
3243                objection_args("Obj Two", Some("closed"), Some("arg--claim")),
3244                actor,
3245                Some(&client),
3246                None,
3247            )
3248            .unwrap();
3249        engine
3250            .create_entity(
3251                objection_args("Obj Three", None, Some("arg--claim")),
3252                actor,
3253                Some(&client),
3254                None,
3255            )
3256            .unwrap();
3257        let attack = sig_of(&engine, "attack_load");
3258        assert_eq!((attack.value, attack.level_wire()), (3, "warn"));
3259        assert_eq!(attack.contributors.len(), 3);
3260        let open = sig_of(&engine, "open_objections");
3261        assert_eq!((open.value, open.level_wire()), (1, "notice"));
3262        assert_eq!(open.contributors[0].0, "arg--obj-one");
3263
3264        // Both serving channels: frontmatter headline + `## Signals`
3265        // contributors on the text channel; `_signals` on the envelope.
3266        let entity = engine.get_entity(&claim_id).unwrap().clone();
3267        let signals = engine.computed_signals(&entity).unwrap();
3268        let md =
3269            crate::render::render_entity_markdown_with_signals(&entity, None, Some(&signals), None);
3270        assert!(
3271            md.contains("_signals: [attack_load: 3 (warn), open_objections: 1 (notice)]"),
3272            "frontmatter headline: {md}"
3273        );
3274        assert!(md.contains("## Signals"), "contributors section: {md}");
3275        assert!(md.contains("arg--obj-one"), "evidence ships: {md}");
3276        let env = crate::render::build_entity_envelope(
3277            &entity,
3278            0,
3279            None,
3280            None,
3281            None,
3282            crate::render::OriginClass::FirstParty,
3283            engine.store().outgoing(&claim_id),
3284            None,
3285            Some(&signals),
3286            None,
3287        );
3288        assert_eq!(env["_signals"][0]["name"], "attack_load");
3289        assert_eq!(env["_signals"][0]["value"], 3);
3290        assert_eq!(env["_signals"][0]["level"], "warn");
3291        assert_eq!(
3292            env["_signals"][0]["contributors"].as_array().unwrap().len(),
3293            3
3294        );
3295        // The canonical form stays signal-free.
3296        let canonical = crate::render::render_entity_markdown(&entity, None);
3297        assert!(
3298            !canonical.contains("_signals"),
3299            "canonical form is signal-free"
3300        );
3301
3302        // Flipping the counterpart's field changes the count on the
3303        // next read, and the update carries the crossing for the CLAIM.
3304        let obj_one = crate::entity::EntityId("arg--obj-one".to_string());
3305        let current = engine.get_entity(&obj_one).unwrap().content_hash.clone();
3306        let mut metadata = IndexMap::new();
3307        metadata.insert("state".to_string(), "closed".to_string());
3308        let outcome = engine
3309            .update_entity(
3310                crate::engine::UpdateEntityArgs {
3311                    anchors: Vec::new(),
3312                    id: obj_one,
3313                    expected_hash: Some(current),
3314                    sections: IndexMap::new(),
3315                    append_sections: IndexMap::new(),
3316                    patch_sections: IndexMap::new(),
3317                    metadata,
3318                    metadata_unset: Vec::new(),
3319                    declare_relations: vec![],
3320                    dry_run: false,
3321                    relations_unset: Vec::new(),
3322                    anchors_unset: Vec::new(),
3323                },
3324                actor,
3325                Some(&client),
3326                None,
3327            )
3328            .unwrap();
3329        assert!(!outcome.write_id.is_empty(), "success shape kept");
3330        let crossings = crossing_warnings_of(&outcome.warnings);
3331        assert!(
3332            crossings.contains(&(
3333                "arg--claim".to_string(),
3334                "open_objections".to_string(),
3335                0,
3336                "notice".to_string(),
3337                "none".to_string()
3338            )),
3339            "the neighbour flip crosses the claim's filtered signal: {crossings:?}"
3340        );
3341        let open = sig_of(&engine, "open_objections");
3342        assert_eq!((open.value, open.level_wire()), (0, "none"));
3343
3344        // Determinism: a second instance over the same on-disk state
3345        // serves an identical payload.
3346        let engine_b = engine_with_signals_schema(&tmp);
3347        let entity_b = engine_b.get_entity(&claim_id).unwrap().clone();
3348        let signals_b = engine_b.computed_signals(&entity_b).unwrap();
3349        let entity_a = engine.get_entity(&claim_id).unwrap().clone();
3350        let signals_a = engine.computed_signals(&entity_a).unwrap();
3351        assert_eq!(
3352            crate::ops::signals::signals_json(&signals_a),
3353            crate::ops::signals::signals_json(&signals_b),
3354            "two instances over the same mem state serve identical signals"
3355        );
3356    }
3357
3358    /// Crossings on the relate surface: upward and downward crossings
3359    /// warn with the full detail set; a write that crosses nothing
3360    /// carries nothing signal-shaped; the mutation stays the success
3361    /// shape throughout. The `signals` health axis serves only
3362    /// above-`none` entities, with per-level counts and a mem filter.
3363    #[test]
3364    fn signal_crossings_on_relate_and_health_axis() {
3365        let tmp = TempDir::new().unwrap();
3366        let mut engine = engine_with_signals_schema(&tmp);
3367        let (actor, client) = cli_actor();
3368
3369        let mut claim_sections = IndexMap::new();
3370        claim_sections.insert("body".to_string(), "a claim body.".to_string());
3371        engine
3372            .create_entity(
3373                CreateEntityArgs {
3374                    anchors: Vec::new(),
3375                    mem: "arg".to_string(),
3376                    title: "Claim".to_string(),
3377                    entity_type: "claim".to_string(),
3378                    sections: claim_sections,
3379                    metadata: IndexMap::new(),
3380                    relations: vec![],
3381                    dry_run: false,
3382                },
3383                actor,
3384                Some(&client),
3385                None,
3386            )
3387            .unwrap();
3388        for (t, s) in [("Obj A", "open"), ("Obj B", "closed"), ("Obj C", "closed")] {
3389            engine
3390                .create_entity(objection_args(t, Some(s), None), actor, Some(&client), None)
3391                .unwrap();
3392        }
3393        let relate = |engine: &mut Engine, from: &str, remove: bool| {
3394            engine
3395                .relate_entity(
3396                    crate::engine::RelateEntityArgs {
3397                        source: crate::entity::EntityId(from.to_string()),
3398                        target: crate::entity::EntityId("arg--claim".to_string()),
3399                        rel_type: "REBUTS".to_string(),
3400                        description: None,
3401                        remove,
3402                        expected_hash: None,
3403                        dry_run: false,
3404                    },
3405                    actor,
3406                    Some(&client),
3407                    None,
3408                )
3409                .unwrap()
3410        };
3411
3412        // 0 → 1: none → notice (upward), on the TARGET of the edge.
3413        let outcome = relate(&mut engine, "arg--obj-a", false);
3414        assert!(!outcome.write_id.is_empty());
3415        let crossings = crossing_warnings_of(&outcome.warnings);
3416        assert!(
3417            crossings.contains(&(
3418                "arg--claim".to_string(),
3419                "attack_load".to_string(),
3420                1,
3421                "none".to_string(),
3422                "notice".to_string()
3423            )),
3424            "{crossings:?}"
3425        );
3426
3427        // 1 → 2: notice → notice — nothing signal-shaped rides.
3428        let outcome = relate(&mut engine, "arg--obj-b", false);
3429        assert!(
3430            crossing_warnings_of(&outcome.warnings).is_empty(),
3431            "no threshold crossed, no warning: {:?}",
3432            outcome.warnings
3433        );
3434
3435        // 2 → 3: notice → warn.
3436        let outcome = relate(&mut engine, "arg--obj-c", false);
3437        let crossings = crossing_warnings_of(&outcome.warnings);
3438        assert!(
3439            crossings.contains(&(
3440                "arg--claim".to_string(),
3441                "attack_load".to_string(),
3442                3,
3443                "notice".to_string(),
3444                "warn".to_string()
3445            )),
3446            "{crossings:?}"
3447        );
3448
3449        // Health axis at warn: the claim is the one above-`none`
3450        // entity; counts split per level; a mem filter narrows.
3451        let axis = engine.health_signals_axis(None);
3452        assert_eq!(axis["entities"].as_array().unwrap().len(), 1);
3453        assert_eq!(axis["entities"][0]["id"], "arg--claim");
3454        assert_eq!(axis["counts"]["warn"], 1, "attack_load at warn: {axis}");
3455        assert_eq!(axis["counts"]["notice"], 1, "open_objections at notice");
3456        let filtered = engine.health_signals_axis(Some("other"));
3457        assert!(filtered["entities"].as_array().unwrap().is_empty());
3458
3459        // 3 → 2: warn → notice (downward crossing warns too).
3460        let outcome = relate(&mut engine, "arg--obj-c", true);
3461        let crossings = crossing_warnings_of(&outcome.warnings);
3462        assert!(
3463            crossings.contains(&(
3464                "arg--claim".to_string(),
3465                "attack_load".to_string(),
3466                2,
3467                "warn".to_string(),
3468                "notice".to_string()
3469            )),
3470            "{crossings:?}"
3471        );
3472    }
3473
3474    /// Regression pin for `no_self_loop_relationships`' single
3475    /// functional behavior: a self-loop (`from == to`) on a rel-type
3476    /// the source type lists there refuses with `RELATIONSHIP_CYCLE`.
3477    /// The constraint vocabulary settles the field's semantics — the
3478    /// new propagation declaration gets a distinct name, and this pin
3479    /// guards that the old field keeps exactly this effect.
3480    #[test]
3481    fn no_self_loop_rel_type_self_loop_refusal_is_pinned() {
3482        let tmp = TempDir::new().unwrap();
3483        // The `constr` fixture declares `no_self_loop_relationships:
3484        // [PART_OF]` on `task`.
3485        let mut engine = engine_with_constraints_schema(&tmp, "warn", "warn");
3486        let (actor, client) = cli_actor();
3487        let a = engine
3488            .create_entity(
3489                task_create_args("Task A", vec![]),
3490                actor,
3491                Some(&client),
3492                None,
3493            )
3494            .unwrap();
3495        let err = engine
3496            .relate_entity(
3497                crate::engine::RelateEntityArgs {
3498                    source: a.id.clone(),
3499                    target: a.id.clone(),
3500                    rel_type: "PART_OF".to_string(),
3501                    description: None,
3502                    remove: false,
3503                    expected_hash: None,
3504                    dry_run: false,
3505                },
3506                actor,
3507                Some(&client),
3508                None,
3509            )
3510            .unwrap_err();
3511        assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
3512    }
3513
3514    /// Generic constraint-proof fixture: one folder-mounted mem
3515    /// (`proof`) pinned to a schema built from the given manifest and
3516    /// type YAMLs.
3517    fn engine_with_proof_schema(
3518        tmp: &TempDir,
3519        schema_name: &str,
3520        manifest_yaml: &str,
3521        types: &[(&str, &str)],
3522    ) -> Engine {
3523        let schemas_dir = tmp.path().join("schemas");
3524        let pkg = schemas_dir.join(schema_name);
3525        std::fs::create_dir_all(pkg.join("types")).unwrap();
3526        std::fs::write(pkg.join("schema.yaml"), manifest_yaml).unwrap();
3527        for (name, yaml) in types {
3528            std::fs::write(pkg.join("types").join(format!("{name}.yaml")), yaml).unwrap();
3529        }
3530        let mem_dir = tmp.path().join("mem");
3531        std::fs::create_dir_all(&mem_dir).unwrap();
3532        let writer = FilesystemMemWriter::new(mem_dir.clone());
3533        let mount = crate::workspace::Mount {
3534            mem: "proof".to_string(),
3535            schema: Some(memstead_schema::SchemaRef::new(
3536                schema_name,
3537                semver::Version::new(0, 1, 0),
3538            )),
3539            storage: crate::workspace::MountStorage::Folder { path: mem_dir },
3540            capability: crate::workspace::MountCapability::Write,
3541            lifecycle: crate::workspace::MountLifecycle::Eager,
3542            cross_linkable: true,
3543            migration_target: None,
3544        };
3545        Engine::from_mounts_with_schemas_dir(
3546            vec![(mount, Box::new(writer) as Box<dyn MemBackend>)],
3547            Some(&schemas_dir),
3548        )
3549        .unwrap()
3550    }
3551
3552    fn proof_create(
3553        engine: &mut Engine,
3554        entity_type: &str,
3555        title: &str,
3556        metadata: &[(&str, &str)],
3557        relations: Vec<crate::ops::RelateArg>,
3558    ) -> Result<CreateEntityOutcome, EngineError> {
3559        let (actor, client) = cli_actor();
3560        let mut sections = IndexMap::new();
3561        sections.insert("body".to_string(), format!("{title} body."));
3562        let mut md = IndexMap::new();
3563        for (k, v) in metadata {
3564            md.insert(k.to_string(), v.to_string());
3565        }
3566        engine.create_entity(
3567            CreateEntityArgs {
3568                anchors: Vec::new(),
3569                mem: "proof".to_string(),
3570                title: title.to_string(),
3571                entity_type: entity_type.to_string(),
3572                sections,
3573                metadata: md,
3574                relations,
3575                dry_run: false,
3576            },
3577            actor,
3578            Some(&client),
3579            None,
3580        )
3581    }
3582
3583    fn rel(to: &str, rel_type: &str) -> crate::ops::RelateArg {
3584        crate::ops::RelateArg {
3585            target: crate::entity::EntityId(to.to_string()),
3586            rel_type: rel_type.to_string(),
3587            description: None,
3588        }
3589    }
3590
3591    const GROUNDING_MANIFEST: &str = r#"name: grounding
3592version: 0.1.0
3593description: anker-shaped grounding proof schema
3594when_to_use: constraint-proof tests
3595types:
3596  - anchor
3597  - tradeoff
3598relationships:
3599  mode: strict
3600  definitions:
3601    - name: FOLLOWS_FROM
3602      description: stands on
3603      default_weight: 3.0
3604    - name: SUPPORTS
3605      description: pro
3606      default_weight: 1.0
3607    - name: OPPOSES
3608      description: contra
3609      default_weight: 1.0
3610    - name: PART_OF
3611      description: hier
3612      default_weight: 1.0
3613    - name: _default
3614      description: fallback
3615      default_weight: 1.0
3616community:
3617  resolution: 1.0
3618  seed: 42
3619"#;
3620
3621    const GROUNDING_ANCHOR: &str = r#"name: anchor
3622description: a judgment standing on others
3623when_to_use: tests
3624sections:
3625  - key: body
3626    heading: Body
3627    required: true
3628    search_weight: 10.0
3629    catch_all: true
3630    write_rules: []
3631metadata_fields:
3632  - key: status
3633    description: lifecycle
3634    field_type: string
3635    enum_values: [open, checked, fallen]
3636  - key: checked_by
3637    description: who checked
3638    field_type: string
3639title_weight: 100.0
3640text_fields:
3641  - body
3642hierarchy_relationship: PART_OF
3643no_self_loop_relationships: []
3644updatable_fields:
3645  - title
3646  - body
3647  - status
3648  - checked_by
3649health_required_fields:
3650  - body
3651staleness_threshold_days: 90
3652constraints:
3653  - kind: requires_when
3654    field: checked_by
3655    when_field: status
3656    when_value: checked
3657  - kind: status_propagation
3658    field: status
3659    value: fallen
3660    rel_type: FOLLOWS_FROM
3661    direction: incoming
3662write_rules: []
3663"#;
3664
3665    const GROUNDING_TRADEOFF: &str = r#"name: tradeoff
3666description: a claim with two sides
3667when_to_use: tests
3668sections:
3669  - key: body
3670    heading: Body
3671    required: true
3672    search_weight: 10.0
3673    catch_all: true
3674    write_rules: []
3675metadata_fields: []
3676title_weight: 100.0
3677text_fields:
3678  - body
3679hierarchy_relationship: PART_OF
3680no_self_loop_relationships: []
3681updatable_fields:
3682  - title
3683  - body
3684health_required_fields:
3685  - body
3686staleness_threshold_days: 90
3687required_outgoing:
3688  - relationships: [SUPPORTS]
3689    cardinality: at_least_one
3690  - relationships: [OPPOSES]
3691    cardinality: at_least_one
3692write_rules: []
3693"#;
3694
3695    /// The anker proof (plan 07, criterion 2): the grounding-shaped
3696    /// schema answers `pruefe_kette.py`'s check questions 1–3 from
3697    /// health output alone — no project Python.
3698    #[test]
3699    fn anker_proof_grounding_schema_answers_check_questions_from_health() {
3700        let tmp = TempDir::new().unwrap();
3701        let mut engine = engine_with_proof_schema(
3702            &tmp,
3703            "grounding",
3704            GROUNDING_MANIFEST,
3705            &[
3706                ("anchor", GROUNDING_ANCHOR),
3707                ("tradeoff", GROUNDING_TRADEOFF),
3708            ],
3709        );
3710
3711        // A fallen root, a child standing on it, a grandchild standing
3712        // on the child (transitive), plus an untainted sibling chain.
3713        let root = proof_create(
3714            &mut engine,
3715            "anchor",
3716            "Root",
3717            &[("status", "fallen")],
3718            vec![],
3719        )
3720        .unwrap();
3721        let child = proof_create(
3722            &mut engine,
3723            "anchor",
3724            "Child",
3725            &[],
3726            vec![rel(&root.id.0, "FOLLOWS_FROM")],
3727        )
3728        .unwrap();
3729        let grandchild = proof_create(
3730            &mut engine,
3731            "anchor",
3732            "Grandchild",
3733            &[],
3734            vec![rel(&child.id.0, "FOLLOWS_FROM")],
3735        )
3736        .unwrap();
3737        let standing = proof_create(
3738            &mut engine,
3739            "anchor",
3740            "Standing Root",
3741            &[("status", "open")],
3742            vec![],
3743        )
3744        .unwrap();
3745        let standing_child = proof_create(
3746            &mut engine,
3747            "anchor",
3748            "Standing Child",
3749            &[],
3750            vec![rel(&standing.id.0, "FOLLOWS_FROM")],
3751        )
3752        .unwrap();
3753        // Question 3's subject: checked without a checker.
3754        let unchecked = proof_create(
3755            &mut engine,
3756            "anchor",
3757            "Checked No Checker",
3758            &[("status", "checked")],
3759            vec![],
3760        )
3761        .unwrap();
3762        // Question 2's subject: a one-sided trade-off.
3763        let onesided = proof_create(
3764            &mut engine,
3765            "tradeoff",
3766            "One Sided",
3767            &[],
3768            vec![rel(&standing.id.0, "SUPPORTS")],
3769        )
3770        .unwrap();
3771
3772        // Question 1 — descendants of the fallen anchor are flagged,
3773        // naming their ancestor; the standing chain is not.
3774        let findings =
3775            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
3776        let tainted_of = |id: &crate::entity::EntityId| -> Vec<String> {
3777            findings
3778                .iter()
3779                .filter(|r| &r.id == id)
3780                .flat_map(|r| &r.violations)
3781                .filter_map(|v| match v {
3782                    crate::ops::health::UnsatisfiedConstraint::StatusPropagation {
3783                        tainted_by,
3784                        ..
3785                    } => Some(tainted_by.clone()),
3786                    _ => None,
3787                })
3788                .collect()
3789        };
3790        assert_eq!(tainted_of(&child.id), vec![root.id.to_string()]);
3791        assert_eq!(
3792            tainted_of(&grandchild.id),
3793            vec![root.id.to_string()],
3794            "the taint is transitive and names the terminal ancestor"
3795        );
3796        assert!(tainted_of(&standing_child.id).is_empty());
3797        assert!(
3798            tainted_of(&root.id).is_empty(),
3799            "the source is not its own finding"
3800        );
3801
3802        // Question 3 — checked-without-checker is flagged.
3803        assert!(
3804            findings.iter().any(|r| r.id == unchecked.id
3805                && r.violations.iter().any(|v| matches!(
3806                    v,
3807                    crate::ops::health::UnsatisfiedConstraint::RequiresWhen { field, .. }
3808                        if field == "checked_by"
3809                ))),
3810            "checked-without-checker must be a health finding"
3811        );
3812
3813        // Question 2 — the one-sided trade-off is flagged missing its
3814        // OPPOSES block (form 4 at warn), from health output alone.
3815        let missing = crate::ops::health::collect_missing_required_outgoing(
3816            engine.store(),
3817            None,
3818            engine.schemas(),
3819        );
3820        let onesided_report = missing
3821            .iter()
3822            .find(|r| r.id == onesided.id)
3823            .expect("one-sided trade-off flagged");
3824        assert_eq!(onesided_report.missing.len(), 1);
3825        assert_eq!(onesided_report.missing[0].relationships, vec!["OPPOSES"]);
3826    }
3827
3828    const PLENUM_MANIFEST: &str = r#"name: plenum-proof
3829version: 0.1.0
3830description: plenum-shaped uniqueness and vocabulary proof schema
3831when_to_use: constraint-proof tests
3832types:
3833  - rede
3834  - vocabulary
3835relationships:
3836  mode: strict
3837  definitions:
3838    - name: REFERENCES
3839      description: soft ref
3840      default_weight: 0.5
3841    - name: PART_OF
3842      description: hier
3843      default_weight: 1.0
3844    - name: _default
3845      description: fallback
3846      default_weight: 1.0
3847community:
3848  resolution: 1.0
3849  seed: 42
3850"#;
3851
3852    fn plenum_rede_type(unique_severity: &str) -> String {
3853        format!(
3854            r#"name: rede
3855description: one speech
3856when_to_use: tests
3857sections:
3858  - key: body
3859    heading: Body
3860    required: true
3861    search_weight: 10.0
3862    catch_all: true
3863    write_rules: []
3864metadata_fields:
3865  - key: rede_id
3866    description: source id
3867    field_type: string
3868  - key: rede_sha256
3869    description: content hash
3870    field_type: string
3871  - key: kategorie
3872    description: category from the shared vocabulary
3873    field_type: string
3874title_weight: 100.0
3875text_fields:
3876  - body
3877hierarchy_relationship: PART_OF
3878no_self_loop_relationships: []
3879updatable_fields:
3880  - title
3881  - body
3882  - rede_id
3883  - rede_sha256
3884  - kategorie
3885health_required_fields:
3886  - body
3887staleness_threshold_days: 90
3888constraints:
3889  - kind: unique
3890    fields: [rede_id, rede_sha256]
3891    severity: {unique_severity}
3892  - kind: enum_from_neighbour
3893    field: kategorie
3894    rel_type: REFERENCES
3895    section: terms
3896write_rules: []
3897"#
3898        )
3899    }
3900
3901    const PLENUM_VOCABULARY: &str = r#"name: vocabulary
3902description: the shared term list
3903when_to_use: tests
3904sections:
3905  - key: terms
3906    heading: Terms
3907    required: false
3908    search_weight: 5.0
3909    catch_all: false
3910    write_rules: []
3911  - key: body
3912    heading: Body
3913    required: true
3914    search_weight: 10.0
3915    catch_all: true
3916    write_rules: []
3917metadata_fields: []
3918title_weight: 100.0
3919text_fields:
3920  - body
3921hierarchy_relationship: PART_OF
3922no_self_loop_relationships: []
3923updatable_fields:
3924  - title
3925  - body
3926  - terms
3927health_required_fields:
3928  - body
3929staleness_threshold_days: 90
3930write_rules: []
3931"#;
3932
3933    /// The plenum proof, uniqueness half (plan 07, criterion 3): a
3934    /// second create with the same declared key tuple refuses with a
3935    /// typed code naming the colliding entity — the 37-duplicates
3936    /// scenario bounces at the engine. Health reports a pre-existing
3937    /// violation planted under a warn-tier variant.
3938    #[test]
3939    fn plenum_proof_uniqueness_refuses_duplicates_and_health_reports_planted_ones() {
3940        // Block tier: the duplicate refuses, naming the collider.
3941        let tmp = TempDir::new().unwrap();
3942        let rede = plenum_rede_type("block");
3943        let mut engine = engine_with_proof_schema(
3944            &tmp,
3945            "plenum-proof",
3946            PLENUM_MANIFEST,
3947            &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
3948        );
3949        let first = proof_create(
3950            &mut engine,
3951            "rede",
3952            "Speech One",
3953            &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3954            vec![],
3955        )
3956        .unwrap();
3957        let err = proof_create(
3958            &mut engine,
3959            "rede",
3960            "Speech One Duplicate",
3961            &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3962            vec![],
3963        )
3964        .unwrap_err();
3965        assert_eq!(err.code(), "CONSTRAINT_UNSATISFIED");
3966        assert_eq!(
3967            err.details()["violations"][0]["colliding"],
3968            first.id.to_string(),
3969            "the refusal names the colliding entity"
3970        );
3971        // A different tuple passes.
3972        proof_create(
3973            &mut engine,
3974            "rede",
3975            "Speech Two",
3976            &[("rede_id", "19-43"), ("rede_sha256", "def456")],
3977            vec![],
3978        )
3979        .unwrap();
3980
3981        // Warn tier: plant the duplicate, health reports it.
3982        let tmp = TempDir::new().unwrap();
3983        let rede = plenum_rede_type("warn");
3984        let mut engine = engine_with_proof_schema(
3985            &tmp,
3986            "plenum-proof",
3987            PLENUM_MANIFEST,
3988            &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
3989        );
3990        proof_create(
3991            &mut engine,
3992            "rede",
3993            "Planted A",
3994            &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
3995            vec![],
3996        )
3997        .unwrap();
3998        let planted = proof_create(
3999            &mut engine,
4000            "rede",
4001            "Planted B",
4002            &[("rede_id", "19-42"), ("rede_sha256", "abc123")],
4003            vec![],
4004        )
4005        .unwrap();
4006        assert!(
4007            planted
4008                .warnings
4009                .iter()
4010                .any(|w| matches!(w, WarningHint::ConstraintUnsatisfied { .. })),
4011            "warn tier surfaces the duplicate as a warning and commits"
4012        );
4013        let findings =
4014            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
4015        assert_eq!(
4016            findings.len(),
4017            2,
4018            "both sides of the planted duplicate are findings: {findings:?}"
4019        );
4020    }
4021
4022    /// The plenum proof, enum-from-neighbour half (plan 07,
4023    /// criterion 3): renaming a value in the neighbour's section makes
4024    /// every stale holder a health finding.
4025    #[test]
4026    fn plenum_proof_enum_from_neighbour_flags_stale_holders_after_rename() {
4027        let tmp = TempDir::new().unwrap();
4028        let rede = plenum_rede_type("warn");
4029        let mut engine = engine_with_proof_schema(
4030            &tmp,
4031            "plenum-proof",
4032            PLENUM_MANIFEST,
4033            &[("rede", &rede), ("vocabulary", PLENUM_VOCABULARY)],
4034        );
4035        let (actor, client) = cli_actor();
4036
4037        // The vocabulary entity enumerates the legal categories.
4038        let mut sections = IndexMap::new();
4039        sections.insert("body".to_string(), "the term list.".to_string());
4040        sections.insert("terms".to_string(), "- haushalt\n- verkehr\n".to_string());
4041        let vocab = engine
4042            .create_entity(
4043                CreateEntityArgs {
4044                    anchors: Vec::new(),
4045                    mem: "proof".to_string(),
4046                    title: "Kategorien".to_string(),
4047                    entity_type: "vocabulary".to_string(),
4048                    sections,
4049                    metadata: IndexMap::new(),
4050                    relations: vec![],
4051                    dry_run: false,
4052                },
4053                actor,
4054                Some(&client),
4055                None,
4056            )
4057            .unwrap();
4058
4059        // A holder whose value is backed: clean.
4060        let holder = proof_create(
4061            &mut engine,
4062            "rede",
4063            "Holder",
4064            &[("kategorie", "haushalt")],
4065            vec![rel(&vocab.id.0, "REFERENCES")],
4066        )
4067        .unwrap();
4068        let findings =
4069            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
4070        assert!(
4071            findings.iter().all(|r| r.id != holder.id),
4072            "backed value produces no finding: {findings:?}"
4073        );
4074
4075        // Rename the value in the neighbour's section — the holder
4076        // goes stale and health flags it.
4077        let current = engine.get_entity(&vocab.id).unwrap().content_hash.clone();
4078        let mut sections = IndexMap::new();
4079        sections.insert("terms".to_string(), "- finanzen\n- verkehr\n".to_string());
4080        engine
4081            .update_entity(
4082                crate::engine::UpdateEntityArgs {
4083                    anchors: Vec::new(),
4084                    id: vocab.id.clone(),
4085                    expected_hash: Some(current),
4086                    sections,
4087                    append_sections: IndexMap::new(),
4088                    patch_sections: IndexMap::new(),
4089                    metadata: IndexMap::new(),
4090                    metadata_unset: Vec::new(),
4091                    declare_relations: vec![],
4092                    dry_run: false,
4093                    relations_unset: Vec::new(),
4094                    anchors_unset: Vec::new(),
4095                },
4096                actor,
4097                Some(&client),
4098                None,
4099            )
4100            .unwrap();
4101        let findings =
4102            crate::ops::health::collect_constraint_findings(engine.store(), None, engine.schemas());
4103        let stale = findings
4104            .iter()
4105            .find(|r| r.id == holder.id)
4106            .expect("stale holder is flagged after the rename");
4107        assert!(stale.violations.iter().any(|v| matches!(
4108            v,
4109            crate::ops::health::UnsatisfiedConstraint::EnumFromNeighbour { value, .. }
4110                if value == "haushalt"
4111        )));
4112    }
4113
4114    /// The advertised mutation warning is real: a create leaving a
4115    /// required-outgoing block unsatisfied returns
4116    /// `MISSING_REQUIRED_OUTGOING` naming the block with cardinality —
4117    /// and still commits. Complements: a create satisfying the block
4118    /// via inline `relations` emits no such warning; the health sweep
4119    /// reports exactly the same unsatisfied blocks (shared evaluation).
4120    #[test]
4121    fn create_warns_missing_required_outgoing_and_still_commits() {
4122        let tmp = TempDir::new().unwrap();
4123        let mut engine = engine_with_required_outgoing_schema(&tmp);
4124        let (actor, client) = cli_actor();
4125
4126        let outcome = engine
4127            .create_entity(
4128                task_create_args("Orphan Task", vec![]),
4129                actor,
4130                Some(&client),
4131                None,
4132            )
4133            .unwrap();
4134        assert!(
4135            !outcome.write_id.is_empty(),
4136            "the warning never blocks the mutation"
4137        );
4138        let blocks = missing_outgoing_of(&outcome.warnings);
4139        assert_eq!(
4140            blocks,
4141            vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())],
4142            "warning names the unsatisfied block with cardinality; warnings = {:?}",
4143            outcome.warnings
4144        );
4145
4146        // Health-path parity: the sweep reports the same entity with
4147        // the same block — the two surfaces share one evaluation.
4148        let reports = crate::ops::health::collect_missing_required_outgoing(
4149            engine.store(),
4150            None,
4151            engine.schemas(),
4152        );
4153        assert_eq!(reports.len(), 1);
4154        assert_eq!(reports[0].id, outcome.id);
4155        assert_eq!(reports[0].missing.len(), 1);
4156        assert_eq!(reports[0].missing[0].relationships, vec!["PART_OF"]);
4157        assert_eq!(reports[0].missing[0].cardinality, "at_least_one");
4158
4159        // Complement: a create whose inline relation satisfies the
4160        // block emits no MISSING_REQUIRED_OUTGOING.
4161        let satisfied = engine
4162            .create_entity(
4163                task_create_args(
4164                    "Child Task",
4165                    vec![crate::ops::RelateArg {
4166                        target: outcome.id.clone(),
4167                        rel_type: "PART_OF".to_string(),
4168                        description: None,
4169                    }],
4170                ),
4171                actor,
4172                Some(&client),
4173                None,
4174            )
4175            .unwrap();
4176        assert!(
4177            missing_outgoing_of(&satisfied.warnings).is_empty(),
4178            "satisfied block emits no warning: {:?}",
4179            satisfied.warnings
4180        );
4181    }
4182
4183    /// Update-side mirror: a section-only update on an entity with an
4184    /// unsatisfied block warns; declaring the satisfying relation in
4185    /// the same update clears it.
4186    #[test]
4187    fn update_warns_missing_required_outgoing_until_satisfied() {
4188        let tmp = TempDir::new().unwrap();
4189        let mut engine = engine_with_required_outgoing_schema(&tmp);
4190        let (actor, client) = cli_actor();
4191        let a = engine
4192            .create_entity(
4193                task_create_args("Task A", vec![]),
4194                actor,
4195                Some(&client),
4196                None,
4197            )
4198            .unwrap();
4199        let b = engine
4200            .create_entity(
4201                task_create_args("Task B", vec![]),
4202                actor,
4203                Some(&client),
4204                None,
4205            )
4206            .unwrap();
4207
4208        let update = |engine: &mut Engine,
4209                      id: &crate::entity::EntityId,
4210                      declare: Vec<crate::ops::RelateArg>| {
4211            let current = engine.get_entity(id).unwrap().content_hash.clone();
4212            let mut sections = IndexMap::new();
4213            sections.insert("body".to_string(), format!("edited at {:?}", declare.len()));
4214            engine
4215                .update_entity(
4216                    crate::engine::UpdateEntityArgs {
4217                        anchors: Vec::new(),
4218                        id: id.clone(),
4219                        expected_hash: Some(current),
4220                        sections,
4221                        append_sections: IndexMap::new(),
4222                        patch_sections: IndexMap::new(),
4223                        metadata: IndexMap::new(),
4224                        metadata_unset: Vec::new(),
4225                        declare_relations: declare,
4226                        dry_run: false,
4227                        relations_unset: Vec::new(),
4228                        anchors_unset: Vec::new(),
4229                    },
4230                    actor,
4231                    Some(&client),
4232                    None,
4233                )
4234                .unwrap()
4235        };
4236
4237        // Section-only update on an unsatisfied entity: warning fires,
4238        // mutation commits.
4239        let outcome = update(&mut engine, &a.id, vec![]);
4240        assert!(!outcome.write_id.is_empty());
4241        assert_eq!(
4242            missing_outgoing_of(&outcome.warnings),
4243            vec![(vec!["PART_OF".to_string()], "at_least_one".to_string())]
4244        );
4245
4246        // Declaring the satisfying relation in the update clears it.
4247        let outcome = update(
4248            &mut engine,
4249            &a.id,
4250            vec![crate::ops::RelateArg {
4251                target: b.id.clone(),
4252                rel_type: "PART_OF".to_string(),
4253                description: None,
4254            }],
4255        );
4256        assert!(
4257            missing_outgoing_of(&outcome.warnings).is_empty(),
4258            "satisfied block emits no warning: {:?}",
4259            outcome.warnings
4260        );
4261    }
4262
4263    /// The source-vs-binding check at the engine seam: an anchor naming
4264    /// BOTH a producing binding (by hash) and a `source` refuses when
4265    /// the binding resolves in this workspace but does not declare the
4266    /// name — with the declared names in the recovery payload. A
4267    /// declared name is accepted; an unresolvable binding hash accepts
4268    /// any non-empty name (validation never requires resolution).
4269    #[test]
4270    fn anchor_source_validated_against_resolvable_binding() {
4271        use crate::binding::{
4272            BINDING_VERSION, Binding, BuildMode, BuildOperation, Operations, hash_binding,
4273        };
4274        use crate::pipeline::{IngestTrigger, PatternEntry, PatternMode, Source};
4275
4276        let tmp = TempDir::new().unwrap();
4277        let (mut engine, _seed) = engine_with_seed(&tmp, "Seed");
4278        let (actor, client) = cli_actor();
4279
4280        // A workspace root carrying one binding with two declared sources.
4281        let ws = TempDir::new().unwrap();
4282        let binding = Binding {
4283            version: BINDING_VERSION,
4284            intent: None,
4285            sources: ["api-docs", "guides"]
4286                .into_iter()
4287                .map(|n| Source {
4288                    name: n.to_string(),
4289                    medium_type: crate::pipeline::MediumType::Codebase,
4290                    pointer: "../src".to_string(),
4291                    change_detection: None,
4292                    scope: vec![PatternEntry {
4293                        path: "**/*".to_string(),
4294                        mode: PatternMode::Allow,
4295                    }],
4296                    engagement: None,
4297                    preparation: None,
4298                })
4299                .collect(),
4300            reference_mems: Vec::new(),
4301            destination_mem: "specs".to_string(),
4302            deny_paths: Vec::new(),
4303            coverage_semantics: None,
4304            rules: None,
4305            prune: None,
4306            operations: Operations {
4307                build: Some(BuildOperation {
4308                    mode: BuildMode::Discovery,
4309                    trigger: IngestTrigger::Loop,
4310                    batch_size: 20,
4311                    post_actions: None,
4312                }),
4313                sync: None,
4314                verify: None,
4315            },
4316        };
4317        let dir = ws
4318            .path()
4319            .join(".memstead")
4320            .join("projections")
4321            .join("specs");
4322        std::fs::create_dir_all(&dir).unwrap();
4323        std::fs::write(
4324            dir.join("docs.json"),
4325            serde_json::to_string_pretty(&binding).unwrap(),
4326        )
4327        .unwrap();
4328        engine.set_workspace_root(ws.path().to_path_buf());
4329        let binding_hash = hash_binding(&binding);
4330
4331        // The artifact must resolve (workspace-relative fallback) — the
4332        // write gate refuses dead references; this test's subject is the
4333        // source-NAME validation, not the path join.
4334        std::fs::create_dir_all(ws.path().join("src")).unwrap();
4335        std::fs::write(ws.path().join("src").join("x.rs"), "fn x() {}").unwrap();
4336
4337        let anchor = |source: &str, binding: &str| crate::anchor::AnchorInput {
4338            artifact: Some("src/x.rs".into()),
4339            grain: Some("file".into()),
4340            class: Some("anchored".into()),
4341            binding: Some(binding.into()),
4342            source: Some(source.into()),
4343            ..Default::default()
4344        };
4345        let make_args = |title: &str, a: crate::anchor::AnchorInput| {
4346            let mut args = empty_create_args("specs", title);
4347            args.anchors = vec![a];
4348            args
4349        };
4350
4351        // Undeclared name against the RESOLVING binding: refuses with the
4352        // declared names in the payload.
4353        let err = engine
4354            .create_entity(
4355                make_args("Bad Source", anchor("front-page", &binding_hash)),
4356                actor,
4357                Some(&client),
4358                None,
4359            )
4360            .unwrap_err();
4361        assert_eq!(err.code(), "INVALID_ANCHOR", "got {err:?}");
4362        let details = err.details();
4363        assert_eq!(details["field"], "source");
4364        assert_eq!(details["got"], "front-page");
4365        assert_eq!(
4366            details["declared"],
4367            serde_json::json!(["api-docs", "guides"])
4368        );
4369
4370        // A declared name is accepted, and the anchor round-trips with it.
4371        let ok = engine
4372            .create_entity(
4373                make_args("Good Source", anchor("api-docs", &binding_hash)),
4374                actor,
4375                Some(&client),
4376                None,
4377            )
4378            .expect("declared source name accepted");
4379        let anchors = engine.mem_anchors_resolved("specs");
4380        let stored = anchors
4381            .iter()
4382            .find(|(id, _)| id == &ok.id)
4383            .map(|(_, a)| &a.anchor)
4384            .expect("anchor stored for the new entity");
4385        assert_eq!(stored.source.as_deref(), Some("api-docs"));
4386
4387        // An unresolvable binding hash accepts any non-empty name.
4388        engine
4389            .create_entity(
4390                make_args("Orphaned Binding", anchor("whatever", "deadbeef")),
4391                actor,
4392                Some(&client),
4393                None,
4394            )
4395            .expect("unresolvable binding accepts any non-empty name");
4396    }
4397
4398    /// Batch create: N mutually-referencing entities (cycle included —
4399    /// USES is not acyclic in the default schema) land in ONE
4400    /// invocation with every reference resolving to a REAL typed
4401    /// entity, never a stub, and no stub warnings.
4402    #[test]
4403    fn batch_create_intra_batch_references_resolve_real() {
4404        let tmp = TempDir::new().unwrap();
4405        let mem_dir = tmp.path().to_path_buf();
4406        let writer = FilesystemMemWriter::new(mem_dir.clone());
4407        let mut engine = Engine::from_mounts(vec![(
4408            folder_mount("specs", mem_dir),
4409            Box::new(writer) as Box<dyn MemBackend>,
4410        )])
4411        .unwrap();
4412        let (actor, client) = cli_actor();
4413
4414        let with_rel = |title: &str, to: &str| {
4415            let mut args = empty_create_args("specs", title);
4416            args.relations = vec![crate::ops::RelateArg {
4417                target: crate::entity::EntityId::new("specs", to),
4418                rel_type: "USES".to_string(),
4419                description: None,
4420            }];
4421            (args, Some(format!("note for {title}")))
4422        };
4423        // A → B → C → A: a cycle the schema permits.
4424        let result = engine
4425            .batch_create(
4426                vec![
4427                    with_rel("Alpha", "beta"),
4428                    with_rel("Beta", "gamma"),
4429                    with_rel("Gamma", "alpha"),
4430                ],
4431                actor,
4432                Some(&client),
4433                false,
4434            )
4435            .unwrap();
4436        assert!(result.applied, "{result:?}");
4437        assert_eq!(result.succeeded, 3);
4438        assert!(!result.write_id.is_empty(), "one real commit");
4439        assert!(
4440            result.results.iter().all(|r| r.action == "created"),
4441            "{result:?}"
4442        );
4443
4444        // Every reference resolves to a REAL entity of the right type.
4445        for name in ["alpha", "beta", "gamma"] {
4446            let e = engine
4447                .get_entity(&crate::entity::EntityId::new("specs", name))
4448                .unwrap();
4449            assert!(!e.stub, "{name} must be real, not a stub");
4450            assert_eq!(e.entity_type, "spec");
4451            assert_eq!(e.relationships.len(), 1, "{name} carries its edge");
4452        }
4453        // No stub warnings anywhere in the outcome (in-batch targets
4454        // never transit through the stub machinery).
4455        // (BatchResult carries no warnings channel; absence of stubs in
4456        // the store is the observable.)
4457    }
4458
4459    /// Rehearsal contract (agent-trust plan 07): `batch_create` with
4460    /// `dry_run: true` validates the whole batch — intra-batch
4461    /// references included — and reports the would-be receipt with the
4462    /// marker form's empty `write_id`, writing NOTHING. The
4463    /// follow-up real call on the unchanged mem succeeds.
4464    #[test]
4465    fn batch_create_dry_run_reports_receipt_and_writes_nothing() {
4466        let tmp = TempDir::new().unwrap();
4467        let mem_dir = tmp.path().to_path_buf();
4468        let writer = FilesystemMemWriter::new(mem_dir.clone());
4469        let mut engine = Engine::from_mounts(vec![(
4470            folder_mount("specs", mem_dir),
4471            Box::new(writer) as Box<dyn MemBackend>,
4472        )])
4473        .unwrap();
4474        let (actor, client) = cli_actor();
4475
4476        let with_rel = |title: &str, to: &str| {
4477            let mut args = empty_create_args("specs", title);
4478            args.relations = vec![crate::ops::RelateArg {
4479                target: crate::entity::EntityId::new("specs", to),
4480                rel_type: "USES".to_string(),
4481                description: None,
4482            }];
4483            (args, None)
4484        };
4485        let batch = || {
4486            vec![
4487                with_rel("Alpha", "beta"),
4488                with_rel("Beta", "gamma"),
4489                with_rel("Gamma", "alpha"),
4490            ]
4491        };
4492
4493        let rehearsed = engine
4494            .batch_create(batch(), actor, Some(&client), true)
4495            .unwrap();
4496        assert!(rehearsed.applied, "{rehearsed:?}");
4497        assert_eq!(rehearsed.succeeded, 3);
4498        assert!(rehearsed.write_id.is_empty(), "marker form: empty write_id");
4499        assert!(rehearsed.results.iter().all(|r| r.action == "created"));
4500        // The receipt names the prospective ids; nothing landed.
4501        for name in ["alpha", "beta", "gamma"] {
4502            let id = crate::entity::EntityId::new("specs", name);
4503            assert!(
4504                rehearsed.results.iter().any(|r| r.id == id),
4505                "receipt must name {id}: {rehearsed:?}"
4506            );
4507            assert!(
4508                !engine.store().contains(&id),
4509                "rehearsal must create nothing"
4510            );
4511        }
4512        assert_eq!(engine.store().all_entities().count(), 0);
4513
4514        // Identical validation: the real call on the unchanged mem lands.
4515        let real = engine
4516            .batch_create(batch(), actor, Some(&client), false)
4517            .unwrap();
4518        assert!(real.applied, "{real:?}");
4519        assert!(!real.write_id.is_empty(), "the real batch commits");
4520        assert_eq!(real.succeeded, 3);
4521    }
4522
4523    /// Rehearsal refusal parity: a batch with failing entries refuses
4524    /// under `dry_run: true` with the SAME per-entry report-all
4525    /// envelope the real call returns — and both perform nothing, so
4526    /// the paired invocations are directly comparable.
4527    #[test]
4528    fn batch_create_dry_run_refuses_identically_to_real() {
4529        let tmp = TempDir::new().unwrap();
4530        let (mut engine, _seeded) = engine_with_seed(&tmp, "Existing");
4531        let (actor, client) = cli_actor();
4532        let plain = |title: &str| (empty_create_args("specs", title), None);
4533        let batch = || {
4534            vec![
4535                plain("Fine One"),
4536                plain("Existing"),  // duplicate vs pre-batch store
4537                plain("Bad/Title"), // invalid title character
4538            ]
4539        };
4540
4541        let rehearsed = engine
4542            .batch_create(batch(), actor, Some(&client), true)
4543            .unwrap();
4544        let real = engine
4545            .batch_create(batch(), actor, Some(&client), false)
4546            .unwrap();
4547        assert!(!rehearsed.applied && !real.applied);
4548        assert_eq!(rehearsed.failed, real.failed);
4549        assert_eq!(rehearsed.errors_suppressed, real.errors_suppressed);
4550        let envelope = |r: &crate::ops::BatchResult| {
4551            r.results
4552                .iter()
4553                .map(|e| {
4554                    (
4555                        e.id.to_string(),
4556                        e.action.clone(),
4557                        e.error.as_ref().map(|err| {
4558                            (err.code.clone(), err.message.clone(), err.details.clone())
4559                        }),
4560                    )
4561                })
4562                .collect::<Vec<_>>()
4563        };
4564        assert_eq!(envelope(&rehearsed), envelope(&real), "identical refusals");
4565        assert!(
4566            !engine
4567                .store()
4568                .contains(&crate::entity::EntityId::new("specs", "fine-one"))
4569        );
4570    }
4571
4572    /// Atomicity + report-all: a batch with several invalid entries
4573    /// writes NOTHING (no entity, no head movement) and names EVERY
4574    /// failing entry with its typed code — not only the first.
4575    #[test]
4576    fn batch_create_refuses_whole_batch_reporting_every_failure() {
4577        let tmp = TempDir::new().unwrap();
4578        let (mut engine, seeded) = engine_with_seed(&tmp, "Existing");
4579        let (actor, client) = cli_actor();
4580        let head_before = engine
4581            .mem_head_sha("specs")
4582            .ok()
4583            .flatten()
4584            .unwrap_or_default();
4585        let count_before = engine.store().all_entities().count();
4586
4587        let plain = |title: &str| (empty_create_args("specs", title), None);
4588        let result = engine
4589            .batch_create(
4590                vec![
4591                    plain("Fine One"),
4592                    plain("Existing"),   // duplicate vs pre-batch store
4593                    plain("Bad\nTitle"), // control character in title
4594                    plain("Fine Two"),
4595                    plain("Fine Two"), // duplicate WITHIN the batch
4596                ],
4597                actor,
4598                Some(&client),
4599                false,
4600            )
4601            .unwrap();
4602        assert!(!result.applied);
4603        assert_eq!(result.failed, 3, "{result:?}");
4604        assert!(result.write_id.is_empty());
4605        let codes: Vec<(usize, &str)> = result
4606            .results
4607            .iter()
4608            .enumerate()
4609            .filter(|(_, r)| r.action == "error")
4610            .map(|(i, r)| (i, r.error.as_ref().map(|e| e.code.as_str()).unwrap_or("")))
4611            .collect();
4612        assert_eq!(
4613            codes,
4614            vec![
4615                (1, "ENTITY_ALREADY_EXISTS"),
4616                (2, "INVALID_TITLE"),
4617                (4, "ENTITY_ALREADY_EXISTS"),
4618            ],
4619            "every failing entry named with index + typed code: {result:?}"
4620        );
4621        // Valid entries are marked not_applied, and NOTHING was written.
4622        assert_eq!(result.results[0].action, "not_applied");
4623        assert_eq!(result.results[3].action, "not_applied");
4624        let head_after = engine
4625            .mem_head_sha("specs")
4626            .ok()
4627            .flatten()
4628            .unwrap_or_default();
4629        assert_eq!(head_before, head_after, "mem head unmoved");
4630        assert_eq!(
4631            engine.store().all_entities().count(),
4632            count_before,
4633            "no entity created, no skeleton left behind"
4634        );
4635        let _ = seeded;
4636    }
4637
4638    /// Bounded reporting: with more failing entries than the cap, the
4639    /// report carries the cap's worth of detailed envelopes and counts
4640    /// the suppressed remainder — never a silent truncation.
4641    #[test]
4642    fn batch_create_bounds_the_failure_report() {
4643        let tmp = TempDir::new().unwrap();
4644        let mem_dir = tmp.path().to_path_buf();
4645        let writer = FilesystemMemWriter::new(mem_dir.clone());
4646        let mut engine = Engine::from_mounts(vec![(
4647            folder_mount("specs", mem_dir),
4648            Box::new(writer) as Box<dyn MemBackend>,
4649        )])
4650        .unwrap();
4651        let (actor, client) = cli_actor();
4652        let n = Engine::BATCH_ERROR_REPORT_CAP + 10;
4653        let batch: Vec<_> = (0..n)
4654            .map(|i| (empty_create_args("specs", &format!("Bad\nTitle {i}")), None))
4655            .collect();
4656        let result = engine
4657            .batch_create(batch, actor, Some(&client), false)
4658            .unwrap();
4659        assert!(!result.applied);
4660        assert_eq!(result.failed, n);
4661        let detailed = result
4662            .results
4663            .iter()
4664            .filter(|r| r.action == "error" && r.error.is_some())
4665            .count();
4666        let bare = result
4667            .results
4668            .iter()
4669            .filter(|r| r.action == "error" && r.error.is_none())
4670            .count();
4671        assert_eq!(detailed, Engine::BATCH_ERROR_REPORT_CAP);
4672        assert_eq!(bare, 10);
4673        assert_eq!(
4674            result.errors_suppressed, 10,
4675            "suppression is counted, never silent"
4676        );
4677    }
4678
4679    #[test]
4680    fn create_entity_writes_through_folder_backend_and_updates_store() {
4681        let tmp = TempDir::new().unwrap();
4682        let mem_dir = tmp.path().to_path_buf();
4683        let writer = FilesystemMemWriter::new(mem_dir.clone());
4684        let mut engine = Engine::from_mounts(vec![(
4685            folder_mount("specs", mem_dir.clone()),
4686            Box::new(writer) as Box<dyn MemBackend>,
4687        )])
4688        .unwrap();
4689        let (actor, client) = cli_actor();
4690
4691        let outcome = engine
4692            .create_entity(
4693                empty_create_args("specs", "Hello World"),
4694                actor,
4695                Some(&client),
4696                Some("first draft"),
4697            )
4698            .unwrap();
4699
4700        // Outcome reports a real id, real file path, real hash.
4701        assert_eq!(outcome.id.to_string(), "specs--hello-world");
4702        assert_eq!(outcome.file_path, "hello-world.md");
4703        assert!(!outcome.content_hash.is_empty());
4704
4705        // Store has the new entity.
4706        let entity = engine
4707            .get_entity(&crate::EntityId::new("specs", "hello-world"))
4708            .expect("entity must be in the store after create");
4709        assert_eq!(entity.title, "Hello World");
4710        assert_eq!(entity.entity_type, "spec");
4711        assert_eq!(entity.content_hash, outcome.content_hash);
4712
4713        // On-disk markdown exists at the expected path.
4714        let on_disk = std::fs::read_to_string(mem_dir.join("hello-world.md")).unwrap();
4715        assert!(on_disk.contains("# Hello World"));
4716        assert!(on_disk.contains("type: spec"));
4717
4718        // Provenance log has the create record.
4719        let log_path = mem_dir.join(".memstead").join("changes.jsonl");
4720        let log = std::fs::read_to_string(&log_path).unwrap();
4721        assert!(log.contains("\"kind\":\"create\""));
4722        assert!(log.contains("\"entity\":\"specs--hello-world\""));
4723        assert!(log.contains("\"actor\":\"cli\""));
4724        assert!(log.contains("\"note\":\"first draft\""));
4725    }
4726
4727    /// Supplying a
4728    /// value for an auto-managed field (`created_date`) on create no
4729    /// longer silently discards it — the response carries an
4730    /// `IGNORED_READONLY_FIELD` warning, and the stored value is the
4731    /// engine-stamped one, not the supplied `2020-01-01`.
4732    #[test]
4733    fn create_entity_warns_on_supplied_auto_managed_field() {
4734        let tmp = TempDir::new().unwrap();
4735        let mem_dir = tmp.path().to_path_buf();
4736        let writer = FilesystemMemWriter::new(mem_dir.clone());
4737        let mut engine = Engine::from_mounts(vec![(
4738            folder_mount("specs", mem_dir),
4739            Box::new(writer) as Box<dyn MemBackend>,
4740        )])
4741        .unwrap();
4742        let (actor, client) = cli_actor();
4743
4744        let mut args = empty_create_args("specs", "Dated Entity");
4745        args.metadata
4746            .insert("created_date".to_string(), "2020-01-01".to_string());
4747
4748        let outcome = engine
4749            .create_entity(args, actor, Some(&client), None)
4750            .unwrap();
4751
4752        let warned = outcome.warnings.iter().any(|w| {
4753            w.code() == "IGNORED_READONLY_FIELD"
4754                && matches!(w, WarningHint::IgnoredReadonlyField { field, supplied }
4755                    if field == "created_date" && supplied == "2020-01-01")
4756        });
4757        assert!(
4758            warned,
4759            "expected IGNORED_READONLY_FIELD; got {:?}",
4760            outcome.warnings
4761        );
4762
4763        // The engine value was stamped, not the supplied 2020 date.
4764        assert_ne!(outcome.created_date, "2020-01-01");
4765    }
4766
4767    /// Complement: a create with no auto-managed field supplied emits no
4768    /// `IGNORED_READONLY_FIELD` warning.
4769    #[test]
4770    fn create_entity_no_warning_when_auto_managed_field_absent() {
4771        let tmp = TempDir::new().unwrap();
4772        let mem_dir = tmp.path().to_path_buf();
4773        let writer = FilesystemMemWriter::new(mem_dir.clone());
4774        let mut engine = Engine::from_mounts(vec![(
4775            folder_mount("specs", mem_dir),
4776            Box::new(writer) as Box<dyn MemBackend>,
4777        )])
4778        .unwrap();
4779        let (actor, client) = cli_actor();
4780
4781        let outcome = engine
4782            .create_entity(
4783                empty_create_args("specs", "Plain Entity"),
4784                actor,
4785                Some(&client),
4786                None,
4787            )
4788            .unwrap();
4789        assert!(
4790            !outcome
4791                .warnings
4792                .iter()
4793                .any(|w| w.code() == "IGNORED_READONLY_FIELD"),
4794            "no auto-managed field supplied — no warning expected; got {:?}",
4795            outcome.warnings
4796        );
4797    }
4798
4799    #[test]
4800    fn create_entity_returns_write_id_title_mem_on_real_write() {
4801        let tmp = TempDir::new().unwrap();
4802        let mem_dir = tmp.path().to_path_buf();
4803        let writer = FilesystemMemWriter::new(mem_dir.clone());
4804        let mut engine = Engine::from_mounts(vec![(
4805            folder_mount("specs", mem_dir),
4806            Box::new(writer) as Box<dyn MemBackend>,
4807        )])
4808        .unwrap();
4809        let (actor, client) = cli_actor();
4810
4811        let outcome = engine
4812            .create_entity(
4813                empty_create_args("specs", "Rich Shape"),
4814                actor,
4815                Some(&client),
4816                None,
4817            )
4818            .unwrap();
4819
4820        // Folder backend produces a synthetic CommitId — wire-equiv
4821        // to full's commit SHA.
4822        assert!(
4823            !outcome.write_id.is_empty(),
4824            "write_id must be populated on a real create"
4825        );
4826        // title + mem echoed from args (full CreateResult parity).
4827        assert_eq!(outcome.title, "Rich Shape");
4828        assert_eq!(outcome.mem, "specs");
4829        // The create path refuses on missing required sections, so
4830        // `empty_create_args` seeds identity + purpose and the
4831        // success path's warnings vec carries no
4832        // `MissingRequiredSection` entries. The dedicated refusal
4833        // tests below exercise the gate directly.
4834        assert!(
4835            !outcome
4836                .warnings
4837                .iter()
4838                .any(|w| matches!(w, WarningHint::MissingRequiredSection { .. })),
4839            "success path must not carry MissingRequiredSection warnings — those refuse on create now",
4840        );
4841    }
4842
4843    /// Missing
4844    /// required sections refuse on create. The error envelope names
4845    /// every missing key (in schema-declaration order), carries each
4846    /// section's `write_rules`, and surfaces the type-level
4847    /// `type_guidance` map keyed by `entity_type`.
4848    #[test]
4849    fn create_entity_refuses_missing_required_sections_with_typed_envelope() {
4850        let tmp = TempDir::new().unwrap();
4851        let mem_dir = tmp.path().to_path_buf();
4852        let writer = FilesystemMemWriter::new(mem_dir.clone());
4853        let mut engine = Engine::from_mounts(vec![(
4854            folder_mount("specs", mem_dir),
4855            Box::new(writer) as Box<dyn MemBackend>,
4856        )])
4857        .unwrap();
4858        let (actor, client) = cli_actor();
4859
4860        // `spec` requires `identity` + `purpose`. Supply neither.
4861        let args = CreateEntityArgs {
4862            anchors: Vec::new(),
4863            mem: "specs".to_string(),
4864            title: "Half Done".to_string(),
4865            entity_type: "spec".to_string(),
4866            sections: IndexMap::new(),
4867            metadata: IndexMap::new(),
4868            relations: Vec::new(),
4869            dry_run: false,
4870        };
4871        let err = engine
4872            .create_entity(args, actor, Some(&client), None)
4873            .unwrap_err();
4874        match err {
4875            EngineError::MissingRequiredSection {
4876                entity_type,
4877                missing_count,
4878                sections,
4879                type_guidance,
4880                pre_announced_missing_fields: _,
4881            } => {
4882                assert_eq!(entity_type, "spec");
4883                assert_eq!(missing_count, sections.len());
4884                assert!(
4885                    missing_count >= 2,
4886                    "expected ≥2 missing sections, got {missing_count}"
4887                );
4888                let keys: Vec<String> = sections.iter().map(|s| s.key.clone()).collect();
4889                assert!(
4890                    keys.contains(&"identity".to_string()),
4891                    "missing keys: {keys:?}"
4892                );
4893                assert!(
4894                    keys.contains(&"purpose".to_string()),
4895                    "missing keys: {keys:?}"
4896                );
4897                assert!(
4898                    type_guidance.contains_key("spec"),
4899                    "type_guidance must include `spec` entry, got: {type_guidance:?}",
4900                );
4901            }
4902            other => panic!("expected MissingRequiredSection, got {other:?}"),
4903        }
4904
4905        // No entity landed in the store.
4906        let id = crate::EntityId::new("specs", "half-done");
4907        assert!(
4908            engine.store().get(&id).is_none(),
4909            "refused create must not persist any entity"
4910        );
4911    }
4912
4913    /// Cross-gate pre-announcement (backlog-sweep/09): a first write
4914    /// failing BOTH the section gate and the metadata gate learns both
4915    /// demands in the one `MISSING_REQUIRED_SECTION` refusal — the
4916    /// pre-announced set names exactly what `REQUIRED_FIELD_UNSET`
4917    /// would demand next — and a second submission fixing everything
4918    /// announced succeeds. The cold write that took three round-trips
4919    /// takes two.
4920    #[test]
4921    fn create_refusing_sections_pre_announces_metadata_gate_and_fixed_retry_succeeds() {
4922        let tmp = TempDir::new().unwrap();
4923        let mut engine = engine_with_planning_schema(&tmp);
4924        let (actor, client) = cli_actor();
4925
4926        // Round-trip 1: neither gate satisfied — no sections, no
4927        // metadata. `planning.decision` requires sections decision/
4928        // context/consequences and no-default fields decided_on +
4929        // deciders.
4930        let bare = CreateEntityArgs {
4931            anchors: Vec::new(),
4932            mem: "planning".to_string(),
4933            title: "Cold Write".to_string(),
4934            entity_type: "decision".to_string(),
4935            sections: IndexMap::new(),
4936            metadata: IndexMap::new(),
4937            relations: Vec::new(),
4938            dry_run: false,
4939        };
4940        let err = engine
4941            .create_entity(bare, actor, Some(&client), None)
4942            .unwrap_err();
4943        let (sections, announced) = match err {
4944            EngineError::MissingRequiredSection {
4945                sections,
4946                pre_announced_missing_fields,
4947                ..
4948            } => (sections, pre_announced_missing_fields),
4949            other => panic!("expected MissingRequiredSection, got {other:?}"),
4950        };
4951        let section_keys: Vec<&str> = sections.iter().map(|s| s.key.as_str()).collect();
4952        for key in ["decision", "context", "consequences"] {
4953            assert!(section_keys.contains(&key), "sections: {section_keys:?}");
4954        }
4955        // The announcement is exactly the metadata gate's demand set —
4956        // nothing speculative, nothing withheld that is knowable.
4957        let announced_keys: Vec<&str> = announced.iter().map(|m| m.key.as_str()).collect();
4958        assert_eq!(
4959            announced_keys,
4960            vec!["decided_on", "deciders"],
4961            "pre-announced set must equal the metadata gate's demand in declaration order"
4962        );
4963        // The wire payload carries the block under `pre_announced`, in
4964        // REQUIRED_FIELD_UNSET's established `missing[]` element shape.
4965        let rebuilt = EngineError::MissingRequiredSection {
4966            entity_type: "decision".to_string(),
4967            missing_count: sections.len(),
4968            sections,
4969            type_guidance: Default::default(),
4970            pre_announced_missing_fields: announced,
4971        };
4972        let details = rebuilt.details();
4973        let wire_missing = details["pre_announced"]["required_field_unset"]["missing"]
4974            .as_array()
4975            .expect("pre_announced.required_field_unset.missing[] present");
4976        assert_eq!(wire_missing[0]["field"], "decided_on");
4977        assert!(wire_missing[0].get("description").is_some());
4978        assert!(wire_missing[0].get("enum_values").is_some());
4979
4980        // Round-trip 2: fix everything the one refusal announced —
4981        // and nothing else. Succeeds: no third round-trip exists.
4982        let mut metadata = IndexMap::new();
4983        metadata.insert("decided_on".to_string(), "2026-08-19".to_string());
4984        metadata.insert("deciders".to_string(), "alice".to_string());
4985        engine
4986            .create_entity(
4987                CreateEntityArgs {
4988                    anchors: Vec::new(),
4989                    mem: "planning".to_string(),
4990                    title: "Cold Write".to_string(),
4991                    entity_type: "decision".to_string(),
4992                    sections: IndexMap::from_iter([
4993                        ("decision".to_string(), "x".to_string()),
4994                        ("context".to_string(), "y".to_string()),
4995                        ("consequences".to_string(), "z".to_string()),
4996                    ]),
4997                    metadata,
4998                    relations: Vec::new(),
4999                    dry_run: false,
5000                },
5001                actor,
5002                Some(&client),
5003                None,
5004            )
5005            .expect("fixing everything announced must succeed in the second round-trip");
5006    }
5007
5008    /// Complement (backlog-sweep/09 criterion 3): a body failing ONLY
5009    /// the section gate — metadata complete — refuses with an empty
5010    /// pre-announcement, and its `details` payload carries no
5011    /// `pre_announced` key at all: byte-compatible with the
5012    /// pre-announcement-free shape.
5013    #[test]
5014    fn section_only_refusal_omits_the_pre_announced_block() {
5015        let tmp = TempDir::new().unwrap();
5016        let mut engine = engine_with_planning_schema(&tmp);
5017        let (actor, client) = cli_actor();
5018
5019        let mut metadata = IndexMap::new();
5020        metadata.insert("decided_on".to_string(), "2026-08-19".to_string());
5021        metadata.insert("deciders".to_string(), "alice".to_string());
5022        let err = engine
5023            .create_entity(
5024                CreateEntityArgs {
5025                    anchors: Vec::new(),
5026                    mem: "planning".to_string(),
5027                    title: "Sections Only".to_string(),
5028                    entity_type: "decision".to_string(),
5029                    sections: IndexMap::new(),
5030                    metadata,
5031                    relations: Vec::new(),
5032                    dry_run: false,
5033                },
5034                actor,
5035                Some(&client),
5036                None,
5037            )
5038            .unwrap_err();
5039        match &err {
5040            EngineError::MissingRequiredSection {
5041                pre_announced_missing_fields,
5042                ..
5043            } => assert!(
5044                pre_announced_missing_fields.is_empty(),
5045                "metadata gate is satisfied — nothing to pre-announce"
5046            ),
5047            other => panic!("expected MissingRequiredSection, got {other:?}"),
5048        }
5049        assert!(
5050            err.details().get("pre_announced").is_none(),
5051            "single-gate refusal must stay byte-compatible: no pre_announced key"
5052        );
5053    }
5054
5055    /// `dry_run: true` returns the same refusal envelope
5056    /// the real call would. The preview surface doesn't admit content
5057    /// the real call would refuse.
5058    #[test]
5059    fn create_entity_dry_run_returns_same_refusal_envelope_as_real_call() {
5060        let tmp = TempDir::new().unwrap();
5061        let mem_dir = tmp.path().to_path_buf();
5062        let writer = FilesystemMemWriter::new(mem_dir.clone());
5063        let mut engine = Engine::from_mounts(vec![(
5064            folder_mount("specs", mem_dir),
5065            Box::new(writer) as Box<dyn MemBackend>,
5066        )])
5067        .unwrap();
5068        let (actor, client) = cli_actor();
5069
5070        let args = CreateEntityArgs {
5071            anchors: Vec::new(),
5072            mem: "specs".to_string(),
5073            title: "Half Done Dry".to_string(),
5074            entity_type: "spec".to_string(),
5075            sections: IndexMap::new(),
5076            metadata: IndexMap::new(),
5077            relations: Vec::new(),
5078            dry_run: true,
5079        };
5080        let err = engine
5081            .create_entity(args, actor, Some(&client), None)
5082            .unwrap_err();
5083        assert!(
5084            matches!(err, EngineError::MissingRequiredSection { .. }),
5085            "dry_run must surface the same refusal envelope, got {err:?}"
5086        );
5087    }
5088
5089    /// A follow-up call with the missing sections filled
5090    /// in succeeds. The refusal carries enough recovery information
5091    /// that the agent's next attempt resolves in one round-trip.
5092    #[test]
5093    fn create_entity_succeeds_after_filling_in_required_sections() {
5094        let tmp = TempDir::new().unwrap();
5095        let mem_dir = tmp.path().to_path_buf();
5096        let writer = FilesystemMemWriter::new(mem_dir.clone());
5097        let mut engine = Engine::from_mounts(vec![(
5098            folder_mount("specs", mem_dir),
5099            Box::new(writer) as Box<dyn MemBackend>,
5100        )])
5101        .unwrap();
5102        let (actor, client) = cli_actor();
5103
5104        let mut sections = IndexMap::new();
5105        sections.insert("identity".to_string(), "the identity body".to_string());
5106        sections.insert("purpose".to_string(), "the purpose body".to_string());
5107        let args = CreateEntityArgs {
5108            anchors: Vec::new(),
5109            mem: "specs".to_string(),
5110            title: "Complete".to_string(),
5111            entity_type: "spec".to_string(),
5112            sections,
5113            metadata: IndexMap::new(),
5114            relations: Vec::new(),
5115            dry_run: false,
5116        };
5117        let outcome = engine
5118            .create_entity(args, actor, Some(&client), None)
5119            .expect("complete create succeeds");
5120        assert_eq!(outcome.title, "Complete");
5121    }
5122
5123    #[test]
5124    fn create_entity_promotes_existing_stub_and_preserves_incoming_edges() {
5125        let tmp = TempDir::new().unwrap();
5126        let (mut engine, source) = engine_with_seed(&tmp, "Source");
5127        let (actor, client) = cli_actor();
5128
5129        // Step 1: relate source → "ghost-target" — creates a stub
5130        // entity at `specs--ghost-target` with one incoming edge.
5131        let stub_target = crate::EntityId::new("specs", "ghost-target");
5132        engine
5133            .relate_entity(
5134                RelateEntityArgs {
5135                    source: source.id.clone(),
5136                    expected_hash: Some(source.content_hash.clone()),
5137                    rel_type: "USES".to_string(),
5138                    target: stub_target.clone(),
5139                    remove: false,
5140                    description: None,
5141                    dry_run: false,
5142                },
5143                actor,
5144                Some(&client),
5145                None,
5146            )
5147            .unwrap();
5148        let stub = engine
5149            .store()
5150            .get(&stub_target)
5151            .expect("stub must be in store");
5152        assert!(stub.stub);
5153        assert_eq!(engine.store().incoming(&stub_target).len(), 1);
5154
5155        // Step 2: create a real entity with the same title — should
5156        // promote the stub and preserve the incoming edge.
5157        let outcome = engine
5158            .create_entity(
5159                empty_create_args("specs", "Ghost Target"),
5160                actor,
5161                Some(&client),
5162                None,
5163            )
5164            .unwrap();
5165
5166        // No error: stub adoption proceeded.
5167        assert_eq!(outcome.id, stub_target);
5168        // Entity is now a real entity, not a stub.
5169        let real = engine
5170            .store()
5171            .get(&stub_target)
5172            .expect("entity must still be in store");
5173        assert!(!real.stub);
5174        // Incoming edge survived the upsert.
5175        assert_eq!(engine.store().incoming(&stub_target).len(), 1);
5176        // Outcome surfaces stub adoption.
5177        assert_eq!(outcome.incoming_count, Some(1));
5178        assert_eq!(outcome.incoming.len(), 1);
5179        assert_eq!(outcome.incoming[0].from, source.id);
5180        assert_eq!(outcome.incoming[0].rel_type, "USES");
5181    }
5182
5183    #[test]
5184    fn create_entity_reports_no_incoming_on_greenfield_create() {
5185        let tmp = TempDir::new().unwrap();
5186        let mem_dir = tmp.path().to_path_buf();
5187        let writer = FilesystemMemWriter::new(mem_dir.clone());
5188        let mut engine = Engine::from_mounts(vec![(
5189            folder_mount("specs", mem_dir),
5190            Box::new(writer) as Box<dyn MemBackend>,
5191        )])
5192        .unwrap();
5193        let (actor, client) = cli_actor();
5194
5195        let outcome = engine
5196            .create_entity(
5197                empty_create_args("specs", "Greenfield"),
5198                actor,
5199                Some(&client),
5200                None,
5201            )
5202            .unwrap();
5203        // No pre-existing stub → incoming_count is None, incoming vec
5204        // is empty. Full's wire shape skip-serialises both.
5205        assert!(outcome.incoming_count.is_none());
5206        assert!(outcome.incoming.is_empty());
5207    }
5208
5209    #[test]
5210    fn create_entity_populates_created_date_from_schema_auto_stamp() {
5211        let tmp = TempDir::new().unwrap();
5212        let mem_dir = tmp.path().to_path_buf();
5213        let writer = FilesystemMemWriter::new(mem_dir.clone());
5214        let mut engine = Engine::from_mounts(vec![(
5215            folder_mount("specs", mem_dir),
5216            Box::new(writer) as Box<dyn MemBackend>,
5217        )])
5218        .unwrap();
5219        let (actor, client) = cli_actor();
5220
5221        let outcome = engine
5222            .create_entity(
5223                empty_create_args("specs", "Has Date"),
5224                actor,
5225                Some(&client),
5226                None,
5227            )
5228            .unwrap();
5229        // The default `spec` schema declares `created_date` with
5230        // an init_timestamp default. The parsed entity carries the
5231        // auto-stamped value; the outcome surfaces it for callers
5232        // who need it without a follow-up read.
5233        assert!(
5234            !outcome.created_date.is_empty(),
5235            "created_date must be populated when the schema auto-stamps it"
5236        );
5237    }
5238
5239    #[test]
5240    fn create_overrides_user_supplied_timestamps_update_rejects_them() {
5241        // Schema-declared `init_timestamp` (set on create) and
5242        // `auto_timestamp` (re-stamped on every update) fields are
5243        // engine-managed. On create the engine still silently
5244        // overrides any caller-supplied value (the entity must be
5245        // stampable in one shot from the user's perspective). On
5246        // update the writable-metadata validator rejects the write
5247        // up-front with `READ_ONLY_FIELD` — the agent gets a
5248        // structured rejection instead of a "set" response whose
5249        // value the auto-stamp pass silently discards (per the F13
5250        // / F14 contract).
5251        let tmp = TempDir::new().unwrap();
5252        let mem_dir = tmp.path().to_path_buf();
5253        let writer = FilesystemMemWriter::new(mem_dir.clone());
5254        let mut engine = Engine::from_mounts(vec![(
5255            folder_mount("specs", mem_dir),
5256            Box::new(writer) as Box<dyn MemBackend>,
5257        )])
5258        .unwrap();
5259        // Pin the mutation clock. The auto-stamp is second-resolution,
5260        // and the assertions below compare it against a separately
5261        // computed "now" — so an unpinned run fails whenever a second
5262        // ticks between the create and the comparison. That is a real
5263        // flake, not a theoretical one: it fired on a suite run that
5264        // straddled midnight. The engine's injectable clock exists for
5265        // exactly this, and pinning it here also makes the expected
5266        // string a constant rather than a second read of the wall clock.
5267        const FROZEN_SECS: u64 = 1_754_000_000;
5268        let frozen = std::time::UNIX_EPOCH + std::time::Duration::from_secs(FROZEN_SECS);
5269        engine.set_mutation_clock(std::sync::Arc::new(move || frozen));
5270        let (actor, client) = cli_actor();
5271
5272        // Caller supplies a past value for the init_timestamp field
5273        // and the auto_timestamp field. The engine ignores both on
5274        // create.
5275        let mut args = empty_create_args("specs", "Stamped Today");
5276        args.metadata
5277            .insert("created_date".to_string(), "2020-01-01".to_string());
5278        args.metadata
5279            .insert("last_modified".to_string(), "2020-01-01".to_string());
5280
5281        let outcome = engine
5282            .create_entity(args, actor, Some(&client), None)
5283            .unwrap();
5284
5285        // Both timestamps should reflect the engine's own clock, not
5286        // the caller's `2020-01-01`.
5287        let today = crate::engine::mutation::iso_from_system_time(frozen);
5288        assert_eq!(outcome.created_date, today);
5289        let entity = engine
5290            .get_entity(&outcome.id)
5291            .expect("entity must be in store after create");
5292        assert_eq!(
5293            entity
5294                .metadata
5295                .get("created_date")
5296                .and_then(|v| v.as_str())
5297                .unwrap_or_default(),
5298            today,
5299            "init_timestamp field must be engine-determined on create, not user-supplied"
5300        );
5301        assert_eq!(
5302            entity
5303                .metadata
5304                .get("last_modified")
5305                .and_then(|v| v.as_str())
5306                .unwrap_or_default(),
5307            today,
5308            "auto_timestamp field must be engine-determined on create, not user-supplied"
5309        );
5310
5311        // F13/F14: update rejects a user-supplied value for either
5312        // init_timestamp or auto_timestamp metadata fields with
5313        // `READ_ONLY_FIELD`. Test both fields in turn.
5314        let attempt_update = |key: &str, value: &str| {
5315            let mut metadata = IndexMap::new();
5316            metadata.insert(key.to_string(), value.to_string());
5317            crate::engine::UpdateEntityArgs {
5318                anchors: Vec::new(),
5319                id: outcome.id.clone(),
5320                metadata,
5321                metadata_unset: Vec::new(),
5322                sections: IndexMap::new(),
5323                append_sections: IndexMap::new(),
5324                patch_sections: IndexMap::new(),
5325                expected_hash: Some(outcome.content_hash.clone()),
5326                dry_run: false,
5327                declare_relations: Vec::new(),
5328                relations_unset: Vec::new(),
5329                anchors_unset: Vec::new(),
5330            }
5331        };
5332        for key in ["created_date", "last_modified"] {
5333            let err = engine
5334                .update_entity(
5335                    attempt_update(key, "2019-12-31"),
5336                    actor,
5337                    Some(&client),
5338                    None,
5339                )
5340                .expect_err("schema-managed timestamp must be rejected on update");
5341            assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
5342        }
5343        // Stored value is unchanged after a rejected attempt.
5344        let entity = engine
5345            .get_entity(&outcome.id)
5346            .expect("entity must remain in store after rejected update");
5347        assert_eq!(
5348            entity
5349                .metadata
5350                .get("last_modified")
5351                .and_then(|v| v.as_str())
5352                .unwrap_or_default(),
5353            today,
5354            "rejected update must not mutate the auto_timestamp field"
5355        );
5356    }
5357
5358    #[test]
5359    fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
5360        let tmp = TempDir::new().unwrap();
5361        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
5362        let (actor, client) = cli_actor();
5363        let absent = crate::EntityId::new("specs", "future-target");
5364        assert!(!engine.store().contains(&absent));
5365
5366        let mut args = empty_create_args("specs", "Source With Relations");
5367        args.relations = vec![
5368            crate::ops::RelateArg {
5369                target: existing.id.clone(),
5370                rel_type: "USES".to_string(),
5371                description: None,
5372            },
5373            crate::ops::RelateArg {
5374                target: absent.clone(),
5375                rel_type: "USES".to_string(),
5376                description: None,
5377            },
5378        ];
5379
5380        let outcome = engine
5381            .create_entity(args, actor, Some(&client), None)
5382            .unwrap();
5383
5384        // New entity in store with both edges materialised.
5385        let source = engine
5386            .store()
5387            .get(&outcome.id)
5388            .expect("source must be in store");
5389        assert_eq!(source.relationships.len(), 2);
5390        assert!(
5391            source
5392                .relationships
5393                .iter()
5394                .any(|r| r.target == existing.id && r.rel_type == "USES")
5395        );
5396        assert!(
5397            source
5398                .relationships
5399                .iter()
5400                .any(|r| r.target == absent && r.rel_type == "USES")
5401        );
5402
5403        // Absent target was auto-stubbed (mirrors the relate path's
5404        // ensure_target).
5405        let stub = engine
5406            .store()
5407            .get(&absent)
5408            .expect("absent relation target must be auto-stubbed");
5409        assert!(stub.stub);
5410        // Existing target unchanged.
5411        let existing_after = engine.store().get(&existing.id).unwrap();
5412        assert!(!existing_after.stub);
5413    }
5414
5415    /// Build a folder-mount engine pinned to the `planning` schema, so
5416    /// tests can exercise `decision` — a type with `decided_on` (Date,
5417    /// required, no default / no init_timestamp) — without inventing a
5418    /// synthetic schema.
5419    fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
5420        use crate::workspace::Mount;
5421        use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
5422        let mem_dir = tmp.path().to_path_buf();
5423        let writer = FilesystemMemWriter::new(mem_dir.clone());
5424        let mount = Mount {
5425            mem: "planning".to_string(),
5426            schema: Some(memstead_schema::SchemaRef::new(
5427                "planning",
5428                semver::Version::new(0, 1, 0),
5429            )),
5430            storage: MountStorage::Folder { path: mem_dir },
5431            capability: MountCapability::Write,
5432            lifecycle: MountLifecycle::Eager,
5433            cross_linkable: true,
5434            migration_target: None,
5435        };
5436        Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
5437    }
5438
5439    /// A
5440    /// required metadata field the schema does not auto-fill
5441    /// (`default_value` / `init_timestamp` / `auto_timestamp` all
5442    /// absent) now triggers `REQUIRED_FIELD_UNSET` refusal on the
5443    /// create path. Pre-fix this surfaced as a `MissingRequiredField`
5444    /// warning and the generator silently wrote placeholder values
5445    /// that the install-time strict validator could later refuse,
5446    /// breaking the export-then-install round-trip.
5447    #[test]
5448    fn create_entity_refuses_unsupplied_no_default_required_field() {
5449        // The `planning.decision` schema declares `decided_on`
5450        // (Date, required, no default_value, no init_timestamp) and
5451        // `deciders` (String csv_array, required, no default).
5452        let tmp = TempDir::new().unwrap();
5453        let mut engine = engine_with_planning_schema(&tmp);
5454        let (actor, client) = cli_actor();
5455
5456        let mut args = CreateEntityArgs {
5457            anchors: Vec::new(),
5458            mem: "planning".to_string(),
5459            title: "Skip Postgres".to_string(),
5460            entity_type: "decision".to_string(),
5461            sections: IndexMap::from_iter([
5462                ("decision".to_string(), "Use SQLite locally.".to_string()),
5463                ("context".to_string(), "Single-user dev.".to_string()),
5464                ("consequences".to_string(), "Lose multi-writer.".to_string()),
5465            ]),
5466            metadata: IndexMap::new(),
5467            relations: Vec::new(),
5468            dry_run: false,
5469        };
5470
5471        // Real-write path: refuse on the first missing field
5472        // (declaration order).
5473        let err = engine
5474            .create_entity(args.clone(), actor, Some(&client), None)
5475            .unwrap_err();
5476        match err {
5477            EngineError::RequiredFieldUnset {
5478                field, entity_type, ..
5479            } => {
5480                assert!(
5481                    field == "decided_on" || field == "deciders",
5482                    "expected first missing field, got {field:?}"
5483                );
5484                assert_eq!(entity_type, "decision");
5485            }
5486            other => panic!("expected RequiredFieldUnset, got {other:?}"),
5487        }
5488
5489        // Dry-run path on the same shape (different title to avoid the
5490        // already-exists check). Must surface the same refusal — the
5491        // create dry-run is the agent's preview surface.
5492        args.title = "Different Title".to_string();
5493        args.dry_run = true;
5494        let dry_err = engine
5495            .create_entity(args, actor, Some(&client), None)
5496            .unwrap_err();
5497        assert!(
5498            matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
5499            "dry_run must surface the same refusal envelope, got {dry_err:?}"
5500        );
5501    }
5502
5503    /// A follow-up call with all required-no-default
5504    /// fields supplied succeeds. The refusal recovery is a single
5505    /// round-trip.
5506    #[test]
5507    fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
5508        let tmp = TempDir::new().unwrap();
5509        let mut engine = engine_with_planning_schema(&tmp);
5510        let (actor, client) = cli_actor();
5511
5512        let mut metadata = IndexMap::new();
5513        metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
5514        metadata.insert("deciders".to_string(), "alice, bob".to_string());
5515
5516        let outcome = engine
5517            .create_entity(
5518                CreateEntityArgs {
5519                    anchors: Vec::new(),
5520                    mem: "planning".to_string(),
5521                    title: "Complete Decision".to_string(),
5522                    entity_type: "decision".to_string(),
5523                    sections: IndexMap::from_iter([
5524                        ("decision".to_string(), "x".to_string()),
5525                        ("context".to_string(), "y".to_string()),
5526                        ("consequences".to_string(), "z".to_string()),
5527                    ]),
5528                    metadata,
5529                    relations: Vec::new(),
5530                    dry_run: false,
5531                },
5532                actor,
5533                Some(&client),
5534                None,
5535            )
5536            .expect("complete decision create succeeds");
5537        // No MissingRequiredField warnings on the success path —
5538        // refusal swallows the case before any warning could fire.
5539        let missing_field_warnings: Vec<&WarningHint> = outcome
5540            .warnings
5541            .iter()
5542            .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
5543            .collect();
5544        assert!(
5545            missing_field_warnings.is_empty(),
5546            "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
5547        );
5548    }
5549
5550    /// Item 02: `memstead_create.relations[]` runs the same target-id
5551    /// grammar gate as `memstead_relate`. Pre-fix the create path
5552    /// admitted malformed ids (auto-stub at `bad@chars$here`) even
5553    /// though `memstead_relate` rejected them.
5554    #[test]
5555    fn create_entity_rejects_inline_relation_with_malformed_target_id() {
5556        let tmp = TempDir::new().unwrap();
5557        let mem_dir = tmp.path().to_path_buf();
5558        let writer = FilesystemMemWriter::new(mem_dir.clone());
5559        let mut engine = Engine::from_mounts(vec![(
5560            folder_mount("specs", mem_dir),
5561            Box::new(writer) as Box<dyn MemBackend>,
5562        )])
5563        .unwrap();
5564        let (actor, client) = cli_actor();
5565
5566        let mut args = empty_create_args("specs", "Source");
5567        args.relations = vec![crate::ops::RelateArg {
5568            target: crate::EntityId("specs--bad target with spaces!!".to_string()),
5569            rel_type: "USES".to_string(),
5570            description: None,
5571        }];
5572        let err = engine
5573            .create_entity(args, actor, Some(&client), None)
5574            .unwrap_err();
5575        assert!(
5576            matches!(err, EngineError::InvalidEntityId { .. }),
5577            "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
5578        );
5579    }
5580
5581    /// Item 02: `memstead_create.relations[]` runs the same schema-shape
5582    /// gate as `memstead_relate`. The relate-path shape gate is already
5583    /// pinned by `memstead-mcp::tool_surface::INVALID_REL_SHAPE` and the
5584    /// schema-loader tests; the cross-path lock here exercises the
5585    /// `software` schema's `VIOLATES` rel-type, which declares
5586    /// `source_types: [incident]` — an inline create from a `spec`
5587    /// must trip the shape gate even though the rel-type itself is
5588    /// valid vocabulary.
5589    #[test]
5590    fn create_entity_rejects_inline_relation_with_shape_violation() {
5591        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
5592        let tmp = TempDir::new().unwrap();
5593        let mem_dir = tmp.path().to_path_buf();
5594        let writer = FilesystemMemWriter::new(mem_dir.clone());
5595        let mount = Mount {
5596            mem: "code".to_string(),
5597            schema: Some(memstead_schema::SchemaRef::new(
5598                "software",
5599                semver::Version::new(0, 1, 0),
5600            )),
5601            storage: MountStorage::Folder { path: mem_dir },
5602            capability: MountCapability::Write,
5603            lifecycle: MountLifecycle::Eager,
5604            cross_linkable: true,
5605            migration_target: None,
5606        };
5607        let mut engine =
5608            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
5609        let (actor, client) = cli_actor();
5610
5611        // Seed an existing target so the shape gate evaluates the
5612        // real target type (not `None`, which the gate admits as the
5613        // stub-bound case). The `requirement` type requires `statement` +
5614        // `rationale` sections plus `verified_on` + `source` metadata
5615        // (the schema lists these without `default_value` or
5616        // `optional: true`, so the strict-on-create gate refuses
5617        // unless supplied).
5618        let target = engine
5619            .create_entity(
5620                CreateEntityArgs {
5621                    anchors: Vec::new(),
5622                    mem: "code".to_string(),
5623                    title: "Target Requirement".to_string(),
5624                    entity_type: "requirement".to_string(),
5625                    sections: IndexMap::from_iter([
5626                        ("statement".to_string(), "MUST hold.".to_string()),
5627                        ("rationale".to_string(), "Because tests.".to_string()),
5628                    ]),
5629                    metadata: IndexMap::from_iter([
5630                        ("verified_on".to_string(), "2026-05-19".to_string()),
5631                        ("source".to_string(), "test fixture".to_string()),
5632                    ]),
5633                    relations: Vec::new(),
5634                    dry_run: false,
5635                },
5636                actor,
5637                Some(&client),
5638                None,
5639            )
5640            .unwrap();
5641
5642        // `VIOLATES` declares `source_types: [incident]`. A `spec`
5643        // create with `VIOLATES` violates the shape. The `spec` type
5644        // in the software schema requires `identity` + `purpose`;
5645        // supply both so the shape gate (not the missing-sections
5646        // gate) is what fires.
5647        let args = CreateEntityArgs {
5648            anchors: Vec::new(),
5649            mem: "code".to_string(),
5650            title: "Misshape Source".to_string(),
5651            entity_type: "spec".to_string(),
5652            sections: IndexMap::from_iter([
5653                ("identity".to_string(), "this spec".to_string()),
5654                (
5655                    "purpose".to_string(),
5656                    "exercising the shape gate".to_string(),
5657                ),
5658            ]),
5659            metadata: IndexMap::new(),
5660            relations: vec![crate::ops::RelateArg {
5661                target: target.id.clone(),
5662                rel_type: "VIOLATES".to_string(),
5663                description: None,
5664            }],
5665            dry_run: false,
5666        };
5667        let err = engine
5668            .create_entity(args, actor, Some(&client), None)
5669            .unwrap_err();
5670        assert!(
5671            matches!(err, EngineError::Validation(_)),
5672            "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
5673        );
5674    }
5675
5676    #[test]
5677    fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
5678        // Wire-level contract: rel_type on inline relations is
5679        // case-insensitive. The engine stores the relationship as
5680        // UPPER_SNAKE_CASE regardless of input case.
5681        let tmp = TempDir::new().unwrap();
5682        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
5683        let (actor, client) = cli_actor();
5684
5685        let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
5686        args.relations = vec![crate::ops::RelateArg {
5687            target: existing.id.clone(),
5688            rel_type: "uses".to_string(),
5689            description: None,
5690        }];
5691
5692        let outcome = engine
5693            .create_entity(args, actor, Some(&client), None)
5694            .unwrap();
5695
5696        let source = engine
5697            .store()
5698            .get(&outcome.id)
5699            .expect("source must be in store");
5700        assert_eq!(source.relationships.len(), 1);
5701        assert_eq!(
5702            source.relationships[0].rel_type, "USES",
5703            "inline relation rel_type must be stored UPPER_SNAKE_CASE",
5704        );
5705    }
5706
5707    #[test]
5708    fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
5709        let tmp = TempDir::new().unwrap();
5710        let mem_dir = tmp.path().to_path_buf();
5711        let writer = FilesystemMemWriter::new(mem_dir.clone());
5712        let mut engine = Engine::from_mounts(vec![(
5713            folder_mount("specs", mem_dir.clone()),
5714            Box::new(writer) as Box<dyn MemBackend>,
5715        )])
5716        .unwrap();
5717        let (actor, client) = cli_actor();
5718
5719        let mut args = empty_create_args("specs", "Preview Only");
5720        args.dry_run = true;
5721
5722        let outcome = engine
5723            .create_entity(args, actor, Some(&client), None)
5724            .unwrap();
5725
5726        // Wire shape: content_hash = prospective hash; write_id empty.
5727        assert_eq!(outcome.id.to_string(), "specs--preview-only");
5728        assert!(
5729            !outcome.content_hash.is_empty(),
5730            "prospective hash populated"
5731        );
5732        assert!(outcome.write_id.is_empty(), "no commit on dry_run");
5733        // No store entry — the engine didn't push.
5734        assert!(
5735            engine.store().get(&outcome.id).is_none(),
5736            "dry_run must not mutate the store",
5737        );
5738        // No file on disk.
5739        assert!(
5740            !mem_dir.join("preview-only.md").exists(),
5741            "dry_run must not touch disk",
5742        );
5743        // No provenance line.
5744        let log = mem_dir.join(".memstead").join("changes.jsonl");
5745        assert!(
5746            !log.exists()
5747                || !std::fs::read_to_string(&log)
5748                    .unwrap()
5749                    .contains("preview-only"),
5750            "dry_run must not append provenance",
5751        );
5752    }
5753
5754    #[test]
5755    fn create_entity_rejects_read_only_mount_before_backend() {
5756        let tmp = TempDir::new().unwrap();
5757        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
5758        let mut engine = Engine::from_mounts(vec![(
5759            archive_mount("external", archive_path.clone()),
5760            Box::new(ArchiveBackend::new(archive_path)),
5761        )])
5762        .unwrap();
5763        let (actor, client) = cli_actor();
5764
5765        let err = engine
5766            .create_entity(
5767                empty_create_args("external", "Should Fail"),
5768                actor,
5769                Some(&client),
5770                None,
5771            )
5772            .unwrap_err();
5773        match err {
5774            EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
5775            other => panic!("expected ReadOnlyMount, got {other:?}"),
5776        }
5777        // Capability gating runs before the backend → the typed
5778        // BackendError::Sealed variant never surfaces here. That's
5779        // the intended ordering.
5780    }
5781
5782    #[test]
5783    fn create_entity_rejects_unknown_mem() {
5784        let tmp = TempDir::new().unwrap();
5785        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
5786        let mut engine = Engine::from_mounts(vec![(
5787            folder_mount("specs", tmp.path().to_path_buf()),
5788            Box::new(writer) as Box<dyn MemBackend>,
5789        )])
5790        .unwrap();
5791        let (actor, client) = cli_actor();
5792
5793        let err = engine
5794            .create_entity(
5795                empty_create_args("does-not-exist", "Anything"),
5796                actor,
5797                Some(&client),
5798                None,
5799            )
5800            .unwrap_err();
5801        assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
5802    }
5803
5804    #[test]
5805    fn create_entity_rejects_unknown_type_against_pinned_schema() {
5806        let tmp = TempDir::new().unwrap();
5807        let mem_dir = tmp.path().to_path_buf();
5808        let writer = FilesystemMemWriter::new(mem_dir.clone());
5809        let mut engine = Engine::from_mounts(vec![(
5810            folder_mount("specs", mem_dir),
5811            Box::new(writer) as Box<dyn MemBackend>,
5812        )])
5813        .unwrap();
5814        let (actor, client) = cli_actor();
5815
5816        let mut args = empty_create_args("specs", "Anything");
5817        args.entity_type = "definitely-not-a-real-type".to_string();
5818        let err = engine
5819            .create_entity(args, actor, Some(&client), None)
5820            .unwrap_err();
5821        match err {
5822            EngineError::UnknownType { name, declared, .. } => {
5823                assert_eq!(name, "definitely-not-a-real-type");
5824                assert!(!declared.is_empty(), "declared types must be listed");
5825            }
5826            other => panic!("expected UnknownType, got {other:?}"),
5827        }
5828    }
5829
5830    #[test]
5831    fn create_entity_rejects_duplicate_id() {
5832        let tmp = TempDir::new().unwrap();
5833        let mem_dir = tmp.path().to_path_buf();
5834        let writer = FilesystemMemWriter::new(mem_dir.clone());
5835        let mut engine = Engine::from_mounts(vec![(
5836            folder_mount("specs", mem_dir),
5837            Box::new(writer) as Box<dyn MemBackend>,
5838        )])
5839        .unwrap();
5840        let (actor, client) = cli_actor();
5841
5842        engine
5843            .create_entity(
5844                empty_create_args("specs", "Same Slug"),
5845                actor,
5846                Some(&client),
5847                None,
5848            )
5849            .unwrap();
5850        let err = engine
5851            .create_entity(
5852                empty_create_args("specs", "Same Slug"),
5853                actor,
5854                Some(&client),
5855                None,
5856            )
5857            .unwrap_err();
5858        match err {
5859            EngineError::AlreadyExists {
5860                id,
5861                existing_title,
5862                existing_is_stub,
5863            } => {
5864                assert_eq!(id, "specs--same-slug");
5865                // The refusal names the occupying title so the caller
5866                // sees which existing title derived the colliding slug.
5867                assert!(!existing_title.is_empty());
5868                assert!(!existing_is_stub);
5869            }
5870            other => panic!("expected AlreadyExists, got {other:?}"),
5871        }
5872    }
5873
5874    #[test]
5875    fn create_entity_rejects_invalid_title() {
5876        let tmp = TempDir::new().unwrap();
5877        let mem_dir = tmp.path().to_path_buf();
5878        let writer = FilesystemMemWriter::new(mem_dir.clone());
5879        let mut engine = Engine::from_mounts(vec![(
5880            folder_mount("specs", mem_dir),
5881            Box::new(writer) as Box<dyn MemBackend>,
5882        )])
5883        .unwrap();
5884        let (actor, client) = cli_actor();
5885
5886        // F4: empty/whitespace-only titles now refuse with
5887        // `INVALID_TITLE` / reason `empty`. The earlier hash-fallback
5888        // behaviour applies only to the loader path (pre-gate
5889        // entities); the strict mutation gate rejects so the
5890        // structured-content envelope can carry actionable details.
5891        let err = engine
5892            .create_entity(empty_create_args("specs", "  "), actor, Some(&client), None)
5893            .unwrap_err();
5894        match err {
5895            EngineError::InvalidTitle(slug_err) => {
5896                assert_eq!(slug_err.reason(), "empty", "expected empty reason");
5897            }
5898            other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
5899        }
5900
5901        // Widened grammar: char-drop titles land, with the divergence
5902        // reported as the typed warning naming the dropped characters
5903        // and the derived slug.
5904        let outcome = engine
5905            .create_entity(
5906                empty_create_args("specs", "Hello, World!"),
5907                actor,
5908                Some(&client),
5909                None,
5910            )
5911            .expect("char-drop title lands under the widened grammar");
5912        assert_eq!(outcome.id.as_ref(), "specs--hello-world");
5913        let dropped = outcome
5914            .warnings
5915            .iter()
5916            .find_map(|w| match w {
5917                WarningHint::TitleCharsDroppedFromSlug {
5918                    dropped_chars,
5919                    slug,
5920                    ..
5921                } => Some((dropped_chars.clone(), slug.clone())),
5922                _ => None,
5923            })
5924            .expect("divergence warning rides the outcome");
5925        assert!(dropped.0.contains(&',') && dropped.0.contains(&'!'));
5926        assert_eq!(dropped.1, "hello-world");
5927
5928        // Path-traversal-shaped titles are display text too — the
5929        // dropped `/` and `.` never reach the slug, so the id stays
5930        // sanitised (no traversal), and the divergence is reported.
5931        let outcome = engine
5932            .create_entity(
5933                empty_create_args("specs", "../etc/passwd"),
5934                actor,
5935                Some(&client),
5936                None,
5937            )
5938            .expect("traversal-shaped title lands with a sanitised slug");
5939        assert_eq!(outcome.id.as_ref(), "specs--etcpasswd");
5940        assert!(
5941            outcome
5942                .warnings
5943                .iter()
5944                .any(|w| matches!(w, WarningHint::TitleCharsDroppedFromSlug { .. }))
5945        );
5946    }
5947
5948    #[test]
5949    fn create_entity_rejects_unknown_section_key() {
5950        let tmp = TempDir::new().unwrap();
5951        let mem_dir = tmp.path().to_path_buf();
5952        let writer = FilesystemMemWriter::new(mem_dir.clone());
5953        let mut engine = Engine::from_mounts(vec![(
5954            folder_mount("specs", mem_dir),
5955            Box::new(writer) as Box<dyn MemBackend>,
5956        )])
5957        .unwrap();
5958        let (actor, client) = cli_actor();
5959
5960        let mut args = empty_create_args("specs", "Bad Sections");
5961        args.sections
5962            .insert("not-a-real-section-key".to_string(), "body".to_string());
5963        let err = engine
5964            .create_entity(args, actor, Some(&client), None)
5965            .unwrap_err();
5966        assert!(matches!(err, EngineError::Validation(_)));
5967    }
5968
5969    #[test]
5970    fn create_entity_persists_across_engine_restart() {
5971        let tmp = TempDir::new().unwrap();
5972        let mem_dir = tmp.path().to_path_buf();
5973        {
5974            let writer = FilesystemMemWriter::new(mem_dir.clone());
5975            let mut engine = Engine::from_mounts(vec![(
5976                folder_mount("specs", mem_dir.clone()),
5977                Box::new(writer) as Box<dyn MemBackend>,
5978            )])
5979            .unwrap();
5980            let (actor, client) = cli_actor();
5981            engine
5982                .create_entity(
5983                    empty_create_args("specs", "Survives Restart"),
5984                    actor,
5985                    Some(&client),
5986                    None,
5987                )
5988                .unwrap();
5989        }
5990        // New engine reading the same mem must see the entity.
5991        let writer2 = FilesystemMemWriter::new(mem_dir.clone());
5992        let engine2 = Engine::from_mounts(vec![(
5993            folder_mount("specs", mem_dir),
5994            Box::new(writer2) as Box<dyn MemBackend>,
5995        )])
5996        .unwrap();
5997        let entity = engine2
5998            .get_entity(&crate::EntityId::new("specs", "survives-restart"))
5999            .expect("entity must persist across engine restart");
6000        assert_eq!(entity.title, "Survives Restart");
6001    }
6002
6003    // ---- Engine::update_entity --------------------------------------
6004
6005    /// Build a folder-mount Engine with one freshly-created entity.
6006    /// Returns the engine + the created outcome so tests have the
6007    /// id and current hash to use as `expected_hash` for the next
6008    /// mutation.
6009    fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
6010        let mem_dir = tmp.path().to_path_buf();
6011        let writer = FilesystemMemWriter::new(mem_dir.clone());
6012        let mut engine = Engine::from_mounts(vec![(
6013            folder_mount("specs", mem_dir),
6014            Box::new(writer) as Box<dyn MemBackend>,
6015        )])
6016        .unwrap();
6017        let (actor, client) = cli_actor();
6018        let outcome = engine
6019            .create_entity(
6020                empty_create_args("specs", title),
6021                actor,
6022                Some(&client),
6023                None,
6024            )
6025            .unwrap();
6026        (engine, outcome)
6027    }
6028
6029    /// Create with
6030    /// a body wiki-link to a non-existent target emits
6031    /// `INLINE_WIKI_LINK_AUTO_STUBBED` with the stubbed target id in
6032    /// `details.stubs`. Pre-fix the warning never fired because the
6033    /// emission walked `parse_markdown(generated_markdown).inline_links`,
6034    /// which the parser-side coverage filter had already emptied for
6035    /// the alias-synthesised body link.
6036    #[test]
6037    fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
6038        let tmp = TempDir::new().unwrap();
6039        let mem_dir = tmp.path().to_path_buf();
6040        let writer = FilesystemMemWriter::new(mem_dir.clone());
6041        let mut engine = Engine::from_mounts(vec![(
6042            folder_mount("specs", mem_dir),
6043            Box::new(writer) as Box<dyn MemBackend>,
6044        )])
6045        .unwrap();
6046        let (actor, client) = cli_actor();
6047
6048        let ghost = crate::EntityId::new("specs", "ghost-target");
6049        assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
6050
6051        let mut args = empty_create_args("specs", "Source With Body Link");
6052        args.sections.insert(
6053            "identity".to_string(),
6054            "ref [[ghost-target]] for context".to_string(),
6055        );
6056
6057        let outcome = engine
6058            .create_entity(args, actor, Some(&client), None)
6059            .unwrap();
6060        let stubbed: Vec<&crate::EntityId> = outcome
6061            .warnings
6062            .iter()
6063            .filter_map(|w| match w {
6064                WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
6065                _ => None,
6066            })
6067            .flatten()
6068            .collect();
6069        assert!(
6070            stubbed.contains(&&ghost),
6071            "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
6072            outcome.warnings,
6073        );
6074        // The stub also lands in the store and the REFERENCES edge exists.
6075        assert!(
6076            engine.store().contains(&ghost),
6077            "ghost stub must materialise"
6078        );
6079    }
6080
6081    /// CLI F11: a body wiki-link to the entity's own slug is dropped (no
6082    /// vacuous self-edge) with a `SELF_LINK_IGNORED` warning, while a body
6083    /// link to a *different* target in the same entity still synthesises
6084    /// its REFERENCES edge normally — only the self-target is dropped.
6085    #[test]
6086    fn create_entity_drops_self_link_keeps_other_links_and_warns() {
6087        let tmp = TempDir::new().unwrap();
6088        let mem_dir = tmp.path().to_path_buf();
6089        let writer = FilesystemMemWriter::new(mem_dir.clone());
6090        let mut engine = Engine::from_mounts(vec![(
6091            folder_mount("specs", mem_dir),
6092            Box::new(writer) as Box<dyn MemBackend>,
6093        )])
6094        .unwrap();
6095        let (actor, client) = cli_actor();
6096
6097        // Title "Selfie" → slug "selfie" → id "specs--selfie". The body
6098        // links its own slug AND a different target.
6099        let mut args = empty_create_args("specs", "Selfie");
6100        args.sections.insert(
6101            "identity".to_string(),
6102            "see [[selfie]] itself and also [[other-ref]]".to_string(),
6103        );
6104        let outcome = engine
6105            .create_entity(args, actor, Some(&client), None)
6106            .unwrap();
6107        let self_id = outcome.id.clone();
6108        assert_eq!(self_id.to_string(), "specs--selfie");
6109        let other_id = crate::EntityId::new("specs", "other-ref");
6110
6111        // SELF_LINK_IGNORED warning names the self-linking entity.
6112        assert!(
6113            outcome.warnings.iter().any(|w| matches!(
6114                w, WarningHint::SelfLinkIgnored { id } if *id == self_id
6115            )),
6116            "self-link must emit SELF_LINK_IGNORED; got: {:?}",
6117            outcome.warnings,
6118        );
6119
6120        // No self-edge: not in relationships, not Outgoing, not Incoming.
6121        let ent = engine.get_entity(&self_id).unwrap();
6122        assert!(
6123            ent.relationships.iter().all(|r| r.target != self_id),
6124            "no self-relation may be synthesised; got: {:?}",
6125            ent.relationships,
6126        );
6127        assert!(
6128            engine
6129                .store()
6130                .outgoing(&self_id)
6131                .iter()
6132                .all(|e| e.target != self_id),
6133            "self must not be its own Outgoing neighbour",
6134        );
6135        assert!(
6136            engine
6137                .store()
6138                .incoming(&self_id)
6139                .iter()
6140                .all(|e| e.from != self_id),
6141            "self must not be its own Incoming neighbour",
6142        );
6143
6144        // Complement: the link to a *different* target synthesised its
6145        // REFERENCES edge normally.
6146        assert!(
6147            ent.relationships
6148                .iter()
6149                .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
6150            "non-self body link must still synthesise its edge; got: {:?}",
6151            ent.relationships,
6152        );
6153    }
6154
6155    /// dry_run preview matches real-write outcome.
6156    #[test]
6157    fn create_entity_dry_run_emits_same_auto_stub_warning() {
6158        let tmp = TempDir::new().unwrap();
6159        let mem_dir = tmp.path().to_path_buf();
6160        let writer = FilesystemMemWriter::new(mem_dir.clone());
6161        let mut engine = Engine::from_mounts(vec![(
6162            folder_mount("specs", mem_dir),
6163            Box::new(writer) as Box<dyn MemBackend>,
6164        )])
6165        .unwrap();
6166        let (actor, client) = cli_actor();
6167
6168        let mut args = empty_create_args("specs", "Dry Run Body Link");
6169        args.dry_run = true;
6170        args.sections
6171            .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
6172
6173        let outcome = engine
6174            .create_entity(args, actor, Some(&client), None)
6175            .unwrap();
6176        let has_warning = outcome.warnings.iter().any(|w| {
6177            matches!(
6178                w,
6179                WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
6180                    if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
6181            )
6182        });
6183        assert!(
6184            has_warning,
6185            "dry_run must emit the same warning as real write: {:?}",
6186            outcome.warnings
6187        );
6188    }
6189
6190    /// Body wiki-link to a target that already exists
6191    /// in the store does NOT fire the warning — no stub was created.
6192    #[test]
6193    fn create_entity_no_auto_stub_warning_when_target_exists() {
6194        let tmp = TempDir::new().unwrap();
6195        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
6196        let (actor, client) = cli_actor();
6197
6198        let mut args = empty_create_args("specs", "Source Linking Existing");
6199        let body = format!("ref [[{}]]", existing.id.path());
6200        args.sections.insert("identity".to_string(), body);
6201
6202        let outcome = engine
6203            .create_entity(args, actor, Some(&client), None)
6204            .unwrap();
6205        let has_warning = outcome
6206            .warnings
6207            .iter()
6208            .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
6209        assert!(
6210            !has_warning,
6211            "no auto-stub warning when target pre-exists; got: {:?}",
6212            outcome.warnings
6213        );
6214    }
6215
6216    /// Obligation-schema counterpart of the ingest wildcard
6217    /// (first-author-path plan 09, criterion 5): an obligation mem
6218    /// body-links into a NON-SOFTWARE user-schema destination; the
6219    /// wildcard alias grant admits the auto-emitted REFERENCES edge.
6220    #[test]
6221    fn obligation_wildcard_links_into_arbitrary_destination_schema() {
6222        use crate::engine::test_helpers::write_schema_files_with_default_type;
6223        use memstead_schema::workspace_config::CrossLinkValue;
6224
6225        let tmp = TempDir::new().unwrap();
6226        let dest_dir = tmp.path().join("dest");
6227        let duties_dir = tmp.path().join("duties");
6228        std::fs::create_dir_all(&dest_dir).unwrap();
6229        std::fs::create_dir_all(&duties_dir).unwrap();
6230        let schemas_dir = tmp.path().join("schemas");
6231        let user_manifest = r#"name: casefiles
6232version: 0.1.0
6233description: a user-written, non-software destination schema
6234when_to_use: tests
6235types:
6236  - doc
6237relationships:
6238  mode: strict
6239  definitions:
6240    - name: _default
6241      description: fallback
6242      default_weight: 1.0
6243community:
6244  resolution: 1.0
6245  seed: 42
6246"#;
6247        write_schema_files_with_default_type(
6248            &schemas_dir,
6249            "casefiles@0.1.0",
6250            user_manifest,
6251            &["doc"],
6252        );
6253
6254        let mount = |mem: &str, dir: &std::path::Path, schema: &str| crate::workspace::Mount {
6255            mem: mem.to_string(),
6256            schema: Some(memstead_schema::SchemaRef::new(
6257                schema,
6258                semver::Version::new(0, 1, 0),
6259            )),
6260            storage: crate::workspace::MountStorage::Folder {
6261                path: dir.to_path_buf(),
6262            },
6263            capability: crate::workspace::MountCapability::Write,
6264            lifecycle: crate::workspace::MountLifecycle::Eager,
6265            cross_linkable: true,
6266            migration_target: None,
6267        };
6268        let mounts = vec![
6269            (
6270                mount("dest", &dest_dir, "casefiles"),
6271                Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
6272            ),
6273            (
6274                mount("duties", &duties_dir, "obligation"),
6275                Box::new(FilesystemMemWriter::new(duties_dir.clone())) as Box<dyn MemBackend>,
6276            ),
6277        ];
6278        let mut engine = Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
6279            .expect("obligation + user schema boot");
6280        let mut settings = crate::workspace::WorkspaceSettings::default();
6281        settings.cross_mem_links.insert(
6282            "duties".to_string(),
6283            CrossLinkValue::List(vec!["dest".to_string()]),
6284        );
6285        engine.set_settings(settings);
6286        let (actor, client) = cli_actor();
6287
6288        let target = engine
6289            .create_entity(
6290                CreateEntityArgs {
6291                    anchors: Vec::new(),
6292                    mem: "dest".to_string(),
6293                    title: "Case File 17".to_string(),
6294                    entity_type: "doc".to_string(),
6295                    sections: IndexMap::from_iter([(
6296                        "body".to_string(),
6297                        "destination content".to_string(),
6298                    )]),
6299                    metadata: IndexMap::new(),
6300                    relations: Vec::new(),
6301                    dry_run: false,
6302                },
6303                actor,
6304                Some(&client),
6305                None,
6306            )
6307            .unwrap();
6308
6309        let entry = engine
6310            .create_entity(
6311                CreateEntityArgs {
6312                    anchors: Vec::new(),
6313                    mem: "duties".to_string(),
6314                    title: "File Annual Report & Notice".to_string(),
6315                    entity_type: "obligation".to_string(),
6316                    sections: IndexMap::from_iter([
6317                        (
6318                            "duty".to_string(),
6319                            "File the report cited in [[dest--case-file-17]].".to_string(),
6320                        ),
6321                        (
6322                            "consequence".to_string(),
6323                            "Standing lapses at the deadline.".to_string(),
6324                        ),
6325                    ]),
6326                    metadata: IndexMap::from_iter([
6327                        ("due_date".to_string(), "2026-12-31".to_string()),
6328                        ("status".to_string(), "open".to_string()),
6329                    ]),
6330                    relations: vec![crate::ops::RelateArg {
6331                        target: crate::entity::EntityId::new("duties", "subject"),
6332                        rel_type: "CONCERNS".to_string(),
6333                        description: None,
6334                    }],
6335                    dry_run: false,
6336                },
6337                actor,
6338                Some(&client),
6339                None,
6340            )
6341            .expect("wildcard admits the alias link into the non-software destination");
6342        let stored = engine.get_entity(&entry.id).unwrap();
6343        assert!(
6344            stored
6345                .relationships
6346                .iter()
6347                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6348            "alias REFERENCES edge must emit cross-mem: {:?}",
6349            stored.relationships
6350        );
6351    }
6352
6353    /// Plan 11 end-to-end: an `ingest`-schema process mem body-links
6354    /// into a destination pinning an ARBITRARY user-written schema.
6355    /// The wildcard (bound to `alias_target_rel_type: REFERENCES`)
6356    /// admits the auto-emitted alias edge; the edge survives a fresh
6357    /// boot (the load path routes through the same matcher); explicit
6358    /// authoring of the alias type still refuses
6359    /// RELATION_MANUAL_AUTHORING_FORBIDDEN; a structural rel-type into
6360    /// the undeclared destination still refuses
6361    /// CROSS_MEM_EDGE_NOT_DECLARED; and the workspace policy gate
6362    /// still fires when the direction is not granted.
6363    #[test]
6364    fn ingest_wildcard_links_into_arbitrary_destination_schema() {
6365        use crate::engine::test_helpers::write_schema_files_with_default_type;
6366        use memstead_schema::workspace_config::CrossLinkValue;
6367
6368        let tmp = TempDir::new().unwrap();
6369        let dest_dir = tmp.path().join("dest");
6370        let proc_dir = tmp.path().join("proc");
6371        std::fs::create_dir_all(&dest_dir).unwrap();
6372        std::fs::create_dir_all(&proc_dir).unwrap();
6373
6374        // A user-written schema the engine has never shipped.
6375        let schemas_dir = tmp.path().join("schemas");
6376        let user_manifest = r#"name: debate
6377version: 0.1.0
6378description: a user-written destination schema
6379when_to_use: tests
6380types:
6381  - doc
6382relationships:
6383  mode: strict
6384  definitions:
6385    - name: _default
6386      description: fallback
6387      default_weight: 1.0
6388community:
6389  resolution: 1.0
6390  seed: 42
6391"#;
6392        write_schema_files_with_default_type(&schemas_dir, "debate@0.1.0", user_manifest, &["doc"]);
6393
6394        let mount = |mem: &str, dir: &std::path::Path, schema: &str, version: (u64, u64, u64)| {
6395            crate::workspace::Mount {
6396                mem: mem.to_string(),
6397                schema: Some(memstead_schema::SchemaRef::new(
6398                    schema,
6399                    semver::Version::new(version.0, version.1, version.2),
6400                )),
6401                storage: crate::workspace::MountStorage::Folder {
6402                    path: dir.to_path_buf(),
6403                },
6404                capability: crate::workspace::MountCapability::Write,
6405                lifecycle: crate::workspace::MountLifecycle::Eager,
6406                cross_linkable: true,
6407                migration_target: None,
6408            }
6409        };
6410        let boot = |grant: bool| -> Engine {
6411            let mounts = vec![
6412                (
6413                    mount("dest", &dest_dir, "debate", (0, 1, 0)),
6414                    Box::new(FilesystemMemWriter::new(dest_dir.clone())) as Box<dyn MemBackend>,
6415                ),
6416                (
6417                    mount("proc", &proc_dir, "ingest", (0, 2, 0)),
6418                    Box::new(FilesystemMemWriter::new(proc_dir.clone())) as Box<dyn MemBackend>,
6419                ),
6420            ];
6421            let mut engine =
6422                Engine::from_mounts_with_schemas_dir(mounts, Some(schemas_dir.as_path()))
6423                    .expect("ingest + user schema boot");
6424            let mut settings = crate::workspace::WorkspaceSettings::default();
6425            if grant {
6426                settings.cross_mem_links.insert(
6427                    "proc".to_string(),
6428                    CrossLinkValue::List(vec!["dest".to_string()]),
6429                );
6430            }
6431            engine.set_settings(settings);
6432            engine
6433        };
6434        let (actor, client) = cli_actor();
6435
6436        let mut engine = boot(true);
6437        // Destination entity in the user-schema mem.
6438        let target = engine
6439            .create_entity(
6440                CreateEntityArgs {
6441                    anchors: Vec::new(),
6442                    mem: "dest".to_string(),
6443                    title: "Target Doc".to_string(),
6444                    entity_type: "doc".to_string(),
6445                    sections: IndexMap::from_iter([(
6446                        "body".to_string(),
6447                        "destination content".to_string(),
6448                    )]),
6449                    metadata: IndexMap::new(),
6450                    relations: Vec::new(),
6451                    dry_run: false,
6452                },
6453                actor,
6454                Some(&client),
6455                None,
6456            )
6457            .unwrap();
6458
6459        // Process-mem entry body-linking the destination entity.
6460        let entry = engine
6461            .create_entity(
6462                CreateEntityArgs {
6463                    anchors: Vec::new(),
6464                    mem: "proc".to_string(),
6465                    title: "Check The Claim".to_string(),
6466                    entity_type: "verification_target".to_string(),
6467                    sections: IndexMap::from_iter([
6468                        (
6469                            "claim".to_string(),
6470                            "the claim under suspicion lives in [[dest--target-doc]]".to_string(),
6471                        ),
6472                        ("source_to_check".to_string(), "dest mem".to_string()),
6473                        (
6474                            "verifiable_when".to_string(),
6475                            "the linked entity still says so".to_string(),
6476                        ),
6477                    ]),
6478                    metadata: IndexMap::new(),
6479                    relations: Vec::new(),
6480                    dry_run: false,
6481                },
6482                actor,
6483                Some(&client),
6484                None,
6485            )
6486            .expect("wildcard admits the alias link into the user-schema destination");
6487        let stored = engine.get_entity(&entry.id).unwrap();
6488        assert!(
6489            stored
6490                .relationships
6491                .iter()
6492                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6493            "alias REFERENCES edge must emit: {:?}",
6494            stored.relationships
6495        );
6496
6497        // Explicit authoring of the alias rel-type: still forbidden.
6498        let err = engine
6499            .relate_entity(
6500                RelateEntityArgs {
6501                    source: entry.id.clone(),
6502                    expected_hash: None,
6503                    rel_type: "REFERENCES".to_string(),
6504                    target: target.id.clone(),
6505                    remove: false,
6506                    description: None,
6507                    dry_run: false,
6508                },
6509                actor,
6510                Some(&client),
6511                None,
6512            )
6513            .unwrap_err();
6514        assert_eq!(err.code(), "RELATION_MANUAL_AUTHORING_FORBIDDEN", "{err:?}");
6515
6516        // Structural rel-type into the undeclared destination: the
6517        // historical refusal, wildcard notwithstanding.
6518        let err = engine
6519            .relate_entity(
6520                RelateEntityArgs {
6521                    source: entry.id.clone(),
6522                    expected_hash: None,
6523                    rel_type: "PART_OF".to_string(),
6524                    target: target.id.clone(),
6525                    remove: false,
6526                    description: None,
6527                    dry_run: false,
6528                },
6529                actor,
6530                Some(&client),
6531                None,
6532            )
6533            .unwrap_err();
6534        assert_eq!(err.code(), "CROSS_MEM_EDGE_NOT_DECLARED", "{err:?}");
6535
6536        // Load-path survival: a FRESH boot over the same folders (the
6537        // store-builder path that previously dropped undeclared
6538        // cross-mem edges) keeps the alias edge.
6539        drop(engine);
6540        let rebooted = boot(true);
6541        let reloaded = rebooted.get_entity(&entry.id).unwrap();
6542        assert!(
6543            reloaded
6544                .relationships
6545                .iter()
6546                .any(|r| r.rel_type == "REFERENCES" && r.target == target.id),
6547            "alias edge must survive reload: {:?}",
6548            reloaded.relationships
6549        );
6550
6551        // Policy gate intact: without the grant, the same wildcarded
6552        // link refuses CROSS_MEM_LINK_NOT_ALLOWED.
6553        let mut denied = boot(false);
6554        let err = denied
6555            .create_entity(
6556                CreateEntityArgs {
6557                    anchors: Vec::new(),
6558                    mem: "proc".to_string(),
6559                    title: "Denied Entry".to_string(),
6560                    entity_type: "verification_target".to_string(),
6561                    sections: IndexMap::from_iter([
6562                        (
6563                            "claim".to_string(),
6564                            "points at [[dest--target-doc]]".to_string(),
6565                        ),
6566                        ("source_to_check".to_string(), "dest mem".to_string()),
6567                        ("verifiable_when".to_string(), "never".to_string()),
6568                    ]),
6569                    metadata: IndexMap::new(),
6570                    relations: Vec::new(),
6571                    dry_run: false,
6572                },
6573                actor,
6574                Some(&client),
6575                None,
6576            )
6577            .unwrap_err();
6578        assert_eq!(err.code(), "CROSS_MEM_LINK_NOT_ALLOWED", "{err:?}");
6579    }
6580
6581    /// Two-mem Write-Write scaffold —
6582    /// `test` and `other` both pin the default schema, no
6583    /// `cross_mem_links` policy set yet (default deny-all). The
6584    /// caller installs the policy that matches each scenario.
6585    fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
6586        let tmp_test = TempDir::new().unwrap();
6587        let tmp_other = TempDir::new().unwrap();
6588        let test_dir = tmp_test.path().to_path_buf();
6589        let other_dir = tmp_other.path().to_path_buf();
6590        let writer_test = FilesystemMemWriter::new(test_dir.clone());
6591        let writer_other = FilesystemMemWriter::new(other_dir.clone());
6592        let engine = Engine::from_mounts(vec![
6593            (
6594                folder_mount("test", test_dir),
6595                Box::new(writer_test) as Box<dyn MemBackend>,
6596            ),
6597            (
6598                folder_mount("other", other_dir),
6599                Box::new(writer_other) as Box<dyn MemBackend>,
6600            ),
6601        ])
6602        .unwrap();
6603        (tmp_test, tmp_other, engine)
6604    }
6605
6606    /// `memstead_create` with an inline cross-mem relation refuses
6607    /// with `CROSS_MEM_LINK_NOT_ALLOWED` when policy denies the
6608    /// direction. The entity does not persist; the would-be id reads
6609    /// as `NotFound`.
6610    #[test]
6611    fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
6612        use crate::entity::EntityId;
6613        use crate::ops::RelateArg;
6614        use memstead_schema::workspace_config::CrossLinkValue;
6615
6616        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6617        let (actor, client) = cli_actor();
6618
6619        // Policy: `test → other` granted only. The inline create
6620        // request below is `other → test`, which must refuse.
6621        let mut settings = crate::workspace::WorkspaceSettings::default();
6622        settings.cross_mem_links.insert(
6623            "test".to_string(),
6624            CrossLinkValue::List(vec!["other".to_string()]),
6625        );
6626        engine.set_settings(settings);
6627
6628        // Seed a target in the `test` mem so the inline relation
6629        // names a real id (the policy gate fires before target
6630        // resolution regardless, but a real target removes any
6631        // ambiguity from the assertion).
6632        let target = engine
6633            .create_entity(
6634                empty_create_args("test", "Target"),
6635                actor,
6636                Some(&client),
6637                None,
6638            )
6639            .unwrap();
6640
6641        let mut args = empty_create_args("other", "Source");
6642        args.relations = vec![RelateArg {
6643            rel_type: "IMPLEMENTS".to_string(),
6644            target: target.id.clone(),
6645            description: None,
6646        }];
6647        let err = engine
6648            .create_entity(args, actor, Some(&client), None)
6649            .unwrap_err();
6650        match err {
6651            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
6652                assert_eq!(from_mem, "other");
6653                assert_eq!(to_mem, "test");
6654            }
6655            other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
6656        }
6657
6658        // No entity landed: the would-be id is absent.
6659        let would_be = EntityId::new("other", "source");
6660        assert!(
6661            engine.get_entity(&would_be).is_none(),
6662            "entity must not persist when inline relation refuses"
6663        );
6664    }
6665
6666    /// With the granted direction, the
6667    /// inline cross-mem relation succeeds and the edge persists.
6668    #[test]
6669    fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
6670        use crate::ops::RelateArg;
6671        use memstead_schema::workspace_config::CrossLinkValue;
6672
6673        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6674        let (actor, client) = cli_actor();
6675
6676        let mut settings = crate::workspace::WorkspaceSettings::default();
6677        settings.cross_mem_links.insert(
6678            "other".to_string(),
6679            CrossLinkValue::List(vec!["test".to_string()]),
6680        );
6681        engine.set_settings(settings);
6682
6683        let target = engine
6684            .create_entity(
6685                empty_create_args("test", "Target"),
6686                actor,
6687                Some(&client),
6688                None,
6689            )
6690            .unwrap();
6691
6692        let mut args = empty_create_args("other", "Source");
6693        args.relations = vec![RelateArg {
6694            rel_type: "IMPLEMENTS".to_string(),
6695            target: target.id.clone(),
6696            description: None,
6697        }];
6698        let outcome = engine
6699            .create_entity(args, actor, Some(&client), None)
6700            .unwrap();
6701        let stored = engine.get_entity(&outcome.id).expect("entity persists");
6702        assert!(
6703            stored
6704                .relationships
6705                .iter()
6706                .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
6707            "IMPLEMENTS edge must persist on the source's relationships",
6708        );
6709    }
6710
6711    /// A same-mem inline relation
6712    /// bypasses the policy gate entirely. Even with an empty policy
6713    /// (default deny-all for cross-mem), the create succeeds.
6714    #[test]
6715    fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
6716        use crate::ops::RelateArg;
6717
6718        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6719        let (actor, client) = cli_actor();
6720        // No cross_mem_links set; same-mem writes must still work.
6721
6722        let target = engine
6723            .create_entity(
6724                empty_create_args("test", "Target"),
6725                actor,
6726                Some(&client),
6727                None,
6728            )
6729            .unwrap();
6730        let mut args = empty_create_args("test", "Source");
6731        args.relations = vec![RelateArg {
6732            rel_type: "USES".to_string(),
6733            target: target.id.clone(),
6734            description: None,
6735        }];
6736        let outcome = engine
6737            .create_entity(args, actor, Some(&client), None)
6738            .unwrap();
6739        let stored = engine.get_entity(&outcome.id).expect("entity persists");
6740        assert!(
6741            stored
6742                .relationships
6743                .iter()
6744                .any(|r| r.rel_type == "USES" && r.target == target.id),
6745            "same-mem USES edge must persist",
6746        );
6747    }
6748
6749    /// The existing `memstead_relate` path
6750    /// refuses the same scenario with the same typed code and
6751    /// payload shape — the two surfaces' refusals are
6752    /// indistinguishable to an agent.
6753    #[test]
6754    fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
6755        use crate::ops::RelateArg;
6756        use memstead_schema::workspace_config::CrossLinkValue;
6757
6758        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6759        let (actor, client) = cli_actor();
6760
6761        let mut settings = crate::workspace::WorkspaceSettings::default();
6762        settings.cross_mem_links.insert(
6763            "test".to_string(),
6764            CrossLinkValue::List(vec!["other".to_string()]),
6765        );
6766        engine.set_settings(settings);
6767
6768        let target = engine
6769            .create_entity(
6770                empty_create_args("test", "Target"),
6771                actor,
6772                Some(&client),
6773                None,
6774            )
6775            .unwrap();
6776        let src = engine
6777            .create_entity(
6778                empty_create_args("other", "Source"),
6779                actor,
6780                Some(&client),
6781                None,
6782            )
6783            .unwrap();
6784
6785        // memstead_relate refusal.
6786        let relate_err = engine
6787            .relate_entity(
6788                RelateEntityArgs {
6789                    source: src.id.clone(),
6790                    rel_type: "IMPLEMENTS".to_string(),
6791                    target: target.id.clone(),
6792                    expected_hash: Some(src.content_hash.clone()),
6793                    remove: false,
6794                    description: None,
6795                    dry_run: false,
6796                },
6797                actor,
6798                Some(&client),
6799                None,
6800            )
6801            .unwrap_err();
6802
6803        // memstead_create.relations[] refusal — fresh title so the create
6804        // attempt hasn't already landed.
6805        let mut create_args = empty_create_args("other", "Source Two");
6806        create_args.relations = vec![RelateArg {
6807            rel_type: "IMPLEMENTS".to_string(),
6808            target: target.id.clone(),
6809            description: None,
6810        }];
6811        let create_err = engine
6812            .create_entity(create_args, actor, Some(&client), None)
6813            .unwrap_err();
6814
6815        // Both refusals share the typed code, the payload shape, and
6816        // the (from_mem, to_mem) values.
6817        match (relate_err, create_err) {
6818            (
6819                EngineError::CrossMemLinkNotAllowed {
6820                    from_mem: rfv,
6821                    to_mem: rtv,
6822                },
6823                EngineError::CrossMemLinkNotAllowed {
6824                    from_mem: cfv,
6825                    to_mem: ctv,
6826                },
6827            ) => {
6828                assert_eq!(rfv, "other");
6829                assert_eq!(rtv, "test");
6830                assert_eq!(cfv, "other");
6831                assert_eq!(ctv, "test");
6832            }
6833            (a, b) => panic!(
6834                "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
6835            ),
6836        }
6837    }
6838
6839    /// Body wiki-link `[[other--target]]` in mem `test` (with
6840    /// `test → other` granted) creates the entity, auto-stubs at
6841    /// `other--target` (NOT `test--other--target` — that was the
6842    /// pre-fix phantom-stub bug), and emits one REFERENCES edge via
6843    /// the alias-synthesis path.
6844    #[test]
6845    fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
6846        use crate::entity::EntityId;
6847        use indexmap::IndexMap;
6848        use memstead_schema::workspace_config::CrossLinkValue;
6849
6850        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6851        let (actor, client) = cli_actor();
6852
6853        let mut settings = crate::workspace::WorkspaceSettings::default();
6854        settings.cross_mem_links.insert(
6855            "test".to_string(),
6856            CrossLinkValue::List(vec!["other".to_string()]),
6857        );
6858        engine.set_settings(settings);
6859
6860        let mut sections: IndexMap<String, String> = IndexMap::new();
6861        sections.insert(
6862            "identity".to_string(),
6863            "see [[other--target]] for details".to_string(),
6864        );
6865        sections.insert("purpose".to_string(), "source purpose".to_string());
6866        let outcome = engine
6867            .create_entity(
6868                crate::engine::CreateEntityArgs {
6869                    anchors: Vec::new(),
6870                    mem: "test".to_string(),
6871                    title: "Source".to_string(),
6872                    entity_type: "spec".to_string(),
6873                    sections,
6874                    metadata: IndexMap::new(),
6875                    relations: Vec::new(),
6876                    dry_run: false,
6877                },
6878                actor,
6879                Some(&client),
6880                None,
6881            )
6882            .unwrap();
6883
6884        // Auto-stub landed at `other--target`, NOT `test--other--target`.
6885        let canonical = EntityId::new("other", "target");
6886        assert!(
6887            engine.get_entity(&canonical).is_some(),
6888            "auto-stub must land at the canonical cross-mem id"
6889        );
6890        let phantom = EntityId::new("test", "other--target");
6891        assert!(
6892            engine.get_entity(&phantom).is_none(),
6893            "no double-prefixed phantom stub"
6894        );
6895
6896        // Exactly one REFERENCES edge to the cross-mem target.
6897        let source = engine.get_entity(&outcome.id).unwrap();
6898        let references_count = source
6899            .relationships
6900            .iter()
6901            .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
6902            .count();
6903        assert_eq!(
6904            references_count, 1,
6905            "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
6906        );
6907    }
6908
6909    /// Complement: body wiki-link cross-mem refusal when policy
6910    /// denies the direction. The auto-stub never lands, the entity
6911    /// never persists.
6912    #[test]
6913    fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
6914        use indexmap::IndexMap;
6915
6916        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
6917        let (actor, client) = cli_actor();
6918        // Empty cross-link policy — `test → other` denied.
6919
6920        let mut sections: IndexMap<String, String> = IndexMap::new();
6921        sections.insert(
6922            "identity".to_string(),
6923            "see [[other--target]] for details".to_string(),
6924        );
6925        sections.insert("purpose".to_string(), "source purpose".to_string());
6926        let err = engine
6927            .create_entity(
6928                crate::engine::CreateEntityArgs {
6929                    anchors: Vec::new(),
6930                    mem: "test".to_string(),
6931                    title: "Source".to_string(),
6932                    entity_type: "spec".to_string(),
6933                    sections,
6934                    metadata: IndexMap::new(),
6935                    relations: Vec::new(),
6936                    dry_run: false,
6937                },
6938                actor,
6939                Some(&client),
6940                None,
6941            )
6942            .unwrap_err();
6943        match err {
6944            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
6945                assert_eq!(from_mem, "test");
6946                assert_eq!(to_mem, "other");
6947            }
6948            other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
6949        }
6950    }
6951
6952    /// `[mutations].require_notes = true` drives a single `NOTE_MISSING`
6953    /// warning out of the engine mutation pipeline on every noteless
6954    /// mutation — the single enforcement point both the CLI and the MCP
6955    /// transport inherit. The mutation still commits (the policy nudges,
6956    /// it never blocks). Supplying a note suppresses it; turning the
6957    /// policy off silences it entirely. Covers create / update / relate
6958    /// in one engine instance.
6959    #[test]
6960    fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
6961        use crate::engine::UpdateEntityArgs;
6962        use crate::workspace::{MutationsSection, WorkspaceSettings};
6963        use indexmap::IndexMap;
6964
6965        let tmp = TempDir::new().unwrap();
6966        let mem_dir = tmp.path().to_path_buf();
6967        let writer = FilesystemMemWriter::new(mem_dir.clone());
6968        let mut engine = Engine::from_mounts(vec![(
6969            folder_mount("specs", mem_dir.clone()),
6970            Box::new(writer) as Box<dyn MemBackend>,
6971        )])
6972        .unwrap();
6973        engine.set_workspace_root(mem_dir.clone());
6974        engine.set_settings(WorkspaceSettings {
6975            mutations: MutationsSection {
6976                require_notes: Some(true),
6977            },
6978            ..Default::default()
6979        });
6980        let (actor, client) = cli_actor();
6981
6982        let note_missing = |ws: &[WarningHint]| -> usize {
6983            ws.iter()
6984                .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
6985                .count()
6986        };
6987
6988        // --- create, no note: exactly one NOTE_MISSING, commit landed ---
6989        let created = engine
6990            .create_entity(
6991                empty_create_args("specs", "Noteless"),
6992                actor,
6993                Some(&client),
6994                None,
6995            )
6996            .unwrap();
6997        assert_eq!(
6998            note_missing(&created.warnings),
6999            1,
7000            "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
7001            created.warnings,
7002        );
7003        assert!(
7004            matches!(
7005                created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
7006                Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
7007            ),
7008            "the warning names the engine-level verb",
7009        );
7010        assert!(
7011            !created.write_id.is_empty(),
7012            "create still commits (nudge, not block)"
7013        );
7014
7015        // --- update, no note: NOTE_MISSING + commit landed ---
7016        let mut edit: IndexMap<String, String> = IndexMap::new();
7017        edit.insert("identity".to_string(), "revised".to_string());
7018        let updated = engine
7019            .update_entity(
7020                UpdateEntityArgs {
7021                    anchors: Vec::new(),
7022                    id: created.id.clone(),
7023                    expected_hash: Some(created.content_hash.clone()),
7024                    sections: edit,
7025                    append_sections: IndexMap::new(),
7026                    patch_sections: IndexMap::new(),
7027                    metadata: IndexMap::new(),
7028                    metadata_unset: Vec::new(),
7029                    declare_relations: Vec::new(),
7030                    dry_run: false,
7031                    relations_unset: Vec::new(),
7032                    anchors_unset: Vec::new(),
7033                },
7034                actor,
7035                Some(&client),
7036                None,
7037            )
7038            .unwrap();
7039        assert_eq!(
7040            note_missing(&updated.warnings),
7041            1,
7042            "update emits NOTE_MISSING"
7043        );
7044        assert!(!updated.write_id.is_empty(), "update still commits");
7045
7046        // --- relate, no note: NOTE_MISSING + commit landed ---
7047        let target = engine
7048            .create_entity(
7049                empty_create_args("specs", "Target"),
7050                actor,
7051                Some(&client),
7052                Some("seed"),
7053            )
7054            .unwrap();
7055        let related = engine
7056            .relate_entity(
7057                RelateEntityArgs {
7058                    source: updated.id.clone(),
7059                    expected_hash: Some(updated.content_hash.clone()),
7060                    rel_type: "USES".to_string(),
7061                    target: target.id.clone(),
7062                    remove: false,
7063                    description: None,
7064                    dry_run: false,
7065                },
7066                actor,
7067                Some(&client),
7068                None,
7069            )
7070            .unwrap();
7071        assert_eq!(
7072            note_missing(&related.warnings),
7073            1,
7074            "relate emits NOTE_MISSING"
7075        );
7076        assert!(!related.write_id.is_empty(), "relate still commits");
7077
7078        // --- with a note: suppressed ---
7079        let with_note = engine
7080            .create_entity(
7081                empty_create_args("specs", "Documented"),
7082                actor,
7083                Some(&client),
7084                Some("a real provenance note"),
7085            )
7086            .unwrap();
7087        assert_eq!(
7088            note_missing(&with_note.warnings),
7089            0,
7090            "a supplied note suppresses the warning",
7091        );
7092
7093        // --- policy off: silent even without a note ---
7094        engine.set_settings(WorkspaceSettings::default());
7095        let after_off = engine
7096            .create_entity(
7097                empty_create_args("specs", "Quiet"),
7098                actor,
7099                Some(&client),
7100                None,
7101            )
7102            .unwrap();
7103        assert_eq!(
7104            note_missing(&after_off.warnings),
7105            0,
7106            "no NOTE_MISSING when require_notes is unset",
7107        );
7108    }
7109
7110    // ---- E3a anchors: create/persist/reload/isolation ------------------
7111
7112    fn file_anchor(artifact: &str, hash: &str) -> crate::anchor::AnchorInput {
7113        crate::anchor::AnchorInput {
7114            artifact: Some(artifact.to_string()),
7115            grain: Some("file".to_string()),
7116            class: Some("anchored".to_string()),
7117            hash: Some(hash.to_string()),
7118            hash_stability: Some("stable".to_string()),
7119            ..Default::default()
7120        }
7121    }
7122
7123    fn folder_engine(mem: &str) -> (Engine, TempDir) {
7124        let tmp = TempDir::new().unwrap();
7125        let dir = tmp.path().to_path_buf();
7126        let writer = FilesystemMemWriter::new(dir.clone());
7127        let engine = Engine::from_mounts(vec![(
7128            folder_mount(mem, dir.clone()),
7129            Box::new(writer) as Box<dyn MemBackend>,
7130        )])
7131        .unwrap();
7132        (engine, tmp)
7133    }
7134
7135    #[test]
7136    fn create_with_anchors_persists_and_survives_reload() {
7137        let (mut engine, tmp) = folder_engine("specs");
7138        let dir = tmp.path().to_path_buf();
7139        let (actor, client) = cli_actor();
7140        let mut args = empty_create_args("specs", "Anchored Entity");
7141        args.anchors = vec![file_anchor("src/lib.rs", "h1")];
7142        engine
7143            .create_entity(args, actor, Some(&client), None)
7144            .unwrap();
7145
7146        let id = crate::EntityId::new("specs", "anchored-entity");
7147        let anchors = engine.entity_anchors(&id);
7148        assert_eq!(anchors.len(), 1);
7149        assert_eq!(anchors[0].artifact, "src/lib.rs");
7150        assert_eq!(
7151            anchors[0].class,
7152            crate::anchor::AnchorProvenanceClass::Anchored
7153        );
7154
7155        // Survives a fresh boot from the same on-disk mem.
7156        let writer = FilesystemMemWriter::new(dir.clone());
7157        let reloaded = Engine::from_mounts(vec![(
7158            folder_mount("specs", dir.clone()),
7159            Box::new(writer) as Box<dyn MemBackend>,
7160        )])
7161        .unwrap();
7162        assert_eq!(reloaded.entity_anchors(&id).len(), 1);
7163        // Reverse lookup finds it by artifact path.
7164        assert_eq!(reloaded.anchors_referencing_artifact("src/lib.rs").len(), 1);
7165    }
7166
7167    #[test]
7168    fn malformed_anchor_refuses_and_entity_not_written() {
7169        let (mut engine, tmp) = folder_engine("specs");
7170        let (actor, client) = cli_actor();
7171        let mut args = empty_create_args("specs", "Bad Anchor");
7172        args.anchors = vec![crate::anchor::AnchorInput {
7173            artifact: Some("x".into()),
7174            grain: Some("paragraph".into()), // unknown grain
7175            class: Some("anchored".into()),
7176            ..Default::default()
7177        }];
7178        let err = engine
7179            .create_entity(args, actor, Some(&client), None)
7180            .unwrap_err();
7181        assert_eq!(err.code(), crate::anchor::INVALID_ANCHOR_CODE);
7182        // Entity was not written (refusal fires before the disk write).
7183        assert!(
7184            engine
7185                .get_entity(&crate::EntityId::new("specs", "bad-anchor"))
7186                .is_none()
7187        );
7188        assert!(!tmp.path().join("bad-anchor.md").exists());
7189    }
7190
7191    #[test]
7192    fn anchors_are_not_folded_into_content_hash() {
7193        // Two identical creates — one anchored, one not — produce the same
7194        // `_hash`: the anchors sidecar lives under `.memstead/` and never
7195        // enters content hashing.
7196        //
7197        // Both engines run on ONE frozen clock. The schema auto-stamps
7198        // `created_date` / `last_modified` at second granularity, so without
7199        // this the assertion also silently depended on both creates landing
7200        // inside the same second — true on an idle machine, false under a
7201        // loaded one, where the two entities differ in frontmatter and the
7202        // hashes diverge for a reason that has nothing to do with anchors.
7203        let (mut anchored, _t1) = folder_engine("specs");
7204        let (mut plain, _t2) = folder_engine("specs");
7205        let frozen = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_000_000);
7206        anchored.set_mutation_clock(std::sync::Arc::new(move || frozen));
7207        plain.set_mutation_clock(std::sync::Arc::new(move || frozen));
7208        let (actor, client) = cli_actor();
7209
7210        let mut a = empty_create_args("specs", "Same Title");
7211        a.anchors = vec![file_anchor("src/lib.rs", "h1")];
7212        let with = anchored
7213            .create_entity(a, actor, Some(&client), None)
7214            .unwrap();
7215
7216        let p = empty_create_args("specs", "Same Title");
7217        let without = plain.create_entity(p, actor, Some(&client), None).unwrap();
7218
7219        assert_eq!(
7220            with.content_hash, without.content_hash,
7221            "anchors must not change the entity content hash"
7222        );
7223    }
7224
7225    #[test]
7226    fn anchorless_create_writes_no_sidecar() {
7227        let (mut engine, _tmp) = folder_engine("specs");
7228        let (actor, client) = cli_actor();
7229        engine
7230            .create_entity(
7231                empty_create_args("specs", "No Anchors"),
7232                actor,
7233                Some(&client),
7234                None,
7235            )
7236            .unwrap();
7237        assert!(
7238            engine
7239                .entity_anchors(&crate::EntityId::new("specs", "no-anchors"))
7240                .is_empty()
7241        );
7242    }
7243
7244    // ---- reserved metadata keys on create --------------------------------
7245
7246    /// A create carrying a reserved identity/discriminator metadata key
7247    /// (`type` / `mem` / `id`) refuses with the same deliberate
7248    /// `READ_ONLY_FIELD` the update path uses — not the incidental
7249    /// `UNKNOWN_METADATA_FIELD` — and the entity is not written.
7250    /// Refusal complement: a create with only declared, non-reserved
7251    /// keys lands exactly as today (covered pervasively by every other
7252    /// create test; the explicit control below re-asserts it beside
7253    /// the refusals).
7254    #[test]
7255    fn create_refuses_reserved_metadata_keys_deliberately() {
7256        let (mut engine, _tmp) = folder_engine("specs");
7257        let (actor, client) = cli_actor();
7258        for reserved in ["type", "mem", "id"] {
7259            let mut args = empty_create_args("specs", "Smuggler");
7260            args.metadata
7261                .insert(reserved.to_string(), "bogus".to_string());
7262            let err = engine
7263                .create_entity(args, actor, Some(&client), None)
7264                .expect_err("reserved key must refuse on create");
7265            assert_eq!(err.code(), "READ_ONLY_FIELD", "key '{reserved}': {err:?}");
7266            assert!(
7267                engine
7268                    .get_entity(&crate::EntityId::new("specs", "smuggler"))
7269                    .is_none(),
7270                "entity must not be written after the '{reserved}' refusal"
7271            );
7272        }
7273        // Control: the same create without the smuggled key lands.
7274        engine
7275            .create_entity(
7276                empty_create_args("specs", "Smuggler"),
7277                actor,
7278                Some(&client),
7279                None,
7280            )
7281            .expect("a clean create is untouched by the reserved-key gate");
7282    }
7283
7284    // ---- cycle family on the create paths --------------------------------
7285
7286    fn create_with_relation(mem: &str, title: &str, rel_type: &str, to: &str) -> CreateEntityArgs {
7287        let mut args = empty_create_args(mem, title);
7288        args.relations = vec![crate::ops::RelateArg {
7289            target: crate::EntityId(to.to_string()),
7290            rel_type: rel_type.to_string(),
7291            description: None,
7292        }];
7293        args
7294    }
7295
7296    /// `create.relations[]` runs the same cycle family as
7297    /// `memstead_relate`: an edge closing a cycle through a promoted
7298    /// stub refuses `RELATIONSHIP_CYCLE` (acyclic rel-type), a
7299    /// self-loop on a listed no-self-loop rel-type refuses
7300    /// identically, and —
7301    /// refusal complement — a non-cycle edge on the acyclic type lands
7302    /// exactly as today.
7303    #[test]
7304    fn create_relations_refuse_cycle_and_self_loop_like_relate() {
7305        let (mut engine, _tmp) = folder_engine("specs");
7306        let (actor, client) = cli_actor();
7307
7308        // A PART_OF→ghost auto-stubs `ghost` with an incoming edge.
7309        engine
7310            .create_entity(
7311                create_with_relation("specs", "Alpha", "PART_OF", "specs--ghost"),
7312                actor,
7313                Some(&client),
7314                None,
7315            )
7316            .unwrap();
7317
7318        // Promoting the stub with a back-edge closes alpha→ghost→alpha.
7319        let err = engine
7320            .create_entity(
7321                create_with_relation("specs", "Ghost", "PART_OF", "specs--alpha"),
7322                actor,
7323                Some(&client),
7324                None,
7325            )
7326            .expect_err("cycle-closing create.relations[] must refuse");
7327        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
7328        // Recovery detail matches the relate path's shape.
7329        let details = err.details();
7330        assert_eq!(details["rel_type"], "PART_OF");
7331        assert!(details["existing_path"].is_array());
7332        assert!(
7333            engine
7334                .get_entity(&crate::EntityId::new("specs", "ghost"))
7335                .is_none_or(|e| e.stub),
7336            "the refused entity must not be written"
7337        );
7338
7339        // Self-loop on a listed no-self-loop rel-type (spec lists USES).
7340        let err = engine
7341            .create_entity(
7342                create_with_relation("specs", "Selfy", "USES", "specs--selfy"),
7343                actor,
7344                Some(&client),
7345                None,
7346            )
7347            .expect_err("self-loop create.relations[] must refuse");
7348        assert_eq!(err.code(), "RELATIONSHIP_CYCLE", "{err:?}");
7349
7350        // Refusal complement: a non-cycle edge on the acyclic type
7351        // lands (fresh chain link, no back-path).
7352        engine
7353            .create_entity(
7354                create_with_relation("specs", "Beta", "PART_OF", "specs--alpha"),
7355                actor,
7356                Some(&client),
7357                None,
7358            )
7359            .expect("a non-cycle PART_OF edge must land as today");
7360    }
7361
7362    /// An intra-batch cycle on an acyclic rel-type refuses the whole
7363    /// batch — the staged state IS the graph state the batch validates
7364    /// against. Refusal complement: an acyclic intra-batch chain lands.
7365    #[test]
7366    fn batch_create_refuses_intra_batch_cycle() {
7367        let (mut engine, _tmp) = folder_engine("specs");
7368        let (actor, client) = cli_actor();
7369
7370        let result = engine
7371            .batch_create(
7372                vec![
7373                    (
7374                        create_with_relation("specs", "Ping", "PART_OF", "specs--pong"),
7375                        None,
7376                    ),
7377                    (
7378                        create_with_relation("specs", "Pong", "PART_OF", "specs--ping"),
7379                        None,
7380                    ),
7381                ],
7382                actor,
7383                Some(&client),
7384                false,
7385            )
7386            .expect("batch returns a result envelope");
7387        assert!(!result.applied, "intra-batch cycle must refuse the batch");
7388        assert!(
7389            result.results.iter().any(|r| r
7390                .error
7391                .as_ref()
7392                .is_some_and(|e| e.code == "RELATIONSHIP_CYCLE")),
7393            "the refusal must carry RELATIONSHIP_CYCLE: {:?}",
7394            result.results
7395        );
7396        assert!(
7397            engine
7398                .get_entity(&crate::EntityId::new("specs", "ping"))
7399                .is_none(),
7400            "nothing lands from a refused batch"
7401        );
7402
7403        // Refusal complement: an acyclic intra-batch chain lands.
7404        let result = engine
7405            .batch_create(
7406                vec![
7407                    (
7408                        create_with_relation("specs", "Chain One", "PART_OF", "specs--chain-two"),
7409                        None,
7410                    ),
7411                    (empty_create_args("specs", "Chain Two"), None),
7412                ],
7413                actor,
7414                Some(&client),
7415                false,
7416            )
7417            .expect("acyclic batch lands");
7418        assert!(result.applied, "{:?}", result.results);
7419        assert_eq!(result.succeeded, 2);
7420    }
7421
7422    /// Refusal complement at depth: a deep-but-acyclic PART_OF chain
7423    /// past the cycle path cap is accepted on the create path — the cap
7424    /// bounds the *reported* path on refusal, never the legality of a
7425    /// long acyclic chain — and one closing edge at the far end still
7426    /// refuses.
7427    #[test]
7428    fn deep_acyclic_chain_near_path_cap_is_accepted() {
7429        let (mut engine, _tmp) = folder_engine("specs");
7430        let (actor, client) = cli_actor();
7431        let depth = crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP + 2;
7432
7433        // link-0 ← link-1 ← … each new entity PART_OF the previous.
7434        engine
7435            .create_entity(
7436                empty_create_args("specs", "Link 0"),
7437                actor,
7438                Some(&client),
7439                None,
7440            )
7441            .unwrap();
7442        for i in 1..depth {
7443            engine
7444                .create_entity(
7445                    create_with_relation(
7446                        "specs",
7447                        &format!("Link {i}"),
7448                        "PART_OF",
7449                        &format!("specs--link-{}", i - 1),
7450                    ),
7451                    actor,
7452                    Some(&client),
7453                    None,
7454                )
7455                .unwrap_or_else(|e| panic!("deep acyclic link {i} must land: {e:?}"));
7456        }
7457
7458        // Closing the loop end-to-end still refuses, with the reported
7459        // path truncated at the cap.
7460        let last = depth - 1;
7461        let err = engine
7462            .update_entity(
7463                {
7464                    let id = crate::EntityId::new("specs", "link-0");
7465                    let hash = engine.get_entity(&id).unwrap().content_hash.clone();
7466                    crate::engine::UpdateEntityArgs {
7467                        anchors: Vec::new(),
7468                        anchors_unset: Vec::new(),
7469                        id,
7470                        expected_hash: Some(hash),
7471                        sections: IndexMap::new(),
7472                        append_sections: IndexMap::new(),
7473                        patch_sections: IndexMap::new(),
7474                        metadata: IndexMap::new(),
7475                        metadata_unset: Vec::new(),
7476                        declare_relations: vec![crate::ops::RelateArg {
7477                            target: crate::EntityId::new("specs", &format!("link-{last}")),
7478                            rel_type: "PART_OF".to_string(),
7479                            description: None,
7480                        }],
7481                        dry_run: false,
7482                        relations_unset: Vec::new(),
7483                    }
7484                },
7485                actor,
7486                Some(&client),
7487                None,
7488            )
7489            .expect_err("closing the deep chain must refuse");
7490        assert_eq!(err.code(), "RELATIONSHIP_CYCLE");
7491        let details = err.details();
7492        assert_eq!(details["path_truncated"], true);
7493        assert_eq!(
7494            details["existing_path"].as_array().unwrap().len(),
7495            crate::engine::mutation::RELATIONSHIP_CYCLE_PATH_CAP
7496        );
7497    }
7498
7499    const FORMAT_MANIFEST: &str = r#"name: formatproof
7500version: 0.1.0
7501description: section-format proof schema
7502when_to_use: format tests
7503types:
7504  - plan
7505relationships:
7506  mode: strict
7507  definitions:
7508    - name: PART_OF
7509      description: hier
7510      default_weight: 1.0
7511    - name: _default
7512      description: fallback
7513      default_weight: 1.0
7514community:
7515  resolution: 1.0
7516  seed: 42
7517"#;
7518
7519    const FORMAT_PLAN_TYPE: &str = r#"name: plan
7520description: a plan with formatted milestones
7521when_to_use: tests
7522sections:
7523  - key: body
7524    heading: Body
7525    required: true
7526    search_weight: 10.0
7527    catch_all: true
7528    write_rules: []
7529  - key: meilensteine
7530    heading: Meilensteine
7531    required: false
7532    search_weight: 5.0
7533    catch_all: false
7534    write_rules: []
7535    content: "(heading(3) list(bullet))+"
7536    item_pattern: '\*\*(?<name>[^*]+)\*\* — (?<datum>\d{4}-\d{2}-\d{2})'
7537    example: |
7538      ### Phase 1
7539      - **Kickoff** — 2026-09-01
7540  - key: notizen
7541    heading: Notizen
7542    required: false
7543    search_weight: 5.0
7544    catch_all: false
7545    write_rules: []
7546    content: "list(bullet)"
7547    format_severity: warn
7548metadata_fields: []
7549title_weight: 100.0
7550text_fields:
7551  - body
7552hierarchy_relationship: PART_OF
7553no_self_loop_relationships: []
7554updatable_fields:
7555  - title
7556  - body
7557  - meilensteine
7558  - notizen
7559health_required_fields:
7560  - body
7561staleness_threshold_days: 90
7562write_rules: []
7563"#;
7564
7565    fn format_engine(tmp: &TempDir) -> Engine {
7566        engine_with_proof_schema(
7567            tmp,
7568            "formatproof",
7569            FORMAT_MANIFEST,
7570            &[("plan", FORMAT_PLAN_TYPE)],
7571        )
7572    }
7573
7574    fn plan_create_args(
7575        title: &str,
7576        meilensteine: Option<&str>,
7577        notizen: Option<&str>,
7578    ) -> CreateEntityArgs {
7579        let mut sections = IndexMap::new();
7580        sections.insert("body".to_string(), "a plan body.".to_string());
7581        if let Some(m) = meilensteine {
7582            sections.insert("meilensteine".to_string(), m.to_string());
7583        }
7584        if let Some(n) = notizen {
7585            sections.insert("notizen".to_string(), n.to_string());
7586        }
7587        CreateEntityArgs {
7588            anchors: Vec::new(),
7589            mem: "proof".to_string(),
7590            title: title.to_string(),
7591            entity_type: "plan".to_string(),
7592            sections,
7593            metadata: IndexMap::new(),
7594            relations: vec![],
7595            dry_run: false,
7596        }
7597    }
7598
7599    /// Block-tier format enforcement on create: a nonconforming
7600    /// section refuses with the format code and the echoed example;
7601    /// the conforming write passes; a warn-tier section never refuses.
7602    #[test]
7603    fn create_enforces_declared_section_format() {
7604        let tmp = TempDir::new().unwrap();
7605        let mut engine = format_engine(&tmp);
7606        let (actor, client) = cli_actor();
7607
7608        let err = engine
7609            .create_entity(
7610                plan_create_args("Plan A", Some("### Phase 1\n\nprose statt liste\n"), None),
7611                actor,
7612                Some(&client),
7613                None,
7614            )
7615            .unwrap_err();
7616        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7617        let details = err.details();
7618        assert_eq!(details["section"], "meilensteine");
7619        assert!(
7620            details["example"].as_str().unwrap().contains("Kickoff"),
7621            "the conforming example is echoed: {details}"
7622        );
7623        assert_eq!(details["expected_next"][0], "list(bullet)");
7624
7625        // Item-pattern violation gets its own code.
7626        let err = engine
7627            .create_entity(
7628                plan_create_args("Plan B", Some("### Phase 1\n- kein format\n"), None),
7629                actor,
7630                Some(&client),
7631                None,
7632            )
7633            .unwrap_err();
7634        assert_eq!(err.code(), "SECTION_ITEM_PATTERN_MISMATCH");
7635
7636        // Conforming write passes.
7637        engine
7638            .create_entity(
7639                plan_create_args(
7640                    "Plan C",
7641                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7642                    None,
7643                ),
7644                actor,
7645                Some(&client),
7646                None,
7647            )
7648            .unwrap();
7649
7650        // Warn-tier section: nonconforming content commits.
7651        let outcome = engine
7652            .create_entity(
7653                plan_create_args(
7654                    "Plan D",
7655                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7656                    Some("kein listenpunkt\n"),
7657                ),
7658                actor,
7659                Some(&client),
7660                None,
7661            )
7662            .unwrap();
7663        assert!(!outcome.write_id.is_empty(), "warn tier never refuses");
7664
7665        // Absent-as-empty: omitting the block-tier section refuses
7666        // exactly like an explicit empty body — the generator renders
7667        // the empty heading either way, and write path and health
7668        // must agree about that on-disk state. `+` does not admit the
7669        // empty sequence, so the section is effectively required.
7670        let err = engine
7671            .create_entity(
7672                plan_create_args("Plan E", None, None),
7673                actor,
7674                Some(&client),
7675                None,
7676            )
7677            .unwrap_err();
7678        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7679    }
7680
7681    /// Composed-body rule on update: an append whose delta is
7682    /// harmless refuses when the COMPOSED body violates; the
7683    /// conforming replacement passes.
7684    #[test]
7685    fn update_judges_format_on_composed_body() {
7686        let tmp = TempDir::new().unwrap();
7687        let mut engine = format_engine(&tmp);
7688        let (actor, client) = cli_actor();
7689        let created = engine
7690            .create_entity(
7691                plan_create_args(
7692                    "Plan A",
7693                    Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7694                    None,
7695                ),
7696                actor,
7697                Some(&client),
7698                None,
7699            )
7700            .unwrap();
7701
7702        // Append a trailing paragraph: the delta alone is legal
7703        // markdown, the composed body no longer matches the shape.
7704        let current = engine.get_entity(&created.id).unwrap().content_hash.clone();
7705        let mut append = IndexMap::new();
7706        append.insert(
7707            "meilensteine".to_string(),
7708            "\n\nnachtrag als absatz\n".to_string(),
7709        );
7710        let err = engine
7711            .update_entity(
7712                crate::engine::UpdateEntityArgs {
7713                    anchors: Vec::new(),
7714                    id: created.id.clone(),
7715                    expected_hash: Some(current.clone()),
7716                    sections: IndexMap::new(),
7717                    append_sections: append,
7718                    patch_sections: IndexMap::new(),
7719                    metadata: IndexMap::new(),
7720                    metadata_unset: Vec::new(),
7721                    declare_relations: vec![],
7722                    dry_run: false,
7723                    relations_unset: Vec::new(),
7724                    anchors_unset: Vec::new(),
7725                },
7726                actor,
7727                Some(&client),
7728                None,
7729            )
7730            .unwrap_err();
7731        assert_eq!(err.code(), "SECTION_CONTENT_MISMATCH");
7732
7733        // A conforming append (another phase) passes.
7734        let mut append = IndexMap::new();
7735        append.insert(
7736            "meilensteine".to_string(),
7737            "\n\n### Phase 2\n- **Go-Live** — 2026-10-01\n".to_string(),
7738        );
7739        engine
7740            .update_entity(
7741                crate::engine::UpdateEntityArgs {
7742                    anchors: Vec::new(),
7743                    id: created.id.clone(),
7744                    expected_hash: Some(current),
7745                    sections: IndexMap::new(),
7746                    append_sections: append,
7747                    patch_sections: IndexMap::new(),
7748                    metadata: IndexMap::new(),
7749                    metadata_unset: Vec::new(),
7750                    declare_relations: vec![],
7751                    dry_run: false,
7752                    relations_unset: Vec::new(),
7753                    anchors_unset: Vec::new(),
7754                },
7755                actor,
7756                Some(&client),
7757                None,
7758            )
7759            .unwrap();
7760    }
7761
7762    /// Reserved-heading extension (criterion 4): `^# ` now refuses in
7763    /// any section body, exactly like `^## ` — free-form sections
7764    /// included, via the byte-class line guard.
7765    #[test]
7766    fn embedded_h1_refuses_in_any_section() {
7767        let tmp = TempDir::new().unwrap();
7768        let mut engine = format_engine(&tmp);
7769        let (actor, client) = cli_actor();
7770        let mut args = plan_create_args(
7771            "Plan H",
7772            Some("### Phase 1\n- **Kickoff** — 2026-09-01\n"),
7773            None,
7774        );
7775        args.sections.insert(
7776            "body".to_string(),
7777            "intro\n# Injected Title\ntail".to_string(),
7778        );
7779        let err = engine
7780            .create_entity(args, actor, Some(&client), None)
7781            .unwrap_err();
7782        assert_eq!(err.code(), "SECTION_CONTENT_INVALID");
7783    }
7784}