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, today_iso, unknown_type_error,
54    validate_relation_target_grammar,
55};
56
57impl Engine {
58    /// Create a new entity in `args.mem`. Six concerns wired here
59    /// in one shape regardless of which backend serves the mount:
60    ///
61    /// 1. **Capability gating** — rejects mounts with `ReadOnly`
62    ///    capability before reaching the backend.
63    /// 2. **Validator pipeline** — `validate_section_keys` +
64    ///    `parse_metadata_value` enforce the pinned schema's strictness;
65    ///    typed `ValidationError` lifts to `EngineError::Validation`.
66    /// 3. **Provenance** — a `Provenance` record routes through
67    ///    `backend.append_provenance` (folder writes JSONL, git-branch
68    ///    no-ops since the commit subject + trailers carry the same
69    ///    fields).
70    /// 4. **Write + commit atomicity** — `backend.write_entity` then
71    ///    `backend.commit` with the canonical `memstead: create <id>`
72    ///    subject so the git-branch backend's `read_provenance` can
73    ///    recover the kind.
74    /// 5. **Store update** — re-parse the freshly-generated markdown
75    ///    so the in-memory `Store` mirrors disk (including
76    ///    generator-determined `content_hash`).
77    /// 6. **Error envelope** — `BackendError::Sealed` lifts via the
78    ///    `Backend` variant so MCP callers see the typed payload
79    ///    intact; `HashMismatch` propagates likewise.
80    pub fn create_entity(
81        &mut self,
82        args: CreateEntityArgs,
83        actor: Actor,
84        client: Option<&ClientId>,
85        note: Option<&str>,
86    ) -> Result<CreateEntityOutcome, EngineError> {
87        let mut args = args;
88        // Canonicalise rel_type on every inline relation — same contract
89        // as `relate_entity`: input is case-insensitive, storage and
90        // response are UPPER_SNAKE_CASE. Syntax errors fall through to
91        // the schema check, which surfaces them as INVALID_REL_TYPE.
92        for rel in &mut args.relations {
93            if let Ok(canonical) = crate::entity::id::validate_rel_type(&rel.rel_type) {
94                rel.rel_type = canonical;
95            }
96        }
97
98        // Trim surrounding whitespace from the title before slug
99        // derivation + storage. Internal whitespace is preserved.
100        // Fully-whitespace titles collapse to empty and fall through to
101        // the validator below (which already refuses empty). Without
102        // trimming, a caller-supplied
103        // `"   Foo   "` renders with leading/trailing spaces despite the
104        // slug being correct. We emit `TITLE_TRIMMED` whenever trimming
105        // changed the value so the audit trail records the drift.
106        let mut title_trimmed_warning: Option<crate::ops::WarningHint> = None;
107        let trimmed_title = args.title.trim();
108        if trimmed_title.len() != args.title.len() {
109            title_trimmed_warning = Some(crate::ops::WarningHint::TitleTrimmed {
110                original: args.title.clone(),
111                trimmed: trimmed_title.to_string(),
112            });
113            args.title = trimmed_title.to_string();
114        }
115
116        // 1. Resolve the mount and gate on capability.
117        let mount_idx = self
118            .mounts
119            .iter()
120            .position(|m| m.mount.mem == args.mem)
121            .ok_or_else(|| EngineError::UnknownMem(args.mem.clone()))?;
122        if self.mounts[mount_idx].mount.capability != MountCapability::Write {
123            return Err(EngineError::ReadOnlyMount(args.mem));
124        }
125
126        // 1a. Reload-before-operation. Probe the mem ref and reload
127        //     if a sibling writer advanced it past our cached head, so
128        //     the duplicate-id check below and the eventual commit both
129        //     run against current truth. Any `MemReloaded` warning
130        //     rides the outcome's `warnings` (merged at the accumulator
131        //     below). This is what makes a create at an id a sibling
132        //     just created refuse as already-exists rather than
133        //     silently rebasing onto an unobserved commit.
134        let mut drift_warnings = self.reload_if_stale(Some(&args.mem));
135
136        // 2. Resolve schema + type. The schema map is populated for
137        //    every mount during `from_mounts`, so the lookup is total.
138        let schema = self
139            .schemas
140            .get(&args.mem)
141            .expect("schema present for every registered mount");
142        let type_def = schema
143            .get_type(&args.entity_type)
144            .ok_or_else(|| unknown_type_error(schema, &args.entity_type))?;
145
146        // 3. Pre-write validators: section keys and metadata values.
147        validate_section_keys(args.sections.keys().map(String::as_str), type_def.as_ref())?;
148        // Refuse section content with embedded `^## ` headings — the
149        // compose-then-reparse pipeline would split the value at the
150        // heading and silently move the trailing content into another
151        // section.
152        validate_section_content(args.sections.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
153
154        // 4. Slug + id; reject duplicates against the in-memory store.
155        //    Stub adoption: a pre-existing stub at the same id is
156        //    *not* a duplicate — the create promotes the stub to a
157        //    real entity while preserving its incoming edges (store.
158        //    upsert leaves in_edges in place). Mirrors full's
159        //    `if let Some(existing) = store.get(&id) && !existing.stub`.
160        let slug = validate_and_derive_slug(&args.title)?;
161        let id = EntityId::new(&args.mem, &slug);
162        crate::entity::id::enforce_id_length(id.as_ref())?;
163        if let Some(existing) = self.store.get(&id)
164            && !existing.stub
165        {
166            return Err(EngineError::AlreadyExists { id: id.to_string() });
167        }
168        let file_path = format!("{slug}.md");
169
170        // 5. Build metadata. `type` is seeded so the generator emits
171        //    the canonical frontmatter; caller-provided overrides go
172        //    through `parse_metadata_value` for enum / type checks.
173        let mut metadata: IndexMap<String, MetadataValue> = IndexMap::new();
174        metadata.insert(
175            "type".to_string(),
176            MetadataValue::String(args.entity_type.clone()),
177        );
178        for (k, v) in &args.metadata {
179            let parsed = parse_metadata_value(k.as_str(), v.as_str(), type_def.as_ref())?;
180            metadata.insert(k.clone(), parsed);
181        }
182
183        // 5a. Engine-managed timestamps: schema-declared `init_timestamp`
184        //     and `auto_timestamp` fields take the engine value
185        //     regardless of any caller-supplied override. Symmetric with
186        //     the update path's `auto_timestamp` loop — both flags carry
187        //     a schema-promised meaning the user cannot override.
188        //     `init_timestamp` is create-only (set once, then stable);
189        //     `auto_timestamp` re-stamps on every update.
190        let today = today_iso();
191        // Accumulate `IGNORED_READONLY_FIELD` warnings: when the caller
192        // supplied a value for an auto-managed field, the engine value
193        // overwrites it below — surface that the input was discarded
194        // rather than swallowing it silently (the update path refuses
195        // these keys with `READ_ONLY_FIELD`; create's posture is
196        // stamp-and-proceed, so it warns). Built here, merged into the
197        // response `warnings` accumulator once that exists.
198        let mut ignored_readonly: Vec<WarningHint> = Vec::new();
199        for field_def in &type_def.metadata_fields {
200            if field_def.init_timestamp || field_def.auto_timestamp {
201                if let Some(supplied) = args.metadata.get(field_def.key.as_str()) {
202                    ignored_readonly.push(WarningHint::IgnoredReadonlyField {
203                        field: field_def.key.clone(),
204                        supplied: supplied.clone(),
205                    });
206                }
207                metadata.insert(field_def.key.clone(), MetadataValue::String(today.clone()));
208            }
209        }
210
211        // 6. Refuse — not warn — when required sections are absent or
212        //    empty. Pre-fix this branch emitted a `WarningHint` per
213        //    missing section and let the entity land with empty
214        //    placeholders; the resulting on-disk state then failed the
215        //    install-time strict validator, breaking the export-then-
216        //    install round-trip. The refusal carries every missing
217        //    section plus the type-level `type_guidance` map so the
218        //    agent recovers in a single round-trip via re-call with
219        //    the missing content filled in. Iterative authoring stays
220        //    available — the agent creates the entity with whatever
221        //    sections they have, then fills in the rest via
222        //    `memstead_update` (which retains its permissive posture on
223        //    `MISSING_REQUIRED_SECTION`).
224        let missing_sections = missing_required_sections(type_def.as_ref(), &args.sections);
225        if !missing_sections.is_empty() {
226            let mut type_guidance: BTreeMap<String, Vec<String>> = BTreeMap::new();
227            if missing_sections
228                .iter()
229                .any(|m| m.entity_type == type_def.name)
230            {
231                type_guidance.insert(type_def.name.clone(), type_def.write_rules.clone());
232            }
233            return Err(EngineError::MissingRequiredSection {
234                entity_type: type_def.name.clone(),
235                missing_count: missing_sections.len(),
236                sections: missing_sections,
237                type_guidance,
238            });
239        }
240
241        // 6a. Parallel for metadata fields: refuse on the first
242        //     missing required field the schema does not auto-fill.
243        //     Same trust-boundary reasoning as the sections case —
244        //     pre-fix the generator silently wrote today's-date / ""
245        //     placeholders that the strict validator at install time
246        //     can refuse. The agent fixes one field per round-trip
247        //     (schema-declaration order); the recovery shape mirrors
248        //     the existing `RequiredFieldUnset` envelope on the update
249        //     path so a single decoder handles both surfaces.
250        let missing_fields = missing_required_fields(type_def.as_ref(), &args.metadata);
251        if !missing_fields.is_empty() {
252            // Surface the
253            // full accumulator (`details.missing[]`) so the agent
254            // fixes every required-no-default field unset in one
255            // retry. The singular `field` / `field_description` /
256            // `enum_values` echo the first entry for back-compat
257            // with consumers reading the singular shape.
258            let first = missing_fields[0].clone();
259            return Err(EngineError::RequiredFieldUnset {
260                field: first.key,
261                entity_type: first.entity_type,
262                field_description: Some(first.description),
263                enum_values: first.enum_values,
264                type_write_rules: type_def.write_rules.clone(),
265                // Create path — the caller never
266                // supplied this field. Display / prose_render flip to
267                // "not provided" wording so the prose matches the
268                // semantic. Recovery is unchanged; the typed code
269                // stays `REQUIRED_FIELD_UNSET`.
270                on_create: true,
271                missing: missing_fields,
272            });
273        }
274
275        let mut warnings: Vec<WarningHint> = Vec::new();
276
277        // Reload-before-operation drift notice (probed at the top, after
278        // the capability gate). Surfaced first so the agent sees the
279        // world moved before reading the rest of the outcome.
280        warnings.append(&mut drift_warnings);
281
282        // Auto-managed fields the caller tried to set (computed during
283        // the stamp loop above) — the supplied values were discarded.
284        warnings.append(&mut ignored_readonly);
285
286        // Surface the title-trim drift (computed pre-validation) so the
287        // audit trail records what the caller sent.
288        if let Some(w) = title_trimmed_warning.take() {
289            warnings.push(w);
290        }
291
292        // 6c. Build `type_guidance` map for the response — one entry
293        //     per distinct entity_type referenced by warnings carrying
294        //     entity-type context (currently
295        //     `UndeclaredRelationshipOpen` etc). Empty when no such
296        //     warnings fire — the section/field cases now refuse
297        //     above. The stable empty shape always ships so callers
298        //     don't branch on field presence.
299        let type_guidance = build_type_guidance(&warnings, type_def.as_ref());
300
301        // 6b. Validate inline relationship inputs through the same
302        //     gates `memstead_relate` runs (Item 02): target-id grammar,
303        //     rel-type vocabulary, schema shape. Pre-fix the create
304        //     path ran only the rel-type check, so an agent could
305        //     sneak a malformed target id (auto-stub at
306        //     `bad@chars$here`) or a shape-violating
307        //     `(rel_type, source_type, target_type)` triple through
308        //     `memstead_create.relations[]` even though `memstead_relate`
309        //     rejected the same input. Strict-mode schemas reject
310        //     unknown rel-types with `INVALID_REL_TYPE`; open-mode
311        //     schemas admit them and surface a typed
312        //     `UndeclaredRelationshipOpen` warning. Stub-as-source
313        //     is impossible here — the source is the newly-created
314        //     entity, always real-by-construction.
315        for rel in &args.relations {
316            validate_relation_target_grammar(&rel.to)?;
317            let target_mem = rel.to.mem().to_string();
318            // Cross-mem policy gate. The funnel
319            // sits ahead of the rel-type / shape checks so the policy
320            // refusal is identical in shape and ordering to
321            // `memstead_relate` and `memstead_update.declare_relations`.
322            super::validate_cross_mem_add_policy(self, &args.mem, &target_mem)?;
323            // Target-type lookup mirrors the relate path: `None` for
324            // not-yet-present targets so the target gate admits the
325            // stub-bound case. The cross-mem router below consults
326            // it for both intra-mem shape and cross-mem-different
327            // shape checks.
328            let target_type = self
329                .store
330                .get(&rel.to)
331                .map(|e| e.entity_type.clone())
332                .filter(|t| !t.is_empty());
333            match route_edge_validation(
334                self,
335                &rel.rel_type,
336                args.entity_type.as_str(),
337                target_type.as_deref(),
338                &args.mem,
339                &target_mem,
340                &id,
341                &rel.to,
342                /* check_shape = */ true,
343            )? {
344                EdgeRouteOutcome::Ok => {}
345                EdgeRouteOutcome::OpenModeWarning(w) => warnings.push(*w),
346            }
347            // Per-edge description posture. Normalise first so empty
348            // strings collapse to `None` before the gate.
349            let normalised = normalise_description(rel.description.as_deref());
350            super::validate_description_posture(
351                self,
352                &rel.rel_type,
353                normalised.as_deref(),
354                &args.mem,
355                &target_mem,
356                &id,
357                &rel.to,
358            )?;
359            // Explicit inline-relations path is an
360            // explicit-author boundary — gate on the rel-type's
361            // `manual_authoring` posture.
362            super::validate_manual_authoring_posture(self, &rel.rel_type, &args.mem, &id, &rel.to)?;
363        }
364
365        // 7. Synthesise the in-memory entity for the generator. The
366        //    `content_hash` and `heading_spans` are derived; left
367        //    blank because we re-parse the generated bytes below.
368        //    Inline relations land in `relationships` so the
369        //    generator emits them and the post-parse re-ingest
370        //    rebuilds the edges in the store.
371        let relationships: Vec<Relationship> = args
372            .relations
373            .iter()
374            .map(|r| Relationship {
375                rel_type: r.rel_type.clone(),
376                target: r.to.clone(),
377                description: normalise_description(r.description.as_deref()),
378            })
379            .collect();
380        // Pre-compute the `relations_declared` outcome echo. Read
381        // `target_was_stubbed` against the pre-mutation store state
382        // (the post-parse `push_entities_into_store` step will
383        // auto-stub absent targets). Shape matches
384        // `memstead_update.relations_declared` so callers see a uniform
385        // wire shape across the two tools.
386        let relations_declared: Vec<crate::engine::outcomes::RelationDeclared> = args
387            .relations
388            .iter()
389            .map(|r| crate::engine::outcomes::RelationDeclared {
390                rel_type: r.rel_type.clone(),
391                target: r.to.clone(),
392                target_was_stubbed: !self.store.contains(&r.to),
393            })
394            .collect();
395        let mut entity_for_render = Entity {
396            id: id.clone(),
397            title: args.title.clone(),
398            entity_type: args.entity_type.clone(),
399            mem: args.mem.clone(),
400            file_path: file_path.clone(),
401            metadata,
402            sections: args.sections,
403            relationships,
404            content_hash: String::new(),
405            stub: false,
406            stub_kind: None,
407            heading_spans: HashMap::new(),
408        };
409        // Alias-synthesis pass: for schemas declaring
410        // `alias_target_rel_type`, append engine-emitted relations of
411        // that rel-type for every body wiki-link not already backed.
412        // Cross-mem refusal aborts the create — no partial state.
413        // Schemas without the pointer fall through unchanged and the
414        // validator below catches the missing relations.
415        //
416        // The returned `Vec<Relationship>` is the per-call set of
417        // relations the pass just emitted (in body iteration order).
418        // It feeds the `InlineWikiLinkAutoStubbed` emission below —
419        // using the post-mutation `entity.relationships` as the source
420        // via `parse_markdown` filters out the body-link targets
421        // because the parser-side `relationships`-coverage filter has
422        // already absorbed them.
423        let empty_prev_targets = std::collections::HashSet::new();
424        let (synthesised_relations, self_link_ignored) =
425            super::synthesise_alias_relations(self, &empty_prev_targets, &mut entity_for_render)?;
426        if self_link_ignored {
427            warnings.push(WarningHint::SelfLinkIgnored { id: id.clone() });
428        }
429
430        // Alias-existence invariant: every body wiki-link must be
431        // backed by an entry in `entity.relationships` (the auto-managed
432        // `## Relationships` section). Runs unconditionally on every
433        // Write-Mem create. See [`scan_wikilinks_without_relation`].
434        let missing = super::scan_wikilinks_without_relation(&entity_for_render)?;
435        if !missing.is_empty() {
436            return Err(EngineError::WikiLinkWithoutRelation {
437                from_id: id.to_string(),
438                missing: missing
439                    .into_iter()
440                    .map(|(section_key, target)| crate::engine::MissingWikiLink {
441                        section_key,
442                        target_id: target.to_string(),
443                    })
444                    .collect(),
445            });
446        }
447
448        let markdown = generate_markdown(&entity_for_render, type_def.as_ref());
449
450        // 7a. Inline `[[wiki-link]]` patterns in section bodies that
451        //     point at non-existent targets get auto-stubbed by the
452        //     loader on re-ingest. Surface the would-be stubs as a
453        //     warning so prose-induced ghosts are reviewable. Mirrors
454        //     `memstead_relate`'s `AUTO_STUB_CREATED` observation
455        //     discipline.
456        //
457        //     The input set is the relations the alias-synthesis pass
458        //     emitted on this call — NOT a re-parse of the generated
459        //     markdown. `parse_markdown` filters its `inline_links`
460        //     against the entity's `relationships` vec (which the
461        //     synthesis pass has already appended to), so the
462        //     pre-fix path saw `inline_links: []` and never fired
463        //     the warning. The synthesised vec is the authoritative
464        //     per-call source.
465        let auto_stubbed: Vec<EntityId> = synthesised_relations
466            .iter()
467            .filter_map(|rel| {
468                if !self.store.contains(&rel.target) {
469                    Some(rel.target.clone())
470                } else {
471                    None
472                }
473            })
474            .collect();
475        if !auto_stubbed.is_empty() {
476            warnings.push(WarningHint::InlineWikiLinkAutoStubbed {
477                from: id.clone(),
478                stubs: auto_stubbed,
479            });
480        }
481
482        // 7b. Dry-run: compute prospective hash from the in-memory
483        //     entity and return without touching disk, store, or
484        //     edges. Mirrors full's `CreateArgs.dry_run` semantics —
485        //     `content_hash` carries the prospective hash since
486        //     there's no current to differentiate from. `commit_sha`
487        //     is empty. Stub creation is also skipped (no
488        //     in-memory side effects).
489        if args.dry_run {
490            let prospective_hash = crate::entity::parser::compute_hash(&markdown);
491            // `created_date` from the in-memory entity (the
492            // metadata-construction loop already set the
493            // init_timestamp default to `today_iso`-equivalent).
494            let created_date = entity_for_render
495                .metadata
496                .get("created_date")
497                .map(|v| v.to_frontmatter_string())
498                .unwrap_or_default();
499            // Full's dry_run computes incoming from the existing
500            // store state (the refs that *would* be adopted if a
501            // stub exists at this id). Read before any mutation.
502            let incoming = project_incoming(self.store.incoming(&id));
503            let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
504            return Ok(CreateEntityOutcome {
505                id,
506                title: args.title,
507                mem: args.mem,
508                file_path,
509                content_hash: prospective_hash,
510                commit_sha: String::new(),
511                created_date,
512                warnings,
513                type_guidance,
514                incoming_count,
515                incoming,
516                relations_declared: relations_declared.clone(),
517            });
518        }
519
520        // 8. Write + commit through the backend. The commit subject
521        //    is `memstead: create <id>` so the git-branch backend's
522        //    `read_provenance` recovers the kind via the verb. The
523        //    folder backend's commit ignores the message; the
524        //    canonical form is harmless there.
525        let backend = self.mounts[mount_idx].backend.as_ref();
526        backend.write_entity(Path::new(&file_path), markdown.as_bytes())?;
527        let commit_subject = format!("memstead: create {id}");
528        let ctx = CommitContext {
529            actor,
530            client: client.cloned(),
531            tool: Some("create_entity"),
532            note: note.map(String::from),
533            logical_operation_id: None,
534            entity_ids: None,
535        };
536        let commit_sha = backend.commit(&commit_subject, &ctx)?;
537
538        // 9. Append provenance. Folder writes a JSONL line; git-branch
539        //    no-ops (the commit object already carries the data).
540        backend.append_provenance(&Provenance::new(
541            std::time::SystemTime::now(),
542            ProvenanceKind::Create,
543            Some(id.to_string()),
544            actor,
545            client.cloned(),
546            note.map(String::from),
547        ))?;
548
549        // Self-write bookkeeping: jump `last_known_head` to the SHA
550        // we just produced so the next read doesn't surface
551        // `MEM_RELOADED` for our own commit.
552        self.record_self_write(mount_idx, &commit_sha);
553
554        // 10. Update the in-memory store via re-parse so the store
555        //     mirrors the on-disk shape (content_hash, heading_spans).
556        let parse_result = parse_markdown(&markdown, &file_path, type_def.as_ref(), &args.mem)
557            .map_err(|e| EngineError::ParseAfterWrite(e.to_string()))?;
558        let content_hash = parse_result.entity.content_hash.clone();
559
560        // Extract `created_date` from the parsed entity's metadata
561        // before pushing into the store (after push, the entity is
562        // borrowed by the store and re-fetching costs a lookup).
563        // The default schema's auto-timestamp fills `created_date`
564        // with today's ISO date; the field is empty for schemas
565        // that don't declare it.
566        let created_date = parse_result
567            .entity
568            .metadata
569            .get("created_date")
570            .map(|v| v.to_frontmatter_string())
571            .unwrap_or_default();
572
573        let fallback = engine_fallback_type();
574        push_entities_into_store(&mut self.store, vec![parse_result], fallback.as_ref(), None);
575        crate::entity::store_builder::remap_alias_target_edge_sources(
576            &mut self.store,
577            &self.schemas,
578        );
579
580        // Materialise stubs for any inline-relation targets that
581        // weren't already in the store. Mirrors the relate path's
582        // ensure_target — full's create relies on the
583        // loader stubbing unresolved targets, but the unified
584        // store doesn't auto-stub on push, so the engine does it
585        // explicitly. Skipped when no relations were declared
586        // (the args.relations vec is empty).
587        for rel in &args.relations {
588            if !self.store.contains(&rel.to) {
589                self.store.upsert(
590                    rel.to.clone(),
591                    make_stub(&rel.to, crate::entity::StubKind::ForwardReference),
592                );
593            }
594        }
595
596        self.invalidate_communities();
597        self.invalidate_search_indexes();
598
599        // Stub-adoption visibility: project the incoming edges that
600        // survived the upsert. Empty for a fresh create; populated
601        // when a pre-existing stub at this id had referrers.
602        let incoming = project_incoming(self.store.incoming(&id));
603        let incoming_count = (!incoming.is_empty()).then_some(incoming.len());
604
605        // `require_notes` provenance nudge — single engine-level
606        // enforcement point (see `Engine::note_missing_warning`). Only
607        // reached on the real-write path (commit landed); the dry-run
608        // early return above never demands a note.
609        if let Some(w) = self.note_missing_warning("create_entity", note) {
610            warnings.push(w);
611        }
612
613        Ok(CreateEntityOutcome {
614            id,
615            title: args.title,
616            mem: args.mem,
617            file_path,
618            content_hash,
619            commit_sha,
620            created_date,
621            warnings,
622            type_guidance,
623            incoming_count,
624            incoming,
625            relations_declared,
626        })
627    }
628
629    /// CommitContext-bundling wrapper around [`Self::create_entity`].
630    /// Destructures `CommitContext` into `(actor, client, note)`
631    /// and delegates.
632    pub fn create_entity_with_ctx(
633        &mut self,
634        args: CreateEntityArgs,
635        ctx: &CommitContext<'_>,
636    ) -> Result<CreateEntityOutcome, EngineError> {
637        self.create_entity(args, ctx.actor, ctx.client.as_ref(), ctx.note.as_deref())
638    }
639}
640
641#[cfg(test)]
642mod tests {
643
644    use indexmap::IndexMap;
645    use tempfile::TempDir;
646
647    use crate::backend::MemBackend;
648    use crate::engine::test_helpers::*;
649    use crate::engine::{
650        CreateEntityArgs, CreateEntityOutcome, Engine, EngineError, RelateEntityArgs,
651    };
652    use crate::ops::WarningHint;
653    use crate::storage::{ArchiveBackend, FilesystemMemWriter};
654
655    #[test]
656    fn create_entity_writes_through_folder_backend_and_updates_store() {
657        let tmp = TempDir::new().unwrap();
658        let mem_dir = tmp.path().to_path_buf();
659        let writer = FilesystemMemWriter::new(mem_dir.clone());
660        let mut engine = Engine::from_mounts(vec![(
661            folder_mount("specs", mem_dir.clone()),
662            Box::new(writer) as Box<dyn MemBackend>,
663        )])
664        .unwrap();
665        let (actor, client) = cli_actor();
666
667        let outcome = engine
668            .create_entity(
669                empty_create_args("specs", "Hello World"),
670                actor,
671                Some(&client),
672                Some("first draft"),
673            )
674            .unwrap();
675
676        // Outcome reports a real id, real file path, real hash.
677        assert_eq!(outcome.id.to_string(), "specs--hello-world");
678        assert_eq!(outcome.file_path, "hello-world.md");
679        assert!(!outcome.content_hash.is_empty());
680
681        // Store has the new entity.
682        let entity = engine
683            .get_entity(&crate::EntityId::new("specs", "hello-world"))
684            .expect("entity must be in the store after create");
685        assert_eq!(entity.title, "Hello World");
686        assert_eq!(entity.entity_type, "spec");
687        assert_eq!(entity.content_hash, outcome.content_hash);
688
689        // On-disk markdown exists at the expected path.
690        let on_disk = std::fs::read_to_string(mem_dir.join("hello-world.md")).unwrap();
691        assert!(on_disk.contains("# Hello World"));
692        assert!(on_disk.contains("type: spec"));
693
694        // Provenance log has the create record.
695        let log_path = mem_dir.join(".memstead").join("changes.jsonl");
696        let log = std::fs::read_to_string(&log_path).unwrap();
697        assert!(log.contains("\"kind\":\"create\""));
698        assert!(log.contains("\"entity\":\"specs--hello-world\""));
699        assert!(log.contains("\"actor\":\"cli\""));
700        assert!(log.contains("\"note\":\"first draft\""));
701    }
702
703    /// Supplying a
704    /// value for an auto-managed field (`created_date`) on create no
705    /// longer silently discards it — the response carries an
706    /// `IGNORED_READONLY_FIELD` warning, and the stored value is the
707    /// engine-stamped one, not the supplied `2020-01-01`.
708    #[test]
709    fn create_entity_warns_on_supplied_auto_managed_field() {
710        let tmp = TempDir::new().unwrap();
711        let mem_dir = tmp.path().to_path_buf();
712        let writer = FilesystemMemWriter::new(mem_dir.clone());
713        let mut engine = Engine::from_mounts(vec![(
714            folder_mount("specs", mem_dir),
715            Box::new(writer) as Box<dyn MemBackend>,
716        )])
717        .unwrap();
718        let (actor, client) = cli_actor();
719
720        let mut args = empty_create_args("specs", "Dated Entity");
721        args.metadata
722            .insert("created_date".to_string(), "2020-01-01".to_string());
723
724        let outcome = engine
725            .create_entity(args, actor, Some(&client), None)
726            .unwrap();
727
728        let warned = outcome.warnings.iter().any(|w| {
729            w.code() == "IGNORED_READONLY_FIELD"
730                && matches!(w, WarningHint::IgnoredReadonlyField { field, supplied }
731                    if field == "created_date" && supplied == "2020-01-01")
732        });
733        assert!(
734            warned,
735            "expected IGNORED_READONLY_FIELD; got {:?}",
736            outcome.warnings
737        );
738
739        // The engine value was stamped, not the supplied 2020 date.
740        assert_ne!(outcome.created_date, "2020-01-01");
741    }
742
743    /// Complement: a create with no auto-managed field supplied emits no
744    /// `IGNORED_READONLY_FIELD` warning.
745    #[test]
746    fn create_entity_no_warning_when_auto_managed_field_absent() {
747        let tmp = TempDir::new().unwrap();
748        let mem_dir = tmp.path().to_path_buf();
749        let writer = FilesystemMemWriter::new(mem_dir.clone());
750        let mut engine = Engine::from_mounts(vec![(
751            folder_mount("specs", mem_dir),
752            Box::new(writer) as Box<dyn MemBackend>,
753        )])
754        .unwrap();
755        let (actor, client) = cli_actor();
756
757        let outcome = engine
758            .create_entity(
759                empty_create_args("specs", "Plain Entity"),
760                actor,
761                Some(&client),
762                None,
763            )
764            .unwrap();
765        assert!(
766            !outcome
767                .warnings
768                .iter()
769                .any(|w| w.code() == "IGNORED_READONLY_FIELD"),
770            "no auto-managed field supplied — no warning expected; got {:?}",
771            outcome.warnings
772        );
773    }
774
775    #[test]
776    fn create_entity_returns_commit_sha_title_mem_on_real_write() {
777        let tmp = TempDir::new().unwrap();
778        let mem_dir = tmp.path().to_path_buf();
779        let writer = FilesystemMemWriter::new(mem_dir.clone());
780        let mut engine = Engine::from_mounts(vec![(
781            folder_mount("specs", mem_dir),
782            Box::new(writer) as Box<dyn MemBackend>,
783        )])
784        .unwrap();
785        let (actor, client) = cli_actor();
786
787        let outcome = engine
788            .create_entity(
789                empty_create_args("specs", "Rich Shape"),
790                actor,
791                Some(&client),
792                None,
793            )
794            .unwrap();
795
796        // Folder backend produces a synthetic CommitId — wire-equiv
797        // to full's commit SHA.
798        assert!(
799            !outcome.commit_sha.is_empty(),
800            "commit_sha must be populated on a real create"
801        );
802        // title + mem echoed from args (full CreateResult parity).
803        assert_eq!(outcome.title, "Rich Shape");
804        assert_eq!(outcome.mem, "specs");
805        // The create path refuses on missing required sections, so
806        // `empty_create_args` seeds identity + purpose and the
807        // success path's warnings vec carries no
808        // `MissingRequiredSection` entries. The dedicated refusal
809        // tests below exercise the gate directly.
810        assert!(
811            !outcome
812                .warnings
813                .iter()
814                .any(|w| matches!(w, WarningHint::MissingRequiredSection { .. })),
815            "success path must not carry MissingRequiredSection warnings — those refuse on create now",
816        );
817    }
818
819    /// Missing
820    /// required sections refuse on create. The error envelope names
821    /// every missing key (in schema-declaration order), carries each
822    /// section's `write_rules`, and surfaces the type-level
823    /// `type_guidance` map keyed by `entity_type`.
824    #[test]
825    fn create_entity_refuses_missing_required_sections_with_typed_envelope() {
826        let tmp = TempDir::new().unwrap();
827        let mem_dir = tmp.path().to_path_buf();
828        let writer = FilesystemMemWriter::new(mem_dir.clone());
829        let mut engine = Engine::from_mounts(vec![(
830            folder_mount("specs", mem_dir),
831            Box::new(writer) as Box<dyn MemBackend>,
832        )])
833        .unwrap();
834        let (actor, client) = cli_actor();
835
836        // `spec` requires `identity` + `purpose`. Supply neither.
837        let args = CreateEntityArgs {
838            mem: "specs".to_string(),
839            title: "Half Done".to_string(),
840            entity_type: "spec".to_string(),
841            sections: IndexMap::new(),
842            metadata: IndexMap::new(),
843            relations: Vec::new(),
844            dry_run: false,
845        };
846        let err = engine
847            .create_entity(args, actor, Some(&client), None)
848            .unwrap_err();
849        match err {
850            EngineError::MissingRequiredSection {
851                entity_type,
852                missing_count,
853                sections,
854                type_guidance,
855            } => {
856                assert_eq!(entity_type, "spec");
857                assert_eq!(missing_count, sections.len());
858                assert!(
859                    missing_count >= 2,
860                    "expected ≥2 missing sections, got {missing_count}"
861                );
862                let keys: Vec<String> = sections.iter().map(|s| s.key.clone()).collect();
863                assert!(
864                    keys.contains(&"identity".to_string()),
865                    "missing keys: {keys:?}"
866                );
867                assert!(
868                    keys.contains(&"purpose".to_string()),
869                    "missing keys: {keys:?}"
870                );
871                assert!(
872                    type_guidance.contains_key("spec"),
873                    "type_guidance must include `spec` entry, got: {type_guidance:?}",
874                );
875            }
876            other => panic!("expected MissingRequiredSection, got {other:?}"),
877        }
878
879        // No entity landed in the store.
880        let id = crate::EntityId::new("specs", "half-done");
881        assert!(
882            engine.store().get(&id).is_none(),
883            "refused create must not persist any entity"
884        );
885    }
886
887    /// `dry_run: true` returns the same refusal envelope
888    /// the real call would. The preview surface doesn't admit content
889    /// the real call would refuse.
890    #[test]
891    fn create_entity_dry_run_returns_same_refusal_envelope_as_real_call() {
892        let tmp = TempDir::new().unwrap();
893        let mem_dir = tmp.path().to_path_buf();
894        let writer = FilesystemMemWriter::new(mem_dir.clone());
895        let mut engine = Engine::from_mounts(vec![(
896            folder_mount("specs", mem_dir),
897            Box::new(writer) as Box<dyn MemBackend>,
898        )])
899        .unwrap();
900        let (actor, client) = cli_actor();
901
902        let args = CreateEntityArgs {
903            mem: "specs".to_string(),
904            title: "Half Done Dry".to_string(),
905            entity_type: "spec".to_string(),
906            sections: IndexMap::new(),
907            metadata: IndexMap::new(),
908            relations: Vec::new(),
909            dry_run: true,
910        };
911        let err = engine
912            .create_entity(args, actor, Some(&client), None)
913            .unwrap_err();
914        assert!(
915            matches!(err, EngineError::MissingRequiredSection { .. }),
916            "dry_run must surface the same refusal envelope, got {err:?}"
917        );
918    }
919
920    /// A follow-up call with the missing sections filled
921    /// in succeeds. The refusal carries enough recovery information
922    /// that the agent's next attempt resolves in one round-trip.
923    #[test]
924    fn create_entity_succeeds_after_filling_in_required_sections() {
925        let tmp = TempDir::new().unwrap();
926        let mem_dir = tmp.path().to_path_buf();
927        let writer = FilesystemMemWriter::new(mem_dir.clone());
928        let mut engine = Engine::from_mounts(vec![(
929            folder_mount("specs", mem_dir),
930            Box::new(writer) as Box<dyn MemBackend>,
931        )])
932        .unwrap();
933        let (actor, client) = cli_actor();
934
935        let mut sections = IndexMap::new();
936        sections.insert("identity".to_string(), "the identity body".to_string());
937        sections.insert("purpose".to_string(), "the purpose body".to_string());
938        let args = CreateEntityArgs {
939            mem: "specs".to_string(),
940            title: "Complete".to_string(),
941            entity_type: "spec".to_string(),
942            sections,
943            metadata: IndexMap::new(),
944            relations: Vec::new(),
945            dry_run: false,
946        };
947        let outcome = engine
948            .create_entity(args, actor, Some(&client), None)
949            .expect("complete create succeeds");
950        assert_eq!(outcome.title, "Complete");
951    }
952
953    #[test]
954    fn create_entity_promotes_existing_stub_and_preserves_incoming_edges() {
955        let tmp = TempDir::new().unwrap();
956        let (mut engine, source) = engine_with_seed(&tmp, "Source");
957        let (actor, client) = cli_actor();
958
959        // Step 1: relate source → "ghost-target" — creates a stub
960        // entity at `specs--ghost-target` with one incoming edge.
961        let stub_target = crate::EntityId::new("specs", "ghost-target");
962        engine
963            .relate_entity(
964                RelateEntityArgs {
965                    source: source.id.clone(),
966                    expected_hash: Some(source.content_hash.clone()),
967                    rel_type: "USES".to_string(),
968                    target: stub_target.clone(),
969                    remove: false,
970                    description: None,
971                },
972                actor,
973                Some(&client),
974                None,
975            )
976            .unwrap();
977        let stub = engine
978            .store()
979            .get(&stub_target)
980            .expect("stub must be in store");
981        assert!(stub.stub);
982        assert_eq!(engine.store().incoming(&stub_target).len(), 1);
983
984        // Step 2: create a real entity with the same title — should
985        // promote the stub and preserve the incoming edge.
986        let outcome = engine
987            .create_entity(
988                empty_create_args("specs", "Ghost Target"),
989                actor,
990                Some(&client),
991                None,
992            )
993            .unwrap();
994
995        // No error: stub adoption proceeded.
996        assert_eq!(outcome.id, stub_target);
997        // Entity is now a real entity, not a stub.
998        let real = engine
999            .store()
1000            .get(&stub_target)
1001            .expect("entity must still be in store");
1002        assert!(!real.stub);
1003        // Incoming edge survived the upsert.
1004        assert_eq!(engine.store().incoming(&stub_target).len(), 1);
1005        // Outcome surfaces stub adoption.
1006        assert_eq!(outcome.incoming_count, Some(1));
1007        assert_eq!(outcome.incoming.len(), 1);
1008        assert_eq!(outcome.incoming[0].from, source.id);
1009        assert_eq!(outcome.incoming[0].rel_type, "USES");
1010    }
1011
1012    #[test]
1013    fn create_entity_reports_no_incoming_on_greenfield_create() {
1014        let tmp = TempDir::new().unwrap();
1015        let mem_dir = tmp.path().to_path_buf();
1016        let writer = FilesystemMemWriter::new(mem_dir.clone());
1017        let mut engine = Engine::from_mounts(vec![(
1018            folder_mount("specs", mem_dir),
1019            Box::new(writer) as Box<dyn MemBackend>,
1020        )])
1021        .unwrap();
1022        let (actor, client) = cli_actor();
1023
1024        let outcome = engine
1025            .create_entity(
1026                empty_create_args("specs", "Greenfield"),
1027                actor,
1028                Some(&client),
1029                None,
1030            )
1031            .unwrap();
1032        // No pre-existing stub → incoming_count is None, incoming vec
1033        // is empty. Full's wire shape skip-serialises both.
1034        assert!(outcome.incoming_count.is_none());
1035        assert!(outcome.incoming.is_empty());
1036    }
1037
1038    #[test]
1039    fn create_entity_populates_created_date_from_schema_auto_stamp() {
1040        let tmp = TempDir::new().unwrap();
1041        let mem_dir = tmp.path().to_path_buf();
1042        let writer = FilesystemMemWriter::new(mem_dir.clone());
1043        let mut engine = Engine::from_mounts(vec![(
1044            folder_mount("specs", mem_dir),
1045            Box::new(writer) as Box<dyn MemBackend>,
1046        )])
1047        .unwrap();
1048        let (actor, client) = cli_actor();
1049
1050        let outcome = engine
1051            .create_entity(
1052                empty_create_args("specs", "Has Date"),
1053                actor,
1054                Some(&client),
1055                None,
1056            )
1057            .unwrap();
1058        // The default `spec` schema declares `created_date` with
1059        // an init_timestamp default. The parsed entity carries the
1060        // auto-stamped value; the outcome surfaces it for callers
1061        // who need it without a follow-up read.
1062        assert!(
1063            !outcome.created_date.is_empty(),
1064            "created_date must be populated when the schema auto-stamps it"
1065        );
1066    }
1067
1068    #[test]
1069    fn create_overrides_user_supplied_timestamps_update_rejects_them() {
1070        // Schema-declared `init_timestamp` (set on create) and
1071        // `auto_timestamp` (re-stamped on every update) fields are
1072        // engine-managed. On create the engine still silently
1073        // overrides any caller-supplied value (the entity must be
1074        // stampable in one shot from the user's perspective). On
1075        // update the writable-metadata validator rejects the write
1076        // up-front with `READ_ONLY_FIELD` — the agent gets a
1077        // structured rejection instead of a "set" response whose
1078        // value the auto-stamp pass silently discards (per the F13
1079        // / F14 contract).
1080        let tmp = TempDir::new().unwrap();
1081        let mem_dir = tmp.path().to_path_buf();
1082        let writer = FilesystemMemWriter::new(mem_dir.clone());
1083        let mut engine = Engine::from_mounts(vec![(
1084            folder_mount("specs", mem_dir),
1085            Box::new(writer) as Box<dyn MemBackend>,
1086        )])
1087        .unwrap();
1088        let (actor, client) = cli_actor();
1089
1090        // Caller supplies a past value for the init_timestamp field
1091        // and the auto_timestamp field. The engine ignores both on
1092        // create.
1093        let mut args = empty_create_args("specs", "Stamped Today");
1094        args.metadata
1095            .insert("created_date".to_string(), "2020-01-01".to_string());
1096        args.metadata
1097            .insert("last_modified".to_string(), "2020-01-01".to_string());
1098
1099        let outcome = engine
1100            .create_entity(args, actor, Some(&client), None)
1101            .unwrap();
1102
1103        // Both timestamps should reflect the engine's `today_iso()`,
1104        // not the caller's `2020-01-01`.
1105        let today = super::today_iso();
1106        assert_eq!(outcome.created_date, today);
1107        let entity = engine
1108            .get_entity(&outcome.id)
1109            .expect("entity must be in store after create");
1110        assert_eq!(
1111            entity
1112                .metadata
1113                .get("created_date")
1114                .and_then(|v| v.as_str())
1115                .unwrap_or_default(),
1116            today,
1117            "init_timestamp field must be engine-determined on create, not user-supplied"
1118        );
1119        assert_eq!(
1120            entity
1121                .metadata
1122                .get("last_modified")
1123                .and_then(|v| v.as_str())
1124                .unwrap_or_default(),
1125            today,
1126            "auto_timestamp field must be engine-determined on create, not user-supplied"
1127        );
1128
1129        // F13/F14: update rejects a user-supplied value for either
1130        // init_timestamp or auto_timestamp metadata fields with
1131        // `READ_ONLY_FIELD`. Test both fields in turn.
1132        let attempt_update = |key: &str, value: &str| {
1133            let mut metadata = IndexMap::new();
1134            metadata.insert(key.to_string(), value.to_string());
1135            crate::engine::UpdateEntityArgs {
1136                id: outcome.id.clone(),
1137                metadata,
1138                metadata_unset: Vec::new(),
1139                sections: IndexMap::new(),
1140                append_sections: IndexMap::new(),
1141                patch_sections: IndexMap::new(),
1142                expected_hash: Some(outcome.content_hash.clone()),
1143                dry_run: false,
1144                declare_relations: Vec::new(),
1145                relations_unset: Vec::new(),
1146            }
1147        };
1148        for key in ["created_date", "last_modified"] {
1149            let err = engine
1150                .update_entity(
1151                    attempt_update(key, "2019-12-31"),
1152                    actor,
1153                    Some(&client),
1154                    None,
1155                )
1156                .expect_err("schema-managed timestamp must be rejected on update");
1157            assert_eq!(err.code(), "READ_ONLY_FIELD", "got: {err:?}");
1158        }
1159        // Stored value is unchanged after a rejected attempt.
1160        let entity = engine
1161            .get_entity(&outcome.id)
1162            .expect("entity must remain in store after rejected update");
1163        assert_eq!(
1164            entity
1165                .metadata
1166                .get("last_modified")
1167                .and_then(|v| v.as_str())
1168                .unwrap_or_default(),
1169            today,
1170            "rejected update must not mutate the auto_timestamp field"
1171        );
1172    }
1173
1174    #[test]
1175    fn create_entity_wires_inline_relations_and_stubs_absent_targets() {
1176        let tmp = TempDir::new().unwrap();
1177        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
1178        let (actor, client) = cli_actor();
1179        let absent = crate::EntityId::new("specs", "future-target");
1180        assert!(!engine.store().contains(&absent));
1181
1182        let mut args = empty_create_args("specs", "Source With Relations");
1183        args.relations = vec![
1184            crate::ops::RelateArg {
1185                to: existing.id.clone(),
1186                rel_type: "USES".to_string(),
1187                description: None,
1188            },
1189            crate::ops::RelateArg {
1190                to: absent.clone(),
1191                rel_type: "USES".to_string(),
1192                description: None,
1193            },
1194        ];
1195
1196        let outcome = engine
1197            .create_entity(args, actor, Some(&client), None)
1198            .unwrap();
1199
1200        // New entity in store with both edges materialised.
1201        let source = engine
1202            .store()
1203            .get(&outcome.id)
1204            .expect("source must be in store");
1205        assert_eq!(source.relationships.len(), 2);
1206        assert!(
1207            source
1208                .relationships
1209                .iter()
1210                .any(|r| r.target == existing.id && r.rel_type == "USES")
1211        );
1212        assert!(
1213            source
1214                .relationships
1215                .iter()
1216                .any(|r| r.target == absent && r.rel_type == "USES")
1217        );
1218
1219        // Absent target was auto-stubbed (mirrors the relate path's
1220        // ensure_target).
1221        let stub = engine
1222            .store()
1223            .get(&absent)
1224            .expect("absent relation target must be auto-stubbed");
1225        assert!(stub.stub);
1226        // Existing target unchanged.
1227        let existing_after = engine.store().get(&existing.id).unwrap();
1228        assert!(!existing_after.stub);
1229    }
1230
1231    /// Build a folder-mount engine pinned to the `planning` schema, so
1232    /// tests can exercise `decision` — a type with `decided_on` (Date,
1233    /// required, no default / no init_timestamp) — without inventing a
1234    /// synthetic schema.
1235    fn engine_with_planning_schema(tmp: &TempDir) -> Engine {
1236        use crate::workspace::Mount;
1237        use crate::workspace::{MountCapability, MountLifecycle, MountStorage};
1238        let mem_dir = tmp.path().to_path_buf();
1239        let writer = FilesystemMemWriter::new(mem_dir.clone());
1240        let mount = Mount {
1241            mem: "planning".to_string(),
1242            schema: Some(memstead_schema::SchemaRef::new(
1243                "planning",
1244                semver::Version::new(0, 1, 0),
1245            )),
1246            storage: MountStorage::Folder { path: mem_dir },
1247            capability: MountCapability::Write,
1248            lifecycle: MountLifecycle::Eager,
1249            cross_linkable: true,
1250            migration_target: None,
1251        };
1252        Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap()
1253    }
1254
1255    /// A
1256    /// required metadata field the schema does not auto-fill
1257    /// (`default_value` / `init_timestamp` / `auto_timestamp` all
1258    /// absent) now triggers `REQUIRED_FIELD_UNSET` refusal on the
1259    /// create path. Pre-fix this surfaced as a `MissingRequiredField`
1260    /// warning and the generator silently wrote placeholder values
1261    /// that the install-time strict validator could later refuse,
1262    /// breaking the export-then-install round-trip.
1263    #[test]
1264    fn create_entity_refuses_unsupplied_no_default_required_field() {
1265        // The `planning.decision` schema declares `decided_on`
1266        // (Date, required, no default_value, no init_timestamp) and
1267        // `deciders` (String csv_array, required, no default).
1268        let tmp = TempDir::new().unwrap();
1269        let mut engine = engine_with_planning_schema(&tmp);
1270        let (actor, client) = cli_actor();
1271
1272        let mut args = CreateEntityArgs {
1273            mem: "planning".to_string(),
1274            title: "Skip Postgres".to_string(),
1275            entity_type: "decision".to_string(),
1276            sections: IndexMap::from_iter([
1277                ("decision".to_string(), "Use SQLite locally.".to_string()),
1278                ("context".to_string(), "Single-user dev.".to_string()),
1279                ("consequences".to_string(), "Lose multi-writer.".to_string()),
1280            ]),
1281            metadata: IndexMap::new(),
1282            relations: Vec::new(),
1283            dry_run: false,
1284        };
1285
1286        // Real-write path: refuse on the first missing field
1287        // (declaration order).
1288        let err = engine
1289            .create_entity(args.clone(), actor, Some(&client), None)
1290            .unwrap_err();
1291        match err {
1292            EngineError::RequiredFieldUnset {
1293                field, entity_type, ..
1294            } => {
1295                assert!(
1296                    field == "decided_on" || field == "deciders",
1297                    "expected first missing field, got {field:?}"
1298                );
1299                assert_eq!(entity_type, "decision");
1300            }
1301            other => panic!("expected RequiredFieldUnset, got {other:?}"),
1302        }
1303
1304        // Dry-run path on the same shape (different title to avoid the
1305        // already-exists check). Must surface the same refusal — the
1306        // create dry-run is the agent's preview surface.
1307        args.title = "Different Title".to_string();
1308        args.dry_run = true;
1309        let dry_err = engine
1310            .create_entity(args, actor, Some(&client), None)
1311            .unwrap_err();
1312        assert!(
1313            matches!(dry_err, EngineError::RequiredFieldUnset { .. }),
1314            "dry_run must surface the same refusal envelope, got {dry_err:?}"
1315        );
1316    }
1317
1318    /// A follow-up call with all required-no-default
1319    /// fields supplied succeeds. The refusal recovery is a single
1320    /// round-trip.
1321    #[test]
1322    fn create_entity_succeeds_when_all_required_no_default_fields_supplied() {
1323        let tmp = TempDir::new().unwrap();
1324        let mut engine = engine_with_planning_schema(&tmp);
1325        let (actor, client) = cli_actor();
1326
1327        let mut metadata = IndexMap::new();
1328        metadata.insert("decided_on".to_string(), "2026-05-13".to_string());
1329        metadata.insert("deciders".to_string(), "alice, bob".to_string());
1330
1331        let outcome = engine
1332            .create_entity(
1333                CreateEntityArgs {
1334                    mem: "planning".to_string(),
1335                    title: "Complete Decision".to_string(),
1336                    entity_type: "decision".to_string(),
1337                    sections: IndexMap::from_iter([
1338                        ("decision".to_string(), "x".to_string()),
1339                        ("context".to_string(), "y".to_string()),
1340                        ("consequences".to_string(), "z".to_string()),
1341                    ]),
1342                    metadata,
1343                    relations: Vec::new(),
1344                    dry_run: false,
1345                },
1346                actor,
1347                Some(&client),
1348                None,
1349            )
1350            .expect("complete decision create succeeds");
1351        // No MissingRequiredField warnings on the success path —
1352        // refusal swallows the case before any warning could fire.
1353        let missing_field_warnings: Vec<&WarningHint> = outcome
1354            .warnings
1355            .iter()
1356            .filter(|w| matches!(w, WarningHint::MissingRequiredField { .. }))
1357            .collect();
1358        assert!(
1359            missing_field_warnings.is_empty(),
1360            "success path must not carry MissingRequiredField warnings, got: {missing_field_warnings:?}"
1361        );
1362    }
1363
1364    /// Item 02: `memstead_create.relations[]` runs the same target-id
1365    /// grammar gate as `memstead_relate`. Pre-fix the create path
1366    /// admitted malformed ids (auto-stub at `bad@chars$here`) even
1367    /// though `memstead_relate` rejected them.
1368    #[test]
1369    fn create_entity_rejects_inline_relation_with_malformed_target_id() {
1370        let tmp = TempDir::new().unwrap();
1371        let mem_dir = tmp.path().to_path_buf();
1372        let writer = FilesystemMemWriter::new(mem_dir.clone());
1373        let mut engine = Engine::from_mounts(vec![(
1374            folder_mount("specs", mem_dir),
1375            Box::new(writer) as Box<dyn MemBackend>,
1376        )])
1377        .unwrap();
1378        let (actor, client) = cli_actor();
1379
1380        let mut args = empty_create_args("specs", "Source");
1381        args.relations = vec![crate::ops::RelateArg {
1382            to: crate::EntityId("specs--bad target with spaces!!".to_string()),
1383            rel_type: "USES".to_string(),
1384            description: None,
1385        }];
1386        let err = engine
1387            .create_entity(args, actor, Some(&client), None)
1388            .unwrap_err();
1389        assert!(
1390            matches!(err, EngineError::InvalidEntityId { .. }),
1391            "malformed target id must trip INVALID_ENTITY_ID on the create path; got {err:?}",
1392        );
1393    }
1394
1395    /// Item 02: `memstead_create.relations[]` runs the same schema-shape
1396    /// gate as `memstead_relate`. The relate-path shape gate is already
1397    /// pinned by `memstead-mcp::tool_surface::INVALID_REL_SHAPE` and the
1398    /// schema-loader tests; the cross-path lock here exercises the
1399    /// `software` schema's `VIOLATES` rel-type, which declares
1400    /// `source_types: [incident]` — an inline create from a `spec`
1401    /// must trip the shape gate even though the rel-type itself is
1402    /// valid vocabulary.
1403    #[test]
1404    fn create_entity_rejects_inline_relation_with_shape_violation() {
1405        use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
1406        let tmp = TempDir::new().unwrap();
1407        let mem_dir = tmp.path().to_path_buf();
1408        let writer = FilesystemMemWriter::new(mem_dir.clone());
1409        let mount = Mount {
1410            mem: "code".to_string(),
1411            schema: Some(memstead_schema::SchemaRef::new(
1412                "software",
1413                semver::Version::new(0, 1, 0),
1414            )),
1415            storage: MountStorage::Folder { path: mem_dir },
1416            capability: MountCapability::Write,
1417            lifecycle: MountLifecycle::Eager,
1418            cross_linkable: true,
1419            migration_target: None,
1420        };
1421        let mut engine =
1422            Engine::from_mounts(vec![(mount, Box::new(writer) as Box<dyn MemBackend>)]).unwrap();
1423        let (actor, client) = cli_actor();
1424
1425        // Seed an existing target so the shape gate evaluates the
1426        // real target type (not `None`, which the gate admits as the
1427        // stub-bound case). The `requirement` type requires `statement` +
1428        // `rationale` sections plus `verified_on` + `source` metadata
1429        // (the schema lists these without `default_value` or
1430        // `optional: true`, so the strict-on-create gate refuses
1431        // unless supplied).
1432        let target = engine
1433            .create_entity(
1434                CreateEntityArgs {
1435                    mem: "code".to_string(),
1436                    title: "Target Requirement".to_string(),
1437                    entity_type: "requirement".to_string(),
1438                    sections: IndexMap::from_iter([
1439                        ("statement".to_string(), "MUST hold.".to_string()),
1440                        ("rationale".to_string(), "Because tests.".to_string()),
1441                    ]),
1442                    metadata: IndexMap::from_iter([
1443                        ("verified_on".to_string(), "2026-05-19".to_string()),
1444                        ("source".to_string(), "test fixture".to_string()),
1445                    ]),
1446                    relations: Vec::new(),
1447                    dry_run: false,
1448                },
1449                actor,
1450                Some(&client),
1451                None,
1452            )
1453            .unwrap();
1454
1455        // `VIOLATES` declares `source_types: [incident]`. A `spec`
1456        // create with `VIOLATES` violates the shape. The `spec` type
1457        // in the software schema requires `identity` + `purpose`;
1458        // supply both so the shape gate (not the missing-sections
1459        // gate) is what fires.
1460        let args = CreateEntityArgs {
1461            mem: "code".to_string(),
1462            title: "Misshape Source".to_string(),
1463            entity_type: "spec".to_string(),
1464            sections: IndexMap::from_iter([
1465                ("identity".to_string(), "this spec".to_string()),
1466                (
1467                    "purpose".to_string(),
1468                    "exercising the shape gate".to_string(),
1469                ),
1470            ]),
1471            metadata: IndexMap::new(),
1472            relations: vec![crate::ops::RelateArg {
1473                to: target.id.clone(),
1474                rel_type: "VIOLATES".to_string(),
1475                description: None,
1476            }],
1477            dry_run: false,
1478        };
1479        let err = engine
1480            .create_entity(args, actor, Some(&client), None)
1481            .unwrap_err();
1482        assert!(
1483            matches!(err, EngineError::Validation(_)),
1484            "shape violation must trip Validation(InvalidRelationshipShape); got {err:?}",
1485        );
1486    }
1487
1488    #[test]
1489    fn create_entity_canonicalises_inline_relation_rel_types_to_upper_snake_case() {
1490        // Wire-level contract: rel_type on inline relations is
1491        // case-insensitive. The engine stores the relationship as
1492        // UPPER_SNAKE_CASE regardless of input case.
1493        let tmp = TempDir::new().unwrap();
1494        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
1495        let (actor, client) = cli_actor();
1496
1497        let mut args = empty_create_args("specs", "Source With Mixed Case Rel");
1498        args.relations = vec![crate::ops::RelateArg {
1499            to: existing.id.clone(),
1500            rel_type: "uses".to_string(),
1501            description: None,
1502        }];
1503
1504        let outcome = engine
1505            .create_entity(args, actor, Some(&client), None)
1506            .unwrap();
1507
1508        let source = engine
1509            .store()
1510            .get(&outcome.id)
1511            .expect("source must be in store");
1512        assert_eq!(source.relationships.len(), 1);
1513        assert_eq!(
1514            source.relationships[0].rel_type, "USES",
1515            "inline relation rel_type must be stored UPPER_SNAKE_CASE",
1516        );
1517    }
1518
1519    #[test]
1520    fn create_entity_dry_run_skips_disk_and_store_yet_returns_hash() {
1521        let tmp = TempDir::new().unwrap();
1522        let mem_dir = tmp.path().to_path_buf();
1523        let writer = FilesystemMemWriter::new(mem_dir.clone());
1524        let mut engine = Engine::from_mounts(vec![(
1525            folder_mount("specs", mem_dir.clone()),
1526            Box::new(writer) as Box<dyn MemBackend>,
1527        )])
1528        .unwrap();
1529        let (actor, client) = cli_actor();
1530
1531        let mut args = empty_create_args("specs", "Preview Only");
1532        args.dry_run = true;
1533
1534        let outcome = engine
1535            .create_entity(args, actor, Some(&client), None)
1536            .unwrap();
1537
1538        // Wire shape: content_hash = prospective hash; commit_sha empty.
1539        assert_eq!(outcome.id.to_string(), "specs--preview-only");
1540        assert!(
1541            !outcome.content_hash.is_empty(),
1542            "prospective hash populated"
1543        );
1544        assert!(outcome.commit_sha.is_empty(), "no commit on dry_run");
1545        // No store entry — the engine didn't push.
1546        assert!(
1547            engine.store().get(&outcome.id).is_none(),
1548            "dry_run must not mutate the store",
1549        );
1550        // No file on disk.
1551        assert!(
1552            !mem_dir.join("preview-only.md").exists(),
1553            "dry_run must not touch disk",
1554        );
1555        // No provenance line.
1556        let log = mem_dir.join(".memstead").join("changes.jsonl");
1557        assert!(
1558            !log.exists()
1559                || !std::fs::read_to_string(&log)
1560                    .unwrap()
1561                    .contains("preview-only"),
1562            "dry_run must not append provenance",
1563        );
1564    }
1565
1566    #[test]
1567    fn create_entity_rejects_read_only_mount_before_backend() {
1568        let tmp = TempDir::new().unwrap();
1569        let archive_path = build_archive(tmp.path(), "ext", &[("a.md", b"# a")]);
1570        let mut engine = Engine::from_mounts(vec![(
1571            archive_mount("external", archive_path.clone()),
1572            Box::new(ArchiveBackend::new(archive_path)),
1573        )])
1574        .unwrap();
1575        let (actor, client) = cli_actor();
1576
1577        let err = engine
1578            .create_entity(
1579                empty_create_args("external", "Should Fail"),
1580                actor,
1581                Some(&client),
1582                None,
1583            )
1584            .unwrap_err();
1585        match err {
1586            EngineError::ReadOnlyMount(v) => assert_eq!(v, "external"),
1587            other => panic!("expected ReadOnlyMount, got {other:?}"),
1588        }
1589        // Capability gating runs before the backend → the typed
1590        // BackendError::Sealed variant never surfaces here. That's
1591        // the intended ordering.
1592    }
1593
1594    #[test]
1595    fn create_entity_rejects_unknown_mem() {
1596        let tmp = TempDir::new().unwrap();
1597        let writer = FilesystemMemWriter::new(tmp.path().to_path_buf());
1598        let mut engine = Engine::from_mounts(vec![(
1599            folder_mount("specs", tmp.path().to_path_buf()),
1600            Box::new(writer) as Box<dyn MemBackend>,
1601        )])
1602        .unwrap();
1603        let (actor, client) = cli_actor();
1604
1605        let err = engine
1606            .create_entity(
1607                empty_create_args("does-not-exist", "Anything"),
1608                actor,
1609                Some(&client),
1610                None,
1611            )
1612            .unwrap_err();
1613        assert!(matches!(err, EngineError::UnknownMem(v) if v == "does-not-exist"));
1614    }
1615
1616    #[test]
1617    fn create_entity_rejects_unknown_type_against_pinned_schema() {
1618        let tmp = TempDir::new().unwrap();
1619        let mem_dir = tmp.path().to_path_buf();
1620        let writer = FilesystemMemWriter::new(mem_dir.clone());
1621        let mut engine = Engine::from_mounts(vec![(
1622            folder_mount("specs", mem_dir),
1623            Box::new(writer) as Box<dyn MemBackend>,
1624        )])
1625        .unwrap();
1626        let (actor, client) = cli_actor();
1627
1628        let mut args = empty_create_args("specs", "Anything");
1629        args.entity_type = "definitely-not-a-real-type".to_string();
1630        let err = engine
1631            .create_entity(args, actor, Some(&client), None)
1632            .unwrap_err();
1633        match err {
1634            EngineError::UnknownType { name, declared, .. } => {
1635                assert_eq!(name, "definitely-not-a-real-type");
1636                assert!(!declared.is_empty(), "declared types must be listed");
1637            }
1638            other => panic!("expected UnknownType, got {other:?}"),
1639        }
1640    }
1641
1642    #[test]
1643    fn create_entity_rejects_duplicate_id() {
1644        let tmp = TempDir::new().unwrap();
1645        let mem_dir = tmp.path().to_path_buf();
1646        let writer = FilesystemMemWriter::new(mem_dir.clone());
1647        let mut engine = Engine::from_mounts(vec![(
1648            folder_mount("specs", mem_dir),
1649            Box::new(writer) as Box<dyn MemBackend>,
1650        )])
1651        .unwrap();
1652        let (actor, client) = cli_actor();
1653
1654        engine
1655            .create_entity(
1656                empty_create_args("specs", "Same Slug"),
1657                actor,
1658                Some(&client),
1659                None,
1660            )
1661            .unwrap();
1662        let err = engine
1663            .create_entity(
1664                empty_create_args("specs", "Same Slug"),
1665                actor,
1666                Some(&client),
1667                None,
1668            )
1669            .unwrap_err();
1670        match err {
1671            EngineError::AlreadyExists { id } => assert_eq!(id, "specs--same-slug"),
1672            other => panic!("expected AlreadyExists, got {other:?}"),
1673        }
1674    }
1675
1676    #[test]
1677    fn create_entity_rejects_invalid_title() {
1678        let tmp = TempDir::new().unwrap();
1679        let mem_dir = tmp.path().to_path_buf();
1680        let writer = FilesystemMemWriter::new(mem_dir.clone());
1681        let mut engine = Engine::from_mounts(vec![(
1682            folder_mount("specs", mem_dir),
1683            Box::new(writer) as Box<dyn MemBackend>,
1684        )])
1685        .unwrap();
1686        let (actor, client) = cli_actor();
1687
1688        // F4: empty/whitespace-only titles now refuse with
1689        // `INVALID_TITLE` / reason `empty`. The earlier hash-fallback
1690        // behaviour applies only to the loader path (pre-gate
1691        // entities); the strict mutation gate rejects so the
1692        // structured-content envelope can carry actionable details.
1693        let err = engine
1694            .create_entity(empty_create_args("specs", "  "), actor, Some(&client), None)
1695            .unwrap_err();
1696        match err {
1697            EngineError::InvalidTitle(slug_err) => {
1698                assert_eq!(slug_err.reason(), "empty", "expected empty reason");
1699            }
1700            other => panic!("expected InvalidTitle/TitleEmpty, got {other:?}"),
1701        }
1702
1703        // F10 + F19: char-drop titles refuse with reason `invalid_chars`
1704        // and a `proposed_slug` for mechanical retry.
1705        let err = engine
1706            .create_entity(
1707                empty_create_args("specs", "Hello, World!"),
1708                actor,
1709                Some(&client),
1710                None,
1711            )
1712            .unwrap_err();
1713        match err {
1714            EngineError::InvalidTitle(crate::SlugError::TitleHasInvalidChars {
1715                invalid_chars,
1716                proposed_slug,
1717                ..
1718            }) => {
1719                assert!(invalid_chars.contains(&',') && invalid_chars.contains(&'!'));
1720                assert_eq!(proposed_slug, "hello-world");
1721            }
1722            other => panic!("expected InvalidTitle/TitleHasInvalidChars, got {other:?}"),
1723        }
1724
1725        // F19: path-traversal-shaped titles fall under the same gate
1726        // (the `/` and `.` chars are pipeline-dropped).
1727        let err = engine
1728            .create_entity(
1729                empty_create_args("specs", "../etc/passwd"),
1730                actor,
1731                Some(&client),
1732                None,
1733            )
1734            .unwrap_err();
1735        match err {
1736            EngineError::InvalidTitle(slug_err) => {
1737                assert_eq!(slug_err.reason(), "invalid_chars");
1738            }
1739            other => panic!("expected InvalidTitle, got {other:?}"),
1740        }
1741    }
1742
1743    #[test]
1744    fn create_entity_rejects_unknown_section_key() {
1745        let tmp = TempDir::new().unwrap();
1746        let mem_dir = tmp.path().to_path_buf();
1747        let writer = FilesystemMemWriter::new(mem_dir.clone());
1748        let mut engine = Engine::from_mounts(vec![(
1749            folder_mount("specs", mem_dir),
1750            Box::new(writer) as Box<dyn MemBackend>,
1751        )])
1752        .unwrap();
1753        let (actor, client) = cli_actor();
1754
1755        let mut args = empty_create_args("specs", "Bad Sections");
1756        args.sections
1757            .insert("not-a-real-section-key".to_string(), "body".to_string());
1758        let err = engine
1759            .create_entity(args, actor, Some(&client), None)
1760            .unwrap_err();
1761        assert!(matches!(err, EngineError::Validation(_)));
1762    }
1763
1764    #[test]
1765    fn create_entity_persists_across_engine_restart() {
1766        let tmp = TempDir::new().unwrap();
1767        let mem_dir = tmp.path().to_path_buf();
1768        {
1769            let writer = FilesystemMemWriter::new(mem_dir.clone());
1770            let mut engine = Engine::from_mounts(vec![(
1771                folder_mount("specs", mem_dir.clone()),
1772                Box::new(writer) as Box<dyn MemBackend>,
1773            )])
1774            .unwrap();
1775            let (actor, client) = cli_actor();
1776            engine
1777                .create_entity(
1778                    empty_create_args("specs", "Survives Restart"),
1779                    actor,
1780                    Some(&client),
1781                    None,
1782                )
1783                .unwrap();
1784        }
1785        // New engine reading the same mem must see the entity.
1786        let writer2 = FilesystemMemWriter::new(mem_dir.clone());
1787        let engine2 = Engine::from_mounts(vec![(
1788            folder_mount("specs", mem_dir),
1789            Box::new(writer2) as Box<dyn MemBackend>,
1790        )])
1791        .unwrap();
1792        let entity = engine2
1793            .get_entity(&crate::EntityId::new("specs", "survives-restart"))
1794            .expect("entity must persist across engine restart");
1795        assert_eq!(entity.title, "Survives Restart");
1796    }
1797
1798    // ---- Engine::update_entity --------------------------------------
1799
1800    /// Build a folder-mount Engine with one freshly-created entity.
1801    /// Returns the engine + the created outcome so tests have the
1802    /// id and current hash to use as `expected_hash` for the next
1803    /// mutation.
1804    fn engine_with_seed(tmp: &TempDir, title: &str) -> (Engine, CreateEntityOutcome) {
1805        let mem_dir = tmp.path().to_path_buf();
1806        let writer = FilesystemMemWriter::new(mem_dir.clone());
1807        let mut engine = Engine::from_mounts(vec![(
1808            folder_mount("specs", mem_dir),
1809            Box::new(writer) as Box<dyn MemBackend>,
1810        )])
1811        .unwrap();
1812        let (actor, client) = cli_actor();
1813        let outcome = engine
1814            .create_entity(
1815                empty_create_args("specs", title),
1816                actor,
1817                Some(&client),
1818                None,
1819            )
1820            .unwrap();
1821        (engine, outcome)
1822    }
1823
1824    /// Create with
1825    /// a body wiki-link to a non-existent target emits
1826    /// `INLINE_WIKI_LINK_AUTO_STUBBED` with the stubbed target id in
1827    /// `details.stubs`. Pre-fix the warning never fired because the
1828    /// emission walked `parse_markdown(generated_markdown).inline_links`,
1829    /// which the parser-side coverage filter had already emptied for
1830    /// the alias-synthesised body link.
1831    #[test]
1832    fn create_entity_emits_inline_wiki_link_auto_stubbed_for_new_stub_target() {
1833        let tmp = TempDir::new().unwrap();
1834        let mem_dir = tmp.path().to_path_buf();
1835        let writer = FilesystemMemWriter::new(mem_dir.clone());
1836        let mut engine = Engine::from_mounts(vec![(
1837            folder_mount("specs", mem_dir),
1838            Box::new(writer) as Box<dyn MemBackend>,
1839        )])
1840        .unwrap();
1841        let (actor, client) = cli_actor();
1842
1843        let ghost = crate::EntityId::new("specs", "ghost-target");
1844        assert!(!engine.store().contains(&ghost), "ghost must not pre-exist");
1845
1846        let mut args = empty_create_args("specs", "Source With Body Link");
1847        args.sections.insert(
1848            "identity".to_string(),
1849            "ref [[ghost-target]] for context".to_string(),
1850        );
1851
1852        let outcome = engine
1853            .create_entity(args, actor, Some(&client), None)
1854            .unwrap();
1855        let stubbed: Vec<&crate::EntityId> = outcome
1856            .warnings
1857            .iter()
1858            .filter_map(|w| match w {
1859                WarningHint::InlineWikiLinkAutoStubbed { stubs, .. } => Some(stubs),
1860                _ => None,
1861            })
1862            .flatten()
1863            .collect();
1864        assert!(
1865            stubbed.contains(&&ghost),
1866            "INLINE_WIKI_LINK_AUTO_STUBBED warning must name the ghost target; got: {:?}",
1867            outcome.warnings,
1868        );
1869        // The stub also lands in the store and the REFERENCES edge exists.
1870        assert!(
1871            engine.store().contains(&ghost),
1872            "ghost stub must materialise"
1873        );
1874    }
1875
1876    /// CLI F11: a body wiki-link to the entity's own slug is dropped (no
1877    /// vacuous self-edge) with a `SELF_LINK_IGNORED` warning, while a body
1878    /// link to a *different* target in the same entity still synthesises
1879    /// its REFERENCES edge normally — only the self-target is dropped.
1880    #[test]
1881    fn create_entity_drops_self_link_keeps_other_links_and_warns() {
1882        let tmp = TempDir::new().unwrap();
1883        let mem_dir = tmp.path().to_path_buf();
1884        let writer = FilesystemMemWriter::new(mem_dir.clone());
1885        let mut engine = Engine::from_mounts(vec![(
1886            folder_mount("specs", mem_dir),
1887            Box::new(writer) as Box<dyn MemBackend>,
1888        )])
1889        .unwrap();
1890        let (actor, client) = cli_actor();
1891
1892        // Title "Selfie" → slug "selfie" → id "specs--selfie". The body
1893        // links its own slug AND a different target.
1894        let mut args = empty_create_args("specs", "Selfie");
1895        args.sections.insert(
1896            "identity".to_string(),
1897            "see [[selfie]] itself and also [[other-ref]]".to_string(),
1898        );
1899        let outcome = engine
1900            .create_entity(args, actor, Some(&client), None)
1901            .unwrap();
1902        let self_id = outcome.id.clone();
1903        assert_eq!(self_id.to_string(), "specs--selfie");
1904        let other_id = crate::EntityId::new("specs", "other-ref");
1905
1906        // SELF_LINK_IGNORED warning names the self-linking entity.
1907        assert!(
1908            outcome.warnings.iter().any(|w| matches!(
1909                w, WarningHint::SelfLinkIgnored { id } if *id == self_id
1910            )),
1911            "self-link must emit SELF_LINK_IGNORED; got: {:?}",
1912            outcome.warnings,
1913        );
1914
1915        // No self-edge: not in relationships, not Outgoing, not Incoming.
1916        let ent = engine.get_entity(&self_id).unwrap();
1917        assert!(
1918            ent.relationships.iter().all(|r| r.target != self_id),
1919            "no self-relation may be synthesised; got: {:?}",
1920            ent.relationships,
1921        );
1922        assert!(
1923            engine
1924                .store()
1925                .outgoing(&self_id)
1926                .iter()
1927                .all(|e| e.target != self_id),
1928            "self must not be its own Outgoing neighbour",
1929        );
1930        assert!(
1931            engine
1932                .store()
1933                .incoming(&self_id)
1934                .iter()
1935                .all(|e| e.from != self_id),
1936            "self must not be its own Incoming neighbour",
1937        );
1938
1939        // Complement: the link to a *different* target synthesised its
1940        // REFERENCES edge normally.
1941        assert!(
1942            ent.relationships
1943                .iter()
1944                .any(|r| r.rel_type == "REFERENCES" && r.target == other_id),
1945            "non-self body link must still synthesise its edge; got: {:?}",
1946            ent.relationships,
1947        );
1948    }
1949
1950    /// dry_run preview matches real-write outcome.
1951    #[test]
1952    fn create_entity_dry_run_emits_same_auto_stub_warning() {
1953        let tmp = TempDir::new().unwrap();
1954        let mem_dir = tmp.path().to_path_buf();
1955        let writer = FilesystemMemWriter::new(mem_dir.clone());
1956        let mut engine = Engine::from_mounts(vec![(
1957            folder_mount("specs", mem_dir),
1958            Box::new(writer) as Box<dyn MemBackend>,
1959        )])
1960        .unwrap();
1961        let (actor, client) = cli_actor();
1962
1963        let mut args = empty_create_args("specs", "Dry Run Body Link");
1964        args.dry_run = true;
1965        args.sections
1966            .insert("identity".to_string(), "see [[dry-run-ghost]]".to_string());
1967
1968        let outcome = engine
1969            .create_entity(args, actor, Some(&client), None)
1970            .unwrap();
1971        let has_warning = outcome.warnings.iter().any(|w| {
1972            matches!(
1973                w,
1974                WarningHint::InlineWikiLinkAutoStubbed { stubs, .. }
1975                    if stubs.iter().any(|t| t.to_string() == "specs--dry-run-ghost")
1976            )
1977        });
1978        assert!(
1979            has_warning,
1980            "dry_run must emit the same warning as real write: {:?}",
1981            outcome.warnings
1982        );
1983    }
1984
1985    /// Body wiki-link to a target that already exists
1986    /// in the store does NOT fire the warning — no stub was created.
1987    #[test]
1988    fn create_entity_no_auto_stub_warning_when_target_exists() {
1989        let tmp = TempDir::new().unwrap();
1990        let (mut engine, existing) = engine_with_seed(&tmp, "Existing Target");
1991        let (actor, client) = cli_actor();
1992
1993        let mut args = empty_create_args("specs", "Source Linking Existing");
1994        let body = format!("ref [[{}]]", existing.id.path());
1995        args.sections.insert("identity".to_string(), body);
1996
1997        let outcome = engine
1998            .create_entity(args, actor, Some(&client), None)
1999            .unwrap();
2000        let has_warning = outcome
2001            .warnings
2002            .iter()
2003            .any(|w| matches!(w, WarningHint::InlineWikiLinkAutoStubbed { .. }));
2004        assert!(
2005            !has_warning,
2006            "no auto-stub warning when target pre-exists; got: {:?}",
2007            outcome.warnings
2008        );
2009    }
2010
2011    /// Two-mem Write-Write scaffold —
2012    /// `test` and `other` both pin the default schema, no
2013    /// `cross_mem_links` policy set yet (default deny-all). The
2014    /// caller installs the policy that matches each scenario.
2015    fn engine_with_two_default_mems() -> (TempDir, TempDir, Engine) {
2016        let tmp_test = TempDir::new().unwrap();
2017        let tmp_other = TempDir::new().unwrap();
2018        let test_dir = tmp_test.path().to_path_buf();
2019        let other_dir = tmp_other.path().to_path_buf();
2020        let writer_test = FilesystemMemWriter::new(test_dir.clone());
2021        let writer_other = FilesystemMemWriter::new(other_dir.clone());
2022        let engine = Engine::from_mounts(vec![
2023            (
2024                folder_mount("test", test_dir),
2025                Box::new(writer_test) as Box<dyn MemBackend>,
2026            ),
2027            (
2028                folder_mount("other", other_dir),
2029                Box::new(writer_other) as Box<dyn MemBackend>,
2030            ),
2031        ])
2032        .unwrap();
2033        (tmp_test, tmp_other, engine)
2034    }
2035
2036    /// `memstead_create` with an inline cross-mem relation refuses
2037    /// with `CROSS_MEM_LINK_NOT_ALLOWED` when policy denies the
2038    /// direction. The entity does not persist; the would-be id reads
2039    /// as `NotFound`.
2040    #[test]
2041    fn create_entity_refuses_inline_cross_mem_relation_when_policy_denies() {
2042        use crate::entity::EntityId;
2043        use crate::ops::RelateArg;
2044        use memstead_schema::workspace_config::CrossLinkValue;
2045
2046        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2047        let (actor, client) = cli_actor();
2048
2049        // Policy: `test → other` granted only. The inline create
2050        // request below is `other → test`, which must refuse.
2051        let mut settings = crate::workspace::WorkspaceSettings::default();
2052        settings.cross_mem_links.insert(
2053            "test".to_string(),
2054            CrossLinkValue::List(vec!["other".to_string()]),
2055        );
2056        engine.set_settings(settings);
2057
2058        // Seed a target in the `test` mem so the inline relation
2059        // names a real id (the policy gate fires before target
2060        // resolution regardless, but a real target removes any
2061        // ambiguity from the assertion).
2062        let target = engine
2063            .create_entity(
2064                empty_create_args("test", "Target"),
2065                actor,
2066                Some(&client),
2067                None,
2068            )
2069            .unwrap();
2070
2071        let mut args = empty_create_args("other", "Source");
2072        args.relations = vec![RelateArg {
2073            rel_type: "IMPLEMENTS".to_string(),
2074            to: target.id.clone(),
2075            description: None,
2076        }];
2077        let err = engine
2078            .create_entity(args, actor, Some(&client), None)
2079            .unwrap_err();
2080        match err {
2081            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2082                assert_eq!(from_mem, "other");
2083                assert_eq!(to_mem, "test");
2084            }
2085            other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
2086        }
2087
2088        // No entity landed: the would-be id is absent.
2089        let would_be = EntityId::new("other", "source");
2090        assert!(
2091            engine.get_entity(&would_be).is_none(),
2092            "entity must not persist when inline relation refuses"
2093        );
2094    }
2095
2096    /// With the granted direction, the
2097    /// inline cross-mem relation succeeds and the edge persists.
2098    #[test]
2099    fn create_entity_allows_inline_cross_mem_relation_when_policy_grants() {
2100        use crate::ops::RelateArg;
2101        use memstead_schema::workspace_config::CrossLinkValue;
2102
2103        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2104        let (actor, client) = cli_actor();
2105
2106        let mut settings = crate::workspace::WorkspaceSettings::default();
2107        settings.cross_mem_links.insert(
2108            "other".to_string(),
2109            CrossLinkValue::List(vec!["test".to_string()]),
2110        );
2111        engine.set_settings(settings);
2112
2113        let target = engine
2114            .create_entity(
2115                empty_create_args("test", "Target"),
2116                actor,
2117                Some(&client),
2118                None,
2119            )
2120            .unwrap();
2121
2122        let mut args = empty_create_args("other", "Source");
2123        args.relations = vec![RelateArg {
2124            rel_type: "IMPLEMENTS".to_string(),
2125            to: target.id.clone(),
2126            description: None,
2127        }];
2128        let outcome = engine
2129            .create_entity(args, actor, Some(&client), None)
2130            .unwrap();
2131        let stored = engine.get_entity(&outcome.id).expect("entity persists");
2132        assert!(
2133            stored
2134                .relationships
2135                .iter()
2136                .any(|r| r.rel_type == "IMPLEMENTS" && r.target == target.id),
2137            "IMPLEMENTS edge must persist on the source's relationships",
2138        );
2139    }
2140
2141    /// A same-mem inline relation
2142    /// bypasses the policy gate entirely. Even with an empty policy
2143    /// (default deny-all for cross-mem), the create succeeds.
2144    #[test]
2145    fn create_entity_admits_same_mem_inline_relation_regardless_of_policy() {
2146        use crate::ops::RelateArg;
2147
2148        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2149        let (actor, client) = cli_actor();
2150        // No cross_mem_links set; same-mem writes must still work.
2151
2152        let target = engine
2153            .create_entity(
2154                empty_create_args("test", "Target"),
2155                actor,
2156                Some(&client),
2157                None,
2158            )
2159            .unwrap();
2160        let mut args = empty_create_args("test", "Source");
2161        args.relations = vec![RelateArg {
2162            rel_type: "USES".to_string(),
2163            to: target.id.clone(),
2164            description: None,
2165        }];
2166        let outcome = engine
2167            .create_entity(args, actor, Some(&client), None)
2168            .unwrap();
2169        let stored = engine.get_entity(&outcome.id).expect("entity persists");
2170        assert!(
2171            stored
2172                .relationships
2173                .iter()
2174                .any(|r| r.rel_type == "USES" && r.target == target.id),
2175            "same-mem USES edge must persist",
2176        );
2177    }
2178
2179    /// The existing `memstead_relate` path
2180    /// refuses the same scenario with the same typed code and
2181    /// payload shape — the two surfaces' refusals are
2182    /// indistinguishable to an agent.
2183    #[test]
2184    fn relate_and_create_refuse_cross_mem_policy_with_identical_envelope() {
2185        use crate::ops::RelateArg;
2186        use memstead_schema::workspace_config::CrossLinkValue;
2187
2188        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2189        let (actor, client) = cli_actor();
2190
2191        let mut settings = crate::workspace::WorkspaceSettings::default();
2192        settings.cross_mem_links.insert(
2193            "test".to_string(),
2194            CrossLinkValue::List(vec!["other".to_string()]),
2195        );
2196        engine.set_settings(settings);
2197
2198        let target = engine
2199            .create_entity(
2200                empty_create_args("test", "Target"),
2201                actor,
2202                Some(&client),
2203                None,
2204            )
2205            .unwrap();
2206        let src = engine
2207            .create_entity(
2208                empty_create_args("other", "Source"),
2209                actor,
2210                Some(&client),
2211                None,
2212            )
2213            .unwrap();
2214
2215        // memstead_relate refusal.
2216        let relate_err = engine
2217            .relate_entity(
2218                RelateEntityArgs {
2219                    source: src.id.clone(),
2220                    rel_type: "IMPLEMENTS".to_string(),
2221                    target: target.id.clone(),
2222                    expected_hash: Some(src.content_hash.clone()),
2223                    remove: false,
2224                    description: None,
2225                },
2226                actor,
2227                Some(&client),
2228                None,
2229            )
2230            .unwrap_err();
2231
2232        // memstead_create.relations[] refusal — fresh title so the create
2233        // attempt hasn't already landed.
2234        let mut create_args = empty_create_args("other", "Source Two");
2235        create_args.relations = vec![RelateArg {
2236            rel_type: "IMPLEMENTS".to_string(),
2237            to: target.id.clone(),
2238            description: None,
2239        }];
2240        let create_err = engine
2241            .create_entity(create_args, actor, Some(&client), None)
2242            .unwrap_err();
2243
2244        // Both refusals share the typed code, the payload shape, and
2245        // the (from_mem, to_mem) values.
2246        match (relate_err, create_err) {
2247            (
2248                EngineError::CrossMemLinkNotAllowed {
2249                    from_mem: rfv,
2250                    to_mem: rtv,
2251                },
2252                EngineError::CrossMemLinkNotAllowed {
2253                    from_mem: cfv,
2254                    to_mem: ctv,
2255                },
2256            ) => {
2257                assert_eq!(rfv, "other");
2258                assert_eq!(rtv, "test");
2259                assert_eq!(cfv, "other");
2260                assert_eq!(ctv, "test");
2261            }
2262            (a, b) => panic!(
2263                "expected matching CROSS_MEM_LINK_NOT_ALLOWED on both surfaces; got relate={a:?}, create={b:?}"
2264            ),
2265        }
2266    }
2267
2268    /// Body wiki-link `[[other--target]]` in mem `test` (with
2269    /// `test → other` granted) creates the entity, auto-stubs at
2270    /// `other--target` (NOT `test--other--target` — that was the
2271    /// pre-fix phantom-stub bug), and emits one REFERENCES edge via
2272    /// the alias-synthesis path.
2273    #[test]
2274    fn create_entity_body_link_cross_mem_dash_form_routes_correctly() {
2275        use crate::entity::EntityId;
2276        use indexmap::IndexMap;
2277        use memstead_schema::workspace_config::CrossLinkValue;
2278
2279        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2280        let (actor, client) = cli_actor();
2281
2282        let mut settings = crate::workspace::WorkspaceSettings::default();
2283        settings.cross_mem_links.insert(
2284            "test".to_string(),
2285            CrossLinkValue::List(vec!["other".to_string()]),
2286        );
2287        engine.set_settings(settings);
2288
2289        let mut sections: IndexMap<String, String> = IndexMap::new();
2290        sections.insert(
2291            "identity".to_string(),
2292            "see [[other--target]] for details".to_string(),
2293        );
2294        sections.insert("purpose".to_string(), "source purpose".to_string());
2295        let outcome = engine
2296            .create_entity(
2297                crate::engine::CreateEntityArgs {
2298                    mem: "test".to_string(),
2299                    title: "Source".to_string(),
2300                    entity_type: "spec".to_string(),
2301                    sections,
2302                    metadata: IndexMap::new(),
2303                    relations: Vec::new(),
2304                    dry_run: false,
2305                },
2306                actor,
2307                Some(&client),
2308                None,
2309            )
2310            .unwrap();
2311
2312        // Auto-stub landed at `other--target`, NOT `test--other--target`.
2313        let canonical = EntityId::new("other", "target");
2314        assert!(
2315            engine.get_entity(&canonical).is_some(),
2316            "auto-stub must land at the canonical cross-mem id"
2317        );
2318        let phantom = EntityId::new("test", "other--target");
2319        assert!(
2320            engine.get_entity(&phantom).is_none(),
2321            "no double-prefixed phantom stub"
2322        );
2323
2324        // Exactly one REFERENCES edge to the cross-mem target.
2325        let source = engine.get_entity(&outcome.id).unwrap();
2326        let references_count = source
2327            .relationships
2328            .iter()
2329            .filter(|r| r.rel_type == "REFERENCES" && r.target == canonical)
2330            .count();
2331        assert_eq!(
2332            references_count, 1,
2333            "alias-synthesis must emit exactly one REFERENCES edge per cross-mem body link",
2334        );
2335    }
2336
2337    /// Complement: body wiki-link cross-mem refusal when policy
2338    /// denies the direction. The auto-stub never lands, the entity
2339    /// never persists.
2340    #[test]
2341    fn create_entity_body_link_cross_mem_refused_when_policy_denies() {
2342        use indexmap::IndexMap;
2343
2344        let (_tmp_test, _tmp_other, mut engine) = engine_with_two_default_mems();
2345        let (actor, client) = cli_actor();
2346        // Empty cross-link policy — `test → other` denied.
2347
2348        let mut sections: IndexMap<String, String> = IndexMap::new();
2349        sections.insert(
2350            "identity".to_string(),
2351            "see [[other--target]] for details".to_string(),
2352        );
2353        sections.insert("purpose".to_string(), "source purpose".to_string());
2354        let err = engine
2355            .create_entity(
2356                crate::engine::CreateEntityArgs {
2357                    mem: "test".to_string(),
2358                    title: "Source".to_string(),
2359                    entity_type: "spec".to_string(),
2360                    sections,
2361                    metadata: IndexMap::new(),
2362                    relations: Vec::new(),
2363                    dry_run: false,
2364                },
2365                actor,
2366                Some(&client),
2367                None,
2368            )
2369            .unwrap_err();
2370        match err {
2371            EngineError::CrossMemLinkNotAllowed { from_mem, to_mem } => {
2372                assert_eq!(from_mem, "test");
2373                assert_eq!(to_mem, "other");
2374            }
2375            other => panic!("expected CROSS_MEM_LINK_NOT_ALLOWED, got {other:?}"),
2376        }
2377    }
2378
2379    /// `[mutations].require_notes = true` drives a single `NOTE_MISSING`
2380    /// warning out of the engine mutation pipeline on every noteless
2381    /// mutation — the single enforcement point both the CLI and the MCP
2382    /// transport inherit. The mutation still commits (the policy nudges,
2383    /// it never blocks). Supplying a note suppresses it; turning the
2384    /// policy off silences it entirely. Covers create / update / relate
2385    /// in one engine instance.
2386    #[test]
2387    fn require_notes_drives_single_note_missing_warning_per_noteless_mutation() {
2388        use crate::engine::UpdateEntityArgs;
2389        use crate::workspace::{MutationsSection, WorkspaceSettings};
2390        use indexmap::IndexMap;
2391
2392        let tmp = TempDir::new().unwrap();
2393        let mem_dir = tmp.path().to_path_buf();
2394        let writer = FilesystemMemWriter::new(mem_dir.clone());
2395        let mut engine = Engine::from_mounts(vec![(
2396            folder_mount("specs", mem_dir.clone()),
2397            Box::new(writer) as Box<dyn MemBackend>,
2398        )])
2399        .unwrap();
2400        engine.set_workspace_root(mem_dir.clone());
2401        engine.set_settings(WorkspaceSettings {
2402            mutations: MutationsSection {
2403                require_notes: Some(true),
2404            },
2405            ..Default::default()
2406        });
2407        let (actor, client) = cli_actor();
2408
2409        let note_missing = |ws: &[WarningHint]| -> usize {
2410            ws.iter()
2411                .filter(|w| matches!(w, WarningHint::NoteMissing { tool: _ }))
2412                .count()
2413        };
2414
2415        // --- create, no note: exactly one NOTE_MISSING, commit landed ---
2416        let created = engine
2417            .create_entity(
2418                empty_create_args("specs", "Noteless"),
2419                actor,
2420                Some(&client),
2421                None,
2422            )
2423            .unwrap();
2424        assert_eq!(
2425            note_missing(&created.warnings),
2426            1,
2427            "create under require_notes must emit exactly one NOTE_MISSING; got {:?}",
2428            created.warnings,
2429        );
2430        assert!(
2431            matches!(
2432                created.warnings.iter().find(|w| matches!(w, WarningHint::NoteMissing { .. })),
2433                Some(WarningHint::NoteMissing { tool }) if tool == "create_entity"
2434            ),
2435            "the warning names the engine-level verb",
2436        );
2437        assert!(
2438            !created.commit_sha.is_empty(),
2439            "create still commits (nudge, not block)"
2440        );
2441
2442        // --- update, no note: NOTE_MISSING + commit landed ---
2443        let mut edit: IndexMap<String, String> = IndexMap::new();
2444        edit.insert("identity".to_string(), "revised".to_string());
2445        let updated = engine
2446            .update_entity(
2447                UpdateEntityArgs {
2448                    id: created.id.clone(),
2449                    expected_hash: Some(created.content_hash.clone()),
2450                    sections: edit,
2451                    append_sections: IndexMap::new(),
2452                    patch_sections: IndexMap::new(),
2453                    metadata: IndexMap::new(),
2454                    metadata_unset: Vec::new(),
2455                    declare_relations: Vec::new(),
2456                    dry_run: false,
2457                    relations_unset: Vec::new(),
2458                },
2459                actor,
2460                Some(&client),
2461                None,
2462            )
2463            .unwrap();
2464        assert_eq!(
2465            note_missing(&updated.warnings),
2466            1,
2467            "update emits NOTE_MISSING"
2468        );
2469        assert!(!updated.commit_sha.is_empty(), "update still commits");
2470
2471        // --- relate, no note: NOTE_MISSING + commit landed ---
2472        let target = engine
2473            .create_entity(
2474                empty_create_args("specs", "Target"),
2475                actor,
2476                Some(&client),
2477                Some("seed"),
2478            )
2479            .unwrap();
2480        let related = engine
2481            .relate_entity(
2482                RelateEntityArgs {
2483                    source: updated.id.clone(),
2484                    expected_hash: Some(updated.content_hash.clone()),
2485                    rel_type: "USES".to_string(),
2486                    target: target.id.clone(),
2487                    remove: false,
2488                    description: None,
2489                },
2490                actor,
2491                Some(&client),
2492                None,
2493            )
2494            .unwrap();
2495        assert_eq!(
2496            note_missing(&related.warnings),
2497            1,
2498            "relate emits NOTE_MISSING"
2499        );
2500        assert!(!related.commit_sha.is_empty(), "relate still commits");
2501
2502        // --- with a note: suppressed ---
2503        let with_note = engine
2504            .create_entity(
2505                empty_create_args("specs", "Documented"),
2506                actor,
2507                Some(&client),
2508                Some("a real provenance note"),
2509            )
2510            .unwrap();
2511        assert_eq!(
2512            note_missing(&with_note.warnings),
2513            0,
2514            "a supplied note suppresses the warning",
2515        );
2516
2517        // --- policy off: silent even without a note ---
2518        engine.set_settings(WorkspaceSettings::default());
2519        let after_off = engine
2520            .create_entity(
2521                empty_create_args("specs", "Quiet"),
2522                actor,
2523                Some(&client),
2524                None,
2525            )
2526            .unwrap();
2527        assert_eq!(
2528            note_missing(&after_off.warnings),
2529            0,
2530            "no NOTE_MISSING when require_notes is unset",
2531        );
2532    }
2533}