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