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