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/// Arguments for [`Engine::retype_entity`].
506#[derive(Debug, Clone)]
507pub struct RetypeEntityArgs {
508 pub id: EntityId,
509 /// Optimistic locking. `None` skips the check (dry runs always skip it).
510 pub expected_hash: Option<String>,
511 /// The type the entity becomes; must be declared by the mem's schema.
512 pub target_type: String,
513 /// Section keys to rename on the way: `old key → new key`. A key not
514 /// mapped keeps its name and must be declared by the target type (or
515 /// the retype refuses `UNKNOWN_SECTION` with a proposed map).
516 pub section_map: IndexMap<String, String>,
517 /// Metadata keys the caller explicitly lets go — the fields the
518 /// source type declared and the target does not (a spec's `level`
519 /// on the way to a memo). Never inferred: an undeclared field that is
520 /// not listed here refuses `UNKNOWN_METADATA_FIELD`, because dropping
521 /// data unannounced is what the write gates exist to prevent. A key
522 /// the entity does not carry is a silent no-op.
523 pub drop_metadata: Vec<String>,
524 /// Validate everything and compute the prospective hash without
525 /// writing, committing, or touching the store.
526 pub dry_run: bool,
527}
528
529/// Successful outcome of [`Engine::retype_entity`].
530#[derive(Debug, Clone, serde::Serialize)]
531pub struct RetypeEntityOutcome {
532 pub id: EntityId,
533 /// Unchanged by the retype — the id and path are the point.
534 pub file_path: String,
535 pub old_type: String,
536 pub new_type: String,
537 /// Wire key `_hash`: the content hash after the write (the next
538 /// `expected_hash`); on a dry run the UNCHANGED current hash.
539 #[serde(rename = "_hash")]
540 pub content_hash: String,
541 /// The hash the entity would carry after the write — dry runs only.
542 #[serde(skip_serializing_if = "Option::is_none")]
543 pub prospective_hash: Option<String>,
544 /// The identity the mem's backend minted for this write — a commit
545 /// SHA on a git-branch mem, an opaque synthetic token on a folder or
546 /// in-memory mem. An identity, never a change cursor. Empty on a dry
547 /// run (no disk write happened).
548 pub write_id: String,
549 /// `(old key, new key)` pairs the `section_map` applied.
550 pub sections_renamed: Vec<(String, String)>,
551 /// Every edge examined against the target type's pins: outgoing,
552 /// incoming (loaded), and incoming from deferred mems.
553 pub edges_rechecked: usize,
554 /// Always true: the content hash moved, so every check record and
555 /// derivation baseline keyed to the previous hash is stale.
556 pub checks_stale: bool,
557 /// The sentence that says so, for the surface to print.
558 pub staleness_note: String,
559 pub warnings: Vec<WarningHint>,
560}
561
562/// Which side of an edge the retyped entity is on.
563#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
564#[serde(rename_all = "lowercase")]
565pub enum RetypeEdgeDirection {
566 Outgoing,
567 Incoming,
568}
569
570/// One edge the target type's pins refuse.
571#[derive(Debug, Clone, PartialEq, serde::Serialize)]
572pub struct RetypeEdge {
573 pub direction: RetypeEdgeDirection,
574 pub from: String,
575 pub to: String,
576 pub rel_type: String,
577 pub cross_mem: bool,
578 /// The shape validator's own recovery detail (allowed source and
579 /// target types, suggestion).
580 pub detail: serde_json::Value,
581}
582
583/// One reason a retype is refused. Every problem found is reported
584/// together in [`EngineError::RetypeRefused`]; each carries the wire code
585/// the same condition has on the create/update/relate surfaces.
586#[derive(Debug, Clone, serde::Serialize)]
587#[serde(tag = "kind", rename_all = "snake_case")]
588pub enum RetypeProblem {
589 /// A (mapped) section key the target type does not declare.
590 UnknownSection {
591 key: String,
592 declared: Vec<String>,
593 suggestion: Option<String>,
594 },
595 /// A section the target type requires is absent or empty.
596 MissingRequiredSection {
597 key: String,
598 heading: String,
599 write_rules: Vec<String>,
600 },
601 /// A metadata field the target type requires is unset and has no
602 /// default.
603 MissingRequiredField {
604 key: String,
605 description: String,
606 enum_values: Vec<String>,
607 },
608 /// A validator refusal carried verbatim: body content, an unknown
609 /// metadata field, an enum or field value the target rejects.
610 Validation {
611 code: &'static str,
612 message: String,
613 details: serde_json::Value,
614 },
615 /// `section_map` names a key the entity does not carry.
616 SectionMapSourceMissing {
617 key: String,
618 present: Vec<String>,
619 },
620 /// Two sections would land under one key.
621 SectionMapCollision {
622 from: String,
623 to: String,
624 also_from: String,
625 },
626 EdgeShape(RetypeEdge),
627 RequiredOutgoingUnsatisfied(Vec<crate::ops::MissingRequiredOutgoingBlock>),
628 ConstraintUnsatisfied(Vec<crate::ops::health::UnsatisfiedConstraint>),
629}
630
631impl RetypeProblem {
632 /// The wire code this condition carries everywhere else.
633 pub fn code(&self) -> &'static str {
634 match self {
635 RetypeProblem::UnknownSection { .. } => "UNKNOWN_SECTION",
636 RetypeProblem::MissingRequiredSection { .. } => "MISSING_REQUIRED_SECTION",
637 RetypeProblem::MissingRequiredField { .. } => "REQUIRED_FIELD_UNSET",
638 RetypeProblem::Validation { code, .. } => code,
639 RetypeProblem::SectionMapSourceMissing { .. } => "SECTION_MAP_SOURCE_MISSING",
640 RetypeProblem::SectionMapCollision { .. } => "SECTION_MAP_COLLISION",
641 RetypeProblem::EdgeShape(_) => "INVALID_REL_SHAPE",
642 RetypeProblem::RequiredOutgoingUnsatisfied(_) => "MISSING_REQUIRED_OUTGOING",
643 RetypeProblem::ConstraintUnsatisfied(_) => "CONSTRAINT_UNSATISFIED",
644 }
645 }
646
647 /// One line a human or agent can act on.
648 pub fn message(&self) -> String {
649 match self {
650 RetypeProblem::UnknownSection {
651 key,
652 declared,
653 suggestion,
654 } => format!(
655 "section `{key}` is not declared by the target type (declared: {}){}",
656 declared.join(", "),
657 suggestion
658 .as_deref()
659 .map(|s| format!("; map it with section_map {key}={s}"))
660 .unwrap_or_default()
661 ),
662 RetypeProblem::MissingRequiredSection { key, heading, .. } => {
663 format!("required section `{key}` ({heading}) is missing or empty")
664 }
665 RetypeProblem::MissingRequiredField { key, .. } => {
666 format!("required metadata field `{key}` is unset and has no default")
667 }
668 RetypeProblem::Validation { message, .. } => message.clone(),
669 RetypeProblem::SectionMapSourceMissing { key, present } => format!(
670 "section_map names `{key}`, which the entity does not carry (present: {})",
671 present.join(", ")
672 ),
673 RetypeProblem::SectionMapCollision {
674 from,
675 to,
676 also_from,
677 } => {
678 format!("section_map sends both `{also_from}` and `{from}` to `{to}`")
679 }
680 RetypeProblem::EdgeShape(e) => format!(
681 "{} edge {} --{}--> {} is outside the target type's pins{}",
682 match e.direction {
683 RetypeEdgeDirection::Outgoing => "outgoing",
684 RetypeEdgeDirection::Incoming => "incoming",
685 },
686 e.from,
687 e.rel_type,
688 e.to,
689 if e.cross_mem { " (cross-mem)" } else { "" }
690 ),
691 RetypeProblem::RequiredOutgoingUnsatisfied(blocks) => format!(
692 "{} block-tier required_outgoing block(s) of the target type unsatisfied",
693 blocks.len()
694 ),
695 RetypeProblem::ConstraintUnsatisfied(v) => {
696 format!(
697 "{} block-tier constraint(s) of the target type violated",
698 v.len()
699 )
700 }
701 }
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn outcome_types_serialize_to_json() {
711 // Lock the Serialize derives: every
712 // outcome type round-trips through `serde_json::to_string`
713 // without panicking. The wire shape's specific field names
714 // are exercised end-to-end via the MCP handlers; this test
715 // is the structural lock.
716 let create = CreateEntityOutcome {
717 id: EntityId("v--e".to_string()),
718 title: "t".to_string(),
719 mem: "v".to_string(),
720 file_path: "v/e.md".to_string(),
721 content_hash: "h".to_string(),
722 write_id: "sha".to_string(),
723 created_date: "2026-05-11".to_string(),
724 warnings: Vec::new(),
725 type_guidance: std::collections::BTreeMap::new(),
726 incoming_count: None,
727 incoming: Vec::new(),
728 relations_declared: Vec::new(),
729 };
730 assert!(serde_json::to_string(&create).is_ok());
731
732 let update = UpdateEntityOutcome {
733 id: EntityId("v--e".to_string()),
734 title: "t".to_string(),
735 file_path: "v/e.md".to_string(),
736 content_hash: "h".to_string(),
737 write_id: "sha".to_string(),
738 modified_date: "2026-05-11".to_string(),
739 modified_sections: ModifiedSections::default(),
740 modified_metadata: ModifiedMetadata::default(),
741 prospective_hash: None,
742 orphan_stubs_removed: Vec::new(),
743 warnings: Vec::new(),
744 relations_declared: Vec::new(),
745 };
746 assert!(serde_json::to_string(&update).is_ok());
747
748 let delete = DeleteEntityOutcome {
749 id: EntityId("v--e".to_string()),
750 file_path: "v/e.md".to_string(),
751 removed_incoming: Vec::new(),
752 write_id: "sha".to_string(),
753 relations_removed: 0,
754 orphan_stubs_removed: Vec::new(),
755 warnings: Vec::new(),
756 };
757 assert!(serde_json::to_string(&delete).is_ok());
758
759 let relate = RelateEntityOutcome {
760 from: EntityId("v--a".to_string()),
761 to: EntityId("v--b".to_string()),
762 rel_type: "PART_OF".to_string(),
763 action: RelateAction::Added,
764 content_hash: "h".to_string(),
765 write_id: "sha".to_string(),
766 source: "explicit".to_string(),
767 warnings: Vec::new(),
768 orphan_stubs_removed: Vec::new(),
769 };
770 assert!(serde_json::to_string(&relate).is_ok());
771
772 let rename = RenameEntityOutcome {
773 old_id: EntityId("v--a".to_string()),
774 new_id: EntityId("v--b".to_string()),
775 old_path: "v/a.md".to_string(),
776 new_path: "v/b.md".to_string(),
777 content_hash: "h".to_string(),
778 write_id: "sha".to_string(),
779 warnings: Vec::new(),
780 };
781 let rename_json = serde_json::to_string(&rename).unwrap();
782 // Field names match full's RenameResult wire shape directly.
783 assert!(
784 rename_json.contains("\"old_path\""),
785 "RenameEntityOutcome must serialize old_path: {rename_json}",
786 );
787 assert!(
788 rename_json.contains("\"new_path\""),
789 "RenameEntityOutcome must serialize new_path: {rename_json}",
790 );
791 }
792}
793
794/// Outcome discriminator for [`Engine::set_mem_schema`]. The agent
795/// branches on this — never on which response fields are populated
796/// (stable additive shape, no response-shape polymorphism).
797#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
798#[serde(rename_all = "snake_case")]
799pub enum SetSchemaResult {
800 /// Requested schema == current pin; no state change.
801 Noop,
802 /// Mem was (or became) integral against the target — the pin
803 /// now IS the target and any migration state is cleared.
804 Switched,
805 /// Mem was not integral against the target; dual-pin state
806 /// entered, `findings` carries the non-integral entities.
807 MigrationStarted,
808 /// Re-issued with the same in-flight target while still not
809 /// integral; `findings` carries the *remaining* non-integral
810 /// entities.
811 MigrationPending,
812}
813
814/// Stable response shape of [`Engine::set_mem_schema`] — all five
815/// fields are always present, populated per outcome.
816#[derive(Debug, Clone, serde::Serialize)]
817pub struct SetSchemaOutcome {
818 pub mem: String,
819 /// The settled pin after this call (`<name>@<version>`).
820 pub schema_pin: String,
821 /// In-flight target while a migration is in progress, else `None`.
822 pub migration_target: Option<String>,
823 pub outcome: SetSchemaResult,
824 /// Integrity-linter findings (`{ id, axis, code, detail }`) for
825 /// the entities not yet integral against the target; empty unless
826 /// a migration is in progress.
827 pub findings: Vec<crate::ops::integrity::IntegrityFinding>,
828}