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