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    /// 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}