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