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