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