Skip to main content

memstead_base/engine/mutation/
create.rs

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