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