Skip to main content

memstead_base/engine/
outcomes.rs

1//! Argument/outcome shapes for the mutation entrypoints
2//! (`Engine::create_entity`, `update_entity`, `delete_entity`,
3//! `relate_entity`, `rename_entity`). The MCP wire envelopes and CLI
4//! command output formatters branch on these shapes; their field
5//! layouts are part of the engine's public surface.
6
7use indexmap::IndexMap;
8
9use crate::entity::EntityId;
10use crate::ops::{IncomingRef, ModifiedMetadata, ModifiedSections, WarningHint};
11
12/// Arguments for [`Engine::create_entity`].
13///
14/// Carries the target mem (routes to the right mount), the entity
15/// shape (title, type, sections, metadata), and nothing else. Caller
16/// identity (actor, client, note) goes through the standalone
17/// arguments so the same MCP-tool / CLI-direct shape works.
18#[derive(Debug, Clone)]
19pub struct CreateEntityArgs {
20    pub mem: String,
21    pub title: String,
22    pub entity_type: String,
23    pub sections: IndexMap<String, String>,
24    pub metadata: IndexMap<String, String>,
25    /// Inline relationships to wire as outgoing edges from the new
26    /// entity. Each entry's `to` may name an absent target — the
27    /// engine auto-stubs it (mirrors full's create + stub
28    /// creation on relate). Open-mode admissions surface as
29    /// [`WarningHint::UndeclaredRelationshipOpen`] in the outcome's
30    /// `warnings`. Empty default — callers omit when no inline
31    /// edges are needed.
32    pub relations: Vec<crate::ops::RelateArg>,
33    /// Permissive `anchors[]` provenance records to attach to the new
34    /// entity — validated ([`crate::anchor::AnchorInput::validate`]) and,
35    /// when non-empty, written into the mem-branch anchors sidecar in the
36    /// SAME commit as the entity so the two land atomically. Empty (the
37    /// default) writes no sidecar and leaves behaviour byte-identical to a
38    /// pre-anchor create. A malformed element refuses the whole create with
39    /// [`EngineError::InvalidAnchor`] (`INVALID_ANCHOR`) — the entity is not
40    /// written. Not folded into `_hash` (sidecar lives under `.memstead/`).
41    pub anchors: Vec<crate::anchor::AnchorInput>,
42    /// When `true`, validate and compute the prospective hash but
43    /// do not write to disk, mutate the store, create edges, or
44    /// commit. Outcome carries `content_hash` = the prospective
45    /// hash and `commit_sha` empty — wire-equivalent to full's
46    /// `CreateArgs.dry_run` semantics.
47    pub dry_run: bool,
48}
49
50/// Successful outcome of [`Engine::create_entity`].
51#[derive(Debug, Clone, serde::Serialize)]
52pub struct CreateEntityOutcome {
53    pub id: EntityId,
54    /// Echoed from the request — full's `CreateResult.title` carries
55    /// the same value so wire callers don't need to derive it from
56    /// the id.
57    pub title: String,
58    /// Echoed from the request — full's `CreateResult.mem`. The
59    /// `EntityId.mem()` accessor projects the same value, but
60    /// surfacing it explicitly mirrors full's wire shape.
61    pub mem: String,
62    /// Mem-relative path of the freshly-written `.md` file.
63    pub file_path: String,
64    /// SHA-256 of the canonical bytes. Round-trips as
65    /// `expected_hash` for the next mutation against this entity.
66    /// The wire
67    /// key is `_hash` to match `memstead_entity`'s read envelope and
68    /// the underscore-prefix convention for engine metadata. Pre-
69    /// fix mutation responses serialised this as `content_hash`,
70    /// forcing agents to rename the field when piping the value
71    /// into a follow-up call.
72    #[serde(rename = "_hash")]
73    pub content_hash: String,
74    /// Per-mem commit identifier returned by the backend's
75    /// [`crate::backend::MemBackend::commit`]. Wire-equivalent to
76    /// full's `CreateResult.commit_sha`.
77    pub commit_sha: String,
78    /// ISO date string from the parsed entity's `created_date`
79    /// metadata. Today's date when the schema's auto-stamp filled
80    /// it in; the existing value when re-materialising a stub with
81    /// `init_timestamp` semantics. Wire-equivalent to full's
82    /// `CreateResult.created_date`.
83    pub created_date: String,
84    /// Typed Tier-2 warnings — today
85    /// [`WarningHint::MissingRequiredSection`] for empty / absent
86    /// required sections. Populated even when the create succeeded
87    /// so callers see the same self-correction prompts the existing
88    /// engines emit. Wire-equivalent to full's
89    /// `CreateResult.warnings`.
90    pub warnings: Vec<WarningHint>,
91    /// Type-level `write_rules` keyed by `entity_type` — the
92    /// MISSING_REQUIRED_SECTION / MISSING_REQUIRED_FIELD warnings
93    /// reference this top-level map via their `entity_type` field
94    /// rather than each carrying the (identical, type-axis) array.
95    /// Empty when no such warnings fire; stable empty shape ships
96    /// on the wire so consumers don't branch on field presence
97    /// (F9). Sorted by key for deterministic output.
98    pub type_guidance: std::collections::BTreeMap<String, Vec<String>>,
99    /// Number of incoming edges adopted from a pre-existing stub
100    /// at this id. `None` when no stub adoption happened (no
101    /// pre-existing entity, or a real entity at the id — but that
102    /// path errors with `AlreadyExists` before this field is
103    /// computed). Wire-equivalent to full's
104    /// `CreateResult.incoming_count`.
105    pub incoming_count: Option<usize>,
106    /// Incoming edges present at this id post-create — populated
107    /// from `store.incoming(id)` after the parse + upsert. Empty
108    /// when no pre-existing stub had referrers. Wire-equivalent to
109    /// full's `CreateResult.incoming`.
110    pub incoming: Vec<IncomingRef>,
111    /// Batched relation declarations from the request's existing
112    /// `relations[]` parameter. Mirrors
113    /// [`UpdateEntityOutcome::relations_declared`] so the agent sees
114    /// one wire shape across `memstead_create` and `memstead_update`. Empty
115    /// `[]` when no relations were declared. `target_was_stubbed`
116    /// reports the same flag the existing relate auto-stub path
117    /// emits via `WarningHint::InlineWikiLinkAutoStubbed`.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub relations_declared: Vec<RelationDeclared>,
120}
121
122/// Arguments for [`Engine::update_entity`].
123#[derive(Debug, Clone)]
124pub struct UpdateEntityArgs {
125    pub id: EntityId,
126    /// Optimistic locking. `None` skips the check.
127    pub expected_hash: Option<String>,
128    /// Section keys whose body should be replaced wholesale. Empty
129    /// values overwrite with empty content.
130    pub sections: IndexMap<String, String>,
131    /// Section keys whose body should be appended to. Existing body
132    /// gets a `\n` separator before the append; empty/absent body
133    /// is replaced wholesale with the append value (parity with
134    /// full's append-on-empty behaviour). The same key may not
135    /// appear in both `sections` and `append_sections`; conflict
136    /// is rejected with [`EngineError::ConflictingSectionModes`].
137    pub append_sections: IndexMap<String, String>,
138    /// Section keys whose body should be patched via find-and-
139    /// replace. Each value is a [`crate::ops::PatchArg`] with
140    /// `old`, `new`, and `all` (replace every occurrence vs first
141    /// only). Errors with [`EngineError::PatchSectionEmpty`] when
142    /// the section is absent and [`EngineError::PatchOldNotFound`]
143    /// when `old` doesn't appear. Mutually exclusive with the
144    /// other two section modes for the same key.
145    pub patch_sections: IndexMap<String, crate::ops::PatchArg>,
146    /// Metadata fields to set or replace. Values land as
147    /// `MetadataValue::String` for V1.
148    pub metadata: IndexMap<String, String>,
149    /// Metadata field keys to unset. Silently no-ops on absent keys.
150    pub metadata_unset: Vec<String>,
151    /// When `true`, validate and compute the prospective hash but
152    /// do not write to disk, mutate the store, or commit. Outcome
153    /// carries `content_hash` = the unchanged on-disk hash (so the
154    /// caller can use it as `expected_hash` on the follow-up real
155    /// call) and `prospective_hash` = the hash the entity would
156    /// have after the proposed write. Wire-equivalent to full's
157    /// `UpdateArgs.dry_run`. Optimistic-lock check is skipped on
158    /// the dry_run path so an agent can preview a change without
159    /// holding a fresh hash — designated stale-hash recovery path.
160    pub dry_run: bool,
161    /// Atomic batched relation declarations applied before the
162    /// section/metadata changes land. Each entry is validated like
163    /// any individual `memstead_relate` call (schema-shape, cross-mem
164    /// policy, target-id grammar), appended to the entity's
165    /// `relationships` list, and — for absent Write-target peers —
166    /// auto-stubbed in the target's mem. The strict
167    /// wiki-link/relation validator then runs against the
168    /// post-mutation state with the freshly-declared relations
169    /// already in place, so a body wiki-link added in the same
170    /// `memstead_update` call passes the gate without a separate
171    /// `memstead_relate` round-trip. Empty default — omit when no
172    /// batched declarations are needed.
173    pub declare_relations: Vec<crate::ops::RelateArg>,
174    /// Permissive `anchors[]` provenance records to attach to this entity
175    /// — validated ([`crate::anchor::AnchorInput::validate`]) and, when
176    /// non-empty, written into the mem-branch anchors sidecar in the SAME
177    /// commit as the update so entity + anchors land atomically. Empty (the
178    /// default) writes no sidecar and leaves the update byte-identical to a
179    /// pre-anchor call. A malformed element refuses the whole update with
180    /// [`EngineError::InvalidAnchor`] (`INVALID_ANCHOR`) — nothing is
181    /// written. Not folded into `_hash` (sidecar lives under `.memstead/`).
182    pub anchors: Vec<crate::anchor::AnchorInput>,
183    /// Repair-shaped relation removals (`{ rel_type, target }`),
184    /// applied atomically within this update. Accepted only when the
185    /// entity currently FAILS the conformance check (against the
186    /// effective schema) — a conformant entity refuses with
187    /// `REPAIR_NOT_NEEDED` and stays unmodified; `memstead_relate(remove)`
188    /// is the everyday detach path. Absent pairs are silent no-ops
189    /// (symmetric with `metadata_unset`). The strict-write
190    /// post-condition is unchanged: the post-repair entity must be
191    /// integral or the whole update refuses with the relevant
192    /// write-time code.
193    pub relations_unset: Vec<crate::ops::RelationUnsetArg>,
194}
195
196/// Successful outcome of [`Engine::update_entity`].
197#[derive(Debug, Clone, serde::Serialize)]
198pub struct UpdateEntityOutcome {
199    pub id: EntityId,
200    /// Title from the parsed entity after the write — wire-equivalent
201    /// to full's `UpdateResult.title`. Reflects post-write state in
202    /// case a future update path touches the title (today the update
203    /// surface doesn't, but reading from the parsed entity rather
204    /// than echoing `args` keeps the field correct as the surface
205    /// evolves).
206    pub title: String,
207    pub file_path: String,
208    /// Wire key `_hash`.
209    #[serde(rename = "_hash")]
210    pub content_hash: String,
211    /// Per-mem commit identifier returned by the backend's
212    /// [`crate::backend::MemBackend::commit`]. Wire-equivalent to
213    /// full's `UpdateResult.commit_sha`.
214    pub commit_sha: String,
215    /// ISO date string from the parsed entity's `modified_date`
216    /// metadata. Populated when the schema auto-stamps the field
217    /// on update; empty when the schema doesn't declare it. Wire-
218    /// equivalent to full's `UpdateResult.modified_date`.
219    pub modified_date: String,
220    /// Section-level mutations grouped by mode (replaced / appended /
221    /// patched). Wire-equivalent to full's
222    /// `UpdateResult.modified_sections`. Empty inner vecs serde-omit
223    /// per `ModifiedSections`'s field attributes; the outer key is
224    /// always present.
225    pub modified_sections: ModifiedSections,
226    /// Metadata-level mutations grouped by direction (set / unset).
227    /// Wire-equivalent to full's `UpdateResult.modified_metadata`.
228    /// Same empty-vec-omit convention as `modified_sections`.
229    pub modified_metadata: ModifiedMetadata,
230    /// `Some(hash)` on the dry_run path — the hash the entity
231    /// would have after the proposed write. `None` on real
232    /// updates (the post-write hash is in `content_hash`).
233    /// Wire-equivalent to full's `UpdateResult.prospective_hash`.
234    pub prospective_hash: Option<String>,
235    /// Stub entities whose last incoming edge was severed by this
236    /// update — when a body wiki-link was removed, the alias-resync
237    /// drops the backing pointer-rel-type edge, and if that was the
238    /// stub target's last referrer the stub is GC'd here. Empty on
239    /// updates that didn't orphan a stub (including section edits with
240    /// no wiki-link change, dry-run, and no-op). Shares the field name
241    /// and always-present shape with
242    /// [`DeleteEntityOutcome::orphan_stubs_removed`] and
243    /// [`RelateEntityOutcome::orphan_stubs_removed`] so MCP / CLI
244    /// consumers branch uniformly across the three GC paths.
245    pub orphan_stubs_removed: Vec<EntityId>,
246    /// Typed non-fatal issues — empty on the unified path today
247    /// (update doesn't surface InlineWikiLinkAutoStubbed or
248    /// MissingRequiredOutgoing yet). Wire-equivalent to full's
249    /// `UpdateResult.warnings`; the field shape parity matters for
250    /// the upcoming handler migration so callers see the same
251    /// `warnings: []` envelope position across flavours.
252    pub warnings: Vec<WarningHint>,
253    /// Batched relation declarations applied by this call (per the
254    /// optional `declare_relations` request param). Empty `[]`
255    /// when no batched declarations were requested; populated with
256    /// one entry per declared relation otherwise. `target_was_stubbed`
257    /// flags which targets were absent at call time and got
258    /// auto-stubbed; agents use this to skip a follow-up
259    /// `memstead_entity` round-trip on the stubbed target.
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub relations_declared: Vec<RelationDeclared>,
262}
263
264/// One batched relation declaration applied by a mutation call.
265/// Echoed in [`UpdateEntityOutcome::relations_declared`] and
266/// [`CreateEntityOutcome::relations_declared`] so agents see, in the
267/// same response, what landed and which targets had to be stubbed.
268#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
269pub struct RelationDeclared {
270    pub rel_type: String,
271    pub target: EntityId,
272    /// `true` when the target was absent at call time and the
273    /// engine materialised a stub for it (subject to the same
274    /// rules as `memstead_relate`'s auto-stub mechanic).
275    pub target_was_stubbed: bool,
276}
277
278/// Arguments for [`Engine::delete_entity`].
279///
280/// No `force` flag — delete is binary. The engine refuses on any
281/// Write-Mem incoming reference (typed `HAS_INCOMING_REFS`); when
282/// only ReadOnly-mount referrers remain, the entity is demoted to a
283/// stub in-memory and the delete proceeds, surfaced via a typed
284/// `RESIDUAL_STUB_FOR_READONLY_REFERRERS` warning on the outcome.
285#[derive(Debug, Clone)]
286pub struct DeleteEntityArgs {
287    pub id: EntityId,
288    /// Optimistic locking. `None` skips the check.
289    pub expected_hash: Option<String>,
290}
291
292/// Successful outcome of [`Engine::delete_entity`].
293#[derive(Debug, Clone, serde::Serialize)]
294pub struct DeleteEntityOutcome {
295    pub id: EntityId,
296    pub file_path: String,
297    /// Ids of entities that referenced the deleted entity (only
298    /// populated on the residual-stub-demotion path — the surviving
299    /// ReadOnly-mount referrers are listed here for diagnostic
300    /// continuity with the warning payload).
301    pub removed_incoming: Vec<String>,
302    /// Total edges removed across incoming + outgoing — full
303    /// `DeleteResult.relations_removed`. Counted from the store
304    /// pre-delete; both directions sum into one number for callers
305    /// that need a single "how much did this delete cascade" signal.
306    pub relations_removed: usize,
307    /// Per-mem commit identifier returned by the backend's
308    /// [`crate::backend::MemBackend::commit`]. Wire-equivalent to
309    /// full's `DeleteResult.commit_sha`.
310    pub commit_sha: String,
311    /// Stub entities that became orphaned by this delete (their last
312    /// incoming edge disappeared with this entity) and were
313    /// garbage-collected. Empty on deletes that didn't sever a
314    /// stub's last referrer. Wire-equivalent to full's
315    /// `DeleteResult.orphan_stubs_removed`.
316    pub orphan_stubs_removed: Vec<EntityId>,
317    /// Typed non-fatal issues — populated on the residual-stub
318    /// demotion path with a `RESIDUAL_STUB_FOR_READONLY_REFERRERS`
319    /// warning naming the surviving ReadOnly-mount referrers. Empty
320    /// on the clean-removal path.
321    pub warnings: Vec<WarningHint>,
322}
323
324/// Arguments for [`Engine::relate_entity`].
325#[derive(Debug, Clone)]
326pub struct RelateEntityArgs {
327    pub source: EntityId,
328    /// Optimistic locking on the source. `None` skips the check.
329    pub expected_hash: Option<String>,
330    pub rel_type: String,
331    pub target: EntityId,
332    /// `false` (default) appends. `true` removes the matching pair.
333    pub remove: bool,
334    /// Optional per-edge description applied on the add path.
335    /// Validated against the rel-type's `per_edge_description`
336    /// posture at call time — `forbidden` rejects `Some`; `required`
337    /// rejects `None`. Empty / whitespace-only strings normalise to
338    /// `None` before validation. Ignored on the remove path (`None`
339    /// keeps the existing behaviour intact).
340    pub description: Option<String>,
341}
342
343/// What a relate call did to the source's relationships.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
345#[serde(rename_all = "snake_case")]
346pub enum RelateAction {
347    Added,
348    Removed,
349    NoOpAlreadyPresent,
350    NoOpAbsent,
351}
352
353/// Successful outcome of [`Engine::relate_entity`].
354#[derive(Debug, Clone, serde::Serialize)]
355pub struct RelateEntityOutcome {
356    pub from: EntityId,
357    pub to: EntityId,
358    pub rel_type: String,
359    pub action: RelateAction,
360    /// Source entity's content hash after the call. Unchanged on
361    /// no-op paths so callers can chain follow-ups without
362    /// refetching. Wire key `_hash`.
363    #[serde(rename = "_hash")]
364    pub content_hash: String,
365    /// Per-mem commit identifier returned by the backend's
366    /// [`crate::backend::MemBackend::commit`]. Empty on the no-op
367    /// paths ([`RelateAction::NoOpAlreadyPresent`],
368    /// [`RelateAction::NoOpAbsent`]) — those branches skip the disk
369    /// write so no commit happens. Wire-equivalent to the full
370    /// `RelateResult.commit_sha`.
371    pub commit_sha: String,
372    /// Edge provenance label — always `"explicit"` for relate-call
373    /// outcomes. Wire-equivalent to full's `RelateResult.source` field;
374    /// reserved for future inline-link-derived edge surfacing.
375    pub source: String,
376    /// Typed non-fatal issues — open-mode schema admissions
377    /// ([`WarningHint::UndeclaredRelationshipOpen`]), duplicate-add
378    /// no-ops ([`WarningHint::DuplicateRelationship`]),
379    /// remove-nonexistent no-ops ([`WarningHint::NoSuchRelationship`]),
380    /// and auto-stubbed targets
381    /// ([`WarningHint::AutoStubCreated`]). Pre-Item-03 the auto-stub
382    /// case rode through a deprecated top-level
383    /// `stub_warning: Option<String>` field that didn't follow the
384    /// `warnings[]` shape — agents iterating diagnostics silently
385    /// skipped it; the field has been retired in favour of the
386    /// uniform warning vocabulary. Empty on the strict-add and
387    /// strict-remove happy paths.
388    pub warnings: Vec<WarningHint>,
389    /// Stub entities whose last incoming edge was severed by a
390    /// `remove=true` call and that were garbage-collected as orphans.
391    /// Empty on the add path and on remove paths that didn't strip
392    /// the last referrer. Wire-equivalent to
393    /// [`DeleteEntityOutcome::orphan_stubs_removed`]; both surfaces
394    /// share the same field name so MCP / CLI consumers can branch
395    /// uniformly (F7).
396    pub orphan_stubs_removed: Vec<EntityId>,
397}
398
399/// Arguments for [`Engine::rename_entity`].
400#[derive(Debug, Clone)]
401pub struct RenameEntityArgs {
402    pub id: EntityId,
403    /// Optimistic locking. `None` skips the check.
404    pub expected_hash: Option<String>,
405    pub new_title: String,
406}
407
408/// Successful outcome of [`Engine::rename_entity`].
409#[derive(Debug, Clone, serde::Serialize)]
410pub struct RenameEntityOutcome {
411    pub old_id: EntityId,
412    pub new_id: EntityId,
413    /// Mem-relative path of the renamed entity before the rewrite.
414    /// Wire-equivalent to full's `RenameResult.old_path`.
415    pub old_path: String,
416    /// Mem-relative path of the renamed entity after the rewrite.
417    /// Wire-equivalent to full's `RenameResult.new_path`.
418    pub new_path: String,
419    /// Wire key `_hash`.
420    #[serde(rename = "_hash")]
421    pub content_hash: String,
422    /// Per-mem commit identifier returned by the backend's
423    /// [`crate::backend::MemBackend::commit`]. Empty on the
424    /// slug-noop short-circuit (no disk write happened).
425    /// Wire-equivalent to full's `RenameResult.commit_sha`.
426    pub commit_sha: String,
427    /// Typed non-fatal issues. The slug-noop short-circuit
428    /// ([`WarningHint::TitleNormalizedToSlugNoop`]) surfaces here
429    /// when a requested title normalises to the existing slug — the
430    /// op stays a silent no-op on disk, but the warning tells
431    /// autonomous skills not to trust `old_id == new_id` as
432    /// "cosmetic rewrite landed". Empty on the real-rename happy
433    /// path. Wire-equivalent to full's `RenameResult.warnings`.
434    pub warnings: Vec<WarningHint>,
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn outcome_types_serialize_to_json() {
443        // Lock the Serialize derives: every
444        // outcome type round-trips through `serde_json::to_string`
445        // without panicking. The wire shape's specific field names
446        // are exercised end-to-end via the MCP handlers; this test
447        // is the structural lock.
448        let create = CreateEntityOutcome {
449            id: EntityId("v--e".to_string()),
450            title: "t".to_string(),
451            mem: "v".to_string(),
452            file_path: "v/e.md".to_string(),
453            content_hash: "h".to_string(),
454            commit_sha: "sha".to_string(),
455            created_date: "2026-05-11".to_string(),
456            warnings: Vec::new(),
457            type_guidance: std::collections::BTreeMap::new(),
458            incoming_count: None,
459            incoming: Vec::new(),
460            relations_declared: Vec::new(),
461        };
462        assert!(serde_json::to_string(&create).is_ok());
463
464        let update = UpdateEntityOutcome {
465            id: EntityId("v--e".to_string()),
466            title: "t".to_string(),
467            file_path: "v/e.md".to_string(),
468            content_hash: "h".to_string(),
469            commit_sha: "sha".to_string(),
470            modified_date: "2026-05-11".to_string(),
471            modified_sections: ModifiedSections::default(),
472            modified_metadata: ModifiedMetadata::default(),
473            prospective_hash: None,
474            orphan_stubs_removed: Vec::new(),
475            warnings: Vec::new(),
476            relations_declared: Vec::new(),
477        };
478        assert!(serde_json::to_string(&update).is_ok());
479
480        let delete = DeleteEntityOutcome {
481            id: EntityId("v--e".to_string()),
482            file_path: "v/e.md".to_string(),
483            removed_incoming: Vec::new(),
484            commit_sha: "sha".to_string(),
485            relations_removed: 0,
486            orphan_stubs_removed: Vec::new(),
487            warnings: Vec::new(),
488        };
489        assert!(serde_json::to_string(&delete).is_ok());
490
491        let relate = RelateEntityOutcome {
492            from: EntityId("v--a".to_string()),
493            to: EntityId("v--b".to_string()),
494            rel_type: "PART_OF".to_string(),
495            action: RelateAction::Added,
496            content_hash: "h".to_string(),
497            commit_sha: "sha".to_string(),
498            source: "explicit".to_string(),
499            warnings: Vec::new(),
500            orphan_stubs_removed: Vec::new(),
501        };
502        assert!(serde_json::to_string(&relate).is_ok());
503
504        let rename = RenameEntityOutcome {
505            old_id: EntityId("v--a".to_string()),
506            new_id: EntityId("v--b".to_string()),
507            old_path: "v/a.md".to_string(),
508            new_path: "v/b.md".to_string(),
509            content_hash: "h".to_string(),
510            commit_sha: "sha".to_string(),
511            warnings: Vec::new(),
512        };
513        let rename_json = serde_json::to_string(&rename).unwrap();
514        // Field names match full's RenameResult wire shape directly.
515        assert!(
516            rename_json.contains("\"old_path\""),
517            "RenameEntityOutcome must serialize old_path: {rename_json}",
518        );
519        assert!(
520            rename_json.contains("\"new_path\""),
521            "RenameEntityOutcome must serialize new_path: {rename_json}",
522        );
523    }
524}
525
526/// Outcome discriminator for [`Engine::set_mem_schema`]. The agent
527/// branches on this — never on which response fields are populated
528/// (stable additive shape, no response-shape polymorphism).
529#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
530#[serde(rename_all = "snake_case")]
531pub enum SetSchemaResult {
532    /// Requested schema == current pin; no state change.
533    Noop,
534    /// Mem was (or became) integral against the target — the pin
535    /// now IS the target and any migration state is cleared.
536    Switched,
537    /// Mem was not integral against the target; dual-pin state
538    /// entered, `findings` carries the non-integral entities.
539    MigrationStarted,
540    /// Re-issued with the same in-flight target while still not
541    /// integral; `findings` carries the *remaining* non-integral
542    /// entities.
543    MigrationPending,
544}
545
546/// Stable response shape of [`Engine::set_mem_schema`] — all five
547/// fields are always present, populated per outcome.
548#[derive(Debug, Clone, serde::Serialize)]
549pub struct SetSchemaOutcome {
550    pub mem: String,
551    /// The settled pin after this call (`<name>@<version>`).
552    pub schema_pin: String,
553    /// In-flight target while a migration is in progress, else `None`.
554    pub migration_target: Option<String>,
555    pub outcome: SetSchemaResult,
556    /// Integrity-linter findings (`{ id, axis, code, detail }`) for
557    /// the entities not yet integral against the target; empty unless
558    /// a migration is in progress.
559    pub findings: Vec<crate::ops::integrity::IntegrityFinding>,
560}