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