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    /// Whether the update's anchors changed the sidecar: absent when the
321    /// update carried no anchors or unsets, `true` when a row was added,
322    /// replaced or removed, `false` when every supplied row restated
323    /// what was stored (nothing written, the sidecar bytes untouched).
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub anchors_changed: Option<bool>,
326}
327
328/// One batched relation declaration applied by a mutation call.
329/// Echoed in [`UpdateEntityOutcome::relations_declared`] and
330/// [`CreateEntityOutcome::relations_declared`] so agents see, in the
331/// same response, what landed and which targets had to be stubbed.
332#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
333pub struct RelationDeclared {
334    pub rel_type: String,
335    pub target: EntityId,
336    /// `true` when the target was absent at call time and the
337    /// engine materialised a stub for it (subject to the same
338    /// rules as `memstead_relate`'s auto-stub mechanic).
339    pub target_was_stubbed: bool,
340}
341
342/// Arguments for [`Engine::delete_entity`].
343///
344/// No `force` flag — delete is binary. The engine refuses on any
345/// Write-Mem incoming reference (typed `HAS_INCOMING_REFS`); when
346/// only ReadOnly-mount referrers remain, the entity is demoted to a
347/// stub in-memory and the delete proceeds, surfaced via a typed
348/// `RESIDUAL_STUB_FOR_READONLY_REFERRERS` warning on the outcome.
349#[derive(Debug, Clone)]
350pub struct DeleteEntityArgs {
351    pub id: EntityId,
352    /// Optimistic locking. `None` skips the check.
353    pub expected_hash: Option<String>,
354}
355
356/// Successful outcome of [`Engine::delete_entity`].
357#[derive(Debug, Clone, serde::Serialize)]
358pub struct DeleteEntityOutcome {
359    pub id: EntityId,
360    pub file_path: String,
361    /// Ids of entities that referenced the deleted entity (only
362    /// populated on the residual-stub-demotion path — the surviving
363    /// ReadOnly-mount referrers are listed here for diagnostic
364    /// continuity with the warning payload).
365    pub removed_incoming: Vec<String>,
366    /// Total edges removed across incoming + outgoing — full
367    /// `DeleteResult.relations_removed`. Counted from the store
368    /// pre-delete; both directions sum into one number for callers
369    /// that need a single "how much did this delete cascade" signal.
370    pub relations_removed: usize,
371    /// The identity the mem's backend minted for this write — a commit
372    /// SHA on a git-branch mem, an opaque synthetic token on a folder or
373    /// in-memory mem. An identity, never a change cursor. Wire-equivalent to
374    /// full's `DeleteResult.write_id`.
375    pub write_id: String,
376    /// Stub entities that became orphaned by this delete (their last
377    /// incoming edge disappeared with this entity) and were
378    /// garbage-collected. Empty on deletes that didn't sever a
379    /// stub's last referrer. Wire-equivalent to full's
380    /// `DeleteResult.orphan_stubs_removed`.
381    pub orphan_stubs_removed: Vec<EntityId>,
382    /// Typed non-fatal issues — populated on the residual-stub
383    /// demotion path with a `RESIDUAL_STUB_FOR_READONLY_REFERRERS`
384    /// warning naming the surviving ReadOnly-mount referrers. Empty
385    /// on the clean-removal path.
386    pub warnings: Vec<WarningHint>,
387}
388
389/// Arguments for [`Engine::relate_entity`].
390#[derive(Debug, Clone)]
391pub struct RelateEntityArgs {
392    pub source: EntityId,
393    /// Optimistic locking on the source. `None` skips the check.
394    pub expected_hash: Option<String>,
395    pub rel_type: String,
396    pub target: EntityId,
397    /// `false` (default) appends. `true` removes the matching pair.
398    pub remove: bool,
399    /// Optional per-edge description applied on the add path.
400    /// Validated against the rel-type's `per_edge_description`
401    /// posture at call time — `forbidden` rejects `Some`; `required`
402    /// rejects `None`. Empty / whitespace-only strings normalise to
403    /// `None` before validation. Ignored on the remove path (`None`
404    /// keeps the existing behaviour intact).
405    pub description: Option<String>,
406    /// Rehearsal mode (agent-trust plan 07): run the FULL validation
407    /// stage — identical refusals, identical warnings (including the
408    /// would-be `AUTO_STUB_CREATED`) — then stop before any write.
409    /// The response carries the marker form: empty `write_id` with
410    /// `_hash` set to the PROSPECTIVE post-write hash. Nothing is
411    /// staged, committed, or stubbed.
412    pub dry_run: bool,
413}
414
415/// What a relate call did to the source's relationships.
416#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
417#[serde(rename_all = "snake_case")]
418pub enum RelateAction {
419    Added,
420    Removed,
421    NoOpAlreadyPresent,
422    NoOpAbsent,
423}
424
425/// Successful outcome of [`Engine::relate_entity`].
426#[derive(Debug, Clone, serde::Serialize)]
427pub struct RelateEntityOutcome {
428    pub from: EntityId,
429    pub to: EntityId,
430    pub rel_type: String,
431    pub action: RelateAction,
432    /// Source entity's content hash after the call. Unchanged on
433    /// no-op paths so callers can chain follow-ups without
434    /// refetching. Wire key `_hash`.
435    #[serde(rename = "_hash")]
436    pub content_hash: String,
437    /// The identity the mem's backend minted for this write — a commit
438    /// SHA on a git-branch mem, an opaque synthetic token on a folder or
439    /// in-memory mem. An identity, never a change cursor. Empty on the no-op
440    /// paths ([`RelateAction::NoOpAlreadyPresent`],
441    /// [`RelateAction::NoOpAbsent`]) — those branches skip the disk
442    /// write so no commit happens. Wire-equivalent to the full
443    /// `RelateResult.write_id`.
444    pub write_id: String,
445    /// Edge provenance label — always `"explicit"` for relate-call
446    /// outcomes. Wire-equivalent to full's `RelateResult.source` field;
447    /// reserved for future inline-link-derived edge surfacing.
448    pub source: String,
449    /// Typed non-fatal issues — open-mode schema admissions
450    /// ([`WarningHint::UndeclaredRelationshipOpen`]), duplicate-add
451    /// no-ops ([`WarningHint::DuplicateRelationship`]),
452    /// remove-nonexistent no-ops ([`WarningHint::NoSuchRelationship`]),
453    /// and auto-stubbed targets
454    /// ([`WarningHint::AutoStubCreated`]). Pre-Item-03 the auto-stub
455    /// case rode through a deprecated top-level
456    /// `stub_warning: Option<String>` field that didn't follow the
457    /// `warnings[]` shape — agents iterating diagnostics silently
458    /// skipped it; the field has been retired in favour of the
459    /// uniform warning vocabulary. Empty on the strict-add and
460    /// strict-remove happy paths.
461    pub warnings: Vec<WarningHint>,
462    /// Stub entities whose last incoming edge was severed by a
463    /// `remove=true` call and that were garbage-collected as orphans.
464    /// Empty on the add path and on remove paths that didn't strip
465    /// the last referrer. Wire-equivalent to
466    /// [`DeleteEntityOutcome::orphan_stubs_removed`]; both surfaces
467    /// share the same field name so MCP / CLI consumers can branch
468    /// uniformly (F7).
469    pub orphan_stubs_removed: Vec<EntityId>,
470}
471
472/// Arguments for [`Engine::rename_entity`].
473#[derive(Debug, Clone)]
474pub struct RenameEntityArgs {
475    pub id: EntityId,
476    /// Optimistic locking. `None` skips the check.
477    pub expected_hash: Option<String>,
478    pub new_title: String,
479}
480
481/// Successful outcome of [`Engine::rename_entity`].
482#[derive(Debug, Clone, serde::Serialize)]
483pub struct RenameEntityOutcome {
484    pub old_id: EntityId,
485    pub new_id: EntityId,
486    /// Mem-relative path of the renamed entity before the rewrite.
487    /// Wire-equivalent to full's `RenameResult.old_path`.
488    pub old_path: String,
489    /// Mem-relative path of the renamed entity after the rewrite.
490    /// Wire-equivalent to full's `RenameResult.new_path`.
491    pub new_path: String,
492    /// Wire key `_hash`.
493    #[serde(rename = "_hash")]
494    pub content_hash: String,
495    /// The identity the mem's backend minted for this write — a commit
496    /// SHA on a git-branch mem, an opaque synthetic token on a folder or
497    /// in-memory mem. An identity, never a change cursor. Empty on the
498    /// slug-noop short-circuit (no disk write happened).
499    /// Wire-equivalent to full's `RenameResult.write_id`.
500    pub write_id: String,
501    /// Typed non-fatal issues. The slug-noop short-circuit
502    /// ([`WarningHint::TitleNormalizedToSlugNoop`]) surfaces here
503    /// when a requested title normalises to the existing slug — the
504    /// op stays a silent no-op on disk, but the warning tells
505    /// autonomous skills not to trust `old_id == new_id` as
506    /// "cosmetic rewrite landed". Empty on the real-rename happy
507    /// path. Wire-equivalent to full's `RenameResult.warnings`.
508    pub warnings: Vec<WarningHint>,
509}
510
511/// Arguments for [`Engine::retype_entity`].
512#[derive(Debug, Clone)]
513pub struct RetypeEntityArgs {
514    pub id: EntityId,
515    /// Optimistic locking. `None` skips the check (dry runs always skip it).
516    pub expected_hash: Option<String>,
517    /// The type the entity becomes; must be declared by the mem's schema.
518    pub target_type: String,
519    /// Section keys to rename on the way: `old key → new key`. A key not
520    /// mapped keeps its name and must be declared by the target type (or
521    /// the retype refuses `UNKNOWN_SECTION` with a proposed map).
522    pub section_map: IndexMap<String, String>,
523    /// Metadata keys the caller explicitly lets go — the fields the
524    /// source type declared and the target does not (a spec's `level`
525    /// on the way to a memo). Never inferred: an undeclared field that is
526    /// not listed here refuses `UNKNOWN_METADATA_FIELD`, because dropping
527    /// data unannounced is what the write gates exist to prevent. A key
528    /// the entity does not carry is a silent no-op.
529    pub drop_metadata: Vec<String>,
530    /// Validate everything and compute the prospective hash without
531    /// writing, committing, or touching the store.
532    pub dry_run: bool,
533}
534
535/// Successful outcome of [`Engine::retype_entity`].
536#[derive(Debug, Clone, serde::Serialize)]
537pub struct RetypeEntityOutcome {
538    pub id: EntityId,
539    /// Unchanged by the retype — the id and path are the point.
540    pub file_path: String,
541    pub old_type: String,
542    pub new_type: String,
543    /// Wire key `_hash`: the content hash after the write (the next
544    /// `expected_hash`); on a dry run the UNCHANGED current hash.
545    #[serde(rename = "_hash")]
546    pub content_hash: String,
547    /// The hash the entity would carry after the write — dry runs only.
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub prospective_hash: Option<String>,
550    /// The identity the mem's backend minted for this write — a commit
551    /// SHA on a git-branch mem, an opaque synthetic token on a folder or
552    /// in-memory mem. An identity, never a change cursor. Empty on a dry
553    /// run (no disk write happened).
554    pub write_id: String,
555    /// `(old key, new key)` pairs the `section_map` applied.
556    pub sections_renamed: Vec<(String, String)>,
557    /// Every edge examined against the target type's pins: outgoing,
558    /// incoming (loaded), and incoming from deferred mems.
559    pub edges_rechecked: usize,
560    /// Always true: the content hash moved, so every check record and
561    /// derivation baseline keyed to the previous hash is stale.
562    pub checks_stale: bool,
563    /// The sentence that says so, for the surface to print.
564    pub staleness_note: String,
565    pub warnings: Vec<WarningHint>,
566}
567
568/// Which side of an edge the retyped entity is on.
569#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
570#[serde(rename_all = "lowercase")]
571pub enum RetypeEdgeDirection {
572    Outgoing,
573    Incoming,
574}
575
576/// One edge the target type's pins refuse.
577#[derive(Debug, Clone, PartialEq, serde::Serialize)]
578pub struct RetypeEdge {
579    pub direction: RetypeEdgeDirection,
580    pub from: String,
581    pub to: String,
582    pub rel_type: String,
583    pub cross_mem: bool,
584    /// The shape validator's own recovery detail (allowed source and
585    /// target types, suggestion).
586    pub detail: serde_json::Value,
587}
588
589/// One reason a retype is refused. Every problem found is reported
590/// together in [`EngineError::RetypeRefused`]; each carries the wire code
591/// the same condition has on the create/update/relate surfaces.
592#[derive(Debug, Clone, serde::Serialize)]
593#[serde(tag = "kind", rename_all = "snake_case")]
594pub enum RetypeProblem {
595    /// A (mapped) section key the target type does not declare.
596    UnknownSection {
597        key: String,
598        declared: Vec<String>,
599        suggestion: Option<String>,
600    },
601    /// A section the target type requires is absent or empty.
602    MissingRequiredSection {
603        key: String,
604        heading: String,
605        write_rules: Vec<String>,
606    },
607    /// A metadata field the target type requires is unset and has no
608    /// default.
609    MissingRequiredField {
610        key: String,
611        description: String,
612        enum_values: Vec<String>,
613    },
614    /// A validator refusal carried verbatim: body content, an unknown
615    /// metadata field, an enum or field value the target rejects.
616    Validation {
617        code: &'static str,
618        message: String,
619        details: serde_json::Value,
620    },
621    /// `section_map` names a key the entity does not carry.
622    SectionMapSourceMissing {
623        key: String,
624        present: Vec<String>,
625    },
626    /// Two sections would land under one key.
627    SectionMapCollision {
628        from: String,
629        to: String,
630        also_from: String,
631    },
632    EdgeShape(RetypeEdge),
633    RequiredOutgoingUnsatisfied(Vec<crate::ops::MissingRequiredOutgoingBlock>),
634    ConstraintUnsatisfied(Vec<crate::ops::health::UnsatisfiedConstraint>),
635}
636
637impl RetypeProblem {
638    /// The wire code this condition carries everywhere else.
639    pub fn code(&self) -> &'static str {
640        match self {
641            RetypeProblem::UnknownSection { .. } => "UNKNOWN_SECTION",
642            RetypeProblem::MissingRequiredSection { .. } => "MISSING_REQUIRED_SECTION",
643            RetypeProblem::MissingRequiredField { .. } => "REQUIRED_FIELD_UNSET",
644            RetypeProblem::Validation { code, .. } => code,
645            RetypeProblem::SectionMapSourceMissing { .. } => "SECTION_MAP_SOURCE_MISSING",
646            RetypeProblem::SectionMapCollision { .. } => "SECTION_MAP_COLLISION",
647            RetypeProblem::EdgeShape(_) => "INVALID_REL_SHAPE",
648            RetypeProblem::RequiredOutgoingUnsatisfied(_) => "MISSING_REQUIRED_OUTGOING",
649            RetypeProblem::ConstraintUnsatisfied(_) => "CONSTRAINT_UNSATISFIED",
650        }
651    }
652
653    /// One line a human or agent can act on.
654    pub fn message(&self) -> String {
655        match self {
656            RetypeProblem::UnknownSection {
657                key,
658                declared,
659                suggestion,
660            } => format!(
661                "section `{key}` is not declared by the target type (declared: {}){}",
662                declared.join(", "),
663                suggestion
664                    .as_deref()
665                    .map(|s| format!("; map it with section_map {key}={s}"))
666                    .unwrap_or_default()
667            ),
668            RetypeProblem::MissingRequiredSection { key, heading, .. } => {
669                format!("required section `{key}` ({heading}) is missing or empty")
670            }
671            RetypeProblem::MissingRequiredField { key, .. } => {
672                format!("required metadata field `{key}` is unset and has no default")
673            }
674            RetypeProblem::Validation { message, .. } => message.clone(),
675            RetypeProblem::SectionMapSourceMissing { key, present } => format!(
676                "section_map names `{key}`, which the entity does not carry (present: {})",
677                present.join(", ")
678            ),
679            RetypeProblem::SectionMapCollision {
680                from,
681                to,
682                also_from,
683            } => {
684                format!("section_map sends both `{also_from}` and `{from}` to `{to}`")
685            }
686            RetypeProblem::EdgeShape(e) => format!(
687                "{} edge {} --{}--> {} is outside the target type's pins{}",
688                match e.direction {
689                    RetypeEdgeDirection::Outgoing => "outgoing",
690                    RetypeEdgeDirection::Incoming => "incoming",
691                },
692                e.from,
693                e.rel_type,
694                e.to,
695                if e.cross_mem { " (cross-mem)" } else { "" }
696            ),
697            RetypeProblem::RequiredOutgoingUnsatisfied(blocks) => format!(
698                "{} block-tier required_outgoing block(s) of the target type unsatisfied",
699                blocks.len()
700            ),
701            RetypeProblem::ConstraintUnsatisfied(v) => {
702                format!(
703                    "{} block-tier constraint(s) of the target type violated",
704                    v.len()
705                )
706            }
707        }
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn outcome_types_serialize_to_json() {
717        // Lock the Serialize derives: every
718        // outcome type round-trips through `serde_json::to_string`
719        // without panicking. The wire shape's specific field names
720        // are exercised end-to-end via the MCP handlers; this test
721        // is the structural lock.
722        let create = CreateEntityOutcome {
723            id: EntityId("v--e".to_string()),
724            title: "t".to_string(),
725            mem: "v".to_string(),
726            file_path: "v/e.md".to_string(),
727            content_hash: "h".to_string(),
728            write_id: "sha".to_string(),
729            created_date: "2026-05-11".to_string(),
730            warnings: Vec::new(),
731            type_guidance: std::collections::BTreeMap::new(),
732            incoming_count: None,
733            incoming: Vec::new(),
734            relations_declared: Vec::new(),
735        };
736        assert!(serde_json::to_string(&create).is_ok());
737
738        let update = UpdateEntityOutcome {
739            id: EntityId("v--e".to_string()),
740            title: "t".to_string(),
741            file_path: "v/e.md".to_string(),
742            content_hash: "h".to_string(),
743            write_id: "sha".to_string(),
744            modified_date: "2026-05-11".to_string(),
745            modified_sections: ModifiedSections::default(),
746            modified_metadata: ModifiedMetadata::default(),
747            prospective_hash: None,
748            orphan_stubs_removed: Vec::new(),
749            warnings: Vec::new(),
750            relations_declared: Vec::new(),
751            anchors_changed: None,
752        };
753        assert!(serde_json::to_string(&update).is_ok());
754
755        let delete = DeleteEntityOutcome {
756            id: EntityId("v--e".to_string()),
757            file_path: "v/e.md".to_string(),
758            removed_incoming: Vec::new(),
759            write_id: "sha".to_string(),
760            relations_removed: 0,
761            orphan_stubs_removed: Vec::new(),
762            warnings: Vec::new(),
763        };
764        assert!(serde_json::to_string(&delete).is_ok());
765
766        let relate = RelateEntityOutcome {
767            from: EntityId("v--a".to_string()),
768            to: EntityId("v--b".to_string()),
769            rel_type: "PART_OF".to_string(),
770            action: RelateAction::Added,
771            content_hash: "h".to_string(),
772            write_id: "sha".to_string(),
773            source: "explicit".to_string(),
774            warnings: Vec::new(),
775            orphan_stubs_removed: Vec::new(),
776        };
777        assert!(serde_json::to_string(&relate).is_ok());
778
779        let rename = RenameEntityOutcome {
780            old_id: EntityId("v--a".to_string()),
781            new_id: EntityId("v--b".to_string()),
782            old_path: "v/a.md".to_string(),
783            new_path: "v/b.md".to_string(),
784            content_hash: "h".to_string(),
785            write_id: "sha".to_string(),
786            warnings: Vec::new(),
787        };
788        let rename_json = serde_json::to_string(&rename).unwrap();
789        // Field names match full's RenameResult wire shape directly.
790        assert!(
791            rename_json.contains("\"old_path\""),
792            "RenameEntityOutcome must serialize old_path: {rename_json}",
793        );
794        assert!(
795            rename_json.contains("\"new_path\""),
796            "RenameEntityOutcome must serialize new_path: {rename_json}",
797        );
798    }
799}
800
801/// Outcome discriminator for [`Engine::set_mem_schema`]. The agent
802/// branches on this — never on which response fields are populated
803/// (stable additive shape, no response-shape polymorphism).
804#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
805#[serde(rename_all = "snake_case")]
806pub enum SetSchemaResult {
807    /// Requested schema == current pin; no state change.
808    Noop,
809    /// Mem was (or became) integral against the target — the pin
810    /// now IS the target and any migration state is cleared.
811    Switched,
812    /// Mem was not integral against the target; dual-pin state
813    /// entered, `findings` carries the non-integral entities.
814    MigrationStarted,
815    /// Re-issued with the same in-flight target while still not
816    /// integral; `findings` carries the *remaining* non-integral
817    /// entities.
818    MigrationPending,
819}
820
821/// Stable response shape of [`Engine::set_mem_schema`] — all six
822/// fields are always present, populated per outcome.
823#[derive(Debug, Clone, serde::Serialize)]
824pub struct SetSchemaOutcome {
825    pub mem: String,
826    /// The settled pin after this call (`<name>@<version>`).
827    pub schema_pin: String,
828    /// In-flight target while a migration is in progress, else `None`.
829    pub migration_target: Option<String>,
830    pub outcome: SetSchemaResult,
831    /// Integrity-linter findings (`{ id, axis, code, detail }`) for
832    /// the entities not yet integral against the target; empty unless
833    /// a migration is in progress.
834    pub findings: Vec<crate::ops::integrity::IntegrityFinding>,
835    /// The resolved schema the mem's mutation stamp names after this
836    /// call — the marker the `ENGINE_VERSION_SKEW` hint reads. A
837    /// completed switch re-stamps it with the target; a dual-pin entry
838    /// leaves it where the last mutation put it, so a reader sees here
839    /// which generation the marker still carries. `None` when the mem
840    /// carries no stamp (config-less, or never mutated by a stamping
841    /// engine).
842    pub stamped_schema: Option<String>,
843}