memstead_cli/commands/update.rs
1//! `memstead update` — strict-by-default entity update.
2//!
3//! Hash handling offers three opt-ins:
4//!
5//! * **Default (strict).** `--expected-hash <h>` must be supplied for any
6//! update that CHANGES CONTENT. Matches MCP's `memstead_update` contract.
7//! Safe for scripts, CI, pre-commit hooks. An anchors-only update
8//! (`--anchor` / `--anchor-unset` and nothing else) needs none: anchors live
9//! outside the content hash, so the token would compare a value the write
10//! cannot move.
11//! * **`--auto-hash`.** Refetch the current hash immediately before writing.
12//! Ergonomic for one-off interactive edits; the user accepts the race window.
13//! * **`--force`.** Skip the hash check entirely. Explicit opt-out.
14//!
15//! Only one of the three may be used per invocation.
16
17use std::path::PathBuf;
18
19use clap::Parser;
20use indexmap::IndexMap;
21use serde::Deserialize;
22
23#[cfg(feature = "mem-repo")]
24use memstead_base::ops::PatchArg;
25use memstead_base::vcs::Actor;
26use memstead_base::{EntityId, UpdateEntityArgs};
27
28use crate::CliError;
29use crate::output::{ExitKind, print_json, print_markdown};
30use crate::setup::{CliContext, CliEngine};
31
32#[derive(Parser, Debug)]
33pub struct Args {
34 /// Full entity ID (e.g. `specs--my-entity`). Required unless `--from` is given.
35 pub id: Option<String>,
36
37 /// Hash from `memstead entity <id>` (the `_hash` field). Required for any
38 /// update that changes content, unless `--auto-hash` or `--force` is
39 /// given. Not required for an anchors-only update (`--anchor` /
40 /// `--anchor-unset` and nothing else), because anchors live outside the
41 /// content hash and the token would compare a value the write cannot
42 /// move. With `--from`, this flag overrides the file's `expected_hash`
43 /// field and enforces CAS exactly as on the inline path.
44 #[arg(long = "expected-hash", value_name = "HASH")]
45 pub expected_hash: Option<String>,
46
47 /// Refetch the current hash immediately before writing.
48 /// Convenient for interactive use; accepts the race window between
49 /// the refetch and the write.
50 #[arg(long, conflicts_with_all = ["expected_hash", "force"])]
51 pub auto_hash: bool,
52
53 /// Skip the hash check entirely (explicit overwrite).
54 #[arg(long, conflicts_with_all = ["expected_hash", "auto_hash"])]
55 pub force: bool,
56
57 /// Replace section content: repeatable `--section key=value`. Body
58 /// wiki-links must take slug-form (`[[idempotency]]`, not the
59 /// title-case `[[Idempotency]]`) — a non-slug target refuses with
60 /// `INVALID_WIKI_LINK_TARGET` carrying a `proposed_slug` to retry with.
61 #[arg(long = "section", value_name = "KEY=VALUE", conflicts_with = "from")]
62 pub sections: Vec<String>,
63
64 /// Append to section content: repeatable `--append key=value`.
65 #[arg(long = "append", value_name = "KEY=VALUE", conflicts_with = "from")]
66 pub append: Vec<String>,
67
68 /// Find-and-replace inside a section: repeatable `--patch key=OLD=>NEW`.
69 /// Use `=>` (two chars) as the separator between old and new. Exact match
70 /// of the first occurrence; use `--patch-all` to replace every occurrence.
71 #[arg(long = "patch", value_name = "KEY=OLD=>NEW", conflicts_with = "from")]
72 pub patch: Vec<String>,
73
74 /// Replace every occurrence of OLD in the section — sibling of `--patch`.
75 /// Repeatable `--patch-all key=OLD=>NEW`.
76 #[arg(
77 long = "patch-all",
78 value_name = "KEY=OLD=>NEW",
79 conflicts_with = "from"
80 )]
81 pub patch_all: Vec<String>,
82
83 /// Metadata field: repeatable `--metadata key=value`.
84 #[arg(long = "metadata", value_name = "KEY=VALUE", conflicts_with = "from")]
85 pub metadata: Vec<String>,
86
87 /// Remove a metadata field: repeatable `--metadata-unset KEY`. Silent
88 /// no-op if the key is absent; errors on read-only fields (mem/id/type
89 /// plus the engine-stamped created_date/last_modified) or
90 /// schema-required fields.
91 #[arg(long = "metadata-unset", value_name = "KEY", conflicts_with = "from")]
92 pub metadata_unset: Vec<String>,
93
94 /// Atomic batched relation declaration: repeatable
95 /// `--declare-relations REL_TYPE:TARGET_ID`. Each entry is
96 /// validated like an individual `memstead relate` call (schema-shape,
97 /// cross-mem policy, target-id grammar) and appended to the
98 /// entity's relations BEFORE the strict wiki-link/relation
99 /// validator runs. Lets the agent add `[[target]]` body
100 /// wiki-links AND declare the backing relation in one
101 /// `memstead update` call without an interleaved `memstead relate`.
102 /// Absent Write-mem targets are auto-stubbed identically to
103 /// `memstead relate`'s add path. Each successful declaration is
104 /// echoed in the response's `relations_declared` (with
105 /// `target_was_stubbed` flagging the auto-stub case).
106 #[arg(
107 long = "declare-relations",
108 value_name = "REL_TYPE:TARGET_ID",
109 conflicts_with = "from"
110 )]
111 pub declare_relations: Vec<String>,
112
113 /// Provenance anchor: repeatable `--anchor '<json>'`, each a JSON
114 /// object of the anchor shape. Written into the mem-branch anchors
115 /// sidecar in the same commit as the update; a malformed anchor
116 /// refuses `INVALID_ANCHOR`. An update carrying only `--anchor` (no
117 /// section/metadata change) still commits the sidecar. Conflicts with
118 /// `--from` (the file's `anchors[]` is authoritative there).
119 #[arg(long = "anchor", value_name = "JSON", conflicts_with = "from")]
120 pub anchors: Vec<String>,
121
122 /// Explicit anchor removal: repeatable `--anchor-unset '<json>'`, each
123 /// a JSON object `{ "artifact": "…" }` optionally narrowed by
124 /// `"grain"` and/or `"class"` — a bare artifact removes every anchor
125 /// on it. Applied BEFORE the `--anchor` merge in the same commit
126 /// (anchors merge; writing never removes an anchor not named here).
127 /// Unsetting a nonexistent target is a no-op. A malformed selector
128 /// refuses `INVALID_ANCHOR`. Conflicts with `--from` (the file's
129 /// `anchors_unset[]` is authoritative there).
130 #[arg(long = "anchor-unset", value_name = "JSON", conflicts_with = "from")]
131 pub anchors_unset: Vec<String>,
132
133 /// Preview what would change without writing. Applies on both the
134 /// inline and `--from` paths; with `--from` it forces a dry run even
135 /// when the file's `dry_run` field is absent or `false`.
136 #[arg(long)]
137 pub dry_run: bool,
138
139 /// JSON file matching MCP `memstead_update` args shape. The file is the
140 /// single source of the mutation content — the content flags
141 /// (`--section` / `--append` / `--patch` / `--patch-all` / `--metadata` /
142 /// `--metadata-unset` / `--declare-relations` / `--anchor` /
143 /// `--anchor-unset`) conflict with `--from` rather than being silently
144 /// ignored. The flags that DO apply
145 /// alongside `--from`: the hash-mode flags (`--expected-hash`, which
146 /// overrides the file's `expected_hash` field; `--auto-hash`; `--force`),
147 /// `--dry-run` (forces a dry run even when the file says otherwise), and
148 /// `--note`.
149 #[arg(long = "from", value_name = "FILE")]
150 pub from: Option<PathBuf>,
151
152 /// Agent-authored provenance note (≤280 chars). When
153 /// `[mutations].require_notes = true` a missing note adds a
154 /// `NOTE_MISSING` warning.
155 #[arg(long)]
156 pub note: Option<String>,
157}
158
159/// Parse repeatable `--anchor-unset '<json>'` values into the engine's
160/// permissive `AnchorUnsetInput` shape — sibling of
161/// [`super::create::parse_anchor_list`]. Only JSON-shape errors refuse
162/// here; selector validation (missing artifact, unknown grain/class) is
163/// the engine's typed `INVALID_ANCHOR`.
164fn parse_anchor_unset_list(
165 items: &[String],
166) -> anyhow::Result<Vec<memstead_base::anchor::AnchorUnsetInput>> {
167 let mut out = Vec::with_capacity(items.len());
168 for raw in items {
169 let unset: memstead_base::anchor::AnchorUnsetInput =
170 serde_json::from_str(raw).map_err(|e| {
171 CliError::new(
172 ExitKind::Validation,
173 "INVALID_INPUT",
174 format!("--anchor-unset: expected a JSON selector object, got `{raw}`: {e}"),
175 )
176 })?;
177 out.push(unset);
178 }
179 Ok(out)
180}
181
182/// On-disk JSON payload shape — mirrors MCP `UpdateParams` + hash flags.
183/// `expected_hash` inside the file takes effect only in strict mode.
184#[derive(Debug, Deserialize)]
185#[serde(deny_unknown_fields)]
186struct UpdatePayload {
187 id: String,
188 expected_hash: Option<String>,
189 #[serde(default)]
190 sections: IndexMap<String, String>,
191 #[serde(default)]
192 append_sections: IndexMap<String, String>,
193 #[serde(default)]
194 patch_sections: IndexMap<String, PatchPayload>,
195 #[serde(default)]
196 metadata: IndexMap<String, String>,
197 #[serde(default)]
198 metadata_unset: Vec<String>,
199 #[serde(default)]
200 declare_relations: Vec<DeclareRelationPayload>,
201 /// Repair-shaped relation removals — matches the MCP `memstead_update`
202 /// `relations_unset[]` shape (`[{ rel_type, target }]`). Accepted only
203 /// when the entity currently fails conformance (the engine refuses
204 /// `REPAIR_NOT_NEEDED` on a conformant entity); everyday edge
205 /// detachment goes through `memstead relate --remove`. Until 2026-08-28
206 /// this key was refused outright here while MCP honoured it — the
207 /// response-shape asymmetry `agent-surfaces.md` forbids.
208 #[serde(default)]
209 relations_unset: Vec<RelationUnsetPayload>,
210 /// Provenance anchors — matches the MCP `memstead_update` `anchors[]`
211 /// shape; validated engine-side into a typed `INVALID_ANCHOR` refusal
212 /// on malformed input. Merged into the entity's existing set (same
213 /// `(artifact, grain, class)` triple replaces, otherwise appends).
214 #[serde(default)]
215 anchors: Vec<memstead_base::anchor::AnchorInput>,
216 /// Explicit anchor removals — matches the MCP `memstead_update`
217 /// `anchors_unset[]` shape; applied before the `anchors` merge.
218 #[serde(default)]
219 anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput>,
220 #[serde(default)]
221 dry_run: bool,
222 /// Agent-authored provenance note — same semantics as
223 /// `create --from`: the command-line `--note` wins when both are
224 /// supplied. One JSON template can therefore feed both
225 /// `create --from` and `update --from`. The optimistic-locking
226 /// selectors (`auto_hash`, `force`) are deliberately flag-only: a
227 /// stored payload must never be able to disable locking on a
228 /// future run.
229 #[serde(default)]
230 note: Option<String>,
231 /// Tolerated for template symmetry with `create --from` (one JSON
232 /// document feeds both commands). Update cannot rename an entity,
233 /// so a supplied `title` is only *checked*: a value differing from
234 /// the entity's current title refuses with `INVALID_INPUT`
235 /// pointing at `memstead rename` — never silently dropped.
236 #[serde(default)]
237 title: Option<String>,
238 /// Tolerated for template symmetry with `create --from`; must
239 /// match the entity's current type (update cannot retype —
240 /// delete + create instead). A differing value refuses.
241 #[serde(default)]
242 entity_type: Option<String>,
243 /// Tolerated for template symmetry with `create --from`; must
244 /// match the mem encoded in the entity id (update cannot move an
245 /// entity between mems). A differing value refuses.
246 #[serde(default)]
247 mem: Option<String>,
248}
249
250#[derive(Debug, Deserialize, Clone)]
251#[serde(deny_unknown_fields)]
252#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
253struct DeclareRelationPayload {
254 /// Target entity id (`mem--slug` or cross-mem form).
255 to: String,
256 /// Relationship type — case-insensitive on input; engine
257 /// canonicalises to UPPER_SNAKE_CASE.
258 rel_type: String,
259 /// Optional per-edge description. Validated against the rel-type's
260 /// `per_edge_description` posture in the engine.
261 #[serde(default)]
262 description: Option<String>,
263}
264
265#[derive(Debug, Deserialize, Clone)]
266#[serde(deny_unknown_fields)]
267struct RelationUnsetPayload {
268 /// Relationship type of the edge to remove (case-insensitive input;
269 /// engine canonicalises).
270 rel_type: String,
271 /// Full target entity id of the edge to remove.
272 target: String,
273}
274
275impl RelationUnsetPayload {
276 fn into_arg(self) -> memstead_base::ops::RelationUnsetArg {
277 memstead_base::ops::RelationUnsetArg {
278 rel_type: self.rel_type,
279 target: EntityId::canonical(&self.target),
280 }
281 }
282}
283
284#[derive(Debug, Deserialize)]
285#[serde(deny_unknown_fields)]
286#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
287struct PatchPayload {
288 old: String,
289 new: String,
290 #[serde(default)]
291 all: bool,
292}
293
294/// Template-symmetry check against the live entity: a shared
295/// create/update template may carry `title` / `entity_type`; update
296/// can change neither, so a present-but-differing value refuses
297/// instead of being silently dropped. Absent entity → skip (the
298/// engine's own `ENTITY_NOT_FOUND` is the better error).
299fn check_template_identity(
300 entity: Option<&memstead_base::Entity>,
301 payload_title: Option<&str>,
302 payload_type: Option<&str>,
303) -> Result<(), CliError> {
304 let Some(entity) = entity else {
305 return Ok(());
306 };
307 if let Some(t) = payload_title
308 && t != entity.title
309 {
310 return Err(CliError::new(
311 ExitKind::Validation,
312 "INVALID_INPUT",
313 format!(
314 "template `title` {t:?} differs from the entity's current title {:?} — \
315 update cannot rename; use `memstead rename`",
316 entity.title
317 ),
318 ));
319 }
320 if let Some(ty) = payload_type
321 && ty != entity.entity_type
322 {
323 return Err(CliError::new(
324 ExitKind::Validation,
325 "INVALID_INPUT",
326 format!(
327 "template `entity_type` {ty:?} differs from the entity's current type {:?} — \
328 update cannot retype; delete + create instead",
329 entity.entity_type
330 ),
331 ));
332 }
333 Ok(())
334}
335
336pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
337 let mut payload = if let Some(ref file) = args.from {
338 let bytes = std::fs::read(file).map_err(|e| {
339 CliError::new(
340 ExitKind::Generic,
341 "INVALID_INPUT",
342 format!("failed to read {}: {e}", file.display()),
343 )
344 })?;
345 let mut parsed: UpdatePayload = serde_json::from_slice(&bytes).map_err(|e| {
346 CliError::new(
347 ExitKind::Validation,
348 "INVALID_INPUT",
349 format!("invalid JSON in {}: {e}", file.display()),
350 )
351 .with_details(serde_json::json!({
352 "path": file.display().to_string(),
353 "parser_error": e.to_string(),
354 }))
355 })?;
356 // The non-content flags apply on the `--from` path exactly as on the
357 // inline path (the content flags conflict at parse time): `--dry-run`
358 // forces a dry run, and an explicit `--expected-hash` overrides the
359 // file's `expected_hash` field. Neither is ever silently dropped.
360 parsed.dry_run |= args.dry_run;
361 if args.expected_hash.is_some() {
362 parsed.expected_hash = args.expected_hash.clone();
363 }
364 parsed
365 } else {
366 let id = args.id.clone().ok_or_else(|| {
367 CliError::new(
368 ExitKind::Validation,
369 "INVALID_INPUT",
370 "missing entity ID (or pass --from <file.json>)",
371 )
372 })?;
373 UpdatePayload {
374 id,
375 expected_hash: args.expected_hash.clone(),
376 sections: parse_kv_list(&args.sections, "--section")?,
377 append_sections: parse_kv_list(&args.append, "--append")?,
378 patch_sections: parse_patch_list_combined(&args.patch, &args.patch_all)?,
379 metadata: parse_kv_list(&args.metadata, "--metadata")?,
380 metadata_unset: args.metadata_unset.clone(),
381 declare_relations: parse_declare_relations(&args.declare_relations)?,
382 // The repair-shaped removal is `--from`-only, like MCP's own
383 // JSON-args shape — the inline flag surface stays everyday-sized.
384 relations_unset: Vec::new(),
385 anchors: super::create::parse_anchor_list(&args.anchors)?,
386 anchors_unset: parse_anchor_unset_list(&args.anchors_unset)?,
387 dry_run: args.dry_run,
388 note: None,
389 title: None,
390 entity_type: None,
391 mem: None,
392 }
393 };
394
395 // `--note` (CLI flag) wins over a `note` carried in the `--from`
396 // payload when both are present — same precedence as `create --from`.
397 let note = args.note.clone().or_else(|| payload.note.clone());
398
399 let entity_id = EntityId::canonical(&payload.id);
400
401 // Template-symmetry consistency checks: a shared create/update
402 // template may carry `title` / `entity_type` / `mem`. Update can
403 // change none of them, so each present value must match the
404 // entity id's mem (checkable here) — the title/type compare runs
405 // against the live entity below, per engine flavour.
406 if let Some(m) = payload.mem.as_deref()
407 && m != entity_id.mem()
408 {
409 return Err(CliError::new(
410 ExitKind::Validation,
411 "INVALID_INPUT",
412 format!(
413 "template `mem` {m:?} does not match the mem in id `{entity_id}` — update cannot move an entity between mems (delete + create instead)"
414 ),
415 )
416 .into());
417 }
418
419 match ctx.cli_engine()? {
420 #[cfg(feature = "mem-repo")]
421 CliEngine::MemRepo(mut engine) => {
422 check_template_identity(
423 engine.get_entity(&entity_id),
424 payload.title.as_deref(),
425 payload.entity_type.as_deref(),
426 )?;
427 // Resolved AFTER the args are assembled, because whether the
428 // compare-and-swap token is required depends on the payload's own
429 // shape and the engine owns that predicate
430 // (consistency-sweep 03/04).
431 let explicit_hash = payload.expected_hash.take();
432
433 let patch_sections = payload
434 .patch_sections
435 .into_iter()
436 .map(|(k, v)| {
437 (
438 k,
439 PatchArg {
440 old: v.old,
441 new: v.new,
442 all: v.all,
443 },
444 )
445 })
446 .collect();
447
448 let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
449 .declare_relations
450 .iter()
451 .map(|r| memstead_base::ops::RelateArg {
452 target: EntityId::canonical(&r.to),
453 rel_type: r.rel_type.clone(),
454 description: r.description.clone(),
455 })
456 .collect();
457 let update_args = UpdateEntityArgs {
458 anchors: payload.anchors,
459 id: entity_id.clone(),
460 expected_hash: None,
461 sections: payload.sections,
462 append_sections: payload.append_sections,
463 patch_sections,
464 metadata: payload.metadata,
465 metadata_unset: payload.metadata_unset,
466 dry_run: payload.dry_run,
467 declare_relations,
468 relations_unset: payload
469 .relations_unset
470 .into_iter()
471 .map(RelationUnsetPayload::into_arg)
472 .collect(),
473 anchors_unset: payload.anchors_unset,
474 };
475 let mut update_args = update_args;
476 update_args.expected_hash = resolve_hash_mem_repo(
477 &engine,
478 &entity_id,
479 explicit_hash,
480 args.auto_hash,
481 args.force,
482 // `dry_run` joins the exemption because MCP's contract already
483 // says dry-run bypasses ONLY the hash check, and it is the
484 // documented stale-hash recovery path. Demanding a token here
485 // while MCP does not is a surface divergence
486 // (consistency-sweep 03/04).
487 !update_args.changes_content() || update_args.dry_run,
488 )?;
489
490 let result = engine
491 .update_entity_with_ctx(update_args, &crate::setup::cli_ctx_with_note(note.clone()))
492 .map_err(CliError::from_engine_op)?;
493 let mem_changed = engine.take_mem_changed_notices();
494
495 if ctx.json {
496 let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
497 super::merge_mem_changed_json(&mut body, &mem_changed);
498 print_json(&body)?;
499 } else {
500 let header = if payload.dry_run {
501 format!("# Dry-run `{}`", result.id)
502 } else {
503 format!("# Updated `{}`", result.id)
504 };
505 let sections_line = render_section_mutations(&result.modified_sections);
506 let metadata_line = render_metadata_mutations(&result.modified_metadata);
507 let mut body = format!("{header}\n\n- Title: {}", result.title);
508 if let Some(line) = sections_line {
509 body.push_str(&format!("\n- Sections: {line}"));
510 }
511 if let Some(line) = metadata_line {
512 body.push_str(&format!("\n- Metadata: {line}"));
513 }
514 if !result.relations_declared.is_empty() {
515 let parts: Vec<String> = result
516 .relations_declared
517 .iter()
518 .map(|r| {
519 let stubbed_tag = if r.target_was_stubbed {
520 " (stubbed)"
521 } else {
522 ""
523 };
524 format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
525 })
526 .collect();
527 body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
528 }
529 if !result.orphan_stubs_removed.is_empty() {
530 let ids: Vec<String> = result
531 .orphan_stubs_removed
532 .iter()
533 .map(|i| i.to_string())
534 .collect();
535 body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
536 }
537 if !result.warnings.is_empty() {
538 let parts: Vec<String> =
539 result.warnings.iter().map(|w| w.to_string()).collect();
540 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
541 }
542 body.push_str(&format!("\n- Hash: `{}`", result.content_hash));
543 body.push_str(&super::render_mem_changed_block(&mem_changed));
544 print_markdown(&body);
545 }
546 }
547 CliEngine::Filesystem(mut engine) => {
548 check_template_identity(
549 engine.get_entity(&entity_id),
550 payload.title.as_deref(),
551 payload.entity_type.as_deref(),
552 )?;
553 // The filesystem-mem `memstead_update` surface is intentionally
554 // smaller than mem-repo's: whole-section replacement,
555 // metadata set, and metadata unset are honoured;
556 // append_sections / patch_sections / dry_run are not yet
557 // wired on the filesystem engine. Surface that as a clear
558 // validation error rather than silently dropping the flags.
559 if !payload.append_sections.is_empty() {
560 return Err(CliError::new(
561 ExitKind::Validation,
562 "INVALID_INPUT",
563 "--append is not yet supported on filesystem-mem `memstead update`",
564 )
565 .into());
566 }
567 if !payload.patch_sections.is_empty() {
568 return Err(CliError::new(
569 ExitKind::Validation,
570 "INVALID_INPUT",
571 "--patch / --patch-all are not yet supported on filesystem-mem `memstead update`",
572 )
573 .into());
574 }
575 if payload.dry_run {
576 return Err(CliError::new(
577 ExitKind::Validation,
578 "INVALID_INPUT",
579 "--dry-run is not yet supported on filesystem-mem `memstead update`",
580 )
581 .into());
582 }
583
584 // Resolved after the args, as on the mem-repo path above.
585 let explicit_hash = payload.expected_hash.take();
586
587 let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
588 .declare_relations
589 .iter()
590 .map(|r| memstead_base::ops::RelateArg {
591 target: EntityId::canonical(&r.to),
592 rel_type: r.rel_type.clone(),
593 description: r.description.clone(),
594 })
595 .collect();
596 let update_args = UpdateEntityArgs {
597 anchors: payload.anchors,
598 id: entity_id.clone(),
599 expected_hash: None,
600 sections: payload.sections,
601 // CLI's update surface doesn't accept
602 // append_sections / patch_sections on its wire
603 // today; pass empty.
604 append_sections: IndexMap::new(),
605 patch_sections: IndexMap::new(),
606 metadata: payload.metadata,
607 metadata_unset: payload.metadata_unset,
608 declare_relations,
609 dry_run: false,
610 relations_unset: payload
611 .relations_unset
612 .into_iter()
613 .map(RelationUnsetPayload::into_arg)
614 .collect(),
615 anchors_unset: payload.anchors_unset,
616 };
617 let mut update_args = update_args;
618 update_args.expected_hash = resolve_hash_filesystem(
619 &engine,
620 &entity_id,
621 explicit_hash,
622 args.auto_hash,
623 args.force,
624 !update_args.changes_content(),
625 )?;
626 let outcome = engine
627 .update_entity(
628 update_args,
629 Actor::Cli,
630 Some(&crate::setup::cli_client_id()),
631 note.as_deref(),
632 )
633 .map_err(CliError::from_engine_op)?;
634
635 if ctx.json {
636 let relations_declared: Vec<serde_json::Value> = outcome
637 .relations_declared
638 .iter()
639 .map(|r| {
640 serde_json::json!({
641 "rel_type": r.rel_type,
642 "target": r.target.to_string(),
643 "target_was_stubbed": r.target_was_stubbed,
644 })
645 })
646 .collect();
647 print_json(&serde_json::json!({
648 "id": outcome.id.as_ref(),
649 "file_path": outcome.file_path,
650 "_hash": outcome.content_hash,
651 // Backend write identity — response-shape parity with
652 // the MCP filesystem flavour and the CLI's own
653 // relate/conflicts commands.
654 "write_id": outcome.write_id,
655 "modified_sections": outcome.modified_sections.replaced,
656 "modified_metadata_set": outcome.modified_metadata.set,
657 "modified_metadata_unset": outcome.modified_metadata.unset,
658 "relations_declared": relations_declared,
659 // Engine-emitted warnings (e.g. `NOTE_MISSING` under
660 // `[mutations].require_notes`) ride the response.
661 "warnings": outcome.warnings,
662 "orphan_stubs_removed": outcome
663 .orphan_stubs_removed
664 .iter()
665 .map(|i| i.to_string())
666 .collect::<Vec<_>>(),
667 }))?;
668 } else {
669 let mut body = format!("# Updated `{}`", outcome.id);
670 if !outcome.modified_sections.replaced.is_empty() {
671 let parts: Vec<String> = outcome
672 .modified_sections
673 .replaced
674 .iter()
675 .map(|k| format!("{k} (replaced)"))
676 .collect();
677 body.push_str(&format!("\n- Sections: {}", parts.join(", ")));
678 }
679 if !outcome.modified_metadata.set.is_empty()
680 || !outcome.modified_metadata.unset.is_empty()
681 {
682 let mut parts = Vec::new();
683 for k in &outcome.modified_metadata.set {
684 parts.push(format!("{k} (set)"));
685 }
686 for k in &outcome.modified_metadata.unset {
687 parts.push(format!("{k} (unset)"));
688 }
689 body.push_str(&format!("\n- Metadata: {}", parts.join(", ")));
690 }
691 if !outcome.relations_declared.is_empty() {
692 let parts: Vec<String> = outcome
693 .relations_declared
694 .iter()
695 .map(|r| {
696 let stubbed_tag = if r.target_was_stubbed {
697 " (stubbed)"
698 } else {
699 ""
700 };
701 format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
702 })
703 .collect();
704 body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
705 }
706 if !outcome.orphan_stubs_removed.is_empty() {
707 let ids: Vec<String> = outcome
708 .orphan_stubs_removed
709 .iter()
710 .map(|i| i.to_string())
711 .collect();
712 body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
713 }
714 if !outcome.warnings.is_empty() {
715 let parts: Vec<String> =
716 outcome.warnings.iter().map(|w| w.to_string()).collect();
717 body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
718 }
719 body.push_str(&format!("\n- Hash: `{}`", outcome.content_hash));
720 print_markdown(&body);
721 }
722 }
723 }
724 Ok(())
725}
726
727/// Render `modified_sections` as `identity (replaced), constraints (appended)`.
728/// Returns `None` when nothing was modified, letting the caller omit the line.
729#[cfg(feature = "mem-repo")]
730fn render_section_mutations(m: &memstead_git_branch::ModifiedSections) -> Option<String> {
731 let mut parts = Vec::new();
732 for k in &m.replaced {
733 parts.push(format!("{k} (replaced)"));
734 }
735 for k in &m.appended {
736 parts.push(format!("{k} (appended)"));
737 }
738 for k in &m.patched {
739 parts.push(format!("{k} (patched)"));
740 }
741 if parts.is_empty() {
742 None
743 } else {
744 Some(parts.join(", "))
745 }
746}
747
748/// Render `modified_metadata` as `level (set), tags (unset)`. `None` when empty.
749#[cfg(feature = "mem-repo")]
750fn render_metadata_mutations(m: &memstead_git_branch::ModifiedMetadata) -> Option<String> {
751 let mut parts = Vec::new();
752 for k in &m.set {
753 parts.push(format!("{k} (set)"));
754 }
755 for k in &m.unset {
756 parts.push(format!("{k} (unset)"));
757 }
758 if parts.is_empty() {
759 None
760 } else {
761 Some(parts.join(", "))
762 }
763}
764
765/// Resolve the hash the update will be issued with.
766///
767/// * `--force` and `--auto-hash` both refetch from the engine's in-memory
768/// store. Because the CLI initializes a fresh engine per invocation, the
769/// loaded hash matches the on-disk content as long as no concurrent writer
770/// changed the file between load and update (race window is microseconds).
771/// The two flags exist to encode user intent — `--auto-hash` for "I didn't
772/// bother reading the entity first," `--force` for "I intend to overwrite
773/// regardless of what's there."
774/// * Strict (default) → use the explicit `--expected-hash` / JSON field, else error.
775#[cfg(feature = "mem-repo")]
776fn resolve_hash_mem_repo(
777 engine: &memstead_base::Engine,
778 id: &EntityId,
779 explicit: Option<String>,
780 auto_hash: bool,
781 force: bool,
782 exempt: bool,
783) -> anyhow::Result<Option<String>> {
784 if auto_hash || force {
785 let entity = engine.get_entity(id).ok_or_else(|| {
786 CliError::new(
787 ExitKind::NotFound,
788 "ENTITY_NOT_FOUND",
789 format!("entity not found: {id}"),
790 )
791 .with_details(serde_json::json!({ "id": id.to_string() }))
792 })?;
793 return Ok(Some(entity.content_hash.clone()));
794 }
795 require_explicit_hash(explicit, exempt)
796}
797
798/// Filesystem-mem counterpart of [`resolve_hash_mem_repo`]. Same
799/// semantics; differs only in the engine accessor type.
800fn resolve_hash_filesystem(
801 engine: &memstead_base::Engine,
802 id: &EntityId,
803 explicit: Option<String>,
804 auto_hash: bool,
805 force: bool,
806 exempt: bool,
807) -> anyhow::Result<Option<String>> {
808 if auto_hash || force {
809 let entity = engine.get_entity(id).ok_or_else(|| {
810 CliError::new(
811 ExitKind::NotFound,
812 "ENTITY_NOT_FOUND",
813 format!("entity not found: {id}"),
814 )
815 .with_details(serde_json::json!({ "id": id.to_string() }))
816 })?;
817 return Ok(Some(entity.content_hash.clone()));
818 }
819 require_explicit_hash(explicit, exempt)
820}
821
822/// `exempt` waives the requirement (consistency-sweep 03/04). The
823/// compare-and-swap token asserts that the entity's CONTENT is unchanged, and
824/// on an anchors-only write the content is unchanged by construction: the
825/// anchors sidecar is outside `_hash` by deliberate design, so the token
826/// compares a value the guarded write cannot move. Demanding it therefore
827/// bought no protection and cost a read or dry-run roundtrip per entity,
828/// falling on exactly the backfill flows the anchor dialect exists to make
829/// attractive.
830///
831/// Callers derive `exempt` from the engine's own `changes_content()`, so this
832/// surface and MCP cannot come to disagree about whether a write is safe. The
833/// mem-repo path additionally waives it for `--dry-run`, matching the shipped
834/// MCP contract that a dry run bypasses only this check and is the designated
835/// stale-hash recovery path; a dry run writes nothing, so there is nothing to
836/// guard.
837///
838/// An EMPTY token counts as no token, here and on every other surface: it can
839/// never match a real hash, so treating it as a supplied one turned an
840/// anchors-only write into a spurious mismatch on whichever surface forgot.
841fn require_explicit_hash(explicit: Option<String>, exempt: bool) -> anyhow::Result<Option<String>> {
842 match explicit {
843 Some(h) if !h.is_empty() => Ok(Some(h)),
844 _ if exempt => Ok(None),
845 _ => Err(CliError::new(
846 ExitKind::Validation,
847 crate::HASH_FLAG_REQUIRED_CODE,
848 "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
849 or use --auto-hash for one-off interactive updates, or --force to overwrite. \
850 An anchors-only update (--anchor / --anchor-unset and nothing else) needs none: \
851 anchors are outside the content hash.",
852 )
853 .into()),
854 }
855}
856
857/// Parse repeatable `--declare-relations REL_TYPE:TARGET_ID` into
858/// the structured payload used downstream. Splits on the FIRST `:`
859/// so the target id can itself contain colons (cross-mem
860/// `[[mem:slug]]` form). The rel-type half must match the
861/// `[A-Za-z][A-Za-z_]*` grammar already used by `memstead relate`;
862/// validation against the workspace's schema vocabulary happens at
863/// the engine layer.
864fn parse_declare_relations(items: &[String]) -> anyhow::Result<Vec<DeclareRelationPayload>> {
865 let mut out = Vec::with_capacity(items.len());
866 for raw in items {
867 let (rel_type, target) = raw.split_once(':').ok_or_else(|| {
868 CliError::new(
869 ExitKind::Validation,
870 "INVALID_INPUT",
871 format!("--declare-relations: expected REL_TYPE:TARGET_ID, got `{raw}`"),
872 )
873 })?;
874 if rel_type.is_empty() || target.is_empty() {
875 return Err(CliError::new(
876 ExitKind::Validation,
877 "INVALID_INPUT",
878 format!(
879 "--declare-relations: REL_TYPE and TARGET_ID must both be non-empty, got `{raw}`"
880 ),
881 )
882 .into());
883 }
884 out.push(DeclareRelationPayload {
885 to: target.to_string(),
886 rel_type: rel_type.to_string(),
887 description: None,
888 });
889 }
890 Ok(out)
891}
892
893fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
894 let mut out = IndexMap::with_capacity(items.len());
895 for raw in items {
896 let (k, v) = raw.split_once('=').ok_or_else(|| {
897 CliError::new(
898 ExitKind::Validation,
899 "INVALID_INPUT",
900 format!("{flag}: expected KEY=VALUE, got `{raw}`"),
901 )
902 })?;
903 out.insert(k.to_string(), v.to_string());
904 }
905 Ok(out)
906}
907
908fn parse_patch_list_combined(
909 first_only: &[String],
910 all: &[String],
911) -> anyhow::Result<IndexMap<String, PatchPayload>> {
912 let mut out = IndexMap::with_capacity(first_only.len() + all.len());
913 for (items, flag, replace_all) in [(first_only, "--patch", false), (all, "--patch-all", true)] {
914 for raw in items {
915 let (key, rest) = raw.split_once('=').ok_or_else(|| {
916 CliError::new(
917 ExitKind::Validation,
918 "INVALID_INPUT",
919 format!("{flag}: expected KEY=OLD=>NEW, got `{raw}`"),
920 )
921 })?;
922 let (old, new) = rest.split_once("=>").ok_or_else(|| {
923 CliError::new(
924 ExitKind::Validation,
925 "INVALID_INPUT",
926 format!("{flag}: expected KEY=OLD=>NEW (missing `=>`), got `{raw}`"),
927 )
928 })?;
929 if out.contains_key(key) {
930 return Err(CliError::new(
931 ExitKind::Validation,
932 "INVALID_INPUT",
933 format!(
934 "duplicate patch for section `{key}` -- only one of --patch / --patch-all per section"
935 ),
936 )
937 .into());
938 }
939 out.insert(
940 key.to_string(),
941 PatchPayload {
942 old: old.to_string(),
943 new: new.to_string(),
944 all: replace_all,
945 },
946 );
947 }
948 }
949 Ok(out)
950}