Skip to main content

omgbase_mutate/
effects.rs

1//! Effects and plans (`spec/mutate/README.md` §11, mutate 2.3; ADR-021):
2//! the public intentional-write vocabulary — nine effects over document,
3//! block, node and section targets — as a **planner tier** over the six
4//! kernel ops. Everything here is pure: the effect and plan shapes with their
5//! JSON forms, the `$new.<n>` symbols a plan uses for identities it creates,
6//! the shape validator (§11.1), the focus bookkeeping the conflict rules run
7//! on ([`ConflictTracker`], §11.3), the canonical JSON the plan digest is
8//! computed over (§11.5), and the [`Plan`] with its digest. The store
9//! resolves targets, lowers under a symbolic minter, pins the preconditions
10//! and commits a plan (`omgbase-store`'s `effects_plan`).
11//!
12//! **Canonical JSON** (§11.5): no whitespace; object members sorted by the
13//! UTF-8 bytes of their keys; a `null` value stays `null` (optional keys are
14//! absent, never `null`, by construction); strings escaped as JSON requires
15//! and no further (`"`, `\`, U+0000–U+001F as `\b \f \n \r \t` else
16//! `\u00xx` lower-case — `serde_json`'s own rules — everything else literal
17//! UTF-8); a number integral with |v| < 2^53 as a plain integer (`-0` → `0`),
18//! any other finite number as ECMAScript `Number::toString` prints it
19//! (shortest round-trip digits; exponential outside `1e-7 ≤ |x| < 1e21`).
20//!
21//! `plan_id = hex(sha256(canonical({ version, planner_v, effects, opset,
22//! preconditions, new_ids, selection? })))` — `selection` (3.1, §11.8) is
23//! the seventh core member when the plan was derived from an OQX selector,
24//! absent otherwise.
25
26use std::collections::{BTreeMap, HashMap};
27
28use omgbase_format::hash::{hex, sha256};
29use serde_json::{Map, Value, json};
30
31use crate::changeset::Op;
32use crate::error::{ErrorCode, MutationError, Result};
33use crate::ops::{At, Expect, OpResult, Parent, To};
34use crate::opset::{OpsetSummary, PlanDisposition};
35
36/// The plan schema version this crate reads and writes.
37pub const PLAN_VERSION: u64 = 1;
38/// The lowering + conflict rules version pinned in `spec/mutate` §11 (the
39/// `matcher_v` precedent): `p2` since mutate 3.0 — a `create` is a loaded
40/// document whose block symbols are in `new_ids`; `p3` since mutate 3.1 —
41/// the core may carry a `selection` (§11.8), so every plan digest changes.
42pub const PLANNER_V: &str = "p3";
43/// §11.8: the most hits a selector may yield when the request names no
44/// `expect.max` — a selection past it is `selection_too_large`.
45pub const SELECTION_CEILING: usize = 1000;
46
47// ---- `$new.<n>` ---------------------------------------------------------------------------
48
49/// `$new.<n>` → `n`; `None` for anything else.
50#[must_use]
51pub fn parse_new_symbol(s: &str) -> Option<usize> {
52    let n = s.strip_prefix("$new.")?;
53    if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
54        return None;
55    }
56    n.parse().ok()
57}
58
59/// The `n`-th symbolic identity.
60#[must_use]
61pub fn new_symbol(n: usize) -> String {
62    format!("$new.{n}")
63}
64
65// ---- the shape check's `fix` (§8, 3.4) ----------------------------------------------------
66
67/// A §11.1 shape error: the message (naming `effects[i]` and the member) and,
68/// since 3.4 (§8), the corrected call when the check can spell it — the
69/// request as given with the offending effect rewritten, canonical JSON.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct ShapeError {
72    pub message: String,
73    pub fix: Option<String>,
74}
75
76impl From<String> for ShapeError {
77    fn from(message: String) -> Self {
78        Self { message, fix: None }
79    }
80}
81
82/// A live block's row for the anchor fix: its parent block id (`None` at the
83/// top level) and its document's path (storage form).
84#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct ParentRow {
86    pub parent: Option<String>,
87    pub path: String,
88}
89
90/// What the shape check needs to spell a `fix` for the shape errors of §8
91/// (3.4; the reference's `ShapeFixContext`): the request as given — `select`
92/// and `expect` when present, the `effects` list (the corrected call is that
93/// request with `effects[i]` AS WRITTEN rewritten, before any desugaring) —
94/// and the live-row lookup for the one fix that reads the store: `parent_of`
95/// a block id → its row, or `None` when no live block has the id (then there
96/// is no fix). The planner's compile supplies it; a call without it gets the
97/// same errors with no `fix`.
98pub struct ShapeFixContext<'a> {
99    pub select: Option<&'a Value>,
100    pub effects: &'a Value,
101    pub expect: Option<&'a Value>,
102    pub parent_of: &'a dyn Fn(&str) -> Option<ParentRow>,
103}
104
105/// The fixer of one effect (the reference's `EffectFixer`): `build(effect,
106/// expect)` is the request with `effects[i]` replaced by `effect` (and, when
107/// given, its top-level `expect` replaced), canonical.
108struct Fixer<'a> {
109    ctx: &'a ShapeFixContext<'a>,
110    i: usize,
111    /// the effect as written in the request (the base every corrected effect is spelled from)
112    written: Map<String, Value>,
113}
114
115impl<'a> Fixer<'a> {
116    fn new(ctx: Option<&'a ShapeFixContext<'a>>, i: usize, v: &Map<String, Value>) -> Option<Self> {
117        let ctx = ctx?;
118        let written = ctx
119            .effects
120            .as_array()
121            .and_then(|l| l.get(i))
122            .and_then(Value::as_object)
123            .unwrap_or(v)
124            .clone();
125        Some(Self { ctx, i, written })
126    }
127
128    fn build(&self, effect: Map<String, Value>, expect: Option<Value>) -> String {
129        let effects = match self.ctx.effects {
130            Value::Array(list) => {
131                let mut copy = list.clone();
132                if self.i < copy.len() {
133                    copy[self.i] = Value::Object(effect);
134                } else {
135                    copy.resize(self.i, Value::Null);
136                    copy.push(Value::Object(effect));
137                }
138                Value::Array(copy)
139            }
140            other => {
141                let mut o = other.as_object().cloned().unwrap_or_default();
142                o.insert(self.i.to_string(), Value::Object(effect));
143                Value::Object(o)
144            }
145        };
146        let mut m = Map::new();
147        if let Some(s) = self.ctx.select {
148            m.insert("select".to_owned(), s.clone());
149        }
150        m.insert("effects".to_owned(), effects);
151        match expect {
152            Some(e) => {
153                m.insert("expect".to_owned(), e);
154            }
155            None => {
156                if let Some(e) = self.ctx.expect {
157                    m.insert("expect".to_owned(), e.clone());
158                }
159            }
160        }
161        canonical_json(&Value::Object(m))
162    }
163}
164
165/// The reference form of a storage path: exactly one leading `/`.
166fn reference_path(path: &str) -> String {
167    format!("/{}", path.trim_start_matches('/'))
168}
169
170/// §8 (3.4): an effect-level `expect.max` — the bound belongs to the
171/// request's top-level `expect`, beside `select`. The fix drops it from the
172/// effect (the rest of that `expect` stays; an emptied `expect` goes) and,
173/// when the request has a `select`, moves it to the top level unless a
174/// top-level `expect.max` is already there (then the top-level one is kept
175/// as written); without a `select` a top-level `expect` would itself be
176/// refused, so the fix only drops it.
177fn effect_level_max(v: &Map<String, Value>, here: &str, f: Option<&Fixer<'_>>) -> ShapeError {
178    let message = format!(
179        "{here}: `expect.max` bounds a selection — it is the request's top-level `expect`, beside `select`, not an effect's"
180    );
181    let Some(f) = f else {
182        return message.into();
183    };
184    let mut rest = v.clone();
185    let max = rest.remove("max").unwrap_or(Value::Null);
186    let mut effect = f.written.clone();
187    if rest.is_empty() {
188        effect.remove("expect");
189    } else {
190        effect.insert("expect".to_owned(), Value::Object(rest));
191    }
192    if f.ctx.select.is_none() {
193        return ShapeError {
194            message,
195            fix: Some(f.build(effect, None)),
196        };
197    }
198    let mut top = f
199        .ctx
200        .expect
201        .and_then(Value::as_object)
202        .cloned()
203        .unwrap_or_default();
204    if !top.contains_key("max") {
205        top.insert("max".to_owned(), max);
206    }
207    ShapeError {
208        message,
209        fix: Some(f.build(effect, Some(Value::Object(top)))),
210    }
211}
212
213/// §8 (3.4): `at` given as the bare string `"after"` / `"before"` with a
214/// block `into` — "right after block X" is `into: X's parent, at: { after:
215/// X }`. The fix needs the block's live row (its parent, or its document
216/// when it is top-level, spelled as the rooted path); a block the store does
217/// not know gets no fix.
218fn bare_anchor(at: &str, into: &Target, here: &str, f: Option<&Fixer<'_>>) -> ShapeError {
219    let message = format!(
220        "{here}: `at` must be \"start\", \"end\", {{ before: <block id> }} or {{ after: <block id> }} — \"{at} block X\" is into: X's parent (a block, or the document when X is top-level), at: {{ {at}: X }}"
221    );
222    let (Some(f), Target::Block { id }) = (f, into) else {
223        return message.into();
224    };
225    let Some(row) = (f.ctx.parent_of)(id) else {
226        return message.into();
227    };
228    let parent = match row.parent {
229        None => json!(reference_path(&row.path)),
230        Some(p) => json!({ "kind": "block", "id": p }),
231    };
232    let mut effect = f.written.clone();
233    effect.insert("into".to_owned(), parent);
234    effect.insert("at".to_owned(), json!({ at: id }));
235    ShapeError {
236        message,
237        fix: Some(f.build(effect, None)),
238    }
239}
240
241/// §8 (3.4): a plural-target `expect` written as `{ content_hash: { <id>:
242/// <hash>, … } }` (every key a guard name, every value a map of strings) —
243/// the fix inverts the nesting to `{ <id>: { content_hash: <hash> }, … }`.
244fn inverted_expect_map(
245    v: &Map<String, Value>,
246    here: &str,
247    f: Option<&Fixer<'_>>,
248) -> Option<ShapeError> {
249    let is_string_record = |x: &Value| {
250        x.as_object()
251            .is_some_and(|o| o.values().all(Value::is_string))
252    };
253    if v.is_empty()
254        || !v
255            .keys()
256            .all(|k| k == "content_hash" || k == "parent_children_hash")
257        || !v.values().all(is_string_record)
258    {
259        return None;
260    }
261    let message = format!(
262        "{here}: a plural-target `expect` is keyed by block id — {{ <block id>: {{ content_hash?, parent_children_hash? }}, … }}"
263    );
264    let Some(f) = f else {
265        return Some(message.into());
266    };
267    let mut inverted: Map<String, Value> = Map::new();
268    for (guard, by_id) in v {
269        for (id, hash) in by_id.as_object().expect("checked") {
270            let entry = inverted
271                .entry(id.clone())
272                .or_insert_with(|| Value::Object(Map::new()));
273            if let Some(o) = entry.as_object_mut() {
274                o.insert(guard.clone(), hash.clone());
275            }
276        }
277    }
278    let mut effect = f.written.clone();
279    effect.insert("expect".to_owned(), Value::Object(inverted));
280    Some(ShapeError {
281        message,
282        fix: Some(f.build(effect, None)),
283    })
284}
285
286/// §8 (3.4): the block-field misspellings the fix corrects to `markdown`.
287const BLOCK_FIELD_ALIASES: &[&str] = &["text", "content", "body", "raw"];
288
289// ---- targets ------------------------------------------------------------------------------
290
291/// What an effect addresses (§11 `Target`). A document is named by `id`
292/// and/or `path` (a resolved target carries both); a section is the derived
293/// range under a heading block (ADR-005), never a stored id.
294#[derive(Clone, Debug, PartialEq, Eq)]
295pub enum Target {
296    Document {
297        id: Option<String>,
298        path: Option<String>,
299    },
300    Block {
301        id: String,
302    },
303    Node {
304        id: String,
305    },
306    Section {
307        heading_id: String,
308    },
309}
310
311impl Target {
312    /// `document` / `block` / `node` / `section`.
313    #[must_use]
314    pub const fn kind(&self) -> &'static str {
315        match self {
316            Target::Document { .. } => "document",
317            Target::Block { .. } => "block",
318            Target::Node { .. } => "node",
319            Target::Section { .. } => "section",
320        }
321    }
322
323    /// The id the target names, if any (a document's `id`, a block's or
324    /// node's `id`, a section's heading id).
325    #[must_use]
326    pub fn id(&self) -> Option<&str> {
327        match self {
328            Target::Document { id, .. } => id.as_deref(),
329            Target::Block { id } | Target::Node { id } => Some(id),
330            Target::Section { heading_id } => Some(heading_id),
331        }
332    }
333
334    /// `{ kind, … }`; optional keys absent.
335    #[must_use]
336    pub fn to_json(&self) -> Value {
337        let mut m = Map::new();
338        m.insert("kind".to_owned(), json!(self.kind()));
339        match self {
340            Target::Document { id, path } => {
341                if let Some(id) = id {
342                    m.insert("id".to_owned(), json!(id));
343                }
344                if let Some(path) = path {
345                    m.insert("path".to_owned(), json!(path));
346                }
347            }
348            Target::Block { id } | Target::Node { id } => {
349                m.insert("id".to_owned(), json!(id));
350            }
351            Target::Section { heading_id } => {
352                m.insert("heading_id".to_owned(), json!(heading_id));
353            }
354        }
355        Value::Object(m)
356    }
357
358    /// Parse `{ kind, … }` of any kind.
359    pub fn from_json(v: &Value) -> std::result::Result<Target, String> {
360        validate_target(Some(v), "target", &["document", "block", "node", "section"])
361    }
362}
363
364/// A `targets` list as resolved effects spell it: block targets.
365fn block_refs_to_json(ids: &[String]) -> Value {
366    Value::Array(
367        ids.iter()
368            .map(|id| Target::Block { id: id.clone() }.to_json())
369            .collect(),
370    )
371}
372
373// ---- expectations -------------------------------------------------------------------------
374
375/// §11 `Expect`: the kernel's CAS tokens plus the document's current
376/// revision (`revision`), checked at plan time and pinned into the plan.
377#[derive(Clone, Debug, Default, PartialEq, Eq)]
378pub struct EffectExpect {
379    pub content_hash: Option<String>,
380    pub parent_children_hash: Option<String>,
381    pub revision: Option<String>,
382}
383
384impl EffectExpect {
385    /// The kernel's `Expect` when either hash is given.
386    #[must_use]
387    pub fn kernel(&self) -> Option<Expect> {
388        if self.content_hash.is_none() && self.parent_children_hash.is_none() {
389            return None;
390        }
391        Some(Expect {
392            content_hash: self.content_hash.clone(),
393            parent_children_hash: self.parent_children_hash.clone(),
394        })
395    }
396
397    #[must_use]
398    pub fn to_json(&self) -> Value {
399        let mut m = Map::new();
400        if let Some(h) = &self.content_hash {
401            m.insert("content_hash".to_owned(), json!(h));
402        }
403        if let Some(h) = &self.parent_children_hash {
404            m.insert("parent_children_hash".to_owned(), json!(h));
405        }
406        if let Some(r) = &self.revision {
407            m.insert("revision".to_owned(), json!(r));
408        }
409        Value::Object(m)
410    }
411
412    pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
413        validate_expect(Some(v), "expect", None)
414            .map(|e| e.expect("given"))
415            .map_err(|e| e.message)
416    }
417}
418
419// ---- the effects --------------------------------------------------------------------------
420
421/// A `from` → `to` destination rewrite of a `retarget`.
422#[derive(Clone, Debug, PartialEq, Eq)]
423pub struct Repair {
424    pub from: String,
425    pub to: String,
426}
427
428/// Per-block expectations of a `remove`/`merge` (§11.1 `expect: { <block id>: Expect }`).
429pub type ExpectMap = BTreeMap<String, Expect>;
430
431/// §11 `Effect`: the nine public effects (two shapes each for `move` and
432/// `remove`).
433#[derive(Clone, Debug, PartialEq)]
434pub enum Effect {
435    /// A new document at `path` from `markdown` and an optional frontmatter
436    /// mapping (composed as `docs_create` composes).
437    Create {
438        path: String,
439        markdown: String,
440        frontmatter: Option<Map<String, Value>>,
441    },
442    /// New blocks parsed from `markdown` into a document's top level, a
443    /// section's range or a container block, at `at` (default `end`).
444    Insert {
445        into: Target,
446        at: At,
447        markdown: String,
448        expect: Option<EffectExpect>,
449    },
450    /// Write a field: `frontmatter.<key>` / `content` of a document,
451    /// `markdown` / `attrs.checked` of a block, an editable property of a
452    /// node.
453    Set {
454        target: Target,
455        field: String,
456        value: Value,
457        expect: Option<EffectExpect>,
458    },
459    /// Remove a document's `frontmatter.<key>`.
460    Unset {
461        target: Target,
462        field: String,
463        expect: Option<EffectExpect>,
464    },
465    /// Rename a document (identity kept), rewriting the inbound links unless
466    /// `retarget_inbound` is false.
467    MoveDocument {
468        target: Target,
469        to: String,
470        retarget_inbound: bool,
471    },
472    /// Move blocks under a new parent at a position.
473    MoveBlocks {
474        targets: Vec<String>,
475        into: Target,
476        at: At,
477        expect: Option<EffectExpect>,
478    },
479    /// Tombstone a document (nothing pooled).
480    RemoveDocument { target: Target },
481    /// Remove blocks (subtrees).
482    RemoveBlocks {
483        targets: Vec<String>,
484        expect: Option<ExpectMap>,
485    },
486    /// Split a block at byte offsets.
487    Split {
488        target: String,
489        at: Vec<usize>,
490        expect: Option<EffectExpect>,
491    },
492    /// Merge blocks into the first (`separator` default `" "`, made explicit
493    /// once resolved).
494    Merge {
495        targets: Vec<String>,
496        separator: Option<String>,
497        expect: Option<ExpectMap>,
498    },
499    /// Rewrite link destinations repository-wide (`links_repair`).
500    Retarget {
501        repairs: Vec<Repair>,
502        path_glob: Option<String>,
503    },
504}
505
506/// `start` / `end` / `{ before }` / `{ after }`.
507#[must_use]
508pub fn at_to_json(at: &At) -> Value {
509    match at {
510        At::Start => json!("start"),
511        At::End => json!("end"),
512        At::Before(id) => json!({ "before": id }),
513        At::After(id) => json!({ "after": id }),
514    }
515}
516
517/// Parse an `at`; absent → `end`.
518pub fn at_from_json(v: Option<&Value>, here: &str) -> std::result::Result<At, String> {
519    match v {
520        None => Ok(At::End),
521        Some(Value::String(s)) if s == "start" => Ok(At::Start),
522        Some(Value::String(s)) if s == "end" => Ok(At::End),
523        Some(Value::Object(a)) if a.len() == 1 => {
524            if let Some(b) = a.get("before").and_then(Value::as_str) {
525                Ok(At::Before(b.to_owned()))
526            } else if let Some(b) = a.get("after").and_then(Value::as_str) {
527                Ok(At::After(b.to_owned()))
528            } else {
529                Err(format!(
530                    "{here}: `at` must be \"start\", \"end\", {{ before: <block id> }} or {{ after: <block id> }}"
531                ))
532            }
533        }
534        _ => Err(format!(
535            "{here}: `at` must be \"start\", \"end\", {{ before: <block id> }} or {{ after: <block id> }}"
536        )),
537    }
538}
539
540/// `at` of an `insert` / block `move` (the reference's `validateAt`): the
541/// bare string `"before"` / `"after"` is the anchor mistake of §8 (3.4), its
542/// message naming the right shape and its fix anchoring on the block from
543/// its parent.
544fn validate_at(
545    v: Option<&Value>,
546    here: &str,
547    into: &Target,
548    f: Option<&Fixer<'_>>,
549) -> std::result::Result<At, ShapeError> {
550    if let Some(Value::String(s)) = v
551        && (s == "before" || s == "after")
552    {
553        return Err(bare_anchor(s, into, here, f));
554    }
555    at_from_json(v, here).map_err(Into::into)
556}
557
558fn require_string(
559    obj: &Map<String, Value>,
560    key: &str,
561    here: &str,
562) -> std::result::Result<String, String> {
563    obj.get(key)
564        .and_then(Value::as_str)
565        .map(str::to_owned)
566        .ok_or_else(|| format!("{here}: `{key}` must be a string"))
567}
568
569fn optional_string(
570    obj: &Map<String, Value>,
571    key: &str,
572    here: &str,
573) -> std::result::Result<Option<String>, String> {
574    match obj.get(key) {
575        None => Ok(None),
576        Some(Value::String(s)) => Ok(Some(s.clone())),
577        Some(_) => Err(format!("{here}: `{key}` must be a string")),
578    }
579}
580
581fn only_keys(
582    obj: &Map<String, Value>,
583    allowed: &[&str],
584    here: &str,
585) -> std::result::Result<(), String> {
586    let extra: Vec<String> = obj
587        .keys()
588        .filter(|k| !allowed.contains(&k.as_str()))
589        .map(|k| format!("`{k}`"))
590        .collect();
591    if extra.is_empty() {
592        Ok(())
593    } else {
594        Err(format!(
595            "{here}: unknown key{} {}",
596            if extra.len() > 1 { "s" } else { "" },
597            extra.join(", ")
598        ))
599    }
600}
601
602/// §11.1: a target of one of `kinds`.
603fn validate_target(
604    v: Option<&Value>,
605    here: &str,
606    kinds: &[&str],
607) -> std::result::Result<Target, String> {
608    let kinds_text = kinds
609        .iter()
610        .map(|k| format!("\"{k}\""))
611        .collect::<Vec<_>>()
612        .join(" | ");
613    let obj = v
614        .and_then(Value::as_object)
615        .filter(|o| o.get("kind").is_some_and(Value::is_string))
616        .ok_or_else(|| format!("{here}: a target is {{ kind: {kinds_text}, … }}"))?;
617    let kind = obj["kind"].as_str().expect("checked");
618    if !kinds.contains(&kind) {
619        return Err(format!(
620            "{here}: `kind` must be one of {} (got \"{kind}\")",
621            kinds.join(", ")
622        ));
623    }
624    Ok(match kind {
625        "document" => {
626            only_keys(obj, &["kind", "id", "path"], here)?;
627            let id = optional_string(obj, "id", here)?;
628            let path = optional_string(obj, "path", here)?;
629            if id.is_none() && path.is_none() {
630                return Err(format!("{here}: a document target needs `id` or `path`"));
631            }
632            Target::Document { id, path }
633        }
634        "block" => {
635            only_keys(obj, &["kind", "id"], here)?;
636            Target::Block {
637                id: require_string(obj, "id", here)?,
638            }
639        }
640        "node" => {
641            only_keys(obj, &["kind", "id"], here)?;
642            Target::Node {
643                id: require_string(obj, "id", here)?,
644            }
645        }
646        "section" => {
647            only_keys(obj, &["kind", "heading_id"], here)?;
648            Target::Section {
649                heading_id: require_string(obj, "heading_id", here)?,
650            }
651        }
652        _ => unreachable!("kinds are the four above"),
653    })
654}
655
656/// `targets`: a non-empty array of block targets.
657fn validate_targets(v: Option<&Value>, here: &str) -> std::result::Result<Vec<String>, String> {
658    let list = v
659        .and_then(Value::as_array)
660        .filter(|a| !a.is_empty())
661        .ok_or_else(|| format!("{here}: `targets` must be a non-empty array of block targets"))?;
662    list.iter()
663        .enumerate()
664        .map(|(i, t)| {
665            match validate_target(Some(t), &format!("{here}.targets[{i}]"), &["block"])? {
666                Target::Block { id } => Ok(id),
667                _ => unreachable!("kinds is [block]"),
668            }
669        })
670        .collect()
671}
672
673fn validate_expect(
674    v: Option<&Value>,
675    here: &str,
676    f: Option<&Fixer<'_>>,
677) -> std::result::Result<Option<EffectExpect>, ShapeError> {
678    let Some(v) = v else { return Ok(None) };
679    let obj = v
680        .as_object()
681        .ok_or_else(|| format!("{here}: `expect` must be an object"))?;
682    if obj.contains_key("max") {
683        return Err(effect_level_max(obj, here, f));
684    }
685    let at = format!("{here}.expect");
686    only_keys(
687        obj,
688        &["content_hash", "parent_children_hash", "revision"],
689        &at,
690    )?;
691    Ok(Some(EffectExpect {
692        content_hash: optional_string(obj, "content_hash", &at)?,
693        parent_children_hash: optional_string(obj, "parent_children_hash", &at)?,
694        revision: optional_string(obj, "revision", &at)?,
695    }))
696}
697
698fn validate_expect_map(
699    v: Option<&Value>,
700    here: &str,
701    f: Option<&Fixer<'_>>,
702) -> std::result::Result<Option<ExpectMap>, ShapeError> {
703    let Some(v) = v else { return Ok(None) };
704    let obj = v
705        .as_object()
706        .ok_or_else(|| format!("{here}: `expect` must be an object keyed by block id"))?;
707    if obj.contains_key("max") {
708        return Err(effect_level_max(obj, here, f));
709    }
710    if let Some(e) = inverted_expect_map(obj, here, f) {
711        return Err(e);
712    }
713    let mut out = ExpectMap::new();
714    for (id, x) in obj {
715        let at = format!("{here}.expect[{}]", Value::String(id.clone()));
716        let x = x
717            .as_object()
718            .ok_or_else(|| format!("{at}: must be an object"))?;
719        only_keys(x, &["content_hash", "parent_children_hash"], &at)?;
720        out.insert(
721            id.clone(),
722            Expect {
723                content_hash: optional_string(x, "content_hash", &at)?,
724                parent_children_hash: optional_string(x, "parent_children_hash", &at)?,
725            },
726        );
727    }
728    Ok(Some(out))
729}
730
731/// §11.2: the `field` spellings per target kind, checked at the shape level.
732fn check_field(
733    target: &Target,
734    field: &str,
735    here: &str,
736    op: &str,
737    f: Option<&Fixer<'_>>,
738) -> std::result::Result<(), ShapeError> {
739    match target {
740        Target::Document { .. } => {
741            if field
742                .strip_prefix("frontmatter.")
743                .is_some_and(|k| !k.is_empty())
744                || (op == "set" && field == "content")
745            {
746                Ok(())
747            } else {
748                Err(format!(
749                    "{here}: a document's `field` is `frontmatter.<key>`{} (got \"{field}\")",
750                    if op == "set" { " or `content`" } else { "" }
751                )
752                .into())
753            }
754        }
755        Target::Block { .. } => {
756            if field == "markdown" || field == "attrs.checked" {
757                Ok(())
758            } else {
759                // (3.4, §8) `text` / `content` / `body` / `raw` on a block: the fix spells `markdown`.
760                let fix = f.filter(|_| BLOCK_FIELD_ALIASES.contains(&field)).map(|f| {
761                    let mut effect = f.written.clone();
762                    effect.insert("field".to_owned(), json!("markdown"));
763                    f.build(effect, None)
764                });
765                Err(ShapeError {
766                    message: format!(
767                        "{here}: a block's `field` is `markdown` or `attrs.checked` (got \"{field}\")"
768                    ),
769                    fix,
770                })
771            }
772        }
773        Target::Node { .. } => {
774            if field.is_empty() || field.contains('.') {
775                Err(format!(
776                    "{here}: a node's `field` is one of its editable properties (got \"{field}\")"
777                )
778                .into())
779            } else {
780                Ok(())
781            }
782        }
783        Target::Section { .. } => Err(format!("{here}: a section has no settable field").into()),
784    }
785}
786
787fn expect_map_to_json(m: &ExpectMap) -> Value {
788    Value::Object(
789        m.iter()
790            .map(|(k, e)| {
791                let mut o = Map::new();
792                if let Some(h) = &e.content_hash {
793                    o.insert("content_hash".to_owned(), json!(h));
794                }
795                if let Some(h) = &e.parent_children_hash {
796                    o.insert("parent_children_hash".to_owned(), json!(h));
797                }
798                (k.clone(), Value::Object(o))
799            })
800            .collect(),
801    )
802}
803
804impl Effect {
805    /// The effect's `op`.
806    #[must_use]
807    pub const fn name(&self) -> &'static str {
808        match self {
809            Effect::Create { .. } => "create",
810            Effect::Insert { .. } => "insert",
811            Effect::Set { .. } => "set",
812            Effect::Unset { .. } => "unset",
813            Effect::MoveDocument { .. } | Effect::MoveBlocks { .. } => "move",
814            Effect::RemoveDocument { .. } | Effect::RemoveBlocks { .. } => "remove",
815            Effect::Split { .. } => "split",
816            Effect::Merge { .. } => "merge",
817            Effect::Retarget { .. } => "retarget",
818        }
819    }
820
821    /// The wire form (§11.4 `ResolvedEffect` once resolved): `op` first,
822    /// `at` always present, `separator` present when set, optional keys
823    /// absent.
824    #[must_use]
825    pub fn to_json(&self) -> Value {
826        let mut m = Map::new();
827        m.insert("op".to_owned(), json!(self.name()));
828        let expect = |m: &mut Map<String, Value>, e: &Option<EffectExpect>| {
829            if let Some(e) = e {
830                m.insert("expect".to_owned(), e.to_json());
831            }
832        };
833        match self {
834            Effect::Create {
835                path,
836                markdown,
837                frontmatter,
838            } => {
839                m.insert("path".to_owned(), json!(path));
840                m.insert("markdown".to_owned(), json!(markdown));
841                if let Some(fm) = frontmatter {
842                    m.insert("frontmatter".to_owned(), Value::Object(fm.clone()));
843                }
844            }
845            Effect::Insert {
846                into,
847                at,
848                markdown,
849                expect: e,
850            } => {
851                m.insert("into".to_owned(), into.to_json());
852                m.insert("at".to_owned(), at_to_json(at));
853                m.insert("markdown".to_owned(), json!(markdown));
854                expect(&mut m, e);
855            }
856            Effect::Set {
857                target,
858                field,
859                value,
860                expect: e,
861            } => {
862                m.insert("target".to_owned(), target.to_json());
863                m.insert("field".to_owned(), json!(field));
864                m.insert("value".to_owned(), value.clone());
865                expect(&mut m, e);
866            }
867            Effect::Unset {
868                target,
869                field,
870                expect: e,
871            } => {
872                m.insert("target".to_owned(), target.to_json());
873                m.insert("field".to_owned(), json!(field));
874                expect(&mut m, e);
875            }
876            Effect::MoveDocument {
877                target,
878                to,
879                retarget_inbound,
880            } => {
881                m.insert("target".to_owned(), target.to_json());
882                m.insert("to".to_owned(), json!(to));
883                m.insert("retarget_inbound".to_owned(), json!(retarget_inbound));
884            }
885            Effect::MoveBlocks {
886                targets,
887                into,
888                at,
889                expect: e,
890            } => {
891                m.insert("targets".to_owned(), block_refs_to_json(targets));
892                m.insert("into".to_owned(), into.to_json());
893                m.insert("at".to_owned(), at_to_json(at));
894                expect(&mut m, e);
895            }
896            Effect::RemoveDocument { target } => {
897                m.insert("target".to_owned(), target.to_json());
898            }
899            Effect::RemoveBlocks { targets, expect: e } => {
900                m.insert("targets".to_owned(), block_refs_to_json(targets));
901                if let Some(e) = e {
902                    m.insert("expect".to_owned(), expect_map_to_json(e));
903                }
904            }
905            Effect::Split {
906                target,
907                at,
908                expect: e,
909            } => {
910                m.insert(
911                    "target".to_owned(),
912                    Target::Block { id: target.clone() }.to_json(),
913                );
914                m.insert("at".to_owned(), json!(at));
915                expect(&mut m, e);
916            }
917            Effect::Merge {
918                targets,
919                separator,
920                expect: e,
921            } => {
922                m.insert("targets".to_owned(), block_refs_to_json(targets));
923                if let Some(s) = separator {
924                    m.insert("separator".to_owned(), json!(s));
925                }
926                if let Some(e) = e {
927                    m.insert("expect".to_owned(), expect_map_to_json(e));
928                }
929            }
930            Effect::Retarget { repairs, path_glob } => {
931                m.insert(
932                    "repairs".to_owned(),
933                    Value::Array(
934                        repairs
935                            .iter()
936                            .map(|r| json!({ "from": r.from, "to": r.to }))
937                            .collect(),
938                    ),
939                );
940                if let Some(g) = path_glob {
941                    m.insert("path_glob".to_owned(), json!(g));
942                }
943            }
944        }
945        Value::Object(m)
946    }
947
948    /// §11.1's shape check of `effects[i]`: an unknown key, a missing or
949    /// mistyped member, a target kind the op does not take, a `field` not in
950    /// §11.2 — the message names `effects[i]` and the member (the surface
951    /// reports it as `filter_invalid { reason, hint: "arguments" }`).
952    pub fn validate(v: &Value, i: usize) -> std::result::Result<Effect, String> {
953        Effect::validate_with(v, i, None).map_err(|e| e.message)
954    }
955
956    /// [`Effect::validate`] with the shape fixes of §8 (3.4): given a
957    /// [`ShapeFixContext`] — the request as written and the store's row
958    /// lookup — the four shape mistakes carry their corrected call as
959    /// [`ShapeError::fix`]; without one the same errors carry none.
960    pub fn validate_with(
961        v: &Value,
962        i: usize,
963        fix: Option<&ShapeFixContext<'_>>,
964    ) -> std::result::Result<Effect, ShapeError> {
965        let here = format!("effects[{i}]");
966        let ops = "create/insert/set/unset/move/remove/split/merge/retarget";
967        let obj = v
968            .as_object()
969            .filter(|o| o.get("op").is_some_and(Value::is_string))
970            .ok_or_else(|| format!("{here}: an effect is an object whose `op` is one of {ops}"))?;
971        let op = obj["op"].as_str().expect("checked");
972        let fixer = Fixer::new(fix, i, obj);
973        let f = fixer.as_ref();
974        Ok(match op {
975            "create" => {
976                only_keys(obj, &["op", "path", "markdown", "frontmatter"], &here)?;
977                let path = require_string(obj, "path", &here)?;
978                let markdown = require_string(obj, "markdown", &here)?;
979                let frontmatter = match obj.get("frontmatter") {
980                    None => None,
981                    Some(Value::Object(fm)) => Some(fm.clone()),
982                    Some(_) => {
983                        return Err(format!("{here}: `frontmatter` must be an object").into());
984                    }
985                };
986                Effect::Create {
987                    path,
988                    markdown,
989                    frontmatter,
990                }
991            }
992            "insert" => {
993                only_keys(obj, &["op", "into", "at", "markdown", "expect"], &here)?;
994                let into = validate_target(
995                    obj.get("into"),
996                    &format!("{here}.into"),
997                    &["document", "section", "block"],
998                )?;
999                let at = validate_at(obj.get("at"), &here, &into, f)?;
1000                let markdown = require_string(obj, "markdown", &here)?;
1001                let expect = validate_expect(obj.get("expect"), &here, f)?;
1002                Effect::Insert {
1003                    into,
1004                    at,
1005                    markdown,
1006                    expect,
1007                }
1008            }
1009            "set" => {
1010                only_keys(obj, &["op", "target", "field", "value", "expect"], &here)?;
1011                let target = validate_target(
1012                    obj.get("target"),
1013                    &format!("{here}.target"),
1014                    &["document", "block", "node"],
1015                )?;
1016                let field = require_string(obj, "field", &here)?;
1017                let value = obj
1018                    .get("value")
1019                    .cloned()
1020                    .ok_or_else(|| format!("{here}: `value` is required"))?;
1021                check_field(&target, &field, &here, "set", f)?;
1022                let expect = validate_expect(obj.get("expect"), &here, f)?;
1023                Effect::Set {
1024                    target,
1025                    field,
1026                    value,
1027                    expect,
1028                }
1029            }
1030            "unset" => {
1031                only_keys(obj, &["op", "target", "field", "expect"], &here)?;
1032                let target =
1033                    validate_target(obj.get("target"), &format!("{here}.target"), &["document"])?;
1034                let field = require_string(obj, "field", &here)?;
1035                check_field(&target, &field, &here, "unset", None)?;
1036                let expect = validate_expect(obj.get("expect"), &here, f)?;
1037                Effect::Unset {
1038                    target,
1039                    field,
1040                    expect,
1041                }
1042            }
1043            "move" => {
1044                if obj.contains_key("targets") {
1045                    only_keys(obj, &["op", "targets", "into", "at", "expect"], &here)?;
1046                    let targets = validate_targets(obj.get("targets"), &here)?;
1047                    let into = validate_target(
1048                        obj.get("into"),
1049                        &format!("{here}.into"),
1050                        &["document", "section", "block"],
1051                    )?;
1052                    let at = validate_at(obj.get("at"), &here, &into, f)?;
1053                    let expect = validate_expect(obj.get("expect"), &here, f)?;
1054                    Effect::MoveBlocks {
1055                        targets,
1056                        into,
1057                        at,
1058                        expect,
1059                    }
1060                } else {
1061                    only_keys(obj, &["op", "target", "to", "retarget_inbound"], &here)?;
1062                    let target = validate_target(
1063                        obj.get("target"),
1064                        &format!("{here}.target"),
1065                        &["document"],
1066                    )?;
1067                    let to = require_string(obj, "to", &here)?;
1068                    let retarget_inbound = match obj.get("retarget_inbound") {
1069                        None => true,
1070                        Some(Value::Bool(b)) => *b,
1071                        Some(_) => {
1072                            return Err(
1073                                format!("{here}: `retarget_inbound` must be a boolean").into()
1074                            );
1075                        }
1076                    };
1077                    Effect::MoveDocument {
1078                        target,
1079                        to,
1080                        retarget_inbound,
1081                    }
1082                }
1083            }
1084            "remove" => {
1085                if obj.contains_key("targets") {
1086                    only_keys(obj, &["op", "targets", "expect"], &here)?;
1087                    Effect::RemoveBlocks {
1088                        targets: validate_targets(obj.get("targets"), &here)?,
1089                        expect: validate_expect_map(obj.get("expect"), &here, f)?,
1090                    }
1091                } else {
1092                    only_keys(obj, &["op", "target"], &here)?;
1093                    Effect::RemoveDocument {
1094                        target: validate_target(
1095                            obj.get("target"),
1096                            &format!("{here}.target"),
1097                            &["document"],
1098                        )?,
1099                    }
1100                }
1101            }
1102            "split" => {
1103                only_keys(obj, &["op", "target", "at", "expect"], &here)?;
1104                let Target::Block { id } =
1105                    validate_target(obj.get("target"), &format!("{here}.target"), &["block"])?
1106                else {
1107                    unreachable!("kinds is [block]")
1108                };
1109                let at = obj
1110                    .get("at")
1111                    .and_then(Value::as_array)
1112                    .and_then(|a| {
1113                        a.iter()
1114                            .map(|n| n.as_u64().and_then(|n| usize::try_from(n).ok()))
1115                            .collect::<Option<Vec<usize>>>()
1116                    })
1117                    .ok_or_else(|| format!("{here}: `at` must be an array of byte offsets"))?;
1118                let expect = validate_expect(obj.get("expect"), &here, f)?;
1119                Effect::Split {
1120                    target: id,
1121                    at,
1122                    expect,
1123                }
1124            }
1125            "merge" => {
1126                only_keys(obj, &["op", "targets", "separator", "expect"], &here)?;
1127                Effect::Merge {
1128                    targets: validate_targets(obj.get("targets"), &here)?,
1129                    separator: optional_string(obj, "separator", &here)?,
1130                    expect: validate_expect_map(obj.get("expect"), &here, f)?,
1131                }
1132            }
1133            "retarget" => {
1134                only_keys(obj, &["op", "repairs", "path_glob"], &here)?;
1135                let repairs = obj
1136                    .get("repairs")
1137                    .and_then(Value::as_array)
1138                    .ok_or_else(|| format!("{here}: `repairs` must be an array of {{ from, to }}"))?
1139                    .iter()
1140                    .enumerate()
1141                    .map(|(j, r)| {
1142                        let at = format!("{here}.repairs[{j}]");
1143                        let r = r
1144                            .as_object()
1145                            .ok_or_else(|| format!("{at}: must be {{ from, to }}"))?;
1146                        only_keys(r, &["from", "to"], &at)?;
1147                        Ok(Repair {
1148                            from: require_string(r, "from", &at)?,
1149                            to: require_string(r, "to", &at)?,
1150                        })
1151                    })
1152                    .collect::<std::result::Result<Vec<_>, String>>()?;
1153                Effect::Retarget {
1154                    repairs,
1155                    path_glob: optional_string(obj, "path_glob", &here)?,
1156                }
1157            }
1158            other => {
1159                return Err(format!("{here}: `op` must be one of {ops} (got \"{other}\")").into());
1160            }
1161        })
1162    }
1163
1164    /// Parse one effect (its message names `effects[0]`).
1165    pub fn from_json(v: &Value) -> std::result::Result<Effect, String> {
1166        Effect::validate(v, 0)
1167    }
1168
1169    /// Parse an `effects` array: a non-array or an empty list is a shape
1170    /// error too.
1171    pub fn list_from_json(v: &Value) -> std::result::Result<Vec<Effect>, String> {
1172        let list = v
1173            .as_array()
1174            .ok_or("`effects` must be an array of effects")?;
1175        if list.is_empty() {
1176            return Err("`effects` must name at least one effect".into());
1177        }
1178        list.iter()
1179            .enumerate()
1180            .map(|(i, e)| Effect::validate(e, i))
1181            .collect()
1182    }
1183
1184    /// The `expect` an effect carries, if any (not the per-block maps).
1185    #[must_use]
1186    pub fn expect(&self) -> Option<&EffectExpect> {
1187        match self {
1188            Effect::Insert { expect, .. }
1189            | Effect::Set { expect, .. }
1190            | Effect::Unset { expect, .. }
1191            | Effect::MoveBlocks { expect, .. }
1192            | Effect::Split { expect, .. } => expect.as_ref(),
1193            _ => None,
1194        }
1195    }
1196
1197    /// `describe` (§11.3): the words a dropped duplicate's diagnostic uses —
1198    /// `set <field> on document <id>`, `move block b_1,block b_2`, ….
1199    #[must_use]
1200    pub fn describe(&self) -> String {
1201        fn name(t: &Target) -> String {
1202            match t {
1203                Target::Section { heading_id } => format!("section {heading_id}"),
1204                other => format!("{} {}", other.kind(), other.id().unwrap_or_default()),
1205            }
1206        }
1207        fn blocks(ids: &[String]) -> String {
1208            ids.iter()
1209                .map(|id| format!("block {id}"))
1210                .collect::<Vec<_>>()
1211                .join(",")
1212        }
1213        match self {
1214            Effect::Create { path, .. } => format!("create {path}"),
1215            Effect::Insert { into, .. } => format!("insert into {}", name(into)),
1216            Effect::Set { target, field, .. } => format!("set {field} on {}", name(target)),
1217            Effect::Unset { target, field, .. } => format!("unset {field} on {}", name(target)),
1218            Effect::MoveDocument { target, to, .. } => format!("move {} -> {to}", name(target)),
1219            Effect::MoveBlocks { targets, .. } => format!("move {}", blocks(targets)),
1220            Effect::RemoveDocument { target } => format!("remove {}", name(target)),
1221            Effect::RemoveBlocks { targets, .. } => format!("remove {}", blocks(targets)),
1222            Effect::Split { target, .. } => format!("split block {target}"),
1223            Effect::Merge { targets, .. } => format!("merge {}", blocks(targets)),
1224            Effect::Retarget { repairs, .. } => format!(
1225                "retarget {}",
1226                repairs
1227                    .iter()
1228                    .map(|r| format!("{}->{}", r.from, r.to))
1229                    .collect::<Vec<_>>()
1230                    .join(",")
1231            ),
1232        }
1233    }
1234}
1235
1236// ---- fields ------------------------------------------------------------------------------
1237
1238/// What a `set`/`unset` `field` names, by target kind (§11.2).
1239#[derive(Clone, Debug, PartialEq, Eq)]
1240pub enum Field<'a> {
1241    /// `frontmatter.<key>` of a document.
1242    Frontmatter(&'a str),
1243    /// `content` of a document: the whole-document planner.
1244    Content,
1245    /// `markdown` of a block.
1246    Markdown,
1247    /// `attrs.checked` of a block.
1248    Checked,
1249    /// An editable property of a node (`value`, `name`, `checked`).
1250    NodeProp(&'a str),
1251}
1252
1253/// Classify a validated `field` for `target`.
1254pub fn classify_field<'a>(
1255    target: &Target,
1256    field: &'a str,
1257) -> std::result::Result<Field<'a>, String> {
1258    check_field(target, field, "field", "set", None).map_err(|e| e.message)?;
1259    Ok(match target {
1260        Target::Document { .. } => match field.strip_prefix("frontmatter.") {
1261            Some(key) => Field::Frontmatter(key),
1262            None => Field::Content,
1263        },
1264        Target::Block { .. } => {
1265            if field == "markdown" {
1266                Field::Markdown
1267            } else {
1268                Field::Checked
1269            }
1270        }
1271        Target::Node { .. } => Field::NodeProp(field),
1272        Target::Section { .. } => unreachable!("check_field rejects sections"),
1273    })
1274}
1275
1276/// `$n.ids[i]` placeholders of one kernel op shifted by `offset` (an opset
1277/// planned alone, spliced into a longer op list — `set document.content`).
1278#[must_use]
1279pub fn shift_placeholders(op: &Op, offset: usize) -> Op {
1280    map_block_ids(op, |v| match crate::changeset::parse_placeholder(v) {
1281        Some((n, i)) => format!("${}.ids[{i}]", n + offset),
1282        None => v.to_owned(),
1283    })
1284}
1285
1286/// Every block id a kernel op names — `block`, `blocks`, a placement's parent
1287/// block or section heading and its anchor, the keys of a per-block `expect`
1288/// map, and an `insert`'s `doc` — rewritten by `f` (§11.7: the replay turns
1289/// the `$new.<n>` symbols of a created document into the ids it minted).
1290#[must_use]
1291pub fn map_block_ids(op: &Op, f: impl Fn(&str) -> String) -> Op {
1292    let s = |v: &String| -> String { f(v) };
1293    let to = |t: &To| To {
1294        parent: match &t.parent {
1295            Parent::Block(id) => Parent::Block(s(id)),
1296            Parent::Section { heading } => Parent::Section {
1297                heading: s(heading),
1298            },
1299            Parent::Doc => Parent::Doc,
1300        },
1301        at: match &t.at {
1302            At::Before(id) => At::Before(s(id)),
1303            At::After(id) => At::After(s(id)),
1304            other => other.clone(),
1305        },
1306    };
1307    let map = |m: &Option<BTreeMap<String, Expect>>| -> Option<BTreeMap<String, Expect>> {
1308        m.as_ref()
1309            .map(|m| m.iter().map(|(k, e)| (s(k), e.clone())).collect())
1310    };
1311    match op {
1312        Op::Insert {
1313            doc,
1314            to: t,
1315            markdown,
1316            expect,
1317        } => Op::Insert {
1318            doc: doc.as_ref().map(s),
1319            to: to(t),
1320            markdown: markdown.clone(),
1321            expect: expect.clone(),
1322        },
1323        Op::Update {
1324            block,
1325            markdown,
1326            attrs,
1327            expect,
1328            trivia,
1329            child_ids,
1330        } => Op::Update {
1331            block: s(block),
1332            markdown: markdown.clone(),
1333            attrs: attrs.clone(),
1334            expect: expect.clone(),
1335            trivia: trivia.clone(),
1336            child_ids: child_ids.clone(),
1337        },
1338        Op::Move {
1339            blocks,
1340            to: t,
1341            doc,
1342            expect,
1343        } => Op::Move {
1344            blocks: blocks.iter().map(s).collect(),
1345            to: to(t),
1346            doc: doc.as_ref().map(s),
1347            expect: expect.clone(),
1348        },
1349        Op::Remove { blocks, expect } => Op::Remove {
1350            blocks: blocks.iter().map(s).collect(),
1351            expect: map(expect),
1352        },
1353        Op::Split { block, at, expect } => Op::Split {
1354            block: s(block),
1355            at: at.clone(),
1356            expect: expect.clone(),
1357        },
1358        Op::Merge {
1359            blocks,
1360            separator,
1361            expect,
1362        } => Op::Merge {
1363            blocks: blocks.iter().map(s).collect(),
1364            separator: separator.clone(),
1365            expect: map(expect),
1366        },
1367    }
1368}
1369
1370// ---- focus and conflicts (decision 4, §11.3) ----------------------------------------------
1371
1372/// What a focus names: `{ kind: "document" | "block" | "node" | "path", id? | path? }`.
1373#[derive(Clone, Debug, PartialEq, Eq)]
1374pub struct FocusTarget {
1375    pub kind: &'static str,
1376    pub id: Option<String>,
1377    pub path: Option<String>,
1378}
1379
1380impl FocusTarget {
1381    #[must_use]
1382    pub fn document(id: &str) -> Self {
1383        Self {
1384            kind: "document",
1385            id: Some(id.to_owned()),
1386            path: None,
1387        }
1388    }
1389
1390    #[must_use]
1391    pub fn block(id: &str) -> Self {
1392        Self {
1393            kind: "block",
1394            id: Some(id.to_owned()),
1395            path: None,
1396        }
1397    }
1398
1399    #[must_use]
1400    pub fn node(id: &str) -> Self {
1401        Self {
1402            kind: "node",
1403            id: Some(id.to_owned()),
1404            path: None,
1405        }
1406    }
1407
1408    #[must_use]
1409    pub fn path(path: &str) -> Self {
1410        Self {
1411            kind: "path",
1412            id: None,
1413            path: Some(path.to_owned()),
1414        }
1415    }
1416
1417    #[must_use]
1418    pub fn to_json(&self) -> Value {
1419        let mut m = Map::new();
1420        m.insert("kind".to_owned(), json!(self.kind));
1421        if let Some(id) = &self.id {
1422            m.insert("id".to_owned(), json!(id));
1423        }
1424        if let Some(path) = &self.path {
1425            m.insert("path".to_owned(), json!(path));
1426        }
1427        Value::Object(m)
1428    }
1429}
1430
1431/// The focus of a conflict: `{ kind, target, field? }`.
1432#[derive(Clone, Debug, PartialEq, Eq)]
1433pub struct Focus {
1434    /// `scalar` or `identity`.
1435    pub kind: &'static str,
1436    pub target: FocusTarget,
1437    pub field: Option<String>,
1438}
1439
1440impl Focus {
1441    #[must_use]
1442    pub fn to_json(&self) -> Value {
1443        let mut m = Map::new();
1444        m.insert("kind".to_owned(), json!(self.kind));
1445        m.insert("target".to_owned(), self.target.to_json());
1446        if let Some(f) = &self.field {
1447            m.insert("field".to_owned(), json!(f));
1448        }
1449        Value::Object(m)
1450    }
1451}
1452
1453/// `conflicting_effects { effects: [i, j], focus, retriable: false }` (§8).
1454#[must_use]
1455pub fn conflicting_effects(i: usize, j: usize, focus: &Focus) -> MutationError {
1456    let who = focus
1457        .target
1458        .id
1459        .clone()
1460        .or_else(|| focus.target.path.clone())
1461        .unwrap_or_default();
1462    let what = match &focus.field {
1463        Some(f) => format!("{} {who} {f}", focus.target.kind),
1464        None => format!("{} {who}", focus.target.kind),
1465    };
1466    MutationError::with_data(
1467        ErrorCode::ConflictingEffects,
1468        format!("effects {i} and {j} contradict each other on {what}"),
1469        json!({ "effects": [i, j], "focus": focus.to_json(), "retriable": false }),
1470    )
1471}
1472
1473/// `target_missing { symbol }` for a `$new.<n>` no earlier effect created
1474/// (`kind` `None`), or one of the other kind.
1475#[must_use]
1476pub fn symbol_missing(symbol: &str, kind: Option<NewIdKind>, wanted: &str) -> MutationError {
1477    let message = match kind {
1478        None => format!("{symbol} is not created by an earlier effect"),
1479        Some(k) => format!("{symbol} names a {}, not a {wanted}", k.as_str()),
1480    };
1481    MutationError::with_data(
1482        ErrorCode::TargetMissing,
1483        message,
1484        json!({ "symbol": symbol }),
1485    )
1486}
1487
1488/// A scalar write: one `(target, field)` key, `set` with a value or `unset`.
1489#[derive(Clone, Debug, PartialEq)]
1490pub struct ScalarWrite {
1491    /// `document:<id>:<field>`, `block:<id>:<field>`, `node:<id>:<field>`.
1492    pub key: String,
1493    pub target: FocusTarget,
1494    pub field: String,
1495    /// `Some(value)` for a `set`, `None` for an `unset`.
1496    pub value: Option<Value>,
1497}
1498
1499/// What one effect addresses and retires (§11.3): the planner computes it
1500/// against its trees and the store, the [`ConflictTracker`] judges it.
1501#[derive(Clone, Debug, Default, PartialEq)]
1502pub struct FocusInfo {
1503    pub scalar: Option<ScalarWrite>,
1504    /// Every `(block, markdown)` focus of a `retarget` (never deduped).
1505    pub scalars: Vec<ScalarWrite>,
1506    /// The documents, blocks and paths the effect addresses.
1507    pub docs: Vec<String>,
1508    pub blocks: Vec<String>,
1509    pub paths: Vec<String>,
1510    /// What later effects may no longer address.
1511    pub retires_docs: Vec<String>,
1512    pub retires_blocks: Vec<String>,
1513    pub retires_paths: Vec<String>,
1514    /// `set document.content`: the whole document is rewritten.
1515    pub whole_doc: Option<String>,
1516}
1517
1518/// The focus bookkeeping over an ordered effect list (§11.3).
1519#[derive(Debug, Default)]
1520pub struct ConflictTracker {
1521    scalar: HashMap<String, (usize, ScalarWrite)>,
1522    retired_docs: HashMap<String, usize>,
1523    retired_blocks: HashMap<String, usize>,
1524    retired_paths: HashMap<String, usize>,
1525    touched_docs: HashMap<String, usize>,
1526    /// The dropped duplicates, verbatim.
1527    pub diagnostics: Vec<String>,
1528}
1529
1530impl ConflictTracker {
1531    /// Check effect `i`: `Ok(false)` when it is an identical duplicate to
1532    /// drop (a diagnostic is recorded), `Ok(true)` when it stands,
1533    /// `Err(conflicting_effects)` when it contradicts an earlier effect.
1534    pub fn check(&mut self, i: usize, info: &FocusInfo, describe: &str) -> Result<bool> {
1535        for d in &info.docs {
1536            if let Some(&by) = self.retired_docs.get(d) {
1537                return Err(conflicting_effects(
1538                    by,
1539                    i,
1540                    &identity(FocusTarget::document(d)),
1541                ));
1542            }
1543        }
1544        for b in &info.blocks {
1545            if let Some(&by) = self.retired_blocks.get(b) {
1546                return Err(conflicting_effects(by, i, &identity(FocusTarget::block(b))));
1547            }
1548        }
1549        for p in &info.paths {
1550            if let Some(&by) = self.retired_paths.get(p) {
1551                return Err(conflicting_effects(by, i, &identity(FocusTarget::path(p))));
1552            }
1553        }
1554        if let Some(d) = &info.whole_doc {
1555            if let Some(&by) = self.touched_docs.get(d) {
1556                return Err(conflicting_effects(
1557                    by,
1558                    i,
1559                    &identity(FocusTarget::document(d)),
1560                ));
1561            }
1562        }
1563        if let Some(s) = &info.scalar {
1564            if let Some((prev_i, prev)) = self.scalar.get(&s.key) {
1565                let identical = match (&prev.value, &s.value) {
1566                    (None, None) => true,
1567                    (Some(a), Some(b)) => canonical_json(a) == canonical_json(b),
1568                    _ => false,
1569                };
1570                if !identical {
1571                    return Err(conflicting_effects(
1572                        *prev_i,
1573                        i,
1574                        &Focus {
1575                            kind: "scalar",
1576                            target: s.target.clone(),
1577                            field: Some(s.field.clone()),
1578                        },
1579                    ));
1580                }
1581                self.diagnostics.push(format!(
1582                    "effect {i} ({describe}) duplicates effect {prev_i}; dropped"
1583                ));
1584                return Ok(false);
1585            }
1586        }
1587        for s in &info.scalars {
1588            if let Some((prev_i, _)) = self.scalar.get(&s.key) {
1589                return Err(conflicting_effects(
1590                    *prev_i,
1591                    i,
1592                    &Focus {
1593                        kind: "scalar",
1594                        target: s.target.clone(),
1595                        field: Some(s.field.clone()),
1596                    },
1597                ));
1598            }
1599        }
1600        // Record.
1601        if let Some(s) = &info.scalar {
1602            self.scalar.insert(s.key.clone(), (i, s.clone()));
1603        }
1604        for s in &info.scalars {
1605            self.scalar.insert(s.key.clone(), (i, s.clone()));
1606        }
1607        for d in &info.docs {
1608            self.touched_docs.entry(d.clone()).or_insert(i);
1609        }
1610        for d in &info.retires_docs {
1611            self.retired_docs.insert(d.clone(), i);
1612        }
1613        for b in &info.retires_blocks {
1614            self.retired_blocks.insert(b.clone(), i);
1615        }
1616        for p in &info.retires_paths {
1617            self.retired_paths.insert(p.clone(), i);
1618        }
1619        Ok(true)
1620    }
1621}
1622
1623fn identity(target: FocusTarget) -> Focus {
1624    Focus {
1625        kind: "identity",
1626        target,
1627        field: None,
1628    }
1629}
1630
1631// ---- canonical JSON and the digest ---------------------------------------------------------
1632
1633/// §11 canonical JSON (module docs).
1634#[must_use]
1635pub fn canonical_json(v: &Value) -> String {
1636    let mut out = String::new();
1637    write_canonical(v, &mut out);
1638    out
1639}
1640
1641fn write_canonical(v: &Value, out: &mut String) {
1642    match v {
1643        Value::Object(map) => {
1644            let mut keys: Vec<&String> = map.keys().collect();
1645            keys.sort_unstable_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1646            out.push('{');
1647            for (i, k) in keys.iter().enumerate() {
1648                if i > 0 {
1649                    out.push(',');
1650                }
1651                out.push_str(&Value::String((*k).clone()).to_string());
1652                out.push(':');
1653                write_canonical(&map[*k], out);
1654            }
1655            out.push('}');
1656        }
1657        Value::Array(items) => {
1658            out.push('[');
1659            for (i, item) in items.iter().enumerate() {
1660                if i > 0 {
1661                    out.push(',');
1662                }
1663                write_canonical(item, out);
1664            }
1665            out.push(']');
1666        }
1667        Value::Number(n) => out.push_str(&canonical_number(n)),
1668        scalar => out.push_str(&scalar.to_string()),
1669    }
1670}
1671
1672/// A number as ECMAScript `JSON.stringify` prints it.
1673fn canonical_number(n: &serde_json::Number) -> String {
1674    if let Some(i) = n.as_i64() {
1675        return i.to_string();
1676    }
1677    if let Some(u) = n.as_u64() {
1678        return u.to_string();
1679    }
1680    let Some(f) = n.as_f64() else {
1681        return "null".to_owned();
1682    };
1683    js_number(f)
1684}
1685
1686/// ECMAScript `Number::toString(x)` for a finite `x` (`null` otherwise).
1687#[must_use]
1688pub fn js_number(x: f64) -> String {
1689    const TWO_53: f64 = 9_007_199_254_740_992.0;
1690    if !x.is_finite() {
1691        return "null".to_owned();
1692    }
1693    if x == 0.0 {
1694        return "0".to_owned();
1695    }
1696    if x.fract() == 0.0 && x.abs() < TWO_53 {
1697        // Integral below 2^53: no fraction, no exponent.
1698        #[allow(clippy::cast_possible_truncation)]
1699        return (x as i64).to_string();
1700    }
1701    // Shortest round-trip digits and the decimal exponent from Rust's `{:e}`
1702    // (`d.ddde±X`), re-laid out per Number::toString.
1703    let sci = format!("{:e}", x.abs());
1704    let (mantissa, exp) = sci.split_once('e').expect("{:e} has an exponent");
1705    let exp: i32 = exp.parse().expect("{:e} exponent is an integer");
1706    let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
1707    let k = digits.len() as i32;
1708    let n = exp + 1; // x = 0.digits × 10^n
1709    let mut out = String::new();
1710    if x < 0.0 {
1711        out.push('-');
1712    }
1713    if k <= n && n <= 21 {
1714        out.push_str(&digits);
1715        out.push_str(&"0".repeat((n - k) as usize));
1716    } else if 0 < n && n <= 21 {
1717        out.push_str(&digits[..n as usize]);
1718        out.push('.');
1719        out.push_str(&digits[n as usize..]);
1720    } else if -6 < n && n <= 0 {
1721        out.push_str("0.");
1722        out.push_str(&"0".repeat((-n) as usize));
1723        out.push_str(&digits);
1724    } else {
1725        let e = n - 1;
1726        out.push_str(&digits[..1]);
1727        if k > 1 {
1728            out.push('.');
1729            out.push_str(&digits[1..]);
1730        }
1731        out.push('e');
1732        out.push(if e < 0 { '-' } else { '+' });
1733        out.push_str(&e.abs().to_string());
1734    }
1735    out
1736}
1737
1738/// `hex(sha256(canonical_json(core)))`.
1739#[must_use]
1740pub fn digest(core: &Value) -> String {
1741    hex(&sha256(canonical_json(core).as_bytes()))
1742}
1743
1744// ---- the plan ----------------------------------------------------------------------------
1745
1746/// `opset.creates[]` (3.0): a `create` as the plan carries it — its document
1747/// symbol, path, composed file, and `before_op`, the index of the first
1748/// kernel op planned after it (`ops.len()` when none): where `execute`
1749/// adopts it again so the replay mints its block ids in the plan's order.
1750#[derive(Clone, Debug, PartialEq, Eq)]
1751pub struct PlanCreate {
1752    pub symbol: String,
1753    pub path: String,
1754    pub source: String,
1755    pub before_op: usize,
1756}
1757
1758/// `opset.moves[]`: a document rename (its retarget updates are in `ops`).
1759#[derive(Clone, Debug, PartialEq, Eq)]
1760pub struct PlanMove {
1761    pub id: String,
1762    pub from: String,
1763    pub to: String,
1764}
1765
1766/// `opset.tombstones[]`: a `remove document`.
1767#[derive(Clone, Debug, PartialEq, Eq)]
1768pub struct PlanTombstone {
1769    pub id: String,
1770    pub path: String,
1771}
1772
1773/// `opset.set_frontmatter[]`: a document's file **head** — everything before
1774/// its first block (frontmatter with fences and separator, or the leading
1775/// trivia alone).
1776#[derive(Clone, Debug, PartialEq, Eq)]
1777pub struct PlanFrontmatter {
1778    pub doc: String,
1779    pub raw: String,
1780}
1781
1782/// §11.4 `opset`: every key present (`[]` when empty); the ops in the fixture
1783/// spelling (`child_ids`), new structure named by kernel placeholders.
1784#[derive(Clone, Debug, Default, PartialEq)]
1785pub struct PlanOpset {
1786    pub creates: Vec<PlanCreate>,
1787    pub moves: Vec<PlanMove>,
1788    pub tombstones: Vec<PlanTombstone>,
1789    pub set_frontmatter: Vec<PlanFrontmatter>,
1790    pub ops: Vec<Op>,
1791}
1792
1793impl PlanOpset {
1794    #[must_use]
1795    pub fn to_json(&self) -> Value {
1796        json!({
1797            "creates": self.creates.iter().map(|c| json!({ "symbol": c.symbol, "path": c.path, "source": c.source, "before_op": c.before_op })).collect::<Vec<_>>(),
1798            "moves": self.moves.iter().map(|m| json!({ "id": m.id, "from": m.from, "to": m.to })).collect::<Vec<_>>(),
1799            "tombstones": self.tombstones.iter().map(|t| json!({ "id": t.id, "path": t.path })).collect::<Vec<_>>(),
1800            "set_frontmatter": self.set_frontmatter.iter().map(|f| json!({ "doc": f.doc, "raw": f.raw })).collect::<Vec<_>>(),
1801            "ops": self.ops.iter().map(Op::to_json).collect::<Vec<_>>(),
1802        })
1803    }
1804
1805    pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
1806        let obj = v.as_object().ok_or("`plan.opset` must be an object")?;
1807        let arr = |k: &str| -> std::result::Result<&Vec<Value>, String> {
1808            obj.get(k)
1809                .and_then(Value::as_array)
1810                .ok_or_else(|| format!("`plan.opset.{k}` must be an array"))
1811        };
1812        let s = |v: &Value, k: &str, here: &str| -> std::result::Result<String, String> {
1813            v.get(k)
1814                .and_then(Value::as_str)
1815                .map(str::to_owned)
1816                .ok_or_else(|| format!("`plan.opset.{here}.{k}` must be a string"))
1817        };
1818        Ok(Self {
1819            creates: arr("creates")?
1820                .iter()
1821                .map(|c| {
1822                    let str_of = |k: &str| c.get(k).and_then(Value::as_str).map(str::to_owned);
1823                    let before_op = c
1824                        .get("before_op")
1825                        .and_then(Value::as_u64)
1826                        .and_then(|n| usize::try_from(n).ok());
1827                    match (str_of("symbol"), str_of("path"), str_of("source"), before_op) {
1828                        (Some(symbol), Some(path), Some(source), Some(before_op)) => {
1829                            Ok(PlanCreate {
1830                                symbol,
1831                                path,
1832                                source,
1833                                before_op,
1834                            })
1835                        }
1836                        _ => Err(
1837                            "`plan.opset.creates[]` entries are { symbol, path, source, before_op }"
1838                                .to_owned(),
1839                        ),
1840                    }
1841                })
1842                .collect::<std::result::Result<_, String>>()?,
1843            moves: arr("moves")?
1844                .iter()
1845                .map(|m| {
1846                    Ok(PlanMove {
1847                        id: s(m, "id", "moves[]")?,
1848                        from: s(m, "from", "moves[]")?,
1849                        to: s(m, "to", "moves[]")?,
1850                    })
1851                })
1852                .collect::<std::result::Result<_, String>>()?,
1853            tombstones: arr("tombstones")?
1854                .iter()
1855                .map(|t| {
1856                    Ok(PlanTombstone {
1857                        id: s(t, "id", "tombstones[]")?,
1858                        path: s(t, "path", "tombstones[]")?,
1859                    })
1860                })
1861                .collect::<std::result::Result<_, String>>()?,
1862            set_frontmatter: arr("set_frontmatter")?
1863                .iter()
1864                .map(|f| {
1865                    Ok(PlanFrontmatter {
1866                        doc: s(f, "doc", "set_frontmatter[]")?,
1867                        raw: s(f, "raw", "set_frontmatter[]")?,
1868                    })
1869                })
1870                .collect::<std::result::Result<_, String>>()?,
1871            ops: arr("ops")?
1872                .iter()
1873                .map(Op::from_json)
1874                .collect::<std::result::Result<Vec<_>, String>>()?,
1875        })
1876    }
1877}
1878
1879/// A document's precondition: the `DocBase` triple as `{ id, path,
1880/// base_revision, hash }` — `null`s kept (they are values, §11.5).
1881#[derive(Clone, Debug, PartialEq, Eq)]
1882pub struct DocPrecondition {
1883    pub id: String,
1884    pub path: String,
1885    pub base_revision: Option<String>,
1886    /// Lower-case hex of `docs.file_hash`.
1887    pub hash: Option<String>,
1888}
1889
1890/// A block's CAS pin: `{ id, content_hash }`.
1891#[derive(Clone, Debug, PartialEq, Eq)]
1892pub struct BlockPrecondition {
1893    pub id: String,
1894    pub content_hash: String,
1895}
1896
1897/// A caller's destination-parent pin: `{ doc, parent: <block id> | null,
1898/// parent_children_hash }`.
1899#[derive(Clone, Debug, PartialEq, Eq)]
1900pub struct ParentPrecondition {
1901    pub doc: String,
1902    pub parent: Option<String>,
1903    pub parent_children_hash: String,
1904}
1905
1906/// §11.4 `preconditions`.
1907#[derive(Clone, Debug, Default, PartialEq, Eq)]
1908pub struct Preconditions {
1909    pub docs: Vec<DocPrecondition>,
1910    pub blocks: Vec<BlockPrecondition>,
1911    pub parents: Vec<ParentPrecondition>,
1912    pub absent_paths: Vec<String>,
1913}
1914
1915impl Preconditions {
1916    #[must_use]
1917    pub fn to_json(&self) -> Value {
1918        json!({
1919            "docs": self.docs.iter().map(|d| json!({ "id": d.id, "path": d.path, "base_revision": d.base_revision, "hash": d.hash })).collect::<Vec<_>>(),
1920            "blocks": self.blocks.iter().map(|b| json!({ "id": b.id, "content_hash": b.content_hash })).collect::<Vec<_>>(),
1921            "parents": self.parents.iter().map(|p| json!({ "doc": p.doc, "parent": p.parent, "parent_children_hash": p.parent_children_hash })).collect::<Vec<_>>(),
1922            "absent_paths": self.absent_paths,
1923        })
1924    }
1925
1926    pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
1927        let obj = v
1928            .as_object()
1929            .ok_or("`plan.preconditions` must be an object")?;
1930        let arr = |k: &str| -> std::result::Result<&Vec<Value>, String> {
1931            obj.get(k)
1932                .and_then(Value::as_array)
1933                .ok_or_else(|| format!("`plan.preconditions.{k}` must be an array"))
1934        };
1935        let s = |v: &Value, k: &str, here: &str| -> std::result::Result<String, String> {
1936            v.get(k)
1937                .and_then(Value::as_str)
1938                .map(str::to_owned)
1939                .ok_or_else(|| format!("`plan.preconditions.{here}.{k}` must be a string"))
1940        };
1941        let opt = |v: &Value, k: &str, here: &str| -> std::result::Result<Option<String>, String> {
1942            match v.get(k) {
1943                None | Some(Value::Null) => Ok(None),
1944                Some(Value::String(x)) => Ok(Some(x.clone())),
1945                Some(_) => Err(format!(
1946                    "`plan.preconditions.{here}.{k}` must be a string or null"
1947                )),
1948            }
1949        };
1950        Ok(Self {
1951            docs: arr("docs")?
1952                .iter()
1953                .map(|d| {
1954                    Ok(DocPrecondition {
1955                        id: s(d, "id", "docs[]")?,
1956                        path: s(d, "path", "docs[]")?,
1957                        base_revision: opt(d, "base_revision", "docs[]")?,
1958                        hash: opt(d, "hash", "docs[]")?,
1959                    })
1960                })
1961                .collect::<std::result::Result<_, String>>()?,
1962            blocks: arr("blocks")?
1963                .iter()
1964                .map(|b| {
1965                    Ok(BlockPrecondition {
1966                        id: s(b, "id", "blocks[]")?,
1967                        content_hash: s(b, "content_hash", "blocks[]")?,
1968                    })
1969                })
1970                .collect::<std::result::Result<_, String>>()?,
1971            parents: arr("parents")?
1972                .iter()
1973                .map(|p| {
1974                    Ok(ParentPrecondition {
1975                        doc: s(p, "doc", "parents[]")?,
1976                        parent: opt(p, "parent", "parents[]")?,
1977                        parent_children_hash: s(p, "parent_children_hash", "parents[]")?,
1978                    })
1979                })
1980                .collect::<std::result::Result<_, String>>()?,
1981            absent_paths: arr("absent_paths")?
1982                .iter()
1983                .map(|p| {
1984                    p.as_str().map(str::to_owned).ok_or_else(|| {
1985                        "`plan.preconditions.absent_paths[]` must be strings".to_owned()
1986                    })
1987                })
1988                .collect::<std::result::Result<_, String>>()?,
1989        })
1990    }
1991}
1992
1993/// The kind of identity a `$new.<n>` stands for.
1994#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1995pub enum NewIdKind {
1996    Block,
1997    Document,
1998}
1999
2000impl NewIdKind {
2001    #[must_use]
2002    pub const fn as_str(&self) -> &'static str {
2003        match self {
2004            NewIdKind::Block => "block",
2005            NewIdKind::Document => "document",
2006        }
2007    }
2008
2009    /// The kind a mint prefix stands for (`b` / `d`).
2010    #[must_use]
2011    pub fn from_prefix(prefix: &str) -> Option<Self> {
2012        match prefix {
2013            "b" => Some(NewIdKind::Block),
2014            "d" => Some(NewIdKind::Document),
2015            _ => None,
2016        }
2017    }
2018}
2019
2020/// `{ symbol, kind }` in allocation order.
2021#[derive(Clone, Debug, PartialEq, Eq)]
2022pub struct NewId {
2023    pub symbol: String,
2024    pub kind: NewIdKind,
2025}
2026
2027/// A planned revision `{ id, path, hash }` (a created document's `id` is
2028/// its symbol).
2029#[derive(Clone, Debug, PartialEq, Eq)]
2030pub struct PlanRevision {
2031    pub id: String,
2032    pub path: String,
2033    pub hash: String,
2034}
2035
2036/// One planned op of a `set document.content` as `preview.identity` reports
2037/// it (mutate 3.0): the §7 `PlanOp` minus the op itself — `op_index` names
2038/// the entry of `opset.ops` it became.
2039#[derive(Clone, Debug, PartialEq)]
2040pub struct IdentityOp {
2041    pub op_index: usize,
2042    pub disposition: PlanDisposition,
2043    /// The carried id(s) the op acts on; empty for an insert of new structure.
2044    pub blocks: Vec<String>,
2045    pub confidence: Option<f64>,
2046    pub reason: Option<String>,
2047    /// The matcher's detail, when the planner recorded one.
2048    pub detail: Option<Value>,
2049}
2050
2051/// `preview.identity[<doc id>]` (mutate 3.0): what the whole-document
2052/// planner (§7) decided for a `set document.content` — the `Opset`'s
2053/// identity fields, surfaced since the opset itself is no longer a wire
2054/// shape (`converges` is not here: a returned plan always converges).
2055#[derive(Clone, Debug, PartialEq)]
2056pub struct IdentityPreview {
2057    pub matcher_v: String,
2058    pub summary: OpsetSummary,
2059    pub ops: Vec<IdentityOp>,
2060    pub diagnostics: Vec<String>,
2061}
2062
2063impl IdentityPreview {
2064    #[must_use]
2065    pub fn to_json(&self) -> Value {
2066        json!({
2067            "matcher_v": self.matcher_v,
2068            "summary": self.summary.to_json(),
2069            "ops": self.ops.iter().map(|o| {
2070                let mut m = Map::new();
2071                m.insert("op_index".to_owned(), json!(o.op_index));
2072                m.insert("disposition".to_owned(), json!(o.disposition.as_str()));
2073                m.insert("blocks".to_owned(), json!(o.blocks));
2074                m.insert("confidence".to_owned(), json!(o.confidence));
2075                m.insert("reason".to_owned(), json!(o.reason));
2076                if let Some(d) = &o.detail {
2077                    m.insert("detail".to_owned(), d.clone());
2078                }
2079                Value::Object(m)
2080            }).collect::<Vec<_>>(),
2081            "diagnostics": self.diagnostics,
2082        })
2083    }
2084
2085    pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2086        let obj = v
2087            .as_object()
2088            .ok_or("`plan.preview.identity[]` must be an object")?;
2089        let matcher_v = obj
2090            .get("matcher_v")
2091            .and_then(Value::as_str)
2092            .ok_or("`plan.preview.identity[].matcher_v` must be a string")?
2093            .to_owned();
2094        let summary = obj
2095            .get("summary")
2096            .and_then(Value::as_object)
2097            .ok_or("`plan.preview.identity[].summary` must be an object")?;
2098        let count = |k: &str| -> usize {
2099            summary
2100                .get(k)
2101                .and_then(Value::as_u64)
2102                .and_then(|n| usize::try_from(n).ok())
2103                .unwrap_or(0)
2104        };
2105        let summary = OpsetSummary {
2106            preserved: count("preserved"),
2107            updated: count("updated"),
2108            moved: count("moved"),
2109            created: count("created"),
2110            removed: count("removed"),
2111            split: count("split"),
2112            merged: count("merged"),
2113            ambiguous: count("ambiguous"),
2114        };
2115        let ops = obj
2116            .get("ops")
2117            .and_then(Value::as_array)
2118            .ok_or("`plan.preview.identity[].ops` must be an array")?
2119            .iter()
2120            .map(|o| {
2121                let disposition = o
2122                    .get("disposition")
2123                    .and_then(Value::as_str)
2124                    .and_then(PlanDisposition::parse)
2125                    .ok_or("`plan.preview.identity[].ops[].disposition` is not a disposition")?;
2126                let op_index = o
2127                    .get("op_index")
2128                    .and_then(Value::as_u64)
2129                    .and_then(|n| usize::try_from(n).ok())
2130                    .ok_or("`plan.preview.identity[].ops[].op_index` must be an integer")?;
2131                Ok(IdentityOp {
2132                    op_index,
2133                    disposition,
2134                    blocks: o
2135                        .get("blocks")
2136                        .and_then(Value::as_array)
2137                        .map(|a| {
2138                            a.iter()
2139                                .filter_map(Value::as_str)
2140                                .map(str::to_owned)
2141                                .collect()
2142                        })
2143                        .unwrap_or_default(),
2144                    confidence: o.get("confidence").and_then(Value::as_f64),
2145                    reason: o.get("reason").and_then(Value::as_str).map(str::to_owned),
2146                    detail: o.get("detail").cloned(),
2147                })
2148            })
2149            .collect::<std::result::Result<Vec<_>, String>>()?;
2150        let diagnostics = obj
2151            .get("diagnostics")
2152            .and_then(Value::as_array)
2153            .map(|a| {
2154                a.iter()
2155                    .filter_map(Value::as_str)
2156                    .map(str::to_owned)
2157                    .collect()
2158            })
2159            .unwrap_or_default();
2160        Ok(Self {
2161            matcher_v,
2162            summary,
2163            ops,
2164            diagnostics,
2165        })
2166    }
2167}
2168
2169/// A block a `retarget` rewrites, as `links_repair` reports it:
2170/// `{ id, path, oldRaw, newRaw }`.
2171#[derive(Clone, Debug, PartialEq, Eq)]
2172pub struct RetargetHitPreview {
2173    pub id: String,
2174    pub path: String,
2175    pub old_raw: String,
2176    pub new_raw: String,
2177}
2178
2179/// A repair pair with how many destinations it matched: `{ from, to, hits }`.
2180#[derive(Clone, Debug, PartialEq, Eq)]
2181pub struct RetargetPairPreview {
2182    pub from: String,
2183    pub to: String,
2184    pub hits: usize,
2185}
2186
2187/// `preview.retarget[]` (mutate 3.0): what a `retarget` effect found —
2188/// `{ effect_index, hits, pairs }`, the `links_repair` report the retired
2189/// `links_retarget { dry_run }` used to return.
2190#[derive(Clone, Debug, PartialEq, Eq)]
2191pub struct RetargetPreview {
2192    pub effect_index: usize,
2193    pub hits: Vec<RetargetHitPreview>,
2194    pub pairs: Vec<RetargetPairPreview>,
2195}
2196
2197impl RetargetPreview {
2198    #[must_use]
2199    pub fn to_json(&self) -> Value {
2200        json!({
2201            "effect_index": self.effect_index,
2202            "hits": self.hits.iter().map(|h| json!({ "id": h.id, "path": h.path, "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect::<Vec<_>>(),
2203            "pairs": self.pairs.iter().map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits })).collect::<Vec<_>>(),
2204        })
2205    }
2206
2207    pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2208        let obj = v
2209            .as_object()
2210            .ok_or("`plan.preview.retarget[]` must be an object")?;
2211        let s = |v: &Value, k: &str| -> std::result::Result<String, String> {
2212            v.get(k)
2213                .and_then(Value::as_str)
2214                .map(str::to_owned)
2215                .ok_or_else(|| format!("`plan.preview.retarget[]` {k} must be a string"))
2216        };
2217        let n = |v: &Value, k: &str| -> std::result::Result<usize, String> {
2218            v.get(k)
2219                .and_then(Value::as_u64)
2220                .and_then(|n| usize::try_from(n).ok())
2221                .ok_or_else(|| format!("`plan.preview.retarget[]` {k} must be an integer"))
2222        };
2223        Ok(Self {
2224            effect_index: n(v, "effect_index")?,
2225            hits: obj
2226                .get("hits")
2227                .and_then(Value::as_array)
2228                .ok_or("`plan.preview.retarget[].hits` must be an array")?
2229                .iter()
2230                .map(|h| {
2231                    Ok(RetargetHitPreview {
2232                        id: s(h, "id")?,
2233                        path: s(h, "path")?,
2234                        old_raw: s(h, "oldRaw")?,
2235                        new_raw: s(h, "newRaw")?,
2236                    })
2237                })
2238                .collect::<std::result::Result<_, String>>()?,
2239            pairs: obj
2240                .get("pairs")
2241                .and_then(Value::as_array)
2242                .ok_or("`plan.preview.retarget[].pairs` must be an array")?
2243                .iter()
2244                .map(|p| {
2245                    Ok(RetargetPairPreview {
2246                        from: s(p, "from")?,
2247                        to: s(p, "to")?,
2248                        hits: n(p, "hits")?,
2249                    })
2250                })
2251                .collect::<std::result::Result<_, String>>()?,
2252        })
2253    }
2254}
2255
2256/// §11.4 `preview`: excluded from the digest, omittable on echo. `identity`
2257/// (per document, in effect order) and `retarget` (per effect) are present
2258/// on the wire only when non-empty (mutate 3.0); `summary` (3.3) always.
2259#[derive(Clone, Debug, Default, PartialEq)]
2260pub struct PlanPreview {
2261    pub results: Vec<OpResult>,
2262    pub revisions: Vec<PlanRevision>,
2263    /// Path → `(before, after)`, in insertion order.
2264    pub diffs: Vec<(String, (String, String))>,
2265    /// Doc id → the whole-document planner's identity decision.
2266    pub identity: Vec<(String, IdentityPreview)>,
2267    pub retarget: Vec<RetargetPreview>,
2268    /// One line in words per resolved effect (3.3, [`crate::summary`]);
2269    /// always on the wire (`[]` for a plan with nothing to do).
2270    pub summary: Vec<String>,
2271}
2272
2273impl PlanPreview {
2274    #[must_use]
2275    pub fn to_json(&self) -> Value {
2276        let mut diffs = Map::new();
2277        for (path, (before, after)) in &self.diffs {
2278            diffs.insert(path.clone(), json!({ "before": before, "after": after }));
2279        }
2280        let mut m = Map::new();
2281        m.insert(
2282            "results".to_owned(),
2283            Value::Array(self.results.iter().map(OpResult::to_json).collect()),
2284        );
2285        m.insert(
2286            "revisions".to_owned(),
2287            Value::Array(
2288                self.revisions
2289                    .iter()
2290                    .map(|r| json!({ "id": r.id, "path": r.path, "hash": r.hash }))
2291                    .collect(),
2292            ),
2293        );
2294        m.insert("diffs".to_owned(), Value::Object(diffs));
2295        m.insert("summary".to_owned(), json!(self.summary));
2296        if !self.identity.is_empty() {
2297            let mut identity = Map::new();
2298            for (doc, i) in &self.identity {
2299                identity.insert(doc.clone(), i.to_json());
2300            }
2301            m.insert("identity".to_owned(), Value::Object(identity));
2302        }
2303        if !self.retarget.is_empty() {
2304            m.insert(
2305                "retarget".to_owned(),
2306                Value::Array(self.retarget.iter().map(RetargetPreview::to_json).collect()),
2307            );
2308        }
2309        Value::Object(m)
2310    }
2311
2312    pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2313        let obj = v.as_object().ok_or("`plan.preview` must be an object")?;
2314        let identity = obj
2315            .get("identity")
2316            .and_then(Value::as_object)
2317            .map(|m| {
2318                m.iter()
2319                    .map(|(doc, i)| Ok((doc.clone(), IdentityPreview::from_json(i)?)))
2320                    .collect::<std::result::Result<Vec<_>, String>>()
2321            })
2322            .transpose()?
2323            .unwrap_or_default();
2324        let retarget = obj
2325            .get("retarget")
2326            .and_then(Value::as_array)
2327            .map(|a| {
2328                a.iter()
2329                    .map(RetargetPreview::from_json)
2330                    .collect::<std::result::Result<Vec<_>, String>>()
2331            })
2332            .transpose()?
2333            .unwrap_or_default();
2334        let strings = |v: Option<&Value>| -> Option<Vec<String>> {
2335            v.and_then(Value::as_array).map(|l| {
2336                l.iter()
2337                    .filter_map(Value::as_str)
2338                    .map(str::to_owned)
2339                    .collect()
2340            })
2341        };
2342        let results = obj
2343            .get("results")
2344            .and_then(Value::as_array)
2345            .map(|a| {
2346                a.iter()
2347                    .map(|r| OpResult {
2348                        ids: strings(r.get("ids")).unwrap_or_default(),
2349                        removed: strings(r.get("removed")),
2350                        merged_into: strings(r.get("merged_into")),
2351                    })
2352                    .collect()
2353            })
2354            .unwrap_or_default();
2355        let str_of = |v: &Value, k: &str| -> std::result::Result<String, String> {
2356            v.get(k)
2357                .and_then(Value::as_str)
2358                .map(str::to_owned)
2359                .ok_or_else(|| format!("`plan.preview` {k} must be a string"))
2360        };
2361        let revisions = obj
2362            .get("revisions")
2363            .and_then(Value::as_array)
2364            .map(|a| {
2365                a.iter()
2366                    .map(|r| {
2367                        Ok(PlanRevision {
2368                            id: str_of(r, "id")?,
2369                            path: str_of(r, "path")?,
2370                            hash: str_of(r, "hash")?,
2371                        })
2372                    })
2373                    .collect::<std::result::Result<Vec<_>, String>>()
2374            })
2375            .transpose()?
2376            .unwrap_or_default();
2377        let diffs = obj
2378            .get("diffs")
2379            .and_then(Value::as_object)
2380            .map(|d| {
2381                d.iter()
2382                    .map(|(p, v)| Ok((p.clone(), (str_of(v, "before")?, str_of(v, "after")?))))
2383                    .collect::<std::result::Result<Vec<_>, String>>()
2384            })
2385            .transpose()?
2386            .unwrap_or_default();
2387        let summary = strings(obj.get("summary")).unwrap_or_default();
2388        Ok(Self {
2389            results,
2390            revisions,
2391            diffs,
2392            identity,
2393            retarget,
2394            summary,
2395        })
2396    }
2397}
2398
2399/// §11.8 `selection.expect`: the caller's bound on the selection.
2400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2401pub struct SelectionExpect {
2402    pub max: usize,
2403}
2404
2405/// §11.8 `selection` (3.1): the seventh member of the digested core when
2406/// the plan was derived from an OQX selector — the query and the templates
2407/// **as given** (an `{ oqx }` slot intact; never the rewritten tree), the
2408/// bound, the hit count and the hit ids in result order. A hit that appears
2409/// or vanishes between `plan` and `execute` changes `identities`, so the
2410/// digest form is `plan_mismatch` without re-running anything but the plan.
2411#[derive(Clone, Debug, PartialEq)]
2412pub struct Selection {
2413    pub query: String,
2414    pub expect: Option<SelectionExpect>,
2415    pub templates: Vec<Value>,
2416    pub cardinality: usize,
2417    pub identities: Vec<String>,
2418}
2419
2420impl Selection {
2421    /// `{ query, expect?, templates, cardinality, identities }`; `expect`
2422    /// absent when none was given.
2423    #[must_use]
2424    pub fn to_json(&self) -> Value {
2425        let mut m = Map::new();
2426        m.insert("query".to_owned(), json!(self.query));
2427        if let Some(e) = &self.expect {
2428            m.insert("expect".to_owned(), json!({ "max": e.max }));
2429        }
2430        m.insert("templates".to_owned(), Value::Array(self.templates.clone()));
2431        m.insert("cardinality".to_owned(), json!(self.cardinality));
2432        m.insert("identities".to_owned(), json!(self.identities));
2433        Value::Object(m)
2434    }
2435
2436    pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2437        let obj = v.as_object().ok_or("`plan.selection` must be an object")?;
2438        only_keys(
2439            obj,
2440            &["query", "expect", "templates", "cardinality", "identities"],
2441            "`plan.selection`",
2442        )?;
2443        let query = obj
2444            .get("query")
2445            .and_then(Value::as_str)
2446            .ok_or("`plan.selection.query` must be a string")?
2447            .to_owned();
2448        let expect = match obj.get("expect") {
2449            None => None,
2450            Some(e) => {
2451                let e = e
2452                    .as_object()
2453                    .ok_or("`plan.selection.expect` must be { max }")?;
2454                only_keys(e, &["max"], "`plan.selection.expect`")?;
2455                let max = e
2456                    .get("max")
2457                    .and_then(Value::as_u64)
2458                    .and_then(|n| usize::try_from(n).ok())
2459                    .ok_or("`plan.selection.expect.max` must be a non-negative integer")?;
2460                Some(SelectionExpect { max })
2461            }
2462        };
2463        let templates = obj
2464            .get("templates")
2465            .and_then(Value::as_array)
2466            .filter(|t| !t.is_empty())
2467            .ok_or("`plan.selection.templates` must be a non-empty array")?
2468            .clone();
2469        let cardinality = obj
2470            .get("cardinality")
2471            .and_then(Value::as_u64)
2472            .and_then(|n| usize::try_from(n).ok())
2473            .ok_or("`plan.selection.cardinality` must be a non-negative integer")?;
2474        let identities = obj
2475            .get("identities")
2476            .and_then(Value::as_array)
2477            .ok_or("`plan.selection.identities` must be an array of ids")?
2478            .iter()
2479            .map(|id| {
2480                id.as_str()
2481                    .map(str::to_owned)
2482                    .ok_or_else(|| "`plan.selection.identities[]` must be strings".to_owned())
2483            })
2484            .collect::<std::result::Result<Vec<_>, String>>()?;
2485        if identities.len() != cardinality {
2486            return Err(
2487                "`plan.selection.cardinality` must equal the number of identities".to_owned(),
2488            );
2489        }
2490        Ok(Self {
2491            query,
2492            expect,
2493            templates,
2494            cardinality,
2495            identities,
2496        })
2497    }
2498}
2499
2500/// §11.4 `Plan`.
2501#[derive(Clone, Debug, PartialEq)]
2502pub struct Plan {
2503    pub version: u64,
2504    pub planner_v: String,
2505    /// The effects with their targets resolved; `$new.<n>` kept symbolic;
2506    /// dropped duplicates excluded.
2507    pub effects: Vec<Effect>,
2508    pub opset: PlanOpset,
2509    pub preconditions: Preconditions,
2510    pub new_ids: Vec<NewId>,
2511    /// §11.8: present when the plan was derived from a selector (digested).
2512    pub selection: Option<Selection>,
2513    /// §11.8 "Sugar" (3.2): the effects **as written** when a selector-less
2514    /// request carried a `-> ` leader or an `{ oqx }` directive that was
2515    /// evaluated in its target's row scope — outside the digest (a plain
2516    /// request produces none, so no existing `plan_id` moves), on the wire,
2517    /// and in the intent record as `templates`.
2518    pub templates: Option<Vec<Value>>,
2519    pub plan_id: String,
2520    pub diagnostics: Vec<String>,
2521    pub preview: Option<PlanPreview>,
2522}
2523
2524/// Why a plan handed to `execute` was not admitted (§11.7).
2525#[derive(Clone, Debug, PartialEq)]
2526pub enum Admission {
2527    /// `unsupported_plan_version` or `plan_mismatch`.
2528    Refused(MutationError),
2529    /// A missing or mistyped core member (`filter_invalid`).
2530    Invalid(String),
2531}
2532
2533impl Plan {
2534    /// The executable core the digest covers: `{ version, planner_v,
2535    /// effects, opset, preconditions, new_ids, selection? }` (`selection`
2536    /// only when present, §11.8).
2537    #[must_use]
2538    pub fn core_json(&self) -> Value {
2539        let mut core = json!({
2540            "version": self.version,
2541            "planner_v": self.planner_v,
2542            "effects": self.effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
2543            "opset": self.opset.to_json(),
2544            "preconditions": self.preconditions.to_json(),
2545            "new_ids": self.new_ids.iter().map(|n| json!({ "symbol": n.symbol, "kind": n.kind.as_str() })).collect::<Vec<_>>(),
2546        });
2547        if let Some(sel) = &self.selection {
2548            core["selection"] = sel.to_json();
2549        }
2550        core
2551    }
2552
2553    /// `hex(sha256(canonical(core)))`.
2554    #[must_use]
2555    pub fn digest(&self) -> String {
2556        digest(&self.core_json())
2557    }
2558
2559    /// The plan with `plan_id` recomputed over its core.
2560    #[must_use]
2561    pub fn sealed(mut self) -> Self {
2562        self.plan_id = self.digest();
2563        self
2564    }
2565
2566    /// The wire form; `preview` only when present and `with_preview`.
2567    #[must_use]
2568    pub fn to_json(&self, with_preview: bool) -> Value {
2569        let Value::Object(mut m) = self.core_json() else {
2570            unreachable!("core_json is an object")
2571        };
2572        if let Some(t) = &self.templates {
2573            m.insert("templates".to_owned(), Value::Array(t.clone()));
2574        }
2575        m.insert("plan_id".to_owned(), json!(self.plan_id));
2576        m.insert("diagnostics".to_owned(), json!(self.diagnostics));
2577        if with_preview {
2578            if let Some(p) = &self.preview {
2579                m.insert("preview".to_owned(), p.to_json());
2580            }
2581        }
2582        Value::Object(m)
2583    }
2584
2585    /// Parse a well-formed plan (`version` must already be 1: use
2586    /// [`Plan::admit`] for a client's object).
2587    pub fn from_json(v: &Value) -> std::result::Result<Plan, String> {
2588        let obj = v.as_object().ok_or("`plan` must be an object")?;
2589        for k in [
2590            "planner_v",
2591            "effects",
2592            "opset",
2593            "preconditions",
2594            "new_ids",
2595            "plan_id",
2596        ] {
2597            if !obj.contains_key(k) {
2598                return Err(format!("`plan.{k}` is required"));
2599            }
2600        }
2601        let version = obj
2602            .get("version")
2603            .and_then(Value::as_u64)
2604            .ok_or("`plan.version` must be an integer")?;
2605        let planner_v = obj["planner_v"]
2606            .as_str()
2607            .ok_or("`plan.planner_v` must be a string")?
2608            .to_owned();
2609        let effects = obj["effects"]
2610            .as_array()
2611            .ok_or("`plan.effects` must be an array")?
2612            .iter()
2613            .enumerate()
2614            .map(|(i, e)| Effect::validate(e, i))
2615            .collect::<std::result::Result<Vec<_>, String>>()?;
2616        let opset = PlanOpset::from_json(&obj["opset"])?;
2617        let preconditions = Preconditions::from_json(&obj["preconditions"])?;
2618        let new_ids = obj["new_ids"]
2619            .as_array()
2620            .ok_or("`plan.new_ids` must be an array")?
2621            .iter()
2622            .map(|n| {
2623                let kind = match n.get("kind").and_then(Value::as_str) {
2624                    Some("block") => NewIdKind::Block,
2625                    Some("document") => NewIdKind::Document,
2626                    _ => return Err("`plan.new_ids[].kind` must be block or document".to_owned()),
2627                };
2628                let symbol = n
2629                    .get("symbol")
2630                    .and_then(Value::as_str)
2631                    .ok_or("`plan.new_ids[].symbol` must be a string")?
2632                    .to_owned();
2633                Ok(NewId { symbol, kind })
2634            })
2635            .collect::<std::result::Result<Vec<_>, String>>()?;
2636        let plan_id = obj["plan_id"]
2637            .as_str()
2638            .ok_or("`plan.plan_id` must be a string")?
2639            .to_owned();
2640        let diagnostics = obj
2641            .get("diagnostics")
2642            .and_then(Value::as_array)
2643            .map(|a| {
2644                a.iter()
2645                    .filter_map(Value::as_str)
2646                    .map(str::to_owned)
2647                    .collect()
2648            })
2649            .unwrap_or_default();
2650        let preview = match obj.get("preview") {
2651            None | Some(Value::Null) => None,
2652            Some(p) => Some(PlanPreview::from_json(p)?),
2653        };
2654        // §11.8: an optional member — `null` is not "absent" (a value stays a
2655        // value under the canonical rules, so a client's `selection: null`
2656        // would digest differently from the planner's omission).
2657        let selection = match obj.get("selection") {
2658            None => None,
2659            Some(s) => Some(Selection::from_json(s)?),
2660        };
2661        // 3.2: the selector-less sugar's record of the effects as written;
2662        // outside the digest, so a client may drop it — never mistype it.
2663        let templates = match obj.get("templates") {
2664            None | Some(Value::Null) => None,
2665            Some(t) => Some(
2666                t.as_array()
2667                    .ok_or("`plan.templates` must be an array of effects as written")?
2668                    .clone(),
2669            ),
2670        };
2671        Ok(Plan {
2672            version,
2673            planner_v,
2674            effects,
2675            opset,
2676            preconditions,
2677            new_ids,
2678            selection,
2679            templates,
2680            plan_id,
2681            diagnostics,
2682            preview,
2683        })
2684    }
2685
2686    /// §11.7 admission of a client's plan object, in this order: `version`
2687    /// ≠ 1 → `unsupported_plan_version` (before any other member is read);
2688    /// a missing or mistyped core member → invalid; the recomputed digest
2689    /// ≠ `plan_id` → `plan_mismatch { expected, actual }`.
2690    pub fn admit(v: &Value) -> std::result::Result<Plan, Admission> {
2691        let obj = v
2692            .as_object()
2693            .ok_or_else(|| Admission::Invalid("`plan` must be an object".into()))?;
2694        let version = obj.get("version").cloned().unwrap_or(Value::Null);
2695        if version != json!(PLAN_VERSION) {
2696            return Err(Admission::Refused(unsupported_plan_version(&version)));
2697        }
2698        let plan = Plan::from_json(v).map_err(Admission::Invalid)?;
2699        let actual = plan.digest();
2700        if actual != plan.plan_id {
2701            return Err(Admission::Refused(plan_mismatch(&plan.plan_id, &actual)));
2702        }
2703        Ok(plan)
2704    }
2705}
2706
2707/// `unsupported_plan_version { version, supported: [1] }`.
2708#[must_use]
2709pub fn unsupported_plan_version(version: &Value) -> MutationError {
2710    MutationError::with_data(
2711        ErrorCode::UnsupportedPlanVersion,
2712        format!("plan version {version} is not supported (supported: {PLAN_VERSION})"),
2713        json!({ "version": version, "supported": [PLAN_VERSION] }),
2714    )
2715}
2716
2717/// `plan_mismatch { expected, actual }`: the digest a caller named against
2718/// the one the plan's core (or a re-plan) yields.
2719#[must_use]
2720pub fn plan_mismatch(expected: &str, actual: &str) -> MutationError {
2721    MutationError::with_data(
2722        ErrorCode::PlanMismatch,
2723        "the plan's digest does not match its plan_id; it was edited or produced by a different planner — re-plan",
2724        json!({ "expected": expected, "actual": actual }),
2725    )
2726}
2727
2728/// §11.7 `plan_mismatch` of the digest form (`execute { plan_id, effects }`):
2729/// the re-plan's digest is not the `plan_id` given — the effects, or the
2730/// documents they target, changed since the plan was made.
2731#[must_use]
2732pub fn plan_mismatch_replanned(expected: &str, actual: &str) -> MutationError {
2733    MutationError::with_data(
2734        ErrorCode::PlanMismatch,
2735        "the effects or the documents they target changed since the plan was made; re-plan",
2736        json!({ "expected": expected, "actual": actual }),
2737    )
2738}
2739
2740/// The intent record an effect-driven commit stores in `commits.ops`
2741/// (ADR-003): `canonical({ v: 1, plan_id, effects, selection?, templates? })`
2742/// — `selection` only when the plan carried one (§11.8), `templates` only
2743/// when a selector-less request wrote `-> ` / `{ oqx }` slots (3.2, §11.8
2744/// "Sugar": the effects as written).
2745#[must_use]
2746pub fn intent_record(
2747    plan_id: &str,
2748    effects: &[Effect],
2749    selection: Option<&Selection>,
2750    templates: Option<&[Value]>,
2751) -> String {
2752    let mut record = json!({
2753        "v": 1,
2754        "plan_id": plan_id,
2755        "effects": effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
2756    });
2757    if let Some(sel) = selection {
2758        record["selection"] = sel.to_json();
2759    }
2760    if let Some(t) = templates {
2761        record["templates"] = Value::Array(t.to_vec());
2762    }
2763    canonical_json(&record)
2764}
2765
2766#[cfg(test)]
2767mod tests {
2768    use super::*;
2769
2770    fn doc(id: &str, path: &str) -> Target {
2771        Target::Document {
2772            id: Some(id.into()),
2773            path: Some(path.into()),
2774        }
2775    }
2776
2777    fn fm_set(key: &str, value: Value) -> Effect {
2778        Effect::Set {
2779            target: doc("d_0", "a.md"),
2780            field: format!("frontmatter.{key}"),
2781            value,
2782            expect: None,
2783        }
2784    }
2785
2786    fn scalar(key: &str, target: FocusTarget, field: &str, value: Option<Value>) -> FocusInfo {
2787        FocusInfo {
2788            scalar: Some(ScalarWrite {
2789                key: key.into(),
2790                target,
2791                field: field.into(),
2792                value,
2793            }),
2794            docs: vec!["d_0".into()],
2795            ..Default::default()
2796        }
2797    }
2798
2799    #[test]
2800    fn symbols_parse() {
2801        assert_eq!(parse_new_symbol("$new.0"), Some(0));
2802        assert_eq!(parse_new_symbol("$new.12"), Some(12));
2803        assert_eq!(parse_new_symbol("$new."), None);
2804        assert_eq!(parse_new_symbol("$new.a"), None);
2805        assert_eq!(parse_new_symbol("b_0"), None);
2806        assert_eq!(new_symbol(3), "$new.3");
2807    }
2808
2809    #[test]
2810    fn effects_validate_and_round_trip_through_json() {
2811        let effects = json!([
2812            { "op": "create", "path": "n.md", "markdown": "# N", "frontmatter": { "k": 1 } },
2813            { "op": "insert", "into": { "kind": "section", "heading_id": "b_1" }, "at": "end", "markdown": "x" },
2814            { "op": "insert", "into": { "kind": "document", "id": "d_0", "path": "a.md" }, "at": { "after": "b_1" }, "markdown": "x", "expect": { "parent_children_hash": "cd", "revision": "r_0" } },
2815            { "op": "set", "target": { "kind": "document", "path": "a.md" }, "field": "frontmatter.status", "value": "done" },
2816            { "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "attrs.checked", "value": true, "expect": { "content_hash": "ab" } },
2817            { "op": "set", "target": { "kind": "node", "id": "n_1" }, "field": "value", "value": 3 },
2818            { "op": "unset", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.old" },
2819            { "op": "move", "target": { "kind": "document", "id": "d_0" }, "to": "b.md", "retarget_inbound": true },
2820            { "op": "move", "targets": [{ "kind": "block", "id": "b_1" }], "into": { "kind": "block", "id": "b_2" }, "at": "start" },
2821            { "op": "remove", "target": { "kind": "document", "id": "d_0" } },
2822            { "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }], "expect": { "b_1": { "content_hash": "ab" } } },
2823            { "op": "split", "target": { "kind": "block", "id": "b_1" }, "at": [3] },
2824            { "op": "merge", "targets": [{ "kind": "block", "id": "b_1" }, { "kind": "block", "id": "b_2" }], "separator": " " },
2825            { "op": "retarget", "repairs": [{ "from": "a.md", "to": "b.md" }], "path_glob": "notes/*" },
2826        ]);
2827        let parsed = Effect::list_from_json(&effects).unwrap();
2828        assert_eq!(parsed.len(), 14);
2829        for (v, e) in effects.as_array().unwrap().iter().zip(&parsed) {
2830            assert_eq!(e.to_json(), *v, "{}", e.name());
2831        }
2832        // Defaults materialize on the way out (`at`, `retarget_inbound`).
2833        let e = Effect::from_json(&json!({ "op": "move", "target": { "kind": "document", "path": "a.md" }, "to": "b.md" })).unwrap();
2834        assert_eq!(e.to_json()["retarget_inbound"], json!(true));
2835        let e = Effect::from_json(
2836            &json!({ "op": "insert", "into": { "kind": "block", "id": "b_1" }, "markdown": "x" }),
2837        )
2838        .unwrap();
2839        assert_eq!(e.to_json()["at"], json!("end"));
2840        // Shape errors name the effect and the member.
2841        let bad = |v: Value| Effect::validate(&v, 2).unwrap_err();
2842        assert!(bad(json!({ "op": "nope" })).starts_with("effects[2]: `op` must be one of"));
2843        assert_eq!(
2844            bad(
2845                json!({ "op": "set", "target": { "kind": "block", "id": "b" }, "field": "markdown" })
2846            ),
2847            "effects[2]: `value` is required"
2848        );
2849        assert!(
2850            bad(json!({ "op": "move", "target": { "kind": "block", "id": "b" }, "to": "x" }))
2851                .contains("`kind` must be one of document")
2852        );
2853        assert!(
2854            bad(json!({ "op": "create", "path": "x.md", "markdown": "", "extra": 1 }))
2855                .contains("unknown key `extra`")
2856        );
2857        assert!(
2858            bad(json!({ "op": "remove", "targets": ["b_1"] }))
2859                .contains("effects[2].targets[0]: a target is")
2860        );
2861        assert!(bad(json!({ "op": "remove", "targets": [] })).contains("non-empty"));
2862        assert!(bad(json!({ "op": "set", "target": { "kind": "document", "path": "a.md" }, "field": "title", "value": 1 })).contains("a document's `field` is `frontmatter.<key>` or `content`"));
2863        assert!(bad(json!({ "op": "unset", "target": { "kind": "document", "path": "a.md" }, "field": "content" })).contains("(got \"content\")"));
2864        assert!(bad(json!({ "op": "set", "target": { "kind": "node", "id": "n" }, "field": "a.b", "value": 1 })).contains("editable properties"));
2865        assert!(bad(json!({ "op": "insert", "into": { "kind": "document", "path": "a.md" }, "at": { "before": "b", "after": "c" }, "markdown": "x" })).contains("`at` must be"));
2866        assert!(Effect::list_from_json(&json!([])).is_err());
2867        assert!(Effect::list_from_json(&json!({})).is_err());
2868    }
2869
2870    #[test]
2871    fn describe_and_classify() {
2872        assert_eq!(
2873            fm_set("status", json!(1)).describe(),
2874            "set frontmatter.status on document d_0"
2875        );
2876        let mv = Effect::MoveBlocks {
2877            targets: vec!["b_1".into(), "b_2".into()],
2878            into: Target::Section {
2879                heading_id: "b_0".into(),
2880            },
2881            at: At::End,
2882            expect: None,
2883        };
2884        assert_eq!(mv.describe(), "move block b_1,block b_2");
2885        let rt = Effect::Retarget {
2886            repairs: vec![Repair {
2887                from: "a".into(),
2888                to: "b".into(),
2889            }],
2890            path_glob: None,
2891        };
2892        assert_eq!(rt.describe(), "retarget a->b");
2893        assert_eq!(
2894            classify_field(&doc("d_0", "a.md"), "frontmatter.status"),
2895            Ok(Field::Frontmatter("status"))
2896        );
2897        assert_eq!(
2898            classify_field(&doc("d_0", "a.md"), "content"),
2899            Ok(Field::Content)
2900        );
2901        assert_eq!(
2902            classify_field(&Target::Block { id: "b".into() }, "attrs.checked"),
2903            Ok(Field::Checked)
2904        );
2905        assert_eq!(
2906            classify_field(&Target::Node { id: "n".into() }, "value"),
2907            Ok(Field::NodeProp("value"))
2908        );
2909        assert!(
2910            classify_field(
2911                &Target::Section {
2912                    heading_id: "h".into()
2913                },
2914                "x"
2915            )
2916            .is_err()
2917        );
2918    }
2919
2920    #[test]
2921    fn placeholders_shift() {
2922        let op = Op::Move {
2923            blocks: vec!["$0.ids[1]".into(), "b_3".into()],
2924            to: To {
2925                parent: Parent::Block("$1.ids[0]".into()),
2926                at: At::After("$2.ids[0]".into()),
2927            },
2928            doc: None,
2929            expect: None,
2930        };
2931        assert_eq!(
2932            shift_placeholders(&op, 5).to_json(),
2933            json!({ "op": "move", "blocks": ["$5.ids[1]", "b_3"], "to": { "parent": "$6.ids[0]", "at": { "after": "$7.ids[0]" } } })
2934        );
2935    }
2936
2937    #[test]
2938    fn the_tracker_dedupes_identical_scalars_and_refuses_the_rest() {
2939        let mut t = ConflictTracker::default();
2940        let write = |v: Option<Value>| {
2941            scalar(
2942                "document:d_0:frontmatter.status",
2943                FocusTarget::document("d_0"),
2944                "frontmatter.status",
2945                v,
2946            )
2947        };
2948        assert!(
2949            t.check(
2950                0,
2951                &write(Some(json!("done"))),
2952                "set frontmatter.status on document d_0"
2953            )
2954            .unwrap()
2955        );
2956        assert!(
2957            !t.check(
2958                1,
2959                &write(Some(json!("done"))),
2960                "set frontmatter.status on document d_0"
2961            )
2962            .unwrap()
2963        );
2964        assert_eq!(
2965            t.diagnostics,
2966            ["effect 1 (set frontmatter.status on document d_0) duplicates effect 0; dropped"]
2967        );
2968        let e = t.check(2, &write(Some(json!("open"))), "x").unwrap_err();
2969        assert_eq!(e.code, ErrorCode::ConflictingEffects);
2970        assert_eq!(
2971            e.to_json(),
2972            json!({ "code": "conflicting_effects", "effects": [0, 2], "focus": { "kind": "scalar", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.status" }, "retriable": false })
2973        );
2974        // set then unset
2975        let e = t.check(3, &write(None), "x").unwrap_err();
2976        assert_eq!(e.data["effects"], json!([0, 3]));
2977        // A retarget's scalars never dedupe.
2978        let mut t = ConflictTracker::default();
2979        let hit = FocusInfo {
2980            scalars: vec![ScalarWrite {
2981                key: "block:b_1:markdown".into(),
2982                target: FocusTarget::block("b_1"),
2983                field: "markdown".into(),
2984                value: None,
2985            }],
2986            blocks: vec!["b_1".into()],
2987            ..Default::default()
2988        };
2989        assert!(t.check(0, &hit, "retarget").unwrap());
2990        let e = t.check(1, &hit, "retarget").unwrap_err();
2991        assert_eq!(
2992            e.data["focus"]["target"],
2993            json!({ "kind": "block", "id": "b_1" })
2994        );
2995    }
2996
2997    #[test]
2998    fn the_tracker_enforces_identity_retirements() {
2999        let mut t = ConflictTracker::default();
3000        let remove = FocusInfo {
3001            docs: vec!["d_0".into()],
3002            blocks: vec!["b_1".into()],
3003            retires_blocks: vec!["b_1".into(), "b_2".into()],
3004            ..Default::default()
3005        };
3006        assert!(t.check(0, &remove, "remove block b_1").unwrap());
3007        let touch_child = FocusInfo {
3008            docs: vec!["d_0".into()],
3009            blocks: vec!["b_2".into()],
3010            ..Default::default()
3011        };
3012        let e = t.check(1, &touch_child, "x").unwrap_err();
3013        assert_eq!(
3014            e.data["focus"],
3015            json!({ "kind": "identity", "target": { "kind": "block", "id": "b_2" } })
3016        );
3017        // A path retired by a move; a create there.
3018        let mut t = ConflictTracker::default();
3019        let mv = FocusInfo {
3020            docs: vec!["d_0".into()],
3021            paths: vec!["moved/a.md".into()],
3022            retires_paths: vec!["a.md".into()],
3023            ..Default::default()
3024        };
3025        assert!(t.check(0, &mv, "move").unwrap());
3026        let create = FocusInfo {
3027            paths: vec!["a.md".into()],
3028            ..Default::default()
3029        };
3030        let e = t.check(1, &create, "create a.md").unwrap_err();
3031        assert_eq!(
3032            e.data["focus"],
3033            json!({ "kind": "identity", "target": { "kind": "path", "path": "a.md" } })
3034        );
3035        // `set content` after any touch of the document.
3036        let mut t = ConflictTracker::default();
3037        let insert = FocusInfo {
3038            docs: vec!["d_0".into()],
3039            blocks: vec!["b_2".into()],
3040            ..Default::default()
3041        };
3042        assert!(t.check(0, &insert, "insert").unwrap());
3043        let content = FocusInfo {
3044            scalar: Some(ScalarWrite {
3045                key: "document:d_0:content".into(),
3046                target: FocusTarget::document("d_0"),
3047                field: "content".into(),
3048                value: Some(json!("x")),
3049            }),
3050            docs: vec!["d_0".into()],
3051            whole_doc: Some("d_0".into()),
3052            ..Default::default()
3053        };
3054        let e = t
3055            .check(1, &content, "set content on document d_0")
3056            .unwrap_err();
3057        assert_eq!(
3058            e.data["focus"],
3059            json!({ "kind": "identity", "target": { "kind": "document", "id": "d_0" } })
3060        );
3061        // A removed document: its id is retired.
3062        let mut t = ConflictTracker::default();
3063        let rm = FocusInfo {
3064            docs: vec!["d_0".into()],
3065            retires_docs: vec!["d_0".into()],
3066            ..Default::default()
3067        };
3068        assert!(t.check(0, &rm, "remove document d_0").unwrap());
3069        let e = t.check(1, &insert, "insert").unwrap_err();
3070        assert_eq!(
3071            e.data["focus"]["target"],
3072            json!({ "kind": "document", "id": "d_0" })
3073        );
3074        let m = symbol_missing("$new.5", None, "block");
3075        assert_eq!(
3076            m.to_json(),
3077            json!({ "code": "target_missing", "symbol": "$new.5" })
3078        );
3079        let m = symbol_missing("$new.0", Some(NewIdKind::Document), "block");
3080        assert!(m.message.contains("names a document, not a block"));
3081    }
3082
3083    #[test]
3084    fn canonical_json_sorts_keys_and_prints_numbers_like_javascript() {
3085        let v = json!({ "b": [1, 2.0, 2.5, -0.0, 1e21, 1e-7, 123456789012345680000.0, 0.000001, 1.5e300],
3086                        "a": { "z": null, "y": "q\"\n\u{1}é" }, "c": true });
3087        assert_eq!(
3088            canonical_json(&v),
3089            "{\"a\":{\"y\":\"q\\\"\\n\\u0001é\",\"z\":null},\"b\":[1,2,2.5,0,1e+21,1e-7,123456789012345680000,0.000001,1.5e+300],\"c\":true}"
3090        );
3091        assert_eq!(js_number(9007199254740993.0), "9007199254740992");
3092        assert_eq!(js_number(0.1), "0.1");
3093        assert_eq!(js_number(0.30000000000000004), "0.30000000000000004");
3094        assert_eq!(js_number(-1.5), "-1.5");
3095        assert_eq!(js_number(1e-6), "0.000001");
3096        assert_eq!(js_number(1.2e-7), "1.2e-7");
3097        assert_eq!(js_number(f64::NAN), "null");
3098        assert_eq!(js_number(100.0), "100");
3099        assert_eq!(js_number(1e20), "100000000000000000000");
3100        assert_eq!(js_number(1.7976931348623157e308), "1.7976931348623157e+308");
3101        assert_eq!(js_number(5e-324), "5e-324");
3102        // §11.5: the empty core digests to the pinned value (`p1`, the 2.3
3103        // vector) — and under the current planner version to its own.
3104        let empty = json!({ "version": 1, "planner_v": "p1", "effects": [], "opset": { "creates": [], "moves": [], "tombstones": [], "set_frontmatter": [], "ops": [] }, "preconditions": { "docs": [], "blocks": [], "parents": [], "absent_paths": [] }, "new_ids": [] });
3105        assert_eq!(
3106            digest(&empty),
3107            "a20097a9c319b59a1313c83e1fd494bbb3cf4895040fbbddd2866e56b16cddd3"
3108        );
3109        let mut current = empty.clone();
3110        current["planner_v"] = json!(PLANNER_V);
3111        assert_eq!(
3112            digest(&current),
3113            "5051e40051314fa855721d1943b42a9acb867c79a7b49eb4db0fd811559289ff"
3114        );
3115        // `p2` (the 3.0 vector) is still pinned: a plan echoed from a 3.0
3116        // planner is `plan_mismatch`, not a shape error.
3117        current["planner_v"] = json!("p2");
3118        assert_eq!(
3119            digest(&current),
3120            "6f43b302fdcd58ca4fd182b21a5bdab6fee3787ddc074f5e7c5c23b2cc368bf9"
3121        );
3122        // The spec's number vector (`effects::digest-vectors`), byte for byte.
3123        let numbers: Value = serde_json::from_str(
3124            "[1, 1.5, 0.30000000000000004, 1e+21, 1e-07, 9007199254740992, 1.7976931348623157e+308, -2.5, 100, 123456789012345680000]",
3125        )
3126        .unwrap();
3127        assert_eq!(
3128            canonical_json(&numbers),
3129            "[1,1.5,0.30000000000000004,1e+21,1e-7,9007199254740992,1.7976931348623157e+308,-2.5,100,123456789012345680000]"
3130        );
3131        // `z` before `é`; `/` and non-ASCII literal; lower-case `\u001f`.
3132        assert_eq!(
3133            canonical_json(&json!({ "é": "e", "z": 0 })),
3134            "{\"z\":0,\"é\":\"e\"}"
3135        );
3136        assert_eq!(canonical_json(&json!("a/b\u{1f}é😀")), "\"a/b\\u001fé😀\"");
3137    }
3138
3139    /// 3.2 (§11.8 "Sugar"): `templates` — the effects as written of a
3140    /// selector-less request that evaluated an expression — rides outside the
3141    /// digest: on the wire and in the intent record, never in the core, so a
3142    /// plan with it has the literal plan's `plan_id`; admission keeps it and
3143    /// refuses a mistyped one; `null` is an omission.
3144    #[test]
3145    fn templates_ride_outside_the_digest() {
3146        let bare = Plan {
3147            version: PLAN_VERSION,
3148            planner_v: PLANNER_V.into(),
3149            effects: vec![fm_set("status", json!("done"))],
3150            opset: PlanOpset::default(),
3151            preconditions: Preconditions::default(),
3152            new_ids: vec![],
3153            selection: None,
3154            templates: None,
3155            plan_id: String::new(),
3156            diagnostics: vec![],
3157            preview: None,
3158        }
3159        .sealed();
3160        let written = vec![
3161            json!({ "op": "set", "target": "a.md", "field": "frontmatter.status", "value": "-> `done`" }),
3162        ];
3163        let sugared = Plan {
3164            templates: Some(written.clone()),
3165            ..bare.clone()
3166        }
3167        .sealed();
3168        assert_eq!(sugared.plan_id, bare.plan_id);
3169        assert!(sugared.core_json().get("templates").is_none());
3170        assert!(bare.to_json(false).get("templates").is_none());
3171        let wire = sugared.to_json(false);
3172        assert_eq!(wire["templates"], Value::Array(written.clone()));
3173        assert_eq!(Plan::admit(&wire).unwrap(), sugared);
3174        let mut nulled = wire.clone();
3175        nulled["templates"] = Value::Null;
3176        assert_eq!(Plan::admit(&nulled).unwrap(), bare);
3177        let mut mistyped = wire;
3178        mistyped["templates"] = json!("x");
3179        assert!(matches!(
3180            Plan::admit(&mistyped),
3181            Err(Admission::Invalid(m)) if m.contains("`plan.templates` must be an array")
3182        ));
3183        let plain = intent_record(&bare.plan_id, &bare.effects, None, None);
3184        assert!(!plain.contains("templates"), "{plain}");
3185        let record = intent_record(
3186            &sugared.plan_id,
3187            &sugared.effects,
3188            None,
3189            sugared.templates.as_deref(),
3190        );
3191        assert!(
3192            record.ends_with(
3193                "\"templates\":[{\"field\":\"frontmatter.status\",\"op\":\"set\",\"target\":\"a.md\",\"value\":\"-> `done`\"}],\"v\":1}"
3194            ),
3195            "{record}"
3196        );
3197    }
3198
3199    #[test]
3200    fn a_plan_digests_its_core_and_round_trips() {
3201        let plan = Plan {
3202            version: PLAN_VERSION,
3203            planner_v: PLANNER_V.into(),
3204            effects: vec![fm_set("status", json!("done"))],
3205            templates: None,
3206            opset: PlanOpset {
3207                set_frontmatter: vec![PlanFrontmatter {
3208                    doc: "d_0".into(),
3209                    raw: "---\nstatus: done\n---\n\n".into(),
3210                }],
3211                ..Default::default()
3212            },
3213            preconditions: Preconditions {
3214                docs: vec![DocPrecondition {
3215                    id: "d_0".into(),
3216                    path: "a.md".into(),
3217                    base_revision: Some("r_0".into()),
3218                    hash: None,
3219                }],
3220                ..Default::default()
3221            },
3222            new_ids: vec![NewId {
3223                symbol: "$new.0".into(),
3224                kind: NewIdKind::Block,
3225            }],
3226            selection: None,
3227            plan_id: String::new(),
3228            diagnostics: vec![],
3229            preview: Some(PlanPreview::default()),
3230        }
3231        .sealed();
3232        assert_eq!(plan.plan_id.len(), 64);
3233        assert_eq!(plan.plan_id, digest(&plan.core_json()));
3234        let wire = plan.to_json(true);
3235        assert!(wire.get("preview").is_some());
3236        assert_eq!(
3237            wire["preconditions"]["docs"][0]["hash"],
3238            Value::Null,
3239            "a null value stays"
3240        );
3241        assert_eq!(wire["opset"]["creates"], json!([]));
3242        let back = Plan::from_json(&wire).unwrap();
3243        assert_eq!(back, plan);
3244        let trimmed = plan.to_json(false);
3245        assert!(trimmed.get("preview").is_none());
3246        assert_eq!(Plan::admit(&trimmed).unwrap().digest(), plan.plan_id);
3247        // Admission: version first, then shape, then the digest.
3248        let mut v2 = trimmed.clone();
3249        v2["version"] = json!(2);
3250        v2["opset"] = json!({});
3251        match Plan::admit(&v2) {
3252            Err(Admission::Refused(m)) => {
3253                assert_eq!(m.code, ErrorCode::UnsupportedPlanVersion);
3254                assert_eq!(m.data["supported"], json!([1]));
3255                assert_eq!(m.data["version"], json!(2));
3256            }
3257            other => panic!("{other:?}"),
3258        }
3259        let mut bare = trimmed.clone();
3260        bare["opset"] = json!({});
3261        assert!(
3262            matches!(Plan::admit(&bare), Err(Admission::Invalid(m)) if m.contains("opset.creates"))
3263        );
3264        let mut tampered = trimmed.clone();
3265        tampered["effects"] = json!([]);
3266        match Plan::admit(&tampered) {
3267            Err(Admission::Refused(m)) => {
3268                assert_eq!(m.code, ErrorCode::PlanMismatch);
3269                assert_eq!(m.data["expected"], json!(plan.plan_id));
3270                assert_ne!(m.data["actual"], json!(plan.plan_id));
3271            }
3272            other => panic!("{other:?}"),
3273        }
3274        let record = intent_record(&plan.plan_id, &plan.effects, None, None);
3275        assert!(
3276            record.starts_with("{\"effects\":[{\"field\":\"frontmatter.status\""),
3277            "{record}"
3278        );
3279        assert!(record.ends_with("\"v\":1}"));
3280        assert!(!record.contains("selection"));
3281    }
3282
3283    /// §11.8: `selection` is the seventh core member — digested when present,
3284    /// omitted (never `null`) when absent; `expect` likewise inside it; the
3285    /// intent record carries it; admission reads it back and refuses a
3286    /// malformed one as a shape error.
3287    #[test]
3288    fn a_selection_is_digested_and_round_trips() {
3289        let selection = Selection {
3290            query: "from docs where status == \"completed\"".into(),
3291            expect: Some(SelectionExpect { max: 10 }),
3292            templates: vec![
3293                json!({ "op": "set", "field": "frontmatter.rolled_up", "value": true }),
3294            ],
3295            cardinality: 2,
3296            identities: vec!["d_1".into(), "d_0".into()],
3297        };
3298        assert_eq!(
3299            canonical_json(&selection.to_json()),
3300            "{\"cardinality\":2,\"expect\":{\"max\":10},\"identities\":[\"d_1\",\"d_0\"],\"query\":\"from docs where status == \\\"completed\\\"\",\"templates\":[{\"field\":\"frontmatter.rolled_up\",\"op\":\"set\",\"value\":true}]}"
3301        );
3302        let without = Selection {
3303            expect: None,
3304            ..selection.clone()
3305        };
3306        assert!(!canonical_json(&without.to_json()).contains("expect"));
3307        assert_eq!(
3308            Selection::from_json(&selection.to_json()),
3309            Ok(selection.clone())
3310        );
3311        assert_eq!(Selection::from_json(&without.to_json()), Ok(without));
3312        let bare = Plan {
3313            version: PLAN_VERSION,
3314            planner_v: PLANNER_V.into(),
3315            effects: vec![],
3316            opset: PlanOpset::default(),
3317            preconditions: Preconditions::default(),
3318            new_ids: vec![],
3319            selection: None,
3320            templates: None,
3321            plan_id: String::new(),
3322            diagnostics: vec![],
3323            preview: None,
3324        }
3325        .sealed();
3326        let selected = Plan {
3327            selection: Some(selection.clone()),
3328            ..bare.clone()
3329        }
3330        .sealed();
3331        assert_ne!(bare.plan_id, selected.plan_id);
3332        assert!(bare.core_json().get("selection").is_none());
3333        assert_eq!(selected.core_json()["selection"], selection.to_json());
3334        // The spec's vector (`effects::select-zero-hits-commit-null`): the empty
3335        // p3 core with a zero-hit selection, `expect` absent.
3336        let zero = Plan {
3337            selection: Some(Selection {
3338                query: "from docs where type == \"nope\"".into(),
3339                expect: None,
3340                templates: vec![json!({ "op": "set", "field": "frontmatter.x", "value": 1 })],
3341                cardinality: 0,
3342                identities: vec![],
3343            }),
3344            ..bare.clone()
3345        }
3346        .sealed();
3347        assert_eq!(
3348            zero.plan_id,
3349            "73ca03407ddf05fb522c4118bd5ef1e88887f8430acbaba7428378a0b1e342c9"
3350        );
3351        // The record and the wire form carry it; admission reads it back.
3352        let record = intent_record(
3353            &selected.plan_id,
3354            &selected.effects,
3355            selected.selection.as_ref(),
3356            None,
3357        );
3358        assert!(
3359            record.contains("\"selection\":{\"cardinality\":2"),
3360            "{record}"
3361        );
3362        let wire = selected.to_json(false);
3363        assert_eq!(Plan::admit(&wire).unwrap(), selected);
3364        // Tampering with the identities is a digest mismatch; a malformed
3365        // member is a shape error; `null` is not an omission.
3366        let mut tampered = wire.clone();
3367        tampered["selection"]["identities"] = json!(["d_1", "d_2"]);
3368        assert!(matches!(
3369            Plan::admit(&tampered),
3370            Err(Admission::Refused(m)) if m.code == ErrorCode::PlanMismatch
3371        ));
3372        let mut short = wire.clone();
3373        short["selection"]["identities"] = json!(["d_1"]);
3374        assert!(matches!(
3375            Plan::admit(&short),
3376            Err(Admission::Invalid(m)) if m.contains("cardinality")
3377        ));
3378        let mut nulled = wire.clone();
3379        nulled["selection"] = Value::Null;
3380        assert!(matches!(Plan::admit(&nulled), Err(Admission::Invalid(_))));
3381        let mut empty = wire;
3382        empty["selection"]["templates"] = json!([]);
3383        assert!(matches!(
3384            Plan::admit(&empty),
3385            Err(Admission::Invalid(m)) if m.contains("templates")
3386        ));
3387    }
3388
3389    // ---- the shape fixes (§8, 3.4) ----------------------------------------------------------
3390
3391    fn ctx<'a>(
3392        effects: &'a Value,
3393        select: Option<&'a Value>,
3394        expect: Option<&'a Value>,
3395        parent_of: &'a dyn Fn(&str) -> Option<ParentRow>,
3396    ) -> ShapeFixContext<'a> {
3397        ShapeFixContext {
3398            select,
3399            effects,
3400            expect,
3401            parent_of,
3402        }
3403    }
3404
3405    fn rows(id: &str) -> Option<ParentRow> {
3406        match id {
3407            "b_4" => Some(ParentRow {
3408                parent: Some("b_3".to_owned()),
3409                path: "a.md".to_owned(),
3410            }),
3411            "b_1" => Some(ParentRow {
3412                parent: None,
3413                path: "a.md".to_owned(),
3414            }),
3415            _ => None,
3416        }
3417    }
3418
3419    #[test]
3420    fn a_bare_anchor_is_fixed_from_the_blocks_parent() {
3421        let effects = json!([{ "op": "insert", "into": { "kind": "block", "id": "b_4" }, "at": "after", "markdown": "x" }]);
3422        let c = ctx(&effects, None, None, &rows);
3423        let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3424        assert_eq!(
3425            e.message,
3426            "effects[0]: `at` must be \"start\", \"end\", { before: <block id> } or { after: <block id> } — \"after block X\" is into: X's parent (a block, or the document when X is top-level), at: { after: X }"
3427        );
3428        assert_eq!(
3429            e.fix.as_deref(),
3430            Some(
3431                r#"{"effects":[{"at":{"after":"b_4"},"into":{"id":"b_3","kind":"block"},"markdown":"x","op":"insert"}]}"#
3432            )
3433        );
3434        // top-level: the document's rooted path; the written effect is the base
3435        let effects = json!([{ "op": "move", "targets": [{ "kind": "block", "id": "b_6" }], "into": { "kind": "block", "id": "b_1" }, "at": "before" }]);
3436        let c = ctx(&effects, None, None, &rows);
3437        let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3438        assert!(e.message.contains("\"before block X\""));
3439        assert_eq!(
3440            e.fix.as_deref(),
3441            Some(
3442                r#"{"effects":[{"at":{"before":"b_1"},"into":"/a.md","op":"move","targets":[{"id":"b_6","kind":"block"}]}]}"#
3443            )
3444        );
3445        // an unknown block, a document `into`, or no context: the message alone
3446        let effects = json!([{ "op": "insert", "into": { "kind": "block", "id": "b_99" }, "at": "after", "markdown": "x" }]);
3447        let c = ctx(&effects, None, None, &rows);
3448        assert_eq!(
3449            Effect::validate_with(&effects[0], 0, Some(&c))
3450                .unwrap_err()
3451                .fix,
3452            None
3453        );
3454        let effects = json!([{ "op": "insert", "into": { "kind": "document", "path": "a.md" }, "at": "after", "markdown": "x" }]);
3455        let c = ctx(&effects, None, None, &rows);
3456        assert_eq!(
3457            Effect::validate_with(&effects[0], 0, Some(&c))
3458                .unwrap_err()
3459                .fix,
3460            None
3461        );
3462        assert_eq!(
3463            Effect::validate_with(&effects[0], 0, None).unwrap_err().fix,
3464            None
3465        );
3466    }
3467
3468    #[test]
3469    fn a_guard_first_plural_expect_is_inverted() {
3470        let effects = json!([{ "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }, { "kind": "block", "id": "b_8" }],
3471            "expect": { "content_hash": { "b_1": "h1", "b_8": "h8" }, "parent_children_hash": { "b_1": "p1" } } }]);
3472        let c = ctx(&effects, None, None, &rows);
3473        let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3474        assert_eq!(
3475            e.message,
3476            "effects[0]: a plural-target `expect` is keyed by block id — { <block id>: { content_hash?, parent_children_hash? }, … }"
3477        );
3478        assert_eq!(
3479            e.fix.as_deref(),
3480            Some(
3481                r#"{"effects":[{"expect":{"b_1":{"content_hash":"h1","parent_children_hash":"p1"},"b_8":{"content_hash":"h8"}},"op":"remove","targets":[{"id":"b_1","kind":"block"},{"id":"b_8","kind":"block"}]}]}"#
3482            )
3483        );
3484        // a value that is not a map of strings is the plain per-id message
3485        let effects = json!([{ "op": "merge", "targets": [{ "kind": "block", "id": "b_1" }], "expect": { "content_hash": "h1" } }]);
3486        let c = ctx(&effects, None, None, &rows);
3487        let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3488        assert_eq!(
3489            e.message,
3490            "effects[0].expect[\"content_hash\"]: must be an object"
3491        );
3492        assert_eq!(e.fix, None);
3493    }
3494
3495    #[test]
3496    fn an_effect_level_max_moves_beside_select_or_is_dropped() {
3497        let select = json!("from blocks where type == \"paragraph\"");
3498        let effects =
3499            json!([{ "op": "set", "field": "markdown", "value": "x", "expect": { "max": 5 } }]);
3500        // the probe the compile validates (a target in the slot)
3501        let probe = json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "markdown", "value": "x", "expect": { "max": 5 } });
3502        let c = ctx(&effects, Some(&select), None, &rows);
3503        let e = Effect::validate_with(&probe, 0, Some(&c)).unwrap_err();
3504        assert_eq!(
3505            e.message,
3506            "effects[0]: `expect.max` bounds a selection — it is the request's top-level `expect`, beside `select`, not an effect's"
3507        );
3508        assert_eq!(
3509            e.fix.as_deref(),
3510            Some(
3511                r#"{"effects":[{"field":"markdown","op":"set","value":"x"}],"expect":{"max":5},"select":"from blocks where type == \"paragraph\""}"#
3512            )
3513        );
3514        // a top-level `max` already there is kept; the other guards stay
3515        let effects = json!([{ "op": "set", "field": "markdown", "value": "x", "expect": { "max": 9, "revision": "r_0" } }]);
3516        let probe = json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "markdown", "value": "x", "expect": { "max": 9, "revision": "r_0" } });
3517        let top = json!({ "max": 2 });
3518        let c = ctx(&effects, Some(&select), Some(&top), &rows);
3519        let e = Effect::validate_with(&probe, 0, Some(&c)).unwrap_err();
3520        assert_eq!(
3521            e.fix.as_deref(),
3522            Some(
3523                r#"{"effects":[{"expect":{"revision":"r_0"},"field":"markdown","op":"set","value":"x"}],"expect":{"max":2},"select":"from blocks where type == \"paragraph\""}"#
3524            )
3525        );
3526        // without a `select`: only the drop (a plural `expect` too)
3527        let effects = json!([{ "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }], "expect": { "max": 2 } }]);
3528        let c = ctx(&effects, None, None, &rows);
3529        let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3530        assert_eq!(
3531            e.fix.as_deref(),
3532            Some(r#"{"effects":[{"op":"remove","targets":[{"id":"b_1","kind":"block"}]}]}"#)
3533        );
3534    }
3535
3536    #[test]
3537    fn a_block_field_alias_is_fixed_to_markdown() {
3538        for alias in ["text", "content", "body", "raw"] {
3539            let effects = json!([{ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": alias, "value": "x" }]);
3540            let c = ctx(&effects, None, None, &rows);
3541            let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3542            assert_eq!(
3543                e.message,
3544                format!(
3545                    "effects[0]: a block's `field` is `markdown` or `attrs.checked` (got \"{alias}\")"
3546                )
3547            );
3548            assert_eq!(
3549                e.fix.as_deref(),
3550                Some(
3551                    r#"{"effects":[{"field":"markdown","op":"set","target":{"id":"b_1","kind":"block"},"value":"x"}]}"#
3552                )
3553            );
3554        }
3555        let effects = json!([{ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "title", "value": "x" }]);
3556        let c = ctx(&effects, None, None, &rows);
3557        assert_eq!(
3558            Effect::validate_with(&effects[0], 0, Some(&c))
3559                .unwrap_err()
3560                .fix,
3561            None
3562        );
3563        // the corrected effect is spelled from the request as written, not the probe
3564        let effects = json!([{ "op": "set", "target": "b_1", "field": "text", "value": "x" }]);
3565        let probe = json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "text", "value": "x" });
3566        let c = ctx(&effects, None, None, &rows);
3567        assert_eq!(
3568            Effect::validate_with(&probe, 0, Some(&c))
3569                .unwrap_err()
3570                .fix
3571                .as_deref(),
3572            Some(r#"{"effects":[{"field":"markdown","op":"set","target":"b_1","value":"x"}]}"#)
3573        );
3574    }
3575}