Skip to main content

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    /// Provenance anchors — matches the MCP `memstead_update` `anchors[]`
202    /// shape; validated engine-side into a typed `INVALID_ANCHOR` refusal
203    /// on malformed input. Merged into the entity's existing set (same
204    /// `(artifact, grain, class)` triple replaces, otherwise appends).
205    #[serde(default)]
206    anchors: Vec<memstead_base::anchor::AnchorInput>,
207    /// Explicit anchor removals — matches the MCP `memstead_update`
208    /// `anchors_unset[]` shape; applied before the `anchors` merge.
209    #[serde(default)]
210    anchors_unset: Vec<memstead_base::anchor::AnchorUnsetInput>,
211    #[serde(default)]
212    dry_run: bool,
213    /// Agent-authored provenance note — same semantics as
214    /// `create --from`: the command-line `--note` wins when both are
215    /// supplied. One JSON template can therefore feed both
216    /// `create --from` and `update --from`. The optimistic-locking
217    /// selectors (`auto_hash`, `force`) are deliberately flag-only: a
218    /// stored payload must never be able to disable locking on a
219    /// future run.
220    #[serde(default)]
221    note: Option<String>,
222    /// Tolerated for template symmetry with `create --from` (one JSON
223    /// document feeds both commands). Update cannot rename an entity,
224    /// so a supplied `title` is only *checked*: a value differing from
225    /// the entity's current title refuses with `INVALID_INPUT`
226    /// pointing at `memstead rename` — never silently dropped.
227    #[serde(default)]
228    title: Option<String>,
229    /// Tolerated for template symmetry with `create --from`; must
230    /// match the entity's current type (update cannot retype —
231    /// delete + create instead). A differing value refuses.
232    #[serde(default)]
233    entity_type: Option<String>,
234    /// Tolerated for template symmetry with `create --from`; must
235    /// match the mem encoded in the entity id (update cannot move an
236    /// entity between mems). A differing value refuses.
237    #[serde(default)]
238    mem: Option<String>,
239}
240
241#[derive(Debug, Deserialize, Clone)]
242#[serde(deny_unknown_fields)]
243#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
244struct DeclareRelationPayload {
245    /// Target entity id (`mem--slug` or cross-mem form).
246    to: String,
247    /// Relationship type — case-insensitive on input; engine
248    /// canonicalises to UPPER_SNAKE_CASE.
249    rel_type: String,
250    /// Optional per-edge description. Validated against the rel-type's
251    /// `per_edge_description` posture in the engine.
252    #[serde(default)]
253    description: Option<String>,
254}
255
256#[derive(Debug, Deserialize)]
257#[serde(deny_unknown_fields)]
258#[cfg_attr(not(feature = "mem-repo"), allow(dead_code))]
259struct PatchPayload {
260    old: String,
261    new: String,
262    #[serde(default)]
263    all: bool,
264}
265
266/// Template-symmetry check against the live entity: a shared
267/// create/update template may carry `title` / `entity_type`; update
268/// can change neither, so a present-but-differing value refuses
269/// instead of being silently dropped. Absent entity → skip (the
270/// engine's own `ENTITY_NOT_FOUND` is the better error).
271fn check_template_identity(
272    entity: Option<&memstead_base::Entity>,
273    payload_title: Option<&str>,
274    payload_type: Option<&str>,
275) -> Result<(), CliError> {
276    let Some(entity) = entity else {
277        return Ok(());
278    };
279    if let Some(t) = payload_title
280        && t != entity.title
281    {
282        return Err(CliError::new(
283            ExitKind::Validation,
284            "INVALID_INPUT",
285            format!(
286                "template `title` {t:?} differs from the entity's current title {:?} — \
287                 update cannot rename; use `memstead rename`",
288                entity.title
289            ),
290        ));
291    }
292    if let Some(ty) = payload_type
293        && ty != entity.entity_type
294    {
295        return Err(CliError::new(
296            ExitKind::Validation,
297            "INVALID_INPUT",
298            format!(
299                "template `entity_type` {ty:?} differs from the entity's current type {:?} — \
300                 update cannot retype; delete + create instead",
301                entity.entity_type
302            ),
303        ));
304    }
305    Ok(())
306}
307
308pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
309    let mut payload = if let Some(ref file) = args.from {
310        let bytes = std::fs::read(file).map_err(|e| {
311            CliError::new(
312                ExitKind::Generic,
313                "INVALID_INPUT",
314                format!("failed to read {}: {e}", file.display()),
315            )
316        })?;
317        let mut parsed: UpdatePayload = serde_json::from_slice(&bytes).map_err(|e| {
318            CliError::new(
319                ExitKind::Validation,
320                "INVALID_INPUT",
321                format!("invalid JSON in {}: {e}", file.display()),
322            )
323            .with_details(serde_json::json!({
324                "path": file.display().to_string(),
325                "parser_error": e.to_string(),
326            }))
327        })?;
328        // The non-content flags apply on the `--from` path exactly as on the
329        // inline path (the content flags conflict at parse time): `--dry-run`
330        // forces a dry run, and an explicit `--expected-hash` overrides the
331        // file's `expected_hash` field. Neither is ever silently dropped.
332        parsed.dry_run |= args.dry_run;
333        if args.expected_hash.is_some() {
334            parsed.expected_hash = args.expected_hash.clone();
335        }
336        parsed
337    } else {
338        let id = args.id.clone().ok_or_else(|| {
339            CliError::new(
340                ExitKind::Validation,
341                "INVALID_INPUT",
342                "missing entity ID (or pass --from <file.json>)",
343            )
344        })?;
345        UpdatePayload {
346            id,
347            expected_hash: args.expected_hash.clone(),
348            sections: parse_kv_list(&args.sections, "--section")?,
349            append_sections: parse_kv_list(&args.append, "--append")?,
350            patch_sections: parse_patch_list_combined(&args.patch, &args.patch_all)?,
351            metadata: parse_kv_list(&args.metadata, "--metadata")?,
352            metadata_unset: args.metadata_unset.clone(),
353            declare_relations: parse_declare_relations(&args.declare_relations)?,
354            anchors: super::create::parse_anchor_list(&args.anchors)?,
355            anchors_unset: parse_anchor_unset_list(&args.anchors_unset)?,
356            dry_run: args.dry_run,
357            note: None,
358            title: None,
359            entity_type: None,
360            mem: None,
361        }
362    };
363
364    // `--note` (CLI flag) wins over a `note` carried in the `--from`
365    // payload when both are present — same precedence as `create --from`.
366    let note = args.note.clone().or_else(|| payload.note.clone());
367
368    let entity_id = EntityId::canonical(&payload.id);
369
370    // Template-symmetry consistency checks: a shared create/update
371    // template may carry `title` / `entity_type` / `mem`. Update can
372    // change none of them, so each present value must match the
373    // entity id's mem (checkable here) — the title/type compare runs
374    // against the live entity below, per engine flavour.
375    if let Some(m) = payload.mem.as_deref()
376        && m != entity_id.mem()
377    {
378        return Err(CliError::new(
379            ExitKind::Validation,
380            "INVALID_INPUT",
381            format!(
382                "template `mem` {m:?} does not match the mem in id `{entity_id}` — update                  cannot move an entity between mems (delete + create instead)"
383            ),
384        )
385        .into());
386    }
387
388    match ctx.cli_engine()? {
389        #[cfg(feature = "mem-repo")]
390        CliEngine::MemRepo(mut engine) => {
391            check_template_identity(
392                engine.get_entity(&entity_id),
393                payload.title.as_deref(),
394                payload.entity_type.as_deref(),
395            )?;
396            // Resolved AFTER the args are assembled, because whether the
397            // compare-and-swap token is required depends on the payload's own
398            // shape and the engine owns that predicate
399            // (consistency-sweep 03/04).
400            let explicit_hash = payload.expected_hash.take();
401
402            let patch_sections = payload
403                .patch_sections
404                .into_iter()
405                .map(|(k, v)| {
406                    (
407                        k,
408                        PatchArg {
409                            old: v.old,
410                            new: v.new,
411                            all: v.all,
412                        },
413                    )
414                })
415                .collect();
416
417            let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
418                .declare_relations
419                .iter()
420                .map(|r| memstead_base::ops::RelateArg {
421                    target: EntityId::canonical(&r.to),
422                    rel_type: r.rel_type.clone(),
423                    description: r.description.clone(),
424                })
425                .collect();
426            let update_args = UpdateEntityArgs {
427                anchors: payload.anchors,
428                id: entity_id.clone(),
429                expected_hash: None,
430                sections: payload.sections,
431                append_sections: payload.append_sections,
432                patch_sections,
433                metadata: payload.metadata,
434                metadata_unset: payload.metadata_unset,
435                dry_run: payload.dry_run,
436                declare_relations,
437                relations_unset: Vec::new(),
438                anchors_unset: payload.anchors_unset,
439            };
440            let mut update_args = update_args;
441            update_args.expected_hash = resolve_hash_mem_repo(
442                &engine,
443                &entity_id,
444                explicit_hash,
445                args.auto_hash,
446                args.force,
447                // `dry_run` joins the exemption because MCP's contract already
448                // says dry-run bypasses ONLY the hash check, and it is the
449                // documented stale-hash recovery path. Demanding a token here
450                // while MCP does not is a surface divergence
451                // (consistency-sweep 03/04).
452                !update_args.changes_content() || update_args.dry_run,
453            )?;
454
455            let result = engine
456                .update_entity_with_ctx(update_args, &crate::setup::cli_ctx_with_note(note.clone()))
457                .map_err(CliError::from_engine_op)?;
458            let mem_changed = engine.take_mem_changed_notices();
459
460            if ctx.json {
461                let mut body = serde_json::to_value(&result).unwrap_or(serde_json::Value::Null);
462                super::merge_mem_changed_json(&mut body, &mem_changed);
463                print_json(&body)?;
464            } else {
465                let header = if payload.dry_run {
466                    format!("# Dry-run `{}`", result.id)
467                } else {
468                    format!("# Updated `{}`", result.id)
469                };
470                let sections_line = render_section_mutations(&result.modified_sections);
471                let metadata_line = render_metadata_mutations(&result.modified_metadata);
472                let mut body = format!("{header}\n\n- Title: {}", result.title);
473                if let Some(line) = sections_line {
474                    body.push_str(&format!("\n- Sections: {line}"));
475                }
476                if let Some(line) = metadata_line {
477                    body.push_str(&format!("\n- Metadata: {line}"));
478                }
479                if !result.relations_declared.is_empty() {
480                    let parts: Vec<String> = result
481                        .relations_declared
482                        .iter()
483                        .map(|r| {
484                            let stubbed_tag = if r.target_was_stubbed {
485                                " (stubbed)"
486                            } else {
487                                ""
488                            };
489                            format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
490                        })
491                        .collect();
492                    body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
493                }
494                if !result.orphan_stubs_removed.is_empty() {
495                    let ids: Vec<String> = result
496                        .orphan_stubs_removed
497                        .iter()
498                        .map(|i| i.to_string())
499                        .collect();
500                    body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
501                }
502                if !result.warnings.is_empty() {
503                    let parts: Vec<String> =
504                        result.warnings.iter().map(|w| w.to_string()).collect();
505                    body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
506                }
507                body.push_str(&format!("\n- Hash: `{}`", result.content_hash));
508                body.push_str(&super::render_mem_changed_block(&mem_changed));
509                print_markdown(&body);
510            }
511        }
512        CliEngine::Filesystem(mut engine) => {
513            check_template_identity(
514                engine.get_entity(&entity_id),
515                payload.title.as_deref(),
516                payload.entity_type.as_deref(),
517            )?;
518            // The filesystem-mem `memstead_update` surface is intentionally
519            // smaller than mem-repo's: whole-section replacement,
520            // metadata set, and metadata unset are honoured;
521            // append_sections / patch_sections / dry_run are not yet
522            // wired on the filesystem engine. Surface that as a clear
523            // validation error rather than silently dropping the flags.
524            if !payload.append_sections.is_empty() {
525                return Err(CliError::new(
526                    ExitKind::Validation,
527                    "INVALID_INPUT",
528                    "--append is not yet supported on filesystem-mem `memstead update`",
529                )
530                .into());
531            }
532            if !payload.patch_sections.is_empty() {
533                return Err(CliError::new(
534                    ExitKind::Validation,
535                    "INVALID_INPUT",
536                    "--patch / --patch-all are not yet supported on filesystem-mem `memstead update`",
537                )
538                .into());
539            }
540            if payload.dry_run {
541                return Err(CliError::new(
542                    ExitKind::Validation,
543                    "INVALID_INPUT",
544                    "--dry-run is not yet supported on filesystem-mem `memstead update`",
545                )
546                .into());
547            }
548
549            // Resolved after the args, as on the mem-repo path above.
550            let explicit_hash = payload.expected_hash.take();
551
552            let declare_relations: Vec<memstead_base::ops::RelateArg> = payload
553                .declare_relations
554                .iter()
555                .map(|r| memstead_base::ops::RelateArg {
556                    target: EntityId::canonical(&r.to),
557                    rel_type: r.rel_type.clone(),
558                    description: r.description.clone(),
559                })
560                .collect();
561            let update_args = UpdateEntityArgs {
562                anchors: payload.anchors,
563                id: entity_id.clone(),
564                expected_hash: None,
565                sections: payload.sections,
566                // CLI's update surface doesn't accept
567                // append_sections / patch_sections on its wire
568                // today; pass empty.
569                append_sections: IndexMap::new(),
570                patch_sections: IndexMap::new(),
571                metadata: payload.metadata,
572                metadata_unset: payload.metadata_unset,
573                declare_relations,
574                dry_run: false,
575                relations_unset: Vec::new(),
576                anchors_unset: payload.anchors_unset,
577            };
578            let mut update_args = update_args;
579            update_args.expected_hash = resolve_hash_filesystem(
580                &engine,
581                &entity_id,
582                explicit_hash,
583                args.auto_hash,
584                args.force,
585                !update_args.changes_content(),
586            )?;
587            let outcome = engine
588                .update_entity(
589                    update_args,
590                    Actor::Cli,
591                    Some(&crate::setup::cli_client_id()),
592                    note.as_deref(),
593                )
594                .map_err(CliError::from_engine_op)?;
595
596            if ctx.json {
597                let relations_declared: Vec<serde_json::Value> = outcome
598                    .relations_declared
599                    .iter()
600                    .map(|r| {
601                        serde_json::json!({
602                            "rel_type": r.rel_type,
603                            "target": r.target.to_string(),
604                            "target_was_stubbed": r.target_was_stubbed,
605                        })
606                    })
607                    .collect();
608                print_json(&serde_json::json!({
609                    "id": outcome.id.as_ref(),
610                    "file_path": outcome.file_path,
611                    "_hash": outcome.content_hash,
612                    // Backend write identity — response-shape parity with
613                    // the MCP filesystem flavour and the CLI's own
614                    // relate/conflicts commands.
615                    "write_id": outcome.write_id,
616                    "modified_sections": outcome.modified_sections.replaced,
617                    "modified_metadata_set": outcome.modified_metadata.set,
618                    "modified_metadata_unset": outcome.modified_metadata.unset,
619                    "relations_declared": relations_declared,
620                    // Engine-emitted warnings (e.g. `NOTE_MISSING` under
621                    // `[mutations].require_notes`) ride the response.
622                    "warnings": outcome.warnings,
623                    "orphan_stubs_removed": outcome
624                        .orphan_stubs_removed
625                        .iter()
626                        .map(|i| i.to_string())
627                        .collect::<Vec<_>>(),
628                }))?;
629            } else {
630                let mut body = format!("# Updated `{}`", outcome.id);
631                if !outcome.modified_sections.replaced.is_empty() {
632                    let parts: Vec<String> = outcome
633                        .modified_sections
634                        .replaced
635                        .iter()
636                        .map(|k| format!("{k} (replaced)"))
637                        .collect();
638                    body.push_str(&format!("\n- Sections: {}", parts.join(", ")));
639                }
640                if !outcome.modified_metadata.set.is_empty()
641                    || !outcome.modified_metadata.unset.is_empty()
642                {
643                    let mut parts = Vec::new();
644                    for k in &outcome.modified_metadata.set {
645                        parts.push(format!("{k} (set)"));
646                    }
647                    for k in &outcome.modified_metadata.unset {
648                        parts.push(format!("{k} (unset)"));
649                    }
650                    body.push_str(&format!("\n- Metadata: {}", parts.join(", ")));
651                }
652                if !outcome.relations_declared.is_empty() {
653                    let parts: Vec<String> = outcome
654                        .relations_declared
655                        .iter()
656                        .map(|r| {
657                            let stubbed_tag = if r.target_was_stubbed {
658                                " (stubbed)"
659                            } else {
660                                ""
661                            };
662                            format!("{} → {}{}", r.rel_type, r.target, stubbed_tag)
663                        })
664                        .collect();
665                    body.push_str(&format!("\n- Relations declared: {}", parts.join(", ")));
666                }
667                if !outcome.orphan_stubs_removed.is_empty() {
668                    let ids: Vec<String> = outcome
669                        .orphan_stubs_removed
670                        .iter()
671                        .map(|i| i.to_string())
672                        .collect();
673                    body.push_str(&format!("\n- Orphan stubs GC'd: {}", ids.join(", ")));
674                }
675                if !outcome.warnings.is_empty() {
676                    let parts: Vec<String> =
677                        outcome.warnings.iter().map(|w| w.to_string()).collect();
678                    body.push_str(&format!("\n- Warnings: {}", parts.join("; ")));
679                }
680                body.push_str(&format!("\n- Hash: `{}`", outcome.content_hash));
681                print_markdown(&body);
682            }
683        }
684    }
685    Ok(())
686}
687
688/// Render `modified_sections` as `identity (replaced), constraints (appended)`.
689/// Returns `None` when nothing was modified, letting the caller omit the line.
690#[cfg(feature = "mem-repo")]
691fn render_section_mutations(m: &memstead_git_branch::ModifiedSections) -> Option<String> {
692    let mut parts = Vec::new();
693    for k in &m.replaced {
694        parts.push(format!("{k} (replaced)"));
695    }
696    for k in &m.appended {
697        parts.push(format!("{k} (appended)"));
698    }
699    for k in &m.patched {
700        parts.push(format!("{k} (patched)"));
701    }
702    if parts.is_empty() {
703        None
704    } else {
705        Some(parts.join(", "))
706    }
707}
708
709/// Render `modified_metadata` as `level (set), tags (unset)`. `None` when empty.
710#[cfg(feature = "mem-repo")]
711fn render_metadata_mutations(m: &memstead_git_branch::ModifiedMetadata) -> Option<String> {
712    let mut parts = Vec::new();
713    for k in &m.set {
714        parts.push(format!("{k} (set)"));
715    }
716    for k in &m.unset {
717        parts.push(format!("{k} (unset)"));
718    }
719    if parts.is_empty() {
720        None
721    } else {
722        Some(parts.join(", "))
723    }
724}
725
726/// Resolve the hash the update will be issued with.
727///
728/// * `--force` and `--auto-hash` both refetch from the engine's in-memory
729///   store. Because the CLI initializes a fresh engine per invocation, the
730///   loaded hash matches the on-disk content as long as no concurrent writer
731///   changed the file between load and update (race window is microseconds).
732///   The two flags exist to encode user intent — `--auto-hash` for "I didn't
733///   bother reading the entity first," `--force` for "I intend to overwrite
734///   regardless of what's there."
735/// * Strict (default) → use the explicit `--expected-hash` / JSON field, else error.
736#[cfg(feature = "mem-repo")]
737fn resolve_hash_mem_repo(
738    engine: &memstead_base::Engine,
739    id: &EntityId,
740    explicit: Option<String>,
741    auto_hash: bool,
742    force: bool,
743    exempt: bool,
744) -> anyhow::Result<Option<String>> {
745    if auto_hash || force {
746        let entity = engine.get_entity(id).ok_or_else(|| {
747            CliError::new(
748                ExitKind::NotFound,
749                "ENTITY_NOT_FOUND",
750                format!("entity not found: {id}"),
751            )
752            .with_details(serde_json::json!({ "id": id.to_string() }))
753        })?;
754        return Ok(Some(entity.content_hash.clone()));
755    }
756    require_explicit_hash(explicit, exempt)
757}
758
759/// Filesystem-mem counterpart of [`resolve_hash_mem_repo`]. Same
760/// semantics; differs only in the engine accessor type.
761fn resolve_hash_filesystem(
762    engine: &memstead_base::Engine,
763    id: &EntityId,
764    explicit: Option<String>,
765    auto_hash: bool,
766    force: bool,
767    exempt: bool,
768) -> anyhow::Result<Option<String>> {
769    if auto_hash || force {
770        let entity = engine.get_entity(id).ok_or_else(|| {
771            CliError::new(
772                ExitKind::NotFound,
773                "ENTITY_NOT_FOUND",
774                format!("entity not found: {id}"),
775            )
776            .with_details(serde_json::json!({ "id": id.to_string() }))
777        })?;
778        return Ok(Some(entity.content_hash.clone()));
779    }
780    require_explicit_hash(explicit, exempt)
781}
782
783/// `exempt` waives the requirement (consistency-sweep 03/04). The
784/// compare-and-swap token asserts that the entity's CONTENT is unchanged, and
785/// on an anchors-only write the content is unchanged by construction: the
786/// anchors sidecar is outside `_hash` by deliberate design, so the token
787/// compares a value the guarded write cannot move. Demanding it therefore
788/// bought no protection and cost a read or dry-run roundtrip per entity,
789/// falling on exactly the backfill flows the anchor dialect exists to make
790/// attractive.
791///
792/// Callers derive `exempt` from the engine's own `changes_content()`, so this
793/// surface and MCP cannot come to disagree about whether a write is safe. The
794/// mem-repo path additionally waives it for `--dry-run`, matching the shipped
795/// MCP contract that a dry run bypasses only this check and is the designated
796/// stale-hash recovery path; a dry run writes nothing, so there is nothing to
797/// guard.
798///
799/// An EMPTY token counts as no token, here and on every other surface: it can
800/// never match a real hash, so treating it as a supplied one turned an
801/// anchors-only write into a spurious mismatch on whichever surface forgot.
802fn require_explicit_hash(explicit: Option<String>, exempt: bool) -> anyhow::Result<Option<String>> {
803    match explicit {
804        Some(h) if !h.is_empty() => Ok(Some(h)),
805        _ if exempt => Ok(None),
806        _ => Err(CliError::new(
807            ExitKind::Validation,
808            crate::HASH_FLAG_REQUIRED_CODE,
809            "missing --expected-hash. Read the entity first (memstead entity <id>) and pass its `_hash`, \
810             or use --auto-hash for one-off interactive updates, or --force to overwrite. \
811             An anchors-only update (--anchor / --anchor-unset and nothing else) needs none: \
812             anchors are outside the content hash.",
813        )
814        .into()),
815    }
816}
817
818/// Parse repeatable `--declare-relations REL_TYPE:TARGET_ID` into
819/// the structured payload used downstream. Splits on the FIRST `:`
820/// so the target id can itself contain colons (cross-mem
821/// `[[mem:slug]]` form). The rel-type half must match the
822/// `[A-Za-z][A-Za-z_]*` grammar already used by `memstead relate`;
823/// validation against the workspace's schema vocabulary happens at
824/// the engine layer.
825fn parse_declare_relations(items: &[String]) -> anyhow::Result<Vec<DeclareRelationPayload>> {
826    let mut out = Vec::with_capacity(items.len());
827    for raw in items {
828        let (rel_type, target) = raw.split_once(':').ok_or_else(|| {
829            CliError::new(
830                ExitKind::Validation,
831                "INVALID_INPUT",
832                format!("--declare-relations: expected REL_TYPE:TARGET_ID, got `{raw}`"),
833            )
834        })?;
835        if rel_type.is_empty() || target.is_empty() {
836            return Err(CliError::new(
837                ExitKind::Validation,
838                "INVALID_INPUT",
839                format!(
840                    "--declare-relations: REL_TYPE and TARGET_ID must both be non-empty, got `{raw}`"
841                ),
842            )
843            .into());
844        }
845        out.push(DeclareRelationPayload {
846            to: target.to_string(),
847            rel_type: rel_type.to_string(),
848            description: None,
849        });
850    }
851    Ok(out)
852}
853
854fn parse_kv_list(items: &[String], flag: &str) -> anyhow::Result<IndexMap<String, String>> {
855    let mut out = IndexMap::with_capacity(items.len());
856    for raw in items {
857        let (k, v) = raw.split_once('=').ok_or_else(|| {
858            CliError::new(
859                ExitKind::Validation,
860                "INVALID_INPUT",
861                format!("{flag}: expected KEY=VALUE, got `{raw}`"),
862            )
863        })?;
864        out.insert(k.to_string(), v.to_string());
865    }
866    Ok(out)
867}
868
869fn parse_patch_list_combined(
870    first_only: &[String],
871    all: &[String],
872) -> anyhow::Result<IndexMap<String, PatchPayload>> {
873    let mut out = IndexMap::with_capacity(first_only.len() + all.len());
874    for (items, flag, replace_all) in [(first_only, "--patch", false), (all, "--patch-all", true)] {
875        for raw in items {
876            let (key, rest) = raw.split_once('=').ok_or_else(|| {
877                CliError::new(
878                    ExitKind::Validation,
879                    "INVALID_INPUT",
880                    format!("{flag}: expected KEY=OLD=>NEW, got `{raw}`"),
881                )
882            })?;
883            let (old, new) = rest.split_once("=>").ok_or_else(|| {
884                CliError::new(
885                    ExitKind::Validation,
886                    "INVALID_INPUT",
887                    format!("{flag}: expected KEY=OLD=>NEW (missing `=>`), got `{raw}`"),
888                )
889            })?;
890            if out.contains_key(key) {
891                return Err(CliError::new(
892                    ExitKind::Validation,
893                    "INVALID_INPUT",
894                    format!(
895                        "duplicate patch for section `{key}` -- only one of --patch / --patch-all per section"
896                    ),
897                )
898                .into());
899            }
900            out.insert(
901                key.to_string(),
902                PatchPayload {
903                    old: old.to_string(),
904                    new: new.to_string(),
905                    all: replace_all,
906                },
907            );
908        }
909    }
910    Ok(out)
911}