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